diff --git a/crates/agcanvas-mcp/src/tools.rs b/crates/agcanvas-mcp/src/tools.rs index db493f8..2940e66 100644 --- a/crates/agcanvas-mcp/src/tools.rs +++ b/crates/agcanvas-mcp/src/tools.rs @@ -105,6 +105,10 @@ pub struct CreateDrawingElementParam { pub x2: Option, #[schemars(description = "End Y (Line/Arrow)")] pub y2: Option, + #[schemars(description = "Arrow control offset X from midpoint (optional)")] + pub control_offset_x: Option, + #[schemars(description = "Arrow control offset Y from midpoint (optional)")] + pub control_offset_y: Option, #[schemars(description = "Text content (Text shape only)")] pub text: Option, #[schemars(description = "Font size in pixels (Text shape, default 20)")] @@ -115,6 +119,24 @@ pub struct CreateDrawingElementParam { pub stroke_color: Option, #[schemars(description = "Stroke width in pixels (default 2.0)")] pub stroke_width: Option, + #[schemars(description = "Opacity from 0.0 to 1.0 (default 1.0)")] + pub opacity: Option, + #[schemars(description = "Rotation in degrees (default 0)")] + pub rotation: Option, + #[schemars(description = "Corner radius for rectangles (default 0)")] + pub corner_radius: Option, + #[schemars(description = "Font family for text: 'monospace' or omit for default")] + pub font_family: Option, + #[schemars(description = "Number of sides for Polygon (default 6)")] + pub sides: Option, + #[schemars(description = "Inner radius ratio for star polygon (0.0-1.0)")] + pub star_inner_ratio: Option, + #[schemars( + description = "Maximum text width for wrapping (Text shape only). Text will wrap at this width." + )] + pub max_width: Option, + #[schemars(description = "Group ID to assign to the element")] + pub group_id: Option, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -149,6 +171,10 @@ pub struct UpdateDrawingElementParam { pub x2: Option, #[schemars(description = "End Y (Line/Arrow)")] pub y2: Option, + #[schemars(description = "Arrow control offset X from midpoint")] + pub control_offset_x: Option, + #[schemars(description = "Arrow control offset Y from midpoint")] + pub control_offset_y: Option, #[schemars(description = "Text content (Text shape)")] pub text: Option, #[schemars(description = "Font size (Text shape)")] @@ -159,6 +185,144 @@ pub struct UpdateDrawingElementParam { pub stroke_color: Option, #[schemars(description = "Stroke width in pixels")] pub stroke_width: Option, + #[schemars(description = "Opacity from 0.0 to 1.0")] + pub opacity: Option, + #[schemars(description = "Rotation in degrees")] + pub rotation: Option, + #[schemars(description = "Corner radius for rectangles")] + pub corner_radius: Option, + #[schemars(description = "Font family for text: 'monospace' or omit for default")] + pub font_family: Option, + #[schemars(description = "Number of sides for Polygon")] + pub sides: Option, + #[schemars(description = "Inner radius ratio for star polygon (0.0-1.0)")] + pub star_inner_ratio: Option, + #[schemars( + description = "Maximum text width for wrapping (Text shape only). Text will wrap at this width." + )] + pub max_width: Option, + #[schemars(description = "Group ID to assign to the element")] + pub group_id: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct GroupElementsParam { + #[schemars(description = "Session ID to target. If omitted, uses the active session.")] + pub session_id: Option, + #[schemars(description = "IDs of drawing elements to group")] + pub element_ids: Vec, + #[schemars(description = "Optional group ID. If omitted, agcanvas auto-generates one")] + pub group_id: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct UngroupElementsParam { + #[schemars(description = "Session ID to target. If omitted, uses the active session.")] + pub session_id: Option, + #[schemars(description = "IDs of drawing elements to ungroup")] + pub element_ids: Vec, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct AlignElementsParam { + #[schemars(description = "Session ID to target. If omitted, uses the active session.")] + pub session_id: Option, + #[schemars(description = "IDs of drawing elements to align")] + pub element_ids: Vec, + #[schemars( + description = "Alignment operation: left, right, top, bottom, center_h, center_v, distribute_h, distribute_v" + )] + pub operation: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct ExportSvgParam { + #[schemars(description = "Session ID to target. If omitted, uses the active session.")] + pub session_id: Option, + #[schemars(description = "File path to save the SVG export to")] + pub path: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct ConvertToPathParam { + #[schemars(description = "Session ID to target. If omitted, uses the active session.")] + pub session_id: Option, + #[schemars(description = "IDs of drawing elements to convert to editable path shapes")] + pub element_ids: Vec, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct MoveVertexParam { + #[schemars(description = "Session ID to target. If omitted, uses the active session.")] + pub session_id: Option, + #[schemars(description = "ID of the Path element")] + pub element_id: String, + #[schemars(description = "Index of the polygon within the Path (usually 0)")] + pub polygon_idx: usize, + #[schemars(description = "Index of the vertex to move")] + pub vertex_idx: usize, + #[schemars(description = "If true, targets hole ring instead of exterior (default false)")] + pub is_hole: Option, + #[schemars(description = "New X position for the vertex")] + pub x: f32, + #[schemars(description = "New Y position for the vertex")] + pub y: f32, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct AddVertexParam { + #[schemars(description = "Session ID to target. If omitted, uses the active session.")] + pub session_id: Option, + #[schemars(description = "ID of the Path element")] + pub element_id: String, + #[schemars(description = "Index of the polygon within the Path (usually 0)")] + pub polygon_idx: usize, + #[schemars(description = "Insert after this vertex index")] + pub after_vertex_idx: usize, + #[schemars(description = "If true, targets hole ring instead of exterior (default false)")] + pub is_hole: Option, + #[schemars(description = "X position for the new vertex")] + pub x: f32, + #[schemars(description = "Y position for the new vertex")] + pub y: f32, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct DeleteVertexParam { + #[schemars(description = "Session ID to target. If omitted, uses the active session.")] + pub session_id: Option, + #[schemars(description = "ID of the Path element")] + pub element_id: String, + #[schemars(description = "Index of the polygon within the Path (usually 0)")] + pub polygon_idx: usize, + #[schemars(description = "Index of the vertex to delete")] + pub vertex_idx: usize, + #[schemars(description = "If true, targets hole ring instead of exterior (default false)")] + pub is_hole: Option, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct ReorderElementParam { + #[schemars(description = "Session ID to target. If omitted, uses the active session.")] + pub session_id: Option, + #[schemars(description = "IDs of drawing elements to reorder")] + pub element_ids: Vec, + #[schemars( + description = "Reorder operation: bring_forward, send_backward, bring_to_front, send_to_back" + )] + pub operation: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct DuplicateElementsParam { + #[schemars(description = "Session ID to target. If omitted, uses the active session.")] + pub session_id: Option, + #[schemars(description = "IDs of drawing elements to duplicate")] + pub element_ids: Vec, + #[schemars(description = "X offset for duplicated elements (default 20)")] + pub offset_x: Option, + #[schemars(description = "Y offset for duplicated elements (default 20)")] + pub offset_y: Option, } #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] @@ -215,6 +379,20 @@ pub struct ExportCanvasParam { pub background: Option, } +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct CaptureScreenshotParam { + #[schemars(description = "Session ID to target. If omitted, uses the active session.")] + pub session_id: Option, + #[schemars(description = "File path to save the screenshot PNG to")] + pub path: String, +} + +#[derive(Debug, serde::Deserialize, schemars::JsonSchema)] +pub struct GetAppStateParam { + #[schemars(description = "Session ID to target. If omitted, uses the active session.")] + pub session_id: Option, +} + #[derive(Debug, serde::Deserialize, schemars::JsonSchema)] pub struct BatchParam { #[schemars( @@ -386,7 +564,7 @@ impl AgCanvasServer { } #[tool( - description = "Create a drawing element on the canvas. Shape types: Rectangle (x,y,width,height), Ellipse (center_x,center_y,radius_x,radius_y), Line (x1,y1,x2,y2), Arrow (x1,y1,x2,y2), Text (x,y,text,font_size). Colors are hex strings like '#ff0000'. Returns the created element with its auto-generated ID." + description = "Create a drawing element on the canvas. Shape types: Rectangle (x,y,width,height), Ellipse (center_x,center_y,radius_x,radius_y), Line (x1,y1,x2,y2), Arrow (x1,y1,x2,y2), Polygon (center_x,center_y,radius_x,sides,star_inner_ratio), Text (x,y,text,font_size). Colors are hex strings like '#ff0000'. Returns the created element with its auto-generated ID." )] async fn create_drawing_element( &self, @@ -436,6 +614,12 @@ impl AgCanvasServer { if let Some(v) = params.y2 { obj.insert("y2".into(), v.into()); } + if let Some(v) = params.control_offset_x { + obj.insert("control_offset_x".into(), v.into()); + } + if let Some(v) = params.control_offset_y { + obj.insert("control_offset_y".into(), v.into()); + } if let Some(v) = params.text { obj.insert("text".into(), v.into()); } @@ -451,6 +635,30 @@ impl AgCanvasServer { if let Some(v) = params.stroke_width { obj.insert("stroke_width".into(), v.into()); } + if let Some(v) = params.opacity { + obj.insert("opacity".into(), v.into()); + } + if let Some(v) = params.rotation { + obj.insert("rotation".into(), v.into()); + } + if let Some(v) = params.corner_radius { + obj.insert("corner_radius".into(), v.into()); + } + if let Some(v) = params.font_family { + obj.insert("font_family".into(), v.into()); + } + if let Some(v) = params.sides { + obj.insert("sides".into(), v.into()); + } + if let Some(v) = params.star_inner_ratio { + obj.insert("star_inner_ratio".into(), v.into()); + } + if let Some(v) = params.max_width { + obj.insert("max_width".into(), v.into()); + } + if let Some(v) = params.group_id { + obj.insert("group_id".into(), v.into()); + } self.call_agcanvas(&request).await } @@ -508,6 +716,12 @@ impl AgCanvasServer { if let Some(v) = params.y2 { obj.insert("y2".into(), v.into()); } + if let Some(v) = params.control_offset_x { + obj.insert("control_offset_x".into(), v.into()); + } + if let Some(v) = params.control_offset_y { + obj.insert("control_offset_y".into(), v.into()); + } if let Some(v) = params.text { obj.insert("text".into(), v.into()); } @@ -523,6 +737,229 @@ impl AgCanvasServer { if let Some(v) = params.stroke_width { obj.insert("stroke_width".into(), v.into()); } + if let Some(v) = params.opacity { + obj.insert("opacity".into(), v.into()); + } + if let Some(v) = params.rotation { + obj.insert("rotation".into(), v.into()); + } + if let Some(v) = params.corner_radius { + obj.insert("corner_radius".into(), v.into()); + } + if let Some(v) = params.font_family { + obj.insert("font_family".into(), v.into()); + } + if let Some(v) = params.sides { + obj.insert("sides".into(), v.into()); + } + if let Some(v) = params.star_inner_ratio { + obj.insert("star_inner_ratio".into(), v.into()); + } + if let Some(v) = params.max_width { + obj.insert("max_width".into(), v.into()); + } + if let Some(v) = params.group_id { + obj.insert("group_id".into(), v.into()); + } + self.call_agcanvas(&request).await + } + + #[tool(description = "Group drawing elements together so they move/select as one unit")] + async fn group_elements( + &self, + Parameters(params): Parameters, + ) -> Result { + let mut request = serde_json::json!({ + "type": "GroupElements", + "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.group_id { + obj.insert("group_id".into(), v.into()); + } + self.call_agcanvas(&request).await + } + + #[tool(description = "Ungroup drawing elements (remove group association)")] + async fn ungroup_elements( + &self, + Parameters(params): Parameters, + ) -> Result { + let mut request = serde_json::json!({ + "type": "UngroupElements", + "element_ids": params.element_ids, + }); + if let Some(v) = params.session_id { + request["session_id"] = serde_json::Value::String(v); + } + self.call_agcanvas(&request).await + } + + #[tool( + description = "Align selected drawing elements (left, right, top, bottom, center_h, center_v, distribute_h, distribute_v)" + )] + async fn align_elements( + &self, + Parameters(params): Parameters, + ) -> Result { + let mut request = serde_json::json!({ + "type": "AlignElements", + "element_ids": params.element_ids, + "operation": params.operation, + }); + if let Some(v) = params.session_id { + request["session_id"] = serde_json::Value::String(v); + } + self.call_agcanvas(&request).await + } + + #[tool(description = "Export drawing elements as SVG vector file")] + async fn export_svg( + &self, + Parameters(params): Parameters, + ) -> Result { + let mut request = serde_json::json!({ + "type": "ExportSvg", + "path": params.path, + }); + if let Some(v) = params.session_id { + request["session_id"] = serde_json::Value::String(v); + } + self.call_agcanvas(&request).await + } + + #[tool( + description = "Reorder drawing elements in the z-stack. Operations: bring_forward (up one), send_backward (down one), bring_to_front (topmost), send_to_back (bottommost)." + )] + async fn reorder_element( + &self, + Parameters(params): Parameters, + ) -> Result { + let mut request = serde_json::json!({ + "type": "ReorderElement", + "element_ids": params.element_ids, + "operation": params.operation, + }); + if let Some(v) = params.session_id { + request["session_id"] = serde_json::Value::String(v); + } + self.call_agcanvas(&request).await + } + + #[tool( + description = "Duplicate drawing elements. Creates copies offset from the originals. Returns the new elements with their auto-generated IDs." + )] + async fn duplicate_elements( + &self, + Parameters(params): Parameters, + ) -> Result { + let mut request = serde_json::json!({ + "type": "DuplicateElements", + "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.offset_x { + obj.insert("offset_x".into(), v.into()); + } + if let Some(v) = params.offset_y { + obj.insert("offset_y".into(), v.into()); + } + self.call_agcanvas(&request).await + } + + #[tool( + description = "Convert drawing elements to editable Path shapes with explicit vertices. Works on Rectangle, Ellipse, Polygon, Line, Arrow. Text and Path shapes are skipped. After conversion, use move_vertex/add_vertex/delete_vertex to edit individual points." + )] + async fn convert_to_path( + &self, + Parameters(params): Parameters, + ) -> Result { + let mut request = serde_json::json!({ + "type": "ConvertToPath", + "element_ids": params.element_ids, + }); + if let Some(v) = params.session_id { + request["session_id"] = serde_json::Value::String(v); + } + self.call_agcanvas(&request).await + } + + #[tool( + description = "Move a vertex on a Path element to a new position. The element must be a Path shape (use convert_to_path first if needed)." + )] + async fn move_vertex( + &self, + Parameters(params): Parameters, + ) -> Result { + let mut request = serde_json::json!({ + "type": "MoveVertex", + "element_id": params.element_id, + "polygon_idx": params.polygon_idx, + "vertex_idx": params.vertex_idx, + "x": params.x, + "y": params.y, + }); + 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.is_hole { + obj.insert("is_hole".into(), v.into()); + } + self.call_agcanvas(&request).await + } + + #[tool( + description = "Insert a new vertex on a Path element after the specified vertex index. The element must be a Path shape." + )] + async fn add_vertex( + &self, + Parameters(params): Parameters, + ) -> Result { + let mut request = serde_json::json!({ + "type": "AddVertex", + "element_id": params.element_id, + "polygon_idx": params.polygon_idx, + "after_vertex_idx": params.after_vertex_idx, + "x": params.x, + "y": params.y, + }); + 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.is_hole { + obj.insert("is_hole".into(), v.into()); + } + self.call_agcanvas(&request).await + } + + #[tool( + description = "Delete a vertex from a Path element. Exterior ring must keep at least 3 vertices. The element must be a Path shape." + )] + async fn delete_vertex( + &self, + Parameters(params): Parameters, + ) -> Result { + let mut request = serde_json::json!({ + "type": "DeleteVertex", + "element_id": params.element_id, + "polygon_idx": params.polygon_idx, + "vertex_idx": params.vertex_idx, + }); + 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.is_hole { + obj.insert("is_hole".into(), v.into()); + } self.call_agcanvas(&request).await } @@ -600,6 +1037,37 @@ impl AgCanvasServer { self.call_agcanvas(&request).await } + #[tool( + description = "Capture a pixel-perfect screenshot of the entire Augmented Canvas application window (including UI chrome, toolbar, panels). Saves as PNG. The screenshot is taken asynchronously and may take 1-2 frames." + )] + async fn capture_screenshot( + &self, + Parameters(params): Parameters, + ) -> Result { + let mut request = serde_json::json!({ + "type": "CaptureScreenshot", + "path": params.path, + }); + if let Some(sid) = params.session_id { + request["session_id"] = serde_json::Value::String(sid); + } + self.call_agcanvas(&request).await + } + + #[tool( + description = "Get the current application state including active tool, selected elements, zoom level, pan offset, theme, panel visibility, session info, and canvas dimensions. Useful for debugging and understanding the current UI state." + )] + async fn get_app_state( + &self, + Parameters(params): Parameters, + ) -> Result { + let mut request = serde_json::json!({"type": "GetAppState"}); + if let Some(sid) = params.session_id { + request["session_id"] = serde_json::Value::String(sid); + } + self.call_agcanvas(&request).await + } + #[tool( description = "Send multiple Augmented Canvas operations in one request. Accepts a JSON array of request objects and returns one response per operation in a BatchResult." )] diff --git a/crates/agcanvas/Cargo.toml b/crates/agcanvas/Cargo.toml index 1d4f8da..da5c9f9 100644 --- a/crates/agcanvas/Cargo.toml +++ b/crates/agcanvas/Cargo.toml @@ -28,7 +28,7 @@ serde = { version = "1.0", features = ["derive"] } serde_json = "1.0" # Agent communication -tokio = { version = "1.0", features = ["rt-multi-thread", "sync", "macros"] } +tokio = { version = "1.0", features = ["rt-multi-thread", "sync", "macros", "time"] } tokio-tungstenite = "0.24" futures-util = "0.3" @@ -42,11 +42,14 @@ tracing-subscriber = { version = "0.3", features = ["env-filter"] } # Filesystem paths dirs = "5.0" +# Singleton lock +fs2 = "0.4" + # Error handling anyhow = "1.0" thiserror = "1.0" # Image handling -image = { version = "0.25", default-features = false, features = ["png"] } +image = { version = "0.25", default-features = false, features = ["png", "jpeg", "gif", "webp", "bmp"] } 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 e7c0163..be564fc 100644 --- a/crates/agcanvas/src/agent/protocol.rs +++ b/crates/agcanvas/src/agent/protocol.rs @@ -47,6 +47,11 @@ pub enum GuiEvent { DrawingElementsCleared { session_id: String, }, + ScreenshotCaptured { + path: String, + width: u32, + height: u32, + }, } // --------------------------------------------------------------------------- @@ -137,6 +142,10 @@ pub enum AgentRequest { #[serde(default)] y2: Option, #[serde(default)] + control_offset_x: Option, + #[serde(default)] + control_offset_y: Option, + #[serde(default)] text: Option, #[serde(default)] font_size: Option, @@ -146,6 +155,22 @@ pub enum AgentRequest { stroke_color: Option, #[serde(default)] stroke_width: Option, + #[serde(default)] + opacity: Option, + #[serde(default)] + rotation: Option, + #[serde(default)] + corner_radius: Option, + #[serde(default)] + font_family: Option, + #[serde(default)] + sides: Option, + #[serde(default)] + star_inner_ratio: Option, + #[serde(default)] + max_width: Option, + #[serde(default)] + group_id: Option, }, UpdateDrawingElement { #[serde(default)] @@ -178,6 +203,10 @@ pub enum AgentRequest { #[serde(default)] y2: Option, #[serde(default)] + control_offset_x: Option, + #[serde(default)] + control_offset_y: Option, + #[serde(default)] text: Option, #[serde(default)] font_size: Option, @@ -187,6 +216,22 @@ pub enum AgentRequest { stroke_color: Option, #[serde(default)] stroke_width: Option, + #[serde(default)] + opacity: Option, + #[serde(default)] + rotation: Option, + #[serde(default)] + corner_radius: Option, + #[serde(default)] + font_family: Option, + #[serde(default)] + sides: Option, + #[serde(default)] + star_inner_ratio: Option, + #[serde(default)] + max_width: Option, + #[serde(default)] + group_id: Option, }, DeleteDrawingElement { #[serde(default)] @@ -197,6 +242,81 @@ pub enum AgentRequest { #[serde(default)] session_id: Option, }, + GroupElements { + #[serde(default)] + session_id: Option, + element_ids: Vec, + #[serde(default)] + group_id: Option, + }, + UngroupElements { + #[serde(default)] + session_id: Option, + element_ids: Vec, + }, + AlignElements { + #[serde(default)] + session_id: Option, + element_ids: Vec, + operation: String, + }, + ReorderElement { + #[serde(default)] + session_id: Option, + element_ids: Vec, + /// One of: bring_forward, send_backward, bring_to_front, send_to_back + operation: String, + }, + DuplicateElements { + #[serde(default)] + session_id: Option, + element_ids: Vec, + #[serde(default)] + offset_x: Option, + #[serde(default)] + offset_y: Option, + }, + ConvertToPath { + #[serde(default)] + session_id: Option, + element_ids: Vec, + }, + MoveVertex { + #[serde(default)] + session_id: Option, + element_id: String, + polygon_idx: usize, + vertex_idx: usize, + #[serde(default)] + is_hole: bool, + x: f32, + y: f32, + }, + AddVertex { + #[serde(default)] + session_id: Option, + element_id: String, + polygon_idx: usize, + after_vertex_idx: usize, + #[serde(default)] + is_hole: bool, + x: f32, + y: f32, + }, + DeleteVertex { + #[serde(default)] + session_id: Option, + element_id: String, + polygon_idx: usize, + vertex_idx: usize, + #[serde(default)] + is_hole: bool, + }, + ExportSvg { + #[serde(default)] + session_id: Option, + path: String, + }, BooleanOp { #[serde(default)] session_id: Option, @@ -233,6 +353,15 @@ pub enum AgentRequest { #[serde(default)] background: Option, }, + CaptureScreenshot { + path: String, + #[serde(default)] + session_id: Option, + }, + GetAppState { + #[serde(default)] + session_id: Option, + }, Batch { requests: Vec, }, @@ -297,6 +426,50 @@ pub enum AgentResponse { DrawingElementsCleared { session_id: String, }, + ElementsGrouped { + session_id: String, + group_id: String, + element_ids: Vec, + }, + ElementsUngrouped { + session_id: String, + element_ids: Vec, + }, + ElementsAligned { + session_id: String, + operation: String, + element_ids: Vec, + }, + ElementsReordered { + session_id: String, + operation: String, + element_ids: Vec, + }, + ElementsDuplicated { + session_id: String, + original_ids: Vec, + new_elements: Vec, + }, + ElementsConverted { + session_id: String, + element_ids: Vec, + }, + VertexMoved { + session_id: String, + element: DrawingElement, + }, + VertexAdded { + session_id: String, + element: DrawingElement, + }, + VertexDeleted { + session_id: String, + element: DrawingElement, + }, + SvgExported { + session_id: String, + path: String, + }, MermaidRendered { session_id: String, overlay_id: String, @@ -310,6 +483,27 @@ pub enum AgentResponse { width: u32, height: u32, }, + ScreenshotCaptured { + path: String, + width: u32, + height: u32, + }, + AppState { + session_id: String, + active_tool: String, + selected_element_ids: Vec, + zoom: f32, + pan_offset_x: f32, + pan_offset_y: f32, + theme: String, + show_tree_panel: bool, + show_description_panel: bool, + show_history_panel: bool, + session_name: String, + element_count: usize, + canvas_width: f32, + canvas_height: f32, + }, BatchResult { results: Vec, }, @@ -340,6 +534,9 @@ pub enum DrawingCommand { Clear { session_id: String, }, + CaptureScreenshot { + path: String, + }, } // --------------------------------------------------------------------------- @@ -434,8 +631,13 @@ pub fn build_shape( y1: Option, x2: Option, y2: Option, + control_offset_x: Option, + control_offset_y: Option, text: Option, font_size: Option, + sides: Option, + star_inner_ratio: Option, + max_width: Option, ) -> Result { match shape_type { "Rectangle" | "rectangle" | "rect" | "Rect" => Ok(Shape::Rectangle { @@ -457,38 +659,72 @@ pub fn build_shape( "Arrow" | "arrow" => { let sx = x1.or(x).unwrap_or(0.0); let sy = y1.or(y).unwrap_or(0.0); + let control_offset = if control_offset_x.is_some() || control_offset_y.is_some() { + Some(Vec2::new( + control_offset_x.unwrap_or(0.0), + control_offset_y.unwrap_or(0.0), + )) + } else { + None + }; Ok(Shape::Arrow { start: Pos2::new(sx, sy), end: Pos2::new(x2.unwrap_or(sx + 100.0), y2.unwrap_or(sy)), + control_offset, }) } "Text" | "text" => Ok(Shape::Text { pos: Pos2::new(x.unwrap_or(0.0), y.unwrap_or(0.0)), content: text.unwrap_or_else(|| "Text".to_string()), font_size: font_size.unwrap_or(20.0), + max_width, }), + "Polygon" | "polygon" => { + let cx = center_x.or(x).unwrap_or(0.0); + let cy = center_y.or(y).unwrap_or(0.0); + let radius = radius_x + .or_else(|| width.map(|w| w.abs() / 2.0)) + .unwrap_or(50.0); + Ok(Shape::Polygon { + center: Pos2::new(cx, cy), + radius, + sides: sides.unwrap_or(6), + star_inner_ratio, + }) + } "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, Text, or Path", + "Unknown shape_type '{}'. Expected: Rectangle, Ellipse, Line, Arrow, Polygon, Text, or Path", other )), } } -/// Build a `ShapeStyle` from optional hex color strings. pub fn build_style( fill: Option, stroke_color: Option, stroke_width: Option, + opacity: Option, + rotation: Option, + corner_radius: Option, + font_family: Option, ) -> ShapeStyle { ShapeStyle { - fill: fill.as_deref().and_then(parse_hex_color), + fill: fill + .as_deref() + .and_then(parse_hex_color) + .map(crate::drawing::Fill::solid), stroke_color: stroke_color .as_deref() .and_then(parse_hex_color) .unwrap_or(Color32::WHITE), stroke_width: stroke_width.unwrap_or(2.0), + opacity: opacity.unwrap_or(1.0), + rotation_degrees: rotation.unwrap_or(0.0), + corner_radius: corner_radius.unwrap_or(0.0), + font_family, + stroke_dash: None, } } diff --git a/crates/agcanvas/src/agent/server.rs b/crates/agcanvas/src/agent/server.rs index 7a973bd..0ba6538 100644 --- a/crates/agcanvas/src/agent/server.rs +++ b/crates/agcanvas/src/agent/server.rs @@ -2,11 +2,12 @@ use super::protocol::{ build_shape, build_style, parse_hex_color, AgentRequest, AgentResponse, CodeGenTarget, DrawingCommand, GuiEvent, SessionCommand, }; -use crate::drawing::{boolean, DrawingElement, Shape, ShapeStyle}; +use crate::drawing::{boolean, describe_elements, DrawingElement, Shape, ShapeStyle}; use crate::export::ExportData; use crate::session::{SessionCreator, SessionStore}; use anyhow::Result; use futures_util::{SinkExt, StreamExt}; +use std::collections::HashSet; use std::sync::atomic::{AtomicUsize, Ordering}; use std::sync::Arc; use tokio::net::{TcpListener, TcpStream}; @@ -15,6 +16,7 @@ use tokio_tungstenite::tungstenite::Message; const EVENT_CHANNEL_CAPACITY: usize = 64; static MERMAID_RENDER_COUNTER: AtomicUsize = AtomicUsize::new(0); +static ALIGN_GROUP_COUNTER: AtomicUsize = AtomicUsize::new(0); pub struct AgentServer { sessions: Arc>, @@ -50,7 +52,16 @@ impl AgentServer { pub async fn run(&self) -> Result<()> { let addr = format!("127.0.0.1:{}", self.port); - let listener = TcpListener::bind(&addr).await?; + let listener = TcpListener::bind(&addr).await.map_err(|e| { + if e.kind() == std::io::ErrorKind::AddrInUse { + anyhow::anyhow!( + "Port {} is already in use — another agcanvas instance may be running", + self.port + ) + } else { + anyhow::anyhow!("Failed to bind agent server to {}: {}", addr, e) + } + })?; tracing::info!("Agent server listening on ws://{}", addr); while let Ok((stream, peer)) = listener.accept().await { @@ -328,13 +339,17 @@ async fn process_single_request( AgentRequest::Describe { session_id } => { let store = sessions.read().await; - match store.get_tree(session_id.as_deref()) { - Some((sid, tree)) => AgentResponse::Description { + match store.get_drawing_elements(session_id.as_deref()) { + Some((sid, elements)) => AgentResponse::Description { session_id: sid, - text: tree.to_semantic_description(), + text: if elements.is_empty() { + "Canvas is empty.".to_string() + } else { + describe_elements(elements) + }, }, None => AgentResponse::Error { - message: "No session or SVG loaded".to_string(), + message: "No session found".to_string(), }, } } @@ -400,11 +415,21 @@ async fn process_single_request( y1, x2, y2, + control_offset_x, + control_offset_y, text, font_size, fill, stroke_color, stroke_width, + opacity, + rotation, + corner_radius, + font_family, + sides, + star_inner_ratio, + max_width, + group_id, } => { let shape = match build_shape( &shape_type, @@ -420,14 +445,28 @@ async fn process_single_request( y1, x2, y2, + control_offset_x, + control_offset_y, text, font_size, + sides, + star_inner_ratio, + max_width, ) { Ok(s) => s, Err(msg) => return AgentResponse::Error { message: msg }, }; - let style = build_style(fill, stroke_color, stroke_width); - let element = DrawingElement::new(shape, style); + let style = build_style( + fill, + stroke_color, + stroke_width, + opacity, + rotation, + corner_radius, + font_family, + ); + let mut element = DrawingElement::new(shape, style); + element.group_id = group_id; let mut store = sessions.write().await; let sid = match store.resolve_session_id(session_id.as_deref()) { @@ -471,16 +510,44 @@ async fn process_single_request( y1, x2, y2, + control_offset_x, + control_offset_y, text, font_size, fill, stroke_color, stroke_width, + opacity, + rotation, + corner_radius, + font_family, + sides, + star_inner_ratio, + max_width, + group_id, } => { let new_shape = if let Some(st) = shape_type { match build_shape( - &st, x, y, width, height, center_x, center_y, radius_x, radius_y, x1, y1, x2, - y2, text, font_size, + &st, + x, + y, + width, + height, + center_x, + center_y, + radius_x, + radius_y, + x1, + y1, + x2, + y2, + control_offset_x, + control_offset_y, + text, + font_size, + sides, + star_inner_ratio, + max_width, ) { Ok(s) => Some(s), Err(msg) => return AgentResponse::Error { message: msg }, @@ -489,13 +556,13 @@ async fn process_single_request( None }; - let has_style_change = - fill.is_some() || stroke_color.is_some() || stroke_width.is_some(); - let new_style = if has_style_change { - Some(build_style(fill, stroke_color, stroke_width)) - } else { - None - }; + let has_style_change = fill.is_some() + || stroke_color.is_some() + || stroke_width.is_some() + || opacity.is_some() + || rotation.is_some() + || corner_radius.is_some() + || font_family.is_some(); let mut store = sessions.write().await; let sid = match store.resolve_session_id(session_id.as_deref()) { @@ -507,8 +574,66 @@ async fn process_single_request( } }; + let existing_element = match store + .get_drawing_elements(Some(&sid)) + .and_then(|(_, elements)| elements.iter().find(|element| element.id == id)) + .cloned() + { + Some(element) => element, + None => { + return AgentResponse::Error { + message: format!("Element '{}' not found in session", id), + } + } + }; + + let new_style = if has_style_change { + let mut style = existing_element.style.clone(); + if let Some(value) = fill { + style.fill = parse_hex_color(&value).map(crate::drawing::Fill::solid); + } + if let Some(value) = stroke_color { + style.stroke_color = parse_hex_color(&value).unwrap_or(style.stroke_color); + } + if let Some(value) = stroke_width { + style.stroke_width = value; + } + if let Some(value) = opacity { + style.opacity = value; + } + if let Some(value) = rotation { + style.rotation_degrees = value; + } + if let Some(value) = corner_radius { + style.corner_radius = value; + } + if let Some(value) = font_family { + style.font_family = Some(value); + } + Some(style) + } else { + None + }; + match store.update_drawing_element(&sid, &id, new_shape, new_style) { - Some(updated) => { + Some(mut updated) => { + if let Some(new_group_id) = group_id { + let mut elements = match store.get_drawing_elements(Some(&sid)) { + Some((_, elements)) => elements.to_vec(), + None => { + return AgentResponse::Error { + message: "No session found".to_string(), + } + } + }; + if let Some(element) = elements.iter_mut().find(|element| element.id == id) + { + element.group_id = Some(new_group_id); + updated = element.clone(); + } + store.update_drawing_elements(&sid, elements); + } + let _ = command_tx.send(DrawingCommand::Update { session_id: sid.clone(), element: updated.clone(), @@ -528,6 +653,724 @@ async fn process_single_request( } } + AgentRequest::GroupElements { + session_id, + element_ids, + group_id, + } => { + if element_ids.is_empty() { + return AgentResponse::Error { + message: "GroupElements requires at least one element ID".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 mut elements = match store.get_drawing_elements(Some(&sid)) { + Some((_, elements)) => elements.to_vec(), + None => { + return AgentResponse::Error { + message: "No session found".to_string(), + } + } + }; + + let selected: HashSet<&str> = element_ids.iter().map(String::as_str).collect(); + let resolved_group_id = group_id.unwrap_or_else(|| { + format!( + "group_{}", + ALIGN_GROUP_COUNTER.fetch_add(1, Ordering::SeqCst) + 1 + ) + }); + let mut updated_elements = Vec::new(); + + for element in &mut elements { + if selected.contains(element.id.as_str()) { + element.group_id = Some(resolved_group_id.clone()); + updated_elements.push(element.clone()); + } + } + + if updated_elements.is_empty() { + return AgentResponse::Error { + message: "None of the requested elements were found in session".to_string(), + }; + } + + store.update_drawing_elements(&sid, elements); + + let mut updated_ids = Vec::with_capacity(updated_elements.len()); + for element in updated_elements { + updated_ids.push(element.id.clone()); + let _ = command_tx.send(DrawingCommand::Update { + session_id: sid.clone(), + element: element.clone(), + }); + let _ = event_tx.send(GuiEvent::DrawingElementUpdated { + session_id: sid.clone(), + element, + }); + } + + AgentResponse::ElementsGrouped { + session_id: sid, + group_id: resolved_group_id, + element_ids: updated_ids, + } + } + + AgentRequest::UngroupElements { + session_id, + element_ids, + } => { + if element_ids.is_empty() { + return AgentResponse::Error { + message: "UngroupElements requires at least one element ID".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 mut elements = match store.get_drawing_elements(Some(&sid)) { + Some((_, elements)) => elements.to_vec(), + None => { + return AgentResponse::Error { + message: "No session found".to_string(), + } + } + }; + + let selected: HashSet<&str> = element_ids.iter().map(String::as_str).collect(); + let mut updated_elements = Vec::new(); + + for element in &mut elements { + if selected.contains(element.id.as_str()) { + element.group_id = None; + updated_elements.push(element.clone()); + } + } + + if updated_elements.is_empty() { + return AgentResponse::Error { + message: "None of the requested elements were found in session".to_string(), + }; + } + + store.update_drawing_elements(&sid, elements); + + let mut updated_ids = Vec::with_capacity(updated_elements.len()); + for element in updated_elements { + updated_ids.push(element.id.clone()); + let _ = command_tx.send(DrawingCommand::Update { + session_id: sid.clone(), + element: element.clone(), + }); + let _ = event_tx.send(GuiEvent::DrawingElementUpdated { + session_id: sid.clone(), + element, + }); + } + + AgentResponse::ElementsUngrouped { + session_id: sid, + element_ids: updated_ids, + } + } + + AgentRequest::AlignElements { + session_id, + element_ids, + operation, + } => { + if element_ids.len() < 2 { + return AgentResponse::Error { + message: "AlignElements requires at least two 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 mut elements = match store.get_drawing_elements(Some(&sid)) { + Some((_, elements)) => elements.to_vec(), + None => { + return AgentResponse::Error { + message: "No session found".to_string(), + } + } + }; + + let selected: HashSet<&str> = element_ids.iter().map(String::as_str).collect(); + let selected_count = elements + .iter() + .filter(|element| selected.contains(element.id.as_str())) + .count(); + if selected_count < 2 { + return AgentResponse::Error { + message: "At least two provided element IDs must exist in session".to_string(), + }; + } + + let align_result = align_elements_in_store(&mut elements, &element_ids, &operation); + let changed_ids = match align_result { + Ok(changed_ids) => changed_ids, + Err(message) => return AgentResponse::Error { message }, + }; + + store.update_drawing_elements(&sid, elements.clone()); + + for changed_id in &changed_ids { + if let Some(element) = elements + .iter() + .find(|element| element.id == *changed_id) + .cloned() + { + let _ = command_tx.send(DrawingCommand::Update { + session_id: sid.clone(), + element: element.clone(), + }); + let _ = event_tx.send(GuiEvent::DrawingElementUpdated { + session_id: sid.clone(), + element, + }); + } + } + + AgentResponse::ElementsAligned { + session_id: sid, + operation, + element_ids, + } + } + + AgentRequest::ReorderElement { + session_id, + element_ids, + operation, + } => { + if element_ids.is_empty() { + return AgentResponse::Error { + message: "ReorderElement requires at least one element ID".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 mut elements = match store.get_drawing_elements(Some(&sid)) { + Some((_, elements)) => elements.to_vec(), + None => { + return AgentResponse::Error { + message: "No session found".to_string(), + } + } + }; + + let selected: HashSet = element_ids.iter().cloned().collect(); + let len = elements.len(); + + match operation.as_str() { + "bring_forward" => { + for idx in (0..len.saturating_sub(1)).rev() { + if selected.contains(&elements[idx].id) + && !selected.contains(&elements[idx + 1].id) + { + elements.swap(idx, idx + 1); + } + } + } + "send_backward" => { + for idx in 1..len { + if selected.contains(&elements[idx].id) + && !selected.contains(&elements[idx - 1].id) + { + elements.swap(idx, idx - 1); + } + } + } + "bring_to_front" => { + let mut sel = Vec::new(); + let mut rest = Vec::new(); + for el in elements.drain(..) { + if selected.contains(&el.id) { + sel.push(el); + } else { + rest.push(el); + } + } + rest.extend(sel); + elements = rest; + } + "send_to_back" => { + let mut sel = Vec::new(); + let mut rest = Vec::new(); + for el in elements.drain(..) { + if selected.contains(&el.id) { + sel.push(el); + } else { + rest.push(el); + } + } + sel.extend(rest); + elements = sel; + } + _ => { + return AgentResponse::Error { + message: format!( + "Invalid reorder operation '{}'. Expected: bring_forward, send_backward, bring_to_front, send_to_back", + operation + ), + }; + } + } + + store.update_drawing_elements(&sid, elements); + + AgentResponse::ElementsReordered { + session_id: sid, + operation, + element_ids, + } + } + + AgentRequest::DuplicateElements { + session_id, + element_ids, + offset_x, + offset_y, + } => { + if element_ids.is_empty() { + return AgentResponse::Error { + message: "DuplicateElements requires at least one element ID".to_string(), + }; + } + + let dx = offset_x.unwrap_or(20.0); + let dy = offset_y.unwrap_or(20.0); + + 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.to_vec(), + None => { + return AgentResponse::Error { + message: "No session found".to_string(), + } + } + }; + + let requested: HashSet<&str> = element_ids.iter().map(String::as_str).collect(); + let mut new_elements = Vec::new(); + + for element in &source_elements { + if requested.contains(element.id.as_str()) { + let mut dup = element.clone(); + dup.id = crate::drawing::generate_drawing_id(); + dup.translate(egui::vec2(dx, dy)); + new_elements.push(dup); + } + } + + if new_elements.is_empty() { + return AgentResponse::Error { + message: "None of the requested element IDs were found".to_string(), + }; + } + + for el in &new_elements { + store.add_drawing_element(&sid, el.clone()); + let _ = command_tx.send(DrawingCommand::Create { + session_id: sid.clone(), + element: el.clone(), + }); + let _ = event_tx.send(GuiEvent::DrawingElementCreated { + session_id: sid.clone(), + element: el.clone(), + }); + } + + AgentResponse::ElementsDuplicated { + session_id: sid, + original_ids: element_ids, + new_elements, + } + } + + AgentRequest::ConvertToPath { + session_id, + element_ids, + } => { + if element_ids.is_empty() { + return AgentResponse::Error { + message: "ConvertToPath requires at least one element ID".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 mut elements = match store.get_drawing_elements(Some(&sid)) { + Some((_, elements)) => elements.to_vec(), + None => { + return AgentResponse::Error { + message: "No session found".to_string(), + } + } + }; + + let requested: HashSet<&str> = element_ids.iter().map(String::as_str).collect(); + let mut converted_ids = Vec::new(); + + for element in &mut elements { + if requested.contains(element.id.as_str()) { + if let Some(path_shape) = element.to_path() { + element.shape = path_shape; + converted_ids.push(element.id.clone()); + let _ = command_tx.send(DrawingCommand::Update { + session_id: sid.clone(), + element: element.clone(), + }); + let _ = event_tx.send(GuiEvent::DrawingElementUpdated { + session_id: sid.clone(), + element: element.clone(), + }); + } + } + } + + store.update_drawing_elements(&sid, elements); + + AgentResponse::ElementsConverted { + session_id: sid, + element_ids: converted_ids, + } + } + + AgentRequest::MoveVertex { + session_id, + element_id, + polygon_idx, + vertex_idx, + is_hole, + x, + y, + } => { + 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 mut elements = match store.get_drawing_elements(Some(&sid)) { + Some((_, elements)) => elements.to_vec(), + None => { + return AgentResponse::Error { + message: "No session found".to_string(), + } + } + }; + + let element = match elements.iter_mut().find(|el| el.id == element_id) { + Some(el) => el, + None => { + return AgentResponse::Error { + message: format!("Element '{}' not found", element_id), + } + } + }; + + if let Shape::Path { polygons } = &mut element.shape { + if let Some(polygon) = polygons.get_mut(polygon_idx) { + let ring = if is_hole { + polygon.holes.get_mut(0) + } else { + Some(&mut polygon.exterior) + }; + if let Some(ring) = ring { + if let Some(vertex) = ring.get_mut(vertex_idx) { + *vertex = egui::Pos2::new(x, y); + } else { + return AgentResponse::Error { + message: format!("Vertex index {} out of range", vertex_idx), + }; + } + } else { + return AgentResponse::Error { + message: "Hole ring not found".to_string(), + }; + } + } else { + return AgentResponse::Error { + message: format!("Polygon index {} out of range", polygon_idx), + }; + } + } else { + return AgentResponse::Error { + message: "Element is not a Path shape. Use ConvertToPath first.".to_string(), + }; + } + + let updated = element.clone(); + store.update_drawing_elements(&sid, elements); + let _ = command_tx.send(DrawingCommand::Update { + session_id: sid.clone(), + element: updated.clone(), + }); + let _ = event_tx.send(GuiEvent::DrawingElementUpdated { + session_id: sid.clone(), + element: updated.clone(), + }); + + AgentResponse::VertexMoved { + session_id: sid, + element: updated, + } + } + + AgentRequest::AddVertex { + session_id, + element_id, + polygon_idx, + after_vertex_idx, + is_hole, + x, + y, + } => { + 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 mut elements = match store.get_drawing_elements(Some(&sid)) { + Some((_, elements)) => elements.to_vec(), + None => { + return AgentResponse::Error { + message: "No session found".to_string(), + } + } + }; + + let element = match elements.iter_mut().find(|el| el.id == element_id) { + Some(el) => el, + None => { + return AgentResponse::Error { + message: format!("Element '{}' not found", element_id), + } + } + }; + + if let Shape::Path { polygons } = &mut element.shape { + if let Some(polygon) = polygons.get_mut(polygon_idx) { + let ring = if is_hole { + polygon.holes.get_mut(0) + } else { + Some(&mut polygon.exterior) + }; + if let Some(ring) = ring { + let insert_idx = (after_vertex_idx + 1).min(ring.len()); + ring.insert(insert_idx, egui::Pos2::new(x, y)); + } else { + return AgentResponse::Error { + message: "Hole ring not found".to_string(), + }; + } + } else { + return AgentResponse::Error { + message: format!("Polygon index {} out of range", polygon_idx), + }; + } + } else { + return AgentResponse::Error { + message: "Element is not a Path shape. Use ConvertToPath first.".to_string(), + }; + } + + let updated = element.clone(); + store.update_drawing_elements(&sid, elements); + let _ = command_tx.send(DrawingCommand::Update { + session_id: sid.clone(), + element: updated.clone(), + }); + let _ = event_tx.send(GuiEvent::DrawingElementUpdated { + session_id: sid.clone(), + element: updated.clone(), + }); + + AgentResponse::VertexAdded { + session_id: sid, + element: updated, + } + } + + AgentRequest::DeleteVertex { + session_id, + element_id, + polygon_idx, + vertex_idx, + is_hole, + } => { + 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 mut elements = match store.get_drawing_elements(Some(&sid)) { + Some((_, elements)) => elements.to_vec(), + None => { + return AgentResponse::Error { + message: "No session found".to_string(), + } + } + }; + + let element = match elements.iter_mut().find(|el| el.id == element_id) { + Some(el) => el, + None => { + return AgentResponse::Error { + message: format!("Element '{}' not found", element_id), + } + } + }; + + if let Shape::Path { polygons } = &mut element.shape { + if let Some(polygon) = polygons.get_mut(polygon_idx) { + let ring = if is_hole { + polygon.holes.get_mut(0) + } else { + Some(&mut polygon.exterior) + }; + if let Some(ring) = ring { + if ring.len() <= 3 && !is_hole { + return AgentResponse::Error { + message: + "Cannot delete vertex: exterior ring needs at least 3 vertices" + .to_string(), + }; + } + if vertex_idx < ring.len() { + ring.remove(vertex_idx); + } else { + return AgentResponse::Error { + message: format!("Vertex index {} out of range", vertex_idx), + }; + } + } else { + return AgentResponse::Error { + message: "Hole ring not found".to_string(), + }; + } + } else { + return AgentResponse::Error { + message: format!("Polygon index {} out of range", polygon_idx), + }; + } + } else { + return AgentResponse::Error { + message: "Element is not a Path shape. Use ConvertToPath first.".to_string(), + }; + } + + let updated = element.clone(); + store.update_drawing_elements(&sid, elements); + let _ = command_tx.send(DrawingCommand::Update { + session_id: sid.clone(), + element: updated.clone(), + }); + let _ = event_tx.send(GuiEvent::DrawingElementUpdated { + session_id: sid.clone(), + element: updated.clone(), + }); + + AgentResponse::VertexDeleted { + session_id: sid, + element: updated, + } + } + + AgentRequest::ExportSvg { session_id, path } => { + let (sid, elements) = { + let store = sessions.read().await; + match store.get_drawing_elements(session_id.as_deref()) { + Some((sid, elements)) => (sid, elements.to_vec()), + None => { + return AgentResponse::Error { + message: "No session found".to_string(), + } + } + } + }; + + let svg_source = crate::svg::export_drawing_elements_to_svg(&elements); + match std::fs::write(&path, svg_source) { + Ok(()) => AgentResponse::SvgExported { + session_id: sid, + path, + }, + Err(e) => AgentResponse::Error { + message: format!("Failed to export SVG: {}", e), + }, + } + } + AgentRequest::DeleteDrawingElement { session_id, id } => { let mut store = sessions.write().await; let sid = match store.resolve_session_id(session_id.as_deref()) { @@ -643,7 +1486,7 @@ async fn process_single_request( let first_style = selected_elements[0].style.clone(); let result_style = ShapeStyle { fill: match fill { - Some(hex) => parse_hex_color(&hex), + Some(hex) => parse_hex_color(&hex).map(crate::drawing::Fill::solid), None => first_style.fill, }, stroke_color: match stroke_color { @@ -651,6 +1494,11 @@ async fn process_single_request( None => first_style.stroke_color, }, stroke_width: stroke_width.unwrap_or(first_style.stroke_width), + opacity: first_style.opacity, + rotation_degrees: first_style.rotation_degrees, + corner_radius: first_style.corner_radius, + font_family: first_style.font_family.clone(), + stroke_dash: first_style.stroke_dash, }; let element = DrawingElement::new(Shape::Path { polygons }, result_style); @@ -814,6 +1662,89 @@ async fn process_single_request( }, } } + AgentRequest::GetAppState { session_id } => { + let store = sessions.read().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(), + } + } + }; + + match store.get_app_state() { + Some(state) => AgentResponse::AppState { + session_id: sid, + active_tool: state.active_tool.clone(), + selected_element_ids: state.selected_element_ids.clone(), + zoom: state.zoom, + pan_offset_x: state.pan_offset_x, + pan_offset_y: state.pan_offset_y, + theme: state.theme.clone(), + show_tree_panel: state.show_tree_panel, + show_description_panel: state.show_description_panel, + show_history_panel: state.show_history_panel, + session_name: state.session_name.clone(), + element_count: state.element_count, + canvas_width: state.canvas_width, + canvas_height: state.canvas_height, + }, + None => AgentResponse::Error { + message: "App state not yet available (GUI has not synced)".to_string(), + }, + } + } + AgentRequest::CaptureScreenshot { path, .. } => { + if path.is_empty() { + return AgentResponse::Error { + message: "path must not be empty".to_string(), + }; + } + + if let Err(e) = + command_tx.send(DrawingCommand::CaptureScreenshot { path: path.clone() }) + { + return AgentResponse::Error { + message: format!("Failed to send screenshot command: {}", e), + }; + } + + let mut event_rx = event_tx.subscribe(); + let timeout = tokio::time::Duration::from_secs(10); + match tokio::time::timeout(timeout, async { + loop { + match event_rx.recv().await { + Ok(GuiEvent::ScreenshotCaptured { + path: captured_path, + width, + height, + }) if captured_path == path => { + return Ok(AgentResponse::ScreenshotCaptured { + path: captured_path, + width, + height, + }); + } + Err(broadcast::error::RecvError::Closed) => { + return Err("Event channel closed".to_string()); + } + Err(broadcast::error::RecvError::Lagged(n)) => { + tracing::warn!("Screenshot listener lagged {} events", n); + } + _ => {} + } + } + }) + .await + { + Ok(Ok(response)) => response, + Ok(Err(msg)) => AgentResponse::Error { message: msg }, + Err(_) => AgentResponse::Error { + message: "Screenshot timed out after 10 seconds".to_string(), + }, + } + } AgentRequest::Batch { .. } => AgentResponse::Error { message: "Nested batch requests are not supported".to_string(), }, @@ -824,6 +1755,183 @@ fn color32_to_skia(c: egui::Color32) -> tiny_skia::Color { tiny_skia::Color::from_rgba8(c.r(), c.g(), c.b(), c.a()) } +fn align_elements_in_store( + elements: &mut [DrawingElement], + selected_ids: &[String], + operation: &str, +) -> Result, String> { + let selected: HashSet<&str> = selected_ids.iter().map(String::as_str).collect(); + let mut selected_indices: Vec = elements + .iter() + .enumerate() + .filter_map(|(idx, element)| selected.contains(element.id.as_str()).then_some(idx)) + .collect(); + + if selected_indices.len() < 2 { + return Err("At least two provided element IDs must exist in session".to_string()); + } + + let mut changed_ids = HashSet::new(); + match operation { + "left" => { + let target = selected_indices + .iter() + .map(|idx| elements[*idx].bounding_rect().min.x) + .fold(f32::INFINITY, f32::min); + for idx in &selected_indices { + let left = elements[*idx].bounding_rect().min.x; + let dx = target - left; + if dx.abs() > f32::EPSILON { + elements[*idx].translate(egui::vec2(dx, 0.0)); + changed_ids.insert(elements[*idx].id.clone()); + } + } + } + "right" => { + let target = selected_indices + .iter() + .map(|idx| elements[*idx].bounding_rect().max.x) + .fold(f32::NEG_INFINITY, f32::max); + for idx in &selected_indices { + let right = elements[*idx].bounding_rect().max.x; + let dx = target - right; + if dx.abs() > f32::EPSILON { + elements[*idx].translate(egui::vec2(dx, 0.0)); + changed_ids.insert(elements[*idx].id.clone()); + } + } + } + "top" => { + let target = selected_indices + .iter() + .map(|idx| elements[*idx].bounding_rect().min.y) + .fold(f32::INFINITY, f32::min); + for idx in &selected_indices { + let top = elements[*idx].bounding_rect().min.y; + let dy = target - top; + if dy.abs() > f32::EPSILON { + elements[*idx].translate(egui::vec2(0.0, dy)); + changed_ids.insert(elements[*idx].id.clone()); + } + } + } + "bottom" => { + let target = selected_indices + .iter() + .map(|idx| elements[*idx].bounding_rect().max.y) + .fold(f32::NEG_INFINITY, f32::max); + for idx in &selected_indices { + let bottom = elements[*idx].bounding_rect().max.y; + let dy = target - bottom; + if dy.abs() > f32::EPSILON { + elements[*idx].translate(egui::vec2(0.0, dy)); + changed_ids.insert(elements[*idx].id.clone()); + } + } + } + "center_h" => { + let avg = selected_indices + .iter() + .map(|idx| elements[*idx].bounding_rect().center().x) + .sum::() + / selected_indices.len() as f32; + for idx in &selected_indices { + let center = elements[*idx].bounding_rect().center().x; + let dx = avg - center; + if dx.abs() > f32::EPSILON { + elements[*idx].translate(egui::vec2(dx, 0.0)); + changed_ids.insert(elements[*idx].id.clone()); + } + } + } + "center_v" => { + let avg = selected_indices + .iter() + .map(|idx| elements[*idx].bounding_rect().center().y) + .sum::() + / selected_indices.len() as f32; + for idx in &selected_indices { + let center = elements[*idx].bounding_rect().center().y; + let dy = avg - center; + if dy.abs() > f32::EPSILON { + elements[*idx].translate(egui::vec2(0.0, dy)); + changed_ids.insert(elements[*idx].id.clone()); + } + } + } + "distribute_h" => { + selected_indices.sort_by(|a, b| { + let a_left = elements[*a].bounding_rect().min.x; + let b_left = elements[*b].bounding_rect().min.x; + a_left.total_cmp(&b_left) + }); + + let n = selected_indices.len(); + if n >= 3 { + let first_rect = elements[selected_indices[0]].bounding_rect(); + let last_rect = elements[selected_indices[n - 1]].bounding_rect(); + let total_width: f32 = selected_indices + .iter() + .map(|idx| elements[*idx].bounding_rect().width()) + .sum(); + let span = last_rect.max.x - first_rect.min.x; + let gap = (span - total_width) / (n - 1) as f32; + + let mut cursor = first_rect.min.x; + for idx in selected_indices { + let rect = elements[idx].bounding_rect(); + let dx = cursor - rect.min.x; + if dx.abs() > f32::EPSILON { + elements[idx].translate(egui::vec2(dx, 0.0)); + changed_ids.insert(elements[idx].id.clone()); + } + cursor += rect.width() + gap; + } + } + } + "distribute_v" => { + selected_indices.sort_by(|a, b| { + let a_top = elements[*a].bounding_rect().min.y; + let b_top = elements[*b].bounding_rect().min.y; + a_top.total_cmp(&b_top) + }); + + let n = selected_indices.len(); + if n >= 3 { + let first_rect = elements[selected_indices[0]].bounding_rect(); + let last_rect = elements[selected_indices[n - 1]].bounding_rect(); + let total_height: f32 = selected_indices + .iter() + .map(|idx| elements[*idx].bounding_rect().height()) + .sum(); + let span = last_rect.max.y - first_rect.min.y; + let gap = (span - total_height) / (n - 1) as f32; + + let mut cursor = first_rect.min.y; + for idx in selected_indices { + let rect = elements[idx].bounding_rect(); + let dy = cursor - rect.min.y; + if dy.abs() > f32::EPSILON { + elements[idx].translate(egui::vec2(0.0, dy)); + changed_ids.insert(elements[idx].id.clone()); + } + cursor += rect.height() + gap; + } + } + } + _ => { + return Err( + "Invalid align operation. Expected one of: left, right, top, bottom, center_h, center_v, distribute_h, distribute_v" + .to_string(), + ) + } + } + + let mut changed = changed_ids.into_iter().collect::>(); + changed.sort(); + Ok(changed) +} + fn generate_code_stub(element: &crate::element_tree::Element, target: CodeGenTarget) -> String { let description = crate::element_tree::ElementTree { root: element.clone(), diff --git a/crates/agcanvas/src/app.rs b/crates/agcanvas/src/app.rs index b72cf56..fa0d5a4 100644 --- a/crates/agcanvas/src/app.rs +++ b/crates/agcanvas/src/app.rs @@ -3,16 +3,20 @@ use crate::canvas::{CanvasInteraction, CanvasState}; use crate::clipboard::ClipboardManager; use crate::command_palette::{CommandId, CommandPalette}; use crate::drawing::{ - draw_creation_preview, draw_elements, draw_marquee, draw_selection, find_handle_at_screen_pos, - screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle, Tool, + arrow_midpoint, boolean, describe_elements, draw_arrow_control_handle, draw_creation_preview, + draw_elements, draw_marquee, draw_selection, draw_vertex_handles, element_label, + find_arrow_control_handle_at_screen_pos, find_handle_at_screen_pos, generate_drawing_id, + screen_to_canvas, BooleanOpType, DragState, DrawingElement, Shape, ShapeStyle, Tool, }; use crate::export::{self, ExportData}; 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}; -use crate::svg::{parse_svg, SvgRenderer}; -use egui::{Color32, ColorImage, TextureOptions}; +use crate::svg::{export_drawing_elements_to_svg, parse_svg, SvgRenderer}; +use crate::theme::CanvasTheme; +use egui::Color32; +use std::collections::HashSet; use std::path::Path; use std::sync::Arc; use tokio::runtime::Runtime; @@ -20,17 +24,27 @@ use tokio::sync::{broadcast, mpsc, RwLock}; const AGENT_PORT: u16 = 9876; const MIN_SHAPE_SIZE: f32 = 5.0; +const DIRECT_SELECT_TOLERANCE: f32 = 8.0; -type SessionExportSnapshot = ( - String, - Vec, - Option, -); +#[derive(Debug, Clone, Copy)] +pub enum AlignOp { + Left, + Right, + Top, + Bottom, + CenterH, + CenterV, + DistributeH, + DistributeV, +} + +type SessionExportSnapshot = (String, Vec, Option); pub struct AgCanvasApp { sessions: Vec, active_session_idx: usize, session_counter: usize, + group_counter: usize, sessions_handle: Arc>, event_tx: broadcast::Sender, command_rx: mpsc::UnboundedReceiver, @@ -43,13 +57,17 @@ pub struct AgCanvasApp { _runtime: Runtime, show_mermaid_dialog: bool, + show_custom_theme_dialog: bool, mermaid_input: String, show_text_input: bool, text_input_buffer: String, text_input_pos: Option, + renaming_tab: Option<(usize, String)>, last_drawing_sync: std::time::Instant, last_auto_save: std::time::Instant, command_palette: CommandPalette, + canvas_theme: CanvasTheme, + pending_screenshot_path: Option, } impl AgCanvasApp { @@ -75,6 +93,7 @@ impl AgCanvasApp { sessions: Vec::new(), active_session_idx: 0, session_counter: 0, + group_counter: 0, sessions_handle, event_tx, command_rx, @@ -86,13 +105,17 @@ impl AgCanvasApp { status_message: None, _runtime: runtime, show_mermaid_dialog: false, + show_custom_theme_dialog: false, mermaid_input: String::new(), show_text_input: false, text_input_buffer: String::new(), text_input_pos: None, + renaming_tab: None, last_drawing_sync: std::time::Instant::now(), last_auto_save: std::time::Instant::now(), command_palette: CommandPalette::new(), + canvas_theme: CanvasTheme::default(), + pending_screenshot_path: None, }; if !app.restore_workspace(&cc.egui_ctx) { @@ -184,51 +207,103 @@ impl AgCanvasApp { } fn handle_paste(&mut self, ctx: &egui::Context) { - let svg_data = self.clipboard.as_mut().and_then(|c| c.get_svg()); - - if let Some(svg_data) = svg_data { - self.load_svg_data(&svg_data, ctx); + if let Some(data) = self.clipboard.as_mut().and_then(|c| c.get_svg()) { + tracing::info!("Paste: got SVG ({} bytes), loading as image", data.len()); + self.load_svg_as_image(&data, ctx); + return; } + + if let Some((pixels, width, height)) = self.clipboard.as_mut().and_then(|c| c.get_image()) { + tracing::info!("Paste: got raster image ({}x{}), loading", width, height); + self.load_raster_image(pixels, width, height, ctx); + return; + } + + tracing::debug!("Paste: clipboard has no SVG or image content"); + self.set_status("No image or SVG found in clipboard".to_string()); } - fn load_svg_data(&mut self, svg_data: &str, ctx: &egui::Context) { + fn load_svg_as_image(&mut self, svg_data: &str, ctx: &egui::Context) { match parse_svg(svg_data) { Ok((tree, usvg_tree)) => { - let (width, height) = (tree.metadata.width, tree.metadata.height); - let session = self.active_session_mut(); - session.element_tree = Some(tree.clone()); - session.description_text = tree.to_semantic_description(); - 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); + let mut renderer = SvgRenderer::new(usvg_tree); + match renderer.render(1.0) { + Ok(pixmap) => { + let size = [pixmap.width() as usize, pixmap.height() as usize]; + let pixels: Vec = pixmap + .pixels() + .iter() + .map(|p| { + Color32::from_rgba_premultiplied( + p.red(), + p.green(), + p.blue(), + p.alpha(), + ) + }) + .collect(); + let texture = ctx.load_texture( + "svg_paste", + egui::ColorImage { size, pixels }, + egui::TextureOptions::LINEAR, + ); - let session_id = session.id.clone(); - let metadata = tree.metadata.clone(); - let sessions_handle = self.sessions_handle.clone(); - let event_tx = self.event_tx.clone(); - let tree_clone = tree.clone(); - let svg_source = svg_data.to_string(); - let sid = session_id.clone(); - std::thread::spawn(move || { - let rt = Runtime::new().unwrap(); - rt.block_on(async { - let mut store = sessions_handle.write().await; - store.update_tree(&sid, Some(tree_clone), Some(svg_source)); - }); - let _ = event_tx.send(GuiEvent::SvgLoaded { - session_id, - metadata, - }); - }); + let (width, height) = (tree.metadata.width, tree.metadata.height); + let aspect_ratio = if height > 0.0 { width / height } else { 1.0 }; + let svg_element = DrawingElement::new( + Shape::SvgImage { + pos: egui::Pos2::ZERO, + size: egui::vec2(width, height), + aspect_ratio, + svg_source: svg_data.to_string(), + }, + ShapeStyle { + stroke_width: 0.0, + stroke_color: Color32::TRANSPARENT, + ..ShapeStyle::default() + }, + ); + let element_id = svg_element.id.clone(); - self.set_status(format!( - "Loaded SVG: {}x{} ({} elements)", - width as i32, height as i32, tree.metadata.element_count - )); + let session = self.active_session_mut(); + session.svg_textures.insert(element_id, texture); + session.element_tree = Some(tree.clone()); + session.svg_source = Some(svg_data.to_string()); + session.drawing_elements.push(svg_element); + + session + .canvas_state + .fit_to_rect(egui::vec2(width, height), ctx.screen_rect().size() * 0.8); + + session.record_edit("Paste SVG (image)", ChangeSource::Human); + + let session_id = session.id.clone(); + let tree_clone = Some(tree.clone()); + let metadata = Some(tree.metadata.clone()); + let sessions_handle = self.sessions_handle.clone(); + let event_tx = self.event_tx.clone(); + let svg_source = svg_data.to_string(); + let sid = session_id.clone(); + std::thread::spawn(move || { + let rt = Runtime::new().unwrap(); + rt.block_on(async { + let mut store = sessions_handle.write().await; + store.update_tree(&sid, tree_clone, Some(svg_source)); + }); + if let Some(metadata) = metadata { + let _ = event_tx.send(GuiEvent::SvgLoaded { + session_id, + metadata, + }); + } + }); + + self.set_status("Pasted SVG as image".to_string()); + } + Err(e) => { + self.set_status(format!("Failed to render SVG: {}", e)); + } + } } Err(e) => { self.set_status(format!("Failed to parse SVG: {}", e)); @@ -236,16 +311,74 @@ impl AgCanvasApp { } } + fn load_raster_image( + &mut self, + pixels_rgba: Vec, + width: usize, + height: usize, + ctx: &egui::Context, + ) { + if width == 0 || height == 0 { + self.set_status("Failed to paste image: invalid dimensions".to_string()); + return; + } + + let expected_len = width.saturating_mul(height).saturating_mul(4); + if pixels_rgba.len() != expected_len { + self.set_status("Failed to paste image: invalid pixel data".to_string()); + return; + } + + let pixels: Vec = pixels_rgba + .chunks_exact(4) + .map(|c| Color32::from_rgba_premultiplied(c[0], c[1], c[2], c[3])) + .collect(); + let texture = ctx.load_texture( + "raster_paste", + egui::ColorImage { + size: [width, height], + pixels, + }, + egui::TextureOptions::LINEAR, + ); + + let w = width as f32; + let h = height as f32; + let aspect_ratio = if h > 0.0 { w / h } else { 1.0 }; + let svg_element = DrawingElement::new( + Shape::SvgImage { + pos: egui::Pos2::ZERO, + size: egui::vec2(w, h), + aspect_ratio, + svg_source: String::new(), + }, + ShapeStyle { + stroke_width: 0.0, + stroke_color: Color32::TRANSPARENT, + ..ShapeStyle::default() + }, + ); + let element_id = svg_element.id.clone(); + + let session = self.active_session_mut(); + session.svg_textures.insert(element_id, texture); + session.drawing_elements.push(svg_element); + session + .canvas_state + .fit_to_rect(egui::vec2(w, h), ctx.screen_rect().size() * 0.8); + session.record_edit("Paste image", ChangeSource::Human); + + self.sync_drawing_elements_to_store(); + self.set_status(format!("Pasted image ({}x{})", width, height)); + } + fn apply_history_checkout(&mut self, ctx: &egui::Context) { let _ = ctx; let idx = self.active_session_idx; if let Some(svg_data) = self.sessions[idx].svg_source.clone() { - if let Ok((tree, usvg_tree)) = parse_svg(&svg_data) { + 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; } } } @@ -269,35 +402,18 @@ 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 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 - .pixels() - .iter() - .map(|p| { - Color32::from_rgba_premultiplied(p.red(), p.green(), p.blue(), p.alpha()) - }) - .collect(); - - let image = ColorImage { size, pixels }; - session.svg_texture = Some(ctx.load_texture( - format!("svg-{}", session.id), - image, - TextureOptions::LINEAR, - )); - } - } - } - fn set_status(&mut self, message: String) { self.status_message = Some((message, std::time::Instant::now())); } + fn set_active_tool(&mut self, tool: Tool) { + let session = self.active_session_mut(); + session.active_tool = tool; + if tool != Tool::DirectSelect { + clear_selected_vertex(session); + } + } + fn handle_mermaid_render(&mut self, _ctx: &egui::Context) { let source = self.mermaid_input.trim().to_string(); if source.is_empty() { @@ -370,6 +486,14 @@ impl AgCanvasApp { session .selected_element_ids .retain(|selected_id| selected_id != &id); + if session + .selected_vertex + .as_ref() + .map(|(element_id, _, _, _)| element_id == &id) + .unwrap_or(false) + { + session.selected_vertex = None; + } session.record_edit( "Agent: Delete Element", ChangeSource::Agent { name: None }, @@ -380,10 +504,14 @@ impl AgCanvasApp { if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) { session.drawing_elements.clear(); session.selected_element_ids.clear(); + session.selected_vertex = None; session .record_edit("Agent: Clear Canvas", ChangeSource::Agent { name: None }); } } + DrawingCommand::CaptureScreenshot { path } => { + self.pending_screenshot_path = Some(path); + } } } } @@ -433,7 +561,13 @@ impl AgCanvasApp { let session_snapshots: Vec = self .sessions .iter() - .map(|s| (s.id.clone(), s.drawing_elements.clone(), s.svg_source.clone())) + .map(|s| { + ( + s.id.clone(), + s.drawing_elements.clone(), + s.svg_source.clone(), + ) + }) .collect(); std::thread::spawn(move || { @@ -454,7 +588,7 @@ impl AgCanvasApp { let export_data = ExportData { svg_source: session.svg_source.clone(), drawing_elements: session.drawing_elements.clone(), - background_color: tiny_skia::Color::from_rgba8(30, 30, 30, 255), + background_color: self.canvas_theme.export_background(), }; let mut path = std::env::temp_dir(); @@ -471,6 +605,478 @@ impl AgCanvasApp { } } + fn export_canvas_svg(&mut self) { + let svg_source = export_drawing_elements_to_svg(&self.active_session().drawing_elements); + + let mut path = std::env::temp_dir(); + path.push(format!("canvas_export_{}.svg", self.active_session().id)); + + match std::fs::write(&path, svg_source) { + Ok(()) => self.set_status(format!("Exported SVG: {}", path.to_string_lossy())), + Err(e) => self.set_status(format!("SVG export failed: {}", e)), + } + } + + fn generate_group_id(&mut self) -> String { + self.group_counter += 1; + format!("group_{}", self.group_counter) + } + + fn group_selection(&mut self) { + if self.active_session().selected_element_ids.len() < 2 { + return; + } + + let group_id = self.generate_group_id(); + let session = self.active_session_mut(); + let selected: HashSet = session.selected_element_ids.iter().cloned().collect(); + for element in &mut session.drawing_elements { + if selected.contains(&element.id) { + element.group_id = Some(group_id.clone()); + } + } + session.record_edit("Group Elements", ChangeSource::Human); + } + + fn ungroup_selection(&mut self) { + if self.active_session().selected_element_ids.is_empty() { + return; + } + + let session = self.active_session_mut(); + let selected: HashSet = session.selected_element_ids.iter().cloned().collect(); + let mut changed = false; + for element in &mut session.drawing_elements { + if selected.contains(&element.id) && element.group_id.is_some() { + element.group_id = None; + changed = true; + } + } + if changed { + session.record_edit("Ungroup Elements", ChangeSource::Human); + } + } + + fn align_elements(&mut self, op: AlignOp) { + let session = self.active_session_mut(); + if session.selected_element_ids.len() < 2 { + return; + } + + let selected: HashSet = session.selected_element_ids.iter().cloned().collect(); + let mut selected_indices: Vec = session + .drawing_elements + .iter() + .enumerate() + .filter_map(|(idx, element)| selected.contains(&element.id).then_some(idx)) + .collect(); + + if selected_indices.len() < 2 { + return; + } + + let mut changed = false; + match op { + AlignOp::Left => { + let target = selected_indices + .iter() + .map(|idx| session.drawing_elements[*idx].bounding_rect().min.x) + .fold(f32::INFINITY, f32::min); + for idx in &selected_indices { + let left = session.drawing_elements[*idx].bounding_rect().min.x; + let dx = target - left; + if dx.abs() > f32::EPSILON { + session.drawing_elements[*idx].translate(egui::vec2(dx, 0.0)); + changed = true; + } + } + } + AlignOp::Right => { + let target = selected_indices + .iter() + .map(|idx| session.drawing_elements[*idx].bounding_rect().max.x) + .fold(f32::NEG_INFINITY, f32::max); + for idx in &selected_indices { + let right = session.drawing_elements[*idx].bounding_rect().max.x; + let dx = target - right; + if dx.abs() > f32::EPSILON { + session.drawing_elements[*idx].translate(egui::vec2(dx, 0.0)); + changed = true; + } + } + } + AlignOp::Top => { + let target = selected_indices + .iter() + .map(|idx| session.drawing_elements[*idx].bounding_rect().min.y) + .fold(f32::INFINITY, f32::min); + for idx in &selected_indices { + let top = session.drawing_elements[*idx].bounding_rect().min.y; + let dy = target - top; + if dy.abs() > f32::EPSILON { + session.drawing_elements[*idx].translate(egui::vec2(0.0, dy)); + changed = true; + } + } + } + AlignOp::Bottom => { + let target = selected_indices + .iter() + .map(|idx| session.drawing_elements[*idx].bounding_rect().max.y) + .fold(f32::NEG_INFINITY, f32::max); + for idx in &selected_indices { + let bottom = session.drawing_elements[*idx].bounding_rect().max.y; + let dy = target - bottom; + if dy.abs() > f32::EPSILON { + session.drawing_elements[*idx].translate(egui::vec2(0.0, dy)); + changed = true; + } + } + } + AlignOp::CenterH => { + let avg = selected_indices + .iter() + .map(|idx| session.drawing_elements[*idx].bounding_rect().center().x) + .sum::() + / selected_indices.len() as f32; + for idx in &selected_indices { + let center = session.drawing_elements[*idx].bounding_rect().center().x; + let dx = avg - center; + if dx.abs() > f32::EPSILON { + session.drawing_elements[*idx].translate(egui::vec2(dx, 0.0)); + changed = true; + } + } + } + AlignOp::CenterV => { + let avg = selected_indices + .iter() + .map(|idx| session.drawing_elements[*idx].bounding_rect().center().y) + .sum::() + / selected_indices.len() as f32; + for idx in &selected_indices { + let center = session.drawing_elements[*idx].bounding_rect().center().y; + let dy = avg - center; + if dy.abs() > f32::EPSILON { + session.drawing_elements[*idx].translate(egui::vec2(0.0, dy)); + changed = true; + } + } + } + AlignOp::DistributeH => { + selected_indices.sort_by(|a, b| { + let a_left = session.drawing_elements[*a].bounding_rect().min.x; + let b_left = session.drawing_elements[*b].bounding_rect().min.x; + a_left.total_cmp(&b_left) + }); + + let n = selected_indices.len(); + if n >= 3 { + let first_rect = session.drawing_elements[selected_indices[0]].bounding_rect(); + let last_rect = + session.drawing_elements[selected_indices[n - 1]].bounding_rect(); + let total_width: f32 = selected_indices + .iter() + .map(|idx| session.drawing_elements[*idx].bounding_rect().width()) + .sum(); + let span = last_rect.max.x - first_rect.min.x; + let gap = (span - total_width) / (n - 1) as f32; + + let mut cursor = first_rect.min.x; + for idx in selected_indices { + let rect = session.drawing_elements[idx].bounding_rect(); + let dx = cursor - rect.min.x; + if dx.abs() > f32::EPSILON { + session.drawing_elements[idx].translate(egui::vec2(dx, 0.0)); + changed = true; + } + cursor += rect.width() + gap; + } + } + } + AlignOp::DistributeV => { + selected_indices.sort_by(|a, b| { + let a_top = session.drawing_elements[*a].bounding_rect().min.y; + let b_top = session.drawing_elements[*b].bounding_rect().min.y; + a_top.total_cmp(&b_top) + }); + + let n = selected_indices.len(); + if n >= 3 { + let first_rect = session.drawing_elements[selected_indices[0]].bounding_rect(); + let last_rect = + session.drawing_elements[selected_indices[n - 1]].bounding_rect(); + let total_height: f32 = selected_indices + .iter() + .map(|idx| session.drawing_elements[*idx].bounding_rect().height()) + .sum(); + let span = last_rect.max.y - first_rect.min.y; + let gap = (span - total_height) / (n - 1) as f32; + + let mut cursor = first_rect.min.y; + for idx in selected_indices { + let rect = session.drawing_elements[idx].bounding_rect(); + let dy = cursor - rect.min.y; + if dy.abs() > f32::EPSILON { + session.drawing_elements[idx].translate(egui::vec2(0.0, dy)); + changed = true; + } + cursor += rect.height() + gap; + } + } + } + } + + if changed { + session.record_edit("Align Elements", ChangeSource::Human); + } + } + + fn duplicate_selection(&mut self) { + let session = self.active_session_mut(); + if session.selected_element_ids.is_empty() { + return; + } + + let selected = session.selected_element_ids.clone(); + let mut duplicates = Vec::new(); + for id in selected { + if let Some(element) = session.drawing_elements.iter().find(|el| el.id == id) { + let mut duplicate = element.clone(); + duplicate.id = generate_drawing_id(); + duplicate.translate(egui::vec2(20.0, 20.0)); + duplicates.push(duplicate); + } + } + + if duplicates.is_empty() { + return; + } + + session.selected_element_ids = duplicates.iter().map(|el| el.id.clone()).collect(); + session.selected_vertex = None; + session.drawing_elements.extend(duplicates); + session.record_edit("Duplicate Element", ChangeSource::Human); + } + + fn bring_selection_forward(&mut self) { + let session = self.active_session_mut(); + if session.selected_element_ids.is_empty() || session.drawing_elements.len() < 2 { + return; + } + + let selected: HashSet = session.selected_element_ids.iter().cloned().collect(); + let mut moved = false; + for idx in (0..session.drawing_elements.len() - 1).rev() { + if selected.contains(&session.drawing_elements[idx].id) + && !selected.contains(&session.drawing_elements[idx + 1].id) + { + session.drawing_elements.swap(idx, idx + 1); + moved = true; + } + } + + if moved { + session.record_edit("Bring Forward", ChangeSource::Human); + } + } + + fn send_selection_backward(&mut self) { + let session = self.active_session_mut(); + if session.selected_element_ids.is_empty() || session.drawing_elements.len() < 2 { + return; + } + + let selected: HashSet = session.selected_element_ids.iter().cloned().collect(); + let mut moved = false; + for idx in 1..session.drawing_elements.len() { + if selected.contains(&session.drawing_elements[idx].id) + && !selected.contains(&session.drawing_elements[idx - 1].id) + { + session.drawing_elements.swap(idx, idx - 1); + moved = true; + } + } + + if moved { + session.record_edit("Send Backward", ChangeSource::Human); + } + } + + fn bring_selection_to_front(&mut self) { + let session = self.active_session_mut(); + if session.selected_element_ids.is_empty() || session.drawing_elements.len() < 2 { + return; + } + + let selected: HashSet = session.selected_element_ids.iter().cloned().collect(); + let mut selected_elements = Vec::new(); + let mut other_elements = Vec::new(); + + for element in session.drawing_elements.drain(..) { + if selected.contains(&element.id) { + selected_elements.push(element); + } else { + other_elements.push(element); + } + } + + if selected_elements.is_empty() || other_elements.is_empty() { + session.drawing_elements = if selected_elements.is_empty() { + other_elements + } else { + selected_elements + }; + return; + } + + other_elements.extend(selected_elements); + session.drawing_elements = other_elements; + session.record_edit("Bring to Front", ChangeSource::Human); + } + + fn send_selection_to_back(&mut self) { + let session = self.active_session_mut(); + if session.selected_element_ids.is_empty() || session.drawing_elements.len() < 2 { + return; + } + + let selected: HashSet = session.selected_element_ids.iter().cloned().collect(); + let mut selected_elements = Vec::new(); + let mut other_elements = Vec::new(); + + for element in session.drawing_elements.drain(..) { + if selected.contains(&element.id) { + selected_elements.push(element); + } else { + other_elements.push(element); + } + } + + if selected_elements.is_empty() || other_elements.is_empty() { + session.drawing_elements = if selected_elements.is_empty() { + other_elements + } else { + selected_elements + }; + return; + } + + selected_elements.extend(other_elements); + session.drawing_elements = selected_elements; + session.record_edit("Send to Back", ChangeSource::Human); + } + + fn perform_boolean_op(&mut self, op_type: BooleanOpType) { + let session = self.active_session_mut(); + if session.selected_element_ids.len() < 2 { + return; + } + + let selected_refs: Vec<&DrawingElement> = session + .drawing_elements + .iter() + .filter(|el| session.selected_element_ids.contains(&el.id)) + .collect(); + + if selected_refs.len() < 2 { + return; + } + + let first_style = selected_refs[0].style.clone(); + let polygons = match boolean::boolean_op(op_type, &selected_refs) { + Ok(p) if !p.is_empty() => p, + _ => return, + }; + + let ids_to_remove: HashSet = session.selected_element_ids.iter().cloned().collect(); + session + .drawing_elements + .retain(|el| !ids_to_remove.contains(&el.id)); + + let result = DrawingElement::new(Shape::Path { polygons }, first_style); + let new_id = result.id.clone(); + session.drawing_elements.push(result); + session.selected_element_ids = vec![new_id]; + session.selected_vertex = None; + session.record_edit("Boolean Op", ChangeSource::Human); + } + + fn convert_selection_to_path(&mut self) { + let session = self.active_session_mut(); + if session.selected_element_ids.is_empty() { + return; + } + + let selected: HashSet = session.selected_element_ids.iter().cloned().collect(); + let mut changed = false; + + for element in &mut session.drawing_elements { + if !selected.contains(&element.id) { + continue; + } + if let Some(path_shape) = element.to_path() { + element.shape = path_shape; + changed = true; + } + } + + if changed { + clear_selected_vertex(session); + session.record_edit("Convert to Path", ChangeSource::Human); + } + } + + fn delete_selected_vertex(&mut self) { + let session = self.active_session_mut(); + if session.active_tool != Tool::DirectSelect { + return; + } + + let Some((element_id, polygon_idx, vertex_idx, is_hole)) = session.selected_vertex.clone() + else { + return; + }; + + let Some(element) = session + .drawing_elements + .iter_mut() + .find(|element| element.id == element_id) + else { + clear_selected_vertex(session); + return; + }; + + let Shape::Path { polygons } = &mut element.shape else { + clear_selected_vertex(session); + return; + }; + + let Some(polygon) = polygons.get_mut(polygon_idx) else { + clear_selected_vertex(session); + return; + }; + + let ring: &mut Vec = if is_hole { + if let Some(hole) = polygon.holes.first_mut() { + hole + } else { + clear_selected_vertex(session); + return; + } + } else { + &mut polygon.exterior + }; + + if ring.len() <= 3 || vertex_idx >= ring.len() { + return; + } + + ring.remove(vertex_idx); + clear_selected_vertex(session); + session.record_edit("Delete Vertex", ChangeSource::Human); + } + fn build_saved_workspace(&self) -> SavedWorkspace { let sessions: Vec = self .sessions @@ -486,7 +1092,13 @@ impl AgCanvasApp { created_at: s.created_at, }) .collect(); - SavedWorkspace::new(self.active_session_idx, self.session_counter, sessions) + SavedWorkspace::new( + self.active_session_idx, + self.session_counter, + self.group_counter, + self.canvas_theme.clone(), + sessions, + ) } fn save_workspace(&mut self) { @@ -508,6 +1120,8 @@ impl AgCanvasApp { }; self.session_counter = workspace.session_counter; + self.group_counter = workspace.group_counter; + self.canvas_theme = workspace.theme.clone(); for saved in &workspace.sessions { let mut session = Session::new( @@ -520,10 +1134,20 @@ impl AgCanvasApp { session.description = saved.description.clone(); session.created_at = saved.created_at; + for element in &session.drawing_elements { + if let Some(group_id) = &element.group_id { + if let Some(num) = group_id.strip_prefix("group_") { + if let Ok(n) = num.parse::() { + if n > self.group_counter { + self.group_counter = n; + } + } + } + } + } + if let Some(svg_data) = &saved.svg_source { - if let Ok((tree, usvg_tree)) = parse_svg(svg_data) { - session.description_text = tree.to_semantic_description(); - session.svg_renderer = Some(SvgRenderer::new(usvg_tree)); + if let Ok((tree, _usvg_tree)) = parse_svg(svg_data) { session.element_tree = Some(tree); session.svg_source = Some(svg_data.clone()); } @@ -590,23 +1214,42 @@ impl AgCanvasApp { self.apply_history_checkout(ctx); } } + CommandId::Duplicate => self.duplicate_selection(), + CommandId::ConvertToPath => self.convert_selection_to_path(), + CommandId::Group => self.group_selection(), + CommandId::Ungroup => self.ungroup_selection(), + CommandId::BringForward => self.bring_selection_forward(), + CommandId::SendBackward => self.send_selection_backward(), + CommandId::BringToFront => self.bring_selection_to_front(), + CommandId::SendToBack => self.send_selection_to_back(), + CommandId::AlignLeft => self.align_elements(AlignOp::Left), + CommandId::AlignRight => self.align_elements(AlignOp::Right), + CommandId::AlignTop => self.align_elements(AlignOp::Top), + CommandId::AlignBottom => self.align_elements(AlignOp::Bottom), + CommandId::AlignCenterH => self.align_elements(AlignOp::CenterH), + CommandId::AlignCenterV => self.align_elements(AlignOp::CenterV), + CommandId::DistributeH => self.align_elements(AlignOp::DistributeH), + CommandId::DistributeV => self.align_elements(AlignOp::DistributeV), CommandId::SaveWorkspace => self.save_workspace(), CommandId::ClearCanvas => self.clear_canvas(), CommandId::PasteSvg => self.handle_paste(ctx), CommandId::PasteMermaid => self.show_mermaid_dialog = true, CommandId::ExportPng => self.export_canvas_png(), - CommandId::ToolSelect => self.active_session_mut().active_tool = Tool::Select, - CommandId::ToolPan => self.active_session_mut().active_tool = Tool::Pan, - CommandId::ToolRectangle => self.active_session_mut().active_tool = Tool::Rectangle, - CommandId::ToolEllipse => self.active_session_mut().active_tool = Tool::Ellipse, - CommandId::ToolLine => self.active_session_mut().active_tool = Tool::Line, - CommandId::ToolArrow => self.active_session_mut().active_tool = Tool::Arrow, - CommandId::ToolText => self.active_session_mut().active_tool = Tool::Text, + CommandId::ExportSvg => self.export_canvas_svg(), + CommandId::ToolSelect => self.set_active_tool(Tool::Select), + CommandId::ToolDirectSelect => self.set_active_tool(Tool::DirectSelect), + CommandId::ToolPan => self.set_active_tool(Tool::Pan), + CommandId::ToolRectangle => self.set_active_tool(Tool::Rectangle), + CommandId::ToolEllipse => self.set_active_tool(Tool::Ellipse), + CommandId::ToolLine => self.set_active_tool(Tool::Line), + CommandId::ToolArrow => self.set_active_tool(Tool::Arrow), + CommandId::ToolPolygon => self.set_active_tool(Tool::Polygon), + CommandId::ToolText => self.set_active_tool(Tool::Text), CommandId::ResetZoom => self.active_session_mut().canvas_state.reset(), CommandId::FitToView => { let session = self.active_session_mut(); - if let Some(renderer) = &session.svg_renderer { - let (w, h) = renderer.size(); + if let Some(tree) = &session.element_tree { + let (w, h) = (tree.metadata.width, tree.metadata.height); session .canvas_state .fit_to_rect(egui::vec2(w, h), ctx.screen_rect().size() * 0.8); @@ -619,6 +1262,7 @@ impl AgCanvasApp { } fn handle_drawing_input(&mut self, response: &egui::Response, canvas_center: egui::Pos2) { + let default_stroke_color = self.canvas_theme.default_stroke(); let session = self.active_session_mut(); let offset = session.canvas_state.offset; let zoom = session.canvas_state.zoom; @@ -628,13 +1272,31 @@ impl AgCanvasApp { Tool::Select => { handle_select_tool(session, response, canvas_center, offset, zoom, pointer_pos); } + Tool::DirectSelect => { + handle_direct_select_tool( + session, + response, + canvas_center, + offset, + zoom, + pointer_pos, + ); + } Tool::Pan => { if response.dragged_by(egui::PointerButton::Primary) { session.canvas_state.pan(response.drag_delta()); } } - Tool::Rectangle | Tool::Ellipse | Tool::Line | Tool::Arrow => { - handle_shape_tool(session, response, canvas_center, offset, zoom, pointer_pos); + Tool::Rectangle | Tool::Ellipse | Tool::Line | Tool::Arrow | Tool::Polygon => { + handle_shape_tool( + session, + response, + canvas_center, + offset, + zoom, + pointer_pos, + default_stroke_color, + ); } Tool::Text => { if response.clicked() { @@ -662,6 +1324,13 @@ fn handle_select_tool( if let Some(screen_pos) = pointer_pos { let shift_held = response.ctx.input(|i| i.modifiers.shift); + if let Some(element_id) = + find_selected_arrow_control_handle(session, screen_pos, canvas_center, offset, zoom) + { + session.drag_state = DragState::ArrowControlDrag { element_id }; + return; + } + if let Some((handle, selected_el)) = session .drawing_elements .iter() @@ -690,17 +1359,29 @@ fn handle_select_tool( .find(|e| e.contains_point(canvas_pos)); if let Some(el) = hit { - let eid = el.id.clone(); + let hit_selection = selection_ids_for_hit(session, el); if shift_held { - if session.selected_element_ids.contains(&eid) { - session.selected_element_ids.retain(|id| id != &eid); + let should_remove = hit_selection + .iter() + .all(|id| session.selected_element_ids.contains(id)); + if should_remove { + session + .selected_element_ids + .retain(|id| !hit_selection.iter().any(|hit_id| hit_id == id)); + clear_selected_vertex(session); } else { - session.selected_element_ids.push(eid); + for id in hit_selection { + if !session.selected_element_ids.contains(&id) { + session.selected_element_ids.push(id); + } + } + clear_selected_vertex(session); } session.drag_state = DragState::None; } else { - if !session.selected_element_ids.contains(&eid) { - session.selected_element_ids = vec![eid.clone()]; + if session.selected_element_ids != hit_selection { + session.selected_element_ids = hit_selection; + clear_selected_vertex(session); } session.drag_state = DragState::Moving { element_ids: session.selected_element_ids.clone(), @@ -709,6 +1390,7 @@ fn handle_select_tool( } else { if !shift_held { session.selected_element_ids.clear(); + clear_selected_vertex(session); } session.drag_state = DragState::MarqueeSelecting { start: canvas_pos, @@ -755,6 +1437,27 @@ fn handle_select_tool( } } } + DragState::ArrowControlDrag { element_id } => { + let eid = element_id.clone(); + if let Some(screen_pos) = pointer_pos { + let canvas_pos = screen_to_canvas(screen_pos, canvas_center, offset, zoom); + if let Some(element) = session + .drawing_elements + .iter_mut() + .find(|element| element.id == eid) + { + if let Shape::Arrow { + start, + end, + control_offset, + } = &mut element.shape + { + let midpoint = arrow_midpoint(*start, *end); + *control_offset = Some(canvas_pos - midpoint); + } + } + } + } DragState::None => { session.canvas_state.pan(delta_screen); } @@ -768,6 +1471,9 @@ fn handle_select_tool( DragState::Resizing { .. } => { session.record_edit("Resize Element", ChangeSource::Human) } + DragState::ArrowControlDrag { .. } => { + session.record_edit("Curve Arrow", ChangeSource::Human) + } DragState::MarqueeSelecting { start, current } => { let marquee = egui::Rect::from_two_pos(*start, *current); let marquee_hits: Vec = session @@ -783,8 +1489,10 @@ fn handle_select_tool( session.selected_element_ids.push(id); } } + clear_selected_vertex(session); } else { session.selected_element_ids = marquee_hits; + clear_selected_vertex(session); } } _ => {} @@ -792,6 +1500,19 @@ fn handle_select_tool( session.drag_state = DragState::None; } + if response.double_clicked_by(egui::PointerButton::Primary) { + if let Some(screen_pos) = pointer_pos { + if let Some(element_id) = + find_selected_arrow_control_handle(session, screen_pos, canvas_center, offset, zoom) + { + if reset_arrow_control_offset(session, &element_id) { + session.record_edit("Straighten Arrow", ChangeSource::Human); + } + return; + } + } + } + if response.clicked_by(egui::PointerButton::Primary) && !response.dragged() { if let Some(screen_pos) = pointer_pos { let shift_held = response.ctx.input(|i| i.modifiers.shift); @@ -804,21 +1525,543 @@ fn handle_select_tool( if shift_held { if let Some(el) = hit { - if session.selected_element_ids.contains(&el.id) { - session.selected_element_ids.retain(|id| id != &el.id); + let hit_selection = selection_ids_for_hit(session, el); + let should_remove = hit_selection + .iter() + .all(|id| session.selected_element_ids.contains(id)); + if should_remove { + session + .selected_element_ids + .retain(|id| !hit_selection.iter().any(|hit_id| hit_id == id)); + clear_selected_vertex(session); } else { - session.selected_element_ids.push(el.id.clone()); + for id in hit_selection { + if !session.selected_element_ids.contains(&id) { + session.selected_element_ids.push(id); + } + } + clear_selected_vertex(session); } } } else if let Some(el) = hit { - session.selected_element_ids = vec![el.id.clone()]; + session.selected_element_ids = selection_ids_for_hit(session, el); + clear_selected_vertex(session); } else { session.selected_element_ids.clear(); + clear_selected_vertex(session); } } } } +fn handle_direct_select_tool( + session: &mut Session, + response: &egui::Response, + canvas_center: egui::Pos2, + offset: egui::Vec2, + zoom: f32, + pointer_pos: Option, +) { + if response.drag_started_by(egui::PointerButton::Primary) { + if let Some(screen_pos) = pointer_pos { + if let Some(element_id) = + find_selected_arrow_control_handle(session, screen_pos, canvas_center, offset, zoom) + { + session.drag_state = DragState::ArrowControlDrag { element_id }; + return; + } + + if let Some((element_id, polygon_idx, vertex_idx, is_hole)) = + find_nearest_selected_path_vertex( + session, + screen_pos, + canvas_center, + offset, + zoom, + DIRECT_SELECT_TOLERANCE, + ) + { + session.selected_vertex = + Some((element_id.clone(), polygon_idx, vertex_idx, is_hole)); + session.drag_state = DragState::VertexDrag { + element_id, + polygon_idx, + vertex_idx, + is_hole, + }; + } + } + } + + if response.dragged_by(egui::PointerButton::Primary) { + match session.drag_state.clone() { + DragState::VertexDrag { + element_id, + polygon_idx, + vertex_idx, + is_hole, + } => { + if let Some(screen_pos) = pointer_pos { + let canvas_pos = screen_to_canvas(screen_pos, canvas_center, offset, zoom); + let _ = update_path_vertex_position( + session, + &element_id, + polygon_idx, + vertex_idx, + is_hole, + canvas_pos, + ); + } + } + DragState::ArrowControlDrag { element_id } => { + if let Some(screen_pos) = pointer_pos { + let canvas_pos = screen_to_canvas(screen_pos, canvas_center, offset, zoom); + let _ = update_arrow_control_offset(session, &element_id, canvas_pos); + } + } + _ => {} + } + } + + if response.drag_stopped_by(egui::PointerButton::Primary) { + match session.drag_state { + DragState::VertexDrag { .. } => { + session.record_edit("Move Vertex", ChangeSource::Human); + session.drag_state = DragState::None; + } + DragState::ArrowControlDrag { .. } => { + session.record_edit("Curve Arrow", ChangeSource::Human); + session.drag_state = DragState::None; + } + _ => {} + } + } + + if response.double_clicked_by(egui::PointerButton::Primary) { + if let Some(screen_pos) = pointer_pos { + if let Some(element_id) = + find_selected_arrow_control_handle(session, screen_pos, canvas_center, offset, zoom) + { + if reset_arrow_control_offset(session, &element_id) { + session.record_edit("Straighten Arrow", ChangeSource::Human); + } + return; + } + } + } + + if response.clicked_by(egui::PointerButton::Primary) && !response.dragged() { + if let Some(screen_pos) = pointer_pos { + if find_selected_arrow_control_handle(session, screen_pos, canvas_center, offset, zoom) + .is_some() + { + return; + } + + if let Some((element_id, polygon_idx, vertex_idx, is_hole)) = + find_nearest_selected_path_vertex( + session, + screen_pos, + canvas_center, + offset, + zoom, + DIRECT_SELECT_TOLERANCE, + ) + { + session.selected_vertex = Some((element_id, polygon_idx, vertex_idx, is_hole)); + return; + } + + if let Some((element_id, polygon_idx, insert_idx, is_hole, midpoint)) = + find_nearest_selected_path_midpoint( + session, + screen_pos, + canvas_center, + offset, + zoom, + DIRECT_SELECT_TOLERANCE, + ) + { + if insert_path_vertex( + session, + &element_id, + polygon_idx, + insert_idx, + is_hole, + midpoint, + ) { + session.selected_vertex = Some((element_id, polygon_idx, insert_idx, is_hole)); + session.record_edit("Add Vertex", ChangeSource::Human); + return; + } + } + + clear_selected_vertex(session); + } + } +} + +fn find_selected_arrow_control_handle( + session: &Session, + screen_pos: egui::Pos2, + canvas_center: egui::Pos2, + offset: egui::Vec2, + zoom: f32, +) -> Option { + if session.selected_element_ids.len() != 1 { + return None; + } + + let selected_id = session.selected_element_ids.first()?.clone(); + let element = session + .drawing_elements + .iter() + .find(|element| element.id == selected_id)?; + if !matches!(element.shape, Shape::Arrow { .. }) { + return None; + } + + find_arrow_control_handle_at_screen_pos(element, screen_pos, canvas_center, offset, zoom) + .then_some(selected_id) +} + +fn update_arrow_control_offset( + session: &mut Session, + element_id: &str, + drag_pos_canvas: egui::Pos2, +) -> bool { + let Some(element) = session + .drawing_elements + .iter_mut() + .find(|element| element.id == element_id) + else { + return false; + }; + + let Shape::Arrow { + start, + end, + control_offset, + } = &mut element.shape + else { + return false; + }; + + let midpoint = arrow_midpoint(*start, *end); + *control_offset = Some(drag_pos_canvas - midpoint); + true +} + +fn reset_arrow_control_offset(session: &mut Session, element_id: &str) -> bool { + let Some(element) = session + .drawing_elements + .iter_mut() + .find(|element| element.id == element_id) + else { + return false; + }; + + let Shape::Arrow { control_offset, .. } = &mut element.shape else { + return false; + }; + + if control_offset.is_none() { + return false; + } + *control_offset = None; + true +} + +fn clear_selected_vertex(session: &mut Session) { + session.selected_vertex = None; + if matches!( + session.drag_state, + DragState::VertexDrag { .. } | DragState::ArrowControlDrag { .. } + ) { + session.drag_state = DragState::None; + } +} + +fn canvas_to_screen_point( + point: egui::Pos2, + canvas_center: egui::Pos2, + offset: egui::Vec2, + zoom: f32, +) -> egui::Pos2 { + canvas_center + (point.to_vec2() + offset) * zoom +} + +fn nearest_distance( + screen_pos: egui::Pos2, + point: egui::Pos2, + canvas_center: egui::Pos2, + offset: egui::Vec2, + zoom: f32, +) -> f32 { + (screen_pos - canvas_to_screen_point(point, canvas_center, offset, zoom)).length() +} + +#[derive(Clone, Copy)] +struct VertexSearchContext { + screen_pos: egui::Pos2, + canvas_center: egui::Pos2, + offset: egui::Vec2, + zoom: f32, + tolerance: f32, +} + +fn find_nearest_selected_path_vertex( + session: &Session, + screen_pos: egui::Pos2, + canvas_center: egui::Pos2, + offset: egui::Vec2, + zoom: f32, + tolerance: f32, +) -> Option<(String, usize, usize, bool)> { + let mut best: Option<(String, usize, usize, bool, f32)> = None; + + for element_id in &session.selected_element_ids { + let Some(element) = session + .drawing_elements + .iter() + .find(|element| &element.id == element_id) + else { + continue; + }; + let Shape::Path { polygons } = &element.shape else { + continue; + }; + + for (polygon_idx, polygon) in polygons.iter().enumerate() { + for (vertex_idx, point) in polygon.exterior.iter().enumerate() { + let distance = nearest_distance(screen_pos, *point, canvas_center, offset, zoom); + if distance <= tolerance + && best + .as_ref() + .map(|(_, _, _, _, best_distance)| distance < *best_distance) + .unwrap_or(true) + { + best = Some((element.id.clone(), polygon_idx, vertex_idx, false, distance)); + } + } + + if let Some(hole) = polygon.holes.first() { + for (vertex_idx, point) in hole.iter().enumerate() { + let distance = + nearest_distance(screen_pos, *point, canvas_center, offset, zoom); + if distance <= tolerance + && best + .as_ref() + .map(|(_, _, _, _, best_distance)| distance < *best_distance) + .unwrap_or(true) + { + best = Some((element.id.clone(), polygon_idx, vertex_idx, true, distance)); + } + } + } + } + } + + best.map(|(element_id, polygon_idx, vertex_idx, is_hole, _)| { + (element_id, polygon_idx, vertex_idx, is_hole) + }) +} + +fn find_nearest_selected_path_midpoint( + session: &Session, + screen_pos: egui::Pos2, + canvas_center: egui::Pos2, + offset: egui::Vec2, + zoom: f32, + tolerance: f32, +) -> Option<(String, usize, usize, bool, egui::Pos2)> { + let mut best: Option<(String, usize, usize, bool, egui::Pos2, f32)> = None; + let search = VertexSearchContext { + screen_pos, + canvas_center, + offset, + zoom, + tolerance, + }; + + for element_id in &session.selected_element_ids { + let Some(element) = session + .drawing_elements + .iter() + .find(|element| &element.id == element_id) + else { + continue; + }; + let Shape::Path { polygons } = &element.shape else { + continue; + }; + + for (polygon_idx, polygon) in polygons.iter().enumerate() { + check_ring_midpoints( + &mut best, + &element.id, + polygon_idx, + false, + &polygon.exterior, + search, + ); + if let Some(hole) = polygon.holes.first() { + check_ring_midpoints(&mut best, &element.id, polygon_idx, true, hole, search); + } + } + } + + best.map( + |(element_id, polygon_idx, insert_idx, is_hole, midpoint, _)| { + (element_id, polygon_idx, insert_idx, is_hole, midpoint) + }, + ) +} + +fn check_ring_midpoints( + best: &mut Option<(String, usize, usize, bool, egui::Pos2, f32)>, + element_id: &str, + polygon_idx: usize, + is_hole: bool, + ring: &[egui::Pos2], + search: VertexSearchContext, +) { + if ring.len() < 2 { + return; + } + + let draw_closed = ring.len() >= 3; + let segment_count = if draw_closed { + ring.len() + } else { + ring.len() - 1 + }; + for idx in 0..segment_count { + let next_idx = if idx + 1 < ring.len() { idx + 1 } else { 0 }; + let midpoint = egui::pos2( + (ring[idx].x + ring[next_idx].x) * 0.5, + (ring[idx].y + ring[next_idx].y) * 0.5, + ); + let distance = nearest_distance( + search.screen_pos, + midpoint, + search.canvas_center, + search.offset, + search.zoom, + ); + if distance <= search.tolerance + && best + .as_ref() + .map(|(_, _, _, _, _, best_distance)| distance < *best_distance) + .unwrap_or(true) + { + *best = Some(( + element_id.to_string(), + polygon_idx, + next_idx, + is_hole, + midpoint, + distance, + )); + } + } +} + +fn update_path_vertex_position( + session: &mut Session, + element_id: &str, + polygon_idx: usize, + vertex_idx: usize, + is_hole: bool, + position: egui::Pos2, +) -> bool { + let Some(element) = session + .drawing_elements + .iter_mut() + .find(|element| element.id == element_id) + else { + return false; + }; + + let Shape::Path { polygons } = &mut element.shape else { + return false; + }; + + let Some(polygon) = polygons.get_mut(polygon_idx) else { + return false; + }; + + let ring: &mut Vec = if is_hole { + if let Some(hole) = polygon.holes.first_mut() { + hole + } else { + return false; + } + } else { + &mut polygon.exterior + }; + + let Some(vertex) = ring.get_mut(vertex_idx) else { + return false; + }; + + *vertex = position; + true +} + +fn insert_path_vertex( + session: &mut Session, + element_id: &str, + polygon_idx: usize, + insert_idx: usize, + is_hole: bool, + position: egui::Pos2, +) -> bool { + let Some(element) = session + .drawing_elements + .iter_mut() + .find(|element| element.id == element_id) + else { + return false; + }; + + let Shape::Path { polygons } = &mut element.shape else { + return false; + }; + + let Some(polygon) = polygons.get_mut(polygon_idx) else { + return false; + }; + + let ring: &mut Vec = if is_hole { + if let Some(hole) = polygon.holes.first_mut() { + hole + } else { + return false; + } + } else { + &mut polygon.exterior + }; + + if insert_idx > ring.len() { + return false; + } + ring.insert(insert_idx, position); + true +} + +fn selection_ids_for_hit(session: &Session, element: &DrawingElement) -> Vec { + match &element.group_id { + Some(group_id) => session + .drawing_elements + .iter() + .filter(|candidate| candidate.group_id.as_ref() == Some(group_id)) + .map(|candidate| candidate.id.clone()) + .collect(), + None => vec![element.id.clone()], + } +} + fn handle_shape_tool( session: &mut Session, response: &egui::Response, @@ -826,6 +2069,7 @@ fn handle_shape_tool( offset: egui::Vec2, zoom: f32, pointer_pos: Option, + default_stroke_color: Color32, ) { if response.drag_started_by(egui::PointerButton::Primary) { if let Some(screen_pos) = pointer_pos { @@ -872,7 +2116,17 @@ fn handle_shape_tool( Tool::Arrow => Shape::Arrow { start, end: current, + control_offset: None, }, + Tool::Polygon => { + let rect = egui::Rect::from_two_pos(start, current); + Shape::Polygon { + center: rect.center(), + radius: rect.width().min(rect.height()) / 2.0, + sides: session.polygon_sides, + star_inner_ratio: session.polygon_star_ratio, + } + } _ => unreachable!(), }; let tool_label = match session.active_tool { @@ -880,11 +2134,19 @@ fn handle_shape_tool( Tool::Ellipse => "Ellipse", Tool::Line => "Line", Tool::Arrow => "Arrow", + Tool::Polygon => "Polygon", _ => "Shape", }; - let element = DrawingElement::new(shape, ShapeStyle::default()); + let element = DrawingElement::new( + shape, + ShapeStyle { + stroke_color: default_stroke_color, + ..ShapeStyle::default() + }, + ); session.selected_element_ids = vec![element.id.clone()]; + clear_selected_vertex(session); session.drawing_elements.push(element); session.record_edit(&format!("Draw {}", tool_label), ChangeSource::Human); } @@ -898,6 +2160,46 @@ impl eframe::App for AgCanvasApp { self.drain_drawing_commands(); self.drain_session_commands(); + if self.pending_screenshot_path.is_some() { + ctx.send_viewport_cmd(egui::ViewportCommand::Screenshot); + } + + ctx.input(|i| { + for event in &i.raw.events { + if let egui::Event::Screenshot { image, .. } = event { + if let Some(path) = self.pending_screenshot_path.take() { + let width = image.width() as u32; + let height = image.height() as u32; + let rgba: Vec = image + .pixels + .iter() + .flat_map(|c| [c.r(), c.g(), c.b(), c.a()]) + .collect(); + let save_result = image::save_buffer( + &path, + &rgba, + width, + height, + image::ColorType::Rgba8, + ); + match save_result { + Ok(()) => { + tracing::info!("Screenshot saved: {} ({}x{})", path, width, height); + let _ = self.event_tx.send(GuiEvent::ScreenshotCaptured { + path, + width, + height, + }); + } + Err(e) => { + tracing::error!("Failed to save screenshot: {}", e); + } + } + } + } + } + }); + let mut paste = false; let mut new_tab = false; let mut close_tab = false; @@ -907,7 +2209,16 @@ impl eframe::App for AgCanvasApp { let mut toggle_history = false; let mut undo = false; let mut redo = false; + let mut duplicate = false; + let mut convert_to_path = false; + let mut group = false; + let mut ungroup = false; + let mut bring_forward = false; + let mut send_backward = false; + let mut bring_to_front = false; + let mut send_to_back = false; let mut export_png = false; + let mut export_svg = false; let mut tool_switch: Option = None; let palette_open = self.command_palette.visible; @@ -916,12 +2227,15 @@ impl eframe::App for AgCanvasApp { if i.modifiers.command && i.key_pressed(egui::Key::K) { toggle_palette = true; } - if i.modifiers.command && i.key_pressed(egui::Key::S) { + if i.modifiers.command && !i.modifiers.shift && i.key_pressed(egui::Key::S) { save_workspace = true; } if !palette_open { - if i.modifiers.command && i.key_pressed(egui::Key::V) { - paste = true; + for event in &i.raw.events { + if matches!(event, egui::Event::Paste(_)) { + paste = true; + break; + } } if i.modifiers.command && i.key_pressed(egui::Key::T) { new_tab = true; @@ -939,9 +2253,39 @@ impl eframe::App for AgCanvasApp { undo = true; } } + if i.modifiers.command && i.key_pressed(egui::Key::D) { + duplicate = true; + } + if i.modifiers.command && i.modifiers.shift && i.key_pressed(egui::Key::P) { + convert_to_path = true; + } + if i.modifiers.command && i.key_pressed(egui::Key::G) { + if i.modifiers.shift { + ungroup = true; + } else { + group = true; + } + } + if i.modifiers.command + && i.modifiers.shift + && i.key_pressed(egui::Key::CloseBracket) + { + bring_to_front = true; + } else if i.modifiers.command && i.key_pressed(egui::Key::CloseBracket) { + bring_forward = true; + } + if i.modifiers.command && i.modifiers.shift && i.key_pressed(egui::Key::OpenBracket) + { + send_to_back = true; + } else if i.modifiers.command && i.key_pressed(egui::Key::OpenBracket) { + send_backward = true; + } if i.modifiers.command && i.modifiers.shift && i.key_pressed(egui::Key::E) { export_png = true; } + if i.modifiers.command && i.modifiers.shift && i.key_pressed(egui::Key::S) { + export_svg = true; + } if i.key_pressed(egui::Key::Delete) || i.key_pressed(egui::Key::Backspace) { delete_selected = true; } @@ -967,6 +2311,12 @@ impl eframe::App for AgCanvasApp { if i.key_pressed(egui::Key::A) { tool_switch = Some(Tool::Arrow); } + if i.key_pressed(egui::Key::P) { + tool_switch = Some(Tool::Polygon); + } + if i.key_pressed(egui::Key::D) { + tool_switch = Some(Tool::DirectSelect); + } } } }); @@ -994,18 +2344,23 @@ impl eframe::App for AgCanvasApp { if export_png { self.export_canvas_png(); } - if delete_selected - && !self.show_text_input - && !self.show_mermaid_dialog - && !self.active_session().selected_element_ids.is_empty() - { - self.active_session_mut().delete_selected(); - self.active_session_mut() - .record_edit("Delete Element", ChangeSource::Human); + if export_svg { + self.export_canvas_svg(); + } + if delete_selected && !self.show_text_input && !self.show_mermaid_dialog { + if self.active_session().active_tool == Tool::DirectSelect + && self.active_session().selected_vertex.is_some() + { + self.delete_selected_vertex(); + } else if !self.active_session().selected_element_ids.is_empty() { + 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 { - self.active_session_mut().active_tool = tool; + self.set_active_tool(tool); } } @@ -1023,14 +2378,54 @@ impl eframe::App for AgCanvasApp { self.apply_history_checkout(ctx); } } + if duplicate { + self.duplicate_selection(); + } + if convert_to_path { + self.convert_selection_to_path(); + } + if group { + self.group_selection(); + } + if ungroup { + self.ungroup_selection(); + } + if bring_forward { + self.bring_selection_forward(); + } + if send_backward { + self.send_selection_backward(); + } + if bring_to_front { + self.bring_selection_to_front(); + } + if send_to_back { + self.send_selection_to_back(); + } + + { + let session = self.active_session_mut(); + if session.active_tool != Tool::DirectSelect { + clear_selected_vertex(session); + } else if let Some((element_id, _, _, _)) = &session.selected_vertex { + let selected = session.selected_element_ids.contains(element_id); + let exists = session + .drawing_elements + .iter() + .any(|element| &element.id == element_id); + if !selected || !exists { + clear_selected_vertex(session); + } + } + } let dropped_files = ctx.input(|i| i.raw.dropped_files.clone()); for file in dropped_files { - let is_svg = file + let extension = file .path .as_ref() .and_then(|path| path.extension().and_then(|ext| ext.to_str())) - .map(|ext| ext.eq_ignore_ascii_case("svg")) + .map(str::to_ascii_lowercase) .or_else(|| { if file.name.is_empty() { None @@ -1038,54 +2433,91 @@ impl eframe::App for AgCanvasApp { Path::new(&file.name) .extension() .and_then(|ext| ext.to_str()) - .map(|ext| ext.eq_ignore_ascii_case("svg")) + .map(str::to_ascii_lowercase) } - }) - .unwrap_or(false); + }); - if !is_svg { + let Some(extension) = extension else { + continue; + }; + + let is_svg = extension == "svg"; + let is_raster = matches!( + extension.as_str(), + "png" | "jpg" | "jpeg" | "gif" | "webp" | "bmp" + ); + + if !is_svg && !is_raster { continue; } - let svg_result = if let Some(bytes) = file.bytes.as_ref() { - String::from_utf8(bytes.to_vec()).map_err(|e| e.to_string()) + let file_name = file + .path + .as_ref() + .and_then(|path| path.file_name()) + .and_then(|name| name.to_str()) + .map(str::to_string) + .or_else(|| { + if file.name.is_empty() { + None + } else { + Some(file.name.clone()) + } + }) + .unwrap_or_else(|| "(dropped file)".to_string()); + + if is_svg { + let svg_result = if let Some(bytes) = file.bytes.as_ref() { + String::from_utf8(bytes.to_vec()).map_err(|e| e.to_string()) + } else if let Some(path) = file.path.as_ref() { + std::fs::read_to_string(path).map_err(|e| e.to_string()) + } else { + Err("Dropped SVG file has no readable source".to_string()) + }; + + match svg_result { + Ok(svg_string) => { + self.load_svg_as_image(&svg_string, ctx); + self.set_status(format!("Loaded SVG file: {}", file_name)); + } + Err(e) => { + self.set_status(format!("Failed to load dropped SVG: {}", e)); + } + } + continue; + } + + let raster_bytes = if let Some(bytes) = file.bytes.as_ref() { + Ok(bytes.to_vec()) } else if let Some(path) = file.path.as_ref() { - std::fs::read_to_string(path).map_err(|e| e.to_string()) + std::fs::read(path).map_err(|e| e.to_string()) } else { - Err("Dropped SVG file has no readable source".to_string()) + Err("Dropped image file has no readable source".to_string()) }; - match svg_result { - Ok(svg_string) => { - self.load_svg_data(&svg_string, ctx); - let file_name = file - .path - .as_ref() - .and_then(|path| path.file_name()) - .and_then(|name| name.to_str()) - .map(str::to_string) - .or_else(|| { - if file.name.is_empty() { - None - } else { - Some(file.name.clone()) - } - }) - .unwrap_or_else(|| "(dropped svg)".to_string()); - self.set_status(format!("Loaded SVG from file: {}", file_name)); - } + match raster_bytes { + Ok(bytes) => match image::load_from_memory(&bytes) { + Ok(decoded) => { + let rgba = decoded.to_rgba8(); + let (width, height) = rgba.dimensions(); + self.load_raster_image( + rgba.into_raw(), + width as usize, + height as usize, + ctx, + ); + self.set_status(format!("Loaded image file: {}", file_name)); + } + Err(e) => { + self.set_status(format!("Failed to decode dropped image: {}", e)); + } + }, Err(e) => { - self.set_status(format!("Failed to load dropped SVG: {}", e)); + self.set_status(format!("Failed to read dropped image: {}", e)); } } } - if self.active_session().svg_texture.is_none() - && self.active_session().svg_renderer.is_some() - { - self.render_svg_to_texture(ctx); - } - egui::TopBottomPanel::top("menu_bar").show(ctx, |ui| { egui::menu::bar(ui, |ui| { ui.menu_button("File", |ui| { @@ -1099,7 +2531,7 @@ impl eframe::App for AgCanvasApp { ui.close_menu(); } ui.separator(); - if ui.button("Paste SVG (Cmd+V)").clicked() { + if ui.button("Paste (Cmd+V)").clicked() { self.handle_paste(ctx); ui.close_menu(); } @@ -1112,6 +2544,10 @@ impl eframe::App for AgCanvasApp { self.export_canvas_png(); ui.close_menu(); } + if ui.button("Export as SVG (Cmd+Shift+S)").clicked() { + self.export_canvas_svg(); + ui.close_menu(); + } ui.separator(); if ui.button("Clear Canvas").clicked() { self.clear_canvas(); @@ -1139,6 +2575,15 @@ impl eframe::App for AgCanvasApp { } ui.close_menu(); } + ui.separator(); + if ui.button("Group Selection (Cmd+G)").clicked() { + self.group_selection(); + ui.close_menu(); + } + if ui.button("Ungroup Selection (Cmd+Shift+G)").clicked() { + self.ungroup_selection(); + ui.close_menu(); + } }); ui.menu_button("View", |ui| { if ui @@ -1160,14 +2605,41 @@ impl eframe::App for AgCanvasApp { ui.close_menu(); } ui.separator(); + ui.menu_button(format!("Theme: {}", self.canvas_theme.label()), |ui| { + let is_dark = matches!(self.canvas_theme, CanvasTheme::Dark); + if ui.selectable_label(is_dark, "Dark").clicked() { + self.canvas_theme = CanvasTheme::Dark; + ui.close_menu(); + } + + let is_light = matches!(self.canvas_theme, CanvasTheme::Light); + if ui.selectable_label(is_light, "Light").clicked() { + self.canvas_theme = CanvasTheme::Light; + ui.close_menu(); + } + + let is_custom = matches!(self.canvas_theme, CanvasTheme::Custom { .. }); + if ui.selectable_label(is_custom, "Custom...").clicked() { + if !is_custom { + self.canvas_theme = CanvasTheme::Custom { + background: color_to_rgb(self.canvas_theme.background()), + grid: color_to_rgb(self.canvas_theme.grid_color()), + stroke: color_to_rgb(self.canvas_theme.default_stroke()), + }; + } + self.show_custom_theme_dialog = true; + ui.close_menu(); + } + }); + ui.separator(); if ui.button("Reset Zoom (Cmd+0)").clicked() { self.active_session_mut().canvas_state.reset(); ui.close_menu(); } if ui.button("Fit to View").clicked() { let session = self.active_session_mut(); - if let Some(renderer) = &session.svg_renderer { - let (w, h) = renderer.size(); + if let Some(tree) = &session.element_tree { + let (w, h) = (tree.metadata.width, tree.metadata.height); session .canvas_state .fit_to_rect(egui::vec2(w, h), ctx.screen_rect().size() * 0.8); @@ -1198,32 +2670,63 @@ impl eframe::App for AgCanvasApp { ui.horizontal(|ui| { let mut close_idx: Option = None; let mut switch_idx: Option = None; + let can_close = self.sessions.len() > 1; - for (idx, session) in self.sessions.iter().enumerate() { + let mut start_rename_idx: Option = None; + let mut finish_rename: Option<(usize, String)> = None; + let mut cancel_rename = false; + + for idx in 0..self.sessions.len() { let is_active = idx == self.active_session_idx; + let is_renaming = self.renaming_tab.as_ref().is_some_and(|(i, _)| *i == idx); + + if is_renaming { + let (_, ref mut buf) = self.renaming_tab.as_mut().unwrap(); + let response = ui.add( + egui::TextEdit::singleline(buf) + .desired_width(100.0) + .font(egui::TextStyle::Body), + ); + if response.lost_focus() || ui.input(|i| i.key_pressed(egui::Key::Enter)) { + finish_rename = Some((idx, buf.trim().to_string())); + } + if ui.input(|i| i.key_pressed(egui::Key::Escape)) { + cancel_rename = true; + } + response.request_focus(); + continue; + } + + let session = &self.sessions[idx]; let has_content = session.element_tree.is_some() || !session.drawing_elements.is_empty(); let creator_icon = match &session.created_by { SessionCreator::Human => "", - SessionCreator::Agent { .. } => "\u{1F916} ", - }; - let label = if has_content { - format!("{}{} *", creator_icon, session.name) - } else { - format!("{}{}", creator_icon, session.name) + SessionCreator::Agent { .. } => "[A] ", }; + let content_marker = if has_content { " *" } else { "" }; - let button = egui::Button::new(&label).fill(if is_active { - Color32::from_gray(50) + let bg = if is_active { + Color32::from_gray(55) } else { Color32::from_gray(35) - }); + }; + + let label = format!("{}{}{}", creator_icon, session.name, content_marker); + let tab_text = egui::RichText::new(&label).size(13.0); + let tab_btn = egui::Button::new(tab_text) + .fill(bg) + .rounding(4.0) + .min_size(egui::vec2(0.0, 28.0)); + let tab_response = ui.add(tab_btn); - let tab_response = ui.add(button); if tab_response.clicked() { switch_idx = Some(idx); } + if tab_response.double_clicked() { + start_rename_idx = Some(idx); + } let mut tooltip_parts = vec![format!("Created by: {}", session.created_by)]; if let Some(desc) = &session.description { @@ -1231,10 +2734,27 @@ impl eframe::App for AgCanvasApp { tooltip_parts.push(desc.clone()); } } + tooltip_parts.push("Double-click to rename".to_string()); tab_response.clone().on_hover_text(tooltip_parts.join("\n")); - if self.sessions.len() > 1 { + if can_close { + let close_text = egui::RichText::new("x") + .size(11.0) + .color(Color32::from_gray(100)); + let close_btn = ui.add( + egui::Button::new(close_text) + .frame(false) + .min_size(egui::vec2(14.0, 14.0)), + ); + if close_btn.clicked() { + close_idx = Some(idx); + } + tab_response.context_menu(|ui| { + if ui.button("Rename").clicked() { + start_rename_idx = Some(idx); + ui.close_menu(); + } if ui.button("Close").clicked() { close_idx = Some(idx); ui.close_menu(); @@ -1243,6 +2763,19 @@ impl eframe::App for AgCanvasApp { } } + if cancel_rename { + self.renaming_tab = None; + } + if let Some((idx, new_name)) = finish_rename { + if !new_name.is_empty() { + self.sessions[idx].name = new_name; + } + self.renaming_tab = None; + } + if let Some(idx) = start_rename_idx { + self.renaming_tab = Some((idx, self.sessions[idx].name.clone())); + } + if let Some(idx) = switch_idx { self.switch_session(idx); } @@ -1251,43 +2784,392 @@ impl eframe::App for AgCanvasApp { } ui.separator(); - if ui.button("+").clicked() { + let plus_text = egui::RichText::new("+").size(15.0); + if ui + .add(egui::Button::new(plus_text).min_size(egui::vec2(28.0, 28.0))) + .clicked() + { self.create_session(); } }); }); - egui::TopBottomPanel::top("toolbar").show(ctx, |ui| { - ui.horizontal(|ui| { - let active_tool = self.active_session().active_tool; - let tools = [ - Tool::Select, - Tool::Pan, - Tool::Rectangle, - Tool::Ellipse, - Tool::Line, - Tool::Arrow, - Tool::Text, - ]; + let selected_ids = self.active_session().selected_element_ids.clone(); + if !selected_ids.is_empty() { + let selected_elements: Vec = { + let session = self.active_session(); + selected_ids + .iter() + .filter_map(|id| { + session + .drawing_elements + .iter() + .find(|element| element.id == *id) + .cloned() + }) + .collect() + }; - for tool in tools { - let label = match tool.shortcut() { - Some(key) => format!("{} ({})", tool.label(), key), - None => tool.label().to_string(), + if let Some(primary) = selected_elements.first() { + let has_rectangle = selected_elements + .iter() + .any(|el| matches!(el.shape, Shape::Rectangle { .. })); + let has_text = selected_elements + .iter() + .any(|el| matches!(el.shape, Shape::Text { .. })); + let has_polygon = selected_elements + .iter() + .any(|el| matches!(el.shape, Shape::Polygon { .. })); + + let mut no_fill = primary.style.fill.is_none(); + let mut fill_color = primary + .style + .fill + .as_ref() + .map(|f| f.primary_color()) + .unwrap_or(Color32::WHITE); + let mut stroke_color = primary.style.stroke_color; + let mut stroke_width = primary.style.stroke_width; + let mut opacity = primary.style.opacity; + let mut rotation = primary.style.rotation_degrees; + let mut corner_radius = primary.style.corner_radius; + let mut font_size = match &primary.shape { + Shape::Text { font_size, .. } => *font_size, + _ => 20.0, + }; + let mut font_family_choice = if matches!(primary.style.font_family.as_deref(), Some(name) if name.eq_ignore_ascii_case("monospace")) + { + 1 + } else { + 0 + }; + let mut polygon_sides = match &primary.shape { + Shape::Polygon { sides, .. } => *sides, + _ => self.active_session().polygon_sides, + }; + let mut polygon_star = match &primary.shape { + Shape::Polygon { + star_inner_ratio, .. + } => star_inner_ratio.unwrap_or(0.0), + _ => self.active_session().polygon_star_ratio.unwrap_or(0.0), + }; + let mut is_star = match &primary.shape { + Shape::Polygon { + star_inner_ratio, .. + } => star_inner_ratio.is_some(), + _ => self.active_session().polygon_star_ratio.is_some(), + }; + + let mut changed = false; + let mut align_requested: Option = None; + let mut zorder_action: Option = None; + let mut boolean_op_requested: Option = None; + let mut convert_to_path_requested = false; + + let screen_rect = ctx.screen_rect(); + let panel_width = 220.0; + let panel_x = screen_rect.max.x - panel_width; + let panel_y = screen_rect.min.y; + let panel_height = screen_rect.height(); + + egui::Area::new(egui::Id::new("properties_overlay")) + .fixed_pos(egui::pos2(panel_x, panel_y)) + .order(egui::Order::Foreground) + .show(ctx, |ui| { + egui::Frame::none() + .fill(Color32::from_rgba_unmultiplied(25, 25, 25, 240)) + .stroke(egui::Stroke::new(1.0, Color32::from_gray(50))) + .inner_margin(10.0) + .show(ui, |ui| { + ui.set_width(panel_width - 20.0); + ui.set_height(panel_height - 20.0); + egui::ScrollArea::vertical().show(ui, |ui| { + ui.heading("Properties"); + ui.separator(); + + if ui.checkbox(&mut no_fill, "No fill").changed() { + changed = true; + } + if ui.color_edit_button_srgba(&mut fill_color).changed() { + changed = true; + } + + ui.separator(); + ui.label("Stroke color"); + if ui.color_edit_button_srgba(&mut stroke_color).changed() { + changed = true; + } + if ui + .add( + egui::Slider::new(&mut stroke_width, 0.0..=20.0) + .text("Stroke width"), + ) + .changed() + { + changed = true; + } + if ui + .add( + egui::Slider::new(&mut opacity, 0.0..=1.0) + .text("Opacity"), + ) + .changed() + { + changed = true; + } + if ui + .add( + egui::Slider::new(&mut rotation, 0.0..=360.0) + .text("Rotation") + .suffix("\u{00B0}"), + ) + .changed() + { + changed = true; + } + + if has_rectangle + && ui + .add( + egui::Slider::new(&mut corner_radius, 0.0..=50.0) + .text("Corner radius"), + ) + .changed() + { + changed = true; + } + + if has_polygon { + ui.separator(); + ui.label("Polygon"); + if ui + .add( + egui::Slider::new(&mut polygon_sides, 3..=24) + .text("Sides") + .integer(), + ) + .changed() + { + changed = true; + } + if ui.checkbox(&mut is_star, "Star").changed() { + changed = true; + if !is_star { + polygon_star = 0.0; + } else if polygon_star < 0.01 { + polygon_star = 0.5; + } + } + if is_star + && ui + .add( + egui::Slider::new( + &mut polygon_star, + 0.05..=0.95, + ) + .text("Inner ratio"), + ) + .changed() + { + changed = true; + } + } + + if has_text { + ui.separator(); + ui.label("Font family"); + egui::ComboBox::from_id_salt("properties_font_family") + .selected_text(if font_family_choice == 1 { + "Monospace" + } else { + "Default" + }) + .show_ui(ui, |ui| { + if ui + .selectable_value( + &mut font_family_choice, + 0, + "Default", + ) + .changed() + { + changed = true; + } + if ui + .selectable_value( + &mut font_family_choice, + 1, + "Monospace", + ) + .changed() + { + changed = true; + } + }); + + if ui + .add( + egui::Slider::new(&mut font_size, 6.0..=200.0) + .text("Font size"), + ) + .changed() + { + changed = true; + } + } + + if selected_elements.len() >= 2 { + ui.separator(); + ui.label("Align"); + ui.horizontal_wrapped(|ui| { + if ui.small_button("L").clicked() { + align_requested = Some(AlignOp::Left); + } + if ui.small_button("R").clicked() { + align_requested = Some(AlignOp::Right); + } + if ui.small_button("T").clicked() { + align_requested = Some(AlignOp::Top); + } + if ui.small_button("B").clicked() { + align_requested = Some(AlignOp::Bottom); + } + if ui.small_button("CH").clicked() { + align_requested = Some(AlignOp::CenterH); + } + if ui.small_button("CV").clicked() { + align_requested = Some(AlignOp::CenterV); + } + if ui.small_button("DH").clicked() { + align_requested = Some(AlignOp::DistributeH); + } + if ui.small_button("DV").clicked() { + align_requested = Some(AlignOp::DistributeV); + } + }); + } + + ui.separator(); + ui.label("Order"); + ui.horizontal_wrapped(|ui| { + if ui.small_button("Forward").clicked() { + zorder_action = Some(1); + } + if ui.small_button("Back").clicked() { + zorder_action = Some(2); + } + if ui.small_button("To Front").clicked() { + zorder_action = Some(3); + } + if ui.small_button("To Back").clicked() { + zorder_action = Some(4); + } + }); + + ui.separator(); + if ui.button("Convert to Path").clicked() { + convert_to_path_requested = true; + } + + if selected_ids.len() >= 2 { + ui.separator(); + ui.label("Boolean"); + ui.horizontal_wrapped(|ui| { + if ui.small_button("Union").clicked() { + boolean_op_requested = Some(BooleanOpType::Union); + } + if ui.small_button("Intersect").clicked() { + boolean_op_requested = + Some(BooleanOpType::Intersection); + } + if ui.small_button("Diff").clicked() { + boolean_op_requested = + Some(BooleanOpType::Difference); + } + if ui.small_button("XOR").clicked() { + boolean_op_requested = Some(BooleanOpType::Xor); + } + }); + } + }); + }); + }); + + if changed { + let fill = if no_fill { + None + } else { + Some(crate::drawing::Fill::solid(fill_color)) }; - if ui.selectable_label(active_tool == tool, &label).clicked() { - self.active_session_mut().active_tool = tool; + let font_family = if font_family_choice == 1 { + Some("monospace".to_string()) + } else { + None + }; + + let session = self.active_session_mut(); + for id in &selected_ids { + if let Some(element) = + session.drawing_elements.iter_mut().find(|el| el.id == *id) + { + element.style.fill = fill.clone(); + element.style.stroke_color = stroke_color; + element.style.stroke_width = stroke_width; + element.style.opacity = opacity; + element.style.rotation_degrees = rotation; + + if has_rectangle && matches!(element.shape, Shape::Rectangle { .. }) { + element.style.corner_radius = corner_radius; + } + + if has_text { + if let Shape::Text { + font_size: text_size, + .. + } = &mut element.shape + { + *text_size = font_size; + element.style.font_family = font_family.clone(); + } + } + + if has_polygon { + if let Shape::Polygon { + sides, + star_inner_ratio, + .. + } = &mut element.shape + { + *sides = polygon_sides; + *star_inner_ratio = + if is_star { Some(polygon_star) } else { None }; + } + } + } } + session.polygon_sides = polygon_sides; + session.polygon_star_ratio = if is_star { Some(polygon_star) } else { None }; + session.record_edit("Edit Properties", ChangeSource::Human); } - ui.separator(); - - let drawing_count = self.active_session().drawing_elements.len(); - if drawing_count > 0 { - ui.label(format!("{} shapes", drawing_count)); + if let Some(op) = align_requested { + self.align_elements(op); } - }); - }); + match zorder_action { + Some(1) => self.bring_selection_forward(), + Some(2) => self.send_selection_backward(), + Some(3) => self.bring_selection_to_front(), + Some(4) => self.send_selection_to_back(), + _ => {} + } + if let Some(op_type) = boolean_op_requested { + self.perform_boolean_op(op_type); + } + if convert_to_path_requested { + self.convert_selection_to_path(); + } + } + } if let Some((msg, time)) = &self.status_message { if time.elapsed().as_secs() < 3 { @@ -1342,16 +3224,19 @@ impl eframe::App for AgCanvasApp { } if self.show_tree_panel { + let elements = self.active_session().drawing_elements.clone(); egui::SidePanel::right("tree_panel") .default_width(300.0) .show(ctx, |ui| { ui.heading("Element Tree"); ui.separator(); egui::ScrollArea::vertical().show(ui, |ui| { - if let Some(tree) = &self.active_session().element_tree { - render_tree_ui(ui, &tree.root, 0); + if elements.is_empty() { + ui.label("No elements. Paste an image or start drawing."); } else { - ui.label("No SVG loaded. Paste an SVG (Cmd+V)"); + for element in &elements { + render_drawing_tree_ui(ui, element, 0); + } } }); }); @@ -1367,7 +3252,12 @@ impl eframe::App for AgCanvasApp { } if self.show_description { - let desc = self.active_session().description_text.clone(); + let elements = &self.active_session().drawing_elements; + let desc = if elements.is_empty() { + String::new() + } else { + describe_elements(elements) + }; egui::SidePanel::left("description_panel") .default_width(300.0) .show(ctx, |ui| { @@ -1381,7 +3271,7 @@ impl eframe::App for AgCanvasApp { .desired_width(f32::INFINITY), ); } else { - ui.label("No SVG loaded. Paste an SVG (Cmd+V)"); + ui.label("No elements. Paste an image or start drawing."); } }); }); @@ -1431,6 +3321,61 @@ impl eframe::App for AgCanvasApp { } } + if self.show_custom_theme_dialog { + let mut open = true; + let (mut background, mut grid, mut stroke) = match &self.canvas_theme { + CanvasTheme::Custom { + background, + grid, + stroke, + } => ( + Color32::from_rgb(background[0], background[1], background[2]), + Color32::from_rgb(grid[0], grid[1], grid[2]), + Color32::from_rgb(stroke[0], stroke[1], stroke[2]), + ), + _ => ( + self.canvas_theme.background(), + self.canvas_theme.grid_color(), + self.canvas_theme.default_stroke(), + ), + }; + + let mut changed = false; + + egui::Window::new("Custom Canvas Theme") + .open(&mut open) + .collapsible(false) + .resizable(false) + .show(ctx, |ui| { + ui.label("Background"); + if ui.color_edit_button_srgba(&mut background).changed() { + changed = true; + } + + ui.label("Grid"); + if ui.color_edit_button_srgba(&mut grid).changed() { + changed = true; + } + + ui.label("Default stroke"); + if ui.color_edit_button_srgba(&mut stroke).changed() { + changed = true; + } + }); + + if changed { + self.canvas_theme = CanvasTheme::Custom { + background: color_to_rgb(background), + grid: color_to_rgb(grid), + stroke: color_to_rgb(stroke), + }; + } + + if !open { + self.show_custom_theme_dialog = false; + } + } + // Text input dialog if self.show_text_input { let mut should_create = false; @@ -1467,11 +3412,13 @@ impl eframe::App for AgCanvasApp { pos, content: self.text_input_buffer.clone(), font_size: 20.0, + max_width: None, }, ShapeStyle { fill: None, - stroke_color: Color32::WHITE, + stroke_color: self.canvas_theme.default_stroke(), stroke_width: 1.0, + ..ShapeStyle::default() }, ); let eid = element.id.clone(); @@ -1479,6 +3426,7 @@ impl eframe::App for AgCanvasApp { self.active_session_mut() .record_edit("Draw Text", ChangeSource::Human); self.active_session_mut().selected_element_ids = vec![eid]; + self.active_session_mut().selected_vertex = None; } self.show_text_input = false; self.text_input_buffer.clear(); @@ -1530,30 +3478,15 @@ impl eframe::App for AgCanvasApp { } let painter = ui.painter_at(response.rect); - painter.rect_filled(response.rect, 0.0, Color32::from_gray(30)); + painter.rect_filled(response.rect, 0.0, self.canvas_theme.background()); draw_grid( &painter, &response.rect, &self.active_session().canvas_state, + self.canvas_theme.grid_color(), ); - 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) * ppp) * canvas_state.zoom; - let offset = canvas_state.offset * canvas_state.zoom; - let rect = egui::Rect::from_center_size(center + offset, size); - painter.image( - texture.id(), - rect, - egui::Rect::from_min_max(egui::pos2(0.0, 0.0), egui::pos2(1.0, 1.0)), - Color32::WHITE, - ); - } - let offset = self.active_session().canvas_state.offset; let zoom = self.active_session().canvas_state.zoom; @@ -1563,10 +3496,54 @@ impl eframe::App for AgCanvasApp { canvas_center, offset, zoom, + Some(&self.active_session().svg_textures), ); for selected_el in self.active_session().selected_elements() { draw_selection(&painter, selected_el, canvas_center, offset, zoom); + if self.active_session().active_tool == Tool::DirectSelect { + let selected_vertex = self.active_session().selected_vertex.as_ref().and_then( + |(element_id, polygon_idx, vertex_idx, is_hole)| { + (element_id == &selected_el.id).then_some(( + *polygon_idx, + *vertex_idx, + *is_hole, + )) + }, + ); + draw_vertex_handles( + &painter, + selected_el, + canvas_center, + offset, + zoom, + selected_vertex, + ); + } + } + + if matches!( + self.active_session().active_tool, + Tool::Select | Tool::DirectSelect + ) && self.active_session().selected_element_ids.len() == 1 + { + let selected_id = &self.active_session().selected_element_ids[0]; + if let Some(selected_el) = self + .active_session() + .drawing_elements + .iter() + .find(|element| &element.id == selected_id) + { + if matches!(selected_el.shape, Shape::Arrow { .. }) { + draw_arrow_control_handle( + &painter, + selected_el, + canvas_center, + offset, + zoom, + ); + } + } } if let DragState::MarqueeSelecting { start, current } = @@ -1583,21 +3560,141 @@ impl eframe::App for AgCanvasApp { canvas_center, offset, zoom, + self.active_session().polygon_sides, + self.active_session().polygon_star_ratio, ); - if self.active_session().svg_texture.is_none() - && self.active_session().drawing_elements.is_empty() - { + if self.active_session().drawing_elements.is_empty() { painter.text( response.rect.center(), egui::Align2::CENTER_CENTER, - "Paste SVG (Cmd+V) or start drawing", + "Paste an image or SVG (Cmd+V) or start drawing", egui::FontId::proportional(24.0), - Color32::from_gray(100), + self.canvas_theme.text_color(), ); } }); + { + let active_tool = self.active_session().active_tool; + + // Tool groups with separators between them + let tool_groups: &[&[Tool]] = &[ + &[Tool::Select, Tool::DirectSelect], + &[Tool::Pan], + &[Tool::Rectangle, Tool::Ellipse, Tool::Polygon], + &[Tool::Line, Tool::Arrow], + &[Tool::Text], + ]; + + let mut tool_switch = None; + + let toolbar_bg = Color32::from_rgba_unmultiplied(28, 28, 36, 235); + let border_color = Color32::from_white_alpha(18); + let separator_color = Color32::from_white_alpha(12); + let accent_color = Color32::from_rgb(59, 130, 246); + + egui::Window::new("tools") + .title_bar(false) + .resizable(false) + .anchor(egui::Align2::LEFT_CENTER, [14.0, 0.0]) + .frame( + egui::Frame::none() + .fill(toolbar_bg) + .inner_margin(6.0) + .rounding(12.0) + .stroke(egui::Stroke::new(1.0, border_color)) + .shadow(egui::epaint::Shadow { + offset: egui::vec2(0.0, 4.0), + blur: 16.0, + spread: 2.0, + color: Color32::from_black_alpha(80), + }), + ) + .show(ctx, |ui| { + ui.vertical(|ui| { + ui.spacing_mut().item_spacing = egui::vec2(0.0, 2.0); + + for (group_idx, group) in tool_groups.iter().enumerate() { + // Separator between groups + if group_idx > 0 { + ui.add_space(3.0); + let sep_rect = ui.available_rect_before_wrap(); + let y = sep_rect.top(); + ui.painter().line_segment( + [ + egui::pos2(sep_rect.left() + 6.0, y), + egui::pos2(sep_rect.right() - 6.0, y), + ], + egui::Stroke::new(1.0, separator_color), + ); + ui.add_space(4.0); + } + + for tool in *group { + let is_active = active_tool == *tool; + let shortcut = tool + .shortcut() + .map(|k| format!(" ({})", k)) + .unwrap_or_default(); + + let btn_size = egui::vec2(36.0, 36.0); + let (rect, response) = + ui.allocate_exact_size(btn_size, egui::Sense::click()); + + if ui.is_rect_visible(rect) { + let is_hovered = response.hovered(); + + // Button background + if is_active { + let active_bg = + Color32::from_rgba_unmultiplied(59, 130, 246, 64); + ui.painter().rect_filled(rect, 8.0, active_bg); + // Left accent bar + let bar_rect = egui::Rect::from_min_size( + rect.left_top(), + egui::vec2(2.5, rect.height()), + ); + ui.painter().rect_filled(bar_rect, 1.0, accent_color); + } else if is_hovered { + let hover_bg = Color32::from_white_alpha(18); + ui.painter().rect_filled(rect, 8.0, hover_bg); + } + + // Icon color + let icon_color = if is_active { + Color32::WHITE + } else if is_hovered { + Color32::from_gray(210) + } else { + Color32::from_gray(150) + }; + + // Draw the vector icon + let icon_rect = rect.shrink(7.0); + crate::drawing::draw_tool_icon( + ui.painter(), + icon_rect, + tool, + icon_color, + ); + } + + if response.clicked() { + tool_switch = Some(*tool); + } + response + .on_hover_text(format!("{}{}", tool.label(), shortcut)); + } + } + }); + }); + + if let Some(tool) = tool_switch { + self.set_active_tool(tool); + } + } + if let Some(cmd) = self.command_palette.show(ctx) { self.execute_command(cmd, ctx); } @@ -1606,6 +3703,23 @@ impl eframe::App for AgCanvasApp { self.last_drawing_sync = std::time::Instant::now(); self.sync_drawing_elements_to_store(); + let session = self.active_session(); + let app_state = crate::session::AppStateSnapshot { + active_tool: format!("{:?}", session.active_tool), + selected_element_ids: session.selected_element_ids.clone(), + zoom: session.canvas_state.zoom, + pan_offset_x: session.canvas_state.offset.x, + pan_offset_y: session.canvas_state.offset.y, + theme: self.canvas_theme.label().to_string(), + show_tree_panel: self.show_tree_panel, + show_description_panel: self.show_description, + show_history_panel: self.show_history_panel, + session_name: session.name.clone(), + element_count: session.drawing_elements.len(), + canvas_width: ctx.screen_rect().width(), + canvas_height: ctx.screen_rect().height(), + }; + let sessions_handle = self.sessions_handle.clone(); let counter = self.session_counter; std::thread::spawn(move || { @@ -1613,6 +3727,7 @@ impl eframe::App for AgCanvasApp { rt.block_on(async { let mut store = sessions_handle.write().await; store.set_counter_minimum(counter); + store.set_app_state(app_state); }); }); } @@ -1645,13 +3760,12 @@ fn configure_fonts(ctx: &egui::Context) { ctx.set_style(style); } -fn draw_grid(painter: &egui::Painter, rect: &egui::Rect, state: &CanvasState) { +fn draw_grid(painter: &egui::Painter, rect: &egui::Rect, state: &CanvasState, grid_color: Color32) { let grid_size = 50.0 * state.zoom; if grid_size < 10.0 { return; } - let color = Color32::from_gray(40); let offset = state.offset * state.zoom; let center = rect.center(); @@ -1665,7 +3779,7 @@ fn draw_grid(painter: &egui::Painter, rect: &egui::Rect, state: &CanvasState) { if x >= rect.left() && x <= rect.right() { painter.line_segment( [egui::pos2(x, rect.top()), egui::pos2(x, rect.bottom())], - egui::Stroke::new(1.0, color), + egui::Stroke::new(1.0, grid_color), ); } } @@ -1675,12 +3789,16 @@ fn draw_grid(painter: &egui::Painter, rect: &egui::Rect, state: &CanvasState) { if y >= rect.top() && y <= rect.bottom() { painter.line_segment( [egui::pos2(rect.left(), y), egui::pos2(rect.right(), y)], - egui::Stroke::new(1.0, color), + egui::Stroke::new(1.0, grid_color), ); } } } +fn color_to_rgb(color: Color32) -> [u8; 3] { + [color.r(), color.g(), color.b()] +} + fn render_history_tree( ui: &mut egui::Ui, history: &HistoryTree, @@ -1807,47 +3925,21 @@ fn relative_time_label(timestamp: std::time::Instant) -> String { } } -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 } => { - format!( - "Group{}", - name.as_ref() - .map(|n| format!(" '{}'", n)) - .unwrap_or_default() - ) - } - crate::element_tree::ElementKind::Rectangle { .. } => "Rect".to_string(), - crate::element_tree::ElementKind::Circle { .. } => "Circle".to_string(), - crate::element_tree::ElementKind::Ellipse { .. } => "Ellipse".to_string(), - crate::element_tree::ElementKind::Path { .. } => "Path".to_string(), - crate::element_tree::ElementKind::Text { content, .. } => { - format!( - "Text '{}'", - if content.len() > 15 { - &content[..15] - } else { - content - } - ) - } - crate::element_tree::ElementKind::Image { .. } => "Image".to_string(), - crate::element_tree::ElementKind::Line { .. } => "Line".to_string(), - crate::element_tree::ElementKind::Unknown { tag } => format!("<{}>", tag), - }; +fn render_drawing_tree_ui(ui: &mut egui::Ui, element: &DrawingElement, depth: usize) { + let label = element_label(element); if element.children.is_empty() { ui.horizontal(|ui| { ui.add_space(depth as f32 * 12.0); - ui.label(format!("• {}", kind_name)); + ui.label(format!("• {}", label)); }); } else { - egui::CollapsingHeader::new(kind_name) + egui::CollapsingHeader::new(label) .id_salt(&element.id) .default_open(depth < 2) .show(ui, |ui| { for child in &element.children { - render_tree_ui(ui, child, depth + 1); + render_drawing_tree_ui(ui, child, depth + 1); } }); } diff --git a/crates/agcanvas/src/clipboard.rs b/crates/agcanvas/src/clipboard.rs index bcc5b49..7f54f6b 100644 --- a/crates/agcanvas/src/clipboard.rs +++ b/crates/agcanvas/src/clipboard.rs @@ -20,6 +20,14 @@ impl ClipboardManager { None } } + + pub fn get_image(&mut self) -> Option<(Vec, usize, usize)> { + let img = self.clipboard.get_image().ok()?; + if img.width == 0 || img.height == 0 { + return None; + } + Some((img.bytes.into_owned(), img.width, img.height)) + } } fn is_svg_content(text: &str) -> bool { diff --git a/crates/agcanvas/src/command_palette.rs b/crates/agcanvas/src/command_palette.rs index f0cc37b..ce77fe6 100644 --- a/crates/agcanvas/src/command_palette.rs +++ b/crates/agcanvas/src/command_palette.rs @@ -6,17 +6,36 @@ pub enum CommandId { CloseTab, Undo, Redo, + Duplicate, + ConvertToPath, + Group, + Ungroup, + BringForward, + SendBackward, + BringToFront, + SendToBack, + AlignLeft, + AlignRight, + AlignTop, + AlignBottom, + AlignCenterH, + AlignCenterV, + DistributeH, + DistributeV, SaveWorkspace, ClearCanvas, PasteSvg, PasteMermaid, ExportPng, + ExportSvg, ToolSelect, + ToolDirectSelect, ToolPan, ToolRectangle, ToolEllipse, ToolLine, ToolArrow, + ToolPolygon, ToolText, ResetZoom, FitToView, @@ -55,6 +74,62 @@ pub fn all_commands() -> Vec { Some("Cmd+Shift+Z"), "Edit", ), + PaletteCommand::new( + CommandId::Duplicate, + "Duplicate Selection", + Some("Cmd+D"), + "Edit", + ), + PaletteCommand::new( + CommandId::ConvertToPath, + "Convert to Path", + Some("Cmd+Shift+P"), + "Edit", + ), + PaletteCommand::new(CommandId::Group, "Group Selection", Some("Cmd+G"), "Edit"), + PaletteCommand::new( + CommandId::Ungroup, + "Ungroup Selection", + Some("Cmd+Shift+G"), + "Edit", + ), + PaletteCommand::new( + CommandId::BringForward, + "Bring Forward", + Some("Cmd+]"), + "Edit", + ), + PaletteCommand::new( + CommandId::SendBackward, + "Send Backward", + Some("Cmd+["), + "Edit", + ), + PaletteCommand::new( + CommandId::BringToFront, + "Bring to Front", + Some("Cmd+Shift+]"), + "Edit", + ), + PaletteCommand::new( + CommandId::SendToBack, + "Send to Back", + Some("Cmd+Shift+["), + "Edit", + ), + PaletteCommand::new(CommandId::AlignLeft, "Align Left", None, "Edit"), + PaletteCommand::new(CommandId::AlignRight, "Align Right", None, "Edit"), + PaletteCommand::new(CommandId::AlignTop, "Align Top", None, "Edit"), + PaletteCommand::new(CommandId::AlignBottom, "Align Bottom", None, "Edit"), + PaletteCommand::new(CommandId::AlignCenterH, "Align Center H", None, "Edit"), + PaletteCommand::new(CommandId::AlignCenterV, "Align Center V", None, "Edit"), + PaletteCommand::new( + CommandId::DistributeH, + "Distribute Horizontal", + None, + "Edit", + ), + PaletteCommand::new(CommandId::DistributeV, "Distribute Vertical", None, "Edit"), PaletteCommand::new( CommandId::SaveWorkspace, "Save Workspace", @@ -62,7 +137,7 @@ pub fn all_commands() -> Vec { "Session", ), PaletteCommand::new(CommandId::ClearCanvas, "Clear Canvas", None, "Canvas"), - PaletteCommand::new(CommandId::PasteSvg, "Paste SVG", Some("Cmd+V"), "Canvas"), + PaletteCommand::new(CommandId::PasteSvg, "Paste", Some("Cmd+V"), "Canvas"), PaletteCommand::new( CommandId::PasteMermaid, "Paste Mermaid Diagram", @@ -75,7 +150,19 @@ pub fn all_commands() -> Vec { Some("Cmd+Shift+E"), "Canvas", ), + PaletteCommand::new( + CommandId::ExportSvg, + "Export as SVG", + Some("Cmd+Shift+S"), + "Canvas", + ), PaletteCommand::new(CommandId::ToolSelect, "Select Tool", Some("V"), "Tool"), + PaletteCommand::new( + CommandId::ToolDirectSelect, + "Direct Select Tool", + Some("D"), + "Tool", + ), PaletteCommand::new(CommandId::ToolPan, "Pan Tool", Some("H"), "Tool"), PaletteCommand::new( CommandId::ToolRectangle, @@ -86,6 +173,7 @@ pub fn all_commands() -> Vec { PaletteCommand::new(CommandId::ToolEllipse, "Ellipse Tool", Some("E"), "Tool"), PaletteCommand::new(CommandId::ToolLine, "Line Tool", Some("L"), "Tool"), PaletteCommand::new(CommandId::ToolArrow, "Arrow Tool", Some("A"), "Tool"), + PaletteCommand::new(CommandId::ToolPolygon, "Polygon Tool", Some("P"), "Tool"), PaletteCommand::new(CommandId::ToolText, "Text Tool", Some("T"), "Tool"), PaletteCommand::new(CommandId::ResetZoom, "Reset Zoom", Some("Cmd+0"), "View"), PaletteCommand::new(CommandId::FitToView, "Fit to View", None, "View"), @@ -367,7 +455,7 @@ mod tests { palette.open(); palette.query = "rect".to_string(); palette.update_filter(); - assert!(palette.filtered.len() >= 1); + assert!(!palette.filtered.is_empty()); let matched: Vec<_> = palette .filtered .iter() diff --git a/crates/agcanvas/src/drawing/boolean.rs b/crates/agcanvas/src/drawing/boolean.rs index 7c4d88f..63bbef0 100644 --- a/crates/agcanvas/src/drawing/boolean.rs +++ b/crates/agcanvas/src/drawing/boolean.rs @@ -1,4 +1,4 @@ -use super::element::{DrawingElement, PathPolygon, Shape}; +use super::element::{polygon_vertices, DrawingElement, PathPolygon, Shape}; use egui::Pos2; use i_overlay::core::fill_rule::FillRule; use i_overlay::core::overlay_rule::OverlayRule; @@ -34,9 +34,24 @@ pub fn shape_to_contour(shape: &Shape) -> Result, String> { ] }) .collect()), + Shape::Polygon { + center, + radius, + sides, + star_inner_ratio, + } => Ok( + polygon_vertices(*center, *radius, *sides, *star_inner_ratio) + .into_iter() + .map(|point| [point.x as f64, point.y as f64]) + .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()) + Shape::Line { .. } + | Shape::Arrow { .. } + | Shape::Text { .. } + | Shape::SvgImage { .. } + | Shape::Group => { + Err("Boolean operations only support Rectangle, Ellipse, and Polygon".to_string()) } } } diff --git a/crates/agcanvas/src/drawing/element.rs b/crates/agcanvas/src/drawing/element.rs index e69b237..04dfe3e 100644 --- a/crates/agcanvas/src/drawing/element.rs +++ b/crates/agcanvas/src/drawing/element.rs @@ -1,8 +1,9 @@ -use egui::{Color32, Pos2}; +use egui::{Color32, Pos2, Vec2}; use serde::{Deserialize, Serialize}; use std::sync::atomic::{AtomicUsize, Ordering}; static DRAWING_ID_COUNTER: AtomicUsize = AtomicUsize::new(0); +pub const ARROW_CURVE_SEGMENTS: usize = 20; pub fn generate_drawing_id() -> String { format!("draw_{}", DRAWING_ID_COUNTER.fetch_add(1, Ordering::SeqCst)) @@ -11,8 +12,12 @@ pub fn generate_drawing_id() -> String { #[derive(Debug, Clone, Serialize, Deserialize)] pub struct DrawingElement { pub id: String, + #[serde(default)] + pub group_id: Option, pub shape: Shape, pub style: ShapeStyle, + #[serde(default)] + pub children: Vec, } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -25,8 +30,10 @@ impl DrawingElement { pub fn new(shape: Shape, style: ShapeStyle) -> Self { Self { id: generate_drawing_id(), + group_id: None, shape, style, + children: Vec::new(), } } @@ -37,18 +44,37 @@ impl DrawingElement { Shape::Ellipse { center, radii } => { egui::Rect::from_center_size(*center, egui::vec2(radii.x * 2.0, radii.y * 2.0)) } - Shape::Line { start, end } | Shape::Arrow { start, end } => { - egui::Rect::from_two_pos(*start, *end) + Shape::Line { start, end } => egui::Rect::from_two_pos(*start, *end), + Shape::Arrow { + start, + end, + control_offset, + } => { + if let Some(control_point) = arrow_control_point(*start, *end, *control_offset) { + rect_from_points(&[*start, control_point, *end]) + .unwrap_or_else(|| egui::Rect::from_two_pos(*start, *end)) + } else { + egui::Rect::from_two_pos(*start, *end) + } + } + Shape::Polygon { + center, + radius, + sides, + star_inner_ratio, + } => { + let vertices = polygon_vertices(*center, *radius, *sides, *star_inner_ratio); + rect_from_points(&vertices) + .unwrap_or_else(|| egui::Rect::from_center_size(*center, egui::Vec2::ZERO)) } Shape::Text { pos, - content: _, + content, font_size, + max_width, } => { - // Approximate: we'll refine during rendering when we know actual text size. - let approx_width = 8.0 * font_size * 0.6; - let approx_height = *font_size * 1.4; - egui::Rect::from_min_size(*pos, egui::vec2(approx_width, approx_height)) + let (w, h) = estimate_text_bounds(content, *font_size, *max_width); + egui::Rect::from_min_size(*pos, egui::vec2(w, h)) } Shape::Path { polygons } => { let mut min_x = f32::INFINITY; @@ -72,6 +98,8 @@ impl DrawingElement { egui::Rect::from_min_size(Pos2::ZERO, egui::Vec2::ZERO) } } + Shape::SvgImage { pos, size, .. } => egui::Rect::from_min_size(*pos, *size), + Shape::Group => children_bounding_rect(&self.children), } } @@ -91,17 +119,49 @@ impl DrawingElement { let ry = radii.y + tolerance; (dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1.0 } - Shape::Line { start, end } | Shape::Arrow { start, end } => { + Shape::Line { start, end } => { point_to_segment_distance(point, *start, *end) <= tolerance } + Shape::Arrow { + start, + end, + control_offset, + } => { + if let Some(control_point) = arrow_control_point(*start, *end, *control_offset) { + point_to_bezier_distance( + point, + *start, + control_point, + *end, + ARROW_CURVE_SEGMENTS, + ) <= tolerance + } else { + point_to_segment_distance(point, *start, *end) <= tolerance + } + } + Shape::Polygon { + center, + radius, + sides, + star_inner_ratio, + } => { + let vertices = polygon_vertices(*center, *radius, *sides, *star_inner_ratio); + point_in_polygon(point, &vertices) + || vertices + .iter() + .zip(vertices.iter().cycle().skip(1)) + .take(vertices.len()) + .map(|(a, b)| (*a, *b)) + .any(|(a, b)| point_to_segment_distance(point, a, b) <= tolerance) + } Shape::Text { pos, - content: _, + content, font_size, + max_width, } => { - let approx_width = 8.0 * font_size * 0.6; - let approx_height = *font_size * 1.4; - let rect = egui::Rect::from_min_size(*pos, egui::vec2(approx_width, approx_height)); + let (w, h) = estimate_text_bounds(content, *font_size, *max_width); + let rect = egui::Rect::from_min_size(*pos, egui::vec2(w, h)); rect.expand(tolerance).contains(point) } Shape::Path { polygons } => polygons.iter().any(|polygon| { @@ -111,6 +171,11 @@ impl DrawingElement { .iter() .any(|hole| point_in_polygon(point, hole)) }), + Shape::SvgImage { pos, size, .. } => { + let rect = egui::Rect::from_min_size(*pos, *size); + rect.expand(tolerance).contains(point) + } + Shape::Group => self.children.iter().any(|c| c.contains_point(point)), } } @@ -119,10 +184,15 @@ impl DrawingElement { match &mut self.shape { Shape::Rectangle { pos, .. } => *pos += delta, Shape::Ellipse { center, .. } => *center += delta, - Shape::Line { start, end } | Shape::Arrow { start, end } => { + Shape::Line { start, end } => { *start += delta; *end += delta; } + Shape::Arrow { start, end, .. } => { + *start += delta; + *end += delta; + } + Shape::Polygon { center, .. } => *center += delta, Shape::Text { pos, .. } => *pos += delta, Shape::Path { polygons } => { for polygon in polygons { @@ -136,6 +206,12 @@ impl DrawingElement { } } } + Shape::SvgImage { pos, .. } => *pos += delta, + Shape::Group => { + for child in &mut self.children { + child.translate(delta); + } + } } } @@ -151,10 +227,42 @@ impl DrawingElement { *center = new_rect.center(); *radii = egui::vec2(new_rect.width() / 2.0, new_rect.height() / 2.0); } - Shape::Line { start, end } | Shape::Arrow { start, end } => { + Shape::Line { start, end } => { *start = new_rect.min; *end = new_rect.max; } + Shape::Arrow { + start, + end, + control_offset, + } => { + *start = new_rect.min; + *end = new_rect.max; + + if let Some(offset) = control_offset.as_mut() { + let old_w = old_rect.width(); + let old_h = old_rect.height(); + let scale_x = if old_w.abs() <= f32::EPSILON { + 1.0 + } else { + new_rect.width() / old_w + }; + let scale_y = if old_h.abs() <= f32::EPSILON { + 1.0 + } else { + new_rect.height() / old_h + }; + *offset = egui::vec2(offset.x * scale_x, offset.y * scale_y); + } + } + Shape::Polygon { center, radius, .. } => { + *center = new_rect.center(); + let old_diameter = (old_rect.width().abs() + old_rect.height().abs()) / 2.0; + let new_diameter = (new_rect.width().abs() + new_rect.height().abs()) / 2.0; + if old_diameter > f32::EPSILON { + *radius *= new_diameter / old_diameter; + } + } Shape::Text { pos, .. } => { *pos = new_rect.min; } @@ -173,8 +281,90 @@ impl DrawingElement { } } } + Shape::SvgImage { + pos, + size, + aspect_ratio, + .. + } => { + let new_w = new_rect.width(); + let new_h = new_w / *aspect_ratio; + *pos = new_rect.min; + *size = egui::vec2(new_w, new_h); + } + Shape::Group => { + let old_w = old_rect.width(); + let old_h = old_rect.height(); + for child in &mut self.children { + let child_rect = child.bounding_rect(); + let mapped = egui::Rect::from_min_max( + map_point_to_rect(child_rect.min, old_rect, new_rect, old_w, old_h), + map_point_to_rect(child_rect.max, old_rect, new_rect, old_w, old_h), + ); + child.resize_to(mapped); + } + } } } + + pub fn to_path(&self) -> Option { + const ELLIPSE_SEGMENTS: usize = 64; + + let polygon = match &self.shape { + Shape::Rectangle { pos, size } => { + let rect = egui::Rect::from_min_size(*pos, *size); + PathPolygon { + exterior: vec![ + rect.left_top(), + rect.right_top(), + rect.right_bottom(), + rect.left_bottom(), + ], + holes: Vec::new(), + } + } + Shape::Ellipse { center, radii } => PathPolygon { + exterior: (0..ELLIPSE_SEGMENTS) + .map(|i| { + let angle = i as f32 / ELLIPSE_SEGMENTS as f32 * std::f32::consts::TAU; + Pos2::new( + center.x + radii.x * angle.cos(), + center.y + radii.y * angle.sin(), + ) + }) + .collect(), + holes: Vec::new(), + }, + Shape::Polygon { + center, + radius, + sides, + star_inner_ratio, + } => PathPolygon { + exterior: polygon_vertices(*center, *radius, *sides, *star_inner_ratio), + holes: Vec::new(), + }, + Shape::Line { start, end } => PathPolygon { + exterior: vec![*start, *end], + holes: Vec::new(), + }, + Shape::Arrow { + start, + end, + control_offset, + } => PathPolygon { + exterior: arrow_curve_points(*start, *end, *control_offset, ARROW_CURVE_SEGMENTS), + holes: Vec::new(), + }, + Shape::Text { .. } | Shape::Path { .. } | Shape::SvgImage { .. } | Shape::Group => { + return None; + } + }; + + Some(Shape::Path { + polygons: vec![polygon], + }) + } } #[derive(Debug, Clone, Serialize, Deserialize)] @@ -195,22 +385,200 @@ pub enum Shape { Arrow { start: Pos2, end: Pos2, + #[serde(default)] + control_offset: Option, + }, + Polygon { + center: Pos2, + radius: f32, + sides: u32, + star_inner_ratio: Option, }, Text { pos: Pos2, content: String, font_size: f32, + #[serde(default)] + max_width: Option, }, Path { polygons: Vec, }, + SvgImage { + pos: Pos2, + size: egui::Vec2, + aspect_ratio: f32, + #[serde(default)] + svg_source: String, + }, + Group, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +pub struct GradientStop { + pub offset: f32, + pub color: Color32, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum Fill { + Solid { + color: Color32, + }, + LinearGradient { + angle_deg: f32, + stops: Vec, + }, + RadialGradient { + stops: Vec, + }, +} + +impl Fill { + /// Create a solid fill from a Color32. + pub fn solid(color: Color32) -> Self { + Fill::Solid { color } + } + + /// Return the primary/first color for contexts that need a single color + /// (e.g. color picker, fallback rendering). + pub fn primary_color(&self) -> Color32 { + match self { + Fill::Solid { color } => *color, + Fill::LinearGradient { stops, .. } | Fill::RadialGradient { stops } => { + stops.first().map(|s| s.color).unwrap_or(Color32::WHITE) + } + } + } + + /// Compute the gradient color at a position within a bounding rect. + /// `pos` is in the same coordinate space as `bounds`. + /// Returns a solid color for Solid fills. + pub fn color_at(&self, pos: Pos2, bounds: egui::Rect) -> Color32 { + match self { + Fill::Solid { color } => *color, + Fill::LinearGradient { angle_deg, stops } => { + if stops.is_empty() { + return Color32::TRANSPARENT; + } + if stops.len() == 1 { + return stops[0].color; + } + let angle = angle_deg.to_radians(); + let center = bounds.center(); + let dx = pos.x - center.x; + let dy = pos.y - center.y; + // Project onto gradient axis + let half_w = bounds.width() * 0.5; + let half_h = bounds.height() * 0.5; + let max_proj = (half_w * angle.cos().abs() + half_h * angle.sin().abs()).max(1.0); + let proj = dx * angle.cos() + dy * angle.sin(); + let t = ((proj / max_proj) * 0.5 + 0.5).clamp(0.0, 1.0); + lerp_stops(stops, t) + } + Fill::RadialGradient { stops } => { + if stops.is_empty() { + return Color32::TRANSPARENT; + } + if stops.len() == 1 { + return stops[0].color; + } + let center = bounds.center(); + let dx = pos.x - center.x; + let dy = pos.y - center.y; + let half_w = bounds.width() * 0.5; + let half_h = bounds.height() * 0.5; + let max_r = (half_w * half_w + half_h * half_h).sqrt().max(1.0); + let dist = (dx * dx + dy * dy).sqrt(); + let t = (dist / max_r).clamp(0.0, 1.0); + lerp_stops(stops, t) + } + } + } +} + +fn lerp_stops(stops: &[GradientStop], t: f32) -> Color32 { + if t <= stops[0].offset { + return stops[0].color; + } + if t >= stops[stops.len() - 1].offset { + return stops[stops.len() - 1].color; + } + for window in stops.windows(2) { + let a = &window[0]; + let b = &window[1]; + if t >= a.offset && t <= b.offset { + let range = b.offset - a.offset; + let local_t = if range > f32::EPSILON { + (t - a.offset) / range + } else { + 0.0 + }; + return lerp_color(a.color, b.color, local_t); + } + } + stops[stops.len() - 1].color +} + +fn lerp_color(a: Color32, b: Color32, t: f32) -> Color32 { + Color32::from_rgba_unmultiplied( + (a.r() as f32 + (b.r() as f32 - a.r() as f32) * t) as u8, + (a.g() as f32 + (b.g() as f32 - a.g() as f32) * t) as u8, + (a.b() as f32 + (b.b() as f32 - a.b() as f32) * t) as u8, + (a.a() as f32 + (b.a() as f32 - a.a() as f32) * t) as u8, + ) } #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ShapeStyle { - pub fill: Option, + #[serde(deserialize_with = "deserialize_fill_compat")] + pub fill: Option, pub stroke_color: Color32, pub stroke_width: f32, + #[serde(default = "default_opacity")] + pub opacity: f32, + #[serde(default)] + pub rotation_degrees: f32, + #[serde(default)] + pub corner_radius: f32, + #[serde(default)] + pub font_family: Option, + #[serde(default)] + pub stroke_dash: Option<(f32, f32)>, +} + +fn deserialize_fill_compat<'de, D>(deserializer: D) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de; + use serde_json::Value; + + let value: Option = Option::deserialize(deserializer)?; + match value { + None => Ok(None), + Some(Value::Array(arr)) => { + let rgba: Vec = arr + .iter() + .filter_map(|v| v.as_u64().map(|n| n as u8)) + .collect(); + if rgba.len() == 4 { + Ok(Some(Fill::solid(Color32::from_rgba_premultiplied( + rgba[0], rgba[1], rgba[2], rgba[3], + )))) + } else { + Err(de::Error::custom("expected 4-element color array")) + } + } + Some(obj @ Value::Object(_)) => serde_json::from_value::(obj) + .map(Some) + .map_err(de::Error::custom), + Some(other) => Err(de::Error::custom(format!( + "expected null, array, or object for fill, got: {}", + other + ))), + } } impl Default for ShapeStyle { @@ -219,10 +587,125 @@ impl Default for ShapeStyle { fill: None, stroke_color: Color32::WHITE, stroke_width: 2.0, + opacity: 1.0, + rotation_degrees: 0.0, + corner_radius: 0.0, + font_family: None, + stroke_dash: None, } } } +fn default_opacity() -> f32 { + 1.0 +} + +pub fn polygon_vertices( + center: Pos2, + radius: f32, + sides: u32, + star_inner_ratio: Option, +) -> Vec { + let side_count = sides.max(3) as usize; + let start_angle = -std::f32::consts::FRAC_PI_2; + + match star_inner_ratio { + Some(inner_ratio) => { + let clamped_inner = radius * inner_ratio.clamp(0.0, 1.0); + let steps = side_count * 2; + (0..steps) + .map(|i| { + let angle = start_angle + i as f32 * std::f32::consts::TAU / steps as f32; + let r = if i % 2 == 0 { radius } else { clamped_inner }; + Pos2::new(center.x + r * angle.cos(), center.y + r * angle.sin()) + }) + .collect() + } + None => (0..side_count) + .map(|i| { + let angle = start_angle + i as f32 * std::f32::consts::TAU / side_count as f32; + Pos2::new( + center.x + radius * angle.cos(), + center.y + radius * angle.sin(), + ) + }) + .collect(), + } +} + +fn children_bounding_rect(children: &[DrawingElement]) -> egui::Rect { + if children.is_empty() { + return egui::Rect::from_min_size(Pos2::ZERO, egui::Vec2::ZERO); + } + let mut result = children[0].bounding_rect(); + for child in children.iter().skip(1) { + result = result.union(child.bounding_rect()); + } + result +} + +pub fn arrow_midpoint(start: Pos2, end: Pos2) -> Pos2 { + Pos2::new((start.x + end.x) * 0.5, (start.y + end.y) * 0.5) +} + +pub fn arrow_handle_position(start: Pos2, end: Pos2, control_offset: Option) -> Pos2 { + arrow_midpoint(start, end) + control_offset.unwrap_or(Vec2::ZERO) +} + +pub fn arrow_control_point(start: Pos2, end: Pos2, control_offset: Option) -> Option { + control_offset.map(|offset| arrow_midpoint(start, end) + offset) +} + +pub fn quadratic_bezier_point(p0: Pos2, p1: Pos2, p2: Pos2, t: f32) -> Pos2 { + let inv = 1.0 - t; + Pos2::new( + inv * inv * p0.x + 2.0 * inv * t * p1.x + t * t * p2.x, + inv * inv * p0.y + 2.0 * inv * t * p1.y + t * t * p2.y, + ) +} + +pub fn quadratic_bezier_end_tangent(start: Pos2, end: Pos2, control_offset: Option) -> Vec2 { + if let Some(control) = arrow_control_point(start, end, control_offset) { + (end - control) * 2.0 + } else { + end - start + } +} + +pub fn arrow_curve_points( + start: Pos2, + end: Pos2, + control_offset: Option, + segments: usize, +) -> Vec { + if let Some(control) = arrow_control_point(start, end, control_offset) { + let steps = segments.max(1); + (0..=steps) + .map(|index| { + let t = index as f32 / steps as f32; + quadratic_bezier_point(start, control, end, t) + }) + .collect() + } else { + vec![start, end] + } +} + +fn point_to_bezier_distance(point: Pos2, p0: Pos2, p1: Pos2, p2: Pos2, samples: usize) -> f32 { + let steps = samples.max(1); + let mut min_distance = f32::INFINITY; + let mut previous = p0; + + for index in 1..=steps { + let t = index as f32 / steps as f32; + let current = quadratic_bezier_point(p0, p1, p2, t); + min_distance = min_distance.min(point_to_segment_distance(point, previous, current)); + previous = current; + } + + min_distance +} + /// Distance from point `p` to segment `a`–`b`. fn point_to_segment_distance(p: Pos2, a: Pos2, b: Pos2) -> f32 { let ab = b - a; @@ -272,6 +755,46 @@ 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 rect_from_points(points: &[Pos2]) -> Option { + if points.is_empty() { + return None; + } + + 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 point in points { + 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() { + Some(egui::Rect::from_min_max( + Pos2::new(min_x, min_y), + Pos2::new(max_x, max_y), + )) + } else { + None + } +} + +fn estimate_text_bounds(content: &str, font_size: f32, max_width: Option) -> (f32, f32) { + let char_count = content.chars().count().max(1) as f32; + let text_width = char_count * font_size * 0.6; + + if let Some(max_width) = max_width { + let wrapped_width = max_width.max(font_size); + let line_count = (text_width / wrapped_width).ceil().max(1.0); + (wrapped_width, line_count * font_size * 1.4) + } else { + (text_width.max(font_size), font_size * 1.4) + } +} + fn map_point_to_rect( point: Pos2, old_rect: egui::Rect, @@ -296,6 +819,86 @@ fn map_point_to_rect( ) } +pub fn element_label(element: &DrawingElement) -> String { + match &element.shape { + Shape::Rectangle { size, .. } => { + format!("Rect ({}x{})", size.x as i32, size.y as i32) + } + Shape::Ellipse { radii, .. } => { + format!( + "Ellipse ({}x{})", + (radii.x * 2.0) as i32, + (radii.y * 2.0) as i32 + ) + } + Shape::Line { .. } => "Line".to_string(), + Shape::Arrow { .. } => "Arrow".to_string(), + Shape::Polygon { + sides, + star_inner_ratio, + .. + } => { + if star_inner_ratio.is_some() { + format!("Star ({}-point)", sides) + } else { + format!("Polygon ({}-sided)", sides) + } + } + Shape::Text { content, .. } => { + let display = if content.len() > 20 { + format!("{}...", &content[..17]) + } else { + content.clone() + }; + format!("Text '{}'", display) + } + Shape::Path { polygons } => { + format!("Path ({} sub-paths)", polygons.len()) + } + Shape::SvgImage { + size, svg_source, .. + } => { + if svg_source.is_empty() { + format!("Image ({}x{})", size.x as i32, size.y as i32) + } else { + format!("SVG Image ({}x{})", size.x as i32, size.y as i32) + } + } + Shape::Group => format!("Group ({} children)", element.children.len()), + } +} + +pub fn describe_elements(elements: &[DrawingElement]) -> String { + let mut desc = String::new(); + desc.push_str(&format!( + "Canvas: {} top-level elements\n\n", + elements.len() + )); + for element in elements { + describe_element_recursive(&mut desc, element, 0); + } + desc +} + +fn describe_element_recursive(desc: &mut String, element: &DrawingElement, depth: usize) { + let indent = " ".repeat(depth); + let label = element_label(element); + + let style_info = match &element.style.fill { + Some(fill) => { + let c = fill.primary_color(); + format!(" fill=#{:02x}{:02x}{:02x}", c.r(), c.g(), c.b()) + } + None => String::new(), + }; + + desc.push_str(&format!("{}- {}{}\n", indent, label, style_info)); + + for child in &element.children { + describe_element_recursive(desc, child, depth + 1); + } +} + #[cfg(test)] mod tests { use super::*; @@ -379,4 +982,26 @@ mod tests { ); assert!((dist - 5.0).abs() < 0.01); } + + #[test] + fn fill_deserializes_from_legacy_color_array() { + let json = r#"{"fill":[255,0,0,255],"stroke_color":[255,255,255,255],"stroke_width":2.0,"opacity":1.0}"#; + let style: ShapeStyle = serde_json::from_str(json).expect("should deserialize old format"); + assert!(matches!(style.fill, Some(Fill::Solid { .. }))); + } + + #[test] + fn fill_deserializes_from_new_tagged_enum() { + let json = r#"{"fill":{"type":"Solid","color":[0,128,255,255]},"stroke_color":[255,255,255,255],"stroke_width":2.0,"opacity":1.0}"#; + let style: ShapeStyle = serde_json::from_str(json).expect("should deserialize new format"); + assert!(matches!(style.fill, Some(Fill::Solid { .. }))); + } + + #[test] + fn fill_deserializes_null() { + let json = + r#"{"fill":null,"stroke_color":[255,255,255,255],"stroke_width":2.0,"opacity":1.0}"#; + let style: ShapeStyle = serde_json::from_str(json).expect("should deserialize null fill"); + assert!(style.fill.is_none()); + } } diff --git a/crates/agcanvas/src/drawing/mod.rs b/crates/agcanvas/src/drawing/mod.rs index db2ac9c..b782128 100644 --- a/crates/agcanvas/src/drawing/mod.rs +++ b/crates/agcanvas/src/drawing/mod.rs @@ -4,9 +4,14 @@ mod render; mod tool; pub use boolean::BooleanOpType; -pub use element::{DrawingElement, Shape, ShapeStyle}; +pub use element::{ + arrow_control_point, arrow_curve_points, arrow_midpoint, describe_elements, element_label, + generate_drawing_id, polygon_vertices, quadratic_bezier_end_tangent, DrawingElement, Fill, + GradientStop, PathPolygon, Shape, ShapeStyle, ARROW_CURVE_SEGMENTS, +}; pub use render::{ - draw_creation_preview, draw_elements, draw_marquee, draw_selection, find_handle_at_screen_pos, - screen_to_canvas, + draw_arrow_control_handle, draw_creation_preview, draw_elements, draw_marquee, draw_selection, + draw_tool_icon, draw_vertex_handles, find_arrow_control_handle_at_screen_pos, + find_handle_at_screen_pos, screen_to_canvas, }; pub use tool::{DragState, Tool}; diff --git a/crates/agcanvas/src/drawing/render.rs b/crates/agcanvas/src/drawing/render.rs index 79fbcdc..3044e42 100644 --- a/crates/agcanvas/src/drawing/render.rs +++ b/crates/agcanvas/src/drawing/render.rs @@ -1,10 +1,18 @@ -use super::element::{DrawingElement, PathPolygon, Shape}; +use super::element::{ + arrow_handle_position, arrow_midpoint, polygon_vertices, quadratic_bezier_end_tangent, + quadratic_bezier_point, DrawingElement, PathPolygon, Shape, ARROW_CURVE_SEGMENTS, +}; use super::tool::{DragState, ResizeHandle, Tool}; -use egui::{Color32, Painter, Pos2, Stroke, Vec2}; +use egui::{Color32, FontFamily, Painter, Pos2, Stroke, Vec2}; +use std::collections::HashMap; const HANDLE_RADIUS: f32 = 4.0; const SELECTION_COLOR: Color32 = Color32::from_rgb(59, 130, 246); const CREATION_PREVIEW_COLOR: Color32 = Color32::from_rgba_premultiplied(59, 130, 246, 128); +const VERTEX_HANDLE_COLOR: Color32 = Color32::WHITE; +const VERTEX_SELECTED_COLOR: Color32 = Color32::from_rgb(255, 196, 64); +const VERTEX_MIDPOINT_COLOR: Color32 = Color32::from_rgb(148, 163, 184); +const ARROW_CONTROL_HANDLE_COLOR: Color32 = Color32::from_rgb(56, 189, 248); pub fn draw_elements( painter: &Painter, @@ -12,9 +20,10 @@ pub fn draw_elements( canvas_center: Pos2, offset: Vec2, zoom: f32, + svg_textures: Option<&HashMap>, ) { for element in elements { - draw_element(painter, element, canvas_center, offset, zoom); + draw_element(painter, element, canvas_center, offset, zoom, svg_textures); } } @@ -24,88 +33,265 @@ pub fn draw_element( canvas_center: Pos2, offset: Vec2, zoom: f32, + svg_textures: Option<&HashMap>, ) { let style = &element.style; - let stroke = Stroke::new(style.stroke_width * zoom, style.stroke_color); + let opacity = style.opacity.clamp(0.0, 1.0); + let stroke_color = apply_opacity(style.stroke_color, opacity); + let stroke = Stroke::new(style.stroke_width * zoom, stroke_color); match &element.shape { Shape::Rectangle { pos, size } => { let screen_pos = canvas_to_screen(*pos, canvas_center, offset, zoom); let screen_size = *size * zoom; let rect = egui::Rect::from_min_size(screen_pos, screen_size); + let rotation = style.rotation_degrees.rem_euclid(360.0); - if let Some(fill) = style.fill { - painter.rect_filled(rect, 0.0, fill); + if rotation.abs() <= f32::EPSILON { + let corner_radius = style.corner_radius.max(0.0) * zoom; + if let Some(fill) = &style.fill { + draw_rect_fill(painter, rect, corner_radius, fill, opacity, element); + } + draw_stroke_or_dashed( + painter, + &[ + rect.left_top(), + rect.right_top(), + rect.right_bottom(), + rect.left_bottom(), + ], + true, + stroke, + style.stroke_dash.map(|(d, g)| (d * zoom, g * zoom)), + ); + } else { + let center = rect.center(); + let corners = vec![ + rotate_point(rect.left_top(), center, rotation), + rotate_point(rect.right_top(), center, rotation), + rotate_point(rect.right_bottom(), center, rotation), + rotate_point(rect.left_bottom(), center, rotation), + ]; + + if let Some(fill) = &style.fill { + draw_gradient_fan_fill( + painter, + center, + &corners, + fill, + opacity, + element.bounding_rect(), + ); + } + draw_stroke_or_dashed( + painter, + &corners, + true, + stroke, + style.stroke_dash.map(|(d, g)| (d * zoom, g * zoom)), + ); } - painter.rect_stroke(rect, 0.0, stroke); } Shape::Ellipse { center, radii } => { let screen_center = canvas_to_screen(*center, canvas_center, offset, zoom); let screen_radii = *radii * zoom; + let points = ellipse_points(screen_center, screen_radii, style.rotation_degrees, 64); - if let Some(fill) = style.fill { - painter.circle_filled(screen_center, screen_radii.x.min(screen_radii.y), fill); + if let Some(fill) = &style.fill { + draw_gradient_fan_fill( + painter, + screen_center, + &points, + fill, + opacity, + element.bounding_rect(), + ); } - - let n_points = 64; - let points: Vec = (0..=n_points) - .map(|i| { - let angle = i as f32 / n_points as f32 * std::f32::consts::TAU; - Pos2::new( - screen_center.x + screen_radii.x * angle.cos(), - screen_center.y + screen_radii.y * angle.sin(), - ) - }) - .collect(); - painter.add(egui::Shape::line(points, stroke)); + draw_stroke_or_dashed( + painter, + &points, + true, + stroke, + style.stroke_dash.map(|(d, g)| (d * zoom, g * zoom)), + ); } Shape::Line { start, end } => { let s = canvas_to_screen(*start, canvas_center, offset, zoom); let e = canvas_to_screen(*end, canvas_center, offset, zoom); - painter.line_segment([s, e], stroke); + draw_line_or_dashed( + painter, + s, + e, + stroke, + style.stroke_dash.map(|(d, g)| (d * zoom, g * zoom)), + ); } - Shape::Arrow { start, end } => { + Shape::Arrow { + start, + end, + control_offset, + } => { let s = canvas_to_screen(*start, canvas_center, offset, zoom); let e = canvas_to_screen(*end, canvas_center, offset, zoom); - painter.line_segment([s, e], stroke); - draw_arrowhead(painter, s, e, stroke); + if let Some(control_offset) = control_offset { + let control = canvas_to_screen( + arrow_midpoint(*start, *end) + *control_offset, + canvas_center, + offset, + zoom, + ); + let points: Vec = (0..=ARROW_CURVE_SEGMENTS) + .map(|index| { + let t = index as f32 / ARROW_CURVE_SEGMENTS as f32; + quadratic_bezier_point(s, control, e, t) + }) + .collect(); + draw_stroke_or_dashed( + painter, + &points, + false, + stroke, + style.stroke_dash.map(|(d, g)| (d * zoom, g * zoom)), + ); + draw_arrowhead( + painter, + e, + quadratic_bezier_end_tangent(*start, *end, Some(*control_offset)), + stroke, + ); + } else { + draw_line_or_dashed( + painter, + s, + e, + stroke, + style.stroke_dash.map(|(d, g)| (d * zoom, g * zoom)), + ); + draw_arrowhead(painter, e, e - s, stroke); + } } Shape::Text { pos, content, font_size, + max_width, } => { - let screen_pos = canvas_to_screen(*pos, canvas_center, offset, zoom); + let mut screen_pos = canvas_to_screen(*pos, canvas_center, offset, zoom); + let text_rect = element.bounding_rect(); + let text_center = canvas_to_screen(text_rect.center(), canvas_center, offset, zoom); + screen_pos = rotate_point(screen_pos, text_center, style.rotation_degrees); let scaled_font_size = font_size * zoom; - painter.text( - screen_pos, - egui::Align2::LEFT_TOP, - content, - egui::FontId::proportional(scaled_font_size), - style.stroke_color, + let font_family = if matches!(style.font_family.as_deref(), Some(name) if name.eq_ignore_ascii_case("monospace")) + { + FontFamily::Monospace + } else { + FontFamily::Proportional + }; + + let wrap_width = max_width.map(|mw| mw * zoom).unwrap_or(f32::INFINITY); + + let mut job = egui::text::LayoutJob::single_section( + content.clone(), + egui::TextFormat { + font_id: egui::FontId::new(scaled_font_size, font_family), + color: stroke_color, + ..Default::default() + }, + ); + job.wrap = egui::text::TextWrapping { + max_width: wrap_width, + break_anywhere: false, + ..Default::default() + }; + let galley = painter.layout_job(job); + painter.galley(screen_pos, galley, stroke_color); + } + Shape::Polygon { + center, + radius, + sides, + star_inner_ratio, + } => { + let canvas_vertices = polygon_vertices(*center, *radius, *sides, *star_inner_ratio); + if canvas_vertices.len() < 3 { + return; + } + + let mut screen_vertices: Vec = canvas_vertices + .iter() + .map(|point| canvas_to_screen(*point, canvas_center, offset, zoom)) + .collect(); + let screen_center = canvas_to_screen(*center, canvas_center, offset, zoom); + for point in &mut screen_vertices { + *point = rotate_point(*point, screen_center, style.rotation_degrees); + } + + if let Some(fill) = &style.fill { + draw_gradient_fan_fill( + painter, + screen_center, + &screen_vertices, + fill, + opacity, + element.bounding_rect(), + ); + } + draw_stroke_or_dashed( + painter, + &screen_vertices, + true, + stroke, + style.stroke_dash.map(|(d, g)| (d * zoom, g * zoom)), ); } Shape::Path { polygons } => { - if let Some(fill) = style.fill { + if let Some(fill) = &style.fill { for polygon in polygons { - draw_path_fill(painter, polygon, canvas_center, offset, zoom, fill); + draw_gradient_path_fill( + painter, + polygon, + canvas_center, + offset, + zoom, + fill, + opacity, + element.bounding_rect(), + ); } } + let dash = style.stroke_dash.map(|(d, g)| (d * zoom, g * zoom)); for polygon in polygons { - draw_closed_ring( - painter, - &polygon.exterior, - canvas_center, - offset, - zoom, - stroke, - ); + let screen_pts: Vec = polygon + .exterior + .iter() + .map(|p| canvas_to_screen(*p, canvas_center, offset, zoom)) + .collect(); + draw_stroke_or_dashed(painter, &screen_pts, true, stroke, dash); for hole in &polygon.holes { - draw_closed_ring(painter, hole, canvas_center, offset, zoom, stroke); + let hole_pts: Vec = hole + .iter() + .map(|p| canvas_to_screen(*p, canvas_center, offset, zoom)) + .collect(); + draw_stroke_or_dashed(painter, &hole_pts, true, stroke, dash); } } } + Shape::SvgImage { pos, size, .. } => { + if let Some(texture) = svg_textures.and_then(|t| t.get(&element.id)) { + let screen_pos = canvas_to_screen(*pos, canvas_center, offset, zoom); + let screen_size = *size * zoom; + let rect = egui::Rect::from_min_size(screen_pos, screen_size); + let uv = egui::Rect::from_min_max(Pos2::new(0.0, 0.0), Pos2::new(1.0, 1.0)); + let tint = apply_opacity(Color32::WHITE, opacity); + painter.image(texture.id(), rect, uv, tint); + } + } + Shape::Group => { + for child in &element.children { + draw_element(painter, child, canvas_center, offset, zoom, svg_textures); + } + } } } @@ -134,6 +320,85 @@ pub fn draw_selection( } } +pub fn draw_vertex_handles( + painter: &Painter, + element: &DrawingElement, + canvas_center: Pos2, + offset: Vec2, + zoom: f32, + selected_vertex: Option<(usize, usize, bool)>, +) { + let Shape::Path { polygons } = &element.shape else { + return; + }; + + for (polygon_idx, polygon) in polygons.iter().enumerate() { + draw_ring_vertex_handles( + painter, + &polygon.exterior, + (polygon_idx, false), + (canvas_center, offset, zoom), + selected_vertex, + ); + for hole in &polygon.holes { + draw_ring_vertex_handles( + painter, + hole, + (polygon_idx, true), + (canvas_center, offset, zoom), + selected_vertex, + ); + } + } +} + +pub fn draw_arrow_control_handle( + painter: &Painter, + element: &DrawingElement, + canvas_center: Pos2, + offset: Vec2, + zoom: f32, +) { + let Shape::Arrow { + start, + end, + control_offset, + } = &element.shape + else { + return; + }; + + let canvas_pos = arrow_handle_position(*start, *end, *control_offset); + let screen_pos = canvas_to_screen(canvas_pos, canvas_center, offset, zoom); + painter.circle_filled(screen_pos, HANDLE_RADIUS + 1.0, ARROW_CONTROL_HANDLE_COLOR); + painter.circle_stroke( + screen_pos, + HANDLE_RADIUS + 1.0, + Stroke::new(1.0, Color32::WHITE), + ); +} + +pub fn find_arrow_control_handle_at_screen_pos( + element: &DrawingElement, + screen_pos: Pos2, + canvas_center: Pos2, + offset: Vec2, + zoom: f32, +) -> bool { + let Shape::Arrow { + start, + end, + control_offset, + } = &element.shape + else { + return false; + }; + + let canvas_pos = arrow_handle_position(*start, *end, *control_offset); + let handle_pos = canvas_to_screen(canvas_pos, canvas_center, offset, zoom); + (screen_pos - handle_pos).length() <= HANDLE_RADIUS + 6.0 +} + pub fn draw_marquee( painter: &Painter, start: Pos2, @@ -191,6 +456,69 @@ fn draw_dashed_rect(painter: &Painter, rect: egui::Rect, stroke: Stroke, dash: f ); } +fn draw_ring_vertex_handles( + painter: &Painter, + ring: &[Pos2], + ring_identity: (usize, bool), + transform: (Pos2, Vec2, f32), + selected_vertex: Option<(usize, usize, bool)>, +) { + if ring.is_empty() { + return; + } + + let (polygon_idx, is_hole) = ring_identity; + let (canvas_center, offset, zoom) = transform; + + for (vertex_idx, point) in ring.iter().enumerate() { + let screen = canvas_to_screen(*point, canvas_center, offset, zoom); + let is_selected = selected_vertex + .map( + |(selected_polygon, selected_vertex_idx, selected_is_hole)| { + selected_polygon == polygon_idx + && selected_vertex_idx == vertex_idx + && selected_is_hole == is_hole + }, + ) + .unwrap_or(false); + let color = if is_selected { + VERTEX_SELECTED_COLOR + } else { + VERTEX_HANDLE_COLOR + }; + + painter.circle_filled(screen, HANDLE_RADIUS, color); + painter.circle_stroke(screen, HANDLE_RADIUS, Stroke::new(1.0, Color32::BLACK)); + } + + if ring.len() < 2 { + return; + } + + let draw_closed = ring.len() >= 3; + let segment_count = if draw_closed { + ring.len() + } else { + ring.len() - 1 + }; + let midpoint_half_size = 3.0; + + for idx in 0..segment_count { + let next_idx = if idx + 1 < ring.len() { idx + 1 } else { 0 }; + let midpoint = Pos2::new( + (ring[idx].x + ring[next_idx].x) * 0.5, + (ring[idx].y + ring[next_idx].y) * 0.5, + ); + let midpoint_screen = canvas_to_screen(midpoint, canvas_center, offset, zoom); + let midpoint_rect = egui::Rect::from_center_size( + midpoint_screen, + egui::vec2(midpoint_half_size * 2.0, midpoint_half_size * 2.0), + ); + painter.rect_filled(midpoint_rect, 0.0, VERTEX_MIDPOINT_COLOR); + painter.rect_stroke(midpoint_rect, 0.0, Stroke::new(1.0, Color32::BLACK)); + } +} + fn draw_dashed_line(painter: &Painter, from: Pos2, to: Pos2, stroke: Stroke, dash: f32, gap: f32) { let dir = to - from; let len = dir.length(); @@ -278,30 +606,7 @@ fn draw_path_fill( 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)); -} - +#[allow(clippy::too_many_arguments)] pub fn draw_creation_preview( painter: &Painter, tool: Tool, @@ -309,6 +614,8 @@ pub fn draw_creation_preview( canvas_center: Pos2, offset: Vec2, zoom: f32, + polygon_sides: u32, + polygon_star_ratio: Option, ) { if let DragState::Creating { start, current } = drag_state { let s = canvas_to_screen(*start, canvas_center, offset, zoom); @@ -340,15 +647,295 @@ pub fn draw_creation_preview( } Tool::Arrow => { painter.line_segment([s, c], preview_stroke); - draw_arrowhead(painter, s, c, preview_stroke); + draw_arrowhead(painter, c, c - s, preview_stroke); + } + Tool::Polygon => { + let rect = egui::Rect::from_two_pos(s, c); + let center = rect.center(); + let radius = rect.width().min(rect.height()) / 2.0; + let points = polygon_vertices(center, radius, polygon_sides, polygon_star_ratio); + draw_closed_polyline(painter, &points, preview_stroke); } _ => {} } } } -fn draw_arrowhead(painter: &Painter, from: Pos2, to: Pos2, stroke: Stroke) { - let dir = to - from; +fn draw_rect_fill( + painter: &Painter, + rect: egui::Rect, + corner_radius: f32, + fill: &super::element::Fill, + opacity: f32, + element: &DrawingElement, +) { + use super::element::Fill; + match fill { + Fill::Solid { color } => { + painter.rect_filled(rect, corner_radius, apply_opacity(*color, opacity)); + } + _ => { + draw_gradient_fan_fill( + painter, + rect.center(), + &[ + rect.left_top(), + rect.right_top(), + rect.right_bottom(), + rect.left_bottom(), + ], + fill, + opacity, + element.bounding_rect(), + ); + } + } +} + +fn draw_gradient_fan_fill( + painter: &Painter, + center: Pos2, + points: &[Pos2], + fill: &super::element::Fill, + opacity: f32, + canvas_bounds: egui::Rect, +) { + use super::element::Fill; + if points.len() < 3 { + return; + } + + match fill { + Fill::Solid { color } => { + draw_fan_fill(painter, center, points, apply_opacity(*color, opacity)); + } + _ => { + let mut mesh = egui::epaint::Mesh::default(); + let center_color = apply_opacity( + fill.color_at(canvas_bounds.center(), canvas_bounds), + opacity, + ); + mesh.colored_vertex(center, center_color); + for point in points { + let color = apply_opacity(fill.color_at(*point, canvas_bounds), opacity); + mesh.colored_vertex(*point, color); + } + for i in 0..points.len() { + let a = 0; + let b = (i + 1) as u32; + let c = ((i + 1) % points.len() + 1) as u32; + mesh.add_triangle(a, b, c); + } + painter.add(egui::Shape::mesh(mesh)); + } + } +} + +#[allow(clippy::too_many_arguments)] +fn draw_gradient_path_fill( + painter: &Painter, + polygon: &super::element::PathPolygon, + canvas_center: Pos2, + offset: Vec2, + zoom: f32, + fill: &super::element::Fill, + opacity: f32, + canvas_bounds: egui::Rect, +) { + use super::element::Fill; + if polygon.exterior.len() < 3 { + return; + } + + match fill { + Fill::Solid { color } => { + draw_path_fill( + painter, + polygon, + canvas_center, + offset, + zoom, + apply_opacity(*color, opacity), + ); + } + _ => { + 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, *point)); + 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, *point)); + 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 (screen_pos, canvas_pos) in &vertices { + let color = apply_opacity(fill.color_at(*canvas_pos, canvas_bounds), opacity); + mesh.colored_vertex(*screen_pos, color); + } + + 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_stroke_or_dashed( + painter: &Painter, + points: &[Pos2], + closed: bool, + stroke: Stroke, + dash: Option<(f32, f32)>, +) { + if points.len() < 2 { + return; + } + match dash { + Some((d, g)) if d > 0.0 && g > 0.0 => { + let count = if closed { + points.len() + } else { + points.len() - 1 + }; + for i in 0..count { + let next = (i + 1) % points.len(); + draw_dashed_line(painter, points[i], points[next], stroke, d, g); + } + } + _ => { + if closed { + draw_closed_polyline(painter, points, stroke); + } else { + painter.add(egui::Shape::line(points.to_vec(), stroke)); + } + } + } +} + +fn draw_line_or_dashed( + painter: &Painter, + from: Pos2, + to: Pos2, + stroke: Stroke, + dash: Option<(f32, f32)>, +) { + match dash { + Some((d, g)) if d > 0.0 && g > 0.0 => { + draw_dashed_line(painter, from, to, stroke, d, g); + } + _ => { + painter.line_segment([from, to], stroke); + } + } +} + +fn apply_opacity(color: Color32, opacity: f32) -> Color32 { + Color32::from_rgba_unmultiplied( + color.r(), + color.g(), + color.b(), + (color.a() as f32 * opacity.clamp(0.0, 1.0)) as u8, + ) +} + +fn rotate_point(point: Pos2, center: Pos2, angle_deg: f32) -> Pos2 { + let angle = angle_deg.to_radians(); + let sin = angle.sin(); + let cos = angle.cos(); + let dx = point.x - center.x; + let dy = point.y - center.y; + Pos2::new( + center.x + dx * cos - dy * sin, + center.y + dx * sin + dy * cos, + ) +} + +fn ellipse_points(center: Pos2, radii: Vec2, rotation_deg: f32, count: usize) -> Vec { + (0..count) + .map(|i| { + let angle = i as f32 / count as f32 * std::f32::consts::TAU; + let point = Pos2::new( + center.x + radii.x * angle.cos(), + center.y + radii.y * angle.sin(), + ); + rotate_point(point, center, rotation_deg) + }) + .collect() +} + +fn draw_closed_polyline(painter: &Painter, points: &[Pos2], stroke: Stroke) { + if points.len() < 2 { + return; + } + + let mut ring = points.to_vec(); + ring.push(points[0]); + painter.add(egui::Shape::line(ring, stroke)); +} + +fn draw_fan_fill(painter: &Painter, center: Pos2, points: &[Pos2], fill: Color32) { + if points.len() < 3 { + return; + } + + let mut mesh = egui::epaint::Mesh::default(); + mesh.colored_vertex(center, fill); + for point in points { + mesh.colored_vertex(*point, fill); + } + + for i in 0..points.len() { + let a = 0; + let b = (i + 1) as u32; + let c = ((i + 1) % points.len() + 1) as u32; + mesh.add_triangle(a, b, c); + } + + painter.add(egui::Shape::mesh(mesh)); +} + +fn draw_arrowhead(painter: &Painter, to: Pos2, direction: Vec2, stroke: Stroke) { + let dir = direction; let len = dir.length(); if len < 1.0 { return; @@ -394,3 +981,201 @@ pub fn find_handle_at_screen_pos( } None } + +// --------------------------------------------------------------------------- +// Toolbar icon drawing +// --------------------------------------------------------------------------- + +/// Draw a crisp vector icon for the given tool inside `rect`. +/// All icons are drawn with egui Painter primitives — no font glyphs. +pub fn draw_tool_icon(painter: &Painter, rect: egui::Rect, tool: &Tool, color: Color32) { + let stroke = Stroke::new(1.5, color); + let c = rect.center(); + let s = rect.width().min(rect.height()) * 0.38; + + match tool { + Tool::Select => { + // Arrow cursor pointing up-left + let tip = Pos2::new(c.x - s * 0.5, c.y - s); + let bl = Pos2::new(c.x - s * 0.5, c.y + s * 0.55); + let notch = Pos2::new(c.x - s * 0.15, c.y + s * 0.25); + let br = Pos2::new(c.x + s * 0.55, c.y + s * 0.85); + let inner = Pos2::new(c.x + s * 0.1, c.y + s * 0.25); + let tr = Pos2::new(c.x + s * 0.3, c.y - s * 0.1); + + let points = [tip, bl, notch, br, inner, tr, tip]; + for pair in points.windows(2) { + painter.line_segment([pair[0], pair[1]], stroke); + } + } + Tool::DirectSelect => { + // Crosshair with small center circle + let len = s * 0.85; + painter.line_segment( + [Pos2::new(c.x, c.y - len), Pos2::new(c.x, c.y + len)], + stroke, + ); + painter.line_segment( + [Pos2::new(c.x - len, c.y), Pos2::new(c.x + len, c.y)], + stroke, + ); + painter.circle_stroke(c, s * 0.22, stroke); + } + Tool::Pan => { + // Four-direction move arrows + let len = s * 0.85; + let head = s * 0.3; + // Up + painter.line_segment( + [Pos2::new(c.x, c.y - len), Pos2::new(c.x, c.y + len)], + stroke, + ); + painter.line_segment( + [ + Pos2::new(c.x - head, c.y - len + head), + Pos2::new(c.x, c.y - len), + ], + stroke, + ); + painter.line_segment( + [ + Pos2::new(c.x + head, c.y - len + head), + Pos2::new(c.x, c.y - len), + ], + stroke, + ); + // Down + painter.line_segment( + [ + Pos2::new(c.x - head, c.y + len - head), + Pos2::new(c.x, c.y + len), + ], + stroke, + ); + painter.line_segment( + [ + Pos2::new(c.x + head, c.y + len - head), + Pos2::new(c.x, c.y + len), + ], + stroke, + ); + // Left + painter.line_segment( + [Pos2::new(c.x - len, c.y), Pos2::new(c.x + len, c.y)], + stroke, + ); + painter.line_segment( + [ + Pos2::new(c.x - len + head, c.y - head), + Pos2::new(c.x - len, c.y), + ], + stroke, + ); + painter.line_segment( + [ + Pos2::new(c.x - len + head, c.y + head), + Pos2::new(c.x - len, c.y), + ], + stroke, + ); + // Right + painter.line_segment( + [ + Pos2::new(c.x + len - head, c.y - head), + Pos2::new(c.x + len, c.y), + ], + stroke, + ); + painter.line_segment( + [ + Pos2::new(c.x + len - head, c.y + head), + Pos2::new(c.x + len, c.y), + ], + stroke, + ); + } + Tool::Rectangle => { + // Rounded rectangle outline + let w = s * 1.5; + let h = s * 1.1; + let icon_rect = egui::Rect::from_center_size(c, egui::vec2(w, h)); + painter.rect_stroke(icon_rect, s * 0.15, stroke); + } + Tool::Ellipse => { + // Circle outline + painter.circle_stroke(c, s * 0.75, stroke); + } + Tool::Line => { + // Diagonal line bottom-left to top-right + let d = s * 0.75; + painter.line_segment( + [Pos2::new(c.x - d, c.y + d), Pos2::new(c.x + d, c.y - d)], + stroke, + ); + } + Tool::Arrow => { + // Diagonal line with arrowhead + let d = s * 0.75; + let start = Pos2::new(c.x - d, c.y + d); + let end = Pos2::new(c.x + d, c.y - d); + painter.line_segment([start, end], stroke); + // Arrowhead + let head = s * 0.35; + painter.line_segment([Pos2::new(end.x - head, end.y), end], stroke); + painter.line_segment([Pos2::new(end.x, end.y + head), end], stroke); + } + Tool::Polygon => { + // Hexagon outline + let r = s * 0.8; + let mut pts = Vec::with_capacity(6); + for i in 0..6 { + let angle = std::f32::consts::FRAC_PI_3 * i as f32 - std::f32::consts::FRAC_PI_6; + pts.push(Pos2::new(c.x + r * angle.cos(), c.y + r * angle.sin())); + } + for i in 0..6 { + painter.line_segment([pts[i], pts[(i + 1) % 6]], stroke); + } + } + Tool::Text => { + // Serif-style "T" + let top_y = c.y - s * 0.65; + let bot_y = c.y + s * 0.75; + let bar_half = s * 0.6; + let serif = s * 0.12; + // Top horizontal bar + painter.line_segment( + [ + Pos2::new(c.x - bar_half, top_y), + Pos2::new(c.x + bar_half, top_y), + ], + stroke, + ); + // Vertical stem + painter.line_segment([Pos2::new(c.x, top_y), Pos2::new(c.x, bot_y)], stroke); + // Left serif on top bar + painter.line_segment( + [ + Pos2::new(c.x - bar_half, top_y - serif), + Pos2::new(c.x - bar_half, top_y + serif), + ], + stroke, + ); + // Right serif on top bar + painter.line_segment( + [ + Pos2::new(c.x + bar_half, top_y - serif), + Pos2::new(c.x + bar_half, top_y + serif), + ], + stroke, + ); + // Bottom serif + painter.line_segment( + [ + Pos2::new(c.x - s * 0.25, bot_y), + Pos2::new(c.x + s * 0.25, bot_y), + ], + stroke, + ); + } + } +} diff --git a/crates/agcanvas/src/drawing/tool.rs b/crates/agcanvas/src/drawing/tool.rs index b6819a6..822f40b 100644 --- a/crates/agcanvas/src/drawing/tool.rs +++ b/crates/agcanvas/src/drawing/tool.rs @@ -5,11 +5,13 @@ use serde::{Deserialize, Serialize}; pub enum Tool { #[default] Select, + DirectSelect, Pan, Rectangle, Ellipse, Line, Arrow, + Polygon, Text, } @@ -17,11 +19,13 @@ impl Tool { pub fn label(&self) -> &'static str { match self { Tool::Select => "Select", + Tool::DirectSelect => "Direct", Tool::Pan => "Pan", Tool::Rectangle => "Rect", Tool::Ellipse => "Ellipse", Tool::Line => "Line", Tool::Arrow => "Arrow", + Tool::Polygon => "Polygon", Tool::Text => "Text", } } @@ -29,11 +33,13 @@ impl Tool { pub fn shortcut(&self) -> Option { match self { Tool::Select => Some('V'), + Tool::DirectSelect => Some('D'), Tool::Pan => Some('H'), Tool::Rectangle => Some('R'), Tool::Ellipse => Some('E'), Tool::Line => Some('L'), Tool::Arrow => Some('A'), + Tool::Polygon => Some('P'), Tool::Text => Some('T'), } } @@ -59,6 +65,15 @@ pub enum DragState { element_id: String, original_rect: egui::Rect, }, + VertexDrag { + element_id: String, + polygon_idx: usize, + vertex_idx: usize, + is_hole: bool, + }, + ArrowControlDrag { + element_id: String, + }, } #[derive(Debug, Clone, Copy, PartialEq, Eq)] diff --git a/crates/agcanvas/src/export.rs b/crates/agcanvas/src/export.rs index 5b6e7a1..61e5a08 100644 --- a/crates/agcanvas/src/export.rs +++ b/crates/agcanvas/src/export.rs @@ -1,6 +1,9 @@ use std::path::Path; -use crate::drawing::{DrawingElement, Shape, ShapeStyle}; +use crate::drawing::{ + arrow_curve_points, polygon_vertices, quadratic_bezier_end_tangent, DrawingElement, Shape, + ShapeStyle, ARROW_CURVE_SEGMENTS, +}; use anyhow::{anyhow, Result}; use tiny_skia::{FillRule, LineCap, LineJoin, Paint, PathBuilder, Pixmap, Stroke, Transform}; @@ -181,18 +184,51 @@ fn render_drawing_elements( stroke_path(pixmap, &path, &element.style, scale); } } - Shape::Arrow { start, end } => { - if let Some(path) = build_line_path(*start, *end, min_x, min_y, scale) { + Shape::Arrow { + start, + end, + control_offset, + } => { + let curve_points = + arrow_curve_points(*start, *end, *control_offset, ARROW_CURVE_SEGMENTS); + if let Some(path) = build_polyline_path(&curve_points, min_x, min_y, scale) { stroke_path(pixmap, &path, &element.style, scale); } - if let Some(arrowhead) = build_arrowhead_path(*start, *end, min_x, min_y, scale) { + let direction = quadratic_bezier_end_tangent(*start, *end, *control_offset); + if let Some(arrowhead) = build_arrowhead_path(*end, direction, min_x, min_y, scale) + { stroke_path(pixmap, &arrowhead, &element.style, scale); } } + Shape::Polygon { + center, + radius, + sides, + star_inner_ratio, + } => { + let points = polygon_vertices(*center, *radius, *sides, *star_inner_ratio); + if points.len() < 3 { + continue; + } + + let mut pb = PathBuilder::new(); + if let Some(first) = points.first() { + pb.move_to((first.x - min_x) * scale, (first.y - min_y) * scale); + for point in points.iter().skip(1) { + pb.line_to((point.x - min_x) * scale, (point.y - min_y) * scale); + } + pb.close(); + } + + if let Some(path) = pb.finish() { + fill_and_stroke_path(pixmap, &path, &element.style, scale); + } + } Shape::Text { pos, content, font_size, + max_width, } => { render_text_element( pixmap, @@ -200,6 +236,7 @@ fn render_drawing_elements( (pos.x - min_x) * scale, (pos.y - min_y) * scale, *font_size * scale, + max_width.map(|w| w * scale), element.style.stroke_color, )?; } @@ -216,6 +253,10 @@ fn render_drawing_elements( fill_and_stroke_path_even_odd(pixmap, &path, &element.style, scale); } } + Shape::SvgImage { .. } => {} + Shape::Group => { + render_drawing_elements(pixmap, &element.children, min_x, min_y, scale)?; + } } } @@ -228,9 +269,9 @@ fn fill_and_stroke_path( style: &ShapeStyle, scale: f32, ) { - if let Some(fill) = style.fill { + if let Some(fill) = &style.fill { let mut fill_paint = Paint::default(); - fill_paint.set_color(egui_to_skia_color(fill)); + fill_paint.set_color(egui_to_skia_color(fill.primary_color())); pixmap.fill_path( path, &fill_paint, @@ -249,9 +290,9 @@ fn fill_and_stroke_path_even_odd( style: &ShapeStyle, scale: f32, ) { - if let Some(fill) = style.fill { + if let Some(fill) = &style.fill { let mut fill_paint = Paint::default(); - fill_paint.set_color(egui_to_skia_color(fill)); + fill_paint.set_color(egui_to_skia_color(fill.primary_color())); pixmap.fill_path( path, &fill_paint, @@ -296,23 +337,39 @@ fn build_line_path( pb.finish() } -fn build_arrowhead_path( - start: egui::Pos2, - end: egui::Pos2, +fn build_polyline_path( + points: &[egui::Pos2], min_x: f32, min_y: f32, scale: f32, ) -> Option { - let dx = end.x - start.x; - let dy = end.y - start.y; - let len = (dx * dx + dy * dy).sqrt(); + if points.len() < 2 { + return None; + } + + let mut pb = PathBuilder::new(); + pb.move_to((points[0].x - min_x) * scale, (points[0].y - min_y) * scale); + for point in points.iter().skip(1) { + pb.line_to((point.x - min_x) * scale, (point.y - min_y) * scale); + } + pb.finish() +} + +fn build_arrowhead_path( + end: egui::Pos2, + direction: egui::Vec2, + min_x: f32, + min_y: f32, + scale: f32, +) -> Option { + let len = direction.length(); if len < 1e-6 { return None; } - let dir_x = dx / len; - let dir_y = dy / len; + let dir_x = direction.x / len; + let dir_y = direction.y / len; let perp_x = -dir_y; let perp_y = dir_x; let arrow_size = 12.0; @@ -361,27 +418,47 @@ fn render_text_element( x: f32, y: f32, font_size: f32, + max_width: Option, color: egui::Color32, ) -> Result<()> { if text.is_empty() || font_size <= 0.0 { return Ok(()); } - let escaped = escape_xml(text); - let approx_width = (font_size * text.chars().count() as f32 * 0.7).max(font_size); - let approx_height = (font_size * 1.6).max(font_size); + let line_height = font_size * 1.4; + let lines = wrap_text_lines(text, font_size, max_width); + let wrapped_width = lines + .iter() + .map(|line| approximate_text_width(line, font_size)) + .fold(0.0_f32, f32::max) + .max(font_size); + let approx_width = max_width.unwrap_or(wrapped_width).max(font_size); + let approx_height = + (font_size + (lines.len().saturating_sub(1) as f32 * line_height)).max(font_size); let opacity = color.a() as f32 / 255.0; + let text_body = if lines.len() <= 1 { + escape_xml(lines.first().map_or(text, String::as_str)) + } else { + lines + .iter() + .enumerate() + .map(|(index, line)| { + let dy = if index == 0 { font_size } else { line_height }; + format!(r#"{}"#, escape_xml(line)) + }) + .collect::>() + .join("") + }; let svg_text = format!( - r#"{text}"#, + r#"{text}"#, w = approx_width, h = approx_height, - baseline = font_size, size = font_size, r = color.r(), g = color.g(), b = color.b(), opacity = opacity, - text = escaped, + text = text_body, ); let tree = parse_svg(&svg_text)?; @@ -390,6 +467,48 @@ fn render_text_element( Ok(()) } +fn wrap_text_lines(text: &str, font_size: f32, max_width: Option) -> Vec { + let Some(max_width) = max_width else { + return vec![text.to_owned()]; + }; + + if max_width <= 0.0 { + return vec![text.to_owned()]; + } + + let mut lines: Vec = Vec::new(); + let mut current = String::new(); + + for word in text.split_whitespace() { + let candidate = if current.is_empty() { + word.to_owned() + } else { + format!("{} {}", current, word) + }; + + if approximate_text_width(&candidate, font_size) <= max_width || current.is_empty() { + current = candidate; + } else { + lines.push(current); + current = word.to_owned(); + } + } + + if !current.is_empty() { + lines.push(current); + } + + if lines.is_empty() { + vec![text.to_owned()] + } else { + lines + } +} + +fn approximate_text_width(text: &str, font_size: f32) -> f32 { + text.chars().count() as f32 * font_size * 0.6 +} + fn parse_svg(svg_source: &str) -> Result { let mut options = usvg::Options::default(); options.fontdb_mut().load_system_fonts(); diff --git a/crates/agcanvas/src/main.rs b/crates/agcanvas/src/main.rs index 5f27dac..a2f7ccf 100644 --- a/crates/agcanvas/src/main.rs +++ b/crates/agcanvas/src/main.rs @@ -11,10 +11,34 @@ mod mermaid; mod persistence; mod session; mod svg; +mod theme; + +pub use theme::CanvasTheme; use anyhow::Result; +use fs2::FileExt; +use std::fs::{self, File}; +use std::path::PathBuf; use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; +fn lock_file_path() -> Result { + let base = dirs::data_dir() + .or_else(dirs::home_dir) + .ok_or_else(|| anyhow::anyhow!("Cannot determine data directory"))?; + let dir = base.join("agcanvas"); + fs::create_dir_all(&dir)?; + Ok(dir.join("agcanvas.lock")) +} + +fn acquire_singleton_lock() -> Result> { + let path = lock_file_path()?; + let file = File::create(&path)?; + match file.try_lock_exclusive() { + Ok(()) => Ok(Some(file)), + Err(_) => Ok(None), + } +} + fn main() -> Result<()> { tracing_subscriber::registry() .with(tracing_subscriber::EnvFilter::new( @@ -23,6 +47,19 @@ fn main() -> Result<()> { .with(tracing_subscriber::fmt::layer()) .init(); + let _lock = match acquire_singleton_lock() { + Ok(Some(lock)) => Some(lock), + Ok(None) => { + tracing::warn!("Another instance of Augmented Canvas is already running"); + eprintln!("Augmented Canvas is already running. Only one instance is allowed."); + std::process::exit(0); + } + Err(e) => { + tracing::warn!("Could not acquire singleton lock: {}", e); + None + } + }; + let native_options = eframe::NativeOptions { viewport: egui::ViewportBuilder::default() .with_inner_size([1400.0, 900.0]) diff --git a/crates/agcanvas/src/persistence.rs b/crates/agcanvas/src/persistence.rs index 2378d8c..eead54c 100644 --- a/crates/agcanvas/src/persistence.rs +++ b/crates/agcanvas/src/persistence.rs @@ -1,6 +1,7 @@ use crate::canvas::CanvasState; use crate::drawing::DrawingElement; use crate::session::SessionCreator; +use crate::theme::CanvasTheme; use anyhow::Result; use serde::{Deserialize, Serialize}; use std::path::PathBuf; @@ -22,6 +23,10 @@ pub struct SavedWorkspace { pub version: u32, pub active_session_idx: usize, pub session_counter: usize, + #[serde(default)] + pub group_counter: usize, + #[serde(default)] + pub theme: CanvasTheme, pub sessions: Vec, } @@ -31,12 +36,16 @@ impl SavedWorkspace { pub fn new( active_session_idx: usize, session_counter: usize, + group_counter: usize, + theme: CanvasTheme, sessions: Vec, ) -> Self { Self { version: Self::CURRENT_VERSION, active_session_idx, session_counter, + group_counter, + theme, sessions, } } @@ -95,12 +104,14 @@ mod tests { #[test] fn round_trip_empty_workspace() { - let workspace = SavedWorkspace::new(0, 1, Vec::new()); + let workspace = SavedWorkspace::new(0, 1, 0, CanvasTheme::default(), Vec::new()); let json = serde_json::to_string(&workspace).unwrap(); let loaded: SavedWorkspace = serde_json::from_str(&json).unwrap(); assert_eq!(loaded.version, 1); assert_eq!(loaded.sessions.len(), 0); assert_eq!(loaded.session_counter, 1); + assert_eq!(loaded.group_counter, 0); + assert_eq!(loaded.theme, CanvasTheme::Dark); } #[test] @@ -115,11 +126,12 @@ mod tests { created_by: SessionCreator::Human, created_at: 1234567890, }; - let workspace = SavedWorkspace::new(0, 1, vec![session]); + let workspace = SavedWorkspace::new(0, 1, 0, CanvasTheme::Light, vec![session]); let json = serde_json::to_string(&workspace).unwrap(); let loaded: SavedWorkspace = serde_json::from_str(&json).unwrap(); assert_eq!(loaded.sessions.len(), 1); assert_eq!(loaded.sessions[0].name, "Test"); + assert_eq!(loaded.theme, CanvasTheme::Light); assert_eq!( loaded.sessions[0].svg_source.as_deref(), Some("") diff --git a/crates/agcanvas/src/session.rs b/crates/agcanvas/src/session.rs index 55578d3..dffee48 100644 --- a/crates/agcanvas/src/session.rs +++ b/crates/agcanvas/src/session.rs @@ -2,8 +2,6 @@ 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}; use std::collections::HashMap; use std::time::{SystemTime, UNIX_EPOCH}; @@ -81,17 +79,19 @@ pub struct Session { pub id: String, pub name: String, pub canvas_state: CanvasState, - pub svg_renderer: Option, - pub svg_texture: Option, pub element_tree: Option, pub svg_source: Option, - pub description_text: String, + + pub svg_textures: HashMap, pub drawing_elements: Vec, pub selected_element_ids: Vec, + pub selected_vertex: Option<(String, usize, usize, bool)>, pub active_tool: Tool, pub drag_state: DragState, pub history: HistoryTree, + pub polygon_sides: u32, + pub polygon_star_ratio: Option, pub description: Option, pub created_by: SessionCreator, @@ -104,16 +104,17 @@ impl Session { id, name, canvas_state: CanvasState::default(), - svg_renderer: None, - svg_texture: None, element_tree: None, svg_source: None, - description_text: String::new(), + svg_textures: HashMap::new(), drawing_elements: Vec::new(), selected_element_ids: Vec::new(), + selected_vertex: None, active_tool: Tool::default(), drag_state: DragState::default(), history: HistoryTree::new(DocumentSnapshot::new_empty()), + polygon_sides: 6, + polygon_star_ratio: None, description: None, created_by, created_at: unix_now(), @@ -139,13 +140,13 @@ impl Session { } pub fn clear(&mut self) { - self.svg_renderer = None; - self.svg_texture = None; self.element_tree = None; self.svg_source = None; - self.description_text.clear(); + + self.svg_textures.clear(); self.drawing_elements.clear(); self.selected_element_ids.clear(); + self.selected_vertex = None; self.drag_state = DragState::default(); self.canvas_state.reset(); } @@ -173,9 +174,13 @@ impl Session { pub fn delete_selected(&mut self) { if !self.selected_element_ids.is_empty() { let selected_ids = self.selected_element_ids.clone(); + for id in &selected_ids { + self.svg_textures.remove(id); + } self.drawing_elements .retain(|e| !selected_ids.contains(&e.id)); self.selected_element_ids.clear(); + self.selected_vertex = None; } } @@ -190,10 +195,9 @@ impl Session { 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_ids.clear(); + self.selected_vertex = None; self.drag_state = DragState::default(); } @@ -202,10 +206,9 @@ impl Session { 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_ids.clear(); + self.selected_vertex = None; self.drag_state = DragState::default(); true } else { @@ -218,10 +221,9 @@ impl Session { 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_ids.clear(); + self.selected_vertex = None; self.drag_state = DragState::default(); true } else { @@ -240,11 +242,30 @@ pub struct SessionData { pub type ExportSessionData = (String, Option, Vec); +/// Snapshot of app-level state for agent inspection. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct AppStateSnapshot { + pub active_tool: String, + pub selected_element_ids: Vec, + pub zoom: f32, + pub pan_offset_x: f32, + pub pan_offset_y: f32, + pub theme: String, + pub show_tree_panel: bool, + pub show_description_panel: bool, + pub show_history_panel: bool, + pub session_name: String, + pub element_count: usize, + pub canvas_width: f32, + pub canvas_height: f32, +} + #[derive(Default)] pub struct SessionStore { sessions: HashMap, active_session_id: Option, session_counter: usize, + app_state: Option, } impl SessionStore { @@ -467,4 +488,12 @@ impl SessionStore { pub fn active_session_id(&self) -> Option<&str> { self.active_session_id.as_deref() } + + pub fn set_app_state(&mut self, state: AppStateSnapshot) { + self.app_state = Some(state); + } + + pub fn get_app_state(&self) -> Option<&AppStateSnapshot> { + self.app_state.as_ref() + } } diff --git a/crates/agcanvas/src/svg/converter.rs b/crates/agcanvas/src/svg/converter.rs index 4cc90fb..bc3b717 100644 --- a/crates/agcanvas/src/svg/converter.rs +++ b/crates/agcanvas/src/svg/converter.rs @@ -2,10 +2,73 @@ use anyhow::Result; use egui::{Color32, Pos2, Vec2}; use usvg::tiny_skia_path::PathSegment; -use crate::drawing::{DrawingElement, Shape, ShapeStyle}; +use crate::drawing::{DrawingElement, Fill, GradientStop, PathPolygon, Shape, ShapeStyle}; + +#[derive(Clone, Copy, Debug)] +struct AffineTransform { + sx: f32, + kx: f32, + ky: f32, + sy: f32, + tx: f32, + ty: f32, +} + +impl AffineTransform { + fn identity() -> Self { + Self { + sx: 1.0, + kx: 0.0, + ky: 0.0, + sy: 1.0, + tx: 0.0, + ty: 0.0, + } + } + + fn from_translate(tx: f32, ty: f32) -> Self { + Self { + tx, + ty, + ..Self::identity() + } + } + + fn from_usvg(transform: usvg::Transform) -> Self { + Self { + sx: transform.sx, + kx: transform.kx, + ky: transform.ky, + sy: transform.sy, + tx: transform.tx, + ty: transform.ty, + } + } + + fn concat(self, other: Self) -> Self { + Self { + sx: self.sx * other.sx + self.kx * other.ky, + kx: self.sx * other.kx + self.kx * other.sy, + ky: self.ky * other.sx + self.sy * other.ky, + sy: self.ky * other.kx + self.sy * other.sy, + tx: self.sx * other.tx + self.kx * other.ty + self.tx, + ty: self.ky * other.tx + self.sy * other.ty + self.ty, + } + } + + fn apply(self, x: f32, y: f32) -> Pos2 { + Pos2::new( + self.sx * x + self.kx * y + self.tx, + self.ky * x + self.sy * y + self.ty, + ) + } + + fn is_axis_aligned(self) -> bool { + self.kx.abs() <= f32::EPSILON && self.ky.abs() <= f32::EPSILON + } +} /// Convert an SVG string into editable DrawingElements. -/// `offset_x` and `offset_y` shift all elements (for positioning on canvas). pub fn svg_to_drawing_elements( svg_source: &str, offset_x: f32, @@ -16,39 +79,68 @@ pub fn svg_to_drawing_elements( let tree = usvg::Tree::from_str(svg_source, &options)?; let mut elements = Vec::new(); - walk_group(tree.root(), offset_x, offset_y, &mut elements); + let base_transform = AffineTransform::from_translate(offset_x, offset_y); + walk_group(tree.root(), base_transform, &mut elements); + tracing::info!( + "svg_to_drawing_elements: produced {} drawing elements from SVG ({}x{})", + elements.len(), + tree.size().width(), + tree.size().height() + ); Ok(elements) } -fn walk_group(group: &usvg::Group, ox: f32, oy: f32, elements: &mut Vec) { - let (gx, gy) = extract_group_translate(group); - let next_ox = ox + gx; - let next_oy = oy + gy; +fn walk_group(group: &usvg::Group, transform: AffineTransform, elements: &mut Vec) { + let next_transform = transform.concat(AffineTransform::from_usvg(group.transform())); + let child_count = group.children().len(); + + tracing::debug!( + "walk_group: {} children, transform: ({:.1},{:.1},{:.1},{:.1},{:.1},{:.1})", + child_count, + next_transform.sx, + next_transform.kx, + next_transform.ky, + next_transform.sy, + next_transform.tx, + next_transform.ty, + ); + + let start_idx = elements.len(); for node in group.children() { match node { - usvg::Node::Group(g) => walk_group(g, next_ox, next_oy, elements), + usvg::Node::Group(g) => walk_group(g, next_transform, elements), usvg::Node::Path(path) => { - if let Some(element) = convert_path(path, next_ox, next_oy) { + let before = elements.len(); + if let Some(element) = convert_path(path, next_transform) { elements.push(element); } + if elements.len() == before { + let bbox = path.bounding_box(); + tracing::debug!( + " SKIP path: bbox=({:.1},{:.1} {:.1}x{:.1}) fill={} stroke={}", + bbox.left(), + bbox.top(), + bbox.width(), + bbox.height(), + path.fill().is_some(), + path.stroke().is_some(), + ); + } } usvg::Node::Text(text) => { - if let Some(element) = convert_text(text, next_ox, next_oy) { + if let Some(element) = convert_text(text, next_transform) { elements.push(element); } } usvg::Node::Image(_) => {} } } + + assign_text_max_widths(elements, start_idx); } -fn extract_group_translate(group: &usvg::Group) -> (f32, f32) { - let t = group.transform(); - (t.tx, t.ty) -} - -fn convert_path(path: &usvg::Path, ox: f32, oy: f32) -> Option { +fn convert_path(path: &usvg::Path, transform: AffineTransform) -> Option { let bbox = path.bounding_box(); // Skip degenerate paths (zero area AND zero length — truly empty) if bbox.width() <= 0.0 && bbox.height() <= 0.0 { @@ -71,79 +163,139 @@ fn convert_path(path: &usvg::Path, ox: f32, oy: f32) -> Option { let style = extract_style(path); - if is_rect_path(&segments) { - return Some(DrawingElement::new( - Shape::Rectangle { - pos: Pos2::new(bbox.left() + ox, bbox.top() + oy), - size: Vec2::new(bbox.width(), bbox.height()), - }, - style, - )); + if transform.is_axis_aligned() && is_rect_path(&segments) { + let (pos, size) = transformed_rect_from_bbox(bbox, transform); + return Some(DrawingElement::new(Shape::Rectangle { pos, size }, style)); } if is_line_path(&segments) { let (start, end) = extract_line_endpoints(&segments)?; + let start = transform.apply(start.0, start.1); + let end = transform.apply(end.0, end.1); let shape = if is_stroke_only(path) { Shape::Arrow { - start: Pos2::new(start.0 + ox, start.1 + oy), - end: Pos2::new(end.0 + ox, end.1 + oy), + start, + end, + control_offset: None, } } else { - Shape::Line { - start: Pos2::new(start.0 + ox, start.1 + oy), - end: Pos2::new(end.0 + ox, end.1 + oy), - } + Shape::Line { start, end } }; return Some(DrawingElement::new(shape, style)); } let aspect = bbox.width() / bbox.height().max(0.001); - if (0.9..=1.1).contains(&aspect) && has_curves(&segments) { + if transform.is_axis_aligned() && (0.9..=1.1).contains(&aspect) && has_curves(&segments) { + let (pos, size) = transformed_rect_from_bbox(bbox, transform); return Some(DrawingElement::new( Shape::Ellipse { - center: Pos2::new( - bbox.left() + bbox.width() * 0.5 + ox, - bbox.top() + bbox.height() * 0.5 + oy, - ), - radii: Vec2::new(bbox.width() * 0.5, bbox.height() * 0.5), + center: Pos2::new(pos.x + size.x * 0.5, pos.y + size.y * 0.5), + radii: Vec2::new(size.x * 0.5, size.y * 0.5), }, style, )); } - None + let polygons = flatten_path_segments(&segments, transform); + if polygons.is_empty() { + return None; + } + + Some(DrawingElement::new(Shape::Path { polygons }, style)) } -fn convert_text(text: &usvg::Text, ox: f32, oy: f32) -> Option { +fn convert_text(text: &usvg::Text, transform: AffineTransform) -> Option { let content = extract_text_content(text); if content.trim().is_empty() { return None; } let bbox = text.bounding_box(); + let pos = transform.apply(bbox.left(), bbox.top()); let font_size = extract_font_size(text); let fill_color = extract_text_color(text); Some(DrawingElement::new( Shape::Text { - pos: Pos2::new(bbox.left() + ox, bbox.top() + oy), + pos, content, font_size, + max_width: None, }, ShapeStyle { - fill: None, + fill: Some(Fill::solid(fill_color)), stroke_color: fill_color, stroke_width: 0.0, + ..ShapeStyle::default() }, )) } +fn assign_text_max_widths(elements: &mut [DrawingElement], start_idx: usize) { + let group_elements = &elements[start_idx..]; + + // Track container bounds and whether it's a non-rectangular shape (diamond/polygon) + // where the usable text area is narrower than the bounding box. + let containers: Vec<(egui::Rect, bool)> = group_elements + .iter() + .filter_map(|el| match &el.shape { + Shape::Rectangle { pos, size } => Some((egui::Rect::from_min_size(*pos, *size), false)), + Shape::Ellipse { center, radii } => Some(( + egui::Rect::from_center_size(*center, egui::vec2(radii.x * 2.0, radii.y * 2.0)), + true, + )), + Shape::Path { .. } => Some((el.bounding_rect(), true)), + _ => None, + }) + .collect(); + + if containers.is_empty() { + return; + } + + for el in &mut elements[start_idx..] { + if let Shape::Text { + pos, + content, + font_size, + max_width, + } = &mut el.shape + { + let text_point = *pos; + let mut best: Option<(f32, egui::Rect, bool)> = None; + + for (rect, is_non_rect) in &containers { + if rect.contains(text_point) && rect.width() > 1.0 { + let area = rect.width() * rect.height(); + if best.is_none() || area < best.unwrap().0 { + best = Some((area, *rect, *is_non_rect)); + } + } + } + + if let Some((_area, container, is_non_rect)) = best { + let padding = 16.0; + // For diamond/polygon/ellipse shapes, usable width at center is ~50% + let width_factor = if is_non_rect { 0.5 } else { 1.0 }; + let usable_width = (container.width() * width_factor - padding).max(20.0); + *max_width = Some(usable_width); + pos.x = container.center().x - usable_width * 0.5; + + let text_width = content.chars().count() as f32 * *font_size * 0.6; + let line_count = (text_width / usable_width).ceil().max(1.0); + let total_text_height = line_count * *font_size * 1.4; + pos.y = container.center().y - total_text_height * 0.5; + } + } + } +} + fn extract_text_content(text: &usvg::Text) -> String { text.chunks() .iter() .map(|chunk| chunk.text()) .collect::>() - .join("") + .join(" ") } fn extract_font_size(text: &usvg::Text) -> f32 { @@ -170,26 +322,66 @@ fn extract_text_color(text: &usvg::Text) -> Color32 { } fn extract_style(path: &usvg::Path) -> ShapeStyle { - let fill = path.fill().and_then(|f| match f.paint() { - usvg::Paint::Color(c) => Some(Color32::from_rgb(c.red, c.green, c.blue)), - _ => None, - }); + let fill = path.fill().and_then(|f| extract_fill(f.paint())); - let (stroke_color, stroke_width) = path + let (stroke_color, stroke_width, stroke_dash) = path .stroke() .map(|s| { let color = match s.paint() { usvg::Paint::Color(c) => Color32::from_rgb(c.red, c.green, c.blue), _ => Color32::WHITE, }; - (color, s.width().get()) + let dash = s.dasharray().and_then(|dash_array| { + (dash_array.len() >= 2).then_some((dash_array[0], dash_array[1])) + }); + (color, s.width().get(), dash) }) - .unwrap_or((Color32::WHITE, 2.0)); + .unwrap_or((Color32::WHITE, 2.0, None)); ShapeStyle { fill, stroke_color, stroke_width, + stroke_dash, + ..ShapeStyle::default() + } +} + +fn extract_fill(paint: &usvg::Paint) -> Option { + match paint { + usvg::Paint::Color(c) => Some(Fill::solid(Color32::from_rgb(c.red, c.green, c.blue))), + usvg::Paint::LinearGradient(gradient) => { + let angle_deg = (gradient.y2() - gradient.y1()) + .atan2(gradient.x2() - gradient.x1()) + .to_degrees(); + let stops = gradient + .stops() + .iter() + .map(|stop| { + let c = stop.color(); + GradientStop { + offset: stop.offset().get(), + color: Color32::from_rgb(c.red, c.green, c.blue), + } + }) + .collect(); + Some(Fill::LinearGradient { angle_deg, stops }) + } + usvg::Paint::RadialGradient(gradient) => { + let stops = gradient + .stops() + .iter() + .map(|stop| { + let c = stop.color(); + GradientStop { + offset: stop.offset().get(), + color: Color32::from_rgb(c.red, c.green, c.blue), + } + }) + .collect(); + Some(Fill::RadialGradient { stops }) + } + usvg::Paint::Pattern(_) => None, } } @@ -225,6 +417,9 @@ fn is_rect_path(segments: &[PathSegment]) -> bool { fn is_line_path(segments: &[PathSegment]) -> bool { !segments.iter().any(|s| matches!(s, PathSegment::Close)) + && segments + .iter() + .all(|s| matches!(s, PathSegment::MoveTo(_) | PathSegment::LineTo(_))) } fn extract_line_endpoints(segments: &[PathSegment]) -> Option<((f32, f32), (f32, f32))> { @@ -262,6 +457,155 @@ fn is_stroke_only(path: &usvg::Path) -> bool { path.fill().is_none() && path.stroke().is_some() } +fn transformed_rect_from_bbox(bbox: usvg::Rect, transform: AffineTransform) -> (Pos2, Vec2) { + let x0 = bbox.left(); + let y0 = bbox.top(); + let x1 = x0 + bbox.width(); + let y1 = y0 + bbox.height(); + + let p0 = transform.apply(x0, y0); + let p1 = transform.apply(x1, y0); + let p2 = transform.apply(x1, y1); + let p3 = transform.apply(x0, y1); + + let min_x = p0.x.min(p1.x).min(p2.x).min(p3.x); + let min_y = p0.y.min(p1.y).min(p2.y).min(p3.y); + let max_x = p0.x.max(p1.x).max(p2.x).max(p3.x); + let max_y = p0.y.max(p1.y).max(p2.y).max(p3.y); + + ( + Pos2::new(min_x, min_y), + Vec2::new((max_x - min_x).max(0.0), (max_y - min_y).max(0.0)), + ) +} + +fn flatten_path_segments(segments: &[PathSegment], transform: AffineTransform) -> Vec { + let mut polygons = Vec::new(); + let mut current_ring: Vec = Vec::new(); + let mut current_point: Option<(f32, f32)> = None; + let mut subpath_start: Option<(f32, f32)> = None; + + for segment in segments { + match segment { + PathSegment::MoveTo(p) => { + finalize_ring(&mut polygons, &mut current_ring); + let point = (p.x, p.y); + current_point = Some(point); + subpath_start = Some(point); + push_transformed_point(&mut current_ring, transform, point); + } + PathSegment::LineTo(p) => { + let point = (p.x, p.y); + push_transformed_point(&mut current_ring, transform, point); + current_point = Some(point); + } + PathSegment::QuadTo(control, p) => { + if let Some(from) = current_point { + let to = (p.x, p.y); + flatten_quad_segment( + &mut current_ring, + transform, + from, + (control.x, control.y), + to, + 8, + ); + current_point = Some(to); + } + } + PathSegment::CubicTo(control1, control2, p) => { + if let Some(from) = current_point { + let to = (p.x, p.y); + flatten_cubic_segment( + &mut current_ring, + transform, + from, + (control1.x, control1.y), + (control2.x, control2.y), + to, + 16, + ); + current_point = Some(to); + } + } + PathSegment::Close => { + if let Some(start) = subpath_start { + push_transformed_point(&mut current_ring, transform, start); + } + finalize_ring(&mut polygons, &mut current_ring); + current_point = None; + subpath_start = None; + } + } + } + + finalize_ring(&mut polygons, &mut current_ring); + polygons +} + +fn finalize_ring(polygons: &mut Vec, ring: &mut Vec) { + if ring.len() >= 2 { + polygons.push(PathPolygon { + exterior: std::mem::take(ring), + holes: Vec::new(), + }); + } else { + ring.clear(); + } +} + +fn push_transformed_point(ring: &mut Vec, transform: AffineTransform, point: (f32, f32)) { + let transformed = transform.apply(point.0, point.1); + if let Some(last) = ring.last() { + let delta = *last - transformed; + if delta.length_sq() <= 1e-6 { + return; + } + } + ring.push(transformed); +} + +fn flatten_quad_segment( + ring: &mut Vec, + transform: AffineTransform, + from: (f32, f32), + control: (f32, f32), + to: (f32, f32), + steps: usize, +) { + for i in 1..=steps { + let t = i as f32 / steps as f32; + let it = 1.0 - t; + let x = it * it * from.0 + 2.0 * it * t * control.0 + t * t * to.0; + let y = it * it * from.1 + 2.0 * it * t * control.1 + t * t * to.1; + push_transformed_point(ring, transform, (x, y)); + } +} + +fn flatten_cubic_segment( + ring: &mut Vec, + transform: AffineTransform, + from: (f32, f32), + control1: (f32, f32), + control2: (f32, f32), + to: (f32, f32), + steps: usize, +) { + for i in 1..=steps { + let t = i as f32 / steps as f32; + let it = 1.0 - t; + let x = it * it * it * from.0 + + 3.0 * it * it * t * control1.0 + + 3.0 * it * t * t * control2.0 + + t * t * t * to.0; + let y = it * it * it * from.1 + + 3.0 * it * it * t * control1.1 + + 3.0 * it * t * t * control2.1 + + t * t * t * to.1; + push_transformed_point(ring, transform, (x, y)); + } +} + #[cfg(test)] mod tests { use super::*; @@ -300,4 +644,106 @@ mod tests { assert!(rect.min.y >= 200.0, "Expected y offset applied"); } } + + #[test] + fn converts_complex_svg_with_paths_and_circles() { + let svg = r##" + + + + + + + + + + + Sample Text + +"##; + let elements = + svg_to_drawing_elements(svg, 0.0, 0.0).expect("converter should parse complex svg"); + + eprintln!("Complex SVG produced {} elements:", elements.len()); + for (i, el) in elements.iter().enumerate() { + eprintln!(" [{i}] {:?}", std::mem::discriminant(&el.shape)); + } + + assert!( + elements.len() >= 5, + "Expected at least 5 elements from complex SVG, got {}", + elements.len() + ); + } + + #[test] + fn converts_circles_to_ellipses() { + let svg = r##" + +"##; + let elements = + svg_to_drawing_elements(svg, 0.0, 0.0).expect("converter should parse circle svg"); + + eprintln!("Circle SVG produced {} elements:", elements.len()); + for (i, el) in elements.iter().enumerate() { + eprintln!(" [{i}] {:?}", el.shape); + } + + assert!( + !elements.is_empty(), + "Expected at least 1 element from circle SVG" + ); + } + + #[test] + fn converts_stroke_only_line() { + let svg = r##" + +"##; + let elements = + svg_to_drawing_elements(svg, 0.0, 0.0).expect("converter should parse line svg"); + + eprintln!("Line SVG produced {} elements:", elements.len()); + for (i, el) in elements.iter().enumerate() { + eprintln!(" [{i}] {:?}", el.shape); + } + + assert!( + !elements.is_empty(), + "Expected at least 1 element from stroke-only line" + ); + } + + #[test] + fn converts_rounded_rect() { + let svg = r##" + +"##; + let elements = svg_to_drawing_elements(svg, 0.0, 0.0) + .expect("converter should parse rounded rect svg"); + + eprintln!("Rounded rect SVG produced {} elements:", elements.len()); + for (i, el) in elements.iter().enumerate() { + eprintln!(" [{i}] {:?}", std::mem::discriminant(&el.shape)); + } + + assert!( + !elements.is_empty(), + "Expected at least 1 element from rounded rect path" + ); + } + + #[test] + fn invisible_elements_are_skipped() { + let svg = r##" + +"##; + let elements = + svg_to_drawing_elements(svg, 0.0, 0.0).expect("converter should parse invisible svg"); + + eprintln!("Invisible circle SVG produced {} elements:", elements.len()); + for (i, el) in elements.iter().enumerate() { + eprintln!(" [{i}] {:?} style={:?}", el.shape, el.style); + } + } } diff --git a/crates/agcanvas/src/svg/export_svg.rs b/crates/agcanvas/src/svg/export_svg.rs new file mode 100644 index 0000000..4a30c45 --- /dev/null +++ b/crates/agcanvas/src/svg/export_svg.rs @@ -0,0 +1,382 @@ +use crate::drawing::{ + arrow_control_point, polygon_vertices, quadratic_bezier_end_tangent, DrawingElement, Shape, +}; + +pub fn export_drawing_elements_to_svg(elements: &[DrawingElement]) -> String { + let bounds = compute_bounds(elements).unwrap_or_else(|| { + egui::Rect::from_min_size(egui::Pos2::new(0.0, 0.0), egui::Vec2::new(1.0, 1.0)) + }); + + let width = bounds.width().max(1.0); + let height = bounds.height().max(1.0); + let mut svg = String::new(); + svg.push_str(&format!( + r#""#, + fmt(bounds.min.x), + fmt(bounds.min.y), + fmt(width), + fmt(height), + fmt(width), + fmt(height) + )); + + for element in elements { + push_element_svg(&mut svg, element); + } + + svg.push_str(""); + svg +} + +fn compute_bounds(elements: &[DrawingElement]) -> Option { + let mut bounds: Option = None; + for element in elements { + let half_stroke = (element.style.stroke_width * 0.5).max(0.0); + let rect = element.bounding_rect().expand(half_stroke); + bounds = Some(match bounds { + Some(existing) => existing.union(rect), + None => rect, + }); + } + bounds +} + +fn push_element_svg(output: &mut String, element: &DrawingElement) { + let stroke = color_to_hex(element.style.stroke_color); + let stroke_opacity = (element.style.opacity.clamp(0.0, 1.0) + * (element.style.stroke_color.a() as f32 / 255.0)) + .clamp(0.0, 1.0); + + match &element.shape { + Shape::Rectangle { pos, size } => { + let (fill, fill_opacity) = fill_attrs(element); + let center_x = pos.x + size.x * 0.5; + let center_y = pos.y + size.y * 0.5; + output.push_str(&format!( + r#""#, + fmt(pos.x), + fmt(pos.y), + fmt(size.x), + fmt(size.y), + fmt(element.style.corner_radius.max(0.0)), + fill, + stroke, + fmt(element.style.stroke_width.max(0.0)), + fmt(fill_opacity.max(stroke_opacity)), + rotation_attr(element.style.rotation_degrees, center_x, center_y) + )); + } + Shape::Ellipse { center, radii } => { + let (fill, fill_opacity) = fill_attrs(element); + output.push_str(&format!( + r#""#, + fmt(center.x), + fmt(center.y), + fmt(radii.x.max(0.0)), + fmt(radii.y.max(0.0)), + fill, + stroke, + fmt(element.style.stroke_width.max(0.0)), + fmt(fill_opacity.max(stroke_opacity)), + rotation_attr(element.style.rotation_degrees, center.x, center.y) + )); + } + Shape::Line { start, end } => { + output.push_str(&format!( + r#""#, + fmt(start.x), + fmt(start.y), + fmt(end.x), + fmt(end.y), + stroke, + fmt(element.style.stroke_width.max(0.0)), + fmt(stroke_opacity) + )); + } + Shape::Arrow { + start, + end, + control_offset, + } => { + if let Some(control_point) = arrow_control_point(*start, *end, *control_offset) { + output.push_str(&format!( + r#""#, + fmt(start.x), + fmt(start.y), + fmt(control_point.x), + fmt(control_point.y), + fmt(end.x), + fmt(end.y), + stroke, + fmt(element.style.stroke_width.max(0.0)), + fmt(stroke_opacity) + )); + } else { + output.push_str(&format!( + r#""#, + fmt(start.x), + fmt(start.y), + fmt(end.x), + fmt(end.y), + stroke, + fmt(element.style.stroke_width.max(0.0)), + fmt(stroke_opacity) + )); + } + + if let Some(points) = arrowhead_points( + *end, + quadratic_bezier_end_tangent(*start, *end, *control_offset), + ) { + output.push_str(&format!( + r#""#, + points, + stroke, + fmt(stroke_opacity) + )); + } + } + Shape::Text { + pos, + content, + font_size, + .. + } => { + let font_family = element.style.font_family.as_deref().unwrap_or("sans-serif"); + let text_rect = element.bounding_rect(); + let center = text_rect.center(); + output.push_str(&format!( + r#"{}"#, + fmt(pos.x), + fmt(pos.y + font_size), + fmt(*font_size), + escape_attr(font_family), + stroke, + fmt(stroke_opacity), + rotation_attr(element.style.rotation_degrees, center.x, center.y), + escape_text(content) + )); + } + Shape::Polygon { + center, + radius, + sides, + star_inner_ratio, + } => { + let (fill, fill_opacity) = fill_attrs(element); + let points = polygon_vertices(*center, *radius, *sides, *star_inner_ratio) + .into_iter() + .map(|point| format!("{},{}", fmt(point.x), fmt(point.y))) + .collect::>() + .join(" "); + output.push_str(&format!( + r#""#, + points, + fill, + stroke, + fmt(element.style.stroke_width.max(0.0)), + fmt(fill_opacity.max(stroke_opacity)), + rotation_attr(element.style.rotation_degrees, center.x, center.y) + )); + } + Shape::Path { polygons } => { + let (fill, fill_opacity) = fill_attrs(element); + let mut d = String::new(); + for polygon in polygons { + append_ring_path(&mut d, &polygon.exterior); + for hole in &polygon.holes { + append_ring_path(&mut d, hole); + } + } + output.push_str(&format!( + r#""#, + d.trim(), + fill, + stroke, + fmt(element.style.stroke_width.max(0.0)), + fmt(fill_opacity.max(stroke_opacity)) + )); + } + Shape::SvgImage { .. } => {} + Shape::Group => { + output.push_str(""); + for child in &element.children { + push_element_svg(output, child); + } + output.push_str(""); + } + } +} + +fn fill_attrs(element: &DrawingElement) -> (String, f32) { + match &element.style.fill { + Some(fill) => { + let color = fill.primary_color(); + let opacity = (element.style.opacity.clamp(0.0, 1.0) * (color.a() as f32 / 255.0)) + .clamp(0.0, 1.0); + (color_to_hex(color), opacity) + } + None => ("none".to_string(), 0.0), + } +} + +fn append_ring_path(d: &mut String, ring: &[egui::Pos2]) { + if ring.is_empty() { + return; + } + + d.push_str(&format!("M {} {} ", fmt(ring[0].x), fmt(ring[0].y))); + for point in ring.iter().skip(1) { + d.push_str(&format!("L {} {} ", fmt(point.x), fmt(point.y))); + } + d.push_str("Z "); +} + +fn rotation_attr(rotation_degrees: f32, cx: f32, cy: f32) -> String { + let rotation = rotation_degrees.rem_euclid(360.0); + if rotation.abs() <= f32::EPSILON { + String::new() + } else { + format!( + " transform=\"rotate({} {} {})\"", + fmt(rotation), + fmt(cx), + fmt(cy) + ) + } +} + +fn arrowhead_points(end: egui::Pos2, direction: egui::Vec2) -> Option { + let len = direction.length(); + if len <= f32::EPSILON { + return None; + } + + let dir_x = direction.x / len; + let dir_y = direction.y / len; + let perp_x = -dir_y; + let perp_y = dir_x; + let arrow_size = 12.0; + + let left = egui::pos2( + end.x - dir_x * arrow_size + perp_x * (arrow_size * 0.4), + end.y - dir_y * arrow_size + perp_y * (arrow_size * 0.4), + ); + let right = egui::pos2( + end.x - dir_x * arrow_size - perp_x * (arrow_size * 0.4), + end.y - dir_y * arrow_size - perp_y * (arrow_size * 0.4), + ); + + Some(format!( + "{},{} {},{} {},{}", + fmt(left.x), + fmt(left.y), + fmt(end.x), + fmt(end.y), + fmt(right.x), + fmt(right.y) + )) +} + +fn color_to_hex(color: egui::Color32) -> String { + format!("#{:02X}{:02X}{:02X}", color.r(), color.g(), color.b()) +} + +fn fmt(value: f32) -> String { + let mut out = format!("{:.3}", value); + while out.ends_with('0') { + out.pop(); + } + if out.ends_with('.') { + out.pop(); + } + if out.is_empty() { + "0".to_string() + } else { + out + } +} + +fn escape_text(text: &str) -> String { + let mut escaped = String::with_capacity(text.len()); + for ch in text.chars() { + match ch { + '&' => escaped.push_str("&"), + '<' => escaped.push_str("<"), + '>' => escaped.push_str(">"), + '"' => escaped.push_str("""), + '\'' => escaped.push_str("'"), + _ => escaped.push(ch), + } + } + escaped +} + +fn escape_attr(text: &str) -> String { + escape_text(text) +} + +#[cfg(test)] +mod tests { + use super::*; + use crate::drawing::ShapeStyle; + + #[test] + fn exports_svg_root_and_rect() { + let elements = vec![DrawingElement::new( + Shape::Rectangle { + pos: egui::pos2(10.0, 20.0), + size: egui::vec2(100.0, 40.0), + }, + ShapeStyle::default(), + )]; + + let svg = export_drawing_elements_to_svg(&elements); + assert!(svg.contains(" (f32, f32) { let size = self.tree.size(); (size.width(), size.height()) diff --git a/crates/agcanvas/src/theme.rs b/crates/agcanvas/src/theme.rs new file mode 100644 index 0000000..78d887c --- /dev/null +++ b/crates/agcanvas/src/theme.rs @@ -0,0 +1,67 @@ +use egui::Color32; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Default)] +pub enum CanvasTheme { + #[default] + Dark, + Light, + Custom { + background: [u8; 3], + grid: [u8; 3], + stroke: [u8; 3], + }, +} + +impl CanvasTheme { + pub fn background(&self) -> Color32 { + match self { + CanvasTheme::Dark => Color32::from_gray(30), + CanvasTheme::Light => Color32::from_rgb(250, 250, 250), + CanvasTheme::Custom { background, .. } => { + Color32::from_rgb(background[0], background[1], background[2]) + } + } + } + + pub fn grid_color(&self) -> Color32 { + match self { + CanvasTheme::Dark => Color32::from_gray(40), + CanvasTheme::Light => Color32::from_gray(220), + CanvasTheme::Custom { grid, .. } => Color32::from_rgb(grid[0], grid[1], grid[2]), + } + } + + pub fn default_stroke(&self) -> Color32 { + match self { + CanvasTheme::Dark => Color32::WHITE, + CanvasTheme::Light => Color32::from_gray(30), + CanvasTheme::Custom { stroke, .. } => { + Color32::from_rgb(stroke[0], stroke[1], stroke[2]) + } + } + } + + pub fn export_background(&self) -> tiny_skia::Color { + let bg = self.background(); + tiny_skia::Color::from_rgba8(bg.r(), bg.g(), bg.b(), 255) + } + + pub fn text_color(&self) -> Color32 { + let bg = self.background(); + let luminance = 0.2126 * bg.r() as f32 + 0.7152 * bg.g() as f32 + 0.0722 * bg.b() as f32; + if luminance > 140.0 { + Color32::from_gray(45) + } else { + Color32::from_gray(210) + } + } + + pub fn label(&self) -> &str { + match self { + CanvasTheme::Dark => "Dark", + CanvasTheme::Light => "Light", + CanvasTheme::Custom { .. } => "Custom", + } + } +}