Add drawing tools, Mermaid support, and MCP server bridge

- Convert flat project to Cargo workspace (crates/agcanvas, crates/agcanvas-mcp)
- Add drawing layer: rectangles, ellipses, lines, arrows, text with select/move/resize
- Add Mermaid diagram rendering via mermaid-rs-renderer
- Add agcanvas-mcp: MCP server bridge for Claude Code, OpenCode, and Codex
- Add toolbar UI with keyboard shortcuts (V/R/E/L/A/T) and shape interaction
- Add example MCP configs for Claude Code, OpenCode, and Codex
- Update README with full feature docs, MCP setup, and updated architecture
This commit is contained in:
David Ibia
2026-02-08 22:49:24 +01:00
parent 732e205943
commit d248864ee2
32 changed files with 2833 additions and 733 deletions

View File

@@ -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

289
README.md
View File

@@ -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 <div className=\"container\">\n {/* TODO: Implement based on structure */}\n </div>\n );\n}",
"target": "react"
}
```
#### Query elements at point
```json
{"type": "GetElementsAtPoint", "x": 150.0, "y": 200.0}
```
#### Get element by ID
```json
{"type": "GetElementById", "id": "button-primary"}
```
#### Ping
```json
{"type": "Ping"}
```
### Events (GUI → Agent)
agcanvas pushes events to all connected agents when state changes:
| Event | Trigger |
|-------|---------|
| `Connected` | Agent connects (includes current session state) |
| `SessionCreated` | New tab created |
| `SessionClosed` | Tab closed |
| `SessionActivated` | User switches tabs |
| `SvgLoaded` | SVG pasted or loaded |
| `SvgCleared` | Canvas cleared |
### Element types
```rust
@@ -190,20 +265,33 @@ enum ElementKind {
## Architecture
```
src/
├── main.rs # Entry point, window setup
├── app.rs # Main application state, UI rendering
├── element_tree.rs # Structured element representation
├── clipboard.rs # System clipboard integration
├── canvas/
├── state.rs # Pan/zoom transformation state
└── interaction.rs # Mouse/keyboard input handling
├── svg/
├── parser.rs # SVG → ElementTree conversion
└── renderer.rs # SVG → pixels (resvg/tiny-skia)
└── agent/
├── protocol.rs # JSON message types
└── server.rs # WebSocket server
crates/
├── agcanvas/ # Desktop app
│ └── src/
│ ├── main.rs # Entry point, window setup
│ ├── app.rs # Main app state, UI, toolbar, drawing interaction
│ ├── session.rs # Session/tab state management
├── element_tree.rs # Structured element representation
├── clipboard.rs # System clipboard integration
│ ├── mermaid.rs # Mermaid → SVG rendering
├── drawing/
│ ├── element.rs # DrawingElement, Shape, ShapeStyle, hit testing
│ │ ├── tool.rs # Tool enum, DragState, ResizeHandle
│ └── render.rs # Shape rendering via egui Painter
├── canvas/
│ │ ├── state.rs # Pan/zoom transformation state
│ │ └── interaction.rs # Mouse/keyboard input handling
│ ├── svg/
│ │ ├── parser.rs # SVG → ElementTree conversion
│ │ └── renderer.rs # SVG → pixels (resvg/tiny-skia)
│ └── agent/
│ ├── protocol.rs # JSON message types
│ └── server.rs # WebSocket server
└── agcanvas-mcp/ # MCP server bridge
└── src/
├── main.rs # CLI entry point, stdio transport
├── bridge.rs # WebSocket client → agcanvas
└── tools.rs # MCP tool definitions
```
### Dependencies
@@ -213,19 +301,26 @@ src/
| `eframe`/`egui` | GUI framework |
| `usvg` | SVG parsing |
| `resvg`/`tiny-skia` | SVG rendering |
| `mermaid-rs-renderer` | Mermaid → SVG |
| `arboard` | Clipboard access |
| `tokio-tungstenite` | WebSocket server |
| `tokio-tungstenite` | WebSocket (both server and client) |
| `rmcp` | MCP server SDK (Anthropic official) |
| `serde`/`serde_json` | Serialization |
## Roadmap
- [x] Multi-frame support (sessions/tabs)
- [x] Shape drawing (rectangle, ellipse, line, arrow, text)
- [x] Selection, move, resize with handles
- [x] Mermaid diagram rendering
- [x] MCP server bridge for AI coding tools
- [ ] Real code generation (not just stubs)
- [ ] Element selection on canvas
- [ ] Agent draw commands (modify canvas from agent)
- [ ] Multi-frame support
- [ ] Export to file
- [ ] Diff view (before/after agent changes)
- [ ] Plugin system for code generators
- [ ] Undo/redo
- [ ] Multi-select and group operations
## License

View File

@@ -0,0 +1,23 @@
[package]
name = "agcanvas-mcp"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "MCP server bridge for agcanvas — exposes canvas tools to Claude Code, OpenCode, and Codex"
[[bin]]
name = "agcanvas-mcp"
path = "src/main.rs"
[dependencies]
rmcp = { version = "0.14", features = ["server", "macros", "transport-io"] }
tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "io-std", "sync"] }
tokio-tungstenite = "0.24"
futures-util = "0.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
schemars = "1.0"
anyhow = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
clap = { version = "4.0", features = ["derive"] }

View File

@@ -0,0 +1,29 @@
use anyhow::Result;
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message;
pub async fn send_request(ws_url: &str, request_json: &str) -> Result<String> {
let (ws_stream, _) = tokio_tungstenite::connect_async(ws_url)
.await
.map_err(|e| {
anyhow::anyhow!(
"Cannot connect to agcanvas at {}. Is agcanvas running? Error: {}",
ws_url,
e
)
})?;
let (mut write, mut read) = ws_stream.split();
// Skip the initial Connected event
let _connected = read.next().await;
write.send(Message::Text(request_json.to_string())).await?;
match read.next().await {
Some(Ok(Message::Text(response))) => Ok(response),
Some(Ok(other)) => Err(anyhow::anyhow!("Unexpected message type: {:?}", other)),
Some(Err(e)) => Err(anyhow::anyhow!("WebSocket error: {}", e)),
None => Err(anyhow::anyhow!("Connection closed before response")),
}
}

View File

@@ -0,0 +1,38 @@
mod bridge;
mod tools;
use anyhow::Result;
use clap::Parser;
use rmcp::ServiceExt;
use tools::AgCanvasServer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[derive(Parser)]
#[command(name = "agcanvas-mcp", about = "MCP server bridge for agcanvas")]
struct Cli {
#[arg(long, default_value = "9876")]
port: u16,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG").unwrap_or_else(|_| "agcanvas_mcp=info".into()),
))
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
.init();
let cli = Cli::parse();
let ws_url = format!("ws://127.0.0.1:{}", cli.port);
tracing::info!("Starting agcanvas MCP server, connecting to {}", ws_url);
let server = AgCanvasServer::new(ws_url);
let service = server.serve(rmcp::transport::stdio()).await?;
tracing::info!("MCP server running on stdio");
service.waiting().await?;
Ok(())
}

View File

