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 \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("