feat: add boolean shape ops, visual undo tree, and Augmented Canvas branding

- Boolean operations (union, intersection, difference, xor) via i_overlay
  with Path shape rendering using earcutr triangulation
- Visual undo tree with branching history, checkout, and fork (Cmd+H)
  using Arc-based snapshots for structural sharing
- Consistent Augmented Canvas branding across app title, MCP server,
  CLI help text, and error messages
- macOS .app bundle script and Info.plist for Finder integration
- New MCP tool: boolean_op for agent-driven shape composition
- 26 tests passing (5 boolean, 6 history, 15 existing)
This commit is contained in:
David Ibia
2026-02-10 00:01:45 +01:00
parent e8ec44d961
commit 9489c390fa
18 changed files with 1202 additions and 21 deletions

32
assets/Info.plist Normal file
View File

@@ -0,0 +1,32 @@
<?xml version="1.0" encoding="UTF-8"?>
<!DOCTYPE plist PUBLIC "-//Apple//DTD PLIST 1.0//EN" "http://www.apple.com/DTDs/PropertyList-1.0.dtd">
<plist version="1.0">
<dict>
<key>CFBundleName</key>
<string>Augmented Canvas</string>
<key>CFBundleDisplayName</key>
<string>Augmented Canvas</string>
<key>CFBundleIdentifier</key>
<string>com.agcanvas.app</string>
<key>CFBundleVersion</key>
<string>0.1.0</string>
<key>CFBundleShortVersionString</key>
<string>0.1.0</string>
<key>CFBundlePackageType</key>
<string>APPL</string>
<key>CFBundleExecutable</key>
<string>agcanvas</string>
<key>CFBundleIconFile</key>
<string>AppIcon</string>
<key>LSMinimumSystemVersion</key>
<string>11.0</string>
<key>NSHighResolutionCapable</key>
<true/>
<key>NSSupportsAutomaticGraphicsSwitching</key>
<true/>
<key>CFBundleInfoDictionaryVersion</key>
<string>6.0</string>
<key>LSApplicationCategoryType</key>
<string>public.app-category.developer-tools</string>
</dict>
</plist>

View File

@@ -3,7 +3,7 @@ name = "agcanvas-mcp"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
description = "MCP server bridge for agcanvas — exposes canvas tools to Claude Code, OpenCode, and Codex" description = "MCP server bridge for Augmented Canvas — exposes canvas tools to Claude Code, OpenCode, and Codex"
[[bin]] [[bin]]
name = "agcanvas-mcp" name = "agcanvas-mcp"

View File

@@ -7,7 +7,7 @@ pub async fn send_request(ws_url: &str, request_json: &str) -> Result<String> {
.await .await
.map_err(|e| { .map_err(|e| {
anyhow::anyhow!( anyhow::anyhow!(
"Cannot connect to agcanvas at {}. Is agcanvas running? Error: {}", "Cannot connect to Augmented Canvas at {}. Is Augmented Canvas running? Error: {}",
ws_url, ws_url,
e e
) )

View File

@@ -8,7 +8,7 @@ use tools::AgCanvasServer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[derive(Parser)] #[derive(Parser)]
#[command(name = "agcanvas-mcp", about = "MCP server bridge for agcanvas")] #[command(name = "agcanvas-mcp", about = "MCP server bridge for Augmented Canvas")]
struct Cli { struct Cli {
#[arg(long, default_value = "9876")] #[arg(long, default_value = "9876")]
port: u16, port: u16,
@@ -26,7 +26,7 @@ async fn main() -> Result<()> {
let cli = Cli::parse(); let cli = Cli::parse();
let ws_url = format!("ws://127.0.0.1:{}", cli.port); let ws_url = format!("ws://127.0.0.1:{}", cli.port);
tracing::info!("Starting agcanvas MCP server, connecting to {}", ws_url); tracing::info!("Starting Augmented Canvas MCP server, connecting to {}", ws_url);
let server = AgCanvasServer::new(ws_url); let server = AgCanvasServer::new(ws_url);
let service = server.serve(rmcp::transport::stdio()).await?; let service = server.serve(rmcp::transport::stdio()).await?;

View File

@@ -169,6 +169,24 @@ pub struct DeleteDrawingElementParam {
pub id: String, pub id: String,
} }
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct BooleanOpParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "Boolean operation: union, intersection, difference, or xor")]
pub operation: String,
#[schemars(description = "IDs of drawing elements to combine (minimum 2)")]
pub element_ids: Vec<String>,
#[schemars(description = "If true, delete source elements after combining")]
pub consume: Option<bool>,
#[schemars(description = "Fill color for result as hex e.g. '#ff0000'")]
pub fill: Option<String>,
#[schemars(description = "Stroke color for result as hex e.g. '#ffffff'")]
pub stroke_color: Option<String>,
#[schemars(description = "Stroke width for result in pixels")]
pub stroke_width: Option<f32>,
}
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
pub struct AgCanvasServer { pub struct AgCanvasServer {
ws_url: String, ws_url: String,
@@ -185,7 +203,7 @@ impl AgCanvasServer {
} }
#[tool( #[tool(
description = "List all open sessions/tabs in agcanvas. Returns session IDs, names, creator info, description, timestamps, and whether they have SVG or drawing content loaded. Supports sorting." description = "List all open sessions/tabs in Augmented Canvas. Returns session IDs, names, creator info, description, timestamps, and whether they have SVG or drawing content loaded. Supports sorting."
)] )]
async fn list_sessions( async fn list_sessions(
&self, &self,
@@ -203,7 +221,7 @@ impl AgCanvasServer {
} }
#[tool( #[tool(
description = "Create a new session/tab in agcanvas. The session is created by an agent. Returns the created session with its ID and metadata." description = "Create a new session/tab in Augmented Canvas. The session is created by an agent. Returns the created session with its ID and metadata."
)] )]
async fn create_session( async fn create_session(
&self, &self,
@@ -472,6 +490,37 @@ impl AgCanvasServer {
self.call_agcanvas(&request).await self.call_agcanvas(&request).await
} }
#[tool(
description = "Perform a boolean operation (union, intersection, difference, xor) on two or more drawing elements. Combines filled shapes into a new path element. Only works on Rectangle and Ellipse shapes."
)]
async fn boolean_op(
&self,
Parameters(params): Parameters<BooleanOpParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({
"type": "BooleanOp",
"operation": params.operation,
"element_ids": params.element_ids,
});
let obj = request.as_object_mut().unwrap();
if let Some(v) = params.session_id {
obj.insert("session_id".into(), v.into());
}
if let Some(v) = params.consume {
obj.insert("consume".into(), v.into());
}
if let Some(v) = params.fill {
obj.insert("fill".into(), v.into());
}
if let Some(v) = params.stroke_color {
obj.insert("stroke_color".into(), v.into());
}
if let Some(v) = params.stroke_width {
obj.insert("stroke_width".into(), v.into());
}
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,
@@ -517,7 +566,7 @@ impl AgCanvasServer {
let msg = parsed let msg = parsed
.get("message") .get("message")
.and_then(serde_json::Value::as_str) .and_then(serde_json::Value::as_str)
.unwrap_or("Unknown error from agcanvas"); .unwrap_or("Unknown error from Augmented Canvas");
return Ok(CallToolResult::error(vec![Content::text(msg)])); return Ok(CallToolResult::error(vec![Content::text(msg)]));
} }
@@ -526,7 +575,7 @@ impl AgCanvasServer {
Ok(CallToolResult::success(vec![Content::text(pretty)])) Ok(CallToolResult::success(vec![Content::text(pretty)]))
} }
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!( Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
"Failed to communicate with agcanvas: {}. Make sure agcanvas is running.", "Failed to communicate with Augmented Canvas: {}. Make sure Augmented Canvas is running.",
e e
))])), ))])),
} }
@@ -538,10 +587,10 @@ impl ServerHandler for AgCanvasServer {
fn get_info(&self) -> ServerInfo { fn get_info(&self) -> ServerInfo {
ServerInfo { ServerInfo {
instructions: Some( instructions: Some(
"agcanvas MCP server — connects to agcanvas desktop app to query SVG designs, \ "Augmented Canvas MCP server — connects to the Augmented Canvas desktop app to query SVG designs, \
element trees, and user-drawn shapes. Use describe_canvas to understand the \ element trees, and user-drawn shapes. Use describe_canvas to understand the \
current design, get_element_tree for structured data, and generate_code for \ current design, get_element_tree for structured data, and generate_code for \
code stubs. Requires agcanvas to be running." code stubs. Requires Augmented Canvas to be running."
.into(), .into(),
), ),
capabilities: ServerCapabilities::builder().enable_tools().build(), capabilities: ServerCapabilities::builder().enable_tools().build(),

View File

@@ -3,7 +3,7 @@ name = "agcanvas"
version.workspace = true version.workspace = true
edition.workspace = true edition.workspace = true
license.workspace = true license.workspace = true
description = "Interactive canvas for agent-human collaboration with SVG support" description = "Augmented Canvas — interactive canvas for agent-human collaboration with SVG support"
[dependencies] [dependencies]
# GUI # GUI
@@ -48,3 +48,5 @@ thiserror = "1.0"
# Image handling # Image handling
image = { version = "0.25", default-features = false, features = ["png"] } image = { version = "0.25", default-features = false, features = ["png"] }
i_overlay = "4.4.0"
earcutr = "0.5.0"

View File

@@ -1,4 +1,4 @@
use crate::drawing::{DrawingElement, Shape, ShapeStyle}; use crate::drawing::{BooleanOpType, DrawingElement, Shape, ShapeStyle};
use crate::element_tree::{ElementTree, TreeMetadata}; use crate::element_tree::{ElementTree, TreeMetadata};
use crate::session::{SessionCreator, SessionInfo, SessionSortField, SortOrder}; use crate::session::{SessionCreator, SessionInfo, SessionSortField, SortOrder};
use egui::{Color32, Pos2, Vec2}; use egui::{Color32, Pos2, Vec2};
@@ -197,6 +197,20 @@ pub enum AgentRequest {
#[serde(default)] #[serde(default)]
session_id: Option<String>, session_id: Option<String>,
}, },
BooleanOp {
#[serde(default)]
session_id: Option<String>,
operation: BooleanOpType,
element_ids: Vec<String>,
#[serde(default)]
consume: Option<bool>,
#[serde(default)]
fill: Option<String>,
#[serde(default)]
stroke_color: Option<String>,
#[serde(default)]
stroke_width: Option<f32>,
},
Ping, Ping,
} }
@@ -412,8 +426,11 @@ pub fn build_shape(
content: text.unwrap_or_else(|| "Text".to_string()), content: text.unwrap_or_else(|| "Text".to_string()),
font_size: font_size.unwrap_or(20.0), font_size: font_size.unwrap_or(20.0),
}), }),
"Path" | "path" => {
Err("Path shapes are created via boolean operations, not directly".to_string())
}
other => Err(format!( other => Err(format!(
"Unknown shape_type '{}'. Expected: Rectangle, Ellipse, Line, Arrow, or Text", "Unknown shape_type '{}'. Expected: Rectangle, Ellipse, Line, Arrow, Text, or Path",
other other
)), )),
} }