@@ -0,0 +1,202 @@
use crate::bridge::send_request;
use rmcp::{
ErrorData as McpError, ServerHandler,
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::*,
schemars, tool, tool_handler, tool_router,
};
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct SessionIdParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct GetElementParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "Element ID to look up")]
pub id: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct GetElementsAtPointParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "X coordinate in canvas space")]
pub x: f32,
#[schemars(description = "Y coordinate in canvas space")]
pub y: f32,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct GenerateCodeParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "Code generation target: html, react, tailwind, svelte, or vue")]
pub target: String,
#[schemars(description = "Element ID to generate code for. If omitted, generates for the entire tree.")]
pub element_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct AgCanvasServer {
ws_url: String,
tool_router: ToolRouter<Self>,
}
#[tool_router]
impl AgCanvasServer {
pub fn new(ws_url: String) -> Self {
Self {
ws_url,
tool_router: Self::tool_router(),
}
}
#[tool(description = "List all open sessions/tabs in agcanvas. Returns session IDs, names, and whether they have SVG or drawing content loaded.")]
async fn list_sessions(&self) -> Result<CallToolResult, McpError> {
let request = serde_json::json!({"type": "ListSessions"});
self.call_agcanvas(&request).await
}
#[tool(description = "Get the full parsed SVG element tree from the canvas. Returns a hierarchical JSON structure with element types (Group, Rectangle, Circle, Path, Text, etc.), bounds, styles, and children. This is the primary way to understand what design is on the canvas.")]
async fn get_element_tree(
&self,
Parameters(params): Parameters<SessionIdParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({"type": "GetTree"});
if let Some(sid) = params.session_id {
request["session_id"] = serde_json::Value::String(sid);
}
self.call_agcanvas(&request).await
}
#[tool(description = "Get a human-readable semantic description of the canvas content. Returns structured text describing the element hierarchy with types, dimensions, and colors. Useful for quickly understanding a design without parsing JSON.")]
async fn describe_canvas(
&self,
Parameters(params): Parameters<SessionIdParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({"type": "Describe"});
if let Some(sid) = params.session_id {
request["session_id"] = serde_json::Value::String(sid);
}
self.call_agcanvas(&request).await
}
#[tool(description = "Get a specific element by its ID from the SVG element tree.")]
async fn get_element_by_id(
&self,
Parameters(params): Parameters<GetElementParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({"type": "GetElementById", "id": params.id});
if let Some(sid) = params.session_id {
request["session_id"] = serde_json::Value::String(sid);
}
self.call_agcanvas(&request).await
}
#[tool(description = "Query which elements exist at a specific (x, y) coordinate on the canvas.")]
async fn get_elements_at_point(
&self,
Parameters(params): Parameters<GetElementsAtPointParam>,
) -> Result<CallToolResult, McpError> {
let mut request =
serde_json::json!({"type": "GetElementsAtPoint", "x": params.x, "y": params.y});
if let Some(sid) = params.session_id {
request["session_id"] = serde_json::Value::String(sid);
}
self.call_agcanvas(&request).await
}
#[tool(description = "Get all user-drawn shapes (rectangles, ellipses, lines, arrows, text) from the drawing layer.")]
async fn get_drawing_elements(
&self,
Parameters(params): Parameters<SessionIdParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({"type": "GetDrawingElements"});
if let Some(sid) = params.session_id {
request["session_id"] = serde_json::Value::String(sid);
}
self.call_agcanvas(&request).await
}
#[tool(description = "Generate a code stub from the SVG structure. Targets: html, react, tailwind, svelte, vue. Returns a starting template based on the element hierarchy.")]
async fn generate_code(
&self,
Parameters(params): Parameters<GenerateCodeParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({
"type": "GenerateCode",
"target": params.target,
"element_id": params.element_id,
});
if let Some(sid) = params.session_id {
request["session_id"] = serde_json::Value::String(sid);
}
self.call_agcanvas(&request).await
}
}
impl AgCanvasServer {
async fn call_agcanvas(
&self,
request: &serde_json::Value,
) -> Result<CallToolResult, McpError> {
let request_str = serde_json::to_string(request)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
match send_request(&self.ws_url, &request_str).await {
Ok(response) => {
let parsed: serde_json::Value = serde_json::from_str(&response)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let is_error = parsed
.get("type")
.and_then(serde_json::Value::as_str)
== Some("Error");
if is_error {
let msg = parsed
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("Unknown error from agcanvas");
return Ok(CallToolResult::error(vec![Content::text(msg)]));
}
let pretty = serde_json::to_string_pretty(&parsed)
.unwrap_or_else(|_| response.clone());
Ok(CallToolResult::success(vec![Content::text(pretty)]))
}
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
"Failed to communicate with agcanvas: {}. Make sure agcanvas is running.",
e
))])),
}
}
}
#[tool_handler]
impl ServerHandler for AgCanvasServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
instructions: Some(
"agcanvas MCP server — connects to agcanvas desktop app to query SVG designs, \
element trees, and user-drawn shapes. Use describe_canvas to understand the \
current design, get_element_tree for structured data, and generate_code for \
code stubs. Requires agcanvas to be running."
.into(),
),
capabilities: ServerCapabilities::builder().enable_tools().build(),
server_info: Implementation {
name: "agcanvas-mcp".into(),
version: env!("CARGO_PKG_VERSION").into(),
title: None,
icons: None,
website_url: None,
},
..Default::default()
}
}
}

View File

@@ -0,0 +1,47 @@
[package]
name = "agcanvas"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Interactive canvas for agent-human collaboration with SVG support"
[dependencies]
# GUI
eframe = { version = "0.29", default-features = false, features = [
"default_fonts",
"glow",
"persistence",
] }
egui = "0.29"
egui_extras = { version = "0.29", features = ["image"] }
# SVG parsing and rendering
usvg = "0.44"
resvg = "0.44"
tiny-skia = "0.11"
# Clipboard
arboard = "3.4"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Agent communication
tokio = { version = "1.0", features = ["rt-multi-thread", "sync", "macros"] }
tokio-tungstenite = "0.24"
futures-util = "0.3"
# Mermaid diagram rendering
mermaid-rs-renderer = { version = "0.1.2", default-features = false }
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Error handling
anyhow = "1.0"
thiserror = "1.0"
# Image handling
image = { version = "0.25", default-features = false, features = ["png"] }

View File

@@ -1,4 +1,5 @@
mod protocol;
mod server;
pub use protocol::GuiEvent;
pub use server::AgentServer;

View File

