diff --git a/.gitignore b/.gitignore index 97f8e81..217643f 100644 --- a/.gitignore +++ b/.gitignore @@ -1,5 +1,8 @@ /target +crates/*/target Cargo.lock *.swp *.swo .DS_Store +opencode.jsonc +.opencode diff --git a/README.md b/README.md index 7c76cff..cc0e2fd 100644 --- a/README.md +++ b/README.md @@ -58,6 +58,43 @@ This builds two binaries: - `target/release/agcanvas` — The desktop app - `target/release/agcanvas-mcp` — The MCP server bridge +### Install to PATH + +After building, symlink (or copy) the binaries so they're available system-wide: + +**macOS / Linux:** + +```bash +sudo ln -sf "$(pwd)/target/release/agcanvas" /usr/local/bin/agcanvas +sudo ln -sf "$(pwd)/target/release/agcanvas-mcp" /usr/local/bin/agcanvas-mcp +``` + +Or install to a user-local directory (no sudo): + +```bash +mkdir -p ~/.local/bin +ln -sf "$(pwd)/target/release/agcanvas" ~/.local/bin/agcanvas +ln -sf "$(pwd)/target/release/agcanvas-mcp" ~/.local/bin/agcanvas-mcp +``` + +> Make sure `~/.local/bin` is in your `PATH`. Add `export PATH="$HOME/.local/bin:$PATH"` to your `~/.zshrc` or `~/.bashrc` if needed. + +**Windows (PowerShell, run as Administrator):** + +```powershell +New-Item -ItemType SymbolicLink -Path "$env:USERPROFILE\.local\bin\agcanvas.exe" -Target "$(Get-Location)\target\release\agcanvas.exe" -Force +New-Item -ItemType SymbolicLink -Path "$env:USERPROFILE\.local\bin\agcanvas-mcp.exe" -Target "$(Get-Location)\target\release\agcanvas-mcp.exe" -Force +``` + +> Make sure `%USERPROFILE%\.local\bin` is in your system `PATH`. Or use an existing PATH directory like `C:\Users\\AppData\Local\Microsoft\WindowsApps`. + +**Verify:** + +```bash +agcanvas --help +agcanvas-mcp --help +``` + ### Requirements - Rust 1.70+ @@ -118,14 +155,15 @@ Add to your project's `.mcp.json` (or `~/.claude/mcp.json` for global): ### Setup for OpenCode -Add to your `opencode.json`: +Add to your `opencode.json` (project-level) or `~/.config/opencode/opencode.json` (global): ```json { - "mcpServers": { + "mcp": { "agcanvas": { - "command": "agcanvas-mcp", - "args": ["--port", "9876"] + "type": "local", + "command": ["agcanvas-mcp", "--port", "9876"], + "enabled": true } } } @@ -135,9 +173,7 @@ Add to your `opencode.json`: 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. +> **Note:** Make sure `agcanvas-mcp` is in your PATH (e.g., `~/.local/bin`), or use the full path to the binary. agcanvas must be running for the MCP tools to work. ### MCP Tools @@ -150,6 +186,10 @@ See [`examples/mcp-configs/`](examples/mcp-configs/) for ready-to-copy configura | `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) | +| `create_drawing_element` | Create a shape on the canvas (Rectangle, Ellipse, Line, Arrow, Text) | +| `update_drawing_element` | Update an existing drawing element's shape or style | +| `delete_drawing_element` | Delete a drawing element by ID | +| `clear_drawing_elements` | Clear all drawing elements from the canvas | All tools accept an optional `session_id` parameter. If omitted, the active session is used. diff --git a/crates/agcanvas-mcp/src/tools.rs b/crates/agcanvas-mcp/src/tools.rs index 7729475..a4c14e4 100644 --- a/crates/agcanvas-mcp/src/tools.rs +++ b/crates/agcanvas-mcp/src/tools.rs @@ -1,9 +1,8 @@ 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, + schemars, tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler, }; #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -36,10 +35,110 @@ pub struct GenerateCodeParam { 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.")] + #[schemars( + description = "Element ID to generate code for. If omitted, generates for the entire tree." + )] pub element_id: Option, } +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct CreateDrawingElementParam { + #[schemars(description = "Session ID to target. If omitted, uses the active session.")] + pub session_id: Option, + #[schemars(description = "Shape type: Rectangle, Ellipse, Line, Arrow, or Text")] + pub shape_type: String, + #[schemars( + description = "X position (Rectangle/Text top-left X, or Line/Arrow start X via x1)" + )] + pub x: Option, + #[schemars( + description = "Y position (Rectangle/Text top-left Y, or Line/Arrow start Y via y1)" + )] + pub y: Option, + #[schemars(description = "Width (Rectangle only)")] + pub width: Option, + #[schemars(description = "Height (Rectangle only)")] + pub height: Option, + #[schemars(description = "Center X (Ellipse only; falls back to x)")] + pub center_x: Option, + #[schemars(description = "Center Y (Ellipse only; falls back to y)")] + pub center_y: Option, + #[schemars(description = "X radius (Ellipse only, default 50)")] + pub radius_x: Option, + #[schemars(description = "Y radius (Ellipse only, default 50)")] + pub radius_y: Option, + #[schemars(description = "Start X (Line/Arrow; falls back to x)")] + pub x1: Option, + #[schemars(description = "Start Y (Line/Arrow; falls back to y)")] + pub y1: Option, + #[schemars(description = "End X (Line/Arrow)")] + pub x2: Option, + #[schemars(description = "End Y (Line/Arrow)")] + pub y2: Option, + #[schemars(description = "Text content (Text shape only)")] + pub text: Option, + #[schemars(description = "Font size in pixels (Text shape, default 20)")] + pub font_size: Option, + #[schemars(description = "Fill color as hex e.g. '#ff0000', or null for no fill")] + pub fill: Option, + #[schemars(description = "Stroke color as hex e.g. '#ffffff' (default white)")] + pub stroke_color: Option, + #[schemars(description = "Stroke width in pixels (default 2.0)")] + pub stroke_width: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct UpdateDrawingElementParam { + #[schemars(description = "Session ID to target. If omitted, uses the active session.")] + pub session_id: Option, + #[schemars(description = "ID of the drawing element to update")] + pub id: String, + #[schemars(description = "New shape type (provide with coordinates to replace shape)")] + pub shape_type: Option, + #[schemars(description = "X position")] + pub x: Option, + #[schemars(description = "Y position")] + pub y: Option, + #[schemars(description = "Width (Rectangle)")] + pub width: Option, + #[schemars(description = "Height (Rectangle)")] + pub height: Option, + #[schemars(description = "Center X (Ellipse)")] + pub center_x: Option, + #[schemars(description = "Center Y (Ellipse)")] + pub center_y: Option, + #[schemars(description = "X radius (Ellipse)")] + pub radius_x: Option, + #[schemars(description = "Y radius (Ellipse)")] + pub radius_y: Option, + #[schemars(description = "Start X (Line/Arrow)")] + pub x1: Option, + #[schemars(description = "Start Y (Line/Arrow)")] + pub y1: Option, + #[schemars(description = "End X (Line/Arrow)")] + pub x2: Option, + #[schemars(description = "End Y (Line/Arrow)")] + pub y2: Option, + #[schemars(description = "Text content (Text shape)")] + pub text: Option, + #[schemars(description = "Font size (Text shape)")] + pub font_size: Option, + #[schemars(description = "Fill color as hex e.g. '#ff0000'")] + pub fill: Option, + #[schemars(description = "Stroke color as hex e.g. '#ffffff'")] + pub stroke_color: Option, + #[schemars(description = "Stroke width in pixels")] + pub stroke_width: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct DeleteDrawingElementParam { + #[schemars(description = "Session ID to target. If omitted, uses the active session.")] + pub session_id: Option, + #[schemars(description = "ID of the drawing element to delete")] + pub id: String, +} + #[derive(Debug, Clone)] pub struct AgCanvasServer { ws_url: String, @@ -55,13 +154,17 @@ impl AgCanvasServer { } } - #[tool(description = "List all open sessions/tabs in agcanvas. Returns session IDs, names, and whether they have SVG or drawing content loaded.")] + #[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.")] + #[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, @@ -73,7 +176,9 @@ impl AgCanvasServer { 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.")] + #[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, @@ -97,7 +202,9 @@ impl AgCanvasServer { self.call_agcanvas(&request).await } - #[tool(description = "Query which elements exist at a specific (x, y) coordinate on the canvas.")] + #[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, @@ -110,7 +217,9 @@ impl AgCanvasServer { self.call_agcanvas(&request).await } - #[tool(description = "Get all user-drawn shapes (rectangles, ellipses, lines, arrows, text) from the drawing layer.")] + #[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, @@ -122,7 +231,9 @@ impl AgCanvasServer { 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.")] + #[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, @@ -137,13 +248,178 @@ impl AgCanvasServer { } self.call_agcanvas(&request).await } + + #[tool( + description = "Create a drawing element on the canvas. Shape types: Rectangle (x,y,width,height), Ellipse (center_x,center_y,radius_x,radius_y), Line (x1,y1,x2,y2), Arrow (x1,y1,x2,y2), Text (x,y,text,font_size). Colors are hex strings like '#ff0000'. Returns the created element with its auto-generated ID." + )] + async fn create_drawing_element( + &self, + Parameters(params): Parameters, + ) -> Result { + let mut request = serde_json::json!({ + "type": "CreateDrawingElement", + "shape_type": params.shape_type, + }); + let obj = request.as_object_mut().unwrap(); + if let Some(v) = params.session_id { + obj.insert("session_id".into(), v.into()); + } + if let Some(v) = params.x { + obj.insert("x".into(), v.into()); + } + if let Some(v) = params.y { + obj.insert("y".into(), v.into()); + } + if let Some(v) = params.width { + obj.insert("width".into(), v.into()); + } + if let Some(v) = params.height { + obj.insert("height".into(), v.into()); + } + if let Some(v) = params.center_x { + obj.insert("center_x".into(), v.into()); + } + if let Some(v) = params.center_y { + obj.insert("center_y".into(), v.into()); + } + if let Some(v) = params.radius_x { + obj.insert("radius_x".into(), v.into()); + } + if let Some(v) = params.radius_y { + obj.insert("radius_y".into(), v.into()); + } + if let Some(v) = params.x1 { + obj.insert("x1".into(), v.into()); + } + if let Some(v) = params.y1 { + obj.insert("y1".into(), v.into()); + } + if let Some(v) = params.x2 { + obj.insert("x2".into(), v.into()); + } + if let Some(v) = params.y2 { + obj.insert("y2".into(), v.into()); + } + if let Some(v) = params.text { + obj.insert("text".into(), v.into()); + } + if let Some(v) = params.font_size { + obj.insert("font_size".into(), v.into()); + } + if let Some(v) = params.fill { + obj.insert("fill".into(), v.into()); + } + if let Some(v) = params.stroke_color { + obj.insert("stroke_color".into(), v.into()); + } + if let Some(v) = params.stroke_width { + obj.insert("stroke_width".into(), v.into()); + } + self.call_agcanvas(&request).await + } + + #[tool( + description = "Update an existing drawing element. Provide shape_type + coordinates to replace the shape. Provide fill/stroke_color/stroke_width to update the style. Only provided fields are changed." + )] + async fn update_drawing_element( + &self, + Parameters(params): Parameters, + ) -> Result { + let mut request = serde_json::json!({ + "type": "UpdateDrawingElement", + "id": params.id, + }); + let obj = request.as_object_mut().unwrap(); + if let Some(v) = params.session_id { + obj.insert("session_id".into(), v.into()); + } + if let Some(v) = params.shape_type { + obj.insert("shape_type".into(), v.into()); + } + if let Some(v) = params.x { + obj.insert("x".into(), v.into()); + } + if let Some(v) = params.y { + obj.insert("y".into(), v.into()); + } + if let Some(v) = params.width { + obj.insert("width".into(), v.into()); + } + if let Some(v) = params.height { + obj.insert("height".into(), v.into()); + } + if let Some(v) = params.center_x { + obj.insert("center_x".into(), v.into()); + } + if let Some(v) = params.center_y { + obj.insert("center_y".into(), v.into()); + } + if let Some(v) = params.radius_x { + obj.insert("radius_x".into(), v.into()); + } + if let Some(v) = params.radius_y { + obj.insert("radius_y".into(), v.into()); + } + if let Some(v) = params.x1 { + obj.insert("x1".into(), v.into()); + } + if let Some(v) = params.y1 { + obj.insert("y1".into(), v.into()); + } + if let Some(v) = params.x2 { + obj.insert("x2".into(), v.into()); + } + if let Some(v) = params.y2 { + obj.insert("y2".into(), v.into()); + } + if let Some(v) = params.text { + obj.insert("text".into(), v.into()); + } + if let Some(v) = params.font_size { + obj.insert("font_size".into(), v.into()); + } + if let Some(v) = params.fill { + obj.insert("fill".into(), v.into()); + } + if let Some(v) = params.stroke_color { + obj.insert("stroke_color".into(), v.into()); + } + if let Some(v) = params.stroke_width { + obj.insert("stroke_width".into(), v.into()); + } + self.call_agcanvas(&request).await + } + + #[tool(description = "Delete a drawing element by its ID.")] + async fn delete_drawing_element( + &self, + Parameters(params): Parameters, + ) -> Result { + let mut request = serde_json::json!({ + "type": "DeleteDrawingElement", + "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 = "Clear all drawing elements from the canvas.")] + async fn clear_drawing_elements( + &self, + Parameters(params): Parameters, + ) -> Result { + let mut request = serde_json::json!({"type": "ClearDrawingElements"}); + 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 { + 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))?; @@ -152,10 +428,8 @@ impl AgCanvasServer { 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"); + let is_error = + parsed.get("type").and_then(serde_json::Value::as_str) == Some("Error"); if is_error { let msg = parsed @@ -165,8 +439,8 @@ impl AgCanvasServer { return Ok(CallToolResult::error(vec![Content::text(msg)])); } - let pretty = serde_json::to_string_pretty(&parsed) - .unwrap_or_else(|_| response.clone()); + 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!( diff --git a/crates/agcanvas/src/agent/mod.rs b/crates/agcanvas/src/agent/mod.rs index cdb6e4e..2ca35ec 100644 --- a/crates/agcanvas/src/agent/mod.rs +++ b/crates/agcanvas/src/agent/mod.rs @@ -1,5 +1,5 @@ mod protocol; mod server; -pub use protocol::GuiEvent; +pub use protocol::{DrawingCommand, GuiEvent}; pub use server::AgentServer; diff --git a/crates/agcanvas/src/agent/protocol.rs b/crates/agcanvas/src/agent/protocol.rs index d1e31c3..c7fb6c1 100644 --- a/crates/agcanvas/src/agent/protocol.rs +++ b/crates/agcanvas/src/agent/protocol.rs @@ -1,8 +1,13 @@ -use crate::drawing::DrawingElement; +use crate::drawing::{DrawingElement, Shape, ShapeStyle}; use crate::element_tree::{ElementTree, TreeMetadata}; use crate::session::SessionInfo; +use egui::{Color32, Pos2, Vec2}; use serde::{Deserialize, Serialize}; +// --------------------------------------------------------------------------- +// GUI → Agent events (broadcast to all connected agents) +// --------------------------------------------------------------------------- + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum GuiEvent { @@ -27,8 +32,27 @@ pub enum GuiEvent { SvgCleared { session_id: String, }, + DrawingElementCreated { + session_id: String, + element: DrawingElement, + }, + DrawingElementUpdated { + session_id: String, + element: DrawingElement, + }, + DrawingElementDeleted { + session_id: String, + id: String, + }, + DrawingElementsCleared { + session_id: String, + }, } +// --------------------------------------------------------------------------- +// Agent → Server requests +// --------------------------------------------------------------------------- + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum AgentRequest { @@ -62,9 +86,105 @@ pub enum AgentRequest { #[serde(default)] session_id: Option, }, + + // ---- Drawing mutations ---- + CreateDrawingElement { + #[serde(default)] + session_id: Option, + shape_type: String, + #[serde(default)] + x: Option, + #[serde(default)] + y: Option, + #[serde(default)] + width: Option, + #[serde(default)] + height: Option, + #[serde(default)] + center_x: Option, + #[serde(default)] + center_y: Option, + #[serde(default)] + radius_x: Option, + #[serde(default)] + radius_y: Option, + #[serde(default)] + x1: Option, + #[serde(default)] + y1: Option, + #[serde(default)] + x2: Option, + #[serde(default)] + y2: Option, + #[serde(default)] + text: Option, + #[serde(default)] + font_size: Option, + #[serde(default)] + fill: Option, + #[serde(default)] + stroke_color: Option, + #[serde(default)] + stroke_width: Option, + }, + UpdateDrawingElement { + #[serde(default)] + session_id: Option, + id: String, + #[serde(default)] + shape_type: Option, + #[serde(default)] + x: Option, + #[serde(default)] + y: Option, + #[serde(default)] + width: Option, + #[serde(default)] + height: Option, + #[serde(default)] + center_x: Option, + #[serde(default)] + center_y: Option, + #[serde(default)] + radius_x: Option, + #[serde(default)] + radius_y: Option, + #[serde(default)] + x1: Option, + #[serde(default)] + y1: Option, + #[serde(default)] + x2: Option, + #[serde(default)] + y2: Option, + #[serde(default)] + text: Option, + #[serde(default)] + font_size: Option, + #[serde(default)] + fill: Option, + #[serde(default)] + stroke_color: Option, + #[serde(default)] + stroke_width: Option, + }, + DeleteDrawingElement { + #[serde(default)] + session_id: Option, + id: String, + }, + ClearDrawingElements { + #[serde(default)] + session_id: Option, + }, + Ping, } +// --------------------------------------------------------------------------- +// Server → Agent responses +// --------------------------------------------------------------------------- + #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type")] pub enum AgentResponse { @@ -97,12 +217,54 @@ pub enum AgentResponse { session_id: String, elements: Vec, }, + DrawingElementCreated { + session_id: String, + element: DrawingElement, + }, + DrawingElementUpdated { + session_id: String, + element: DrawingElement, + }, + DrawingElementDeleted { + session_id: String, + id: String, + }, + DrawingElementsCleared { + session_id: String, + }, Pong, Error { message: String, }, } +// --------------------------------------------------------------------------- +// Agent → GUI drawing commands (reverse sync channel) +// --------------------------------------------------------------------------- + +#[derive(Debug, Clone)] +pub enum DrawingCommand { + Create { + session_id: String, + element: DrawingElement, + }, + Update { + session_id: String, + element: DrawingElement, + }, + Delete { + session_id: String, + id: String, + }, + Clear { + session_id: String, + }, +} + +// --------------------------------------------------------------------------- +// Code generation targets +// --------------------------------------------------------------------------- + #[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] #[serde(rename_all = "lowercase")] pub enum CodeGenTarget { @@ -124,3 +286,106 @@ impl std::fmt::Display for CodeGenTarget { } } } + +// --------------------------------------------------------------------------- +// Conversion: flat agent-friendly params → internal drawing types +// --------------------------------------------------------------------------- + +/// Parse a hex color string like "#ff0000" or "#f00" into a Color32. +pub fn parse_hex_color(hex: &str) -> Option { + let hex = hex.trim_start_matches('#'); + match hex.len() { + 3 => { + let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?; + let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?; + let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?; + Some(Color32::from_rgb(r, g, b)) + } + 6 => { + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + Some(Color32::from_rgb(r, g, b)) + } + 8 => { + let r = u8::from_str_radix(&hex[0..2], 16).ok()?; + let g = u8::from_str_radix(&hex[2..4], 16).ok()?; + let b = u8::from_str_radix(&hex[4..6], 16).ok()?; + let a = u8::from_str_radix(&hex[6..8], 16).ok()?; + Some(Color32::from_rgba_unmultiplied(r, g, b, a)) + } + _ => None, + } +} + +/// Build a `Shape` from flat agent-friendly parameters. +#[allow(clippy::too_many_arguments)] +pub fn build_shape( + shape_type: &str, + x: Option, + y: Option, + width: Option, + height: Option, + center_x: Option, + center_y: Option, + radius_x: Option, + radius_y: Option, + x1: Option, + y1: Option, + x2: Option, + y2: Option, + text: Option, + font_size: Option, +) -> Result { + match shape_type { + "Rectangle" | "rectangle" | "rect" | "Rect" => Ok(Shape::Rectangle { + pos: Pos2::new(x.unwrap_or(0.0), y.unwrap_or(0.0)), + size: Vec2::new(width.unwrap_or(100.0), height.unwrap_or(100.0)), + }), + "Ellipse" | "ellipse" => Ok(Shape::Ellipse { + center: Pos2::new(center_x.or(x).unwrap_or(0.0), center_y.or(y).unwrap_or(0.0)), + radii: Vec2::new(radius_x.unwrap_or(50.0), radius_y.unwrap_or(50.0)), + }), + "Line" | "line" => { + let sx = x1.or(x).unwrap_or(0.0); + let sy = y1.or(y).unwrap_or(0.0); + Ok(Shape::Line { + start: Pos2::new(sx, sy), + end: Pos2::new(x2.unwrap_or(sx + 100.0), y2.unwrap_or(sy)), + }) + } + "Arrow" | "arrow" => { + let sx = x1.or(x).unwrap_or(0.0); + let sy = y1.or(y).unwrap_or(0.0); + Ok(Shape::Arrow { + start: Pos2::new(sx, sy), + end: Pos2::new(x2.unwrap_or(sx + 100.0), y2.unwrap_or(sy)), + }) + } + "Text" | "text" => Ok(Shape::Text { + pos: Pos2::new(x.unwrap_or(0.0), y.unwrap_or(0.0)), + content: text.unwrap_or_else(|| "Text".to_string()), + font_size: font_size.unwrap_or(20.0), + }), + other => Err(format!( + "Unknown shape_type '{}'. Expected: Rectangle, Ellipse, Line, Arrow, or Text", + other + )), + } +} + +/// Build a `ShapeStyle` from optional hex color strings. +pub fn build_style( + fill: Option, + stroke_color: Option, + stroke_width: Option, +) -> ShapeStyle { + ShapeStyle { + fill: fill.as_deref().and_then(parse_hex_color), + stroke_color: stroke_color + .as_deref() + .and_then(parse_hex_color) + .unwrap_or(Color32::WHITE), + stroke_width: stroke_width.unwrap_or(2.0), + } +} diff --git a/crates/agcanvas/src/agent/server.rs b/crates/agcanvas/src/agent/server.rs index c482997..8b8db30 100644 --- a/crates/agcanvas/src/agent/server.rs +++ b/crates/agcanvas/src/agent/server.rs @@ -1,10 +1,13 @@ -use super::protocol::{AgentRequest, AgentResponse, CodeGenTarget, GuiEvent}; +use super::protocol::{ + build_shape, build_style, AgentRequest, AgentResponse, CodeGenTarget, DrawingCommand, GuiEvent, +}; +use crate::drawing::DrawingElement; 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::sync::{broadcast, mpsc, RwLock}; use tokio_tungstenite::tungstenite::Message; const EVENT_CHANNEL_CAPACITY: usize = 64; @@ -12,15 +15,17 @@ const EVENT_CHANNEL_CAPACITY: usize = 64; pub struct AgentServer { sessions: Arc>, event_tx: broadcast::Sender, + command_tx: mpsc::UnboundedSender, port: u16, } impl AgentServer { - pub fn new(port: u16) -> Self { + pub fn new(port: u16, command_tx: mpsc::UnboundedSender) -> Self { let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY); Self { sessions: Arc::new(RwLock::new(SessionStore::new())), event_tx, + command_tx, port, } } @@ -41,9 +46,13 @@ impl AgentServer { while let Ok((stream, peer)) = listener.accept().await { tracing::info!("Agent connected from {}", peer); let sessions = self.sessions.clone(); + let event_tx = self.event_tx.clone(); let event_rx = self.event_tx.subscribe(); + let command_tx = self.command_tx.clone(); tokio::spawn(async move { - if let Err(e) = handle_connection(stream, sessions, event_rx).await { + if let Err(e) = + handle_connection(stream, sessions, event_rx, event_tx, command_tx).await + { tracing::error!("Connection error: {}", e); } }); @@ -57,6 +66,8 @@ async fn handle_connection( stream: TcpStream, sessions: Arc>, mut event_rx: broadcast::Receiver, + event_tx: broadcast::Sender, + command_tx: mpsc::UnboundedSender, ) -> Result<()> { let ws_stream = tokio_tungstenite::accept_async(stream).await?; let (mut write, mut read) = ws_stream.split(); @@ -78,7 +89,11 @@ async fn handle_connection( match msg { Some(Ok(Message::Text(text))) => { let response = match serde_json::from_str::(&text) { - Ok(request) => process_request(request, &sessions).await, + Ok(request) => { + process_request( + request, &sessions, &event_tx, &command_tx, + ).await + } Err(e) => AgentResponse::Error { message: format!("Invalid request: {}", e), }, @@ -117,18 +132,22 @@ async fn handle_connection( async fn process_request( request: AgentRequest, sessions: &Arc>, + event_tx: &broadcast::Sender, + command_tx: &mpsc::UnboundedSender, ) -> 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::ListSessions => { + let store = sessions.read().await; + AgentResponse::Sessions { + sessions: store.list_sessions(), + active_session: store.active_session_id().map(|s| s.to_string()), + } + } AgentRequest::GetTree { session_id } => { + let store = sessions.read().await; match store.get_tree(session_id.as_deref()) { Some((sid, tree)) => AgentResponse::Tree { session_id: sid, @@ -143,6 +162,7 @@ async fn process_request( } AgentRequest::GetElementById { session_id, id } => { + let store = sessions.read().await; match store.get_tree(session_id.as_deref()) { Some((sid, tree)) => AgentResponse::Element { session_id: sid, @@ -155,6 +175,7 @@ async fn process_request( } AgentRequest::GetElementsAtPoint { session_id, x, y } => { + let store = sessions.read().await; match store.get_tree(session_id.as_deref()) { Some((sid, tree)) => AgentResponse::Elements { session_id: sid, @@ -167,6 +188,7 @@ async fn process_request( } AgentRequest::Describe { session_id } => { + let store = sessions.read().await; match store.get_tree(session_id.as_deref()) { Some((sid, tree)) => AgentResponse::Description { session_id: sid, @@ -179,6 +201,7 @@ async fn process_request( } AgentRequest::GetDrawingElements { session_id } => { + let store = sessions.read().await; match store.get_drawing_elements(session_id.as_deref()) { Some((sid, elements)) => AgentResponse::DrawingElements { session_id: sid, @@ -190,7 +213,12 @@ async fn process_request( } } - AgentRequest::GenerateCode { session_id, target, element_id } => { + AgentRequest::GenerateCode { + session_id, + target, + element_id, + } => { + let store = sessions.read().await; match store.get_tree(session_id.as_deref()) { Some((sid, tree)) => { let element = match &element_id { @@ -217,6 +245,201 @@ async fn process_request( }, } } + + AgentRequest::CreateDrawingElement { + session_id, + shape_type, + x, + y, + width, + height, + center_x, + center_y, + radius_x, + radius_y, + x1, + y1, + x2, + y2, + text, + font_size, + fill, + stroke_color, + stroke_width, + } => { + let shape = match build_shape( + &shape_type, + x, + y, + width, + height, + center_x, + center_y, + radius_x, + radius_y, + x1, + y1, + x2, + y2, + text, + font_size, + ) { + Ok(s) => s, + Err(msg) => return AgentResponse::Error { message: msg }, + }; + let style = build_style(fill, stroke_color, stroke_width); + let element = DrawingElement::new(shape, style); + + let mut store = sessions.write().await; + let sid = match store.resolve_session_id(session_id.as_deref()) { + Some(id) => id, + None => { + return AgentResponse::Error { + message: "No session found".to_string(), + } + } + }; + + store.add_drawing_element(&sid, element.clone()); + let _ = command_tx.send(DrawingCommand::Create { + session_id: sid.clone(), + element: element.clone(), + }); + let _ = event_tx.send(GuiEvent::DrawingElementCreated { + session_id: sid.clone(), + element: element.clone(), + }); + + AgentResponse::DrawingElementCreated { + session_id: sid, + element, + } + } + + AgentRequest::UpdateDrawingElement { + session_id, + id, + shape_type, + x, + y, + width, + height, + center_x, + center_y, + radius_x, + radius_y, + x1, + y1, + x2, + y2, + text, + font_size, + fill, + stroke_color, + stroke_width, + } => { + let new_shape = if let Some(st) = shape_type { + match build_shape( + &st, x, y, width, height, center_x, center_y, radius_x, radius_y, x1, y1, x2, + y2, text, font_size, + ) { + Ok(s) => Some(s), + Err(msg) => return AgentResponse::Error { message: msg }, + } + } else { + None + }; + + let has_style_change = + fill.is_some() || stroke_color.is_some() || stroke_width.is_some(); + let new_style = if has_style_change { + Some(build_style(fill, stroke_color, stroke_width)) + } else { + None + }; + + let mut store = sessions.write().await; + let sid = match store.resolve_session_id(session_id.as_deref()) { + Some(id) => id, + None => { + return AgentResponse::Error { + message: "No session found".to_string(), + } + } + }; + + match store.update_drawing_element(&sid, &id, new_shape, new_style) { + Some(updated) => { + let _ = command_tx.send(DrawingCommand::Update { + session_id: sid.clone(), + element: updated.clone(), + }); + let _ = event_tx.send(GuiEvent::DrawingElementUpdated { + session_id: sid.clone(), + element: updated.clone(), + }); + AgentResponse::DrawingElementUpdated { + session_id: sid, + element: updated, + } + } + None => AgentResponse::Error { + message: format!("Element '{}' not found in session", id), + }, + } + } + + AgentRequest::DeleteDrawingElement { session_id, id } => { + let mut store = sessions.write().await; + let sid = match store.resolve_session_id(session_id.as_deref()) { + Some(id) => id, + None => { + return AgentResponse::Error { + message: "No session found".to_string(), + } + } + }; + + if store.delete_drawing_element(&sid, &id) { + let _ = command_tx.send(DrawingCommand::Delete { + session_id: sid.clone(), + id: id.clone(), + }); + let _ = event_tx.send(GuiEvent::DrawingElementDeleted { + session_id: sid.clone(), + id: id.clone(), + }); + AgentResponse::DrawingElementDeleted { + session_id: sid, + id, + } + } else { + AgentResponse::Error { + message: format!("Element '{}' not found in session", id), + } + } + } + + AgentRequest::ClearDrawingElements { session_id } => { + let mut store = sessions.write().await; + let sid = match store.resolve_session_id(session_id.as_deref()) { + Some(id) => id, + None => { + return AgentResponse::Error { + message: "No session found".to_string(), + } + } + }; + + store.clear_drawing_elements(&sid); + let _ = command_tx.send(DrawingCommand::Clear { + session_id: sid.clone(), + }); + let _ = event_tx.send(GuiEvent::DrawingElementsCleared { + session_id: sid.clone(), + }); + AgentResponse::DrawingElementsCleared { session_id: sid } + } } } diff --git a/crates/agcanvas/src/app.rs b/crates/agcanvas/src/app.rs index cb68693..11e2fa9 100644 --- a/crates/agcanvas/src/app.rs +++ b/crates/agcanvas/src/app.rs @@ -1,4 +1,4 @@ -use crate::agent::{AgentServer, GuiEvent}; +use crate::agent::{AgentServer, DrawingCommand, GuiEvent}; use crate::canvas::{CanvasInteraction, CanvasState}; use crate::clipboard::ClipboardManager; use crate::drawing::{ @@ -11,7 +11,7 @@ use crate::svg::{parse_svg, SvgRenderer}; use egui::{Color32, ColorImage, TextureOptions}; use std::sync::Arc; use tokio::runtime::Runtime; -use tokio::sync::{broadcast, RwLock}; +use tokio::sync::{broadcast, mpsc, RwLock}; const AGENT_PORT: u16 = 9876; const MIN_SHAPE_SIZE: f32 = 5.0; @@ -22,6 +22,7 @@ pub struct AgCanvasApp { session_counter: usize, sessions_handle: Arc>, event_tx: broadcast::Sender, + command_rx: mpsc::UnboundedReceiver, clipboard: Option, show_tree_panel: bool, show_description: bool, @@ -33,6 +34,7 @@ pub struct AgCanvasApp { show_text_input: bool, text_input_buffer: String, text_input_pos: Option, + last_drawing_sync: std::time::Instant, } impl AgCanvasApp { @@ -40,7 +42,8 @@ impl AgCanvasApp { configure_fonts(&cc.egui_ctx); let runtime = Runtime::new().expect("Failed to create tokio runtime"); - let server = AgentServer::new(AGENT_PORT); + let (command_tx, command_rx) = mpsc::unbounded_channel(); + let server = AgentServer::new(AGENT_PORT, command_tx); let sessions_handle = server.sessions_handle(); let event_tx = server.event_sender(); @@ -58,6 +61,7 @@ impl AgCanvasApp { session_counter: 0, sessions_handle, event_tx, + command_rx, clipboard, show_tree_panel: false, show_description: false, @@ -68,6 +72,7 @@ impl AgCanvasApp { show_text_input: false, text_input_buffer: String::new(), text_input_pos: None, + last_drawing_sync: std::time::Instant::now(), }; app.create_session(); @@ -94,7 +99,9 @@ impl AgCanvasApp { store.add_session(info_clone.clone(), None); store.set_active(&info_clone.id); }); - let _ = event_tx.send(GuiEvent::SessionCreated { session: info_clone }); + let _ = event_tx.send(GuiEvent::SessionCreated { + session: info_clone, + }); }); self.set_status("Created new session".to_string()); @@ -171,10 +178,9 @@ impl AgCanvasApp { 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, - ); + 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(); @@ -232,7 +238,9 @@ impl AgCanvasApp { let pixels: Vec = pixmap .pixels() .iter() - .map(|p| Color32::from_rgba_premultiplied(p.red(), p.green(), p.blue(), p.alpha())) + .map(|p| { + Color32::from_rgba_premultiplied(p.red(), p.green(), p.blue(), p.alpha()) + }) .collect(); let image = ColorImage { size, pixels }; @@ -269,6 +277,69 @@ impl AgCanvasApp { } } + fn drain_drawing_commands(&mut self) { + while let Ok(cmd) = self.command_rx.try_recv() { + match cmd { + DrawingCommand::Create { + session_id, + element, + } => { + if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) { + session.drawing_elements.push(element); + } + } + DrawingCommand::Update { + session_id, + element, + } => { + if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) { + if let Some(el) = session + .drawing_elements + .iter_mut() + .find(|e| e.id == element.id) + { + el.shape = element.shape; + el.style = element.style; + } + } + } + DrawingCommand::Delete { session_id, id } => { + if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) { + session.drawing_elements.retain(|e| e.id != id); + if session.selected_element_id.as_deref() == Some(&id) { + session.selected_element_id = None; + } + } + } + DrawingCommand::Clear { session_id } => { + if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) { + session.drawing_elements.clear(); + session.selected_element_id = None; + } + } + } + } + } + + fn sync_drawing_elements_to_store(&self) { + let sessions_handle = self.sessions_handle.clone(); + let elements_by_session: Vec<(String, Vec)> = self + .sessions + .iter() + .map(|s| (s.id.clone(), s.drawing_elements.clone())) + .collect(); + + std::thread::spawn(move || { + let rt = Runtime::new().unwrap(); + rt.block_on(async { + let mut store = sessions_handle.write().await; + for (sid, elements) in elements_by_session { + store.update_drawing_elements(&sid, elements); + } + }); + }); + } + 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; @@ -331,9 +402,7 @@ fn handle_select_tool( 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, - }; + session.drag_state = DragState::Moving { element_id: eid }; } else { session.selected_element_id = None; session.drag_state = DragState::None; @@ -464,6 +533,8 @@ fn handle_shape_tool( impl eframe::App for AgCanvasApp { fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + self.drain_drawing_commands(); + let mut paste = false; let mut new_tab = false; let mut close_tab = false; @@ -929,6 +1000,11 @@ impl eframe::App for AgCanvasApp { } }); + if self.last_drawing_sync.elapsed().as_millis() > 500 { + self.last_drawing_sync = std::time::Instant::now(); + self.sync_drawing_elements_to_store(); + } + ctx.request_repaint(); } } @@ -979,14 +1055,26 @@ fn draw_grid(painter: &egui::Painter, rect: &egui::Rect, state: &CanvasState) { 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()) + 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 }) + 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(), diff --git a/crates/agcanvas/src/canvas/mod.rs b/crates/agcanvas/src/canvas/mod.rs index 9e852c1..14c4b22 100644 --- a/crates/agcanvas/src/canvas/mod.rs +++ b/crates/agcanvas/src/canvas/mod.rs @@ -1,5 +1,5 @@ -mod state; mod interaction; +mod state; -pub use state::CanvasState; pub use interaction::CanvasInteraction; +pub use state::CanvasState; diff --git a/crates/agcanvas/src/main.rs b/crates/agcanvas/src/main.rs index 8bd4041..0d6b121 100644 --- a/crates/agcanvas/src/main.rs +++ b/crates/agcanvas/src/main.rs @@ -1,12 +1,12 @@ +mod agent; mod app; mod canvas; +mod clipboard; mod drawing; mod element_tree; mod mermaid; -mod svg; -mod clipboard; -mod agent; mod session; +mod svg; use anyhow::Result; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; diff --git a/crates/agcanvas/src/session.rs b/crates/agcanvas/src/session.rs index 78b9a7d..67ee645 100644 --- a/crates/agcanvas/src/session.rs +++ b/crates/agcanvas/src/session.rs @@ -74,6 +74,7 @@ impl Session { .and_then(|id| self.drawing_elements.iter().find(|e| e.id == *id)) } + #[allow(dead_code)] 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)) @@ -166,6 +167,69 @@ impl SessionStore { Some((id, &data.drawing_elements)) } + pub fn add_drawing_element(&mut self, session_id: &str, element: DrawingElement) -> bool { + if let Some(data) = self.sessions.get_mut(session_id) { + data.drawing_elements.push(element); + data.info.drawing_element_count = data.drawing_elements.len(); + true + } else { + false + } + } + + pub fn update_drawing_element( + &mut self, + session_id: &str, + element_id: &str, + new_shape: Option, + new_style: Option, + ) -> Option { + let data = self.sessions.get_mut(session_id)?; + let el = data + .drawing_elements + .iter_mut() + .find(|e| e.id == element_id)?; + if let Some(shape) = new_shape { + el.shape = shape; + } + if let Some(style) = new_style { + el.style = style; + } + Some(el.clone()) + } + + pub fn delete_drawing_element(&mut self, session_id: &str, element_id: &str) -> bool { + if let Some(data) = self.sessions.get_mut(session_id) { + let before = data.drawing_elements.len(); + data.drawing_elements.retain(|e| e.id != element_id); + data.info.drawing_element_count = data.drawing_elements.len(); + data.drawing_elements.len() < before + } else { + false + } + } + + pub fn clear_drawing_elements(&mut self, session_id: &str) -> bool { + if let Some(data) = self.sessions.get_mut(session_id) { + data.drawing_elements.clear(); + data.info.drawing_element_count = 0; + true + } else { + false + } + } + + pub fn resolve_session_id(&self, session_id: Option<&str>) -> Option { + let id = session_id + .map(|s| s.to_string()) + .or_else(|| self.active_session_id.clone())?; + if self.sessions.contains_key(&id) { + Some(id) + } else { + None + } + } + pub fn list_sessions(&self) -> Vec { self.sessions.values().map(|d| d.info.clone()).collect() } diff --git a/examples/mcp-configs/opencode.json b/examples/mcp-configs/opencode.json deleted file mode 100644 index a997bc3..0000000 --- a/examples/mcp-configs/opencode.json +++ /dev/null @@ -1,9 +0,0 @@ -{ - "$schema": "https://opencode.ai/config.schema.json", - "mcpServers": { - "agcanvas": { - "command": "agcanvas-mcp", - "args": ["--port", "9876"] - } - } -}