View File

@@ -1,8 +1,9 @@
use super::protocol::{ use super::protocol::{
build_shape, build_style, AgentRequest, AgentResponse, CodeGenTarget, DrawingCommand, GuiEvent, build_shape, build_style, parse_hex_color, AgentRequest, AgentResponse, CodeGenTarget,
DrawingCommand, GuiEvent,
SessionCommand, SessionCommand,
}; };
use crate::drawing::DrawingElement; use crate::drawing::{boolean, DrawingElement, Shape, ShapeStyle};
use crate::session::{SessionCreator, SessionStore}; use crate::session::{SessionCreator, SessionStore};
use anyhow::Result; use anyhow::Result;
use futures_util::{SinkExt, StreamExt}; use futures_util::{SinkExt, StreamExt};
@@ -536,6 +537,109 @@ async fn process_request(
}); });
AgentResponse::DrawingElementsCleared { session_id: sid } AgentResponse::DrawingElementsCleared { session_id: sid }
} }
AgentRequest::BooleanOp {
session_id,
operation,
element_ids,
consume,
fill,
stroke_color,
stroke_width,
} => {
if element_ids.len() < 2 {
return AgentResponse::Error {
message: "Boolean operation requires at least 2 element IDs".to_string(),
};
}
let mut store = sessions.write().await;
let sid = match store.resolve_session_id(session_id.as_deref()) {
Some(id) => id,
None => {
return AgentResponse::Error {
message: "No session found".to_string(),
}
}
};
let source_elements = match store.get_drawing_elements(Some(&sid)) {
Some((_, elements)) => elements,
None => {
return AgentResponse::Error {
message: "No session found".to_string(),
}
}
};
let mut selected_elements = Vec::with_capacity(element_ids.len());
for element_id in &element_ids {
match source_elements.iter().find(|element| element.id == *element_id) {
Some(element) => selected_elements.push(element),
None => {
return AgentResponse::Error {
message: format!("Element '{}' not found in session", element_id),
}
}
}
}
let polygons = match boolean::boolean_op(operation, &selected_elements) {
Ok(polygons) => polygons,
Err(message) => return AgentResponse::Error { message },
};
if polygons.is_empty() {
return AgentResponse::Error {
message: "Boolean operation produced empty result".to_string(),
};
}
let first_style = selected_elements[0].style.clone();
let result_style = ShapeStyle {
fill: match fill {
Some(hex) => parse_hex_color(&hex),
None => first_style.fill,
},
stroke_color: match stroke_color {
Some(hex) => parse_hex_color(&hex).unwrap_or(first_style.stroke_color),
None => first_style.stroke_color,
},
stroke_width: stroke_width.unwrap_or(first_style.stroke_width),
};
let element = DrawingElement::new(Shape::Path { polygons }, result_style);
if consume == Some(true) {
for element_id in &element_ids {
if store.delete_drawing_element(&sid, element_id) {
let _ = command_tx.send(DrawingCommand::Delete {
session_id: sid.clone(),
id: element_id.clone(),
});
let _ = event_tx.send(GuiEvent::DrawingElementDeleted {
session_id: sid.clone(),
id: element_id.clone(),
});
}
}
}
store.add_drawing_element(&sid, element.clone());
let _ = command_tx.send(DrawingCommand::Create {
session_id: sid.clone(),
element: element.clone(),
});
let _ = event_tx.send(GuiEvent::DrawingElementCreated {
session_id: sid.clone(),
element: element.clone(),
});
AgentResponse::DrawingElementCreated {
session_id: sid,
element,
}
}
} }
} }

View File

