Compare commits

..

2 Commits

Author SHA1 Message Date
David Ibia
740fa2f5f9 feat: add Mermaid overlay support for agents to inject positioned diagrams
- Agents can send RenderMermaid with Mermaid source + canvas position
  to create SVG texture overlays that coexist with other elements
- MermaidOverlay struct holds source, rendered SVG, SvgRenderer, and
  lazy-loaded egui texture at a specific canvas position/size
- Server handles rendering via mermaid-rs, parses SVG for dimensions,
  sends overlay data through DrawingCommand channel to GUI thread
- Canvas renders overlays as positioned textures between base SVG and
  drawing elements, with proper pan/zoom transforms
- New MCP tool render_mermaid for agent access
- Overlays cleared on undo/redo/checkout to stay consistent with history
- 29 tests passing, clippy clean
2026-02-10 10:44:39 +01:00
David Ibia
5ca1e85209 feat: clickable zoom reset, Pan tool (H), and batch command support
- Clicking the zoom percentage in the menu bar resets zoom to 100%
- New Pan tool (H key) for explicit left-click-drag panning mode
- Batch command support: agents can send multiple operations in a
  single WebSocket message via {"type": "Batch", "requests": [...]}
  with sequential execution and collected results
- New MCP tool 'batch' accepts a JSON array of request objects
- Nested batches rejected with clear error message
- Updated AGENTS.md with .app rebuild requirement
2026-02-10 10:27:06 +01:00
9 changed files with 377 additions and 11 deletions

View File

@@ -30,8 +30,12 @@ cargo test -- --nocapture # Show println! output
cargo check # Type check only (fast)
cargo doc --open # Generate and open docs
./scripts/bundle-macos.sh --install # Rebuild + install .app to /Applications
```
**IMPORTANT:** After every release build or code change that requires testing the running app, you MUST run `./scripts/bundle-macos.sh --install` to update the macOS `.app` bundle in `/Applications`. The running `Augmented Canvas.app` uses a copied binary — a bare `cargo build --release` alone does NOT update it. Kill the running app first with `pkill -f "Augmented Canvas"`, then rebuild and relaunch with `open "/Applications/Augmented Canvas.app"`.
## Project Structure
```

View File

@@ -187,6 +187,30 @@ pub struct BooleanOpParam {
pub stroke_width: Option<f32>,
}
#[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<String>,
#[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<f32>,
#[schemars(description = "Y position on canvas (default: 0)")]
pub y: Option<f32>,
#[schemars(description = "Override width (default: natural SVG width)")]
pub width: Option<f32>,
#[schemars(description = "Override height (default: natural SVG height)")]
pub height: Option<f32>,
}
#[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}]"
)]
pub requests_json: String,
}
#[derive(Debug, Clone)]
pub struct AgCanvasServer {
ws_url: String,
@@ -521,6 +545,55 @@ 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<RenderMermaidParam>,
) -> Result<CallToolResult, McpError> {
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."
)]
async fn batch(
&self,
Parameters(params): Parameters<BatchParam>,
) -> Result<CallToolResult, McpError> {
let requests = match serde_json::from_str::<serde_json::Value>(&params.requests_json) {
Ok(serde_json::Value::Array(requests)) => requests,
Ok(_) => {
return Ok(CallToolResult::error(vec![Content::text(
"Invalid requests_json: expected a JSON array of request objects",
)]))
}
Err(e) => {
return Ok(CallToolResult::error(vec![Content::text(format!(
"Invalid requests_json: {}",
e
))]))
}
};
let request = serde_json::json!({
"type": "Batch",
"requests": requests,
});
self.call_agcanvas(&request).await
}
#[tool(description = "Delete a drawing element by its ID.")]
async fn delete_drawing_element(
&self,

View File

@@ -211,6 +211,22 @@ pub enum AgentRequest {
#[serde(default)]
stroke_width: Option<f32>,
},
RenderMermaid {
#[serde(default)]
session_id: Option<String>,
mermaid_source: String,
#[serde(default)]
x: Option<f32>,
#[serde(default)]
y: Option<f32>,
#[serde(default)]
width: Option<f32>,
#[serde(default)]
height: Option<f32>,
},
Batch {
requests: Vec<AgentRequest>,
},
Ping,
}
@@ -272,6 +288,14 @@ pub enum AgentResponse {
DrawingElementsCleared {
session_id: String,
},
MermaidRendered {
session_id: String,
overlay_id: String,
svg_source: String,
},
BatchResult {
results: Vec<AgentResponse>,
},
Pong,
Error {
message: String,
@@ -299,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,
},
}
// ---------------------------------------------------------------------------

View File

