From d248864ee25b2e36daa80bcb9b9532b86c4ebed8 Mon Sep 17 00:00:00 2001 From: David Ibia Date: Sun, 8 Feb 2026 22:49:24 +0100 Subject: [PATCH] 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 --- Cargo.toml | 46 +- README.md | 289 +++-- crates/agcanvas-mcp/Cargo.toml | 23 + crates/agcanvas-mcp/src/bridge.rs | 29 + crates/agcanvas-mcp/src/main.rs | 38 + crates/agcanvas-mcp/src/tools.rs | 202 ++++ crates/agcanvas/Cargo.toml | 47 + {src => crates/agcanvas/src}/agent/mod.rs | 1 + crates/agcanvas/src/agent/protocol.rs | 126 ++ crates/agcanvas/src/agent/server.rs | 258 +++++ crates/agcanvas/src/app.rs | 1011 +++++++++++++++++ .../agcanvas/src}/canvas/interaction.rs | 0 {src => crates/agcanvas/src}/canvas/mod.rs | 0 {src => crates/agcanvas/src}/canvas/state.rs | 5 - {src => crates/agcanvas/src}/clipboard.rs | 4 - crates/agcanvas/src/drawing/element.rs | 259 +++++ crates/agcanvas/src/drawing/mod.rs | 10 + crates/agcanvas/src/drawing/render.rs | 208 ++++ crates/agcanvas/src/drawing/tool.rs | 109 ++ {src => crates/agcanvas/src}/element_tree.rs | 13 - {src => crates/agcanvas/src}/main.rs | 3 + crates/agcanvas/src/mermaid.rs | 24 + crates/agcanvas/src/session.rs | 176 +++ {src => crates/agcanvas/src}/svg/mod.rs | 0 {src => crates/agcanvas/src}/svg/parser.rs | 2 +- {src => crates/agcanvas/src}/svg/renderer.rs | 4 - examples/mcp-configs/README.md | 94 ++ examples/mcp-configs/claude-code.mcp.json | 8 + examples/mcp-configs/opencode.json | 9 + src/agent/protocol.rs | 68 -- src/agent/server.rs | 172 --- src/app.rs | 328 ------ 32 files changed, 2833 insertions(+), 733 deletions(-) create mode 100644 crates/agcanvas-mcp/Cargo.toml create mode 100644 crates/agcanvas-mcp/src/bridge.rs create mode 100644 crates/agcanvas-mcp/src/main.rs create mode 100644 crates/agcanvas-mcp/src/tools.rs create mode 100644 crates/agcanvas/Cargo.toml rename {src => crates/agcanvas/src}/agent/mod.rs (66%) create mode 100644 crates/agcanvas/src/agent/protocol.rs create mode 100644 crates/agcanvas/src/agent/server.rs create mode 100644 crates/agcanvas/src/app.rs rename {src => crates/agcanvas/src}/canvas/interaction.rs (100%) rename {src => crates/agcanvas/src}/canvas/mod.rs (100%) rename {src => crates/agcanvas/src}/canvas/state.rs (86%) rename {src => crates/agcanvas/src}/clipboard.rs (86%) create mode 100644 crates/agcanvas/src/drawing/element.rs create mode 100644 crates/agcanvas/src/drawing/mod.rs create mode 100644 crates/agcanvas/src/drawing/render.rs create mode 100644 crates/agcanvas/src/drawing/tool.rs rename {src => crates/agcanvas/src}/element_tree.rs (92%) rename {src => crates/agcanvas/src}/main.rs (95%) create mode 100644 crates/agcanvas/src/mermaid.rs create mode 100644 crates/agcanvas/src/session.rs rename {src => crates/agcanvas/src}/svg/mod.rs (100%) rename {src => crates/agcanvas/src}/svg/parser.rs (98%) rename {src => crates/agcanvas/src}/svg/renderer.rs (95%) create mode 100644 examples/mcp-configs/README.md create mode 100644 examples/mcp-configs/claude-code.mcp.json create mode 100644 examples/mcp-configs/opencode.json delete mode 100644 src/agent/protocol.rs delete mode 100644 src/agent/server.rs delete mode 100644 src/app.rs diff --git a/Cargo.toml b/Cargo.toml index 6476d68..511e2c5 100644 --- a/Cargo.toml +++ b/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 diff --git a/README.md b/README.md index 18e4be7..7c76cff 100644 --- a/README.md +++ b/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 │ │ │ -│ │ └────────┘ └───┬────┘ │ │ -│ └──────────────────┼──────────┘ │ -│ │ │ -│ AI Agent ◄───── WebSocket (JSON) ────────┘ │ -│ - Sees structure │ -│ - Describes semantically │ -│ - Generates code │ -└─────────────────────────────────────────────────────────┘ +┌──────────────────────────────────────────────────────────────┐ +│ Figma / Draw / Mermaid agcanvas │ +│ ┌─────┐ ┌─────┐ ┌─────┐ ┌──────────────────────────┐ │ +│ │ SVG │ │Shapes│ │Merm.│ │ Canvas (pan/zoom) │ │ +│ │Paste│ │ Draw │ │ Diag│ │ ┌────────┐ ┌────────┐ │ │ +│ └──┬──┘ └──┬───┘ └──┬──┘ │ │ Parsed │ │ Agent │ │ │ +│ └────────┴─────────┘ │ │ Tree │ │ Server │ │ │ +│ │ │ └────────┘ └───┬────┘ │ │ +│ ▼ └──────────────────┼──────┘ │ +│ Visual Canvas │ │ +│ │ │ +│ 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
\n {/* TODO: Implement based on structure */}\n
\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 diff --git a/crates/agcanvas-mcp/Cargo.toml b/crates/agcanvas-mcp/Cargo.toml new file mode 100644 index 0000000..023f9e7 --- /dev/null +++ b/crates/agcanvas-mcp/Cargo.toml @@ -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"] } diff --git a/crates/agcanvas-mcp/src/bridge.rs b/crates/agcanvas-mcp/src/bridge.rs new file mode 100644 index 0000000..e9c30ca --- /dev/null +++ b/crates/agcanvas-mcp/src/bridge.rs @@ -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 { + 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")), + } +} diff --git a/crates/agcanvas-mcp/src/main.rs b/crates/agcanvas-mcp/src/main.rs new file mode 100644 index 0000000..95048d7 --- /dev/null +++ b/crates/agcanvas-mcp/src/main.rs @@ -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(()) +} diff --git a/crates/agcanvas-mcp/src/tools.rs b/crates/agcanvas-mcp/src/tools.rs new file mode 100644 index 0000000..7729475 --- /dev/null +++ b/crates/agcanvas-mcp/src/tools.rs @@ -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, +} + +#[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, + #[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, + #[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, + #[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, +} + +#[derive(Debug, Clone)] +pub struct AgCanvasServer { + ws_url: String, + tool_router: ToolRouter, +} + +#[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 { + 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, + ) -> Result { + 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, + ) -> Result { + 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, + ) -> Result { + 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, + ) -> Result { + 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, + ) -> Result { + 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, + ) -> Result { + 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 { + 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() + } + } +} diff --git a/crates/agcanvas/Cargo.toml b/crates/agcanvas/Cargo.toml new file mode 100644 index 0000000..ad6323d --- /dev/null +++ b/crates/agcanvas/Cargo.toml @@ -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"] } diff --git a/src/agent/mod.rs b/crates/agcanvas/src/agent/mod.rs similarity index 66% rename from src/agent/mod.rs rename to crates/agcanvas/src/agent/mod.rs index cfd7983..cdb6e4e 100644 --- a/src/agent/mod.rs +++ b/crates/agcanvas/src/agent/mod.rs @@ -1,4 +1,5 @@ mod protocol; mod server; +pub use protocol::GuiEvent; pub use server::AgentServer; diff --git a/crates/agcanvas/src/agent/protocol.rs b/crates/agcanvas/src/agent/protocol.rs new file mode 100644 index 0000000..d1e31c3 --- /dev/null +++ b/crates/agcanvas/src/agent/protocol.rs @@ -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, + active_session: Option, + }, + 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, + }, + GetElementById { + #[serde(default)] + session_id: Option, + id: String, + }, + GetElementsAtPoint { + #[serde(default)] + session_id: Option, + x: f32, + y: f32, + }, + Describe { + #[serde(default)] + session_id: Option, + }, + GenerateCode { + #[serde(default)] + session_id: Option, + target: CodeGenTarget, + element_id: Option, + }, + GetDrawingElements { + #[serde(default)] + session_id: Option, + }, + Ping, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum AgentResponse { + Sessions { + sessions: Vec, + active_session: Option, + }, + Tree { + session_id: String, + tree: ElementTree, + }, + Element { + session_id: String, + element: Option, + }, + Elements { + session_id: String, + elements: Vec, + }, + Description { + session_id: String, + text: String, + }, + Code { + session_id: String, + code: String, + target: CodeGenTarget, + }, + DrawingElements { + session_id: String, + elements: Vec, + }, + 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"), + } + } +} diff --git a/crates/agcanvas/src/agent/server.rs b/crates/agcanvas/src/agent/server.rs new file mode 100644 index 0000000..c482997 --- /dev/null +++ b/crates/agcanvas/src/agent/server.rs @@ -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>, + event_tx: broadcast::Sender, + 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> { + self.sessions.clone() + } + + pub fn event_sender(&self) -> broadcast::Sender { + 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>, + mut event_rx: broadcast::Receiver, +) -> 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::(&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>, +) -> 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!( + "\n\n
\n \n
", + element.id, description + ), + CodeGenTarget::React => format!( + "// Generated from SVG element: {}\n// Structure:\n// {}\n\nexport function Component() {{\n return (\n
\n {{/* TODO: Implement based on structure */}}\n
\n );\n}}", + element.id, + description.replace('\n', "\n// ") + ), + CodeGenTarget::Tailwind => format!( + "\n\n
\n \n
", + element.id, description + ), + CodeGenTarget::Svelte => format!( + "\n\n\n\n
\n \n
", + element.id, description + ), + CodeGenTarget::Vue => format!( + "\n\n\n\n", + element.id, description + ), + } +} diff --git a/crates/agcanvas/src/app.rs b/crates/agcanvas/src/app.rs new file mode 100644 index 0000000..cb68693 --- /dev/null +++ b/crates/agcanvas/src/app.rs @@ -0,0 +1,1011 @@ +use crate::agent::{AgentServer, GuiEvent}; +use crate::canvas::{CanvasInteraction, CanvasState}; +use crate::clipboard::ClipboardManager; +use crate::drawing::{ + draw_creation_preview, draw_elements, draw_selection, find_handle_at_screen_pos, + screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle, Tool, +}; +use crate::mermaid::render_mermaid_to_svg; +use crate::session::{Session, SessionStore}; +use crate::svg::{parse_svg, SvgRenderer}; +use egui::{Color32, ColorImage, TextureOptions}; +use std::sync::Arc; +use tokio::runtime::Runtime; +use tokio::sync::{broadcast, RwLock}; + +const AGENT_PORT: u16 = 9876; +const MIN_SHAPE_SIZE: f32 = 5.0; + +pub struct AgCanvasApp { + sessions: Vec, + active_session_idx: usize, + session_counter: usize, + sessions_handle: Arc>, + event_tx: broadcast::Sender, + clipboard: Option, + show_tree_panel: bool, + show_description: bool, + status_message: Option<(String, std::time::Instant)>, + _runtime: Runtime, + + show_mermaid_dialog: bool, + mermaid_input: String, + show_text_input: bool, + text_input_buffer: String, + text_input_pos: Option, +} + +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 sessions_handle = server.sessions_handle(); + let event_tx = server.event_sender(); + + runtime.spawn(async move { + if let Err(e) = server.run().await { + tracing::error!("Agent server error: {}", e); + } + }); + + let clipboard = ClipboardManager::new().ok(); + + let mut app = Self { + sessions: Vec::new(), + active_session_idx: 0, + session_counter: 0, + sessions_handle, + event_tx, + clipboard, + show_tree_panel: false, + show_description: false, + status_message: None, + _runtime: runtime, + show_mermaid_dialog: false, + mermaid_input: String::new(), + show_text_input: false, + text_input_buffer: String::new(), + text_input_pos: None, + }; + + app.create_session(); + app + } + + fn create_session(&mut self) { + self.session_counter += 1; + let id = format!("session-{}", self.session_counter); + let name = format!("Tab {}", self.session_counter); + let session = Session::new(id.clone(), name); + let info = session.info(); + + self.sessions.push(session); + self.active_session_idx = self.sessions.len() - 1; + + let sessions_handle = self.sessions_handle.clone(); + let event_tx = self.event_tx.clone(); + let info_clone = info.clone(); + std::thread::spawn(move || { + let rt = Runtime::new().unwrap(); + rt.block_on(async { + let mut store = sessions_handle.write().await; + store.add_session(info_clone.clone(), None); + store.set_active(&info_clone.id); + }); + let _ = event_tx.send(GuiEvent::SessionCreated { session: info_clone }); + }); + + self.set_status("Created new session".to_string()); + } + + fn close_session(&mut self, idx: usize) { + if self.sessions.len() <= 1 { + self.set_status("Cannot close last session".to_string()); + return; + } + + let session_id = self.sessions[idx].id.clone(); + self.sessions.remove(idx); + + if self.active_session_idx >= self.sessions.len() { + self.active_session_idx = self.sessions.len() - 1; + } + + let new_active_id = self.sessions[self.active_session_idx].id.clone(); + let sessions_handle = self.sessions_handle.clone(); + let event_tx = self.event_tx.clone(); + std::thread::spawn(move || { + let rt = Runtime::new().unwrap(); + rt.block_on(async { + let mut store = sessions_handle.write().await; + store.remove_session(&session_id); + store.set_active(&new_active_id); + }); + let _ = event_tx.send(GuiEvent::SessionClosed { session_id }); + }); + } + + fn switch_session(&mut self, idx: usize) { + if idx < self.sessions.len() && idx != self.active_session_idx { + self.active_session_idx = idx; + let session_id = self.sessions[idx].id.clone(); + + let sessions_handle = self.sessions_handle.clone(); + let event_tx = self.event_tx.clone(); + let sid = session_id.clone(); + std::thread::spawn(move || { + let rt = Runtime::new().unwrap(); + rt.block_on(async { + let mut store = sessions_handle.write().await; + store.set_active(&sid); + }); + let _ = event_tx.send(GuiEvent::SessionActivated { session_id: sid }); + }); + } + } + + fn active_session(&self) -> &Session { + &self.sessions[self.active_session_idx] + } + + fn active_session_mut(&mut self) -> &mut Session { + &mut self.sessions[self.active_session_idx] + } + + 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 { + self.load_svg_data(&svg_data, ctx); + } + } + + fn load_svg_data(&mut self, svg_data: &str, ctx: &egui::Context) { + match parse_svg(svg_data) { + Ok((tree, usvg_tree)) => { + let (width, height) = (tree.metadata.width, tree.metadata.height); + let session = self.active_session_mut(); + session.element_tree = Some(tree.clone()); + session.description_text = tree.to_semantic_description(); + session.svg_renderer = Some(SvgRenderer::new(usvg_tree)); + session.svg_texture = None; + session.canvas_state.fit_to_rect( + egui::vec2(width, height), + ctx.screen_rect().size() * 0.8, + ); + + let session_id = session.id.clone(); + let metadata = tree.metadata.clone(); + let sessions_handle = self.sessions_handle.clone(); + let event_tx = self.event_tx.clone(); + let tree_clone = tree.clone(); + let sid = session_id.clone(); + std::thread::spawn(move || { + let rt = Runtime::new().unwrap(); + rt.block_on(async { + let mut store = sessions_handle.write().await; + store.update_tree(&sid, Some(tree_clone)); + }); + let _ = event_tx.send(GuiEvent::SvgLoaded { + session_id, + metadata, + }); + }); + + 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 clear_canvas(&mut self) { + let session = self.active_session_mut(); + let session_id = session.id.clone(); + session.clear(); + + let sessions_handle = self.sessions_handle.clone(); + let event_tx = self.event_tx.clone(); + let sid = session_id.clone(); + std::thread::spawn(move || { + let rt = Runtime::new().unwrap(); + rt.block_on(async { + let mut store = sessions_handle.write().await; + store.update_tree(&sid, None); + }); + let _ = event_tx.send(GuiEvent::SvgCleared { session_id }); + }); + } + + fn render_svg_to_texture(&mut self, ctx: &egui::Context) { + let session = self.active_session_mut(); + if let Some(renderer) = &mut session.svg_renderer { + let scale = session.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 = pixmap + .pixels() + .iter() + .map(|p| Color32::from_rgba_premultiplied(p.red(), p.green(), p.blue(), p.alpha())) + .collect(); + + let image = ColorImage { size, pixels }; + session.svg_texture = Some(ctx.load_texture( + format!("svg-{}", session.id), + image, + TextureOptions::LINEAR, + )); + } + } + } + + fn set_status(&mut self, message: String) { + self.status_message = Some((message, std::time::Instant::now())); + } + + fn handle_mermaid_render(&mut self, ctx: &egui::Context) { + let source = self.mermaid_input.trim().to_string(); + if source.is_empty() { + self.set_status("Mermaid input is empty".to_string()); + return; + } + + match render_mermaid_to_svg(&source) { + Ok(svg_string) => { + self.show_mermaid_dialog = false; + self.mermaid_input.clear(); + self.load_svg_data(&svg_string, ctx); + self.set_status("Rendered Mermaid diagram".to_string()); + } + Err(e) => { + self.set_status(format!("Mermaid error: {}", e)); + } + } + } + + fn handle_drawing_input(&mut self, response: &egui::Response, canvas_center: egui::Pos2) { + let session = self.active_session_mut(); + let offset = session.canvas_state.offset; + let zoom = session.canvas_state.zoom; + let pointer_pos = response.interact_pointer_pos(); + + match session.active_tool { + Tool::Select => { + handle_select_tool(session, response, canvas_center, offset, zoom, pointer_pos); + } + Tool::Rectangle | Tool::Ellipse | Tool::Line | Tool::Arrow => { + handle_shape_tool(session, response, canvas_center, offset, zoom, pointer_pos); + } + Tool::Text => { + if response.clicked() { + if let Some(screen_pos) = pointer_pos { + let canvas_pos = screen_to_canvas(screen_pos, canvas_center, offset, zoom); + self.text_input_pos = Some(canvas_pos); + self.text_input_buffer.clear(); + self.show_text_input = true; + } + } + } + } + } +} + +fn handle_select_tool( + session: &mut Session, + response: &egui::Response, + canvas_center: egui::Pos2, + offset: egui::Vec2, + zoom: f32, + pointer_pos: Option, +) { + if response.drag_started() { + if let Some(screen_pos) = pointer_pos { + if let Some(selected_el) = session.selected_element() { + if let Some(handle) = + find_handle_at_screen_pos(selected_el, screen_pos, canvas_center, offset, zoom) + { + let original_rect = selected_el.bounding_rect(); + let eid = selected_el.id.clone(); + session.drag_state = DragState::Resizing { + handle, + element_id: eid, + original_rect, + }; + return; + } + } + + let canvas_pos = screen_to_canvas(screen_pos, canvas_center, offset, zoom); + let hit = session + .drawing_elements + .iter() + .rev() + .find(|e| e.contains_point(canvas_pos)); + + if let Some(el) = hit { + let eid = el.id.clone(); + session.selected_element_id = Some(eid.clone()); + session.drag_state = DragState::Moving { + element_id: eid, + }; + } else { + session.selected_element_id = None; + session.drag_state = DragState::None; + } + } + } + + if response.dragged() { + let delta_screen = response.drag_delta(); + let delta_canvas = delta_screen / zoom; + + match &session.drag_state { + DragState::Moving { element_id, .. } => { + let eid = element_id.clone(); + if let Some(el) = session.drawing_elements.iter_mut().find(|e| e.id == eid) { + el.translate(delta_canvas); + } + } + DragState::Resizing { + handle, + element_id, + original_rect, + } => { + let handle = *handle; + let eid = element_id.clone(); + let orig = *original_rect; + + if let Some(screen_pos) = pointer_pos { + let canvas_pos = screen_to_canvas(screen_pos, canvas_center, offset, zoom); + let start_pos = handle.position_in_rect(orig); + let delta = canvas_pos - start_pos; + let new_rect = handle.apply_delta(orig, delta); + + if let Some(el) = session.drawing_elements.iter_mut().find(|e| e.id == eid) { + el.resize_to(new_rect); + } + } + } + _ => { + session.canvas_state.pan(delta_screen); + } + } + } + + if response.drag_stopped() { + session.drag_state = DragState::None; + } + + if response.clicked() && !response.dragged() { + if let Some(screen_pos) = pointer_pos { + let canvas_pos = screen_to_canvas(screen_pos, canvas_center, offset, zoom); + let hit = session + .drawing_elements + .iter() + .rev() + .find(|e| e.contains_point(canvas_pos)); + + session.selected_element_id = hit.map(|e| e.id.clone()); + } + } +} + +fn handle_shape_tool( + session: &mut Session, + response: &egui::Response, + canvas_center: egui::Pos2, + offset: egui::Vec2, + zoom: f32, + pointer_pos: Option, +) { + if response.drag_started() { + if let Some(screen_pos) = pointer_pos { + let canvas_pos = screen_to_canvas(screen_pos, canvas_center, offset, zoom); + session.drag_state = DragState::Creating { + start: canvas_pos, + current: canvas_pos, + }; + } + } + + if response.dragged() { + if let DragState::Creating { current, .. } = &mut session.drag_state { + if let Some(screen_pos) = pointer_pos { + *current = screen_to_canvas(screen_pos, canvas_center, offset, zoom); + } + } + } + + if response.drag_stopped() { + if let DragState::Creating { start, current } = &session.drag_state { + let start = *start; + let current = *current; + let dx = (current.x - start.x).abs(); + let dy = (current.y - start.y).abs(); + + if dx > MIN_SHAPE_SIZE || dy > MIN_SHAPE_SIZE { + let shape = match session.active_tool { + Tool::Rectangle => Shape::Rectangle { + pos: egui::pos2(start.x.min(current.x), start.y.min(current.y)), + size: egui::vec2(dx, dy), + }, + Tool::Ellipse => { + let rect = egui::Rect::from_two_pos(start, current); + Shape::Ellipse { + center: rect.center(), + radii: egui::vec2(rect.width() / 2.0, rect.height() / 2.0), + } + } + Tool::Line => Shape::Line { + start, + end: current, + }, + Tool::Arrow => Shape::Arrow { + start, + end: current, + }, + _ => unreachable!(), + }; + + let element = DrawingElement::new(shape, ShapeStyle::default()); + session.selected_element_id = Some(element.id.clone()); + session.drawing_elements.push(element); + } + } + session.drag_state = DragState::None; + } +} + +impl eframe::App for AgCanvasApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + let mut paste = false; + let mut new_tab = false; + let mut close_tab = false; + let mut delete_selected = false; + let mut tool_switch: Option = None; + + ctx.input(|i| { + if i.modifiers.command && i.key_pressed(egui::Key::V) { + paste = true; + } + if i.modifiers.command && i.key_pressed(egui::Key::T) { + new_tab = true; + } + if i.modifiers.command && i.key_pressed(egui::Key::W) { + close_tab = true; + } + if i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace) { + delete_selected = true; + } + if i.key_pressed(egui::Key::Escape) { + tool_switch = Some(Tool::Select); + } + if !i.modifiers.command { + if i.key_pressed(egui::Key::V) { + tool_switch = Some(Tool::Select); + } + if i.key_pressed(egui::Key::R) { + tool_switch = Some(Tool::Rectangle); + } + if i.key_pressed(egui::Key::E) { + tool_switch = Some(Tool::Ellipse); + } + if i.key_pressed(egui::Key::L) { + tool_switch = Some(Tool::Line); + } + if i.key_pressed(egui::Key::A) { + tool_switch = Some(Tool::Arrow); + } + } + }); + + if paste { + self.handle_paste(ctx); + } + if new_tab { + self.create_session(); + } + if close_tab { + let idx = self.active_session_idx; + self.close_session(idx); + } + if delete_selected && !self.show_text_input && !self.show_mermaid_dialog { + self.active_session_mut().delete_selected(); + } + if let Some(tool) = tool_switch { + if !self.show_text_input && !self.show_mermaid_dialog { + self.active_session_mut().active_tool = tool; + } + } + + if self.active_session().svg_texture.is_none() + && self.active_session().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("New Tab (Cmd+T)").clicked() { + self.create_session(); + ui.close_menu(); + } + if ui.button("Close Tab (Cmd+W)").clicked() { + let idx = self.active_session_idx; + self.close_session(idx); + ui.close_menu(); + } + ui.separator(); + if ui.button("Paste SVG (Cmd+V)").clicked() { + self.handle_paste(ctx); + ui.close_menu(); + } + if ui.button("Paste Mermaid...").clicked() { + self.show_mermaid_dialog = true; + ui.close_menu(); + } + ui.separator(); + if ui.button("Clear Canvas").clicked() { + self.clear_canvas(); + 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.active_session_mut().canvas_state.reset(); + ui.close_menu(); + } + if ui.button("Fit to View").clicked() { + let session = self.active_session_mut(); + if let Some(renderer) = &session.svg_renderer { + let (w, h) = renderer.size(); + session + .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.active_session().canvas_state.zoom * 100.0 + )); + }); + }); + }); + + egui::TopBottomPanel::top("tab_bar").show(ctx, |ui| { + ui.horizontal(|ui| { + let mut close_idx: Option = None; + let mut switch_idx: Option = None; + + for (idx, session) in self.sessions.iter().enumerate() { + let is_active = idx == self.active_session_idx; + let has_content = + session.element_tree.is_some() || !session.drawing_elements.is_empty(); + let label = if has_content { + format!("{} *", session.name) + } else { + session.name.clone() + }; + + let button = egui::Button::new(&label).fill(if is_active { + Color32::from_gray(50) + } else { + Color32::from_gray(35) + }); + + let tab_response = ui.add(button); + if tab_response.clicked() { + switch_idx = Some(idx); + } + + if self.sessions.len() > 1 { + tab_response.context_menu(|ui| { + if ui.button("Close").clicked() { + close_idx = Some(idx); + ui.close_menu(); + } + }); + } + } + + if let Some(idx) = switch_idx { + self.switch_session(idx); + } + if let Some(idx) = close_idx { + self.close_session(idx); + } + + ui.separator(); + if ui.button("+").clicked() { + self.create_session(); + } + }); + }); + + egui::TopBottomPanel::top("toolbar").show(ctx, |ui| { + ui.horizontal(|ui| { + let active_tool = self.active_session().active_tool; + let tools = [ + Tool::Select, + Tool::Rectangle, + Tool::Ellipse, + Tool::Line, + Tool::Arrow, + Tool::Text, + ]; + + for tool in tools { + let label = match tool.shortcut() { + Some(key) => format!("{} ({})", tool.label(), key), + None => tool.label().to_string(), + }; + if ui.selectable_label(active_tool == tool, &label).clicked() { + self.active_session_mut().active_tool = tool; + } + } + + ui.separator(); + + let drawing_count = self.active_session().drawing_elements.len(); + if drawing_count > 0 { + ui.label(format!("{} shapes", drawing_count)); + } + }); + }); + + 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.active_session().element_tree { + render_tree_ui(ui, &tree.root, 0); + } else { + ui.label("No SVG loaded. Paste an SVG (Cmd+V)"); + } + }); + }); + } + + if self.show_description { + let desc = self.active_session().description_text.clone(); + 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 !desc.is_empty() { + ui.add( + egui::TextEdit::multiline(&mut desc.as_str()) + .font(egui::TextStyle::Monospace) + .desired_width(f32::INFINITY), + ); + } else { + ui.label("No SVG loaded. Paste an SVG (Cmd+V)"); + } + }); + }); + } + + // Mermaid dialog + if self.show_mermaid_dialog { + let mut open = true; + let mut should_render = false; + + egui::Window::new("Paste Mermaid Diagram") + .open(&mut open) + .default_width(500.0) + .default_height(300.0) + .resizable(true) + .show(ctx, |ui| { + ui.label("Enter Mermaid diagram syntax:"); + ui.add_space(4.0); + egui::ScrollArea::vertical() + .max_height(250.0) + .show(ui, |ui| { + ui.add( + egui::TextEdit::multiline(&mut self.mermaid_input) + .font(egui::TextStyle::Monospace) + .desired_width(f32::INFINITY) + .desired_rows(12) + .hint_text("flowchart LR\n A[Start] --> B{Decision}\n B -->|Yes| C[OK]\n B -->|No| D[End]"), + ); + }); + ui.add_space(4.0); + ui.horizontal(|ui| { + if ui.button("Render").clicked() { + should_render = true; + } + if ui.button("Cancel").clicked() { + self.show_mermaid_dialog = false; + self.mermaid_input.clear(); + } + }); + }); + + if !open { + self.show_mermaid_dialog = false; + } + if should_render { + self.handle_mermaid_render(ctx); + } + } + + // Text input dialog + if self.show_text_input { + let mut should_create = false; + let mut should_cancel = false; + + egui::Window::new("Add Text") + .default_width(300.0) + .collapsible(false) + .show(ctx, |ui| { + ui.label("Enter text:"); + let text_response = ui.add( + egui::TextEdit::singleline(&mut self.text_input_buffer) + .desired_width(f32::INFINITY), + ); + text_response.request_focus(); + + if ui.input(|i| i.key_pressed(egui::Key::Enter)) { + should_create = true; + } + ui.horizontal(|ui| { + if ui.button("Add").clicked() { + should_create = true; + } + if ui.button("Cancel").clicked() { + should_cancel = true; + } + }); + }); + + if should_create && !self.text_input_buffer.is_empty() { + if let Some(pos) = self.text_input_pos { + let element = DrawingElement::new( + Shape::Text { + pos, + content: self.text_input_buffer.clone(), + font_size: 20.0, + }, + ShapeStyle { + fill: None, + stroke_color: Color32::WHITE, + stroke_width: 1.0, + }, + ); + let eid = element.id.clone(); + self.active_session_mut().drawing_elements.push(element); + self.active_session_mut().selected_element_id = Some(eid); + } + self.show_text_input = false; + self.text_input_buffer.clear(); + self.text_input_pos = None; + } + if should_cancel { + self.show_text_input = false; + self.text_input_buffer.clear(); + self.text_input_pos = None; + } + } + + // Central canvas + egui::CentralPanel::default().show(ctx, |ui| { + let response = CanvasInteraction::allocate_canvas(ui); + let canvas_center = response.rect.center(); + + let active_tool = self.active_session().active_tool; + let is_select_idle = active_tool == Tool::Select + && matches!(self.active_session().drag_state, DragState::None); + + if is_select_idle { + CanvasInteraction::handle( + ui, + &response, + &mut self.active_session_mut().canvas_state, + ); + } else if active_tool != Tool::Select { + if response.hovered() { + let scroll_delta = ui.input(|i| i.smooth_scroll_delta.y); + if scroll_delta != 0.0 { + let zoom_factor = 1.0 + scroll_delta * 0.001; + if let Some(pointer_pos) = response.hover_pos() { + self.active_session_mut().canvas_state.zoom_at( + pointer_pos, + canvas_center, + zoom_factor, + ); + } + } + } + if response.dragged_by(egui::PointerButton::Middle) { + self.active_session_mut() + .canvas_state + .pan(response.drag_delta()); + } + } + + if !self.show_mermaid_dialog && !self.show_text_input { + self.handle_drawing_input(&response, canvas_center); + } + + let painter = ui.painter_at(response.rect); + painter.rect_filled(response.rect, 0.0, Color32::from_gray(30)); + + draw_grid( + &painter, + &response.rect, + &self.active_session().canvas_state, + ); + + if let Some(texture) = &self.active_session().svg_texture { + let canvas_state = &self.active_session().canvas_state; + let center = response.rect.center(); + let size = texture.size_vec2() / canvas_state.zoom.max(1.0) * canvas_state.zoom; + let offset = canvas_state.offset * 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, + ); + } + + let offset = self.active_session().canvas_state.offset; + let zoom = self.active_session().canvas_state.zoom; + + draw_elements( + &painter, + &self.active_session().drawing_elements, + canvas_center, + offset, + zoom, + ); + + if let Some(selected_el) = self.active_session().selected_element() { + draw_selection(&painter, selected_el, canvas_center, offset, zoom); + } + + let drag_state = self.active_session().drag_state.clone(); + draw_creation_preview( + &painter, + self.active_session().active_tool, + &drag_state, + canvas_center, + offset, + zoom, + ); + + if self.active_session().svg_texture.is_none() + && self.active_session().drawing_elements.is_empty() + { + painter.text( + response.rect.center(), + egui::Align2::CENTER_CENTER, + "Paste SVG (Cmd+V) or start drawing", + 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); + } + }); + } +} diff --git a/src/canvas/interaction.rs b/crates/agcanvas/src/canvas/interaction.rs similarity index 100% rename from src/canvas/interaction.rs rename to crates/agcanvas/src/canvas/interaction.rs diff --git a/src/canvas/mod.rs b/crates/agcanvas/src/canvas/mod.rs similarity index 100% rename from src/canvas/mod.rs rename to crates/agcanvas/src/canvas/mod.rs diff --git a/src/canvas/state.rs b/crates/agcanvas/src/canvas/state.rs similarity index 86% rename from src/canvas/state.rs rename to crates/agcanvas/src/canvas/state.rs index 749540c..87ed07b 100644 --- a/src/canvas/state.rs +++ b/crates/agcanvas/src/canvas/state.rs @@ -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); diff --git a/src/clipboard.rs b/crates/agcanvas/src/clipboard.rs similarity index 86% rename from src/clipboard.rs rename to crates/agcanvas/src/clipboard.rs index f68d9e8..bcc5b49 100644 --- a/src/clipboard.rs +++ b/crates/agcanvas/src/clipboard.rs @@ -20,10 +20,6 @@ impl ClipboardManager { None } } - - pub fn get_text(&mut self) -> Option { - self.clipboard.get_text().ok() - } } fn is_svg_content(text: &str) -> bool { diff --git a/crates/agcanvas/src/drawing/element.rs b/crates/agcanvas/src/drawing/element.rs new file mode 100644 index 0000000..404ad11 --- /dev/null +++ b/crates/agcanvas/src/drawing/element.rs @@ -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, + 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); + } +} diff --git a/crates/agcanvas/src/drawing/mod.rs b/crates/agcanvas/src/drawing/mod.rs new file mode 100644 index 0000000..6a94bea --- /dev/null +++ b/crates/agcanvas/src/drawing/mod.rs @@ -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}; diff --git a/crates/agcanvas/src/drawing/render.rs b/crates/agcanvas/src/drawing/render.rs new file mode 100644 index 0000000..f209674 --- /dev/null +++ b/crates/agcanvas/src/drawing/render.rs @@ -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 = (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 = (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 { + 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 +} diff --git a/crates/agcanvas/src/drawing/tool.rs b/crates/agcanvas/src/drawing/tool.rs new file mode 100644 index 0000000..0586997 --- /dev/null +++ b/crates/agcanvas/src/drawing/tool.rs @@ -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 { + 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)) + } +} diff --git a/src/element_tree.rs b/crates/agcanvas/src/element_tree.rs similarity index 92% rename from src/element_tree.rs rename to crates/agcanvas/src/element_tree.rs index c59e225..d448441 100644 --- a/src/element_tree.rs +++ b/crates/agcanvas/src/element_tree.rs @@ -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(); diff --git a/src/main.rs b/crates/agcanvas/src/main.rs similarity index 95% rename from src/main.rs rename to crates/agcanvas/src/main.rs index c66dc07..8bd4041 100644 --- a/src/main.rs +++ b/crates/agcanvas/src/main.rs @@ -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}; diff --git a/crates/agcanvas/src/mermaid.rs b/crates/agcanvas/src/mermaid.rs new file mode 100644 index 0000000..dd476f1 --- /dev/null +++ b/crates/agcanvas/src/mermaid.rs @@ -0,0 +1,24 @@ +use anyhow::Result; + +pub fn render_mermaid_to_svg(mermaid_source: &str) -> Result { + 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(">Bob: Hello").unwrap(); + assert!(svg.contains(", + pub drawing_element_count: usize, +} + +pub struct Session { + pub id: String, + pub name: String, + pub canvas_state: CanvasState, + pub svg_renderer: Option, + pub svg_texture: Option, + pub element_tree: Option, + pub description_text: String, + + pub drawing_elements: Vec, + pub selected_element_id: Option, + 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, + pub drawing_elements: Vec, +} + +#[derive(Default)] +pub struct SessionStore { + sessions: HashMap, + active_session_id: Option, +} + +impl SessionStore { + pub fn new() -> Self { + Self::default() + } + + pub fn add_session(&mut self, info: SessionInfo, tree: Option) { + 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) { + 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) { + 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 { + self.sessions.values().map(|d| d.info.clone()).collect() + } + + pub fn active_session_id(&self) -> Option<&str> { + self.active_session_id.as_deref() + } +} diff --git a/src/svg/mod.rs b/crates/agcanvas/src/svg/mod.rs similarity index 100% rename from src/svg/mod.rs rename to crates/agcanvas/src/svg/mod.rs diff --git a/src/svg/parser.rs b/crates/agcanvas/src/svg/parser.rs similarity index 98% rename from src/svg/parser.rs rename to crates/agcanvas/src/svg/parser.rs index b1defb4..d8eab16 100644 --- a/src/svg/parser.rs +++ b/crates/agcanvas/src/svg/parser.rs @@ -37,7 +37,7 @@ fn parse_group(group: &Group) -> Element { height: bbox.height(), }; - let children: Vec = group.children().iter().map(|c| parse_node(c)).collect(); + let children: Vec = group.children().iter().map(parse_node).collect(); let id_str = group.id(); let name = if id_str.is_empty() { diff --git a/src/svg/renderer.rs b/crates/agcanvas/src/svg/renderer.rs similarity index 95% rename from src/svg/renderer.rs rename to crates/agcanvas/src/svg/renderer.rs index ce7f03c..fb8163b 100644 --- a/src/svg/renderer.rs +++ b/crates/agcanvas/src/svg/renderer.rs @@ -46,8 +46,4 @@ impl SvgRenderer { let size = self.tree.size(); (size.width(), size.height()) } - - pub fn tree(&self) -> &Tree { - &self.tree - } } diff --git a/examples/mcp-configs/README.md b/examples/mcp-configs/README.md new file mode 100644 index 0000000..f438e29 --- /dev/null +++ b/examples/mcp-configs/README.md @@ -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) | diff --git a/examples/mcp-configs/claude-code.mcp.json b/examples/mcp-configs/claude-code.mcp.json new file mode 100644 index 0000000..329c9cb --- /dev/null +++ b/examples/mcp-configs/claude-code.mcp.json @@ -0,0 +1,8 @@ +{ + "mcpServers": { + "agcanvas": { + "command": "agcanvas-mcp", + "args": ["--port", "9876"] + } + } +} diff --git a/examples/mcp-configs/opencode.json b/examples/mcp-configs/opencode.json new file mode 100644 index 0000000..a997bc3 --- /dev/null +++ b/examples/mcp-configs/opencode.json @@ -0,0 +1,9 @@ +{ + "$schema": "https://opencode.ai/config.schema.json", + "mcpServers": { + "agcanvas": { + "command": "agcanvas-mcp", + "args": ["--port", "9876"] + } + } +} diff --git a/src/agent/protocol.rs b/src/agent/protocol.rs deleted file mode 100644 index 09bca61..0000000 --- a/src/agent/protocol.rs +++ /dev/null @@ -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, - }, - Ping, -} - -#[derive(Debug, Clone, Serialize, Deserialize)] -#[serde(tag = "type")] -pub enum AgentResponse { - Tree { - tree: ElementTree, - }, - Element { - element: Option, - }, - Elements { - elements: Vec, - }, - 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"), - } - } -} diff --git a/src/agent/server.rs b/src/agent/server.rs deleted file mode 100644 index ea725d1..0000000 --- a/src/agent/server.rs +++ /dev/null @@ -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>>, - port: u16, -} - -impl AgentServer { - pub fn new(port: u16) -> Self { - Self { - tree: Arc::new(RwLock::new(None)), - port, - } - } - - pub fn tree_handle(&self) -> Arc>> { - 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>>) -> 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::(&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>>, -) -> 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!( - "\n\n
\n \n
", - element.id, description - ), - CodeGenTarget::React => format!( - "// Generated from SVG element: {}\n// Structure:\n// {}\n\nexport function Component() {{\n return (\n
\n {{/* TODO: Implement based on structure */}}\n
\n );\n}}", - element.id, - description.replace('\n', "\n// ") - ), - CodeGenTarget::Tailwind => format!( - "\n\n
\n \n
", - element.id, description - ), - CodeGenTarget::Svelte => format!( - "\n\n\n\n
\n \n
", - element.id, description - ), - CodeGenTarget::Vue => format!( - "\n\n\n\n", - element.id, description - ), - } -} diff --git a/src/app.rs b/src/app.rs deleted file mode 100644 index 3fa0652..0000000 --- a/src/app.rs +++ /dev/null @@ -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, - svg_renderer: Option, - svg_texture: Option, - element_tree: Option, - tree_handle: Arc>>, - 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 = 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); - } - }); - } -}