diff --git a/crates/agcanvas-mcp/src/tools.rs b/crates/agcanvas-mcp/src/tools.rs index a4ff2f3..6874847 100644 --- a/crates/agcanvas-mcp/src/tools.rs +++ b/crates/agcanvas-mcp/src/tools.rs @@ -187,9 +187,27 @@ pub struct BooleanOpParam { pub stroke_width: Option, } +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct RenderMermaidParam { + #[schemars(description = "Session ID to target. If omitted, uses the active session.")] + pub session_id: Option, + #[schemars(description = "Mermaid diagram source code (e.g., 'flowchart LR\\n A-->B')")] + pub mermaid_source: String, + #[schemars(description = "X position on canvas (default: 0)")] + pub x: Option, + #[schemars(description = "Y position on canvas (default: 0)")] + pub y: Option, + #[schemars(description = "Override width (default: natural SVG width)")] + pub width: Option, + #[schemars(description = "Override height (default: natural SVG height)")] + pub height: Option, +} + #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct BatchParam { - #[schemars(description = "JSON array of request objects. Each object must have a 'type' field and the same parameters as individual tool calls. Example: [{\"type\": \"CreateDrawingElement\", \"shape_type\": \"rectangle\", \"x\": 0, \"y\": 0, \"width\": 100, \"height\": 100}, {\"type\": \"CreateDrawingElement\", \"shape_type\": \"ellipse\", \"center_x\": 200, \"center_y\": 200, \"radius_x\": 50, \"radius_y\": 50}]")] + #[schemars( + description = "JSON array of request objects. Each object must have a 'type' field and the same parameters as individual tool calls. Example: [{\"type\": \"CreateDrawingElement\", \"shape_type\": \"rectangle\", \"x\": 0, \"y\": 0, \"width\": 100, \"height\": 100}, {\"type\": \"CreateDrawingElement\", \"shape_type\": \"ellipse\", \"center_x\": 200, \"center_y\": 200, \"radius_x\": 50, \"radius_y\": 50}]" + )] pub requests_json: String, } @@ -527,6 +545,26 @@ impl AgCanvasServer { self.call_agcanvas(&request).await } + #[tool( + name = "render_mermaid", + description = "Render a Mermaid diagram (flowchart, sequence, etc.) as an SVG overlay at a specific position on the canvas. The diagram appears as a visual element that can coexist with other shapes and diagrams." + )] + async fn render_mermaid( + &self, + Parameters(params): Parameters, + ) -> Result { + let request = serde_json::json!({ + "type": "RenderMermaid", + "session_id": params.session_id, + "mermaid_source": params.mermaid_source, + "x": params.x, + "y": params.y, + "width": params.width, + "height": params.height, + }); + self.call_agcanvas(&request).await + } + #[tool( description = "Send multiple Augmented Canvas operations in one request. Accepts a JSON array of request objects and returns one response per operation in a BatchResult." )] diff --git a/crates/agcanvas/src/agent/protocol.rs b/crates/agcanvas/src/agent/protocol.rs index e95c2a7..b731e36 100644 --- a/crates/agcanvas/src/agent/protocol.rs +++ b/crates/agcanvas/src/agent/protocol.rs @@ -211,6 +211,19 @@ pub enum AgentRequest { #[serde(default)] stroke_width: Option, }, + RenderMermaid { + #[serde(default)] + session_id: Option, + mermaid_source: String, + #[serde(default)] + x: Option, + #[serde(default)] + y: Option, + #[serde(default)] + width: Option, + #[serde(default)] + height: Option, + }, Batch { requests: Vec, }, @@ -275,6 +288,11 @@ pub enum AgentResponse { DrawingElementsCleared { session_id: String, }, + MermaidRendered { + session_id: String, + overlay_id: String, + svg_source: String, + }, BatchResult { results: Vec, }, @@ -305,6 +323,14 @@ pub enum DrawingCommand { Clear { session_id: String, }, + RenderMermaid { + session_id: String, + overlay_id: String, + mermaid_source: String, + svg_source: String, + position: Pos2, + size: Vec2, + }, } // --------------------------------------------------------------------------- diff --git a/crates/agcanvas/src/agent/server.rs b/crates/agcanvas/src/agent/server.rs index f15454e..7fc771c 100644 --- a/crates/agcanvas/src/agent/server.rs +++ b/crates/agcanvas/src/agent/server.rs @@ -6,12 +6,15 @@ use crate::drawing::{boolean, DrawingElement, Shape, ShapeStyle}; use crate::session::{SessionCreator, SessionStore}; use anyhow::Result; use futures_util::{SinkExt, StreamExt}; +use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use tokio::net::{TcpListener, TcpStream}; use tokio::sync::{broadcast, mpsc, RwLock}; use tokio_tungstenite::tungstenite::Message; +use usvg::Tree; const EVENT_CHANNEL_CAPACITY: usize = 64; +static OVERLAY_ID_COUNTER: AtomicUsize = AtomicUsize::new(0); pub struct AgentServer { sessions: Arc>, @@ -682,6 +685,78 @@ async fn process_single_request( element, } } + AgentRequest::RenderMermaid { + session_id, + mermaid_source, + x, + y, + width, + height, + } => { + let sid = { + let store = sessions.read().await; + match store.resolve_session_id(session_id.as_deref()) { + Some(id) => id, + None => { + return AgentResponse::Error { + message: "No session found".to_string(), + } + } + } + }; + + let svg_source = match crate::mermaid::render_mermaid_to_svg(&mermaid_source) { + Ok(svg) => svg, + Err(e) => { + return AgentResponse::Error { + message: format!("Failed to render Mermaid: {}", e), + } + } + }; + + let options = usvg::Options::default(); + let tree = match Tree::from_str(&svg_source, &options) { + Ok(tree) => tree, + Err(e) => { + return AgentResponse::Error { + message: format!("Failed to parse Mermaid SVG: {}", e), + } + } + }; + + let natural_size = tree.size(); + let overlay_id = format!( + "mermaid_{}", + OVERLAY_ID_COUNTER.fetch_add(1, Ordering::SeqCst) + ); + let position = egui::pos2(x.unwrap_or(0.0), y.unwrap_or(0.0)); + let size = egui::vec2( + width.unwrap_or(natural_size.width()), + height.unwrap_or(natural_size.height()), + ); + + if command_tx + .send(DrawingCommand::RenderMermaid { + session_id: sid.clone(), + overlay_id: overlay_id.clone(), + mermaid_source, + svg_source: svg_source.clone(), + position, + size, + }) + .is_err() + { + return AgentResponse::Error { + message: "Failed to enqueue Mermaid render command".to_string(), + }; + } + + AgentResponse::MermaidRendered { + session_id: sid, + overlay_id, + svg_source, + } + } AgentRequest::Batch { .. } => AgentResponse::Error { message: "Nested batch requests are not supported".to_string(), }, diff --git a/crates/agcanvas/src/app.rs b/crates/agcanvas/src/app.rs index 7bdd952..1fed0a5 100644 --- a/crates/agcanvas/src/app.rs +++ b/crates/agcanvas/src/app.rs @@ -3,13 +3,14 @@ use crate::canvas::{CanvasInteraction, CanvasState}; use crate::clipboard::ClipboardManager; use crate::command_palette::{CommandId, CommandPalette}; use crate::drawing::{ - draw_creation_preview, draw_elements, draw_marquee, draw_selection, find_handle_at_screen_pos, - screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle, Tool, + canvas_to_screen, draw_creation_preview, draw_elements, draw_marquee, draw_selection, + find_handle_at_screen_pos, screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle, + Tool, }; use crate::history::{ChangeSource, HistoryTree, NodeId}; use crate::mermaid::render_mermaid_to_svg; use crate::persistence::{self, SavedSession, SavedWorkspace}; -use crate::session::{Session, SessionCreator, SessionStore}; +use crate::session::{MermaidOverlay, Session, SessionCreator, SessionStore}; use crate::svg::{parse_svg, SvgRenderer}; use egui::{Color32, ColorImage, TextureOptions}; use std::path::Path; @@ -290,6 +291,43 @@ impl AgCanvasApp { self.status_message = Some((message, std::time::Instant::now())); } + fn render_mermaid_overlays_to_texture(&mut self, ctx: &egui::Context) { + let ppp = ctx.pixels_per_point(); + let scale = self.active_session().canvas_state.zoom.max(1.0) * ppp; + let session = self.active_session_mut(); + let session_id = session.id.clone(); + + for overlay in &mut session.mermaid_overlays { + if overlay.texture.is_some() { + continue; + } + + if let Ok(pixmap) = overlay.renderer.render(scale) { + let size = [pixmap.width() as usize, pixmap.height() as usize]; + let pixels: Vec = pixmap + .pixels() + .iter() + .map(|p| { + Color32::from_rgba_premultiplied(p.red(), p.green(), p.blue(), p.alpha()) + }) + .collect(); + + let image = ColorImage { size, pixels }; + overlay.texture = Some(ctx.load_texture( + format!( + "mermaid-{}-{}-{}-{}", + session_id, + overlay.id, + overlay.mermaid_source.len(), + overlay.svg_source.len() + ), + image, + TextureOptions::LINEAR, + )); + } + } + } + fn handle_mermaid_render(&mut self, ctx: &egui::Context) { let source = self.mermaid_input.trim().to_string(); if source.is_empty() { @@ -347,7 +385,9 @@ impl AgCanvasApp { 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); - session.selected_element_ids.retain(|selected_id| selected_id != &id); + session + .selected_element_ids + .retain(|selected_id| selected_id != &id); session.record_edit( "Agent: Delete Element", ChangeSource::Agent { name: None }, @@ -362,6 +402,38 @@ impl AgCanvasApp { .record_edit("Agent: Clear Canvas", ChangeSource::Agent { name: None }); } } + DrawingCommand::RenderMermaid { + session_id, + overlay_id, + mermaid_source, + svg_source, + position, + size, + } => { + if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) { + match parse_svg(&svg_source) { + Ok((_, usvg_tree)) => { + let overlay = MermaidOverlay { + id: overlay_id, + mermaid_source, + svg_source, + renderer: SvgRenderer::new(usvg_tree), + texture: None, + position, + size, + }; + session.mermaid_overlays.push(overlay); + session.record_edit( + "Agent: Render Mermaid", + ChangeSource::Agent { name: None }, + ); + } + Err(e) => { + tracing::warn!("Failed to parse Mermaid overlay SVG: {}", e); + } + } + } + } } } } @@ -1030,6 +1102,14 @@ impl eframe::App for AgCanvasApp { { self.render_svg_to_texture(ctx); } + if self + .active_session() + .mermaid_overlays + .iter() + .any(|overlay| overlay.texture.is_none()) + { + self.render_mermaid_overlays_to_texture(ctx); + } egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { egui::menu::bar(ui, |ui| { @@ -1437,7 +1517,9 @@ impl eframe::App for AgCanvasApp { let canvas_center = response.rect.center(); if response.dragged_by(egui::PointerButton::Middle) { - self.active_session_mut().canvas_state.pan(response.drag_delta()); + self.active_session_mut() + .canvas_state + .pan(response.drag_delta()); } if response.hovered() { let scroll_delta = ui.input(|i| i.smooth_scroll_delta.y); @@ -1458,7 +1540,9 @@ impl eframe::App for AgCanvasApp { && response.dragged_by(egui::PointerButton::Primary) && ui.input(|i| i.modifiers.command) { - self.active_session_mut().canvas_state.pan(response.drag_delta()); + self.active_session_mut() + .canvas_state + .pan(response.drag_delta()); } if !self.show_mermaid_dialog && !self.show_text_input { @@ -1493,6 +1577,21 @@ impl eframe::App for AgCanvasApp { let offset = self.active_session().canvas_state.offset; let zoom = self.active_session().canvas_state.zoom; + for overlay in &self.active_session().mermaid_overlays { + if let Some(texture) = &overlay.texture { + let screen_pos = + canvas_to_screen(overlay.position, canvas_center, offset, zoom); + let screen_size = overlay.size * zoom; + let rect = egui::Rect::from_min_size(screen_pos, screen_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, + ); + } + } + draw_elements( &painter, &self.active_session().drawing_elements, @@ -1505,7 +1604,8 @@ impl eframe::App for AgCanvasApp { draw_selection(&painter, selected_el, canvas_center, offset, zoom); } - if let DragState::MarqueeSelecting { start, current } = &self.active_session().drag_state + if let DragState::MarqueeSelecting { start, current } = + &self.active_session().drag_state { draw_marquee(&painter, *start, *current, canvas_center, offset, zoom); } @@ -1521,6 +1621,7 @@ impl eframe::App for AgCanvasApp { ); if self.active_session().svg_texture.is_none() + && self.active_session().mermaid_overlays.is_empty() && self.active_session().drawing_elements.is_empty() { painter.text( diff --git a/crates/agcanvas/src/drawing/mod.rs b/crates/agcanvas/src/drawing/mod.rs index db2ac9c..89fa352 100644 --- a/crates/agcanvas/src/drawing/mod.rs +++ b/crates/agcanvas/src/drawing/mod.rs @@ -6,7 +6,7 @@ mod tool; pub use boolean::BooleanOpType; pub use element::{DrawingElement, Shape, ShapeStyle}; pub use render::{ - draw_creation_preview, draw_elements, draw_marquee, draw_selection, find_handle_at_screen_pos, - screen_to_canvas, + canvas_to_screen, draw_creation_preview, draw_elements, draw_marquee, draw_selection, + find_handle_at_screen_pos, screen_to_canvas, }; pub use tool::{DragState, Tool}; diff --git a/crates/agcanvas/src/session.rs b/crates/agcanvas/src/session.rs index 16d36a1..39d6825 100644 --- a/crates/agcanvas/src/session.rs +++ b/crates/agcanvas/src/session.rs @@ -77,6 +77,16 @@ pub struct SessionInfo { pub created_at: i64, } +pub struct MermaidOverlay { + pub id: String, + pub mermaid_source: String, + pub svg_source: String, + pub renderer: SvgRenderer, + pub texture: Option, + pub position: egui::Pos2, + pub size: egui::Vec2, +} + pub struct Session { pub id: String, pub name: String, @@ -88,6 +98,7 @@ pub struct Session { pub description_text: String, pub drawing_elements: Vec, + pub mermaid_overlays: Vec, pub selected_element_ids: Vec, pub active_tool: Tool, pub drag_state: DragState, @@ -110,6 +121,7 @@ impl Session { svg_source: None, description_text: String::new(), drawing_elements: Vec::new(), + mermaid_overlays: Vec::new(), selected_element_ids: Vec::new(), active_tool: Tool::default(), drag_state: DragState::default(), @@ -145,6 +157,7 @@ impl Session { self.svg_source = None; self.description_text.clear(); self.drawing_elements.clear(); + self.mermaid_overlays.clear(); self.selected_element_ids.clear(); self.drag_state = DragState::default(); self.canvas_state.reset(); @@ -193,6 +206,7 @@ impl Session { self.svg_renderer = None; self.svg_texture = None; self.description_text.clear(); + self.mermaid_overlays.clear(); self.selected_element_ids.clear(); self.drag_state = DragState::default(); } @@ -205,6 +219,7 @@ impl Session { self.svg_renderer = None; self.svg_texture = None; self.description_text.clear(); + self.mermaid_overlays.clear(); self.selected_element_ids.clear(); self.drag_state = DragState::default(); true @@ -221,6 +236,7 @@ impl Session { self.svg_renderer = None; self.svg_texture = None; self.description_text.clear(); + self.mermaid_overlays.clear(); self.selected_element_ids.clear(); self.drag_state = DragState::default(); true