@@ -6,6 +6,7 @@ use crate::drawing::{
draw_creation_preview, draw_elements, draw_selection, find_handle_at_screen_pos, draw_creation_preview, draw_elements, draw_selection, find_handle_at_screen_pos,
screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle, Tool, screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle, Tool,
}; };
use crate::history::{ChangeSource, HistoryTree, NodeId};
use crate::mermaid::render_mermaid_to_svg; use crate::mermaid::render_mermaid_to_svg;
use crate::persistence::{self, SavedSession, SavedWorkspace}; use crate::persistence::{self, SavedSession, SavedWorkspace};
use crate::session::{Session, SessionCreator, SessionStore}; use crate::session::{Session, SessionCreator, SessionStore};
@@ -29,6 +30,7 @@ pub struct AgCanvasApp {
clipboard: Option<ClipboardManager>, clipboard: Option<ClipboardManager>,
show_tree_panel: bool, show_tree_panel: bool,
show_description: bool, show_description: bool,
show_history_panel: bool,
status_message: Option<(String, std::time::Instant)>, status_message: Option<(String, std::time::Instant)>,
_runtime: Runtime, _runtime: Runtime,
@@ -72,6 +74,7 @@ impl AgCanvasApp {
clipboard, clipboard,
show_tree_panel: false, show_tree_panel: false,
show_description: false, show_description: false,
show_history_panel: false,
status_message: None, status_message: None,
_runtime: runtime, _runtime: runtime,
show_mermaid_dialog: false, show_mermaid_dialog: false,
@@ -190,6 +193,7 @@ impl AgCanvasApp {
session.svg_renderer = Some(SvgRenderer::new(usvg_tree)); session.svg_renderer = Some(SvgRenderer::new(usvg_tree));
session.svg_source = Some(svg_data.to_string()); session.svg_source = Some(svg_data.to_string());
session.svg_texture = None; session.svg_texture = None;
session.record_edit("Load SVG", ChangeSource::Human);
session session
.canvas_state .canvas_state
.fit_to_rect(egui::vec2(width, height), ctx.screen_rect().size() * 0.8); .fit_to_rect(egui::vec2(width, height), ctx.screen_rect().size() * 0.8);
@@ -227,6 +231,7 @@ impl AgCanvasApp {
let session = self.active_session_mut(); let session = self.active_session_mut();
let session_id = session.id.clone(); let session_id = session.id.clone();
session.clear(); session.clear();
session.record_edit("Clear Canvas", ChangeSource::Human);
let sessions_handle = self.sessions_handle.clone(); let sessions_handle = self.sessions_handle.clone();
let event_tx = self.event_tx.clone(); let event_tx = self.event_tx.clone();
@@ -244,7 +249,8 @@ impl AgCanvasApp {
fn render_svg_to_texture(&mut self, ctx: &egui::Context) { fn render_svg_to_texture(&mut self, ctx: &egui::Context) {
let session = self.active_session_mut(); let session = self.active_session_mut();
if let Some(renderer) = &mut session.svg_renderer { if let Some(renderer) = &mut session.svg_renderer {
let scale = session.canvas_state.zoom.max(1.0); let ppp = ctx.pixels_per_point();
let scale = session.canvas_state.zoom.max(1.0) * ppp;
if let Ok(pixmap) = renderer.render(scale) { if let Ok(pixmap) = renderer.render(scale) {
let size = [pixmap.width() as usize, pixmap.height() as usize]; let size = [pixmap.width() as usize, pixmap.height() as usize];
let pixels: Vec<Color32> = pixmap let pixels: Vec<Color32> = pixmap
@@ -298,6 +304,10 @@ impl AgCanvasApp {
} => { } => {
if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) { if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
session.drawing_elements.push(element); session.drawing_elements.push(element);
session.record_edit(
"Agent: Create Element",
ChangeSource::Agent { name: None },
);
} }
} }
DrawingCommand::Update { DrawingCommand::Update {
@@ -312,6 +322,10 @@ impl AgCanvasApp {
{ {
el.shape = element.shape; el.shape = element.shape;
el.style = element.style; el.style = element.style;
session.record_edit(
"Agent: Update Element",
ChangeSource::Agent { name: None },
);
} }
} }
} }
@@ -321,12 +335,20 @@ impl AgCanvasApp {
if session.selected_element_id.as_deref() == Some(&id) { if session.selected_element_id.as_deref() == Some(&id) {
session.selected_element_id = None; session.selected_element_id = None;
} }
session.record_edit(
"Agent: Delete Element",
ChangeSource::Agent { name: None },
);
} }
} }
DrawingCommand::Clear { session_id } => { DrawingCommand::Clear { session_id } => {
if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) { if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
session.drawing_elements.clear(); session.drawing_elements.clear();
session.selected_element_id = None; session.selected_element_id = None;
session.record_edit(
"Agent: Clear Canvas",
ChangeSource::Agent { name: None },
);
} }
} }
} }
@@ -516,6 +538,7 @@ impl AgCanvasApp {
} }
CommandId::ToggleTreePanel => self.show_tree_panel = !self.show_tree_panel, CommandId::ToggleTreePanel => self.show_tree_panel = !self.show_tree_panel,
CommandId::ToggleDescription => self.show_description = !self.show_description, CommandId::ToggleDescription => self.show_description = !self.show_description,
CommandId::ToggleHistory => self.show_history_panel = !self.show_history_panel,
} }
} }
@@ -627,6 +650,13 @@ fn handle_select_tool(
} }
if response.drag_stopped() { if response.drag_stopped() {
match &session.drag_state {
DragState::Moving { .. } => session.record_edit("Move Element", ChangeSource::Human),
DragState::Resizing { .. } => {
session.record_edit("Resize Element", ChangeSource::Human)
}
_ => {}
}
session.drag_state = DragState::None; session.drag_state = DragState::None;
} }
@@ -700,10 +730,18 @@ fn handle_shape_tool(
}, },
_ => unreachable!(), _ => unreachable!(),
}; };
let tool_label = match session.active_tool {
Tool::Rectangle => "Rectangle",
Tool::Ellipse => "Ellipse",
Tool::Line => "Line",
Tool::Arrow => "Arrow",
_ => "Shape",
};
let element = DrawingElement::new(shape, ShapeStyle::default()); let element = DrawingElement::new(shape, ShapeStyle::default());
session.selected_element_id = Some(element.id.clone()); session.selected_element_id = Some(element.id.clone());
session.drawing_elements.push(element); session.drawing_elements.push(element);
session.record_edit(&format!("Draw {}", tool_label), ChangeSource::Human);
} }
} }
session.drag_state = DragState::None; session.drag_state = DragState::None;
@@ -721,6 +759,7 @@ impl eframe::App for AgCanvasApp {
let mut save_workspace = false; let mut save_workspace = false;
let mut toggle_palette = false; let mut toggle_palette = false;
let mut delete_selected = false; let mut delete_selected = false;
let mut toggle_history = false;
let mut tool_switch: Option<Tool> = None; let mut tool_switch: Option<Tool> = None;
let palette_open = self.command_palette.visible; let palette_open = self.command_palette.visible;
@@ -742,6 +781,9 @@ impl eframe::App for AgCanvasApp {
if i.modifiers.command && i.key_pressed(egui::Key::W) { if i.modifiers.command && i.key_pressed(egui::Key::W) {
close_tab = true; close_tab = true;
} }
if i.modifiers.command && i.key_pressed(egui::Key::H) {
toggle_history = true;
}
if i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace) { if i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace) {
delete_selected = true; delete_selected = true;
} }
@@ -785,8 +827,13 @@ impl eframe::App for AgCanvasApp {
let idx = self.active_session_idx; let idx = self.active_session_idx;
self.close_session(idx); self.close_session(idx);
} }
if toggle_history {
self.show_history_panel = !self.show_history_panel;
}
if delete_selected && !self.show_text_input && !self.show_mermaid_dialog { if delete_selected && !self.show_text_input && !self.show_mermaid_dialog {
self.active_session_mut().delete_selected(); self.active_session_mut().delete_selected();
self.active_session_mut()
.record_edit("Delete Element", ChangeSource::Human);
} }
if let Some(tool) = tool_switch { if let Some(tool) = tool_switch {
if !self.show_text_input && !self.show_mermaid_dialog { if !self.show_text_input && !self.show_mermaid_dialog {
@@ -840,6 +887,9 @@ impl eframe::App for AgCanvasApp {
{ {
ui.close_menu(); ui.close_menu();
} }
if ui.checkbox(&mut self.show_history_panel, "History").clicked() {
ui.close_menu();
}
ui.separator(); ui.separator();
if ui.button("Reset Zoom (Cmd+0)").clicked() { if ui.button("Reset Zoom (Cmd+0)").clicked() {
self.active_session_mut().canvas_state.reset(); self.active_session_mut().canvas_state.reset();
@@ -972,6 +1022,48 @@ impl eframe::App for AgCanvasApp {
} }
} }
let mut checkout_node_id: Option<NodeId> = None;
if self.show_history_panel {
egui::SidePanel::right("history_panel")
.default_width(220.0)
.show(ctx, |ui| {
ui.horizontal(|ui| {
ui.heading("History");
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
if ui.small_button("×").clicked() {
self.show_history_panel = false;
}
});
});
ui.separator();
let session = &self.sessions[self.active_session_idx];
let history = &session.history;
let active_path = history.path_to_root(history.current);
let current_node = history.node(history.current);
let root_suffix = if history.current == history.root {
" (root)"
} else {
""
};
ui.label(format!(
"{} entries - current: {}{}",
history.node_count(),
current_node.label,
root_suffix
));
ui.separator();
egui::ScrollArea::vertical().show(ui, |ui| {
if let Some(node_id) = render_history_tree(ui, history, &active_path) {
checkout_node_id = Some(node_id);
}
});
});
}
if self.show_tree_panel { if self.show_tree_panel {
egui::SidePanel::right("tree_panel") egui::SidePanel::right("tree_panel")
.default_width(300.0) .default_width(300.0)
@@ -988,6 +1080,28 @@ impl eframe::App for AgCanvasApp {
}); });
} }
if let Some(node_id) = checkout_node_id {
let idx = self.active_session_idx;
let previous_svg = self.sessions[idx]
.history
.current_snapshot()
.svg_source
.as_deref()
.map(str::to_string);
self.sessions[idx].checkout_history(node_id);
if self.sessions[idx].svg_source != previous_svg {
if let Some(svg_data) = self.sessions[idx].svg_source.clone() {
if let Ok((tree, usvg_tree)) = parse_svg(&svg_data) {
let session = &mut self.sessions[idx];
session.description_text = tree.to_semantic_description();
session.svg_renderer = Some(SvgRenderer::new(usvg_tree));
session.element_tree = Some(tree);
session.svg_texture = None;
}
}
}
}
if self.show_description { if self.show_description {
let desc = self.active_session().description_text.clone(); let desc = self.active_session().description_text.clone();
egui::SidePanel::left("description_panel") egui::SidePanel::left("description_panel")
@@ -1098,6 +1212,8 @@ impl eframe::App for AgCanvasApp {
); );
let eid = element.id.clone(); let eid = element.id.clone();
self.active_session_mut().drawing_elements.push(element); self.active_session_mut().drawing_elements.push(element);
self.active_session_mut()
.record_edit("Draw Text", ChangeSource::Human);
self.active_session_mut().selected_element_id = Some(eid); self.active_session_mut().selected_element_id = Some(eid);
} }
self.show_text_input = false; self.show_text_input = false;
@@ -1162,8 +1278,10 @@ impl eframe::App for AgCanvasApp {
if let Some(texture) = &self.active_session().svg_texture { if let Some(texture) = &self.active_session().svg_texture {
let canvas_state = &self.active_session().canvas_state; let canvas_state = &self.active_session().canvas_state;
let ppp = ctx.pixels_per_point();
let center = response.rect.center(); let center = response.rect.center();
let size = texture.size_vec2() / canvas_state.zoom.max(1.0) * canvas_state.zoom; let size =
texture.size_vec2() / (canvas_state.zoom.max(1.0) * ppp) * canvas_state.zoom;
let offset = canvas_state.offset * canvas_state.zoom; let offset = canvas_state.offset * canvas_state.zoom;
let rect = egui::Rect::from_center_size(center + offset, size); let rect = egui::Rect::from_center_size(center + offset, size);
painter.image( painter.image(
@@ -1295,6 +1413,135 @@ fn draw_grid(painter: &egui::Painter, rect: &egui::Rect, state: &CanvasState) {
} }
} }
fn render_history_tree(
ui: &mut egui::Ui,
history: &HistoryTree,
active_path: &[NodeId],
) -> Option<NodeId> {
use std::collections::{HashMap, HashSet};
let active_nodes: HashSet<NodeId> = active_path.iter().copied().collect();
let lanes = history_lanes(history);
let mut checkout: Option<NodeId> = None;
let mut node_centers: HashMap<NodeId, egui::Pos2> = HashMap::new();
let row_height = 26.0;
let lane_spacing = 18.0;
let dot_radius = 6.0;
let line_color = Color32::from_rgb(64, 64, 64);
let current_fill = Color32::WHITE;
let active_fill = Color32::from_gray(180);
let inactive_stroke = Color32::from_gray(80);
for node in history.nodes.iter().rev() {
let node_id = node.id;
let lane = lanes[node_id.0] as f32;
let (rect, response) = ui.allocate_exact_size(
egui::vec2(ui.available_width(), row_height),
egui::Sense::click(),
);
if response.clicked() {
checkout = Some(node_id);
}
let center = egui::pos2(rect.left() + 10.0 + lane * lane_spacing, rect.center().y);
node_centers.insert(node_id, center);
let is_current = node_id == history.current;
let is_active = active_nodes.contains(&node_id);
if is_current {
ui.painter().circle_filled(center, dot_radius, current_fill);
} else if is_active {
ui.painter().circle_filled(center, dot_radius, active_fill);
} else {
ui.painter().circle_stroke(
center,
dot_radius,
egui::Stroke::new(1.5, inactive_stroke),
);
}
let source_prefix = match node.source {
ChangeSource::Human => "",
ChangeSource::Agent { .. } => "🤖 ",
};
let label = format!(
"{}{} {}",
source_prefix,
node.label,
relative_time_label(node.timestamp)
);
let label_color = if is_current {
Color32::WHITE
} else if matches!(node.source, ChangeSource::Agent { .. }) {
Color32::from_gray(185)
} else {
Color32::from_gray(160)
};
ui.painter().text(
egui::pos2(center.x + 12.0, rect.center().y),
egui::Align2::LEFT_CENTER,
label,
egui::FontId::proportional(13.0),
label_color,
);
}
for node in &history.nodes {
if let Some(parent_id) = node.parent {
if let (Some(parent_center), Some(child_center)) =
(node_centers.get(&parent_id), node_centers.get(&node.id))
{
ui.painter().line_segment(
[*parent_center, egui::pos2(child_center.x, parent_center.y)],
egui::Stroke::new(1.0, line_color),
);
ui.painter().line_segment(
[egui::pos2(child_center.x, parent_center.y), *child_center],
egui::Stroke::new(1.0, line_color),
);
}
}
}
checkout
}
fn history_lanes(history: &HistoryTree) -> Vec<usize> {
let mut lanes = vec![0; history.nodes.len()];
for node in &history.nodes {
if let Some(parent_id) = node.parent {
let parent = &history.nodes[parent_id.0];
let sibling_idx = parent
.children
.iter()
.position(|child_id| *child_id == node.id)
.unwrap_or(0);
lanes[node.id.0] = lanes[parent_id.0] + sibling_idx;
}
}
lanes
}
fn relative_time_label(timestamp: std::time::Instant) -> String {
let secs = timestamp.elapsed().as_secs();
if secs < 60 {
format!("{}s ago", secs)
} else if secs < 60 * 60 {
format!("{}m ago", secs / 60)
} else if secs < 60 * 60 * 24 {
format!("{}h ago", secs / (60 * 60))
} else {
format!("{}d ago", secs / (60 * 60 * 24))
}
}
fn render_tree_ui(ui: &mut egui::Ui, element: &crate::element_tree::Element, depth: usize) { fn render_tree_ui(ui: &mut egui::Ui, element: &crate::element_tree::Element, depth: usize) {
let kind_name = match &element.kind { let kind_name = match &element.kind {
crate::element_tree::ElementKind::Group { name } => { crate::element_tree::ElementKind::Group { name } => {

View File

@@ -18,6 +18,7 @@ pub enum CommandId {
FitToView, FitToView,
ToggleTreePanel, ToggleTreePanel,
ToggleDescription, ToggleDescription,
ToggleHistory,
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]
@@ -82,6 +83,12 @@ pub fn all_commands() -> Vec<PaletteCommand> {
None, None,
"View", "View",
), ),
PaletteCommand::new(
CommandId::ToggleHistory,
"Toggle History Panel",
Some("Cmd+H"),
"View",
),
] ]
} }

View File

@@ -0,0 +1,179 @@
use super::element::{DrawingElement, PathPolygon, Shape};
use egui::Pos2;
use i_overlay::core::fill_rule::FillRule;
use i_overlay::core::overlay_rule::OverlayRule;
use i_overlay::float::overlay::FloatOverlay;
use serde::{Deserialize, Serialize};
const ELLIPSE_SEGMENTS: usize = 64;
#[derive(Debug, Clone, Copy, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
pub enum BooleanOpType {
Union,
Intersection,
Difference,
Xor,
}
pub fn shape_to_contour(shape: &Shape) -> Result<Vec<[f64; 2]>, String> {
match shape {
Shape::Rectangle { pos, size } => {
let x0 = pos.x as f64;
let y0 = pos.y as f64;
let x1 = (pos.x + size.x) as f64;
let y1 = (pos.y + size.y) as f64;
Ok(vec![[x0, y0], [x1, y0], [x1, y1], [x0, y1]])
}
Shape::Ellipse { center, radii } => Ok((0..ELLIPSE_SEGMENTS)
.map(|i| {
let angle = (i as f64 / ELLIPSE_SEGMENTS as f64) * std::f64::consts::TAU;
[
center.x as f64 + radii.x as f64 * angle.cos(),
center.y as f64 + radii.y as f64 * angle.sin(),
]
})
.collect()),
Shape::Path { .. } => Err("Path shape cannot be used as boolean input yet".to_string()),
Shape::Line { .. } | Shape::Arrow { .. } | Shape::Text { .. } => {
Err("Boolean operations only support Rectangle and Ellipse".to_string())
}
}
}
pub fn boolean_op(
op: BooleanOpType,
elements: &[&DrawingElement],
) -> Result<Vec<PathPolygon>, String> {
if elements.len() < 2 {
return Err("Boolean operation requires at least 2 elements".to_string());
}
let contours: Vec<Vec<[f64; 2]>> = elements
.iter()
.map(|element| shape_to_contour(&element.shape))
.collect::<Result<_, _>>()?;
let mut current_shapes = vec![vec![contours[0].clone()]];
let rule = overlay_rule(op);
for contour in contours.iter().skip(1) {
let clip_shapes = vec![vec![contour.clone()]];
let mut overlay = FloatOverlay::with_subj_and_clip(&current_shapes, &clip_shapes);
current_shapes = overlay.overlay(rule, FillRule::EvenOdd);
}
Ok(to_path_polygons(current_shapes))
}
fn overlay_rule(op: BooleanOpType) -> OverlayRule {
match op {
BooleanOpType::Union => OverlayRule::Union,
BooleanOpType::Intersection => OverlayRule::Intersect,
BooleanOpType::Difference => OverlayRule::Difference,
BooleanOpType::Xor => OverlayRule::Xor,
}
}
fn to_path_polygons(shapes: Vec<Vec<Vec<[f64; 2]>>>) -> Vec<PathPolygon> {
shapes
.into_iter()
.filter_map(|shape| {
let mut contours = shape.into_iter();
let exterior = contours.next()?;
if exterior.len() < 3 {
return None;
}
let holes = contours
.filter(|contour| contour.len() >= 3)
.map(to_pos2_ring)
.collect();
Some(PathPolygon {
exterior: to_pos2_ring(exterior),
holes,
})
})
.collect()
}
fn to_pos2_ring(ring: Vec<[f64; 2]>) -> Vec<Pos2> {
ring.into_iter()
.map(|point| Pos2::new(point[0] as f32, point[1] as f32))
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
use crate::drawing::ShapeStyle;
use egui::vec2;
fn rect(x: f32, y: f32, w: f32, h: f32) -> DrawingElement {
DrawingElement::new(
Shape::Rectangle {
pos: Pos2::new(x, y),
size: vec2(w, h),
},
ShapeStyle::default(),
)
}
fn ellipse(cx: f32, cy: f32, rx: f32, ry: f32) -> DrawingElement {
DrawingElement::new(
Shape::Ellipse {
center: Pos2::new(cx, cy),
radii: vec2(rx, ry),
},
ShapeStyle::default(),
)
}
#[test]
fn test_rect_union() {
let a = rect(0.0, 0.0, 100.0, 100.0);
let b = rect(50.0, 0.0, 100.0, 100.0);
let result = boolean_op(BooleanOpType::Union, &[&a, &b]).unwrap();
assert!(!result.is_empty());
}
#[test]
fn test_rect_intersection() {
let a = rect(0.0, 0.0, 100.0, 100.0);
let b = rect(50.0, 0.0, 100.0, 100.0);
let result = boolean_op(BooleanOpType::Intersection, &[&a, &b]).unwrap();
assert!(!result.is_empty());
}
#[test]
fn test_disjoint_intersection() {
let a = rect(0.0, 0.0, 100.0, 100.0);
let b = rect(200.0, 0.0, 100.0, 100.0);
let result = boolean_op(BooleanOpType::Intersection, &[&a, &b]);
assert!(result.is_err() || result.unwrap().is_empty());
}
#[test]
fn test_ellipse_union() {
let a = ellipse(80.0, 80.0, 60.0, 40.0);
let b = ellipse(120.0, 80.0, 60.0, 40.0);
let result = boolean_op(BooleanOpType::Union, &[&a, &b]).unwrap();
assert!(!result.is_empty());
}
#[test]
fn test_line_rejected() {
let a = DrawingElement::new(
Shape::Line {
start: Pos2::new(0.0, 0.0),
end: Pos2::new(100.0, 0.0),
},
ShapeStyle::default(),
);
let b = rect(0.0, 0.0, 100.0, 100.0);
let result = boolean_op(BooleanOpType::Union, &[&a, &b]);
assert!(result.is_err());
}
}

View File

@@ -15,6 +15,12 @@ pub struct DrawingElement {
pub style: ShapeStyle, pub style: ShapeStyle,
} }
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct PathPolygon {
pub exterior: Vec<Pos2>,
pub holes: Vec<Vec<Pos2>>,
}
impl DrawingElement { impl DrawingElement {
pub fn new(shape: Shape, style: ShapeStyle) -> Self { pub fn new(shape: Shape, style: ShapeStyle) -> Self {
Self { Self {
@@ -44,6 +50,28 @@ impl DrawingElement {
let approx_height = *font_size * 1.4; let approx_height = *font_size * 1.4;
egui::Rect::from_min_size(*pos, egui::vec2(approx_width, approx_height)) egui::Rect::from_min_size(*pos, egui::vec2(approx_width, approx_height))
} }
Shape::Path { polygons } => {
let mut min_x = f32::INFINITY;
let mut min_y = f32::INFINITY;
let mut max_x = f32::NEG_INFINITY;
let mut max_y = f32::NEG_INFINITY;
for polygon in polygons {
for point in &polygon.exterior {
min_x = min_x.min(point.x);
min_y = min_y.min(point.y);
max_x = max_x.max(point.x);
max_y = max_y.max(point.y);
}
}
if min_x.is_finite() && min_y.is_finite() && max_x.is_finite() && max_y.is_finite()
{
egui::Rect::from_min_max(Pos2::new(min_x, min_y), Pos2::new(max_x, max_y))
} else {
egui::Rect::from_min_size(Pos2::ZERO, egui::Vec2::ZERO)
}
}
} }
} }
@@ -76,6 +104,13 @@ impl DrawingElement {
let rect = egui::Rect::from_min_size(*pos, egui::vec2(approx_width, approx_height)); let rect = egui::Rect::from_min_size(*pos, egui::vec2(approx_width, approx_height));
rect.expand(tolerance).contains(point) rect.expand(tolerance).contains(point)
} }
Shape::Path { polygons } => polygons.iter().any(|polygon| {
point_in_polygon(point, &polygon.exterior)
&& !polygon
.holes
.iter()
.any(|hole| point_in_polygon(point, hole))
}),
} }
} }
@@ -89,11 +124,24 @@ impl DrawingElement {
*end += delta; *end += delta;
} }
Shape::Text { pos, .. } => *pos += delta, Shape::Text { pos, .. } => *pos += delta,
Shape::Path { polygons } => {
for polygon in polygons {
for point in &mut polygon.exterior {
*point += delta;
}
for hole in &mut polygon.holes {
for point in hole {
*point += delta;
}
}
}
}
} }
} }
/// Resize to fit a new bounding rect, preserving shape semantics. /// Resize to fit a new bounding rect, preserving shape semantics.
pub fn resize_to(&mut self, new_rect: egui::Rect) { pub fn resize_to(&mut self, new_rect: egui::Rect) {
let old_rect = self.bounding_rect();
match &mut self.shape { match &mut self.shape {
Shape::Rectangle { pos, size } => { Shape::Rectangle { pos, size } => {
*pos = new_rect.min; *pos = new_rect.min;
@@ -110,6 +158,21 @@ impl DrawingElement {
Shape::Text { pos, .. } => { Shape::Text { pos, .. } => {
*pos = new_rect.min; *pos = new_rect.min;
} }
Shape::Path { polygons } => {
let old_w = old_rect.width();
let old_h = old_rect.height();
for polygon in polygons {
for point in &mut polygon.exterior {
*point = map_point_to_rect(*point, old_rect, new_rect, old_w, old_h);
}
for hole in &mut polygon.holes {
for point in hole {
*point = map_point_to_rect(*point, old_rect, new_rect, old_w, old_h);
}
}
}
}
} }
} }
} }
@@ -138,6 +201,9 @@ pub enum Shape {
content: String, content: String,
font_size: f32, font_size: f32,
}, },
Path {
polygons: Vec<PathPolygon>,
},
} }
#[derive(Debug, Clone, Serialize, Deserialize)] #[derive(Debug, Clone, Serialize, Deserialize)]
@@ -173,6 +239,63 @@ fn point_to_segment_distance(p: Pos2, a: Pos2, b: Pos2) -> f32 {
(p - closest).length() (p - closest).length()
} }
fn point_in_polygon(point: Pos2, ring: &[Pos2]) -> bool {
if ring.len() < 3 {
return false;
}
let mut winding_number = 0;
for (a, b) in ring
.iter()
.zip(ring.iter().cycle().skip(1))
.take(ring.len())
.map(|(a, b)| (*a, *b))
{
if point_to_segment_distance(point, a, b) <= 1e-3 {
return true;
}
if a.y <= point.y {
if b.y > point.y && cross(a, b, point) > 0.0 {
winding_number += 1;
}
} else if b.y <= point.y && cross(a, b, point) < 0.0 {
winding_number -= 1;
}
}
winding_number != 0
}
fn cross(a: Pos2, b: Pos2, p: Pos2) -> f32 {
(b.x - a.x) * (p.y - a.y) - (b.y - a.y) * (p.x - a.x)
}
fn map_point_to_rect(
point: Pos2,
old_rect: egui::Rect,
new_rect: egui::Rect,
old_w: f32,
old_h: f32,
) -> Pos2 {
let rel_x = if old_w.abs() <= f32::EPSILON {
0.0
} else {
(point.x - old_rect.min.x) / old_w
};
let rel_y = if old_h.abs() <= f32::EPSILON {
0.0
} else {
(point.y - old_rect.min.y) / old_h
};
Pos2::new(
new_rect.min.x + rel_x * new_rect.width(),
new_rect.min.y + rel_y * new_rect.height(),
)
}
#[cfg(test)] #[cfg(test)]
mod tests { mod tests {
use super::*; use super::*;

View File

@@ -1,7 +1,9 @@
pub mod boolean;
mod element; mod element;
mod render; mod render;
mod tool; mod tool;
pub use boolean::BooleanOpType;
pub use element::{DrawingElement, Shape, ShapeStyle}; pub use element::{DrawingElement, Shape, ShapeStyle};
pub use render::{ pub use render::{
draw_creation_preview, draw_elements, draw_selection, find_handle_at_screen_pos, draw_creation_preview, draw_elements, draw_selection, find_handle_at_screen_pos,

View File

@@ -1,5 +1,4 @@
use super::element::DrawingElement; use super::element::{DrawingElement, PathPolygon, Shape};
use super::element::Shape;
use super::tool::{DragState, ResizeHandle, Tool}; use super::tool::{DragState, ResizeHandle, Tool};
use egui::{Color32, Painter, Pos2, Stroke, Vec2}; use egui::{Color32, Painter, Pos2, Stroke, Vec2};
@@ -86,6 +85,27 @@ pub fn draw_element(
style.stroke_color, style.stroke_color,
); );
} }
Shape::Path { polygons } => {
if let Some(fill) = style.fill {
for polygon in polygons {
draw_path_fill(painter, polygon, canvas_center, offset, zoom, fill);
}
}
for polygon in polygons {
draw_closed_ring(
painter,
&polygon.exterior,
canvas_center,
offset,
zoom,
stroke,
);
for hole in &polygon.holes {
draw_closed_ring(painter, hole, canvas_center, offset, zoom, stroke);
}
}
}
} }
} }
@@ -114,6 +134,100 @@ pub fn draw_selection(
} }
} }
fn draw_path_fill(
painter: &Painter,
polygon: &PathPolygon,
canvas_center: Pos2,
offset: Vec2,
zoom: f32,
fill: Color32,
) {
if polygon.exterior.len() < 3 {
return;
}
let mut vertices = Vec::new();
let mut coords = Vec::new();
let mut hole_indices = Vec::new();
for point in &polygon.exterior {
let screen = canvas_to_screen(*point, canvas_center, offset, zoom);
vertices.push(screen);
coords.push(screen.x as f64);
coords.push(screen.y as f64);
}
let mut vertex_count = polygon.exterior.len();
for hole in &polygon.holes {
if hole.len() < 3 {
continue;
}
hole_indices.push(vertex_count);
for point in hole {
let screen = canvas_to_screen(*point, canvas_center, offset, zoom);
vertices.push(screen);
coords.push(screen.x as f64);
coords.push(screen.y as f64);
}
vertex_count += hole.len();
}
let triangles = match earcutr::earcut(&coords, &hole_indices, 2) {
Ok(indices) => indices,
Err(_) => return,
};
let mut mesh = egui::epaint::Mesh::default();
mesh.reserve_vertices(vertices.len());
mesh.reserve_triangles(triangles.len() / 3);
for vertex in &vertices {
mesh.colored_vertex(*vertex, fill);
}
for triangle in triangles.chunks_exact(3) {
let a = match u32::try_from(triangle[0]) {
Ok(i) => i,
Err(_) => return,
};
let b = match u32::try_from(triangle[1]) {
Ok(i) => i,
Err(_) => return,
};
let c = match u32::try_from(triangle[2]) {
Ok(i) => i,
Err(_) => return,
};
mesh.add_triangle(a, b, c);
}
painter.add(egui::Shape::mesh(mesh));
}
fn draw_closed_ring(
painter: &Painter,
ring: &[Pos2],
canvas_center: Pos2,
offset: Vec2,
zoom: f32,
stroke: Stroke,
) {
if ring.len() < 2 {
return;
}
let mut points: Vec<Pos2> = ring
.iter()
.map(|point| canvas_to_screen(*point, canvas_center, offset, zoom))
.collect();
if let Some(first) = points.first().copied() {
points.push(first);
}
painter.add(egui::Shape::line(points, stroke));
}
pub fn draw_creation_preview( pub fn draw_creation_preview(
painter: &Painter, painter: &Painter,
tool: Tool, tool: Tool,

View File

@@ -0,0 +1,235 @@
use std::sync::Arc;
use std::time::Instant;
use serde::{Deserialize, Serialize};
use crate::drawing::DrawingElement;
#[derive(Copy, Clone, Debug, Eq, PartialEq, Hash)]
pub struct NodeId(pub usize);
#[derive(Clone, Debug, Serialize, Deserialize)]
pub enum ChangeSource {
Human,
Agent { name: Option<String> },
}
#[derive(Clone, Debug)]
pub struct DocumentSnapshot {
pub drawing_elements: Arc<Vec<DrawingElement>>,
pub svg_source: Option<Arc<str>>,
}
impl DocumentSnapshot {
pub fn new_empty() -> Self {
Self {
drawing_elements: Arc::new(Vec::new()),
svg_source: None,
}
}
pub fn from_state(elements: &[DrawingElement], svg: Option<&str>) -> Self {
Self {
drawing_elements: Arc::new(elements.to_vec()),
svg_source: svg.map(Arc::<str>::from),
}
}
}
#[derive(Clone, Debug)]
pub struct HistoryNode {
pub id: NodeId,
pub parent: Option<NodeId>,
pub children: Vec<NodeId>,
pub label: String,
pub source: ChangeSource,
pub timestamp: Instant,
pub snapshot: DocumentSnapshot,
}
pub struct HistoryTree {
pub nodes: Vec<HistoryNode>,
pub root: NodeId,
pub current: NodeId,
}
impl HistoryTree {
pub fn new(initial_snapshot: DocumentSnapshot) -> Self {
let root = NodeId(0);
let root_node = HistoryNode {
id: root,
parent: None,
children: Vec::new(),
label: "Initial State".to_string(),
source: ChangeSource::Human,
timestamp: Instant::now(),
snapshot: initial_snapshot,
};
Self {
nodes: vec![root_node],
root,
current: root,
}
}
pub fn push(
&mut self,
label: String,
source: ChangeSource,
snapshot: DocumentSnapshot,
) -> NodeId {
let parent = self.current;
let id = NodeId(self.nodes.len());
let node = HistoryNode {
id,
parent: Some(parent),
children: Vec::new(),
label,
source,
timestamp: Instant::now(),
snapshot,
};
self.nodes[parent.0].children.push(id);
self.nodes.push(node);
self.current = id;
id
}
pub fn checkout(&mut self, id: NodeId) -> &DocumentSnapshot {
assert!(id.0 < self.nodes.len(), "invalid history node id");
self.current = id;
&self.nodes[id.0].snapshot
}
pub fn current_snapshot(&self) -> &DocumentSnapshot {
&self.nodes[self.current.0].snapshot
}
pub fn node(&self, id: NodeId) -> &HistoryNode {
&self.nodes[id.0]
}
pub fn node_count(&self) -> usize {
self.nodes.len()
}
pub fn path_to_root(&self, id: NodeId) -> Vec<NodeId> {
let mut path = Vec::new();
let mut cursor = Some(id);
while let Some(node_id) = cursor {
path.push(node_id);
cursor = self.nodes[node_id.0].parent;
}
path
}
}
#[cfg(test)]
mod tests {
use egui::Pos2;
use super::*;
use crate::drawing::{DrawingElement, Shape, ShapeStyle};
#[test]
fn test_new_tree_has_one_node() {
let tree = HistoryTree::new(DocumentSnapshot::new_empty());
assert_eq!(tree.node_count(), 1);
assert_eq!(tree.root, NodeId(0));
assert_eq!(tree.current, NodeId(0));
assert_eq!(tree.node(NodeId(0)).label, "Initial State");
}
#[test]
fn test_push_creates_child() {
let mut tree = HistoryTree::new(DocumentSnapshot::new_empty());
let id = tree.push(
"Draw Rectangle".to_string(),
ChangeSource::Human,
DocumentSnapshot::new_empty(),
);
assert_eq!(id, NodeId(1));
assert_eq!(tree.current, NodeId(1));
assert_eq!(tree.node(NodeId(0)).children, vec![NodeId(1)]);
assert_eq!(tree.node(NodeId(1)).parent, Some(NodeId(0)));
}
#[test]
fn test_checkout_changes_current() {
let mut tree = HistoryTree::new(DocumentSnapshot::new_empty());
let first = tree.push(
"First".to_string(),
ChangeSource::Human,
DocumentSnapshot::new_empty(),
);
let second = tree.push(
"Second".to_string(),
ChangeSource::Human,
DocumentSnapshot::new_empty(),
);
assert_eq!(tree.current, second);
let _ = tree.checkout(first);
assert_eq!(tree.current, first);
}
#[test]
fn test_fork_creates_branch() {
let mut tree = HistoryTree::new(DocumentSnapshot::new_empty());
let parent = tree.push(
"Parent".to_string(),
ChangeSource::Human,
DocumentSnapshot::new_empty(),
);
let _child_a = tree.push(
"Child A".to_string(),
ChangeSource::Human,
DocumentSnapshot::new_empty(),
);
let _ = tree.checkout(parent);
let child_b = tree.push(
"Child B".to_string(),
ChangeSource::Human,
DocumentSnapshot::new_empty(),
);
assert_eq!(tree.node(parent).children.len(), 2);
assert_eq!(tree.node(parent).children[1], child_b);
}
#[test]
fn test_path_to_root() {
let mut tree = HistoryTree::new(DocumentSnapshot::new_empty());
let n1 = tree.push(
"n1".to_string(),
ChangeSource::Human,
DocumentSnapshot::new_empty(),
);
let n2 = tree.push(
"n2".to_string(),
ChangeSource::Human,
DocumentSnapshot::new_empty(),
);
assert_eq!(tree.path_to_root(n2), vec![n2, n1, tree.root]);
}
#[test]
fn test_snapshot_preserves_elements() {
let element = DrawingElement::new(
Shape::Rectangle {
pos: Pos2::new(10.0, 20.0),
size: egui::vec2(50.0, 30.0),
},
ShapeStyle::default(),
);
let snapshot = DocumentSnapshot::from_state(&[element], Some("<svg></svg>"));
assert_eq!(snapshot.drawing_elements.len(), 1);
assert_eq!(snapshot.svg_source.as_deref(), Some("<svg></svg>"));
}
}

View File

@@ -5,6 +5,7 @@ mod clipboard;
mod command_palette; mod command_palette;
mod drawing; mod drawing;
mod element_tree; mod element_tree;
mod history;
mod mermaid; mod mermaid;
mod persistence; mod persistence;
mod session; mod session;
@@ -25,12 +26,12 @@ fn main() -> Result<()> {
viewport: egui::ViewportBuilder::default() viewport: egui::ViewportBuilder::default()
.with_inner_size([1400.0, 900.0]) .with_inner_size([1400.0, 900.0])
.with_min_inner_size([800.0, 600.0]) .with_min_inner_size([800.0, 600.0])
.with_title("agcanvas"), .with_title("Augmented Canvas"),
..Default::default() ..Default::default()
}; };
eframe::run_native( eframe::run_native(
"agcanvas", "Augmented Canvas",
native_options, native_options,
Box::new(|cc| Ok(Box::new(app::AgCanvasApp::new(cc)))), Box::new(|cc| Ok(Box::new(app::AgCanvasApp::new(cc)))),
) )

View File

@@ -1,6 +1,7 @@
use crate::canvas::CanvasState; use crate::canvas::CanvasState;
use crate::drawing::{DragState, DrawingElement, Tool}; use crate::drawing::{DragState, DrawingElement, Tool};
use crate::element_tree::ElementTree; use crate::element_tree::ElementTree;
use crate::history::{ChangeSource, DocumentSnapshot, HistoryTree, NodeId};
use crate::svg::SvgRenderer; use crate::svg::SvgRenderer;
use egui::TextureHandle; use egui::TextureHandle;
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
@@ -90,6 +91,7 @@ pub struct Session {
pub selected_element_id: Option<String>, pub selected_element_id: Option<String>,
pub active_tool: Tool, pub active_tool: Tool,
pub drag_state: DragState, pub drag_state: DragState,
pub history: HistoryTree,
pub description: Option<String>, pub description: Option<String>,
pub created_by: SessionCreator, pub created_by: SessionCreator,
@@ -111,6 +113,7 @@ impl Session {
selected_element_id: None, selected_element_id: None,
active_tool: Tool::default(), active_tool: Tool::default(),
drag_state: DragState::default(), drag_state: DragState::default(),
history: HistoryTree::new(DocumentSnapshot::new_empty()),
description: None, description: None,
created_by, created_by,
created_at: unix_now(), created_at: unix_now(),
@@ -164,6 +167,24 @@ impl Session {
self.drawing_elements.retain(|e| e.id != id); self.drawing_elements.retain(|e| e.id != id);
} }
} }
pub fn record_edit(&mut self, label: &str, source: ChangeSource) {
let snapshot =
DocumentSnapshot::from_state(&self.drawing_elements, self.svg_source.as_deref());
self.history.push(label.to_string(), source, snapshot);
}
pub fn checkout_history(&mut self, node_id: NodeId) {
let snapshot = self.history.checkout(node_id).clone();
self.drawing_elements = (*snapshot.drawing_elements).clone();
self.svg_source = snapshot.svg_source.map(|s| s.to_string());
self.element_tree = None;
self.svg_renderer = None;
self.svg_texture = None;
self.description_text.clear();
self.selected_element_id = None;
self.drag_state = DragState::default();
}
} }
#[derive(Debug, Clone)] #[derive(Debug, Clone)]