@@ -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<RwLock<SessionStore>>,
@@ -151,6 +154,46 @@ async fn process_request(
event_tx: &broadcast::Sender<GuiEvent>,
command_tx: &mpsc::UnboundedSender<DrawingCommand>,
session_command_tx: &mpsc::UnboundedSender<SessionCommand>,
) -> AgentResponse {
match request {
AgentRequest::Batch { requests } => {
if requests
.iter()
.any(|request| matches!(request, AgentRequest::Batch { .. }))
{
return AgentResponse::Error {
message: "Nested batch requests are not supported".to_string(),
};
}
let mut results = Vec::with_capacity(requests.len());
for request in requests {
let response = process_single_request(
request,
sessions,
event_tx,
command_tx,
session_command_tx,
)
.await;
results.push(response);
}
AgentResponse::BatchResult { results }
}
request => {
process_single_request(request, sessions, event_tx, command_tx, session_command_tx)
.await
}
}
}
async fn process_single_request(
request: AgentRequest,
sessions: &Arc<RwLock<SessionStore>>,
event_tx: &broadcast::Sender<GuiEvent>,
command_tx: &mpsc::UnboundedSender<DrawingCommand>,
session_command_tx: &mpsc::UnboundedSender<SessionCommand>,
) -> AgentResponse {
match request {
AgentRequest::Ping => AgentResponse::Pong,
@@ -642,6 +685,81 @@ async fn process_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(),
},
}
}

View File

@@ -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<Color32> = 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);
}
}
}
}
}
}
}
@@ -548,6 +620,7 @@ impl AgCanvasApp {
CommandId::PasteSvg => self.handle_paste(ctx),
CommandId::PasteMermaid => self.show_mermaid_dialog = true,
CommandId::ToolSelect => self.active_session_mut().active_tool = Tool::Select,
CommandId::ToolPan => self.active_session_mut().active_tool = Tool::Pan,
CommandId::ToolRectangle => self.active_session_mut().active_tool = Tool::Rectangle,
CommandId::ToolEllipse => self.active_session_mut().active_tool = Tool::Ellipse,
CommandId::ToolLine => self.active_session_mut().active_tool = Tool::Line,
@@ -579,6 +652,11 @@ impl AgCanvasApp {
Tool::Select => {
handle_select_tool(session, response, canvas_center, offset, zoom, pointer_pos);
}
Tool::Pan => {
if response.dragged_by(egui::PointerButton::Primary) {
session.canvas_state.pan(response.drag_delta());
}
}
Tool::Rectangle | Tool::Ellipse | Tool::Line | Tool::Arrow => {
handle_shape_tool(session, response, canvas_center, offset, zoom, pointer_pos);
}
@@ -894,6 +972,9 @@ impl eframe::App for AgCanvasApp {
if i.key_pressed(egui::Key::V) {
tool_switch = Some(Tool::Select);
}
if i.key_pressed(egui::Key::H) {
tool_switch = Some(Tool::Pan);
}
if i.key_pressed(egui::Key::R) {
tool_switch = Some(Tool::Rectangle);
}
@@ -1021,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| {
@@ -1110,10 +1199,17 @@ impl eframe::App for AgCanvasApp {
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!(
let zoom_label = format!(
"Zoom: {:.0}%",
self.active_session().canvas_state.zoom * 100.0
));
);
if ui
.add(egui::Button::new(zoom_label).frame(false))
.on_hover_text("Click to reset zoom")
.clicked()
{
self.active_session_mut().canvas_state.reset();
}
});
});
});
@@ -1186,6 +1282,7 @@ impl eframe::App for AgCanvasApp {
let active_tool = self.active_session().active_tool;
let tools = [
Tool::Select,
Tool::Pan,
Tool::Rectangle,
Tool::Ellipse,
Tool::Line,
@@ -1420,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);
@@ -1441,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 {
@@ -1476,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,
@@ -1488,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);
}
@@ -1504,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(

View File

@@ -11,6 +11,7 @@ pub enum CommandId {
PasteSvg,
PasteMermaid,
ToolSelect,
ToolPan,
ToolRectangle,
ToolEllipse,
ToolLine,
@@ -68,6 +69,7 @@ pub fn all_commands() -> Vec<PaletteCommand> {
"Canvas",
),
PaletteCommand::new(CommandId::ToolSelect, "Select Tool", Some("V"), "Tool"),
PaletteCommand::new(CommandId::ToolPan, "Pan Tool", Some("H"), "Tool"),
PaletteCommand::new(
CommandId::ToolRectangle,
"Rectangle Tool",

View File

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

View File

@@ -5,6 +5,7 @@ use serde::{Deserialize, Serialize};
pub enum Tool {
#[default]
Select,
Pan,
Rectangle,
Ellipse,
Line,
@@ -16,6 +17,7 @@ impl Tool {
pub fn label(&self) -> &'static str {
match self {
Tool::Select => "Select",
Tool::Pan => "Pan",
Tool::Rectangle => "Rect",
Tool::Ellipse => "Ellipse",
Tool::Line => "Line",
@@ -27,6 +29,7 @@ impl Tool {
pub fn shortcut(&self) -> Option<char> {
match self {
Tool::Select => Some('V'),
Tool::Pan => Some('H'),
Tool::Rectangle => Some('R'),
Tool::Ellipse => Some('E'),
Tool::Line => Some('L'),

View File

@@ -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<TextureHandle>,
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<DrawingElement>,
pub mermaid_overlays: Vec<MermaidOverlay>,
pub selected_element_ids: Vec<String>,
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