Add drawing tools, Mermaid support, and MCP server bridge
- Convert flat project to Cargo workspace (crates/agcanvas, crates/agcanvas-mcp) - Add drawing layer: rectangles, ellipses, lines, arrows, text with select/move/resize - Add Mermaid diagram rendering via mermaid-rs-renderer - Add agcanvas-mcp: MCP server bridge for Claude Code, OpenCode, and Codex - Add toolbar UI with keyboard shortcuts (V/R/E/L/A/T) and shape interaction - Add example MCP configs for Claude Code, OpenCode, and Codex - Update README with full feature docs, MCP setup, and updated architecture
This commit is contained in:
46
Cargo.toml
46
Cargo.toml
@@ -1,48 +1,12 @@
|
||||
[package]
|
||||
name = "agcanvas"
|
||||
[workspace]
|
||||
members = ["crates/agcanvas", "crates/agcanvas-mcp"]
|
||||
resolver = "2"
|
||||
|
||||
[workspace.package]
|
||||
version = "0.1.0"
|
||||
edition = "2021"
|
||||
description = "Interactive canvas for agent-human collaboration with SVG support"
|
||||
license = "MIT"
|
||||
|
||||
[dependencies]
|
||||
# GUI
|
||||
eframe = { version = "0.29", default-features = false, features = [
|
||||
"default_fonts",
|
||||
"glow",
|
||||
"persistence",
|
||||
] }
|
||||
egui = "0.29"
|
||||
egui_extras = { version = "0.29", features = ["image"] }
|
||||
|
||||
# SVG parsing and rendering
|
||||
usvg = "0.44"
|
||||
resvg = "0.44"
|
||||
tiny-skia = "0.11"
|
||||
|
||||
# Clipboard
|
||||
arboard = "3.4"
|
||||
|
||||
# Serialization (for agent protocol)
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Agent communication
|
||||
tokio = { version = "1.0", features = ["rt-multi-thread", "sync", "macros"] }
|
||||
tokio-tungstenite = "0.24"
|
||||
futures-util = "0.3"
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Error handling
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
|
||||
# Image handling for texture conversion
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
|
||||
[profile.release]
|
||||
opt-level = 3
|
||||
lto = true
|
||||
|
||||
285
README.md
285
README.md
@@ -1,38 +1,49 @@
|
||||
# agcanvas
|
||||
|
||||
A system-level interactive canvas for agent-human collaboration. Paste SVGs from Figma, get structured understanding, iterate with AI agents.
|
||||
A system-level interactive canvas for agent-human collaboration. Draw shapes, paste SVGs from Figma, render Mermaid diagrams, and let AI agents understand your designs via MCP or WebSocket.
|
||||
|
||||
## What is this?
|
||||
|
||||
agcanvas bridges the gap between visual design and code generation. It's not a design tool—it's a **feedback tool** for rapid iteration between humans and AI agents.
|
||||
agcanvas bridges the gap between visual design and code generation. It's a **collaboration surface** for humans and AI agents — draw your ideas, paste designs, and let agents see exactly what's on the canvas.
|
||||
|
||||
```
|
||||
┌─────────────────────────────────────────────────────────┐
|
||||
│ Figma agcanvas │
|
||||
│ ┌─────┐ Copy SVG ┌──────────────────────────────┐ │
|
||||
│ │ │ ───────────► │ Canvas (pan/zoom) │ │
|
||||
│ │Frame│ │ ┌────────┐ ┌────────┐ │ │
|
||||
│ │ │ │ │ Parsed │ │ Agent │ │ │
|
||||
│ └─────┘ │ │ Tree │ │ Server │ │ │
|
||||
│ │ └────────┘ └───┬────┘ │ │
|
||||
│ └──────────────────┼──────────┘ │
|
||||
┌──────────────────────────────────────────────────────────────┐
|
||||
│ Figma / Draw / Mermaid agcanvas │
|
||||
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌──────────────────────────┐ │
|
||||
│ │ SVG │ │Shapes│ │Merm.│ │ Canvas (pan/zoom) │ │
|
||||
│ │Paste│ │ Draw │ │ Diag│ │ ┌────────┐ ┌────────┐ │ │
|
||||
│ └──┬──┘ └──┬───┘ └──┬──┘ │ │ Parsed │ │ Agent │ │ │
|
||||
│ └────────┴─────────┘ │ │ Tree │ │ Server │ │ │
|
||||
│ │ │ └────────┘ └───┬────┘ │ │
|
||||
│ ▼ └──────────────────┼──────┘ │
|
||||
│ Visual Canvas │ │
|
||||
│ │ │
|
||||
│ AI Agent ◄───── WebSocket (JSON) ────────┘ │
|
||||
│ - Sees structure │
|
||||
│ - Describes semantically │
|
||||
│ AI Agent ◄──── MCP (stdio) ◄── agcanvas-mcp ──┘ │
|
||||
│ AI Agent ◄──── WebSocket (JSON) ───────────────┘ │
|
||||
│ - Sees structure (element tree) │
|
||||
│ - Reads drawing shapes │
|
||||
│ - Generates code │
|
||||
└─────────────────────────────────────────────────────────┘
|
||||
└──────────────────────────────────────────────────────────────┘
|
||||
```
|
||||
|
||||
## Features
|
||||
|
||||
### Canvas & Drawing
|
||||
- **SVG Paste** — Copy frames from Figma, paste directly (Cmd+V)
|
||||
- **Structure Parsing** — SVG → typed element tree (groups, rects, circles, paths, text, images)
|
||||
- **Semantic Description** — Auto-generates human-readable structure description
|
||||
- **Agent Protocol** — WebSocket server for AI agents to query and understand the canvas
|
||||
- **Code Generation** — Stubs for React, HTML, Tailwind, Svelte, Vue
|
||||
- **Shape Drawing** — Rectangles, ellipses, lines, arrows, text directly on canvas
|
||||
- **Selection & Editing** — Select, move, resize shapes with corner handles
|
||||
- **Mermaid Diagrams** — Write Mermaid syntax, render as SVG on canvas
|
||||
- **Sessions/Tabs** — Multiple canvases in tabs, each with independent state
|
||||
- **Pan/Zoom** — Smooth canvas navigation
|
||||
|
||||
### AI Agent Integration
|
||||
- **MCP Server** — `agcanvas-mcp` bridge for Claude Code, OpenCode, and Codex
|
||||
- **WebSocket Protocol** — Direct JSON API on `ws://127.0.0.1:9876`
|
||||
- **Structure Parsing** — SVG → typed element tree (groups, rects, circles, paths, text, images)
|
||||
- **Semantic Description** — Human-readable canvas description for LLMs
|
||||
- **Bidirectional Events** — Push notifications to agents when canvas state changes
|
||||
- **Code Generation** — Stubs for React, HTML, Tailwind, Svelte, Vue
|
||||
|
||||
## Installation
|
||||
|
||||
### From source
|
||||
@@ -41,9 +52,12 @@ agcanvas bridges the gap between visual design and code generation. It's not a d
|
||||
git clone https://github.com/yourusername/agcanvas.git
|
||||
cd agcanvas
|
||||
cargo build --release
|
||||
./target/release/agcanvas
|
||||
```
|
||||
|
||||
This builds two binaries:
|
||||
- `target/release/agcanvas` — The desktop app
|
||||
- `target/release/agcanvas-mcp` — The MCP server bridge
|
||||
|
||||
### Requirements
|
||||
|
||||
- Rust 1.70+
|
||||
@@ -55,35 +69,93 @@ cargo build --release
|
||||
|
||||
1. **Open agcanvas**
|
||||
```bash
|
||||
cargo run --release
|
||||
cargo run --release -p agcanvas
|
||||
```
|
||||
|
||||
2. **Copy SVG from Figma**
|
||||
- Select a frame in Figma
|
||||
- Right-click → Copy as SVG (or Cmd+C)
|
||||
2. **Draw shapes** — Select a tool from the toolbar (or press a shortcut key) and drag on the canvas
|
||||
|
||||
3. **Paste into agcanvas**
|
||||
- Cmd+V (or File → Paste SVG)
|
||||
3. **Paste SVG from Figma** — Copy a frame in Figma, then Cmd+V
|
||||
|
||||
4. **Navigate**
|
||||
- **Pan**: Middle-click drag, or Cmd+drag
|
||||
- **Zoom**: Scroll wheel
|
||||
- **Reset**: Cmd+0
|
||||
4. **Render Mermaid** — Click the Mermaid button in the toolbar, write your diagram, click Render
|
||||
|
||||
5. **Inspect**
|
||||
- View → Element Tree (hierarchical structure)
|
||||
- View → Description (semantic text)
|
||||
5. **Navigate** — Pan (middle-click drag or Cmd+drag), Zoom (scroll wheel), Reset (Cmd+0)
|
||||
|
||||
### Keyboard shortcuts
|
||||
|
||||
| Action | Shortcut |
|
||||
|--------|----------|
|
||||
| Select tool | V |
|
||||
| Rectangle tool | R |
|
||||
| Ellipse tool | E |
|
||||
| Line tool | L |
|
||||
| Arrow tool | A |
|
||||
| Text tool | T |
|
||||
| Delete selected | Delete / Backspace |
|
||||
| Cancel / back to Select | Escape |
|
||||
| Paste SVG | Cmd+V |
|
||||
| New Tab | Cmd+T |
|
||||
| Close Tab | Cmd+W |
|
||||
| Reset zoom | Cmd+0 |
|
||||
|
||||
## Agent Protocol
|
||||
## MCP Server (AI Agent Integration)
|
||||
|
||||
agcanvas exposes a WebSocket server on `ws://127.0.0.1:9876` for AI agents to interact with the canvas.
|
||||
`agcanvas-mcp` is a standalone MCP server that bridges AI coding tools to the agcanvas desktop app. It communicates with agcanvas over WebSocket and exposes canvas data as MCP tools.
|
||||
|
||||
### Setup for Claude Code
|
||||
|
||||
Add to your project's `.mcp.json` (or `~/.claude/mcp.json` for global):
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"agcanvas": {
|
||||
"command": "agcanvas-mcp",
|
||||
"args": ["--port", "9876"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Setup for OpenCode
|
||||
|
||||
Add to your `opencode.json`:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"agcanvas": {
|
||||
"command": "agcanvas-mcp",
|
||||
"args": ["--port", "9876"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
### Setup for Codex
|
||||
|
||||
Same MCP config format — add the `agcanvas` entry to your Codex MCP configuration.
|
||||
|
||||
> **Note:** Make sure `agcanvas-mcp` is in your PATH, or use the full path to the binary (e.g., `/path/to/target/release/agcanvas-mcp`). agcanvas must be running for the MCP tools to work.
|
||||
|
||||
See [`examples/mcp-configs/`](examples/mcp-configs/) for ready-to-copy configuration files.
|
||||
|
||||
### MCP Tools
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_sessions` | List all open tabs/sessions in agcanvas |
|
||||
| `get_element_tree` | Get the full parsed SVG element tree (structured JSON) |
|
||||
| `describe_canvas` | Get a human-readable description of the canvas |
|
||||
| `get_element_by_id` | Look up a specific element by ID |
|
||||
| `get_elements_at_point` | Find elements at an (x, y) coordinate |
|
||||
| `get_drawing_elements` | Get all user-drawn shapes (rects, ellipses, lines, arrows, text) |
|
||||
| `generate_code` | Generate code stubs (html, react, tailwind, svelte, vue) |
|
||||
|
||||
All tools accept an optional `session_id` parameter. If omitted, the active session is used.
|
||||
|
||||
## WebSocket Protocol
|
||||
|
||||
agcanvas also exposes a direct WebSocket server on `ws://127.0.0.1:9876` for custom integrations.
|
||||
|
||||
### Connecting
|
||||
|
||||
@@ -96,45 +168,56 @@ ws = websocket.create_connection("ws://127.0.0.1:9876")
|
||||
|
||||
### Requests
|
||||
|
||||
#### Get full element tree
|
||||
All requests support an optional `session_id` parameter. If omitted, the active session is used.
|
||||
|
||||
#### List sessions
|
||||
|
||||
```json
|
||||
{"type": "GetTree"}
|
||||
{"type": "ListSessions"}
|
||||
```
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"type": "Tree",
|
||||
"tree": {
|
||||
"root": {
|
||||
"id": "frame-1",
|
||||
"kind": {"type": "Group", "name": "Login Form"},
|
||||
"bounds": {"x": 0, "y": 0, "width": 400, "height": 600},
|
||||
"children": [...]
|
||||
},
|
||||
"metadata": {
|
||||
"source": "svg_paste",
|
||||
"width": 400,
|
||||
"height": 600,
|
||||
"element_count": 15
|
||||
}
|
||||
}
|
||||
"type": "Sessions",
|
||||
"sessions": [
|
||||
{"id": "session-1", "name": "Tab 1", "has_svg": true, "element_count": 15},
|
||||
{"id": "session-2", "name": "Tab 2", "has_svg": false, "element_count": null}
|
||||
],
|
||||
"active_session": "session-1"
|
||||
}
|
||||
```
|
||||
|
||||
#### Get full element tree
|
||||
|
||||
```json
|
||||
{"type": "GetTree"}
|
||||
{"type": "GetTree", "session_id": "session-2"}
|
||||
```
|
||||
|
||||
#### Get semantic description
|
||||
|
||||
```json
|
||||
{"type": "Describe"}
|
||||
```
|
||||
|
||||
Response:
|
||||
#### Get drawing elements (user-drawn shapes)
|
||||
|
||||
```json
|
||||
{
|
||||
"type": "Description",
|
||||
"text": "- Group 'Login Form'\n - Rectangle (400x600) fill=#ffffff\n - Text 'Welcome Back' (24px)\n - Rectangle (320x48) fill=#f0f0f0\n - Text 'Email' (14px)\n ..."
|
||||
}
|
||||
{"type": "GetDrawingElements"}
|
||||
{"type": "GetDrawingElements", "session_id": "session-1"}
|
||||
```
|
||||
|
||||
#### Get element by ID
|
||||
|
||||
```json
|
||||
{"type": "GetElementById", "id": "button-primary"}
|
||||
```
|
||||
|
||||
#### Query elements at point
|
||||
|
||||
```json
|
||||
{"type": "GetElementsAtPoint", "x": 150.0, "y": 200.0}
|
||||
```
|
||||
|
||||
#### Generate code
|
||||
@@ -145,33 +228,25 @@ Response:
|
||||
|
||||
Targets: `html`, `react`, `tailwind`, `svelte`, `vue`
|
||||
|
||||
Response:
|
||||
```json
|
||||
{
|
||||
"type": "Code",
|
||||
"code": "// Generated from SVG...\nexport function Component() {\n return (\n <div className=\"container\">\n {/* TODO: Implement based on structure */}\n </div>\n );\n}",
|
||||
"target": "react"
|
||||
}
|
||||
```
|
||||
|
||||
#### Query elements at point
|
||||
|
||||
```json
|
||||
{"type": "GetElementsAtPoint", "x": 150.0, "y": 200.0}
|
||||
```
|
||||
|
||||
#### Get element by ID
|
||||
|
||||
```json
|
||||
{"type": "GetElementById", "id": "button-primary"}
|
||||
```
|
||||
|
||||
#### Ping
|
||||
|
||||
```json
|
||||
{"type": "Ping"}
|
||||
```
|
||||
|
||||
### Events (GUI → Agent)
|
||||
|
||||
agcanvas pushes events to all connected agents when state changes:
|
||||
|
||||
| Event | Trigger |
|
||||
|-------|---------|
|
||||
| `Connected` | Agent connects (includes current session state) |
|
||||
| `SessionCreated` | New tab created |
|
||||
| `SessionClosed` | Tab closed |
|
||||
| `SessionActivated` | User switches tabs |
|
||||
| `SvgLoaded` | SVG pasted or loaded |
|
||||
| `SvgCleared` | Canvas cleared |
|
||||
|
||||
### Element types
|
||||
|
||||
```rust
|
||||
@@ -190,20 +265,33 @@ enum ElementKind {
|
||||
## Architecture
|
||||
|
||||
```
|
||||
src/
|
||||
├── main.rs # Entry point, window setup
|
||||
├── app.rs # Main application state, UI rendering
|
||||
├── element_tree.rs # Structured element representation
|
||||
├── clipboard.rs # System clipboard integration
|
||||
├── canvas/
|
||||
│ ├── state.rs # Pan/zoom transformation state
|
||||
│ └── interaction.rs # Mouse/keyboard input handling
|
||||
├── svg/
|
||||
│ ├── parser.rs # SVG → ElementTree conversion
|
||||
│ └── renderer.rs # SVG → pixels (resvg/tiny-skia)
|
||||
└── agent/
|
||||
├── protocol.rs # JSON message types
|
||||
└── server.rs # WebSocket server
|
||||
crates/
|
||||
├── agcanvas/ # Desktop app
|
||||
│ └── src/
|
||||
│ ├── main.rs # Entry point, window setup
|
||||
│ ├── app.rs # Main app state, UI, toolbar, drawing interaction
|
||||
│ ├── session.rs # Session/tab state management
|
||||
│ ├── element_tree.rs # Structured element representation
|
||||
│ ├── clipboard.rs # System clipboard integration
|
||||
│ ├── mermaid.rs # Mermaid → SVG rendering
|
||||
│ ├── drawing/
|
||||
│ │ ├── element.rs # DrawingElement, Shape, ShapeStyle, hit testing
|
||||
│ │ ├── tool.rs # Tool enum, DragState, ResizeHandle
|
||||
│ │ └── render.rs # Shape rendering via egui Painter
|
||||
│ ├── canvas/
|
||||
│ │ ├── state.rs # Pan/zoom transformation state
|
||||
│ │ └── interaction.rs # Mouse/keyboard input handling
|
||||
│ ├── svg/
|
||||
│ │ ├── parser.rs # SVG → ElementTree conversion
|
||||
│ │ └── renderer.rs # SVG → pixels (resvg/tiny-skia)
|
||||
│ └── agent/
|
||||
│ ├── protocol.rs # JSON message types
|
||||
│ └── server.rs # WebSocket server
|
||||
└── agcanvas-mcp/ # MCP server bridge
|
||||
└── src/
|
||||
├── main.rs # CLI entry point, stdio transport
|
||||
├── bridge.rs # WebSocket client → agcanvas
|
||||
└── tools.rs # MCP tool definitions
|
||||
```
|
||||
|
||||
### Dependencies
|
||||
@@ -213,19 +301,26 @@ src/
|
||||
| `eframe`/`egui` | GUI framework |
|
||||
| `usvg` | SVG parsing |
|
||||
| `resvg`/`tiny-skia` | SVG rendering |
|
||||
| `mermaid-rs-renderer` | Mermaid → SVG |
|
||||
| `arboard` | Clipboard access |
|
||||
| `tokio-tungstenite` | WebSocket server |
|
||||
| `tokio-tungstenite` | WebSocket (both server and client) |
|
||||
| `rmcp` | MCP server SDK (Anthropic official) |
|
||||
| `serde`/`serde_json` | Serialization |
|
||||
|
||||
## Roadmap
|
||||
|
||||
- [x] Multi-frame support (sessions/tabs)
|
||||
- [x] Shape drawing (rectangle, ellipse, line, arrow, text)
|
||||
- [x] Selection, move, resize with handles
|
||||
- [x] Mermaid diagram rendering
|
||||
- [x] MCP server bridge for AI coding tools
|
||||
- [ ] Real code generation (not just stubs)
|
||||
- [ ] Element selection on canvas
|
||||
- [ ] Agent draw commands (modify canvas from agent)
|
||||
- [ ] Multi-frame support
|
||||
- [ ] Export to file
|
||||
- [ ] Diff view (before/after agent changes)
|
||||
- [ ] Plugin system for code generators
|
||||
- [ ] Undo/redo
|
||||
- [ ] Multi-select and group operations
|
||||
|
||||
## License
|
||||
|
||||
|
||||
23
crates/agcanvas-mcp/Cargo.toml
Normal file
23
crates/agcanvas-mcp/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "agcanvas-mcp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "MCP server bridge for agcanvas — exposes canvas tools to Claude Code, OpenCode, and Codex"
|
||||
|
||||
[[bin]]
|
||||
name = "agcanvas-mcp"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
rmcp = { version = "0.14", features = ["server", "macros", "transport-io"] }
|
||||
tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "io-std", "sync"] }
|
||||
tokio-tungstenite = "0.24"
|
||||
futures-util = "0.3"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
schemars = "1.0"
|
||||
anyhow = "1.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
clap = { version = "4.0", features = ["derive"] }
|
||||
29
crates/agcanvas-mcp/src/bridge.rs
Normal file
29
crates/agcanvas-mcp/src/bridge.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use anyhow::Result;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
pub async fn send_request(ws_url: &str, request_json: &str) -> Result<String> {
|
||||
let (ws_stream, _) = tokio_tungstenite::connect_async(ws_url)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Cannot connect to agcanvas at {}. Is agcanvas running? Error: {}",
|
||||
ws_url,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
// Skip the initial Connected event
|
||||
let _connected = read.next().await;
|
||||
|
||||
write.send(Message::Text(request_json.to_string())).await?;
|
||||
|
||||
match read.next().await {
|
||||
Some(Ok(Message::Text(response))) => Ok(response),
|
||||
Some(Ok(other)) => Err(anyhow::anyhow!("Unexpected message type: {:?}", other)),
|
||||
Some(Err(e)) => Err(anyhow::anyhow!("WebSocket error: {}", e)),
|
||||
None => Err(anyhow::anyhow!("Connection closed before response")),
|
||||
}
|
||||
}
|
||||
38
crates/agcanvas-mcp/src/main.rs
Normal file
38
crates/agcanvas-mcp/src/main.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
mod bridge;
|
||||
mod tools;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use rmcp::ServiceExt;
|
||||
use tools::AgCanvasServer;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "agcanvas-mcp", about = "MCP server bridge for agcanvas")]
|
||||
struct Cli {
|
||||
#[arg(long, default_value = "9876")]
|
||||
port: u16,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new(
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "agcanvas_mcp=info".into()),
|
||||
))
|
||||
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
let ws_url = format!("ws://127.0.0.1:{}", cli.port);
|
||||
|
||||
tracing::info!("Starting agcanvas MCP server, connecting to {}", ws_url);
|
||||
|
||||
let server = AgCanvasServer::new(ws_url);
|
||||
let service = server.serve(rmcp::transport::stdio()).await?;
|
||||
|
||||
tracing::info!("MCP server running on stdio");
|
||||
service.waiting().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
202
crates/agcanvas-mcp/src/tools.rs
Normal file
202
crates/agcanvas-mcp/src/tools.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
use crate::bridge::send_request;
|
||||
use rmcp::{
|
||||
ErrorData as McpError, ServerHandler,
|
||||
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
|
||||
model::*,
|
||||
schemars, tool, tool_handler, tool_router,
|
||||
};
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct SessionIdParam {
|
||||
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct GetElementParam {
|
||||
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
||||
pub session_id: Option<String>,
|
||||
#[schemars(description = "Element ID to look up")]
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct GetElementsAtPointParam {
|
||||
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
||||
pub session_id: Option<String>,
|
||||
#[schemars(description = "X coordinate in canvas space")]
|
||||
pub x: f32,
|
||||
#[schemars(description = "Y coordinate in canvas space")]
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct GenerateCodeParam {
|
||||
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
||||
pub session_id: Option<String>,
|
||||
#[schemars(description = "Code generation target: html, react, tailwind, svelte, or vue")]
|
||||
pub target: String,
|
||||
#[schemars(description = "Element ID to generate code for. If omitted, generates for the entire tree.")]
|
||||
pub element_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgCanvasServer {
|
||||
ws_url: String,
|
||||
tool_router: ToolRouter<Self>,
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl AgCanvasServer {
|
||||
pub fn new(ws_url: String) -> Self {
|
||||
Self {
|
||||
ws_url,
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(description = "List all open sessions/tabs in agcanvas. Returns session IDs, names, and whether they have SVG or drawing content loaded.")]
|
||||
async fn list_sessions(&self) -> Result<CallToolResult, McpError> {
|
||||
let request = serde_json::json!({"type": "ListSessions"});
|
||||
self.call_agcanvas(&request).await
|
||||
}
|
||||
|
||||
#[tool(description = "Get the full parsed SVG element tree from the canvas. Returns a hierarchical JSON structure with element types (Group, Rectangle, Circle, Path, Text, etc.), bounds, styles, and children. This is the primary way to understand what design is on the canvas.")]
|
||||
async fn get_element_tree(
|
||||
&self,
|
||||
Parameters(params): Parameters<SessionIdParam>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut request = serde_json::json!({"type": "GetTree"});
|
||||
if let Some(sid) = params.session_id {
|
||||
request["session_id"] = serde_json::Value::String(sid);
|
||||
}
|
||||
self.call_agcanvas(&request).await
|
||||
}
|
||||
|
||||
#[tool(description = "Get a human-readable semantic description of the canvas content. Returns structured text describing the element hierarchy with types, dimensions, and colors. Useful for quickly understanding a design without parsing JSON.")]
|
||||
async fn describe_canvas(
|
||||
&self,
|
||||
Parameters(params): Parameters<SessionIdParam>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut request = serde_json::json!({"type": "Describe"});
|
||||
if let Some(sid) = params.session_id {
|
||||
request["session_id"] = serde_json::Value::String(sid);
|
||||
}
|
||||
self.call_agcanvas(&request).await
|
||||
}
|
||||
|
||||
#[tool(description = "Get a specific element by its ID from the SVG element tree.")]
|
||||
async fn get_element_by_id(
|
||||
&self,
|
||||
Parameters(params): Parameters<GetElementParam>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut request = serde_json::json!({"type": "GetElementById", "id": params.id});
|
||||
if let Some(sid) = params.session_id {
|
||||
request["session_id"] = serde_json::Value::String(sid);
|
||||
}
|
||||
self.call_agcanvas(&request).await
|
||||
}
|
||||
|
||||
#[tool(description = "Query which elements exist at a specific (x, y) coordinate on the canvas.")]
|
||||
async fn get_elements_at_point(
|
||||
&self,
|
||||
Parameters(params): Parameters<GetElementsAtPointParam>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut request =
|
||||
serde_json::json!({"type": "GetElementsAtPoint", "x": params.x, "y": params.y});
|
||||
if let Some(sid) = params.session_id {
|
||||
request["session_id"] = serde_json::Value::String(sid);
|
||||
}
|
||||
self.call_agcanvas(&request).await
|
||||
}
|
||||
|
||||
#[tool(description = "Get all user-drawn shapes (rectangles, ellipses, lines, arrows, text) from the drawing layer.")]
|
||||
async fn get_drawing_elements(
|
||||
&self,
|
||||
Parameters(params): Parameters<SessionIdParam>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut request = serde_json::json!({"type": "GetDrawingElements"});
|
||||
if let Some(sid) = params.session_id {
|
||||
request["session_id"] = serde_json::Value::String(sid);
|
||||
}
|
||||
self.call_agcanvas(&request).await
|
||||
}
|
||||
|
||||
#[tool(description = "Generate a code stub from the SVG structure. Targets: html, react, tailwind, svelte, vue. Returns a starting template based on the element hierarchy.")]
|
||||
async fn generate_code(
|
||||
&self,
|
||||
Parameters(params): Parameters<GenerateCodeParam>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut request = serde_json::json!({
|
||||
"type": "GenerateCode",
|
||||
"target": params.target,
|
||||
"element_id": params.element_id,
|
||||
});
|
||||
if let Some(sid) = params.session_id {
|
||||
request["session_id"] = serde_json::Value::String(sid);
|
||||
}
|
||||
self.call_agcanvas(&request).await
|
||||
}
|
||||
}
|
||||
|
||||
impl AgCanvasServer {
|
||||
async fn call_agcanvas(
|
||||
&self,
|
||||
request: &serde_json::Value,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let request_str = serde_json::to_string(request)
|
||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||
|
||||
match send_request(&self.ws_url, &request_str).await {
|
||||
Ok(response) => {
|
||||
let parsed: serde_json::Value = serde_json::from_str(&response)
|
||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||
|
||||
let is_error = parsed
|
||||
.get("type")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some("Error");
|
||||
|
||||
if is_error {
|
||||
let msg = parsed
|
||||
.get("message")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("Unknown error from agcanvas");
|
||||
return Ok(CallToolResult::error(vec![Content::text(msg)]));
|
||||
}
|
||||
|
||||
let pretty = serde_json::to_string_pretty(&parsed)
|
||||
.unwrap_or_else(|_| response.clone());
|
||||
Ok(CallToolResult::success(vec![Content::text(pretty)]))
|
||||
}
|
||||
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
|
||||
"Failed to communicate with agcanvas: {}. Make sure agcanvas is running.",
|
||||
e
|
||||
))])),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler]
|
||||
impl ServerHandler for AgCanvasServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo {
|
||||
instructions: Some(
|
||||
"agcanvas MCP server — connects to agcanvas desktop app to query SVG designs, \
|
||||
element trees, and user-drawn shapes. Use describe_canvas to understand the \
|
||||
current design, get_element_tree for structured data, and generate_code for \
|
||||
code stubs. Requires agcanvas to be running."
|
||||
.into(),
|
||||
),
|
||||
capabilities: ServerCapabilities::builder().enable_tools().build(),
|
||||
server_info: Implementation {
|
||||
name: "agcanvas-mcp".into(),
|
||||
version: env!("CARGO_PKG_VERSION").into(),
|
||||
title: None,
|
||||
icons: None,
|
||||
website_url: None,
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
47
crates/agcanvas/Cargo.toml
Normal file
47
crates/agcanvas/Cargo.toml
Normal file
@@ -0,0 +1,47 @@
|
||||
[package]
|
||||
name = "agcanvas"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Interactive canvas for agent-human collaboration with SVG support"
|
||||
|
||||
[dependencies]
|
||||
# GUI
|
||||
eframe = { version = "0.29", default-features = false, features = [
|
||||
"default_fonts",
|
||||
"glow",
|
||||
"persistence",
|
||||
] }
|
||||
egui = "0.29"
|
||||
egui_extras = { version = "0.29", features = ["image"] }
|
||||
|
||||
# SVG parsing and rendering
|
||||
usvg = "0.44"
|
||||
resvg = "0.44"
|
||||
tiny-skia = "0.11"
|
||||
|
||||
# Clipboard
|
||||
arboard = "3.4"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Agent communication
|
||||
tokio = { version = "1.0", features = ["rt-multi-thread", "sync", "macros"] }
|
||||
tokio-tungstenite = "0.24"
|
||||
futures-util = "0.3"
|
||||
|
||||
# Mermaid diagram rendering
|
||||
mermaid-rs-renderer = { version = "0.1.2", default-features = false }
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Error handling
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
|
||||
# Image handling
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
@@ -1,4 +1,5 @@
|
||||
mod protocol;
|
||||
mod server;
|
||||
|
||||
pub use protocol::GuiEvent;
|
||||
pub use server::AgentServer;
|
||||
126
crates/agcanvas/src/agent/protocol.rs
Normal file
126
crates/agcanvas/src/agent/protocol.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use crate::drawing::DrawingElement;
|
||||
use crate::element_tree::{ElementTree, TreeMetadata};
|
||||
use crate::session::SessionInfo;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum GuiEvent {
|
||||
Connected {
|
||||
version: String,
|
||||
sessions: Vec<SessionInfo>,
|
||||
active_session: Option<String>,
|
||||
},
|
||||
SessionCreated {
|
||||
session: SessionInfo,
|
||||
},
|
||||
SessionClosed {
|
||||
session_id: String,
|
||||
},
|
||||
SessionActivated {
|
||||
session_id: String,
|
||||
},
|
||||
SvgLoaded {
|
||||
session_id: String,
|
||||
metadata: TreeMetadata,
|
||||
},
|
||||
SvgCleared {
|
||||
session_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum AgentRequest {
|
||||
ListSessions,
|
||||
GetTree {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
},
|
||||
GetElementById {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
id: String,
|
||||
},
|
||||
GetElementsAtPoint {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
x: f32,
|
||||
y: f32,
|
||||
},
|
||||
Describe {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
},
|
||||
GenerateCode {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
target: CodeGenTarget,
|
||||
element_id: Option<String>,
|
||||
},
|
||||
GetDrawingElements {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
},
|
||||
Ping,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum AgentResponse {
|
||||
Sessions {
|
||||
sessions: Vec<SessionInfo>,
|
||||
active_session: Option<String>,
|
||||
},
|
||||
Tree {
|
||||
session_id: String,
|
||||
tree: ElementTree,
|
||||
},
|
||||
Element {
|
||||
session_id: String,
|
||||
element: Option<crate::element_tree::Element>,
|
||||
},
|
||||
Elements {
|
||||
session_id: String,
|
||||
elements: Vec<crate::element_tree::Element>,
|
||||
},
|
||||
Description {
|
||||
session_id: String,
|
||||
text: String,
|
||||
},
|
||||
Code {
|
||||
session_id: String,
|
||||
code: String,
|
||||
target: CodeGenTarget,
|
||||
},
|
||||
DrawingElements {
|
||||
session_id: String,
|
||||
elements: Vec<DrawingElement>,
|
||||
},
|
||||
Pong,
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CodeGenTarget {
|
||||
Html,
|
||||
React,
|
||||
Tailwind,
|
||||
Svelte,
|
||||
Vue,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CodeGenTarget {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CodeGenTarget::Html => write!(f, "html"),
|
||||
CodeGenTarget::React => write!(f, "react"),
|
||||
CodeGenTarget::Tailwind => write!(f, "tailwind"),
|
||||
CodeGenTarget::Svelte => write!(f, "svelte"),
|
||||
CodeGenTarget::Vue => write!(f, "vue"),
|
||||
}
|
||||
}
|
||||
}
|
||||
258
crates/agcanvas/src/agent/server.rs
Normal file
258
crates/agcanvas/src/agent/server.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
use super::protocol::{AgentRequest, AgentResponse, CodeGenTarget, GuiEvent};
|
||||
use crate::session::SessionStore;
|
||||
use anyhow::Result;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
const EVENT_CHANNEL_CAPACITY: usize = 64;
|
||||
|
||||
pub struct AgentServer {
|
||||
sessions: Arc<RwLock<SessionStore>>,
|
||||
event_tx: broadcast::Sender<GuiEvent>,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl AgentServer {
|
||||
pub fn new(port: u16) -> Self {
|
||||
let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(SessionStore::new())),
|
||||
event_tx,
|
||||
port,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sessions_handle(&self) -> Arc<RwLock<SessionStore>> {
|
||||
self.sessions.clone()
|
||||
}
|
||||
|
||||
pub fn event_sender(&self) -> broadcast::Sender<GuiEvent> {
|
||||
self.event_tx.clone()
|
||||
}
|
||||
|
||||
pub async fn run(&self) -> Result<()> {
|
||||
let addr = format!("127.0.0.1:{}", self.port);
|
||||
let listener = TcpListener::bind(&addr).await?;
|
||||
tracing::info!("Agent server listening on ws://{}", addr);
|
||||
|
||||
while let Ok((stream, peer)) = listener.accept().await {
|
||||
tracing::info!("Agent connected from {}", peer);
|
||||
let sessions = self.sessions.clone();
|
||||
let event_rx = self.event_tx.subscribe();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(stream, sessions, event_rx).await {
|
||||
tracing::error!("Connection error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
stream: TcpStream,
|
||||
sessions: Arc<RwLock<SessionStore>>,
|
||||
mut event_rx: broadcast::Receiver<GuiEvent>,
|
||||
) -> Result<()> {
|
||||
let ws_stream = tokio_tungstenite::accept_async(stream).await?;
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
let connected_event = {
|
||||
let store = sessions.read().await;
|
||||
GuiEvent::Connected {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
sessions: store.list_sessions(),
|
||||
active_session: store.active_session_id().map(|s| s.to_string()),
|
||||
}
|
||||
};
|
||||
let connected_json = serde_json::to_string(&connected_event)?;
|
||||
write.send(Message::Text(connected_json)).await?;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = read.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
let response = match serde_json::from_str::<AgentRequest>(&text) {
|
||||
Ok(request) => process_request(request, &sessions).await,
|
||||
Err(e) => AgentResponse::Error {
|
||||
message: format!("Invalid request: {}", e),
|
||||
},
|
||||
};
|
||||
let response_text = serde_json::to_string(&response)?;
|
||||
write.send(Message::Text(response_text)).await?;
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
Some(Err(e)) => {
|
||||
tracing::warn!("WebSocket error: {}", e);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
event = event_rx.recv() => {
|
||||
match event {
|
||||
Ok(gui_event) => {
|
||||
let event_json = serde_json::to_string(&gui_event)?;
|
||||
if write.send(Message::Text(event_json)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!("Agent lagged, skipped {} events", n);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn process_request(
|
||||
request: AgentRequest,
|
||||
sessions: &Arc<RwLock<SessionStore>>,
|
||||
) -> AgentResponse {
|
||||
let store = sessions.read().await;
|
||||
|
||||
match request {
|
||||
AgentRequest::Ping => AgentResponse::Pong,
|
||||
|
||||
AgentRequest::ListSessions => AgentResponse::Sessions {
|
||||
sessions: store.list_sessions(),
|
||||
active_session: store.active_session_id().map(|s| s.to_string()),
|
||||
},
|
||||
|
||||
AgentRequest::GetTree { session_id } => {
|
||||
match store.get_tree(session_id.as_deref()) {
|
||||
Some((sid, tree)) => AgentResponse::Tree {
|
||||
session_id: sid,
|
||||
tree: tree.clone(),
|
||||
},
|
||||
None => AgentResponse::Error {
|
||||
message: session_id
|
||||
.map(|id| format!("Session '{}' not found or has no SVG", id))
|
||||
.unwrap_or_else(|| "No active session or no SVG loaded".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
AgentRequest::GetElementById { session_id, id } => {
|
||||
match store.get_tree(session_id.as_deref()) {
|
||||
Some((sid, tree)) => AgentResponse::Element {
|
||||
session_id: sid,
|
||||
element: tree.find_by_id(&id).cloned(),
|
||||
},
|
||||
None => AgentResponse::Error {
|
||||
message: "No session or SVG loaded".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
AgentRequest::GetElementsAtPoint { session_id, x, y } => {
|
||||
match store.get_tree(session_id.as_deref()) {
|
||||
Some((sid, tree)) => AgentResponse::Elements {
|
||||
session_id: sid,
|
||||
elements: tree.find_at_point(x, y).into_iter().cloned().collect(),
|
||||
},
|
||||
None => AgentResponse::Error {
|
||||
message: "No session or SVG loaded".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
AgentRequest::Describe { session_id } => {
|
||||
match store.get_tree(session_id.as_deref()) {
|
||||
Some((sid, tree)) => AgentResponse::Description {
|
||||
session_id: sid,
|
||||
text: tree.to_semantic_description(),
|
||||
},
|
||||
None => AgentResponse::Error {
|
||||
message: "No session or SVG loaded".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
AgentRequest::GetDrawingElements { session_id } => {
|
||||
match store.get_drawing_elements(session_id.as_deref()) {
|
||||
Some((sid, elements)) => AgentResponse::DrawingElements {
|
||||
session_id: sid,
|
||||
elements: elements.to_vec(),
|
||||
},
|
||||
None => AgentResponse::Error {
|
||||
message: "No session found".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
AgentRequest::GenerateCode { session_id, target, element_id } => {
|
||||
match store.get_tree(session_id.as_deref()) {
|
||||
Some((sid, tree)) => {
|
||||
let element = match &element_id {
|
||||
Some(id) => tree.find_by_id(id),
|
||||
None => Some(&tree.root),
|
||||
};
|
||||
|
||||
match element {
|
||||
Some(el) => {
|
||||
let code = generate_code_stub(el, target);
|
||||
AgentResponse::Code {
|
||||
session_id: sid,
|
||||
code,
|
||||
target,
|
||||
}
|
||||
}
|
||||
None => AgentResponse::Error {
|
||||
message: format!("Element not found: {:?}", element_id),
|
||||
},
|
||||
}
|
||||
}
|
||||
None => AgentResponse::Error {
|
||||
message: "No session or SVG loaded".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_code_stub(element: &crate::element_tree::Element, target: CodeGenTarget) -> String {
|
||||
let description = crate::element_tree::ElementTree {
|
||||
root: element.clone(),
|
||||
metadata: crate::element_tree::TreeMetadata {
|
||||
source: "code_gen".to_string(),
|
||||
width: element.bounds.width,
|
||||
height: element.bounds.height,
|
||||
element_count: 1,
|
||||
},
|
||||
}
|
||||
.to_semantic_description();
|
||||
|
||||
match target {
|
||||
CodeGenTarget::Html => format!(
|
||||
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<div class=\"container\">\n <!-- TODO: Implement based on structure -->\n</div>",
|
||||
element.id, description
|
||||
),
|
||||
CodeGenTarget::React => format!(
|
||||
"// Generated from SVG element: {}\n// Structure:\n// {}\n\nexport function Component() {{\n return (\n <div className=\"container\">\n {{/* TODO: Implement based on structure */}}\n </div>\n );\n}}",
|
||||
element.id,
|
||||
description.replace('\n', "\n// ")
|
||||
),
|
||||
CodeGenTarget::Tailwind => format!(
|
||||
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<div class=\"flex flex-col\">\n <!-- TODO: Implement with Tailwind classes -->\n</div>",
|
||||
element.id, description
|
||||
),
|
||||
CodeGenTarget::Svelte => format!(
|
||||
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<script>\n // Component logic\n</script>\n\n<div class=\"container\">\n <!-- TODO: Implement -->\n</div>",
|
||||
element.id, description
|
||||
),
|
||||
CodeGenTarget::Vue => format!(
|
||||
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<template>\n <div class=\"container\">\n <!-- TODO: Implement -->\n </div>\n</template>\n\n<script setup>\n// Component logic\n</script>",
|
||||
element.id, description
|
||||
),
|
||||
}
|
||||
}
|
||||
1011
crates/agcanvas/src/app.rs
Normal file
1011
crates/agcanvas/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -26,11 +26,6 @@ impl CanvasState {
|
||||
Pos2::new(canvas_relative.x, canvas_relative.y)
|
||||
}
|
||||
|
||||
pub fn canvas_to_screen(&self, canvas_pos: Pos2, canvas_center: Pos2) -> Pos2 {
|
||||
let offset_pos = Pos2::new(canvas_pos.x + self.offset.x, canvas_pos.y + self.offset.y);
|
||||
canvas_center + (offset_pos.to_vec2() * self.zoom)
|
||||
}
|
||||
|
||||
pub fn zoom_at(&mut self, screen_pos: Pos2, canvas_center: Pos2, zoom_delta: f32) {
|
||||
let canvas_pos_before = self.screen_to_canvas(screen_pos, canvas_center);
|
||||
|
||||
@@ -20,10 +20,6 @@ impl ClipboardManager {
|
||||
None
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_text(&mut self) -> Option<String> {
|
||||
self.clipboard.get_text().ok()
|
||||
}
|
||||
}
|
||||
|
||||
fn is_svg_content(text: &str) -> bool {
|
||||
259
crates/agcanvas/src/drawing/element.rs
Normal file
259
crates/agcanvas/src/drawing/element.rs
Normal file
@@ -0,0 +1,259 @@
|
||||
use egui::{Color32, Pos2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
static DRAWING_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
pub fn generate_drawing_id() -> String {
|
||||
format!("draw_{}", DRAWING_ID_COUNTER.fetch_add(1, Ordering::SeqCst))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DrawingElement {
|
||||
pub id: String,
|
||||
pub shape: Shape,
|
||||
pub style: ShapeStyle,
|
||||
}
|
||||
|
||||
impl DrawingElement {
|
||||
pub fn new(shape: Shape, style: ShapeStyle) -> Self {
|
||||
Self {
|
||||
id: generate_drawing_id(),
|
||||
shape,
|
||||
style,
|
||||
}
|
||||
}
|
||||
|
||||
/// Axis-aligned bounding box in canvas coordinates.
|
||||
pub fn bounding_rect(&self) -> egui::Rect {
|
||||
match &self.shape {
|
||||
Shape::Rectangle { pos, size } => egui::Rect::from_min_size(*pos, *size),
|
||||
Shape::Ellipse { center, radii } => {
|
||||
egui::Rect::from_center_size(*center, egui::vec2(radii.x * 2.0, radii.y * 2.0))
|
||||
}
|
||||
Shape::Line { start, end } | Shape::Arrow { start, end } => {
|
||||
egui::Rect::from_two_pos(*start, *end)
|
||||
}
|
||||
Shape::Text {
|
||||
pos,
|
||||
content: _,
|
||||
font_size,
|
||||
} => {
|
||||
// Approximate: we'll refine during rendering when we know actual text size.
|
||||
let approx_width = 8.0 * font_size * 0.6;
|
||||
let approx_height = *font_size * 1.4;
|
||||
egui::Rect::from_min_size(*pos, egui::vec2(approx_width, approx_height))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hit-test: does the point (in canvas coords) touch this element?
|
||||
pub fn contains_point(&self, point: Pos2) -> bool {
|
||||
let tolerance = 6.0; // px tolerance for thin shapes like lines
|
||||
|
||||
match &self.shape {
|
||||
Shape::Rectangle { pos, size } => {
|
||||
let rect = egui::Rect::from_min_size(*pos, *size);
|
||||
rect.expand(tolerance).contains(point)
|
||||
}
|
||||
Shape::Ellipse { center, radii } => {
|
||||
let dx = point.x - center.x;
|
||||
let dy = point.y - center.y;
|
||||
let rx = radii.x + tolerance;
|
||||
let ry = radii.y + tolerance;
|
||||
(dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1.0
|
||||
}
|
||||
Shape::Line { start, end } | Shape::Arrow { start, end } => {
|
||||
point_to_segment_distance(point, *start, *end) <= tolerance
|
||||
}
|
||||
Shape::Text {
|
||||
pos,
|
||||
content: _,
|
||||
font_size,
|
||||
} => {
|
||||
let approx_width = 8.0 * font_size * 0.6;
|
||||
let approx_height = *font_size * 1.4;
|
||||
let rect = egui::Rect::from_min_size(*pos, egui::vec2(approx_width, approx_height));
|
||||
rect.expand(tolerance).contains(point)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate the element by a delta vector.
|
||||
pub fn translate(&mut self, delta: egui::Vec2) {
|
||||
match &mut self.shape {
|
||||
Shape::Rectangle { pos, .. } => *pos += delta,
|
||||
Shape::Ellipse { center, .. } => *center += delta,
|
||||
Shape::Line { start, end } | Shape::Arrow { start, end } => {
|
||||
*start += delta;
|
||||
*end += delta;
|
||||
}
|
||||
Shape::Text { pos, .. } => *pos += delta,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize to fit a new bounding rect, preserving shape semantics.
|
||||
pub fn resize_to(&mut self, new_rect: egui::Rect) {
|
||||
match &mut self.shape {
|
||||
Shape::Rectangle { pos, size } => {
|
||||
*pos = new_rect.min;
|
||||
*size = new_rect.size();
|
||||
}
|
||||
Shape::Ellipse { center, radii } => {
|
||||
*center = new_rect.center();
|
||||
*radii = egui::vec2(new_rect.width() / 2.0, new_rect.height() / 2.0);
|
||||
}
|
||||
Shape::Line { start, end } | Shape::Arrow { start, end } => {
|
||||
*start = new_rect.min;
|
||||
*end = new_rect.max;
|
||||
}
|
||||
Shape::Text { pos, .. } => {
|
||||
*pos = new_rect.min;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum Shape {
|
||||
Rectangle {
|
||||
pos: Pos2,
|
||||
size: egui::Vec2,
|
||||
},
|
||||
Ellipse {
|
||||
center: Pos2,
|
||||
radii: egui::Vec2,
|
||||
},
|
||||
Line {
|
||||
start: Pos2,
|
||||
end: Pos2,
|
||||
},
|
||||
Arrow {
|
||||
start: Pos2,
|
||||
end: Pos2,
|
||||
},
|
||||
Text {
|
||||
pos: Pos2,
|
||||
content: String,
|
||||
font_size: f32,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShapeStyle {
|
||||
pub fill: Option<Color32>,
|
||||
pub stroke_color: Color32,
|
||||
pub stroke_width: f32,
|
||||
}
|
||||
|
||||
impl Default for ShapeStyle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fill: None,
|
||||
stroke_color: Color32::WHITE,
|
||||
stroke_width: 2.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Distance from point `p` to segment `a`–`b`.
|
||||
fn point_to_segment_distance(p: Pos2, a: Pos2, b: Pos2) -> f32 {
|
||||
let ab = b - a;
|
||||
let ap = p - a;
|
||||
let ab_len_sq = ab.length_sq();
|
||||
|
||||
if ab_len_sq < 1e-6 {
|
||||
return ap.length();
|
||||
}
|
||||
|
||||
let t = (ap.x * ab.x + ap.y * ab.y) / ab_len_sq;
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let closest = a + ab * t;
|
||||
(p - closest).length()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rectangle_contains_point_inside() {
|
||||
let el = DrawingElement::new(
|
||||
Shape::Rectangle {
|
||||
pos: Pos2::new(10.0, 10.0),
|
||||
size: egui::vec2(100.0, 50.0),
|
||||
},
|
||||
ShapeStyle::default(),
|
||||
);
|
||||
assert!(el.contains_point(Pos2::new(50.0, 30.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangle_does_not_contain_distant_point() {
|
||||
let el = DrawingElement::new(
|
||||
Shape::Rectangle {
|
||||
pos: Pos2::new(10.0, 10.0),
|
||||
size: egui::vec2(100.0, 50.0),
|
||||
},
|
||||
ShapeStyle::default(),
|
||||
);
|
||||
assert!(!el.contains_point(Pos2::new(500.0, 500.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_hit_test_near_segment() {
|
||||
let el = DrawingElement::new(
|
||||
Shape::Line {
|
||||
start: Pos2::new(0.0, 0.0),
|
||||
end: Pos2::new(100.0, 0.0),
|
||||
},
|
||||
ShapeStyle::default(),
|
||||
);
|
||||
// Point 3px above the line — within tolerance
|
||||
assert!(el.contains_point(Pos2::new(50.0, 3.0)));
|
||||
// Point 20px above — outside tolerance
|
||||
assert!(!el.contains_point(Pos2::new(50.0, 20.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipse_contains_center() {
|
||||
let el = DrawingElement::new(
|
||||
Shape::Ellipse {
|
||||
center: Pos2::new(50.0, 50.0),
|
||||
radii: egui::vec2(30.0, 20.0),
|
||||
},
|
||||
ShapeStyle::default(),
|
||||
);
|
||||
assert!(el.contains_point(Pos2::new(50.0, 50.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_moves_element() {
|
||||
let mut el = DrawingElement::new(
|
||||
Shape::Rectangle {
|
||||
pos: Pos2::new(10.0, 10.0),
|
||||
size: egui::vec2(100.0, 50.0),
|
||||
},
|
||||
ShapeStyle::default(),
|
||||
);
|
||||
el.translate(egui::vec2(5.0, 5.0));
|
||||
match &el.shape {
|
||||
Shape::Rectangle { pos, .. } => {
|
||||
assert!((pos.x - 15.0).abs() < 0.01);
|
||||
assert!((pos.y - 15.0).abs() < 0.01);
|
||||
}
|
||||
_ => panic!("Expected Rectangle"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_to_segment_distance_perpendicular() {
|
||||
let dist = super::point_to_segment_distance(
|
||||
Pos2::new(5.0, 5.0),
|
||||
Pos2::new(0.0, 0.0),
|
||||
Pos2::new(10.0, 0.0),
|
||||
);
|
||||
assert!((dist - 5.0).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
10
crates/agcanvas/src/drawing/mod.rs
Normal file
10
crates/agcanvas/src/drawing/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
mod element;
|
||||
mod render;
|
||||
mod tool;
|
||||
|
||||
pub use element::{DrawingElement, Shape, ShapeStyle};
|
||||
pub use render::{
|
||||
draw_creation_preview, draw_elements, draw_selection, find_handle_at_screen_pos,
|
||||
screen_to_canvas,
|
||||
};
|
||||
pub use tool::{DragState, Tool};
|
||||
208
crates/agcanvas/src/drawing/render.rs
Normal file
208
crates/agcanvas/src/drawing/render.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
use super::element::DrawingElement;
|
||||
use super::element::Shape;
|
||||
use super::tool::{DragState, ResizeHandle, Tool};
|
||||
use egui::{Color32, Painter, Pos2, Stroke, Vec2};
|
||||
|
||||
const HANDLE_RADIUS: f32 = 4.0;
|
||||
const SELECTION_COLOR: Color32 = Color32::from_rgb(59, 130, 246);
|
||||
const CREATION_PREVIEW_COLOR: Color32 = Color32::from_rgba_premultiplied(59, 130, 246, 128);
|
||||
|
||||
pub fn draw_elements(
|
||||
painter: &Painter,
|
||||
elements: &[DrawingElement],
|
||||
canvas_center: Pos2,
|
||||
offset: Vec2,
|
||||
zoom: f32,
|
||||
) {
|
||||
for element in elements {
|
||||
draw_element(painter, element, canvas_center, offset, zoom);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_element(
|
||||
painter: &Painter,
|
||||
element: &DrawingElement,
|
||||
canvas_center: Pos2,
|
||||
offset: Vec2,
|
||||
zoom: f32,
|
||||
) {
|
||||
let style = &element.style;
|
||||
let stroke = Stroke::new(style.stroke_width * zoom, style.stroke_color);
|
||||
|
||||
match &element.shape {
|
||||
Shape::Rectangle { pos, size } => {
|
||||
let screen_pos = canvas_to_screen(*pos, canvas_center, offset, zoom);
|
||||
let screen_size = *size * zoom;
|
||||
let rect = egui::Rect::from_min_size(screen_pos, screen_size);
|
||||
|
||||
if let Some(fill) = style.fill {
|
||||
painter.rect_filled(rect, 0.0, fill);
|
||||
}
|
||||
painter.rect_stroke(rect, 0.0, stroke);
|
||||
}
|
||||
Shape::Ellipse { center, radii } => {
|
||||
let screen_center = canvas_to_screen(*center, canvas_center, offset, zoom);
|
||||
let screen_radii = *radii * zoom;
|
||||
|
||||
if let Some(fill) = style.fill {
|
||||
painter.circle_filled(screen_center, screen_radii.x.min(screen_radii.y), fill);
|
||||
}
|
||||
|
||||
let n_points = 64;
|
||||
let points: Vec<Pos2> = (0..=n_points)
|
||||
.map(|i| {
|
||||
let angle = i as f32 / n_points as f32 * std::f32::consts::TAU;
|
||||
Pos2::new(
|
||||
screen_center.x + screen_radii.x * angle.cos(),
|
||||
screen_center.y + screen_radii.y * angle.sin(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
painter.add(egui::Shape::line(points, stroke));
|
||||
}
|
||||
Shape::Line { start, end } => {
|
||||
let s = canvas_to_screen(*start, canvas_center, offset, zoom);
|
||||
let e = canvas_to_screen(*end, canvas_center, offset, zoom);
|
||||
painter.line_segment([s, e], stroke);
|
||||
}
|
||||
Shape::Arrow { start, end } => {
|
||||
let s = canvas_to_screen(*start, canvas_center, offset, zoom);
|
||||
let e = canvas_to_screen(*end, canvas_center, offset, zoom);
|
||||
painter.line_segment([s, e], stroke);
|
||||
draw_arrowhead(painter, s, e, stroke);
|
||||
}
|
||||
Shape::Text {
|
||||
pos,
|
||||
content,
|
||||
font_size,
|
||||
} => {
|
||||
let screen_pos = canvas_to_screen(*pos, canvas_center, offset, zoom);
|
||||
let scaled_font_size = font_size * zoom;
|
||||
painter.text(
|
||||
screen_pos,
|
||||
egui::Align2::LEFT_TOP,
|
||||
content,
|
||||
egui::FontId::proportional(scaled_font_size),
|
||||
style.stroke_color,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_selection(
|
||||
painter: &Painter,
|
||||
element: &DrawingElement,
|
||||
canvas_center: Pos2,
|
||||
offset: Vec2,
|
||||
zoom: f32,
|
||||
) {
|
||||
let rect = element.bounding_rect();
|
||||
let screen_min = canvas_to_screen(rect.min, canvas_center, offset, zoom);
|
||||
let screen_max = canvas_to_screen(rect.max, canvas_center, offset, zoom);
|
||||
let screen_rect = egui::Rect::from_min_max(screen_min, screen_max);
|
||||
|
||||
painter.rect_stroke(
|
||||
screen_rect.expand(2.0),
|
||||
0.0,
|
||||
Stroke::new(1.0, SELECTION_COLOR),
|
||||
);
|
||||
|
||||
for handle in ResizeHandle::ALL {
|
||||
let pos = handle.position_in_rect(screen_rect);
|
||||
painter.circle_filled(pos, HANDLE_RADIUS, SELECTION_COLOR);
|
||||
painter.circle_stroke(pos, HANDLE_RADIUS, Stroke::new(1.0, Color32::WHITE));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_creation_preview(
|
||||
painter: &Painter,
|
||||
tool: Tool,
|
||||
drag_state: &DragState,
|
||||
canvas_center: Pos2,
|
||||
offset: Vec2,
|
||||
zoom: f32,
|
||||
) {
|
||||
if let DragState::Creating { start, current } = drag_state {
|
||||
let s = canvas_to_screen(*start, canvas_center, offset, zoom);
|
||||
let c = canvas_to_screen(*current, canvas_center, offset, zoom);
|
||||
let preview_stroke = Stroke::new(2.0, CREATION_PREVIEW_COLOR);
|
||||
|
||||
match tool {
|
||||
Tool::Rectangle => {
|
||||
let rect = egui::Rect::from_two_pos(s, c);
|
||||
painter.rect_stroke(rect, 0.0, preview_stroke);
|
||||
}
|
||||
Tool::Ellipse => {
|
||||
let rect = egui::Rect::from_two_pos(s, c);
|
||||
let center = rect.center();
|
||||
let rx = rect.width() / 2.0;
|
||||
let ry = rect.height() / 2.0;
|
||||
|
||||
let n_points = 64;
|
||||
let points: Vec<Pos2> = (0..=n_points)
|
||||
.map(|i| {
|
||||
let angle = i as f32 / n_points as f32 * std::f32::consts::TAU;
|
||||
Pos2::new(center.x + rx * angle.cos(), center.y + ry * angle.sin())
|
||||
})
|
||||
.collect();
|
||||
painter.add(egui::Shape::line(points, preview_stroke));
|
||||
}
|
||||
Tool::Line => {
|
||||
painter.line_segment([s, c], preview_stroke);
|
||||
}
|
||||
Tool::Arrow => {
|
||||
painter.line_segment([s, c], preview_stroke);
|
||||
draw_arrowhead(painter, s, c, preview_stroke);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_arrowhead(painter: &Painter, from: Pos2, to: Pos2, stroke: Stroke) {
|
||||
let dir = to - from;
|
||||
let len = dir.length();
|
||||
if len < 1.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let dir_norm = dir / len;
|
||||
let perp = Vec2::new(-dir_norm.y, dir_norm.x);
|
||||
let arrow_size = 12.0;
|
||||
|
||||
let left = to - dir_norm * arrow_size + perp * (arrow_size * 0.4);
|
||||
let right = to - dir_norm * arrow_size - perp * (arrow_size * 0.4);
|
||||
|
||||
painter.line_segment([left, to], stroke);
|
||||
painter.line_segment([right, to], stroke);
|
||||
}
|
||||
|
||||
pub fn canvas_to_screen(canvas_pos: Pos2, canvas_center: Pos2, offset: Vec2, zoom: f32) -> Pos2 {
|
||||
canvas_center + (canvas_pos.to_vec2() + offset) * zoom
|
||||
}
|
||||
|
||||
pub fn screen_to_canvas(screen_pos: Pos2, canvas_center: Pos2, offset: Vec2, zoom: f32) -> Pos2 {
|
||||
let relative = screen_pos - canvas_center;
|
||||
(relative / zoom - offset).to_pos2()
|
||||
}
|
||||
|
||||
pub fn find_handle_at_screen_pos(
|
||||
element: &DrawingElement,
|
||||
screen_pos: Pos2,
|
||||
canvas_center: Pos2,
|
||||
offset: Vec2,
|
||||
zoom: f32,
|
||||
) -> Option<ResizeHandle> {
|
||||
let rect = element.bounding_rect();
|
||||
let screen_min = canvas_to_screen(rect.min, canvas_center, offset, zoom);
|
||||
let screen_max = canvas_to_screen(rect.max, canvas_center, offset, zoom);
|
||||
let screen_rect = egui::Rect::from_min_max(screen_min, screen_max);
|
||||
|
||||
for handle in ResizeHandle::ALL {
|
||||
let handle_pos = handle.position_in_rect(screen_rect);
|
||||
if (screen_pos - handle_pos).length() <= HANDLE_RADIUS + 4.0 {
|
||||
return Some(handle);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
109
crates/agcanvas/src/drawing/tool.rs
Normal file
109
crates/agcanvas/src/drawing/tool.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use egui::Pos2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum Tool {
|
||||
#[default]
|
||||
Select,
|
||||
Rectangle,
|
||||
Ellipse,
|
||||
Line,
|
||||
Arrow,
|
||||
Text,
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Tool::Select => "Select",
|
||||
Tool::Rectangle => "Rect",
|
||||
Tool::Ellipse => "Ellipse",
|
||||
Tool::Line => "Line",
|
||||
Tool::Arrow => "Arrow",
|
||||
Tool::Text => "Text",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shortcut(&self) -> Option<char> {
|
||||
match self {
|
||||
Tool::Select => Some('V'),
|
||||
Tool::Rectangle => Some('R'),
|
||||
Tool::Ellipse => Some('E'),
|
||||
Tool::Line => Some('L'),
|
||||
Tool::Arrow => Some('A'),
|
||||
Tool::Text => Some('T'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub enum DragState {
|
||||
#[default]
|
||||
None,
|
||||
Creating {
|
||||
start: Pos2,
|
||||
current: Pos2,
|
||||
},
|
||||
Moving {
|
||||
element_id: String,
|
||||
},
|
||||
Resizing {
|
||||
handle: ResizeHandle,
|
||||
element_id: String,
|
||||
original_rect: egui::Rect,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ResizeHandle {
|
||||
TopLeft,
|
||||
TopRight,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
}
|
||||
|
||||
impl ResizeHandle {
|
||||
pub const ALL: [ResizeHandle; 4] = [
|
||||
ResizeHandle::TopLeft,
|
||||
ResizeHandle::TopRight,
|
||||
ResizeHandle::BottomLeft,
|
||||
ResizeHandle::BottomRight,
|
||||
];
|
||||
|
||||
pub fn position_in_rect(&self, rect: egui::Rect) -> Pos2 {
|
||||
match self {
|
||||
ResizeHandle::TopLeft => rect.left_top(),
|
||||
ResizeHandle::TopRight => rect.right_top(),
|
||||
ResizeHandle::BottomLeft => rect.left_bottom(),
|
||||
ResizeHandle::BottomRight => rect.right_bottom(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_delta(&self, original: egui::Rect, delta: egui::Vec2) -> egui::Rect {
|
||||
let mut r = original;
|
||||
match self {
|
||||
ResizeHandle::TopLeft => {
|
||||
r.min.x += delta.x;
|
||||
r.min.y += delta.y;
|
||||
}
|
||||
ResizeHandle::TopRight => {
|
||||
r.max.x += delta.x;
|
||||
r.min.y += delta.y;
|
||||
}
|
||||
ResizeHandle::BottomLeft => {
|
||||
r.min.x += delta.x;
|
||||
r.max.y += delta.y;
|
||||
}
|
||||
ResizeHandle::BottomRight => {
|
||||
r.max.x += delta.x;
|
||||
r.max.y += delta.y;
|
||||
}
|
||||
}
|
||||
// Normalize so min < max
|
||||
let min_x = r.min.x.min(r.max.x);
|
||||
let max_x = r.min.x.max(r.max.x);
|
||||
let min_y = r.min.y.min(r.max.y);
|
||||
let max_y = r.min.y.max(r.max.y);
|
||||
egui::Rect::from_min_max(Pos2::new(min_x, min_y), Pos2::new(max_x, max_y))
|
||||
}
|
||||
}
|
||||
@@ -76,12 +76,6 @@ impl ElementTree {
|
||||
results
|
||||
}
|
||||
|
||||
pub fn flatten(&self) -> Vec<&Element> {
|
||||
let mut elements = Vec::new();
|
||||
flatten_recursive(&self.root, &mut elements);
|
||||
elements
|
||||
}
|
||||
|
||||
pub fn to_semantic_description(&self) -> String {
|
||||
describe_element(&self.root, 0)
|
||||
}
|
||||
@@ -113,13 +107,6 @@ fn find_elements_at_point_recursive<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
fn flatten_recursive<'a>(element: &'a Element, results: &mut Vec<&'a Element>) {
|
||||
results.push(element);
|
||||
for child in &element.children {
|
||||
flatten_recursive(child, results);
|
||||
}
|
||||
}
|
||||
|
||||
fn describe_element(element: &Element, depth: usize) -> String {
|
||||
let indent = " ".repeat(depth);
|
||||
let mut desc = String::new();
|
||||
@@ -1,9 +1,12 @@
|
||||
mod app;
|
||||
mod canvas;
|
||||
mod drawing;
|
||||
mod element_tree;
|
||||
mod mermaid;
|
||||
mod svg;
|
||||
mod clipboard;
|
||||
mod agent;
|
||||
mod session;
|
||||
|
||||
use anyhow::Result;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
24
crates/agcanvas/src/mermaid.rs
Normal file
24
crates/agcanvas/src/mermaid.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn render_mermaid_to_svg(mermaid_source: &str) -> Result<String> {
|
||||
let svg = mermaid_rs_renderer::render(mermaid_source)
|
||||
.map_err(|e| anyhow::anyhow!("Mermaid render failed: {}", e))?;
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn renders_simple_flowchart() {
|
||||
let svg = render_mermaid_to_svg("flowchart LR\n A-->B").unwrap();
|
||||
assert!(svg.contains("<svg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_sequence_diagram() {
|
||||
let svg = render_mermaid_to_svg("sequenceDiagram\n Alice->>Bob: Hello").unwrap();
|
||||
assert!(svg.contains("<svg"));
|
||||
}
|
||||
}
|
||||
176
crates/agcanvas/src/session.rs
Normal file
176
crates/agcanvas/src/session.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use crate::canvas::CanvasState;
|
||||
use crate::drawing::{DragState, DrawingElement, Tool};
|
||||
use crate::element_tree::ElementTree;
|
||||
use crate::svg::SvgRenderer;
|
||||
use egui::TextureHandle;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub has_svg: bool,
|
||||
pub element_count: Option<usize>,
|
||||
pub drawing_element_count: usize,
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub canvas_state: CanvasState,
|
||||
pub svg_renderer: Option<SvgRenderer>,
|
||||
pub svg_texture: Option<TextureHandle>,
|
||||
pub element_tree: Option<ElementTree>,
|
||||
pub description_text: String,
|
||||
|
||||
pub drawing_elements: Vec<DrawingElement>,
|
||||
pub selected_element_id: Option<String>,
|
||||
pub active_tool: Tool,
|
||||
pub drag_state: DragState,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(id: String, name: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name,
|
||||
canvas_state: CanvasState::default(),
|
||||
svg_renderer: None,
|
||||
svg_texture: None,
|
||||
element_tree: None,
|
||||
description_text: String::new(),
|
||||
drawing_elements: Vec::new(),
|
||||
selected_element_id: None,
|
||||
active_tool: Tool::default(),
|
||||
drag_state: DragState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn info(&self) -> SessionInfo {
|
||||
SessionInfo {
|
||||
id: self.id.clone(),
|
||||
name: self.name.clone(),
|
||||
has_svg: self.element_tree.is_some(),
|
||||
element_count: self.element_tree.as_ref().map(|t| t.metadata.element_count),
|
||||
drawing_element_count: self.drawing_elements.len(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.svg_renderer = None;
|
||||
self.svg_texture = None;
|
||||
self.element_tree = None;
|
||||
self.description_text.clear();
|
||||
self.drawing_elements.clear();
|
||||
self.selected_element_id = None;
|
||||
self.drag_state = DragState::default();
|
||||
self.canvas_state.reset();
|
||||
}
|
||||
|
||||
pub fn selected_element(&self) -> Option<&DrawingElement> {
|
||||
self.selected_element_id
|
||||
.as_ref()
|
||||
.and_then(|id| self.drawing_elements.iter().find(|e| e.id == *id))
|
||||
}
|
||||
|
||||
pub fn selected_element_mut(&mut self) -> Option<&mut DrawingElement> {
|
||||
let id = self.selected_element_id.clone();
|
||||
id.and_then(move |id| self.drawing_elements.iter_mut().find(|e| e.id == id))
|
||||
}
|
||||
|
||||
pub fn delete_selected(&mut self) {
|
||||
if let Some(id) = self.selected_element_id.take() {
|
||||
self.drawing_elements.retain(|e| e.id != id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionData {
|
||||
pub info: SessionInfo,
|
||||
pub tree: Option<ElementTree>,
|
||||
pub drawing_elements: Vec<DrawingElement>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SessionStore {
|
||||
sessions: HashMap<String, SessionData>,
|
||||
active_session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn add_session(&mut self, info: SessionInfo, tree: Option<ElementTree>) {
|
||||
let id = info.id.clone();
|
||||
self.sessions.insert(
|
||||
id.clone(),
|
||||
SessionData {
|
||||
info,
|
||||
tree,
|
||||
drawing_elements: Vec::new(),
|
||||
},
|
||||
);
|
||||
if self.active_session_id.is_none() {
|
||||
self.active_session_id = Some(id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_session(&mut self, session_id: &str) {
|
||||
self.sessions.remove(session_id);
|
||||
if self.active_session_id.as_deref() == Some(session_id) {
|
||||
self.active_session_id = self.sessions.keys().next().cloned();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_active(&mut self, session_id: &str) {
|
||||
if self.sessions.contains_key(session_id) {
|
||||
self.active_session_id = Some(session_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_tree(&mut self, session_id: &str, tree: Option<ElementTree>) {
|
||||
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||
data.info.has_svg = tree.is_some();
|
||||
data.info.element_count = tree.as_ref().map(|t| t.metadata.element_count);
|
||||
data.tree = tree;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_tree(&self, session_id: Option<&str>) -> Option<(String, &ElementTree)> {
|
||||
let id = session_id
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| self.active_session_id.clone())?;
|
||||
let data = self.sessions.get(&id)?;
|
||||
data.tree.as_ref().map(|t| (id, t))
|
||||
}
|
||||
|
||||
pub fn update_drawing_elements(&mut self, session_id: &str, elements: Vec<DrawingElement>) {
|
||||
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||
data.info.drawing_element_count = elements.len();
|
||||
data.drawing_elements = elements;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_drawing_elements(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
) -> Option<(String, &[DrawingElement])> {
|
||||
let id = session_id
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| self.active_session_id.clone())?;
|
||||
let data = self.sessions.get(&id)?;
|
||||
Some((id, &data.drawing_elements))
|
||||
}
|
||||
|
||||
pub fn list_sessions(&self) -> Vec<SessionInfo> {
|
||||
self.sessions.values().map(|d| d.info.clone()).collect()
|
||||
}
|
||||
|
||||
pub fn active_session_id(&self) -> Option<&str> {
|
||||
self.active_session_id.as_deref()
|
||||
}
|
||||
}
|
||||
@@ -37,7 +37,7 @@ fn parse_group(group: &Group) -> Element {
|
||||
height: bbox.height(),
|
||||
};
|
||||
|
||||
let children: Vec<Element> = group.children().iter().map(|c| parse_node(c)).collect();
|
||||
let children: Vec<Element> = group.children().iter().map(parse_node).collect();
|
||||
|
||||
let id_str = group.id();
|
||||
let name = if id_str.is_empty() {
|
||||
@@ -46,8 +46,4 @@ impl SvgRenderer {
|
||||
let size = self.tree.size();
|
||||
(size.width(), size.height())
|
||||
}
|
||||
|
||||
pub fn tree(&self) -> &Tree {
|
||||
&self.tree
|
||||
}
|
||||
}
|
||||
94
examples/mcp-configs/README.md
Normal file
94
examples/mcp-configs/README.md
Normal file
@@ -0,0 +1,94 @@
|
||||
# MCP Configuration Examples
|
||||
|
||||
These are example configuration files for connecting AI coding tools to agcanvas via the MCP bridge.
|
||||
|
||||
## Prerequisites
|
||||
|
||||
1. Build the MCP server:
|
||||
```bash
|
||||
cargo build --release -p agcanvas-mcp
|
||||
```
|
||||
|
||||
2. Make sure `agcanvas-mcp` is in your PATH, or use the full path:
|
||||
```
|
||||
./target/release/agcanvas-mcp
|
||||
```
|
||||
|
||||
3. agcanvas must be running (it hosts the WebSocket server on port 9876).
|
||||
|
||||
## Claude Code
|
||||
|
||||
Copy `claude-code.mcp.json` to your project root as `.mcp.json`:
|
||||
|
||||
```bash
|
||||
cp examples/mcp-configs/claude-code.mcp.json /path/to/your/project/.mcp.json
|
||||
```
|
||||
|
||||
Or add to your global Claude Code config at `~/.claude/mcp.json`.
|
||||
|
||||
If `agcanvas-mcp` is not in your PATH, use the full path:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"agcanvas": {
|
||||
"command": "/path/to/agcanvas-mcp",
|
||||
"args": ["--port", "9876"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## OpenCode
|
||||
|
||||
Add the server to your `opencode.json`:
|
||||
|
||||
```bash
|
||||
cp examples/mcp-configs/opencode.json /path/to/your/project/opencode.json
|
||||
```
|
||||
|
||||
Or merge the `mcpServers` block into your existing config.
|
||||
|
||||
## Codex (OpenAI)
|
||||
|
||||
Codex uses the same MCP config format. Add to your project's MCP config:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"agcanvas": {
|
||||
"command": "agcanvas-mcp",
|
||||
"args": ["--port", "9876"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Custom Port
|
||||
|
||||
If agcanvas is running on a different port, change the `--port` argument:
|
||||
|
||||
```json
|
||||
{
|
||||
"mcpServers": {
|
||||
"agcanvas": {
|
||||
"command": "agcanvas-mcp",
|
||||
"args": ["--port", "8080"]
|
||||
}
|
||||
}
|
||||
}
|
||||
```
|
||||
|
||||
## Available Tools
|
||||
|
||||
Once connected, the following MCP tools are available:
|
||||
|
||||
| Tool | Description |
|
||||
|------|-------------|
|
||||
| `list_sessions` | List all open tabs/sessions in agcanvas |
|
||||
| `get_element_tree` | Get the full parsed SVG element tree (structured JSON) |
|
||||
| `describe_canvas` | Get a human-readable description of the canvas |
|
||||
| `get_element_by_id` | Look up a specific element by ID |
|
||||
| `get_elements_at_point` | Find elements at an (x, y) coordinate |
|
||||
| `get_drawing_elements` | Get all user-drawn shapes (rects, ellipses, lines, arrows, text) |
|
||||
| `generate_code` | Generate code stubs (html, react, tailwind, svelte, vue) |
|
||||
8
examples/mcp-configs/claude-code.mcp.json
Normal file
8
examples/mcp-configs/claude-code.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
||||
{
|
||||
"mcpServers": {
|
||||
"agcanvas": {
|
||||
"command": "agcanvas-mcp",
|
||||
"args": ["--port", "9876"]
|
||||
}
|
||||
}
|
||||
}
|
||||
9
examples/mcp-configs/opencode.json
Normal file
9
examples/mcp-configs/opencode.json
Normal file
@@ -0,0 +1,9 @@
|
||||
{
|
||||
"$schema": "https://opencode.ai/config.schema.json",
|
||||
"mcpServers": {
|
||||
"agcanvas": {
|
||||
"command": "agcanvas-mcp",
|
||||
"args": ["--port", "9876"]
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,68 +0,0 @@
|
||||
use crate::element_tree::ElementTree;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum AgentRequest {
|
||||
GetTree,
|
||||
GetElementById {
|
||||
id: String,
|
||||
},
|
||||
GetElementsAtPoint {
|
||||
x: f32,
|
||||
y: f32,
|
||||
},
|
||||
Describe,
|
||||
GenerateCode {
|
||||
target: CodeGenTarget,
|
||||
element_id: Option<String>,
|
||||
},
|
||||
Ping,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum AgentResponse {
|
||||
Tree {
|
||||
tree: ElementTree,
|
||||
},
|
||||
Element {
|
||||
element: Option<crate::element_tree::Element>,
|
||||
},
|
||||
Elements {
|
||||
elements: Vec<crate::element_tree::Element>,
|
||||
},
|
||||
Description {
|
||||
text: String,
|
||||
},
|
||||
Code {
|
||||
code: String,
|
||||
target: CodeGenTarget,
|
||||
},
|
||||
Pong,
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CodeGenTarget {
|
||||
Html,
|
||||
React,
|
||||
Tailwind,
|
||||
Svelte,
|
||||
Vue,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CodeGenTarget {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CodeGenTarget::Html => write!(f, "html"),
|
||||
CodeGenTarget::React => write!(f, "react"),
|
||||
CodeGenTarget::Tailwind => write!(f, "tailwind"),
|
||||
CodeGenTarget::Svelte => write!(f, "svelte"),
|
||||
CodeGenTarget::Vue => write!(f, "vue"),
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -1,172 +0,0 @@
|
||||
use super::protocol::{AgentRequest, AgentResponse, CodeGenTarget};
|
||||
use crate::element_tree::ElementTree;
|
||||
use anyhow::Result;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::RwLock;
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
pub struct AgentServer {
|
||||
tree: Arc<RwLock<Option<ElementTree>>>,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl AgentServer {
|
||||
pub fn new(port: u16) -> Self {
|
||||
Self {
|
||||
tree: Arc::new(RwLock::new(None)),
|
||||
port,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn tree_handle(&self) -> Arc<RwLock<Option<ElementTree>>> {
|
||||
self.tree.clone()
|
||||
}
|
||||
|
||||
pub async fn run(&self) -> Result<()> {
|
||||
let addr = format!("127.0.0.1:{}", self.port);
|
||||
let listener = TcpListener::bind(&addr).await?;
|
||||
tracing::info!("Agent server listening on ws://{}", addr);
|
||||
|
||||
while let Ok((stream, peer)) = listener.accept().await {
|
||||
tracing::info!("Agent connected from {}", peer);
|
||||
let tree = self.tree.clone();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(stream, tree).await {
|
||||
tracing::error!("Connection error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(stream: TcpStream, tree: Arc<RwLock<Option<ElementTree>>>) -> Result<()> {
|
||||
let ws_stream = tokio_tungstenite::accept_async(stream).await?;
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
while let Some(msg) = read.next().await {
|
||||
let msg = msg?;
|
||||
if let Message::Text(text) = msg {
|
||||
let response = match serde_json::from_str::<AgentRequest>(&text) {
|
||||
Ok(request) => process_request(request, &tree).await,
|
||||
Err(e) => AgentResponse::Error {
|
||||
message: format!("Invalid request: {}", e),
|
||||
},
|
||||
};
|
||||
|
||||
let response_text = serde_json::to_string(&response)?;
|
||||
write.send(Message::Text(response_text.into())).await?;
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn process_request(
|
||||
request: AgentRequest,
|
||||
tree: &Arc<RwLock<Option<ElementTree>>>,
|
||||
) -> AgentResponse {
|
||||
let tree_guard = tree.read().await;
|
||||
|
||||
match request {
|
||||
AgentRequest::Ping => AgentResponse::Pong,
|
||||
|
||||
AgentRequest::GetTree => match tree_guard.as_ref() {
|
||||
Some(t) => AgentResponse::Tree { tree: t.clone() },
|
||||
None => AgentResponse::Error {
|
||||
message: "No SVG loaded".to_string(),
|
||||
},
|
||||
},
|
||||
|
||||
AgentRequest::GetElementById { id } => match tree_guard.as_ref() {
|
||||
Some(t) => AgentResponse::Element {
|
||||
element: t.find_by_id(&id).cloned(),
|
||||
},
|
||||
None => AgentResponse::Error {
|
||||
message: "No SVG loaded".to_string(),
|
||||
},
|
||||
},
|
||||
|
||||
AgentRequest::GetElementsAtPoint { x, y } => match tree_guard.as_ref() {
|
||||
Some(t) => AgentResponse::Elements {
|
||||
elements: t.find_at_point(x, y).into_iter().cloned().collect(),
|
||||
},
|
||||
None => AgentResponse::Error {
|
||||
message: "No SVG loaded".to_string(),
|
||||
},
|
||||
},
|
||||
|
||||
AgentRequest::Describe => match tree_guard.as_ref() {
|
||||
Some(t) => AgentResponse::Description {
|
||||
text: t.to_semantic_description(),
|
||||
},
|
||||
None => AgentResponse::Error {
|
||||
message: "No SVG loaded".to_string(),
|
||||
},
|
||||
},
|
||||
|
||||
AgentRequest::GenerateCode { target, element_id } => {
|
||||
match tree_guard.as_ref() {
|
||||
Some(t) => {
|
||||
let element = match &element_id {
|
||||
Some(id) => t.find_by_id(id),
|
||||
None => Some(&t.root),
|
||||
};
|
||||
|
||||
match element {
|
||||
Some(el) => {
|
||||
let code = generate_code_stub(el, target);
|
||||
AgentResponse::Code { code, target }
|
||||
}
|
||||
None => AgentResponse::Error {
|
||||
message: format!("Element not found: {:?}", element_id),
|
||||
},
|
||||
}
|
||||
}
|
||||
None => AgentResponse::Error {
|
||||
message: "No SVG loaded".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_code_stub(element: &crate::element_tree::Element, target: CodeGenTarget) -> String {
|
||||
let description = crate::element_tree::ElementTree {
|
||||
root: element.clone(),
|
||||
metadata: crate::element_tree::TreeMetadata {
|
||||
source: "code_gen".to_string(),
|
||||
width: element.bounds.width,
|
||||
height: element.bounds.height,
|
||||
element_count: 1,
|
||||
},
|
||||
}
|
||||
.to_semantic_description();
|
||||
|
||||
match target {
|
||||
CodeGenTarget::Html => format!(
|
||||
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<div class=\"container\">\n <!-- TODO: Implement based on structure -->\n</div>",
|
||||
element.id, description
|
||||
),
|
||||
CodeGenTarget::React => format!(
|
||||
"// Generated from SVG element: {}\n// Structure:\n// {}\n\nexport function Component() {{\n return (\n <div className=\"container\">\n {{/* TODO: Implement based on structure */}}\n </div>\n );\n}}",
|
||||
element.id,
|
||||
description.replace('\n', "\n// ")
|
||||
),
|
||||
CodeGenTarget::Tailwind => format!(
|
||||
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<div class=\"flex flex-col\">\n <!-- TODO: Implement with Tailwind classes -->\n</div>",
|
||||
element.id, description
|
||||
),
|
||||
CodeGenTarget::Svelte => format!(
|
||||
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<script>\n // Component logic\n</script>\n\n<div class=\"container\">\n <!-- TODO: Implement -->\n</div>",
|
||||
element.id, description
|
||||
),
|
||||
CodeGenTarget::Vue => format!(
|
||||
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<template>\n <div class=\"container\">\n <!-- TODO: Implement -->\n </div>\n</template>\n\n<script setup>\n// Component logic\n</script>",
|
||||
element.id, description
|
||||
),
|
||||
}
|
||||
}
|
||||
328
src/app.rs
328
src/app.rs
@@ -1,328 +0,0 @@
|
||||
use crate::agent::AgentServer;
|
||||
use crate::canvas::{CanvasInteraction, CanvasState};
|
||||
use crate::clipboard::ClipboardManager;
|
||||
use crate::element_tree::ElementTree;
|
||||
use crate::svg::{parse_svg, SvgRenderer};
|
||||
use egui::{Color32, ColorImage, TextureHandle, TextureOptions};
|
||||
use std::sync::Arc;
|
||||
use tokio::runtime::Runtime;
|
||||
use tokio::sync::RwLock;
|
||||
|
||||
const AGENT_PORT: u16 = 9876;
|
||||
|
||||
pub struct AgCanvasApp {
|
||||
canvas_state: CanvasState,
|
||||
clipboard: Option<ClipboardManager>,
|
||||
svg_renderer: Option<SvgRenderer>,
|
||||
svg_texture: Option<TextureHandle>,
|
||||
element_tree: Option<ElementTree>,
|
||||
tree_handle: Arc<RwLock<Option<ElementTree>>>,
|
||||
show_tree_panel: bool,
|
||||
show_description: bool,
|
||||
description_text: String,
|
||||
status_message: Option<(String, std::time::Instant)>,
|
||||
_runtime: Runtime,
|
||||
}
|
||||
|
||||
impl AgCanvasApp {
|
||||
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
|
||||
configure_fonts(&cc.egui_ctx);
|
||||
|
||||
let runtime = Runtime::new().expect("Failed to create tokio runtime");
|
||||
let server = AgentServer::new(AGENT_PORT);
|
||||
let tree_handle = server.tree_handle();
|
||||
|
||||
runtime.spawn(async move {
|
||||
if let Err(e) = server.run().await {
|
||||
tracing::error!("Agent server error: {}", e);
|
||||
}
|
||||
});
|
||||
|
||||
let clipboard = ClipboardManager::new().ok();
|
||||
|
||||
Self {
|
||||
canvas_state: CanvasState::default(),
|
||||
clipboard,
|
||||
svg_renderer: None,
|
||||
svg_texture: None,
|
||||
element_tree: None,
|
||||
tree_handle,
|
||||
show_tree_panel: false,
|
||||
show_description: false,
|
||||
description_text: String::new(),
|
||||
status_message: None,
|
||||
_runtime: runtime,
|
||||
}
|
||||
}
|
||||
|
||||
fn handle_paste(&mut self, ctx: &egui::Context) {
|
||||
let svg_data = self.clipboard.as_mut().and_then(|c| c.get_svg());
|
||||
|
||||
if let Some(svg_data) = svg_data {
|
||||
match parse_svg(&svg_data) {
|
||||
Ok((tree, usvg_tree)) => {
|
||||
let (width, height) = (tree.metadata.width, tree.metadata.height);
|
||||
self.element_tree = Some(tree.clone());
|
||||
self.description_text = tree.to_semantic_description();
|
||||
|
||||
let tree_handle = self.tree_handle.clone();
|
||||
let tree_clone = tree.clone();
|
||||
std::thread::spawn(move || {
|
||||
let rt = Runtime::new().unwrap();
|
||||
rt.block_on(async {
|
||||
let mut guard = tree_handle.write().await;
|
||||
*guard = Some(tree_clone);
|
||||
});
|
||||
});
|
||||
|
||||
self.svg_renderer = Some(SvgRenderer::new(usvg_tree));
|
||||
self.svg_texture = None;
|
||||
|
||||
self.canvas_state.fit_to_rect(
|
||||
egui::vec2(width, height),
|
||||
ctx.screen_rect().size() * 0.8,
|
||||
);
|
||||
|
||||
self.set_status(format!(
|
||||
"Loaded SVG: {}x{} ({} elements)",
|
||||
width as i32, height as i32, tree.metadata.element_count
|
||||
));
|
||||
}
|
||||
Err(e) => {
|
||||
self.set_status(format!("Failed to parse SVG: {}", e));
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_svg_to_texture(&mut self, ctx: &egui::Context) {
|
||||
if let Some(renderer) = &mut self.svg_renderer {
|
||||
let scale = self.canvas_state.zoom.max(1.0);
|
||||
if let Ok(pixmap) = renderer.render(scale) {
|
||||
let size = [pixmap.width() as usize, pixmap.height() as usize];
|
||||
let pixels: Vec<Color32> = pixmap
|
||||
.pixels()
|
||||
.iter()
|
||||
.map(|p| Color32::from_rgba_premultiplied(p.red(), p.green(), p.blue(), p.alpha()))
|
||||
.collect();
|
||||
|
||||
let image = ColorImage { size, pixels };
|
||||
self.svg_texture = Some(ctx.load_texture("svg", image, TextureOptions::LINEAR));
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn set_status(&mut self, message: String) {
|
||||
self.status_message = Some((message, std::time::Instant::now()));
|
||||
}
|
||||
}
|
||||
|
||||
impl eframe::App for AgCanvasApp {
|
||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||
ctx.input(|i| {
|
||||
if i.modifiers.command && i.key_pressed(egui::Key::V) {
|
||||
self.handle_paste(ctx);
|
||||
}
|
||||
});
|
||||
|
||||
if self.svg_texture.is_none() && self.svg_renderer.is_some() {
|
||||
self.render_svg_to_texture(ctx);
|
||||
}
|
||||
|
||||
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
|
||||
egui::menu::bar(ui, |ui| {
|
||||
ui.menu_button("File", |ui| {
|
||||
if ui.button("Paste SVG (Cmd+V)").clicked() {
|
||||
self.handle_paste(ctx);
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("Clear Canvas").clicked() {
|
||||
self.svg_renderer = None;
|
||||
self.svg_texture = None;
|
||||
self.element_tree = None;
|
||||
self.canvas_state.reset();
|
||||
ui.close_menu();
|
||||
}
|
||||
});
|
||||
ui.menu_button("View", |ui| {
|
||||
if ui.checkbox(&mut self.show_tree_panel, "Element Tree").clicked() {
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.checkbox(&mut self.show_description, "Description").clicked() {
|
||||
ui.close_menu();
|
||||
}
|
||||
ui.separator();
|
||||
if ui.button("Reset Zoom (Cmd+0)").clicked() {
|
||||
self.canvas_state.reset();
|
||||
ui.close_menu();
|
||||
}
|
||||
if ui.button("Fit to View").clicked() {
|
||||
if let Some(renderer) = &self.svg_renderer {
|
||||
let (w, h) = renderer.size();
|
||||
self.canvas_state
|
||||
.fit_to_rect(egui::vec2(w, h), ctx.screen_rect().size() * 0.8);
|
||||
}
|
||||
ui.close_menu();
|
||||
}
|
||||
});
|
||||
|
||||
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||
ui.label(format!("Agent: ws://127.0.0.1:{}", AGENT_PORT));
|
||||
ui.separator();
|
||||
ui.label(format!("Zoom: {:.0}%", self.canvas_state.zoom * 100.0));
|
||||
});
|
||||
});
|
||||
});
|
||||
|
||||
if let Some((msg, time)) = &self.status_message {
|
||||
if time.elapsed().as_secs() < 3 {
|
||||
egui::TopBottomPanel::bottom("status").show(ctx, |ui| {
|
||||
ui.label(msg);
|
||||
});
|
||||
} else {
|
||||
self.status_message = None;
|
||||
}
|
||||
}
|
||||
|
||||
if self.show_tree_panel {
|
||||
egui::SidePanel::right("tree_panel")
|
||||
.default_width(300.0)
|
||||
.show(ctx, |ui| {
|
||||
ui.heading("Element Tree");
|
||||
ui.separator();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
if let Some(tree) = &self.element_tree {
|
||||
render_tree_ui(ui, &tree.root, 0);
|
||||
} else {
|
||||
ui.label("No SVG loaded. Paste an SVG (Cmd+V)");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
if self.show_description {
|
||||
egui::SidePanel::left("description_panel")
|
||||
.default_width(300.0)
|
||||
.show(ctx, |ui| {
|
||||
ui.heading("Semantic Description");
|
||||
ui.separator();
|
||||
egui::ScrollArea::vertical().show(ui, |ui| {
|
||||
if !self.description_text.is_empty() {
|
||||
ui.add(
|
||||
egui::TextEdit::multiline(&mut self.description_text.as_str())
|
||||
.font(egui::TextStyle::Monospace)
|
||||
.desired_width(f32::INFINITY),
|
||||
);
|
||||
} else {
|
||||
ui.label("No SVG loaded. Paste an SVG (Cmd+V)");
|
||||
}
|
||||
});
|
||||
});
|
||||
}
|
||||
|
||||
egui::CentralPanel::default().show(ctx, |ui| {
|
||||
let response = CanvasInteraction::allocate_canvas(ui);
|
||||
CanvasInteraction::handle(ui, &response, &mut self.canvas_state);
|
||||
|
||||
let painter = ui.painter_at(response.rect);
|
||||
painter.rect_filled(response.rect, 0.0, Color32::from_gray(30));
|
||||
|
||||
draw_grid(&painter, &response.rect, &self.canvas_state);
|
||||
|
||||
if let Some(texture) = &self.svg_texture {
|
||||
let center = response.rect.center();
|
||||
let size = texture.size_vec2() / self.canvas_state.zoom.max(1.0) * self.canvas_state.zoom;
|
||||
let offset = self.canvas_state.offset * self.canvas_state.zoom;
|
||||
let rect = egui::Rect::from_center_size(center + offset, size);
|
||||
painter.image(texture.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE);
|
||||
} else {
|
||||
painter.text(
|
||||
response.rect.center(),
|
||||
egui::Align2::CENTER_CENTER,
|
||||
"Paste SVG from Figma (Cmd+V)",
|
||||
egui::FontId::proportional(24.0),
|
||||
Color32::from_gray(100),
|
||||
);
|
||||
}
|
||||
});
|
||||
|
||||
ctx.request_repaint();
|
||||
}
|
||||
}
|
||||
|
||||
fn configure_fonts(ctx: &egui::Context) {
|
||||
let mut style = (*ctx.style()).clone();
|
||||
style.visuals.window_rounding = egui::Rounding::same(8.0);
|
||||
style.visuals.panel_fill = Color32::from_gray(25);
|
||||
ctx.set_style(style);
|
||||
}
|
||||
|
||||
fn draw_grid(painter: &egui::Painter, rect: &egui::Rect, state: &CanvasState) {
|
||||
let grid_size = 50.0 * state.zoom;
|
||||
if grid_size < 10.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let color = Color32::from_gray(40);
|
||||
let offset = state.offset * state.zoom;
|
||||
let center = rect.center();
|
||||
|
||||
let start_x = ((rect.left() - center.x - offset.x) / grid_size).floor() as i32;
|
||||
let end_x = ((rect.right() - center.x - offset.x) / grid_size).ceil() as i32;
|
||||
let start_y = ((rect.top() - center.y - offset.y) / grid_size).floor() as i32;
|
||||
let end_y = ((rect.bottom() - center.y - offset.y) / grid_size).ceil() as i32;
|
||||
|
||||
for i in start_x..=end_x {
|
||||
let x = center.x + offset.x + i as f32 * grid_size;
|
||||
if x >= rect.left() && x <= rect.right() {
|
||||
painter.line_segment(
|
||||
[egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())],
|
||||
egui::Stroke::new(1.0, color),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
for i in start_y..=end_y {
|
||||
let y = center.y + offset.y + i as f32 * grid_size;
|
||||
if y >= rect.top() && y <= rect.bottom() {
|
||||
painter.line_segment(
|
||||
[egui::pos2(rect.left(), y), egui::pos2(rect.right(), y)],
|
||||
egui::Stroke::new(1.0, color),
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn render_tree_ui(ui: &mut egui::Ui, element: &crate::element_tree::Element, depth: usize) {
|
||||
let kind_name = match &element.kind {
|
||||
crate::element_tree::ElementKind::Group { name } => {
|
||||
format!("Group{}", name.as_ref().map(|n| format!(" '{}'", n)).unwrap_or_default())
|
||||
}
|
||||
crate::element_tree::ElementKind::Rectangle { .. } => "Rect".to_string(),
|
||||
crate::element_tree::ElementKind::Circle { .. } => "Circle".to_string(),
|
||||
crate::element_tree::ElementKind::Ellipse { .. } => "Ellipse".to_string(),
|
||||
crate::element_tree::ElementKind::Path { .. } => "Path".to_string(),
|
||||
crate::element_tree::ElementKind::Text { content, .. } => {
|
||||
format!("Text '{}'", if content.len() > 15 { &content[..15] } else { content })
|
||||
}
|
||||
crate::element_tree::ElementKind::Image { .. } => "Image".to_string(),
|
||||
crate::element_tree::ElementKind::Line { .. } => "Line".to_string(),
|
||||
crate::element_tree::ElementKind::Unknown { tag } => format!("<{}>", tag),
|
||||
};
|
||||
|
||||
if element.children.is_empty() {
|
||||
ui.horizontal(|ui| {
|
||||
ui.add_space(depth as f32 * 12.0);
|
||||
ui.label(format!("• {}", kind_name));
|
||||
});
|
||||
} else {
|
||||
egui::CollapsingHeader::new(kind_name)
|
||||
.id_salt(&element.id)
|
||||
.default_open(depth < 2)
|
||||
.show(ui, |ui| {
|
||||
for child in &element.children {
|
||||
render_tree_ui(ui, child, depth + 1);
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user