48
scripts/bundle-macos.sh Executable file
View File

@@ -0,0 +1,48 @@
#!/usr/bin/env bash
#
# Bundle agcanvas as a macOS .app for Finder.
# Works on both Apple Silicon and Intel.
#
# Usage:
# ./scripts/bundle-macos.sh # Build release + bundle
# ./scripts/bundle-macos.sh --install # Also copy to /Applications
#
set -euo pipefail
SCRIPT_DIR="$(cd "$(dirname "$0")" && pwd)"
PROJECT_ROOT="$(cd "$SCRIPT_DIR/.." && pwd)"
APP_NAME="Augmented Canvas"
BUNDLE_DIR="$PROJECT_ROOT/target/release/bundle"
APP_BUNDLE="$BUNDLE_DIR/$APP_NAME.app"
echo "==> Building agcanvas (release)..."
cargo build --release -p agcanvas --manifest-path "$PROJECT_ROOT/Cargo.toml"
echo "==> Creating $APP_NAME.app bundle..."
rm -rf "$APP_BUNDLE"
mkdir -p "$APP_BUNDLE/Contents/MacOS"
mkdir -p "$APP_BUNDLE/Contents/Resources"
cp "$PROJECT_ROOT/target/release/agcanvas" "$APP_BUNDLE/Contents/MacOS/agcanvas"
cp "$PROJECT_ROOT/assets/Info.plist" "$APP_BUNDLE/Contents/Info.plist"
if [ -f "$PROJECT_ROOT/assets/AppIcon.icns" ]; then
cp "$PROJECT_ROOT/assets/AppIcon.icns" "$APP_BUNDLE/Contents/Resources/AppIcon.icns"
echo " Icon: AppIcon.icns"
else
echo " Icon: none (add assets/AppIcon.icns for a custom icon)"
fi
printf 'APPL????' > "$APP_BUNDLE/Contents/PkgInfo"
echo "==> Bundle created: $APP_BUNDLE"
if [[ "${1:-}" == "--install" ]]; then
echo "==> Installing to /Applications..."
rm -rf "/Applications/$APP_NAME.app"
cp -r "$APP_BUNDLE" "/Applications/$APP_NAME.app"
echo "==> Installed: /Applications/$APP_NAME.app"
echo " Open Finder → Applications → Augmented Canvas"
fi
echo "==> Done."