diff --git a/AGENTS.md b/AGENTS.md index e072c8f..7966556 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -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 ``` diff --git a/crates/agcanvas-mcp/src/tools.rs b/crates/agcanvas-mcp/src/tools.rs index 5f548fe..a4ff2f3 100644 --- a/crates/agcanvas-mcp/src/tools.rs +++ b/crates/agcanvas-mcp/src/tools.rs @@ -187,6 +187,12 @@ pub struct BooleanOpParam { pub stroke_width: 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}]")] + pub requests_json: String, +} + #[derive(Debug, Clone)] pub struct AgCanvasServer { ws_url: String, @@ -521,6 +527,35 @@ impl AgCanvasServer { 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, + ) -> Result { + let requests = match serde_json::from_str::(¶ms.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, diff --git a/crates/agcanvas/src/agent/protocol.rs b/crates/agcanvas/src/agent/protocol.rs index 41d7c9a..e95c2a7 100644 --- a/crates/agcanvas/src/agent/protocol.rs +++ b/crates/agcanvas/src/agent/protocol.rs @@ -211,6 +211,9 @@ pub enum AgentRequest { #[serde(default)] stroke_width: Option, }, + Batch { + requests: Vec, + }, Ping, } @@ -272,6 +275,9 @@ pub enum AgentResponse { DrawingElementsCleared { session_id: String, }, + BatchResult { + results: Vec, + }, Pong, Error { message: String, diff --git a/crates/agcanvas/src/agent/server.rs b/crates/agcanvas/src/agent/server.rs index b3844fd..f15454e 100644 --- a/crates/agcanvas/src/agent/server.rs +++ b/crates/agcanvas/src/agent/server.rs @@ -151,6 +151,46 @@ async fn process_request( event_tx: &broadcast::Sender, command_tx: &mpsc::UnboundedSender, session_command_tx: &mpsc::UnboundedSender, +) -> 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>, + event_tx: &broadcast::Sender, + command_tx: &mpsc::UnboundedSender, + session_command_tx: &mpsc::UnboundedSender, ) -> AgentResponse { match request { AgentRequest::Ping => AgentResponse::Pong, @@ -642,6 +682,9 @@ async fn process_request( element, } } + 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 c04232b..7bdd952 100644 --- a/crates/agcanvas/src/app.rs +++ b/crates/agcanvas/src/app.rs @@ -548,6 +548,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 +580,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 +900,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); } @@ -1110,10 +1119,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 +1202,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, diff --git a/crates/agcanvas/src/command_palette.rs b/crates/agcanvas/src/command_palette.rs index dc90169..3633e0e 100644 --- a/crates/agcanvas/src/command_palette.rs +++ b/crates/agcanvas/src/command_palette.rs @@ -11,6 +11,7 @@ pub enum CommandId { PasteSvg, PasteMermaid, ToolSelect, + ToolPan, ToolRectangle, ToolEllipse, ToolLine, @@ -68,6 +69,7 @@ pub fn all_commands() -> Vec { "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", diff --git a/crates/agcanvas/src/drawing/tool.rs b/crates/agcanvas/src/drawing/tool.rs index a8d7f55..b6819a6 100644 --- a/crates/agcanvas/src/drawing/tool.rs +++ b/crates/agcanvas/src/drawing/tool.rs @@ -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 { match self { Tool::Select => Some('V'), + Tool::Pan => Some('H'), Tool::Rectangle => Some('R'), Tool::Ellipse => Some('E'), Tool::Line => Some('L'),