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
This commit is contained in:
David Ibia
2026-02-10 10:27:06 +01:00
parent 9b8acd4002
commit 5ca1e85209
7 changed files with 112 additions and 2 deletions

View File

@@ -30,8 +30,12 @@ cargo test -- --nocapture # Show println! output
cargo check # Type check only (fast) cargo check # Type check only (fast)
cargo doc --open # Generate and open docs 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 ## Project Structure
``` ```

View File

@@ -187,6 +187,12 @@ pub struct BooleanOpParam {
pub stroke_width: Option<f32>, pub stroke_width: 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)] #[derive(Debug, Clone)]
pub struct AgCanvasServer { pub struct AgCanvasServer {
ws_url: String, ws_url: String,
@@ -521,6 +527,35 @@ impl AgCanvasServer {
self.call_agcanvas(&request).await 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.")] #[tool(description = "Delete a drawing element by its ID.")]
async fn delete_drawing_element( async fn delete_drawing_element(
&self, &self,

View File

@@ -211,6 +211,9 @@ pub enum AgentRequest {
#[serde(default)] #[serde(default)]
stroke_width: Option<f32>, stroke_width: Option<f32>,
}, },
Batch {
requests: Vec<AgentRequest>,
},
Ping, Ping,
} }
@@ -272,6 +275,9 @@ pub enum AgentResponse {
DrawingElementsCleared { DrawingElementsCleared {
session_id: String, session_id: String,
}, },
BatchResult {
results: Vec<AgentResponse>,
},
Pong, Pong,
Error { Error {
message: String, message: String,

View File

@@ -151,6 +151,46 @@ async fn process_request(
event_tx: &broadcast::Sender<GuiEvent>, event_tx: &broadcast::Sender<GuiEvent>,
command_tx: &mpsc::UnboundedSender<DrawingCommand>, command_tx: &mpsc::UnboundedSender<DrawingCommand>,
session_command_tx: &mpsc::UnboundedSender<SessionCommand>, 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 { ) -> AgentResponse {
match request { match request {
AgentRequest::Ping => AgentResponse::Pong, AgentRequest::Ping => AgentResponse::Pong,
@@ -642,6 +682,9 @@ async fn process_request(
element, element,
} }
} }
AgentRequest::Batch { .. } => AgentResponse::Error {
message: "Nested batch requests are not supported".to_string(),
},
} }
} }

View File

@@ -548,6 +548,7 @@ impl AgCanvasApp {
CommandId::PasteSvg => self.handle_paste(ctx), CommandId::PasteSvg => self.handle_paste(ctx),
CommandId::PasteMermaid => self.show_mermaid_dialog = true, CommandId::PasteMermaid => self.show_mermaid_dialog = true,
CommandId::ToolSelect => self.active_session_mut().active_tool = Tool::Select, 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::ToolRectangle => self.active_session_mut().active_tool = Tool::Rectangle,
CommandId::ToolEllipse => self.active_session_mut().active_tool = Tool::Ellipse, CommandId::ToolEllipse => self.active_session_mut().active_tool = Tool::Ellipse,
CommandId::ToolLine => self.active_session_mut().active_tool = Tool::Line, CommandId::ToolLine => self.active_session_mut().active_tool = Tool::Line,
@@ -579,6 +580,11 @@ impl AgCanvasApp {
Tool::Select => { Tool::Select => {
handle_select_tool(session, response, canvas_center, offset, zoom, pointer_pos); 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 => { Tool::Rectangle | Tool::Ellipse | Tool::Line | Tool::Arrow => {
handle_shape_tool(session, response, canvas_center, offset, zoom, pointer_pos); 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) { if i.key_pressed(egui::Key::V) {
tool_switch = Some(Tool::Select); tool_switch = Some(Tool::Select);
} }
if i.key_pressed(egui::Key::H) {
tool_switch = Some(Tool::Pan);
}
if i.key_pressed(egui::Key::R) { if i.key_pressed(egui::Key::R) {
tool_switch = Some(Tool::Rectangle); 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.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
ui.label(format!("Agent: ws://127.0.0.1:{}", AGENT_PORT)); ui.label(format!("Agent: ws://127.0.0.1:{}", AGENT_PORT));
ui.separator(); ui.separator();
ui.label(format!( let zoom_label = format!(
"Zoom: {:.0}%", "Zoom: {:.0}%",
self.active_session().canvas_state.zoom * 100.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 active_tool = self.active_session().active_tool;
let tools = [ let tools = [
Tool::Select, Tool::Select,
Tool::Pan,
Tool::Rectangle, Tool::Rectangle,
Tool::Ellipse, Tool::Ellipse,
Tool::Line, Tool::Line,

View File

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

View File

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