commit f466a6af93c3e9c6d8698ddee72ab70745eddcb3 Author: David Ibia Date: Thu Jan 22 21:01:15 2026 +0100 Initial commit: agcanvas - interactive canvas for agent-human collaboration - SVG paste from Figma with structure parsing (usvg) - Element tree representation (groups, rects, paths, text, images) - Canvas rendering with pan/zoom (egui + resvg + tiny-skia) - WebSocket agent protocol on port 9876 - Semantic description generation - Code generation stubs (React, HTML, Tailwind, Svelte, Vue) - Cross-platform Rust implementation diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000..97f8e81 --- /dev/null +++ b/.gitignore @@ -0,0 +1,5 @@ +/target +Cargo.lock +*.swp +*.swo +.DS_Store diff --git a/Cargo.toml b/Cargo.toml new file mode 100644 index 0000000..6476d68 --- /dev/null +++ b/Cargo.toml @@ -0,0 +1,51 @@ +[package] +name = "agcanvas" +version = "0.1.0" +edition = "2021" +description = "Interactive canvas for agent-human collaboration with SVG support" +license = "MIT" + +[dependencies] +# GUI +eframe = { version = "0.29", default-features = false, features = [ + "default_fonts", + "glow", + "persistence", +] } +egui = "0.29" +egui_extras = { version = "0.29", features = ["image"] } + +# SVG parsing and rendering +usvg = "0.44" +resvg = "0.44" +tiny-skia = "0.11" + +# Clipboard +arboard = "3.4" + +# Serialization (for agent protocol) +serde = { version = "1.0", features = ["derive"] } +serde_json = "1.0" + +# Agent communication +tokio = { version = "1.0", features = ["rt-multi-thread", "sync", "macros"] } +tokio-tungstenite = "0.24" +futures-util = "0.3" + +# Logging +tracing = "0.1" +tracing-subscriber = { version = "0.3", features = ["env-filter"] } + +# Error handling +anyhow = "1.0" +thiserror = "1.0" + +# Image handling for texture conversion +image = { version = "0.25", default-features = false, features = ["png"] } + +[profile.release] +opt-level = 3 +lto = true + +[profile.dev] +opt-level = 1 diff --git a/README.md b/README.md new file mode 100644 index 0000000..18e4be7 --- /dev/null +++ b/README.md @@ -0,0 +1,232 @@ +# agcanvas + +A system-level interactive canvas for agent-human collaboration. Paste SVGs from Figma, get structured understanding, iterate with AI agents. + +## What is this? + +agcanvas bridges the gap between visual design and code generation. It's not a design tool—it's a **feedback tool** for rapid iteration between humans and AI agents. + +``` +┌─────────────────────────────────────────────────────────┐ +│ Figma agcanvas │ +│ ┌─────┐ Copy SVG ┌──────────────────────────────┐ │ +│ │ │ ───────────► │ Canvas (pan/zoom) │ │ +│ │Frame│ │ ┌────────┐ ┌────────┐ │ │ +│ │ │ │ │ Parsed │ │ Agent │ │ │ +│ └─────┘ │ │ Tree │ │ Server │ │ │ +│ │ └────────┘ └───┬────┘ │ │ +│ └──────────────────┼──────────┘ │ +│ │ │ +│ AI Agent ◄───── WebSocket (JSON) ────────┘ │ +│ - Sees structure │ +│ - Describes semantically │ +│ - Generates code │ +└─────────────────────────────────────────────────────────┘ +``` + +## Features + +- **SVG Paste** — Copy frames from Figma, paste directly (Cmd+V) +- **Structure Parsing** — SVG → typed element tree (groups, rects, circles, paths, text, images) +- **Semantic Description** — Auto-generates human-readable structure description +- **Agent Protocol** — WebSocket server for AI agents to query and understand the canvas +- **Code Generation** — Stubs for React, HTML, Tailwind, Svelte, Vue +- **Pan/Zoom** — Smooth canvas navigation + +## Installation + +### From source + +```bash +git clone https://github.com/yourusername/agcanvas.git +cd agcanvas +cargo build --release +./target/release/agcanvas +``` + +### Requirements + +- Rust 1.70+ +- macOS / Linux / Windows + +## Usage + +### Basic workflow + +1. **Open agcanvas** + ```bash + cargo run --release + ``` + +2. **Copy SVG from Figma** + - Select a frame in Figma + - Right-click → Copy as SVG (or Cmd+C) + +3. **Paste into agcanvas** + - Cmd+V (or File → Paste SVG) + +4. **Navigate** + - **Pan**: Middle-click drag, or Cmd+drag + - **Zoom**: Scroll wheel + - **Reset**: Cmd+0 + +5. **Inspect** + - View → Element Tree (hierarchical structure) + - View → Description (semantic text) + +### Keyboard shortcuts + +| Action | Shortcut | +|--------|----------| +| Paste SVG | Cmd+V | +| Reset zoom | Cmd+0 | + +## Agent Protocol + +agcanvas exposes a WebSocket server on `ws://127.0.0.1:9876` for AI agents to interact with the canvas. + +### Connecting + +```python +import websocket +import json + +ws = websocket.create_connection("ws://127.0.0.1:9876") +``` + +### Requests + +#### Get full element tree + +```json +{"type": "GetTree"} +``` + +Response: +```json +{ + "type": "Tree", + "tree": { + "root": { + "id": "frame-1", + "kind": {"type": "Group", "name": "Login Form"}, + "bounds": {"x": 0, "y": 0, "width": 400, "height": 600}, + "children": [...] + }, + "metadata": { + "source": "svg_paste", + "width": 400, + "height": 600, + "element_count": 15 + } + } +} +``` + +#### Get semantic description + +```json +{"type": "Describe"} +``` + +Response: +```json +{ + "type": "Description", + "text": "- Group 'Login Form'\n - Rectangle (400x600) fill=#ffffff\n - Text 'Welcome Back' (24px)\n - Rectangle (320x48) fill=#f0f0f0\n - Text 'Email' (14px)\n ..." +} +``` + +#### Generate code + +```json +{"type": "GenerateCode", "target": "react", "element_id": null} +``` + +Targets: `html`, `react`, `tailwind`, `svelte`, `vue` + +Response: +```json +{ + "type": "Code", + "code": "// Generated from SVG...\nexport function Component() {\n return (\n
\n {/* TODO: Implement based on structure */}\n
\n );\n}", + "target": "react" +} +``` + +#### Query elements at point + +```json +{"type": "GetElementsAtPoint", "x": 150.0, "y": 200.0} +``` + +#### Get element by ID + +```json +{"type": "GetElementById", "id": "button-primary"} +``` + +#### Ping + +```json +{"type": "Ping"} +``` + +### Element types + +```rust +enum ElementKind { + Group { name: Option }, + Rectangle { rx: Option, ry: Option }, + Circle { cx: f32, cy: f32, r: f32 }, + Ellipse { cx: f32, cy: f32, rx: f32, ry: f32 }, + Path { d: String }, + Text { content: String, font_size: f32 }, + Image { href: String }, + Line { x1: f32, y1: f32, x2: f32, y2: f32 }, +} +``` + +## Architecture + +``` +src/ +├── main.rs # Entry point, window setup +├── app.rs # Main application state, UI rendering +├── element_tree.rs # Structured element representation +├── clipboard.rs # System clipboard integration +├── canvas/ +│ ├── state.rs # Pan/zoom transformation state +│ └── interaction.rs # Mouse/keyboard input handling +├── svg/ +│ ├── parser.rs # SVG → ElementTree conversion +│ └── renderer.rs # SVG → pixels (resvg/tiny-skia) +└── agent/ + ├── protocol.rs # JSON message types + └── server.rs # WebSocket server +``` + +### Dependencies + +| Crate | Purpose | +|-------|---------| +| `eframe`/`egui` | GUI framework | +| `usvg` | SVG parsing | +| `resvg`/`tiny-skia` | SVG rendering | +| `arboard` | Clipboard access | +| `tokio-tungstenite` | WebSocket server | +| `serde`/`serde_json` | Serialization | + +## Roadmap + +- [ ] Real code generation (not just stubs) +- [ ] Element selection on canvas +- [ ] Agent draw commands (modify canvas from agent) +- [ ] Multi-frame support +- [ ] Export to file +- [ ] Diff view (before/after agent changes) +- [ ] Plugin system for code generators + +## License + +MIT diff --git a/src/agent/mod.rs b/src/agent/mod.rs new file mode 100644 index 0000000..cfd7983 --- /dev/null +++ b/src/agent/mod.rs @@ -0,0 +1,4 @@ +mod protocol; +mod server; + +pub use server::AgentServer; diff --git a/src/agent/protocol.rs b/src/agent/protocol.rs new file mode 100644 index 0000000..09bca61 --- /dev/null +++ b/src/agent/protocol.rs @@ -0,0 +1,68 @@ +use crate::element_tree::ElementTree; +use serde::{Deserialize, Serialize}; + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum AgentRequest { + GetTree, + GetElementById { + id: String, + }, + GetElementsAtPoint { + x: f32, + y: f32, + }, + Describe, + GenerateCode { + target: CodeGenTarget, + element_id: Option, + }, + Ping, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum AgentResponse { + Tree { + tree: ElementTree, + }, + Element { + element: Option, + }, + Elements { + elements: Vec, + }, + Description { + text: String, + }, + Code { + code: String, + target: CodeGenTarget, + }, + Pong, + Error { + message: String, + }, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)] +#[serde(rename_all = "lowercase")] +pub enum CodeGenTarget { + Html, + React, + Tailwind, + Svelte, + Vue, +} + +impl std::fmt::Display for CodeGenTarget { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + CodeGenTarget::Html => write!(f, "html"), + CodeGenTarget::React => write!(f, "react"), + CodeGenTarget::Tailwind => write!(f, "tailwind"), + CodeGenTarget::Svelte => write!(f, "svelte"), + CodeGenTarget::Vue => write!(f, "vue"), + } + } +} diff --git a/src/agent/server.rs b/src/agent/server.rs new file mode 100644 index 0000000..ea725d1 --- /dev/null +++ b/src/agent/server.rs @@ -0,0 +1,172 @@ +use super::protocol::{AgentRequest, AgentResponse, CodeGenTarget}; +use crate::element_tree::ElementTree; +use anyhow::Result; +use futures_util::{SinkExt, StreamExt}; +use std::sync::Arc; +use tokio::net::{TcpListener, TcpStream}; +use tokio::sync::RwLock; +use tokio_tungstenite::tungstenite::Message; + +pub struct AgentServer { + tree: Arc>>, + port: u16, +} + +impl AgentServer { + pub fn new(port: u16) -> Self { + Self { + tree: Arc::new(RwLock::new(None)), + port, + } + } + + pub fn tree_handle(&self) -> Arc>> { + self.tree.clone() + } + + pub async fn run(&self) -> Result<()> { + let addr = format!("127.0.0.1:{}", self.port); + let listener = TcpListener::bind(&addr).await?; + tracing::info!("Agent server listening on ws://{}", addr); + + while let Ok((stream, peer)) = listener.accept().await { + tracing::info!("Agent connected from {}", peer); + let tree = self.tree.clone(); + tokio::spawn(async move { + if let Err(e) = handle_connection(stream, tree).await { + tracing::error!("Connection error: {}", e); + } + }); + } + + Ok(()) + } +} + +async fn handle_connection(stream: TcpStream, tree: Arc>>) -> Result<()> { + let ws_stream = tokio_tungstenite::accept_async(stream).await?; + let (mut write, mut read) = ws_stream.split(); + + while let Some(msg) = read.next().await { + let msg = msg?; + if let Message::Text(text) = msg { + let response = match serde_json::from_str::(&text) { + Ok(request) => process_request(request, &tree).await, + Err(e) => AgentResponse::Error { + message: format!("Invalid request: {}", e), + }, + }; + + let response_text = serde_json::to_string(&response)?; + write.send(Message::Text(response_text.into())).await?; + } + } + + Ok(()) +} + +async fn process_request( + request: AgentRequest, + tree: &Arc>>, +) -> AgentResponse { + let tree_guard = tree.read().await; + + match request { + AgentRequest::Ping => AgentResponse::Pong, + + AgentRequest::GetTree => match tree_guard.as_ref() { + Some(t) => AgentResponse::Tree { tree: t.clone() }, + None => AgentResponse::Error { + message: "No SVG loaded".to_string(), + }, + }, + + AgentRequest::GetElementById { id } => match tree_guard.as_ref() { + Some(t) => AgentResponse::Element { + element: t.find_by_id(&id).cloned(), + }, + None => AgentResponse::Error { + message: "No SVG loaded".to_string(), + }, + }, + + AgentRequest::GetElementsAtPoint { x, y } => match tree_guard.as_ref() { + Some(t) => AgentResponse::Elements { + elements: t.find_at_point(x, y).into_iter().cloned().collect(), + }, + None => AgentResponse::Error { + message: "No SVG loaded".to_string(), + }, + }, + + AgentRequest::Describe => match tree_guard.as_ref() { + Some(t) => AgentResponse::Description { + text: t.to_semantic_description(), + }, + None => AgentResponse::Error { + message: "No SVG loaded".to_string(), + }, + }, + + AgentRequest::GenerateCode { target, element_id } => { + match tree_guard.as_ref() { + Some(t) => { + let element = match &element_id { + Some(id) => t.find_by_id(id), + None => Some(&t.root), + }; + + match element { + Some(el) => { + let code = generate_code_stub(el, target); + AgentResponse::Code { code, target } + } + None => AgentResponse::Error { + message: format!("Element not found: {:?}", element_id), + }, + } + } + None => AgentResponse::Error { + message: "No SVG loaded".to_string(), + }, + } + } + } +} + +fn generate_code_stub(element: &crate::element_tree::Element, target: CodeGenTarget) -> String { + let description = crate::element_tree::ElementTree { + root: element.clone(), + metadata: crate::element_tree::TreeMetadata { + source: "code_gen".to_string(), + width: element.bounds.width, + height: element.bounds.height, + element_count: 1, + }, + } + .to_semantic_description(); + + match target { + CodeGenTarget::Html => format!( + "\n\n
\n \n
", + element.id, description + ), + CodeGenTarget::React => format!( + "// Generated from SVG element: {}\n// Structure:\n// {}\n\nexport function Component() {{\n return (\n
\n {{/* TODO: Implement based on structure */}}\n
\n );\n}}", + element.id, + description.replace('\n', "\n// ") + ), + CodeGenTarget::Tailwind => format!( + "\n\n
\n \n
", + element.id, description + ), + CodeGenTarget::Svelte => format!( + "\n\n\n\n
\n \n
", + element.id, description + ), + CodeGenTarget::Vue => format!( + "\n\n\n\n", + element.id, description + ), + } +} diff --git a/src/app.rs b/src/app.rs new file mode 100644 index 0000000..3fa0652 --- /dev/null +++ b/src/app.rs @@ -0,0 +1,328 @@ +use crate::agent::AgentServer; +use crate::canvas::{CanvasInteraction, CanvasState}; +use crate::clipboard::ClipboardManager; +use crate::element_tree::ElementTree; +use crate::svg::{parse_svg, SvgRenderer}; +use egui::{Color32, ColorImage, TextureHandle, TextureOptions}; +use std::sync::Arc; +use tokio::runtime::Runtime; +use tokio::sync::RwLock; + +const AGENT_PORT: u16 = 9876; + +pub struct AgCanvasApp { + canvas_state: CanvasState, + clipboard: Option, + svg_renderer: Option, + svg_texture: Option, + element_tree: Option, + tree_handle: Arc>>, + show_tree_panel: bool, + show_description: bool, + description_text: String, + status_message: Option<(String, std::time::Instant)>, + _runtime: Runtime, +} + +impl AgCanvasApp { + pub fn new(cc: &eframe::CreationContext<'_>) -> Self { + configure_fonts(&cc.egui_ctx); + + let runtime = Runtime::new().expect("Failed to create tokio runtime"); + let server = AgentServer::new(AGENT_PORT); + let tree_handle = server.tree_handle(); + + runtime.spawn(async move { + if let Err(e) = server.run().await { + tracing::error!("Agent server error: {}", e); + } + }); + + let clipboard = ClipboardManager::new().ok(); + + Self { + canvas_state: CanvasState::default(), + clipboard, + svg_renderer: None, + svg_texture: None, + element_tree: None, + tree_handle, + show_tree_panel: false, + show_description: false, + description_text: String::new(), + status_message: None, + _runtime: runtime, + } + } + + 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 { + match parse_svg(&svg_data) { + Ok((tree, usvg_tree)) => { + let (width, height) = (tree.metadata.width, tree.metadata.height); + self.element_tree = Some(tree.clone()); + self.description_text = tree.to_semantic_description(); + + let tree_handle = self.tree_handle.clone(); + let tree_clone = tree.clone(); + std::thread::spawn(move || { + let rt = Runtime::new().unwrap(); + rt.block_on(async { + let mut guard = tree_handle.write().await; + *guard = Some(tree_clone); + }); + }); + + self.svg_renderer = Some(SvgRenderer::new(usvg_tree)); + self.svg_texture = None; + + self.canvas_state.fit_to_rect( + egui::vec2(width, height), + ctx.screen_rect().size() * 0.8, + ); + + self.set_status(format!( + "Loaded SVG: {}x{} ({} elements)", + width as i32, height as i32, tree.metadata.element_count + )); + } + Err(e) => { + self.set_status(format!("Failed to parse SVG: {}", e)); + } + } + } + } + + fn render_svg_to_texture(&mut self, ctx: &egui::Context) { + if let Some(renderer) = &mut self.svg_renderer { + let scale = self.canvas_state.zoom.max(1.0); + 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 }; + self.svg_texture = Some(ctx.load_texture("svg", image, TextureOptions::LINEAR)); + } + } + } + + fn set_status(&mut self, message: String) { + self.status_message = Some((message, std::time::Instant::now())); + } +} + +impl eframe::App for AgCanvasApp { + fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) { + ctx.input(|i| { + if i.modifiers.command && i.key_pressed(egui::Key::V) { + self.handle_paste(ctx); + } + }); + + if self.svg_texture.is_none() && self.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| { + if ui.button("Paste SVG (Cmd+V)").clicked() { + self.handle_paste(ctx); + ui.close_menu(); + } + if ui.button("Clear Canvas").clicked() { + self.svg_renderer = None; + self.svg_texture = None; + self.element_tree = None; + self.canvas_state.reset(); + ui.close_menu(); + } + }); + ui.menu_button("View", |ui| { + if ui.checkbox(&mut self.show_tree_panel, "Element Tree").clicked() { + ui.close_menu(); + } + if ui.checkbox(&mut self.show_description, "Description").clicked() { + ui.close_menu(); + } + ui.separator(); + if ui.button("Reset Zoom (Cmd+0)").clicked() { + self.canvas_state.reset(); + ui.close_menu(); + } + if ui.button("Fit to View").clicked() { + if let Some(renderer) = &self.svg_renderer { + let (w, h) = renderer.size(); + self.canvas_state + .fit_to_rect(egui::vec2(w, h), ctx.screen_rect().size() * 0.8); + } + ui.close_menu(); + } + }); + + ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| { + ui.label(format!("Agent: ws://127.0.0.1:{}", AGENT_PORT)); + ui.separator(); + ui.label(format!("Zoom: {:.0}%", self.canvas_state.zoom * 100.0)); + }); + }); + }); + + if let Some((msg, time)) = &self.status_message { + if time.elapsed().as_secs() < 3 { + egui::TopBottomPanel::bottom("status").show(ctx, |ui| { + ui.label(msg); + }); + } else { + self.status_message = None; + } + } + + if self.show_tree_panel { + 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.element_tree { + render_tree_ui(ui, &tree.root, 0); + } else { + ui.label("No SVG loaded. Paste an SVG (Cmd+V)"); + } + }); + }); + } + + if self.show_description { + egui::SidePanel::left("description_panel") + .default_width(300.0) + .show(ctx, |ui| { + ui.heading("Semantic Description"); + ui.separator(); + egui::ScrollArea::vertical().show(ui, |ui| { + if !self.description_text.is_empty() { + ui.add( + egui::TextEdit::multiline(&mut self.description_text.as_str()) + .font(egui::TextStyle::Monospace) + .desired_width(f32::INFINITY), + ); + } else { + ui.label("No SVG loaded. Paste an SVG (Cmd+V)"); + } + }); + }); + } + + egui::CentralPanel::default().show(ctx, |ui| { + let response = CanvasInteraction::allocate_canvas(ui); + CanvasInteraction::handle(ui, &response, &mut self.canvas_state); + + let painter = ui.painter_at(response.rect); + painter.rect_filled(response.rect, 0.0, Color32::from_gray(30)); + + draw_grid(&painter, &response.rect, &self.canvas_state); + + if let Some(texture) = &self.svg_texture { + let center = response.rect.center(); + let size = texture.size_vec2() / self.canvas_state.zoom.max(1.0) * self.canvas_state.zoom; + let offset = self.canvas_state.offset * self.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); + } else { + painter.text( + response.rect.center(), + egui::Align2::CENTER_CENTER, + "Paste SVG from Figma (Cmd+V)", + egui::FontId::proportional(24.0), + Color32::from_gray(100), + ); + } + }); + + ctx.request_repaint(); + } +} + +fn configure_fonts(ctx: &egui::Context) { + let mut style = (*ctx.style()).clone(); + style.visuals.window_rounding = egui::Rounding::same(8.0); + style.visuals.panel_fill = Color32::from_gray(25); + ctx.set_style(style); +} + +fn draw_grid(painter: &egui::Painter, rect: &egui::Rect, state: &CanvasState) { + 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(); + + let start_x = ((rect.left() - center.x - offset.x) / grid_size).floor() as i32; + let end_x = ((rect.right() - center.x - offset.x) / grid_size).ceil() as i32; + let start_y = ((rect.top() - center.y - offset.y) / grid_size).floor() as i32; + let end_y = ((rect.bottom() - center.y - offset.y) / grid_size).ceil() as i32; + + for i in start_x..=end_x { + let x = center.x + offset.x + i as f32 * grid_size; + 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), + ); + } + } + + for i in start_y..=end_y { + let y = center.y + offset.y + i as f32 * grid_size; + 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), + ); + } + } +} + +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), + }; + + if element.children.is_empty() { + ui.horizontal(|ui| { + ui.add_space(depth as f32 * 12.0); + ui.label(format!("• {}", kind_name)); + }); + } else { + egui::CollapsingHeader::new(kind_name) + .id_salt(&element.id) + .default_open(depth < 2) + .show(ui, |ui| { + for child in &element.children { + render_tree_ui(ui, child, depth + 1); + } + }); + } +} diff --git a/src/canvas/interaction.rs b/src/canvas/interaction.rs new file mode 100644 index 0000000..8607b09 --- /dev/null +++ b/src/canvas/interaction.rs @@ -0,0 +1,38 @@ +use super::CanvasState; +use egui::{Response, Sense, Ui}; + +pub struct CanvasInteraction; + +impl CanvasInteraction { + pub fn handle(ui: &Ui, response: &Response, state: &mut CanvasState) { + if response.dragged_by(egui::PointerButton::Middle) + || (response.dragged_by(egui::PointerButton::Primary) + && ui.input(|i| i.modifiers.command)) + { + state.pan(response.drag_delta()); + } + + if response.hovered() { + let scroll_delta = ui.input(|i| i.smooth_scroll_delta.y); + if scroll_delta != 0.0 { + let zoom_factor = 1.0 + scroll_delta * 0.001; + if let Some(pointer_pos) = response.hover_pos() { + state.zoom_at(pointer_pos, response.rect.center(), zoom_factor); + } + } + + ui.input(|i| { + if i.key_pressed(egui::Key::Num0) && i.modifiers.command { + state.reset(); + } + }); + } + } + + pub fn allocate_canvas(ui: &mut Ui) -> Response { + let available_size = ui.available_size(); + let (rect, response) = ui.allocate_exact_size(available_size, Sense::click_and_drag()); + ui.set_clip_rect(rect); + response + } +} diff --git a/src/canvas/mod.rs b/src/canvas/mod.rs new file mode 100644 index 0000000..9e852c1 --- /dev/null +++ b/src/canvas/mod.rs @@ -0,0 +1,5 @@ +mod state; +mod interaction; + +pub use state::CanvasState; +pub use interaction::CanvasInteraction; diff --git a/src/canvas/state.rs b/src/canvas/state.rs new file mode 100644 index 0000000..749540c --- /dev/null +++ b/src/canvas/state.rs @@ -0,0 +1,58 @@ +use egui::{Pos2, Vec2}; + +#[derive(Clone)] +pub struct CanvasState { + pub offset: Vec2, + pub zoom: f32, + pub zoom_min: f32, + pub zoom_max: f32, +} + +impl Default for CanvasState { + fn default() -> Self { + Self { + offset: Vec2::ZERO, + zoom: 1.0, + zoom_min: 0.1, + zoom_max: 10.0, + } + } +} + +impl CanvasState { + pub fn screen_to_canvas(&self, screen_pos: Pos2, canvas_center: Pos2) -> Pos2 { + let relative = screen_pos - canvas_center; + let canvas_relative = relative / self.zoom - self.offset; + Pos2::new(canvas_relative.x, canvas_relative.y) + } + + pub fn canvas_to_screen(&self, canvas_pos: Pos2, canvas_center: Pos2) -> Pos2 { + let offset_pos = Pos2::new(canvas_pos.x + self.offset.x, canvas_pos.y + self.offset.y); + canvas_center + (offset_pos.to_vec2() * self.zoom) + } + + pub fn zoom_at(&mut self, screen_pos: Pos2, canvas_center: Pos2, zoom_delta: f32) { + let canvas_pos_before = self.screen_to_canvas(screen_pos, canvas_center); + + self.zoom = (self.zoom * zoom_delta).clamp(self.zoom_min, self.zoom_max); + + let canvas_pos_after = self.screen_to_canvas(screen_pos, canvas_center); + self.offset += canvas_pos_after - canvas_pos_before; + } + + pub fn pan(&mut self, delta: Vec2) { + self.offset += delta / self.zoom; + } + + pub fn reset(&mut self) { + *self = Self::default(); + } + + pub fn fit_to_rect(&mut self, content_size: Vec2, viewport_size: Vec2) { + let scale_x = viewport_size.x / content_size.x; + let scale_y = viewport_size.y / content_size.y; + self.zoom = scale_x.min(scale_y) * 0.9; + self.zoom = self.zoom.clamp(self.zoom_min, self.zoom_max); + self.offset = Vec2::ZERO; + } +} diff --git a/src/clipboard.rs b/src/clipboard.rs new file mode 100644 index 0000000..f68d9e8 --- /dev/null +++ b/src/clipboard.rs @@ -0,0 +1,32 @@ +use anyhow::Result; +use arboard::Clipboard; + +pub struct ClipboardManager { + clipboard: Clipboard, +} + +impl ClipboardManager { + pub fn new() -> Result { + let clipboard = Clipboard::new()?; + Ok(Self { clipboard }) + } + + pub fn get_svg(&mut self) -> Option { + let text = self.clipboard.get_text().ok()?; + + if is_svg_content(&text) { + Some(text) + } else { + None + } + } + + pub fn get_text(&mut self) -> Option { + self.clipboard.get_text().ok() + } +} + +fn is_svg_content(text: &str) -> bool { + let trimmed = text.trim(); + trimmed.starts_with(", + pub attributes: HashMap, +} + +#[derive(Debug, Clone, Serialize, Deserialize)] +#[serde(tag = "type")] +pub enum ElementKind { + Group { name: Option }, + Rectangle { rx: Option, ry: Option }, + Circle { cx: f32, cy: f32, r: f32 }, + Ellipse { cx: f32, cy: f32, rx: f32, ry: f32 }, + Path { d: String }, + Text { content: String, font_size: f32 }, + Image { href: String }, + Line { x1: f32, y1: f32, x2: f32, y2: f32 }, + Unknown { tag: String }, +} + +#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)] +pub struct Bounds { + pub x: f32, + pub y: f32, + pub width: f32, + pub height: f32, +} + +impl Bounds { + pub fn contains(&self, x: f32, y: f32) -> bool { + x >= self.x && x <= self.x + self.width && y >= self.y && y <= self.y + self.height + } + + pub fn center(&self) -> (f32, f32) { + (self.x + self.width / 2.0, self.y + self.height / 2.0) + } +} + +#[derive(Debug, Clone, Serialize, Deserialize, Default)] +pub struct ElementStyle { + pub fill: Option, + pub stroke: Option, + pub stroke_width: Option, + pub opacity: Option, +} + +impl ElementTree { + pub fn find_by_id(&self, id: &str) -> Option<&Element> { + find_element_recursive(&self.root, id) + } + + pub fn find_at_point(&self, x: f32, y: f32) -> Vec<&Element> { + let mut results = Vec::new(); + find_elements_at_point_recursive(&self.root, x, y, &mut results); + results + } + + pub fn flatten(&self) -> Vec<&Element> { + let mut elements = Vec::new(); + flatten_recursive(&self.root, &mut elements); + elements + } + + pub fn to_semantic_description(&self) -> String { + describe_element(&self.root, 0) + } +} + +fn find_element_recursive<'a>(element: &'a Element, id: &str) -> Option<&'a Element> { + if element.id == id { + return Some(element); + } + for child in &element.children { + if let Some(found) = find_element_recursive(child, id) { + return Some(found); + } + } + None +} + +fn find_elements_at_point_recursive<'a>( + element: &'a Element, + x: f32, + y: f32, + results: &mut Vec<&'a Element>, +) { + if element.bounds.contains(x, y) { + results.push(element); + } + for child in &element.children { + find_elements_at_point_recursive(child, x, y, results); + } +} + +fn flatten_recursive<'a>(element: &'a Element, results: &mut Vec<&'a Element>) { + results.push(element); + for child in &element.children { + flatten_recursive(child, results); + } +} + +fn describe_element(element: &Element, depth: usize) -> String { + let indent = " ".repeat(depth); + let mut desc = String::new(); + + let kind_desc = match &element.kind { + ElementKind::Group { name } => { + format!( + "Group{}", + name.as_ref() + .map(|n| format!(" '{}'", n)) + .unwrap_or_default() + ) + } + ElementKind::Rectangle { .. } => format!( + "Rectangle ({}x{})", + element.bounds.width as i32, element.bounds.height as i32 + ), + ElementKind::Circle { r, .. } => format!("Circle (r={})", r), + ElementKind::Ellipse { rx, ry, .. } => format!("Ellipse ({}x{})", rx, ry), + ElementKind::Path { .. } => "Path".to_string(), + ElementKind::Text { content, font_size } => { + format!("Text '{}' ({}px)", truncate(content, 30), font_size) + } + ElementKind::Image { .. } => "Image".to_string(), + ElementKind::Line { .. } => "Line".to_string(), + ElementKind::Unknown { tag } => format!("Unknown <{}>", tag), + }; + + let style_info = element + .style + .fill + .as_ref() + .map(|f| format!(" fill={}", f)) + .unwrap_or_default(); + + desc.push_str(&format!("{}- {}{}\n", indent, kind_desc, style_info)); + + for child in &element.children { + desc.push_str(&describe_element(child, depth + 1)); + } + + desc +} + +fn truncate(s: &str, max_len: usize) -> String { + if s.len() <= max_len { + s.to_string() + } else { + format!("{}...", &s[..max_len - 3]) + } +} diff --git a/src/main.rs b/src/main.rs new file mode 100644 index 0000000..c66dc07 --- /dev/null +++ b/src/main.rs @@ -0,0 +1,33 @@ +mod app; +mod canvas; +mod element_tree; +mod svg; +mod clipboard; +mod agent; + +use anyhow::Result; +use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt}; + +fn main() -> Result<()> { + tracing_subscriber::registry() + .with(tracing_subscriber::EnvFilter::new( + std::env::var("RUST_LOG").unwrap_or_else(|_| "agcanvas=debug".into()), + )) + .with(tracing_subscriber::fmt::layer()) + .init(); + + let native_options = eframe::NativeOptions { + viewport: egui::ViewportBuilder::default() + .with_inner_size([1400.0, 900.0]) + .with_min_inner_size([800.0, 600.0]) + .with_title("agcanvas"), + ..Default::default() + }; + + eframe::run_native( + "agcanvas", + native_options, + Box::new(|cc| Ok(Box::new(app::AgCanvasApp::new(cc)))), + ) + .map_err(|e| anyhow::anyhow!("Failed to run eframe: {}", e)) +} diff --git a/src/svg/mod.rs b/src/svg/mod.rs new file mode 100644 index 0000000..f73ea5b --- /dev/null +++ b/src/svg/mod.rs @@ -0,0 +1,5 @@ +mod parser; +mod renderer; + +pub use parser::parse_svg; +pub use renderer::SvgRenderer; diff --git a/src/svg/parser.rs b/src/svg/parser.rs new file mode 100644 index 0000000..b1defb4 --- /dev/null +++ b/src/svg/parser.rs @@ -0,0 +1,266 @@ +use crate::element_tree::{Bounds, Element, ElementKind, ElementStyle, ElementTree, TreeMetadata}; +use anyhow::Result; +use std::collections::HashMap; +use std::sync::atomic::{AtomicUsize, Ordering}; +use usvg::{Group, Node, Tree}; + +static ELEMENT_COUNTER: AtomicUsize = AtomicUsize::new(0); + +fn generate_id() -> String { + format!("el_{}", ELEMENT_COUNTER.fetch_add(1, Ordering::SeqCst)) +} + +pub fn parse_svg(svg_data: &str) -> Result<(ElementTree, Tree)> { + let options = usvg::Options::default(); + let tree = Tree::from_str(svg_data, &options)?; + + let size = tree.size(); + let root = parse_group(tree.root()); + let element_count = count_elements(&root); + + let metadata = TreeMetadata { + source: "svg_paste".to_string(), + width: size.width(), + height: size.height(), + element_count, + }; + + Ok((ElementTree { root, metadata }, tree)) +} + +fn parse_group(group: &Group) -> Element { + let bbox = group.bounding_box(); + let bounds = Bounds { + x: bbox.left(), + y: bbox.top(), + width: bbox.width(), + height: bbox.height(), + }; + + let children: Vec = group.children().iter().map(|c| parse_node(c)).collect(); + + let id_str = group.id(); + let name = if id_str.is_empty() { + None + } else { + Some(id_str.to_string()) + }; + + Element { + id: if id_str.is_empty() { + generate_id() + } else { + id_str.to_string() + }, + kind: ElementKind::Group { name }, + bounds, + style: ElementStyle::default(), + children, + attributes: HashMap::new(), + } +} + +fn parse_node(node: &Node) -> Element { + match node { + Node::Group(group) => parse_group(group), + Node::Path(path) => { + let bbox = path.bounding_box(); + let bounds = Bounds { + x: bbox.left(), + y: bbox.top(), + width: bbox.width(), + height: bbox.height(), + }; + + let style = extract_path_style(path); + let kind = classify_path(path, bounds); + let id_str = path.id(); + + Element { + id: if id_str.is_empty() { + generate_id() + } else { + id_str.to_string() + }, + kind, + bounds, + style, + children: vec![], + attributes: HashMap::new(), + } + } + Node::Image(img) => { + let size = img.size(); + let bounds = Bounds { + x: 0.0, + y: 0.0, + width: size.width(), + height: size.height(), + }; + let id_str = img.id(); + + Element { + id: if id_str.is_empty() { + generate_id() + } else { + id_str.to_string() + }, + kind: ElementKind::Image { + href: "embedded".to_string(), + }, + bounds, + style: ElementStyle::default(), + children: vec![], + attributes: HashMap::new(), + } + } + Node::Text(text) => { + let bbox = text.bounding_box(); + let bounds = Bounds { + x: bbox.left(), + y: bbox.top(), + width: bbox.width(), + height: bbox.height(), + }; + + let content = extract_text_content(text); + let font_size = extract_font_size(text); + let id_str = text.id(); + + Element { + id: if id_str.is_empty() { + generate_id() + } else { + id_str.to_string() + }, + kind: ElementKind::Text { content, font_size }, + bounds, + style: ElementStyle::default(), + children: vec![], + attributes: HashMap::new(), + } + } + } +} + +fn extract_path_style(path: &usvg::Path) -> ElementStyle { + let fill = path.fill().and_then(|f| match f.paint() { + usvg::Paint::Color(c) => Some(format!("#{:02x}{:02x}{:02x}", c.red, c.green, c.blue)), + _ => None, + }); + + let (stroke, stroke_width) = path + .stroke() + .map(|s| { + let color = match s.paint() { + usvg::Paint::Color(c) => { + Some(format!("#{:02x}{:02x}{:02x}", c.red, c.green, c.blue)) + } + _ => None, + }; + (color, Some(s.width().get())) + }) + .unwrap_or((None, None)); + + let opacity = path.fill().map(|f| f.opacity().get()); + + ElementStyle { + fill, + stroke, + stroke_width, + opacity, + } +} + +fn classify_path(path: &usvg::Path, bounds: Bounds) -> ElementKind { + let d = path_to_d_string(path); + + if is_rectangle_path(path) { + return ElementKind::Rectangle { rx: None, ry: None }; + } + + if is_circle_like(bounds) { + let r = bounds.width / 2.0; + let (cx, cy) = bounds.center(); + return ElementKind::Circle { cx, cy, r }; + } + + ElementKind::Path { d } +} + +fn path_to_d_string(path: &usvg::Path) -> String { + use std::fmt::Write; + let mut d = String::new(); + + for segment in path.data().segments() { + match segment { + usvg::tiny_skia_path::PathSegment::MoveTo(p) => { + write!(d, "M{:.2},{:.2} ", p.x, p.y).unwrap(); + } + usvg::tiny_skia_path::PathSegment::LineTo(p) => { + write!(d, "L{:.2},{:.2} ", p.x, p.y).unwrap(); + } + usvg::tiny_skia_path::PathSegment::QuadTo(p1, p) => { + write!(d, "Q{:.2},{:.2} {:.2},{:.2} ", p1.x, p1.y, p.x, p.y).unwrap(); + } + usvg::tiny_skia_path::PathSegment::CubicTo(p1, p2, p) => { + write!( + d, + "C{:.2},{:.2} {:.2},{:.2} {:.2},{:.2} ", + p1.x, p1.y, p2.x, p2.y, p.x, p.y + ) + .unwrap(); + } + usvg::tiny_skia_path::PathSegment::Close => { + d.push_str("Z "); + } + } + } + + d.trim().to_string() +} + +fn is_rectangle_path(path: &usvg::Path) -> bool { + let segments: Vec<_> = path.data().segments().collect(); + if segments.len() < 4 { + return false; + } + + let mut line_count = 0; + for segment in &segments { + match segment { + usvg::tiny_skia_path::PathSegment::LineTo(_) => line_count += 1, + usvg::tiny_skia_path::PathSegment::MoveTo(_) => {} + usvg::tiny_skia_path::PathSegment::Close => {} + _ => return false, + } + } + + line_count == 4 +} + +fn is_circle_like(bounds: Bounds) -> bool { + let aspect_ratio = bounds.width / bounds.height.max(0.001); + (0.95..=1.05).contains(&aspect_ratio) +} + +fn extract_text_content(text: &usvg::Text) -> String { + text.chunks() + .iter() + .map(|chunk| chunk.text()) + .collect::>() + .join("") +} + +fn extract_font_size(text: &usvg::Text) -> f32 { + text.chunks() + .iter() + .flat_map(|chunk| chunk.spans().iter()) + .next() + .map(|span| span.font_size().get()) + .unwrap_or(16.0) +} + +fn count_elements(element: &Element) -> usize { + 1 + element.children.iter().map(count_elements).sum::() +} diff --git a/src/svg/renderer.rs b/src/svg/renderer.rs new file mode 100644 index 0000000..ce7f03c --- /dev/null +++ b/src/svg/renderer.rs @@ -0,0 +1,53 @@ +use anyhow::Result; +use tiny_skia::Pixmap; +use usvg::Tree; + +pub struct SvgRenderer { + tree: Tree, + pixmap: Option, + scale: f32, +} + +impl SvgRenderer { + pub fn new(tree: Tree) -> Self { + Self { + tree, + pixmap: None, + scale: 1.0, + } + } + + pub fn render(&mut self, scale: f32) -> Result<&Pixmap> { + let needs_rerender = self.pixmap.is_none() || (self.scale - scale).abs() > 0.001; + + if needs_rerender { + self.scale = scale; + let size = self.tree.size(); + let width = (size.width() * scale) as u32; + let height = (size.height() * scale) as u32; + + let mut pixmap = Pixmap::new(width.max(1), height.max(1)) + .ok_or_else(|| anyhow::anyhow!("Failed to create pixmap"))?; + + pixmap.fill(tiny_skia::Color::TRANSPARENT); + + let transform = tiny_skia::Transform::from_scale(scale, scale); + resvg::render(&self.tree, transform, &mut pixmap.as_mut()); + + self.pixmap = Some(pixmap); + } + + self.pixmap + .as_ref() + .ok_or_else(|| anyhow::anyhow!("Pixmap not available")) + } + + pub fn size(&self) -> (f32, f32) { + let size = self.tree.size(); + (size.width(), size.height()) + } + + pub fn tree(&self) -> &Tree { + &self.tree + } +}