diff --git a/assets/Info.plist b/assets/Info.plist new file mode 100644 index 0000000..76d91e2 --- /dev/null +++ b/assets/Info.plist @@ -0,0 +1,32 @@ + + + + + CFBundleName + Augmented Canvas + CFBundleDisplayName + Augmented Canvas + CFBundleIdentifier + com.agcanvas.app + CFBundleVersion + 0.1.0 + CFBundleShortVersionString + 0.1.0 + CFBundlePackageType + APPL + CFBundleExecutable + agcanvas + CFBundleIconFile + AppIcon + LSMinimumSystemVersion + 11.0 + NSHighResolutionCapable + + NSSupportsAutomaticGraphicsSwitching + + CFBundleInfoDictionaryVersion + 6.0 + LSApplicationCategoryType + public.app-category.developer-tools + + diff --git a/crates/agcanvas-mcp/Cargo.toml b/crates/agcanvas-mcp/Cargo.toml index 023f9e7..018e0d7 100644 --- a/crates/agcanvas-mcp/Cargo.toml +++ b/crates/agcanvas-mcp/Cargo.toml @@ -3,7 +3,7 @@ name = "agcanvas-mcp" version.workspace = true edition.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]] name = "agcanvas-mcp" diff --git a/crates/agcanvas-mcp/src/bridge.rs b/crates/agcanvas-mcp/src/bridge.rs index e9c30ca..caf0ba5 100644 --- a/crates/agcanvas-mcp/src/bridge.rs +++ b/crates/agcanvas-mcp/src/bridge.rs @@ -7,7 +7,7 @@ pub async fn send_request(ws_url: &str, request_json: &str) -> Result { .await .map_err(|e| { anyhow::anyhow!( - "Cannot connect to agcanvas at {}. Is agcanvas running? Error: {}", + "Cannot connect to Augmented Canvas at {}. Is Augmented Canvas running? Error: {}", ws_url, e ) diff --git a/crates/agcanvas-mcp/src/main.rs b/crates/agcanvas-mcp/src/main.rs index 95048d7..a0ecb03 100644 --- a/crates/agcanvas-mcp/src/main.rs +++ b/crates/agcanvas-mcp/src/main.rs @@ -8,7 +8,7 @@ use tools::AgCanvasServer; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; #[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 { #[arg(long, default_value = "9876")] port: u16, @@ -26,7 +26,7 @@ async fn main() -> Result<()> { let cli = Cli::parse(); 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 service = server.serve(rmcp::transport::stdio()).await?; diff --git a/crates/agcanvas-mcp/src/tools.rs b/crates/agcanvas-mcp/src/tools.rs index a0509b3..5f548fe 100644 --- a/crates/agcanvas-mcp/src/tools.rs +++ b/crates/agcanvas-mcp/src/tools.rs @@ -169,6 +169,24 @@ pub struct DeleteDrawingElementParam { 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, + #[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, + #[schemars(description = "If true, delete source elements after combining")] + pub consume: Option, + #[schemars(description = "Fill color for result as hex e.g. '#ff0000'")] + pub fill: Option, + #[schemars(description = "Stroke color for result as hex e.g. '#ffffff'")] + pub stroke_color: Option, + #[schemars(description = "Stroke width for result in pixels")] + pub stroke_width: Option, +} + #[derive(Debug, Clone)] pub struct AgCanvasServer { ws_url: String, @@ -185,7 +203,7 @@ impl AgCanvasServer { } #[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( &self, @@ -203,7 +221,7 @@ impl AgCanvasServer { } #[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( &self, @@ -472,6 +490,37 @@ impl AgCanvasServer { 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, + ) -> Result { + 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.")] async fn delete_drawing_element( &self, @@ -517,7 +566,7 @@ impl AgCanvasServer { let msg = parsed .get("message") .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)])); } @@ -526,7 +575,7 @@ impl AgCanvasServer { Ok(CallToolResult::success(vec![Content::text(pretty)])) } 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 ))])), } @@ -538,10 +587,10 @@ impl ServerHandler for AgCanvasServer { fn get_info(&self) -> ServerInfo { ServerInfo { 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 \ 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(), ), capabilities: ServerCapabilities::builder().enable_tools().build(), diff --git a/crates/agcanvas/Cargo.toml b/crates/agcanvas/Cargo.toml index 5705d7b..222a92e 100644 --- a/crates/agcanvas/Cargo.toml +++ b/crates/agcanvas/Cargo.toml @@ -3,7 +3,7 @@ name = "agcanvas" version.workspace = true edition.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] # GUI @@ -48,3 +48,5 @@ thiserror = "1.0" # Image handling image = { version = "0.25", default-features = false, features = ["png"] } +i_overlay = "4.4.0" +earcutr = "0.5.0" diff --git a/crates/agcanvas/src/agent/protocol.rs b/crates/agcanvas/src/agent/protocol.rs index 10326b9..41d7c9a 100644 --- a/crates/agcanvas/src/agent/protocol.rs +++ b/crates/agcanvas/src/agent/protocol.rs @@ -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::session::{SessionCreator, SessionInfo, SessionSortField, SortOrder}; use egui::{Color32, Pos2, Vec2}; @@ -197,6 +197,20 @@ pub enum AgentRequest { #[serde(default)] session_id: Option, }, + BooleanOp { + #[serde(default)] + session_id: Option, + operation: BooleanOpType, + element_ids: Vec, + #[serde(default)] + consume: Option, + #[serde(default)] + fill: Option, + #[serde(default)] + stroke_color: Option, + #[serde(default)] + stroke_width: Option, + }, Ping, } @@ -412,8 +426,11 @@ pub fn build_shape( content: text.unwrap_or_else(|| "Text".to_string()), 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!( - "Unknown shape_type '{}'. Expected: Rectangle, Ellipse, Line, Arrow, or Text", + "Unknown shape_type '{}'. Expected: Rectangle, Ellipse, Line, Arrow, Text, or Path", other )), } diff --git a/crates/agcanvas/src/agent/server.rs b/crates/agcanvas/src/agent/server.rs index 59d7e16..0c19e3d 100644 --- a/crates/agcanvas/src/agent/server.rs +++ b/crates/agcanvas/src/agent/server.rs @@ -1,8 +1,9 @@ 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, }; -use crate::drawing::DrawingElement; +use crate::drawing::{boolean, DrawingElement, Shape, ShapeStyle}; use crate::session::{SessionCreator, SessionStore}; use anyhow::Result; use futures_util::{SinkExt, StreamExt}; @@ -536,6 +537,109 @@ async fn process_request( }); 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, + } + } } } diff --git a/crates/agcanvas/src/app.rs b/crates/agcanvas/src/app.rs index 577b7a7..7e5e3a6 100644 --- a/crates/agcanvas/src/app.rs +++ b/crates/agcanvas/src/app.rs @@ -6,6 +6,7 @@ use crate::drawing::{ draw_creation_preview, draw_elements, 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}; @@ -29,6 +30,7 @@ pub struct AgCanvasApp { clipboard: Option, show_tree_panel: bool, show_description: bool, + show_history_panel: bool, status_message: Option<(String, std::time::Instant)>, _runtime: Runtime, @@ -72,6 +74,7 @@ impl AgCanvasApp { clipboard, show_tree_panel: false, show_description: false, + show_history_panel: false, status_message: None, _runtime: runtime, show_mermaid_dialog: false, @@ -190,6 +193,7 @@ impl AgCanvasApp { session.svg_renderer = Some(SvgRenderer::new(usvg_tree)); session.svg_source = Some(svg_data.to_string()); session.svg_texture = None; + session.record_edit("Load SVG", ChangeSource::Human); session .canvas_state .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_id = session.id.clone(); session.clear(); + session.record_edit("Clear Canvas", ChangeSource::Human); let sessions_handle = self.sessions_handle.clone(); let event_tx = self.event_tx.clone(); @@ -244,7 +249,8 @@ impl AgCanvasApp { fn render_svg_to_texture(&mut self, ctx: &egui::Context) { let session = self.active_session_mut(); 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) { let size = [pixmap.width() as usize, pixmap.height() as usize]; let pixels: Vec = pixmap @@ -298,6 +304,10 @@ impl AgCanvasApp { } => { if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) { session.drawing_elements.push(element); + session.record_edit( + "Agent: Create Element", + ChangeSource::Agent { name: None }, + ); } } DrawingCommand::Update { @@ -312,6 +322,10 @@ impl AgCanvasApp { { el.shape = element.shape; 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) { session.selected_element_id = None; } + session.record_edit( + "Agent: Delete Element", + ChangeSource::Agent { name: None }, + ); } } DrawingCommand::Clear { session_id } => { if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) { session.drawing_elements.clear(); 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::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() { + 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; } @@ -700,10 +730,18 @@ fn handle_shape_tool( }, _ => 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()); session.selected_element_id = Some(element.id.clone()); session.drawing_elements.push(element); + session.record_edit(&format!("Draw {}", tool_label), ChangeSource::Human); } } session.drag_state = DragState::None; @@ -721,6 +759,7 @@ impl eframe::App for AgCanvasApp { let mut save_workspace = false; let mut toggle_palette = false; let mut delete_selected = false; + let mut toggle_history = false; let mut tool_switch: Option = None; 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) { 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) { delete_selected = true; } @@ -785,8 +827,13 @@ impl eframe::App for AgCanvasApp { let idx = self.active_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 { self.active_session_mut().delete_selected(); + self.active_session_mut() + .record_edit("Delete Element", ChangeSource::Human); } if let Some(tool) = tool_switch { if !self.show_text_input && !self.show_mermaid_dialog { @@ -840,6 +887,9 @@ impl eframe::App for AgCanvasApp { { ui.close_menu(); } + if ui.checkbox(&mut self.show_history_panel, "History").clicked() { + ui.close_menu(); + } ui.separator(); if ui.button("Reset Zoom (Cmd+0)").clicked() { self.active_session_mut().canvas_state.reset(); @@ -972,6 +1022,48 @@ impl eframe::App for AgCanvasApp { } } + let mut checkout_node_id: Option = 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 { egui::SidePanel::right("tree_panel") .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 { let desc = self.active_session().description_text.clone(); egui::SidePanel::left("description_panel") @@ -1098,6 +1212,8 @@ impl eframe::App for AgCanvasApp { ); let eid = element.id.clone(); 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.show_text_input = false; @@ -1162,8 +1278,10 @@ impl eframe::App for AgCanvasApp { if let Some(texture) = &self.active_session().svg_texture { let canvas_state = &self.active_session().canvas_state; + let ppp = ctx.pixels_per_point(); 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 rect = egui::Rect::from_center_size(center + offset, size); 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 { + use std::collections::{HashMap, HashSet}; + + let active_nodes: HashSet = active_path.iter().copied().collect(); + let lanes = history_lanes(history); + let mut checkout: Option = None; + let mut node_centers: HashMap = 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 { + 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) { let kind_name = match &element.kind { crate::element_tree::ElementKind::Group { name } => { diff --git a/crates/agcanvas/src/command_palette.rs b/crates/agcanvas/src/command_palette.rs index 89a255d..910eeed 100644 --- a/crates/agcanvas/src/command_palette.rs +++ b/crates/agcanvas/src/command_palette.rs @@ -18,6 +18,7 @@ pub enum CommandId { FitToView, ToggleTreePanel, ToggleDescription, + ToggleHistory, } #[derive(Debug, Clone)] @@ -82,6 +83,12 @@ pub fn all_commands() -> Vec { None, "View", ), + PaletteCommand::new( + CommandId::ToggleHistory, + "Toggle History Panel", + Some("Cmd+H"), + "View", + ), ] } diff --git a/crates/agcanvas/src/drawing/boolean.rs b/crates/agcanvas/src/drawing/boolean.rs new file mode 100644 index 0000000..7c4d88f --- /dev/null +++ b/crates/agcanvas/src/drawing/boolean.rs @@ -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, 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, String> { + if elements.len() < 2 { + return Err("Boolean operation requires at least 2 elements".to_string()); + } + + let contours: Vec> = elements + .iter() + .map(|element| shape_to_contour(&element.shape)) + .collect::>()?; + + 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(¤t_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 { + 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 { + 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()); + } +} diff --git a/crates/agcanvas/src/drawing/element.rs b/crates/agcanvas/src/drawing/element.rs index 404ad11..e69b237 100644 --- a/crates/agcanvas/src/drawing/element.rs +++ b/crates/agcanvas/src/drawing/element.rs @@ -15,6 +15,12 @@ pub struct DrawingElement { pub style: ShapeStyle, } +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct PathPolygon { + pub exterior: Vec, + pub holes: Vec>, +} + impl DrawingElement { pub fn new(shape: Shape, style: ShapeStyle) -> Self { Self { @@ -44,6 +50,28 @@ impl DrawingElement { let approx_height = *font_size * 1.4; 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)); 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; } 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. pub fn resize_to(&mut self, new_rect: egui::Rect) { + let old_rect = self.bounding_rect(); match &mut self.shape { Shape::Rectangle { pos, size } => { *pos = new_rect.min; @@ -110,6 +158,21 @@ impl DrawingElement { Shape::Text { pos, .. } => { *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, font_size: f32, }, + Path { + polygons: Vec, + }, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -173,6 +239,63 @@ fn point_to_segment_distance(p: Pos2, a: Pos2, b: Pos2) -> f32 { (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)] mod tests { use super::*; diff --git a/crates/agcanvas/src/drawing/mod.rs b/crates/agcanvas/src/drawing/mod.rs index 6a94bea..8966917 100644 --- a/crates/agcanvas/src/drawing/mod.rs +++ b/crates/agcanvas/src/drawing/mod.rs @@ -1,7 +1,9 @@ +pub mod boolean; mod element; mod render; mod tool; +pub use boolean::BooleanOpType; pub use element::{DrawingElement, Shape, ShapeStyle}; pub use render::{ draw_creation_preview, draw_elements, draw_selection, find_handle_at_screen_pos, diff --git a/crates/agcanvas/src/drawing/render.rs b/crates/agcanvas/src/drawing/render.rs index f209674..902c429 100644 --- a/crates/agcanvas/src/drawing/render.rs +++ b/crates/agcanvas/src/drawing/render.rs @@ -1,5 +1,4 @@ -use super::element::DrawingElement; -use super::element::Shape; +use super::element::{DrawingElement, PathPolygon, Shape}; use super::tool::{DragState, ResizeHandle, Tool}; use egui::{Color32, Painter, Pos2, Stroke, Vec2}; @@ -86,6 +85,27 @@ pub fn draw_element( 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 = 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( painter: &Painter, tool: Tool, diff --git a/crates/agcanvas/src/history.rs b/crates/agcanvas/src/history.rs new file mode 100644 index 0000000..1fc8f67 --- /dev/null +++ b/crates/agcanvas/src/history.rs @@ -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 }, +} + +#[derive(Clone, Debug)] +pub struct DocumentSnapshot { + pub drawing_elements: Arc>, + pub svg_source: Option>, +} + +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::::from), + } + } +} + +#[derive(Clone, Debug)] +pub struct HistoryNode { + pub id: NodeId, + pub parent: Option, + pub children: Vec, + pub label: String, + pub source: ChangeSource, + pub timestamp: Instant, + pub snapshot: DocumentSnapshot, +} + +pub struct HistoryTree { + pub nodes: Vec, + 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 { + 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("")); + assert_eq!(snapshot.drawing_elements.len(), 1); + assert_eq!(snapshot.svg_source.as_deref(), Some("")); + } +} diff --git a/crates/agcanvas/src/main.rs b/crates/agcanvas/src/main.rs index ce88e94..1780bfa 100644 --- a/crates/agcanvas/src/main.rs +++ b/crates/agcanvas/src/main.rs @@ -5,6 +5,7 @@ mod clipboard; mod command_palette; mod drawing; mod element_tree; +mod history; mod mermaid; mod persistence; mod session; @@ -25,12 +26,12 @@ fn main() -> Result<()> { viewport: egui::ViewportBuilder::default() .with_inner_size([1400.0, 900.0]) .with_min_inner_size([800.0, 600.0]) - .with_title("agcanvas"), + .with_title("Augmented Canvas"), ..Default::default() }; eframe::run_native( - "agcanvas", + "Augmented Canvas", native_options, Box::new(|cc| Ok(Box::new(app::AgCanvasApp::new(cc)))), ) diff --git a/crates/agcanvas/src/session.rs b/crates/agcanvas/src/session.rs index 013bb33..60b840b 100644 --- a/crates/agcanvas/src/session.rs +++ b/crates/agcanvas/src/session.rs @@ -1,6 +1,7 @@ use crate::canvas::CanvasState; use crate::drawing::{DragState, DrawingElement, Tool}; use crate::element_tree::ElementTree; +use crate::history::{ChangeSource, DocumentSnapshot, HistoryTree, NodeId}; use crate::svg::SvgRenderer; use egui::TextureHandle; use serde::{Deserialize, Serialize}; @@ -90,6 +91,7 @@ pub struct Session { pub selected_element_id: Option, pub active_tool: Tool, pub drag_state: DragState, + pub history: HistoryTree, pub description: Option, pub created_by: SessionCreator, @@ -111,6 +113,7 @@ impl Session { selected_element_id: None, active_tool: Tool::default(), drag_state: DragState::default(), + history: HistoryTree::new(DocumentSnapshot::new_empty()), description: None, created_by, created_at: unix_now(), @@ -164,6 +167,24 @@ impl Session { 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)] diff --git a/scripts/bundle-macos.sh b/scripts/bundle-macos.sh new file mode 100755 index 0000000..8e059e1 --- /dev/null +++ b/scripts/bundle-macos.sh @@ -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."