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:
@@ -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
|
||||||
|
|
||||||
```
|
```
|
||||||
|
|||||||
@@ -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>(¶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.")]
|
#[tool(description = "Delete a drawing element by its ID.")]
|
||||||
async fn delete_drawing_element(
|
async fn delete_drawing_element(
|
||||||
&self,
|
&self,
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -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,
|
||||||
|
|||||||
@@ -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",
|
||||||
|
|||||||
@@ -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'),
|
||||||
|
|||||||
Reference in New Issue
Block a user