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
This commit is contained in:
David Ibia
2026-01-22 21:01:15 +01:00
commit f466a6af93
16 changed files with 1523 additions and 0 deletions

5
.gitignore vendored Normal file
View File

@@ -0,0 +1,5 @@
/target
Cargo.lock
*.swp
*.swo
.DS_Store

51
Cargo.toml Normal file
View File

@@ -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

232
README.md Normal file
View File

@@ -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 <div className=\"container\">\n {/* TODO: Implement based on structure */}\n </div>\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<String> },
Rectangle { rx: Option<f32>, ry: Option<f32> },
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

4
src/agent/mod.rs Normal file
View File

@@ -0,0 +1,4 @@
mod protocol;
mod server;
pub use server::AgentServer;

68
src/agent/protocol.rs Normal file
View File

@@ -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<String>,
},
Ping,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum AgentResponse {
Tree {
tree: ElementTree,
},
Element {
element: Option<crate::element_tree::Element>,
},
Elements {
elements: Vec<crate::element_tree::Element>,
},
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"),
}
}
}

172
src/agent/server.rs Normal file
View File

@@ -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<RwLock<Option<ElementTree>>>,
port: u16,
}
impl AgentServer {
pub fn new(port: u16) -> Self {
Self {
tree: Arc::new(RwLock::new(None)),
port,
}
}
pub fn tree_handle(&self) -> Arc<RwLock<Option<ElementTree>>> {
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<RwLock<Option<ElementTree>>>) -> 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::<AgentRequest>(&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<RwLock<Option<ElementTree>>>,
) -> 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!(
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<div class=\"container\">\n <!-- TODO: Implement based on structure -->\n</div>",
element.id, description
),
CodeGenTarget::React => format!(
"// Generated from SVG element: {}\n// Structure:\n// {}\n\nexport function Component() {{\n return (\n <div className=\"container\">\n {{/* TODO: Implement based on structure */}}\n </div>\n );\n}}",
element.id,
description.replace('\n', "\n// ")
),
CodeGenTarget::Tailwind => format!(
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<div class=\"flex flex-col\">\n <!-- TODO: Implement with Tailwind classes -->\n</div>",
element.id, description
),
CodeGenTarget::Svelte => format!(
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<script>\n // Component logic\n</script>\n\n<div class=\"container\">\n <!-- TODO: Implement -->\n</div>",
element.id, description
),
CodeGenTarget::Vue => format!(
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<template>\n <div class=\"container\">\n <!-- TODO: Implement -->\n </div>\n</template>\n\n<script setup>\n// Component logic\n</script>",
element.id, description
),
}
}

328
src/app.rs Normal file
View File

@@ -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<ClipboardManager>,
svg_renderer: Option<SvgRenderer>,
svg_texture: Option<TextureHandle>,
element_tree: Option<ElementTree>,
tree_handle: Arc<RwLock<Option<ElementTree>>>,
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<Color32> = 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);
}
});
}
}

38
src/canvas/interaction.rs Normal file
View File

@@ -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
}
}

5
src/canvas/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
mod state;
mod interaction;
pub use state::CanvasState;
pub use interaction::CanvasInteraction;

58
src/canvas/state.rs Normal file
View File

@@ -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;
}
}

32
src/clipboard.rs Normal file
View File

@@ -0,0 +1,32 @@
use anyhow::Result;
use arboard::Clipboard;
pub struct ClipboardManager {
clipboard: Clipboard,
}
impl ClipboardManager {
pub fn new() -> Result<Self> {
let clipboard = Clipboard::new()?;
Ok(Self { clipboard })
}
pub fn get_svg(&mut self) -> Option<String> {
let text = self.clipboard.get_text().ok()?;
if is_svg_content(&text) {
Some(text)
} else {
None
}
}
pub fn get_text(&mut self) -> Option<String> {
self.clipboard.get_text().ok()
}
}
fn is_svg_content(text: &str) -> bool {
let trimmed = text.trim();
trimmed.starts_with("<svg") || trimmed.starts_with("<?xml") && trimmed.contains("<svg")
}

173
src/element_tree.rs Normal file
View File

@@ -0,0 +1,173 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ElementTree {
pub root: Element,
pub metadata: TreeMetadata,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TreeMetadata {
pub source: String,
pub width: f32,
pub height: f32,
pub element_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Element {
pub id: String,
pub kind: ElementKind,
pub bounds: Bounds,
pub style: ElementStyle,
pub children: Vec<Element>,
pub attributes: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ElementKind {
Group { name: Option<String> },
Rectangle { rx: Option<f32>, ry: Option<f32> },
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<String>,
pub stroke: Option<String>,
pub stroke_width: Option<f32>,
pub opacity: Option<f32>,
}
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])
}
}

33
src/main.rs Normal file
View File

@@ -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))
}

5
src/svg/mod.rs Normal file
View File

@@ -0,0 +1,5 @@
mod parser;
mod renderer;
pub use parser::parse_svg;
pub use renderer::SvgRenderer;

266
src/svg/parser.rs Normal file
View File

@@ -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<Element> = 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::<Vec<_>>()
.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::<usize>()
}

53
src/svg/renderer.rs Normal file
View File

@@ -0,0 +1,53 @@
use anyhow::Result;
use tiny_skia::Pixmap;
use usvg::Tree;
pub struct SvgRenderer {
tree: Tree,
pixmap: Option<Pixmap>,
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
}
}