@@ -0,0 +1,126 @@
use crate::drawing::DrawingElement;
use crate::element_tree::{ElementTree, TreeMetadata};
use crate::session::SessionInfo;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum GuiEvent {
Connected {
version: String,
sessions: Vec<SessionInfo>,
active_session: Option<String>,
},
SessionCreated {
session: SessionInfo,
},
SessionClosed {
session_id: String,
},
SessionActivated {
session_id: String,
},
SvgLoaded {
session_id: String,
metadata: TreeMetadata,
},
SvgCleared {
session_id: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum AgentRequest {
ListSessions,
GetTree {
#[serde(default)]
session_id: Option<String>,
},
GetElementById {
#[serde(default)]
session_id: Option<String>,
id: String,
},
GetElementsAtPoint {
#[serde(default)]
session_id: Option<String>,
x: f32,
y: f32,
},
Describe {
#[serde(default)]
session_id: Option<String>,
},
GenerateCode {
#[serde(default)]
session_id: Option<String>,
target: CodeGenTarget,
element_id: Option<String>,
},
GetDrawingElements {
#[serde(default)]
session_id: Option<String>,
},
Ping,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum AgentResponse {
Sessions {
sessions: Vec<SessionInfo>,
active_session: Option<String>,
},
Tree {
session_id: String,
tree: ElementTree,
},
Element {
session_id: String,
element: Option<crate::element_tree::Element>,
},
Elements {
session_id: String,
elements: Vec<crate::element_tree::Element>,
},
Description {
session_id: String,
text: String,
},
Code {
session_id: String,
code: String,
target: CodeGenTarget,
},
DrawingElements {
session_id: String,
elements: Vec<DrawingElement>,
},
Pong,
Error {
message: String,
},
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CodeGenTarget {
Html,
React,
Tailwind,
Svelte,
Vue,
}
impl std::fmt::Display for CodeGenTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CodeGenTarget::Html => write!(f, "html"),
CodeGenTarget::React => write!(f, "react"),
CodeGenTarget::Tailwind => write!(f, "tailwind"),
CodeGenTarget::Svelte => write!(f, "svelte"),
CodeGenTarget::Vue => write!(f, "vue"),
}
}
}

View File

@@ -0,0 +1,258 @@
use super::protocol::{AgentRequest, AgentResponse, CodeGenTarget, GuiEvent};
use crate::session::SessionStore;
use anyhow::Result;
use futures_util::{SinkExt, StreamExt};
use std::sync::Arc;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{broadcast, RwLock};
use tokio_tungstenite::tungstenite::Message;
const EVENT_CHANNEL_CAPACITY: usize = 64;
pub struct AgentServer {
sessions: Arc<RwLock<SessionStore>>,
event_tx: broadcast::Sender<GuiEvent>,
port: u16,
}
impl AgentServer {
pub fn new(port: u16) -> Self {
let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
Self {
sessions: Arc::new(RwLock::new(SessionStore::new())),
event_tx,
port,
}
}
pub fn sessions_handle(&self) -> Arc<RwLock<SessionStore>> {
self.sessions.clone()
}
pub fn event_sender(&self) -> broadcast::Sender<GuiEvent> {
self.event_tx.clone()
}
pub async fn run(&self) -> Result<()> {
let addr = format!("127.0.0.1:{}", self.port);
let listener = TcpListener::bind(&addr).await?;
tracing::info!("Agent server listening on ws://{}", addr);
while let Ok((stream, peer)) = listener.accept().await {
tracing::info!("Agent connected from {}", peer);
let sessions = self.sessions.clone();
let event_rx = self.event_tx.subscribe();
tokio::spawn(async move {
if let Err(e) = handle_connection(stream, sessions, event_rx).await {
tracing::error!("Connection error: {}", e);
}
});
}
Ok(())
}
}
async fn handle_connection(
stream: TcpStream,
sessions: Arc<RwLock<SessionStore>>,
mut event_rx: broadcast::Receiver<GuiEvent>,
) -> Result<()> {
let ws_stream = tokio_tungstenite::accept_async(stream).await?;
let (mut write, mut read) = ws_stream.split();
let connected_event = {
let store = sessions.read().await;
GuiEvent::Connected {
version: env!("CARGO_PKG_VERSION").to_string(),
sessions: store.list_sessions(),
active_session: store.active_session_id().map(|s| s.to_string()),
}
};
let connected_json = serde_json::to_string(&connected_event)?;
write.send(Message::Text(connected_json)).await?;
loop {
tokio::select! {
msg = read.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
let response = match serde_json::from_str::<AgentRequest>(&text) {
Ok(request) => process_request(request, &sessions).await,
Err(e) => AgentResponse::Error {
message: format!("Invalid request: {}", e),
},
};
let response_text = serde_json::to_string(&response)?;
write.send(Message::Text(response_text)).await?;
}
Some(Ok(Message::Close(_))) | None => break,
Some(Err(e)) => {
tracing::warn!("WebSocket error: {}", e);
break;
}
_ => {}
}
}
event = event_rx.recv() => {
match event {
Ok(gui_event) => {
let event_json = serde_json::to_string(&gui_event)?;
if write.send(Message::Text(event_json)).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!("Agent lagged, skipped {} events", n);
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
}
}
Ok(())
}
async fn process_request(
request: AgentRequest,
sessions: &Arc<RwLock<SessionStore>>,
) -> AgentResponse {
let store = sessions.read().await;
match request {
AgentRequest::Ping => AgentResponse::Pong,
AgentRequest::ListSessions => AgentResponse::Sessions {
sessions: store.list_sessions(),
active_session: store.active_session_id().map(|s| s.to_string()),
},
AgentRequest::GetTree { session_id } => {
match store.get_tree(session_id.as_deref()) {
Some((sid, tree)) => AgentResponse::Tree {
session_id: sid,
tree: tree.clone(),
},
None => AgentResponse::Error {
message: session_id
.map(|id| format!("Session '{}' not found or has no SVG", id))
.unwrap_or_else(|| "No active session or no SVG loaded".to_string()),
},
}
}
AgentRequest::GetElementById { session_id, id } => {
match store.get_tree(session_id.as_deref()) {
Some((sid, tree)) => AgentResponse::Element {
session_id: sid,
element: tree.find_by_id(&id).cloned(),
},
None => AgentResponse::Error {
message: "No session or SVG loaded".to_string(),
},
}
}
AgentRequest::GetElementsAtPoint { session_id, x, y } => {
match store.get_tree(session_id.as_deref()) {
Some((sid, tree)) => AgentResponse::Elements {
session_id: sid,
elements: tree.find_at_point(x, y).into_iter().cloned().collect(),
},
None => AgentResponse::Error {
message: "No session or SVG loaded".to_string(),
},
}
}
AgentRequest::Describe { session_id } => {
match store.get_tree(session_id.as_deref()) {
Some((sid, tree)) => AgentResponse::Description {
session_id: sid,
text: tree.to_semantic_description(),
},
None => AgentResponse::Error {
message: "No session or SVG loaded".to_string(),
},
}
}
AgentRequest::GetDrawingElements { session_id } => {
match store.get_drawing_elements(session_id.as_deref()) {
Some((sid, elements)) => AgentResponse::DrawingElements {
session_id: sid,
elements: elements.to_vec(),
},
None => AgentResponse::Error {
message: "No session found".to_string(),
},
}
}
AgentRequest::GenerateCode { session_id, target, element_id } => {
match store.get_tree(session_id.as_deref()) {
Some((sid, tree)) => {
let element = match &element_id {
Some(id) => tree.find_by_id(id),
None => Some(&tree.root),
};
match element {
Some(el) => {
let code = generate_code_stub(el, target);
AgentResponse::Code {
session_id: sid,
code,
target,
}
}
None => AgentResponse::Error {
message: format!("Element not found: {:?}", element_id),
},
}
}
None => AgentResponse::Error {
message: "No session or SVG loaded".to_string(),
},
}
}
}
}
fn generate_code_stub(element: &crate::element_tree::Element, target: CodeGenTarget) -> String {
let description = crate::element_tree::ElementTree {
root: element.clone(),
metadata: crate::element_tree::TreeMetadata {
source: "code_gen".to_string(),
width: element.bounds.width,
height: element.bounds.height,
element_count: 1,
},
}
.to_semantic_description();
match target {
CodeGenTarget::Html => format!(
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<div class=\"container\">\n <!-- TODO: Implement based on structure -->\n</div>",
element.id, description
),
CodeGenTarget::React => format!(
"// Generated from SVG element: {}\n// Structure:\n// {}\n\nexport function Component() {{\n return (\n <div className=\"container\">\n {{/* TODO: Implement based on structure */}}\n </div>\n );\n}}",
element.id,
description.replace('\n', "\n// ")
),
CodeGenTarget::Tailwind => format!(
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<div class=\"flex flex-col\">\n <!-- TODO: Implement with Tailwind classes -->\n</div>",
element.id, description
),
CodeGenTarget::Svelte => format!(
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<script>\n // Component logic\n</script>\n\n<div class=\"container\">\n <!-- TODO: Implement -->\n</div>",
element.id, description
),
CodeGenTarget::Vue => format!(
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<template>\n <div class=\"container\">\n <!-- TODO: Implement -->\n </div>\n</template>\n\n<script setup>\n// Component logic\n</script>",
element.id, description
),
}
}

1011
crates/agcanvas/src/app.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -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);

View File

