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:
5
.gitignore
vendored
Normal file
5
.gitignore
vendored
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
/target
|
||||||
|
Cargo.lock
|
||||||
|
*.swp
|
||||||
|
*.swo
|
||||||
|
.DS_Store
|
||||||
51
Cargo.toml
Normal file
51
Cargo.toml
Normal 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
232
README.md
Normal 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
4
src/agent/mod.rs
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
mod protocol;
|
||||||
|
mod server;
|
||||||
|
|
||||||
|
pub use server::AgentServer;
|
||||||
68
src/agent/protocol.rs
Normal file
68
src/agent/protocol.rs
Normal 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
172
src/agent/server.rs
Normal 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
328
src/app.rs
Normal 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
38
src/canvas/interaction.rs
Normal 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
5
src/canvas/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mod state;
|
||||||
|
mod interaction;
|
||||||
|
|
||||||
|
pub use state::CanvasState;
|
||||||
|
pub use interaction::CanvasInteraction;
|
||||||
58
src/canvas/state.rs
Normal file
58
src/canvas/state.rs
Normal 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
32
src/clipboard.rs
Normal 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
173
src/element_tree.rs
Normal 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
33
src/main.rs
Normal 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
5
src/svg/mod.rs
Normal 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
266
src/svg/parser.rs
Normal 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
53
src/svg/renderer.rs
Normal 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
|
||||||
|
}
|
||||||
|
}
|
||||||
Reference in New Issue
Block a user