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."