@@ -20,10 +20,6 @@ impl ClipboardManager {
None
}
}
pub fn get_text(&mut self) -> Option<String> {
self.clipboard.get_text().ok()
}
}
fn is_svg_content(text: &str) -> bool {

View File

@@ -0,0 +1,259 @@
use egui::{Color32, Pos2};
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicUsize, Ordering};
static DRAWING_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
pub fn generate_drawing_id() -> String {
format!("draw_{}", DRAWING_ID_COUNTER.fetch_add(1, Ordering::SeqCst))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DrawingElement {
pub id: String,
pub shape: Shape,
pub style: ShapeStyle,
}
impl DrawingElement {
pub fn new(shape: Shape, style: ShapeStyle) -> Self {
Self {
id: generate_drawing_id(),
shape,
style,
}
}
/// Axis-aligned bounding box in canvas coordinates.
pub fn bounding_rect(&self) -> egui::Rect {
match &self.shape {
Shape::Rectangle { pos, size } => egui::Rect::from_min_size(*pos, *size),
Shape::Ellipse { center, radii } => {
egui::Rect::from_center_size(*center, egui::vec2(radii.x * 2.0, radii.y * 2.0))
}
Shape::Line { start, end } | Shape::Arrow { start, end } => {
egui::Rect::from_two_pos(*start, *end)
}
Shape::Text {
pos,
content: _,
font_size,
} => {
// Approximate: we'll refine during rendering when we know actual text size.
let approx_width = 8.0 * font_size * 0.6;
let approx_height = *font_size * 1.4;
egui::Rect::from_min_size(*pos, egui::vec2(approx_width, approx_height))
}
}
}
/// Hit-test: does the point (in canvas coords) touch this element?
pub fn contains_point(&self, point: Pos2) -> bool {
let tolerance = 6.0; // px tolerance for thin shapes like lines
match &self.shape {
Shape::Rectangle { pos, size } => {
let rect = egui::Rect::from_min_size(*pos, *size);
rect.expand(tolerance).contains(point)
}
Shape::Ellipse { center, radii } => {
let dx = point.x - center.x;
let dy = point.y - center.y;
let rx = radii.x + tolerance;
let ry = radii.y + tolerance;
(dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1.0
}
Shape::Line { start, end } | Shape::Arrow { start, end } => {
point_to_segment_distance(point, *start, *end) <= tolerance
}
Shape::Text {
pos,
content: _,
font_size,
} => {
let approx_width = 8.0 * font_size * 0.6;
let approx_height = *font_size * 1.4;
let rect = egui::Rect::from_min_size(*pos, egui::vec2(approx_width, approx_height));
rect.expand(tolerance).contains(point)
}
}
}
/// Translate the element by a delta vector.
pub fn translate(&mut self, delta: egui::Vec2) {
match &mut self.shape {
Shape::Rectangle { pos, .. } => *pos += delta,
Shape::Ellipse { center, .. } => *center += delta,
Shape::Line { start, end } | Shape::Arrow { start, end } => {
*start += delta;
*end += delta;
}
Shape::Text { pos, .. } => *pos += delta,
}
}
/// Resize to fit a new bounding rect, preserving shape semantics.
pub fn resize_to(&mut self, new_rect: egui::Rect) {
match &mut self.shape {
Shape::Rectangle { pos, size } => {
*pos = new_rect.min;
*size = new_rect.size();
}
Shape::Ellipse { center, radii } => {
*center = new_rect.center();
*radii = egui::vec2(new_rect.width() / 2.0, new_rect.height() / 2.0);
}
Shape::Line { start, end } | Shape::Arrow { start, end } => {
*start = new_rect.min;
*end = new_rect.max;
}
Shape::Text { pos, .. } => {
*pos = new_rect.min;
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Shape {
Rectangle {
pos: Pos2,
size: egui::Vec2,
},
Ellipse {
center: Pos2,
radii: egui::Vec2,
},
Line {
start: Pos2,
end: Pos2,
},
Arrow {
start: Pos2,
end: Pos2,
},
Text {
pos: Pos2,
content: String,
font_size: f32,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShapeStyle {
pub fill: Option<Color32>,
pub stroke_color: Color32,
pub stroke_width: f32,
}
impl Default for ShapeStyle {
fn default() -> Self {
Self {
fill: None,
stroke_color: Color32::WHITE,
stroke_width: 2.0,
}
}
}
/// Distance from point `p` to segment `a``b`.
fn point_to_segment_distance(p: Pos2, a: Pos2, b: Pos2) -> f32 {
let ab = b - a;
let ap = p - a;
let ab_len_sq = ab.length_sq();
if ab_len_sq < 1e-6 {
return ap.length();
}
let t = (ap.x * ab.x + ap.y * ab.y) / ab_len_sq;
let t = t.clamp(0.0, 1.0);
let closest = a + ab * t;
(p - closest).length()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rectangle_contains_point_inside() {
let el = DrawingElement::new(
Shape::Rectangle {
pos: Pos2::new(10.0, 10.0),
size: egui::vec2(100.0, 50.0),
},
ShapeStyle::default(),
);
assert!(el.contains_point(Pos2::new(50.0, 30.0)));
}
#[test]
fn rectangle_does_not_contain_distant_point() {
let el = DrawingElement::new(
Shape::Rectangle {
pos: Pos2::new(10.0, 10.0),
size: egui::vec2(100.0, 50.0),
},
ShapeStyle::default(),
);
assert!(!el.contains_point(Pos2::new(500.0, 500.0)));
}
#[test]
fn line_hit_test_near_segment() {
let el = DrawingElement::new(
Shape::Line {
start: Pos2::new(0.0, 0.0),
end: Pos2::new(100.0, 0.0),
},
ShapeStyle::default(),
);
// Point 3px above the line — within tolerance
assert!(el.contains_point(Pos2::new(50.0, 3.0)));
// Point 20px above — outside tolerance
assert!(!el.contains_point(Pos2::new(50.0, 20.0)));
}
#[test]
fn ellipse_contains_center() {
let el = DrawingElement::new(
Shape::Ellipse {
center: Pos2::new(50.0, 50.0),
radii: egui::vec2(30.0, 20.0),
},
ShapeStyle::default(),
);
assert!(el.contains_point(Pos2::new(50.0, 50.0)));
}
#[test]
fn translate_moves_element() {
let mut el = DrawingElement::new(
Shape::Rectangle {
pos: Pos2::new(10.0, 10.0),
size: egui::vec2(100.0, 50.0),
},
ShapeStyle::default(),
);
el.translate(egui::vec2(5.0, 5.0));
match &el.shape {
Shape::Rectangle { pos, .. } => {
assert!((pos.x - 15.0).abs() < 0.01);
assert!((pos.y - 15.0).abs() < 0.01);
}
_ => panic!("Expected Rectangle"),
}
}
#[test]
fn point_to_segment_distance_perpendicular() {
let dist = super::point_to_segment_distance(
Pos2::new(5.0, 5.0),
Pos2::new(0.0, 0.0),
Pos2::new(10.0, 0.0),
);
assert!((dist - 5.0).abs() < 0.01);
}
}

View File

@@ -0,0 +1,10 @@
mod element;
mod render;
mod tool;
pub use element::{DrawingElement, Shape, ShapeStyle};
pub use render::{
draw_creation_preview, draw_elements, draw_selection, find_handle_at_screen_pos,
screen_to_canvas,
};
pub use tool::{DragState, Tool};

View File

@@ -0,0 +1,208 @@
use super::element::DrawingElement;
use super::element::Shape;
use super::tool::{DragState, ResizeHandle, Tool};
use egui::{Color32, Painter, Pos2, Stroke, Vec2};
const HANDLE_RADIUS: f32 = 4.0;
const SELECTION_COLOR: Color32 = Color32::from_rgb(59, 130, 246);
const CREATION_PREVIEW_COLOR: Color32 = Color32::from_rgba_premultiplied(59, 130, 246, 128);
pub fn draw_elements(
painter: &Painter,
elements: &[DrawingElement],
canvas_center: Pos2,
offset: Vec2,
zoom: f32,
) {
for element in elements {
draw_element(painter, element, canvas_center, offset, zoom);
}
}
pub fn draw_element(
painter: &Painter,
element: &DrawingElement,
canvas_center: Pos2,
offset: Vec2,
zoom: f32,
) {
let style = &element.style;
let stroke = Stroke::new(style.stroke_width * zoom, style.stroke_color);
match &element.shape {
Shape::Rectangle { pos, size } => {
let screen_pos = canvas_to_screen(*pos, canvas_center, offset, zoom);
let screen_size = *size * zoom;
let rect = egui::Rect::from_min_size(screen_pos, screen_size);
if let Some(fill) = style.fill {
painter.rect_filled(rect, 0.0, fill);
}
painter.rect_stroke(rect, 0.0, stroke);
}
Shape::Ellipse { center, radii } => {
let screen_center = canvas_to_screen(*center, canvas_center, offset, zoom);
let screen_radii = *radii * zoom;
if let Some(fill) = style.fill {
painter.circle_filled(screen_center, screen_radii.x.min(screen_radii.y), fill);
}
let n_points = 64;
let points: Vec<Pos2> = (0..=n_points)
.map(|i| {
let angle = i as f32 / n_points as f32 * std::f32::consts::TAU;
Pos2::new(
screen_center.x + screen_radii.x * angle.cos(),
screen_center.y + screen_radii.y * angle.sin(),
)
})
.collect();
painter.add(egui::Shape::line(points, stroke));
}
Shape::Line { start, end } => {
let s = canvas_to_screen(*start, canvas_center, offset, zoom);
let e = canvas_to_screen(*end, canvas_center, offset, zoom);
painter.line_segment([s, e], stroke);
}
Shape::Arrow { start, end } => {
let s = canvas_to_screen(*start, canvas_center, offset, zoom);
let e = canvas_to_screen(*end, canvas_center, offset, zoom);
painter.line_segment([s, e], stroke);
draw_arrowhead(painter, s, e, stroke);
}
Shape::Text {
pos,
content,
font_size,
} => {
let screen_pos = canvas_to_screen(*pos, canvas_center, offset, zoom);
let scaled_font_size = font_size * zoom;
painter.text(
screen_pos,
egui::Align2::LEFT_TOP,
content,
egui::FontId::proportional(scaled_font_size),
style.stroke_color,
);
}
}
}
pub fn draw_selection(
painter: &Painter,
element: &DrawingElement,
canvas_center: Pos2,
offset: Vec2,
zoom: f32,
) {
let rect = element.bounding_rect();
let screen_min = canvas_to_screen(rect.min, canvas_center, offset, zoom);
let screen_max = canvas_to_screen(rect.max, canvas_center, offset, zoom);
let screen_rect = egui::Rect::from_min_max(screen_min, screen_max);
painter.rect_stroke(
screen_rect.expand(2.0),
0.0,
Stroke::new(1.0, SELECTION_COLOR),
);
for handle in ResizeHandle::ALL {
let pos = handle.position_in_rect(screen_rect);
painter.circle_filled(pos, HANDLE_RADIUS, SELECTION_COLOR);
painter.circle_stroke(pos, HANDLE_RADIUS, Stroke::new(1.0, Color32::WHITE));
}
}
pub fn draw_creation_preview(
painter: &Painter,
tool: Tool,
drag_state: &DragState,
canvas_center: Pos2,
offset: Vec2,
zoom: f32,
) {
if let DragState::Creating { start, current } = drag_state {
let s = canvas_to_screen(*start, canvas_center, offset, zoom);
let c = canvas_to_screen(*current, canvas_center, offset, zoom);
let preview_stroke = Stroke::new(2.0, CREATION_PREVIEW_COLOR);
match tool {
Tool::Rectangle => {
let rect = egui::Rect::from_two_pos(s, c);
painter.rect_stroke(rect, 0.0, preview_stroke);
}
Tool::Ellipse => {
let rect = egui::Rect::from_two_pos(s, c);
let center = rect.center();
let rx = rect.width() / 2.0;
let ry = rect.height() / 2.0;
let n_points = 64;
let points: Vec<Pos2> = (0..=n_points)
.map(|i| {
let angle = i as f32 / n_points as f32 * std::f32::consts::TAU;
Pos2::new(center.x + rx * angle.cos(), center.y + ry * angle.sin())
})
.collect();
painter.add(egui::Shape::line(points, preview_stroke));
}
Tool::Line => {
painter.line_segment([s, c], preview_stroke);
}
Tool::Arrow => {
painter.line_segment([s, c], preview_stroke);
draw_arrowhead(painter, s, c, preview_stroke);
}
_ => {}
}
}
}
fn draw_arrowhead(painter: &Painter, from: Pos2, to: Pos2, stroke: Stroke) {
let dir = to - from;
let len = dir.length();
if len < 1.0 {
return;
}
let dir_norm = dir / len;
let perp = Vec2::new(-dir_norm.y, dir_norm.x);
let arrow_size = 12.0;
let left = to - dir_norm * arrow_size + perp * (arrow_size * 0.4);
let right = to - dir_norm * arrow_size - perp * (arrow_size * 0.4);
painter.line_segment([left, to], stroke);
painter.line_segment([right, to], stroke);
}
pub fn canvas_to_screen(canvas_pos: Pos2, canvas_center: Pos2, offset: Vec2, zoom: f32) -> Pos2 {
canvas_center + (canvas_pos.to_vec2() + offset) * zoom
}
pub fn screen_to_canvas(screen_pos: Pos2, canvas_center: Pos2, offset: Vec2, zoom: f32) -> Pos2 {
let relative = screen_pos - canvas_center;
(relative / zoom - offset).to_pos2()
}
pub fn find_handle_at_screen_pos(
element: &DrawingElement,
screen_pos: Pos2,
canvas_center: Pos2,
offset: Vec2,
zoom: f32,
) -> Option<ResizeHandle> {
let rect = element.bounding_rect();
let screen_min = canvas_to_screen(rect.min, canvas_center, offset, zoom);
let screen_max = canvas_to_screen(rect.max, canvas_center, offset, zoom);
let screen_rect = egui::Rect::from_min_max(screen_min, screen_max);
for handle in ResizeHandle::ALL {
let handle_pos = handle.position_in_rect(screen_rect);
if (screen_pos - handle_pos).length() <= HANDLE_RADIUS + 4.0 {
return Some(handle);
}
}
None
}

View File

@@ -0,0 +1,109 @@
use egui::Pos2;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Tool {
#[default]
Select,
Rectangle,
Ellipse,
Line,
Arrow,
Text,
}
impl Tool {
pub fn label(&self) -> &'static str {
match self {
Tool::Select => "Select",
Tool::Rectangle => "Rect",
Tool::Ellipse => "Ellipse",
Tool::Line => "Line",
Tool::Arrow => "Arrow",
Tool::Text => "Text",
}
}
pub fn shortcut(&self) -> Option<char> {
match self {
Tool::Select => Some('V'),
Tool::Rectangle => Some('R'),
Tool::Ellipse => Some('E'),
Tool::Line => Some('L'),
Tool::Arrow => Some('A'),
Tool::Text => Some('T'),
}
}
}
#[derive(Debug, Clone, Default)]
pub enum DragState {
#[default]
None,
Creating {
start: Pos2,
current: Pos2,
},
Moving {
element_id: String,
},
Resizing {
handle: ResizeHandle,
element_id: String,
original_rect: egui::Rect,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResizeHandle {
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
impl ResizeHandle {
pub const ALL: [ResizeHandle; 4] = [
ResizeHandle::TopLeft,
ResizeHandle::TopRight,
ResizeHandle::BottomLeft,
ResizeHandle::BottomRight,
];
pub fn position_in_rect(&self, rect: egui::Rect) -> Pos2 {
match self {
ResizeHandle::TopLeft => rect.left_top(),
ResizeHandle::TopRight => rect.right_top(),
ResizeHandle::BottomLeft => rect.left_bottom(),
ResizeHandle::BottomRight => rect.right_bottom(),
}
}
pub fn apply_delta(&self, original: egui::Rect, delta: egui::Vec2) -> egui::Rect {
let mut r = original;
match self {
ResizeHandle::TopLeft => {
r.min.x += delta.x;
r.min.y += delta.y;
}
ResizeHandle::TopRight => {
r.max.x += delta.x;
r.min.y += delta.y;
}
ResizeHandle::BottomLeft => {
r.min.x += delta.x;
r.max.y += delta.y;
}
ResizeHandle::BottomRight => {
r.max.x += delta.x;
r.max.y += delta.y;
}
}
// Normalize so min < max
let min_x = r.min.x.min(r.max.x);
let max_x = r.min.x.max(r.max.x);
let min_y = r.min.y.min(r.max.y);
let max_y = r.min.y.max(r.max.y);
egui::Rect::from_min_max(Pos2::new(min_x, min_y), Pos2::new(max_x, max_y))
}
}

View File

@@ -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();

View File

@@ -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};

View File

@@ -0,0 +1,24 @@
use anyhow::Result;
pub fn render_mermaid_to_svg(mermaid_source: &str) -> Result<String> {
let svg = mermaid_rs_renderer::render(mermaid_source)
.map_err(|e| anyhow::anyhow!("Mermaid render failed: {}", e))?;
Ok(svg)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_simple_flowchart() {
let svg = render_mermaid_to_svg("flowchart LR\n A-->B").unwrap();
assert!(svg.contains("<svg"));
}
#[test]
fn renders_sequence_diagram() {
let svg = render_mermaid_to_svg("sequenceDiagram\n Alice->>Bob: Hello").unwrap();
assert!(svg.contains("<svg"));
}
}

View File

@@ -0,0 +1,176 @@
use crate::canvas::CanvasState;
use crate::drawing::{DragState, DrawingElement, Tool};
use crate::element_tree::ElementTree;
use crate::svg::SvgRenderer;
use egui::TextureHandle;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionInfo {
pub id: String,
pub name: String,
pub has_svg: bool,
pub element_count: Option<usize>,
pub drawing_element_count: usize,
}
pub struct Session {
pub id: String,
pub name: String,
pub canvas_state: CanvasState,
pub svg_renderer: Option<SvgRenderer>,
pub svg_texture: Option<TextureHandle>,
pub element_tree: Option<ElementTree>,
pub description_text: String,
pub drawing_elements: Vec<DrawingElement>,
pub selected_element_id: Option<String>,
pub active_tool: Tool,
pub drag_state: DragState,
}
impl Session {
pub fn new(id: String, name: String) -> Self {
Self {
id,
name,
canvas_state: CanvasState::default(),
svg_renderer: None,
svg_texture: None,
element_tree: None,
description_text: String::new(),
drawing_elements: Vec::new(),
selected_element_id: None,
active_tool: Tool::default(),
drag_state: DragState::default(),
}
}
pub fn info(&self) -> SessionInfo {
SessionInfo {
id: self.id.clone(),
name: self.name.clone(),
has_svg: self.element_tree.is_some(),
element_count: self.element_tree.as_ref().map(|t| t.metadata.element_count),
drawing_element_count: self.drawing_elements.len(),
}
}
pub fn clear(&mut self) {
self.svg_renderer = None;
self.svg_texture = None;
self.element_tree = None;
self.description_text.clear();
self.drawing_elements.clear();
self.selected_element_id = None;
self.drag_state = DragState::default();
self.canvas_state.reset();
}
pub fn selected_element(&self) -> Option<&DrawingElement> {
self.selected_element_id
.as_ref()
.and_then(|id| self.drawing_elements.iter().find(|e| e.id == *id))
}
pub fn selected_element_mut(&mut self) -> Option<&mut DrawingElement> {
let id = self.selected_element_id.clone();
id.and_then(move |id| self.drawing_elements.iter_mut().find(|e| e.id == id))
}
pub fn delete_selected(&mut self) {
if let Some(id) = self.selected_element_id.take() {
self.drawing_elements.retain(|e| e.id != id);
}
}
}
#[derive(Debug, Clone)]
pub struct SessionData {
pub info: SessionInfo,
pub tree: Option<ElementTree>,
pub drawing_elements: Vec<DrawingElement>,
}
#[derive(Default)]
pub struct SessionStore {
sessions: HashMap<String, SessionData>,
active_session_id: Option<String>,
}
impl SessionStore {
pub fn new() -> Self {
Self::default()
}
pub fn add_session(&mut self, info: SessionInfo, tree: Option<ElementTree>) {
let id = info.id.clone();
self.sessions.insert(
id.clone(),
SessionData {
info,
tree,
drawing_elements: Vec::new(),
},
);
if self.active_session_id.is_none() {
self.active_session_id = Some(id);
}
}
pub fn remove_session(&mut self, session_id: &str) {
self.sessions.remove(session_id);
if self.active_session_id.as_deref() == Some(session_id) {
self.active_session_id = self.sessions.keys().next().cloned();
}
}
pub fn set_active(&mut self, session_id: &str) {
if self.sessions.contains_key(session_id) {
self.active_session_id = Some(session_id.to_string());
}
}
pub fn update_tree(&mut self, session_id: &str, tree: Option<ElementTree>) {
if let Some(data) = self.sessions.get_mut(session_id) {
data.info.has_svg = tree.is_some();
data.info.element_count = tree.as_ref().map(|t| t.metadata.element_count);
data.tree = tree;
}
}
pub fn get_tree(&self, session_id: Option<&str>) -> Option<(String, &ElementTree)> {
let id = session_id
.map(|s| s.to_string())
.or_else(|| self.active_session_id.clone())?;
let data = self.sessions.get(&id)?;
data.tree.as_ref().map(|t| (id, t))
}
pub fn update_drawing_elements(&mut self, session_id: &str, elements: Vec<DrawingElement>) {
if let Some(data) = self.sessions.get_mut(session_id) {
data.info.drawing_element_count = elements.len();
data.drawing_elements = elements;
}
}
pub fn get_drawing_elements(
&self,
session_id: Option<&str>,
) -> Option<(String, &[DrawingElement])> {
let id = session_id
.map(|s| s.to_string())
.or_else(|| self.active_session_id.clone())?;
let data = self.sessions.get(&id)?;
Some((id, &data.drawing_elements))
}
pub fn list_sessions(&self) -> Vec<SessionInfo> {
self.sessions.values().map(|d| d.info.clone()).collect()
}
pub fn active_session_id(&self) -> Option<&str> {
self.active_session_id.as_deref()
}
}

View File

@@ -37,7 +37,7 @@ fn parse_group(group: &Group) -> Element {
height: bbox.height(),
};
let children: Vec<Element> = group.children().iter().map(|c| parse_node(c)).collect();
let children: Vec<Element> = group.children().iter().map(parse_node).collect();
let id_str = group.id();
let name = if id_str.is_empty() {

View File

@@ -46,8 +46,4 @@ impl SvgRenderer {
let size = self.tree.size();
(size.width(), size.height())
}
pub fn tree(&self) -> &Tree {
&self.tree
}
}

View File

@@ -0,0 +1,94 @@
# MCP Configuration Examples
These are example configuration files for connecting AI coding tools to agcanvas via the MCP bridge.
## Prerequisites
1. Build the MCP server:
```bash
cargo build --release -p agcanvas-mcp
```
2. Make sure `agcanvas-mcp` is in your PATH, or use the full path:
```
./target/release/agcanvas-mcp
```
3. agcanvas must be running (it hosts the WebSocket server on port 9876).
## Claude Code
Copy `claude-code.mcp.json` to your project root as `.mcp.json`:
```bash
cp examples/mcp-configs/claude-code.mcp.json /path/to/your/project/.mcp.json
```
Or add to your global Claude Code config at `~/.claude/mcp.json`.
If `agcanvas-mcp` is not in your PATH, use the full path:
```json
{
"mcpServers": {
"agcanvas": {
"command": "/path/to/agcanvas-mcp",
"args": ["--port", "9876"]
}
}
}
```
## OpenCode
Add the server to your `opencode.json`:
```bash
cp examples/mcp-configs/opencode.json /path/to/your/project/opencode.json
```
Or merge the `mcpServers` block into your existing config.
## Codex (OpenAI)
Codex uses the same MCP config format. Add to your project's MCP config:
```json
{
"mcpServers": {
"agcanvas": {
"command": "agcanvas-mcp",
"args": ["--port", "9876"]
}
}
}
```
## Custom Port
If agcanvas is running on a different port, change the `--port` argument:
```json
{
"mcpServers": {
"agcanvas": {
"command": "agcanvas-mcp",
"args": ["--port", "8080"]
}
}
}
```
## Available Tools
Once connected, the following MCP tools are available:
| Tool | Description |
|------|-------------|
| `list_sessions` | List all open tabs/sessions in agcanvas |
| `get_element_tree` | Get the full parsed SVG element tree (structured JSON) |
| `describe_canvas` | Get a human-readable description of the canvas |
| `get_element_by_id` | Look up a specific element by ID |
| `get_elements_at_point` | Find elements at an (x, y) coordinate |
| `get_drawing_elements` | Get all user-drawn shapes (rects, ellipses, lines, arrows, text) |
| `generate_code` | Generate code stubs (html, react, tailwind, svelte, vue) |

View File

@@ -0,0 +1,8 @@
{
"mcpServers": {
"agcanvas": {
"command": "agcanvas-mcp",
"args": ["--port", "9876"]
}
}
}

View File

@@ -0,0 +1,9 @@
{
"$schema": "https://opencode.ai/config.schema.json",
"mcpServers": {
"agcanvas": {
"command": "agcanvas-mcp",
"args": ["--port", "9876"]
}
}
}

View File

@@ -1,68 +0,0 @@
use crate::element_tree::ElementTree;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum AgentRequest {
GetTree,
GetElementById {
id: String,
},
GetElementsAtPoint {
x: f32,
y: f32,
},
Describe,
GenerateCode {
target: CodeGenTarget,
element_id: Option<String>,
},
Ping,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum AgentResponse {
Tree {
tree: ElementTree,
},
Element {
element: Option<crate::element_tree::Element>,
},
Elements {
elements: Vec<crate::element_tree::Element>,
},
Description {
text: String,
},
Code {
code: String,
target: CodeGenTarget,
},
Pong,
Error {
message: String,
},
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CodeGenTarget {
Html,
React,
Tailwind,
Svelte,
Vue,
}
impl std::fmt::Display for CodeGenTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CodeGenTarget::Html => write!(f, "html"),
CodeGenTarget::React => write!(f, "react"),
CodeGenTarget::Tailwind => write!(f, "tailwind"),
CodeGenTarget::Svelte => write!(f, "svelte"),
CodeGenTarget::Vue => write!(f, "vue"),
}
}
}

View File

@@ -1,172 +0,0 @@
use super::protocol::{AgentRequest, AgentResponse, CodeGenTarget};
use crate::element_tree::ElementTree;
use anyhow::Result;
use futures_util::{SinkExt, StreamExt};
use std::sync::Arc;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::RwLock;
use tokio_tungstenite::tungstenite::Message;
pub struct AgentServer {
tree: Arc<RwLock<Option<ElementTree>>>,
port: u16,
}
impl AgentServer {
pub fn new(port: u16) -> Self {
Self {
tree: Arc::new(RwLock::new(None)),
port,
}
}
pub fn tree_handle(&self) -> Arc<RwLock<Option<ElementTree>>> {
self.tree.clone()
}
pub async fn run(&self) -> Result<()> {
let addr = format!("127.0.0.1:{}", self.port);
let listener = TcpListener::bind(&addr).await?;
tracing::info!("Agent server listening on ws://{}", addr);
while let Ok((stream, peer)) = listener.accept().await {
tracing::info!("Agent connected from {}", peer);
let tree = self.tree.clone();
tokio::spawn(async move {
if let Err(e) = handle_connection(stream, tree).await {
tracing::error!("Connection error: {}", e);
}
});
}
Ok(())
}
}
async fn handle_connection(stream: TcpStream, tree: Arc<RwLock<Option<ElementTree>>>) -> Result<()> {
let ws_stream = tokio_tungstenite::accept_async(stream).await?;
let (mut write, mut read) = ws_stream.split();
while let Some(msg) = read.next().await {
let msg = msg?;
if let Message::Text(text) = msg {
let response = match serde_json::from_str::<AgentRequest>(&text) {
Ok(request) => process_request(request, &tree).await,
Err(e) => AgentResponse::Error {
message: format!("Invalid request: {}", e),
},
};
let response_text = serde_json::to_string(&response)?;
write.send(Message::Text(response_text.into())).await?;
}
}
Ok(())
}
async fn process_request(
request: AgentRequest,
tree: &Arc<RwLock<Option<ElementTree>>>,
) -> AgentResponse {
let tree_guard = tree.read().await;
match request {
AgentRequest::Ping => AgentResponse::Pong,
AgentRequest::GetTree => match tree_guard.as_ref() {
Some(t) => AgentResponse::Tree { tree: t.clone() },
None => AgentResponse::Error {
message: "No SVG loaded".to_string(),
},
},
AgentRequest::GetElementById { id } => match tree_guard.as_ref() {
Some(t) => AgentResponse::Element {
element: t.find_by_id(&id).cloned(),
},
None => AgentResponse::Error {
message: "No SVG loaded".to_string(),
},
},
AgentRequest::GetElementsAtPoint { x, y } => match tree_guard.as_ref() {
Some(t) => AgentResponse::Elements {
elements: t.find_at_point(x, y).into_iter().cloned().collect(),
},
None => AgentResponse::Error {
message: "No SVG loaded".to_string(),
},
},
AgentRequest::Describe => match tree_guard.as_ref() {
Some(t) => AgentResponse::Description {
text: t.to_semantic_description(),
},
None => AgentResponse::Error {
message: "No SVG loaded".to_string(),
},
},
AgentRequest::GenerateCode { target, element_id } => {
match tree_guard.as_ref() {
Some(t) => {
let element = match &element_id {
Some(id) => t.find_by_id(id),
None => Some(&t.root),
};
match element {
Some(el) => {
let code = generate_code_stub(el, target);
AgentResponse::Code { code, target }
}
None => AgentResponse::Error {
message: format!("Element not found: {:?}", element_id),
},
}
}
None => AgentResponse::Error {
message: "No SVG loaded".to_string(),
},
}
}
}
}
fn generate_code_stub(element: &crate::element_tree::Element, target: CodeGenTarget) -> String {
let description = crate::element_tree::ElementTree {
root: element.clone(),
metadata: crate::element_tree::TreeMetadata {
source: "code_gen".to_string(),
width: element.bounds.width,
height: element.bounds.height,
element_count: 1,
},
}
.to_semantic_description();
match target {
CodeGenTarget::Html => format!(
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<div class=\"container\">\n <!-- TODO: Implement based on structure -->\n</div>",
element.id, description
),
CodeGenTarget::React => format!(
"// Generated from SVG element: {}\n// Structure:\n// {}\n\nexport function Component() {{\n return (\n <div className=\"container\">\n {{/* TODO: Implement based on structure */}}\n </div>\n );\n}}",
element.id,
description.replace('\n', "\n// ")
),
CodeGenTarget::Tailwind => format!(
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<div class=\"flex flex-col\">\n <!-- TODO: Implement with Tailwind classes -->\n</div>",
element.id, description
),
CodeGenTarget::Svelte => format!(
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<script>\n // Component logic\n</script>\n\n<div class=\"container\">\n <!-- TODO: Implement -->\n</div>",
element.id, description
),
CodeGenTarget::Vue => format!(
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<template>\n <div class=\"container\">\n <!-- TODO: Implement -->\n </div>\n</template>\n\n<script setup>\n// Component logic\n</script>",
element.id, description
),
}
}

View File

@@ -1,328 +0,0 @@
use crate::agent::AgentServer;
use crate::canvas::{CanvasInteraction, CanvasState};
use crate::clipboard::ClipboardManager;
use crate::element_tree::ElementTree;
use crate::svg::{parse_svg, SvgRenderer};
use egui::{Color32, ColorImage, TextureHandle, TextureOptions};
use std::sync::Arc;
use tokio::runtime::Runtime;
use tokio::sync::RwLock;
const AGENT_PORT: u16 = 9876;
pub struct AgCanvasApp {
canvas_state: CanvasState,
clipboard: Option<ClipboardManager>,
svg_renderer: Option<SvgRenderer>,
svg_texture: Option<TextureHandle>,
element_tree: Option<ElementTree>,
tree_handle: Arc<RwLock<Option<ElementTree>>>,
show_tree_panel: bool,
show_description: bool,
description_text: String,
status_message: Option<(String, std::time::Instant)>,
_runtime: Runtime,
}
impl AgCanvasApp {
pub fn new(cc: &eframe::CreationContext<'_>) -> Self {
configure_fonts(&cc.egui_ctx);
let runtime = Runtime::new().expect("Failed to create tokio runtime");
let server = AgentServer::new(AGENT_PORT);
let tree_handle = server.tree_handle();
runtime.spawn(async move {
if let Err(e) = server.run().await {
tracing::error!("Agent server error: {}", e);
}
});
let clipboard = ClipboardManager::new().ok();
Self {
canvas_state: CanvasState::default(),
clipboard,
svg_renderer: None,
svg_texture: None,
element_tree: None,
tree_handle,
show_tree_panel: false,
show_description: false,
description_text: String::new(),
status_message: None,
_runtime: runtime,
}
}
fn handle_paste(&mut self, ctx: &egui::Context) {
let svg_data = self.clipboard.as_mut().and_then(|c| c.get_svg());
if let Some(svg_data) = svg_data {
match parse_svg(&svg_data) {
Ok((tree, usvg_tree)) => {
let (width, height) = (tree.metadata.width, tree.metadata.height);
self.element_tree = Some(tree.clone());
self.description_text = tree.to_semantic_description();
let tree_handle = self.tree_handle.clone();
let tree_clone = tree.clone();
std::thread::spawn(move || {
let rt = Runtime::new().unwrap();
rt.block_on(async {
let mut guard = tree_handle.write().await;
*guard = Some(tree_clone);
});
});
self.svg_renderer = Some(SvgRenderer::new(usvg_tree));
self.svg_texture = None;
self.canvas_state.fit_to_rect(
egui::vec2(width, height),
ctx.screen_rect().size() * 0.8,
);
self.set_status(format!(
"Loaded SVG: {}x{} ({} elements)",
width as i32, height as i32, tree.metadata.element_count
));
}
Err(e) => {
self.set_status(format!("Failed to parse SVG: {}", e));
}
}
}
}
fn render_svg_to_texture(&mut self, ctx: &egui::Context) {
if let Some(renderer) = &mut self.svg_renderer {
let scale = self.canvas_state.zoom.max(1.0);
if let Ok(pixmap) = renderer.render(scale) {
let size = [pixmap.width() as usize, pixmap.height() as usize];
let pixels: Vec<Color32> = pixmap
.pixels()
.iter()
.map(|p| Color32::from_rgba_premultiplied(p.red(), p.green(), p.blue(), p.alpha()))
.collect();
let image = ColorImage { size, pixels };
self.svg_texture = Some(ctx.load_texture("svg", image, TextureOptions::LINEAR));
}
}
}
fn set_status(&mut self, message: String) {
self.status_message = Some((message, std::time::Instant::now()));
}
}
impl eframe::App for AgCanvasApp {
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
ctx.input(|i| {
if i.modifiers.command && i.key_pressed(egui::Key::V) {
self.handle_paste(ctx);
}
});
if self.svg_texture.is_none() && self.svg_renderer.is_some() {
self.render_svg_to_texture(ctx);
}
egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| {
egui::menu::bar(ui, |ui| {
ui.menu_button("File", |ui| {
if ui.button("Paste SVG (Cmd+V)").clicked() {
self.handle_paste(ctx);
ui.close_menu();
}
if ui.button("Clear Canvas").clicked() {
self.svg_renderer = None;
self.svg_texture = None;
self.element_tree = None;
self.canvas_state.reset();
ui.close_menu();
}
});
ui.menu_button("View", |ui| {
if ui.checkbox(&mut self.show_tree_panel, "Element Tree").clicked() {
ui.close_menu();
}
if ui.checkbox(&mut self.show_description, "Description").clicked() {
ui.close_menu();
}
ui.separator();
if ui.button("Reset Zoom (Cmd+0)").clicked() {
self.canvas_state.reset();
ui.close_menu();
}
if ui.button("Fit to View").clicked() {
if let Some(renderer) = &self.svg_renderer {
let (w, h) = renderer.size();
self.canvas_state
.fit_to_rect(egui::vec2(w, h), ctx.screen_rect().size() * 0.8);
}
ui.close_menu();
}
});
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(format!("Agent: ws://127.0.0.1:{}", AGENT_PORT));
ui.separator();
ui.label(format!("Zoom: {:.0}%", self.canvas_state.zoom * 100.0));
});
});
});
if let Some((msg, time)) = &self.status_message {
if time.elapsed().as_secs() < 3 {
egui::TopBottomPanel::bottom("status").show(ctx, |ui| {
ui.label(msg);
});
} else {
self.status_message = None;
}
}
if self.show_tree_panel {
egui::SidePanel::right("tree_panel")
.default_width(300.0)
.show(ctx, |ui| {
ui.heading("Element Tree");
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
if let Some(tree) = &self.element_tree {
render_tree_ui(ui, &tree.root, 0);
} else {
ui.label("No SVG loaded. Paste an SVG (Cmd+V)");
}
});
});
}
if self.show_description {
egui::SidePanel::left("description_panel")
.default_width(300.0)
.show(ctx, |ui| {
ui.heading("Semantic Description");
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
if !self.description_text.is_empty() {
ui.add(
egui::TextEdit::multiline(&mut self.description_text.as_str())
.font(egui::TextStyle::Monospace)
.desired_width(f32::INFINITY),
);
} else {
ui.label("No SVG loaded. Paste an SVG (Cmd+V)");
}
});
});
}
egui::CentralPanel::default().show(ctx, |ui| {
let response = CanvasInteraction::allocate_canvas(ui);
CanvasInteraction::handle(ui, &response, &mut self.canvas_state);
let painter = ui.painter_at(response.rect);
painter.rect_filled(response.rect, 0.0, Color32::from_gray(30));
draw_grid(&painter, &response.rect, &self.canvas_state);
if let Some(texture) = &self.svg_texture {
let center = response.rect.center();
let size = texture.size_vec2() / self.canvas_state.zoom.max(1.0) * self.canvas_state.zoom;
let offset = self.canvas_state.offset * self.canvas_state.zoom;
let rect = egui::Rect::from_center_size(center + offset, size);
painter.image(texture.id(), rect, egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), Color32::WHITE);
} else {
painter.text(
response.rect.center(),
egui::Align2::CENTER_CENTER,
"Paste SVG from Figma (Cmd+V)",
egui::FontId::proportional(24.0),
Color32::from_gray(100),
);
}
});
ctx.request_repaint();
}
}
fn configure_fonts(ctx: &egui::Context) {
let mut style = (*ctx.style()).clone();
style.visuals.window_rounding = egui::Rounding::same(8.0);
style.visuals.panel_fill = Color32::from_gray(25);
ctx.set_style(style);
}
fn draw_grid(painter: &egui::Painter, rect: &egui::Rect, state: &CanvasState) {
let grid_size = 50.0 * state.zoom;
if grid_size < 10.0 {
return;
}
let color = Color32::from_gray(40);
let offset = state.offset * state.zoom;
let center = rect.center();
let start_x = ((rect.left() - center.x - offset.x) / grid_size).floor() as i32;
let end_x = ((rect.right() - center.x - offset.x) / grid_size).ceil() as i32;
let start_y = ((rect.top() - center.y - offset.y) / grid_size).floor() as i32;
let end_y = ((rect.bottom() - center.y - offset.y) / grid_size).ceil() as i32;
for i in start_x..=end_x {
let x = center.x + offset.x + i as f32 * grid_size;
if x >= rect.left() && x <= rect.right() {
painter.line_segment(
[egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())],
egui::Stroke::new(1.0, color),
);
}
}
for i in start_y..=end_y {
let y = center.y + offset.y + i as f32 * grid_size;
if y >= rect.top() && y <= rect.bottom() {
painter.line_segment(
[egui::pos2(rect.left(), y), egui::pos2(rect.right(), y)],
egui::Stroke::new(1.0, color),
);
}
}
}
fn render_tree_ui(ui: &mut egui::Ui, element: &crate::element_tree::Element, depth: usize) {
let kind_name = match &element.kind {
crate::element_tree::ElementKind::Group { name } => {
format!("Group{}", name.as_ref().map(|n| format!(" '{}'", n)).unwrap_or_default())
}
crate::element_tree::ElementKind::Rectangle { .. } => "Rect".to_string(),
crate::element_tree::ElementKind::Circle { .. } => "Circle".to_string(),
crate::element_tree::ElementKind::Ellipse { .. } => "Ellipse".to_string(),
crate::element_tree::ElementKind::Path { .. } => "Path".to_string(),
crate::element_tree::ElementKind::Text { content, .. } => {
format!("Text '{}'", if content.len() > 15 { &content[..15] } else { content })
}
crate::element_tree::ElementKind::Image { .. } => "Image".to_string(),
crate::element_tree::ElementKind::Line { .. } => "Line".to_string(),
crate::element_tree::ElementKind::Unknown { tag } => format!("<{}>", tag),
};
if element.children.is_empty() {
ui.horizontal(|ui| {
ui.add_space(depth as f32 * 12.0);
ui.label(format!("{}", kind_name));
});
} else {
egui::CollapsingHeader::new(kind_name)
.id_salt(&element.id)
.default_open(depth < 2)
.show(ui, |ui| {
for child in &element.children {
render_tree_ui(ui, child, depth + 1);
}
});
}
}