Add drawing tools, Mermaid support, and MCP server bridge
- Convert flat project to Cargo workspace (crates/agcanvas, crates/agcanvas-mcp) - Add drawing layer: rectangles, ellipses, lines, arrows, text with select/move/resize - Add Mermaid diagram rendering via mermaid-rs-renderer - Add agcanvas-mcp: MCP server bridge for Claude Code, OpenCode, and Codex - Add toolbar UI with keyboard shortcuts (V/R/E/L/A/T) and shape interaction - Add example MCP configs for Claude Code, OpenCode, and Codex - Update README with full feature docs, MCP setup, and updated architecture
This commit is contained in:
46
Cargo.toml
46
Cargo.toml
@@ -1,48 +1,12 @@
|
|||||||
[package]
|
[workspace]
|
||||||
name = "agcanvas"
|
members = ["crates/agcanvas", "crates/agcanvas-mcp"]
|
||||||
|
resolver = "2"
|
||||||
|
|
||||||
|
[workspace.package]
|
||||||
version = "0.1.0"
|
version = "0.1.0"
|
||||||
edition = "2021"
|
edition = "2021"
|
||||||
description = "Interactive canvas for agent-human collaboration with SVG support"
|
|
||||||
license = "MIT"
|
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]
|
[profile.release]
|
||||||
opt-level = 3
|
opt-level = 3
|
||||||
lto = true
|
lto = true
|
||||||
|
|||||||
285
README.md
285
README.md
@@ -1,38 +1,49 @@
|
|||||||
# agcanvas
|
# agcanvas
|
||||||
|
|
||||||
A system-level interactive canvas for agent-human collaboration. Paste SVGs from Figma, get structured understanding, iterate with AI agents.
|
A system-level interactive canvas for agent-human collaboration. Draw shapes, paste SVGs from Figma, render Mermaid diagrams, and let AI agents understand your designs via MCP or WebSocket.
|
||||||
|
|
||||||
## What is this?
|
## 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.
|
agcanvas bridges the gap between visual design and code generation. It's a **collaboration surface** for humans and AI agents — draw your ideas, paste designs, and let agents see exactly what's on the canvas.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌─────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
│ Figma agcanvas │
|
│ Figma / Draw / Mermaid agcanvas │
|
||||||
│ ┌─────┐ Copy SVG ┌──────────────────────────────┐ │
|
│ ┌─────┐ ┌─────┐ ┌─────┐ ┌──────────────────────────┐ │
|
||||||
│ │ │ ───────────► │ Canvas (pan/zoom) │ │
|
│ │ SVG │ │Shapes│ │Merm.│ │ Canvas (pan/zoom) │ │
|
||||||
│ │Frame│ │ ┌────────┐ ┌────────┐ │ │
|
│ │Paste│ │ Draw │ │ Diag│ │ ┌────────┐ ┌────────┐ │ │
|
||||||
│ │ │ │ │ Parsed │ │ Agent │ │ │
|
│ └──┬──┘ └──┬───┘ └──┬──┘ │ │ Parsed │ │ Agent │ │ │
|
||||||
│ └─────┘ │ │ Tree │ │ Server │ │ │
|
│ └────────┴─────────┘ │ │ Tree │ │ Server │ │ │
|
||||||
│ │ └────────┘ └───┬────┘ │ │
|
│ │ │ └────────┘ └───┬────┘ │ │
|
||||||
│ └──────────────────┼──────────┘ │
|
│ ▼ └──────────────────┼──────┘ │
|
||||||
|
│ Visual Canvas │ │
|
||||||
│ │ │
|
│ │ │
|
||||||
│ AI Agent ◄───── WebSocket (JSON) ────────┘ │
|
│ AI Agent ◄──── MCP (stdio) ◄── agcanvas-mcp ──┘ │
|
||||||
│ - Sees structure │
|
│ AI Agent ◄──── WebSocket (JSON) ───────────────┘ │
|
||||||
│ - Describes semantically │
|
│ - Sees structure (element tree) │
|
||||||
|
│ - Reads drawing shapes │
|
||||||
│ - Generates code │
|
│ - Generates code │
|
||||||
└─────────────────────────────────────────────────────────┘
|
└──────────────────────────────────────────────────────────────┘
|
||||||
```
|
```
|
||||||
|
|
||||||
## Features
|
## Features
|
||||||
|
|
||||||
|
### Canvas & Drawing
|
||||||
- **SVG Paste** — Copy frames from Figma, paste directly (Cmd+V)
|
- **SVG Paste** — Copy frames from Figma, paste directly (Cmd+V)
|
||||||
- **Structure Parsing** — SVG → typed element tree (groups, rects, circles, paths, text, images)
|
- **Shape Drawing** — Rectangles, ellipses, lines, arrows, text directly on canvas
|
||||||
- **Semantic Description** — Auto-generates human-readable structure description
|
- **Selection & Editing** — Select, move, resize shapes with corner handles
|
||||||
- **Agent Protocol** — WebSocket server for AI agents to query and understand the canvas
|
- **Mermaid Diagrams** — Write Mermaid syntax, render as SVG on canvas
|
||||||
- **Code Generation** — Stubs for React, HTML, Tailwind, Svelte, Vue
|
- **Sessions/Tabs** — Multiple canvases in tabs, each with independent state
|
||||||
- **Pan/Zoom** — Smooth canvas navigation
|
- **Pan/Zoom** — Smooth canvas navigation
|
||||||
|
|
||||||
|
### AI Agent Integration
|
||||||
|
- **MCP Server** — `agcanvas-mcp` bridge for Claude Code, OpenCode, and Codex
|
||||||
|
- **WebSocket Protocol** — Direct JSON API on `ws://127.0.0.1:9876`
|
||||||
|
- **Structure Parsing** — SVG → typed element tree (groups, rects, circles, paths, text, images)
|
||||||
|
- **Semantic Description** — Human-readable canvas description for LLMs
|
||||||
|
- **Bidirectional Events** — Push notifications to agents when canvas state changes
|
||||||
|
- **Code Generation** — Stubs for React, HTML, Tailwind, Svelte, Vue
|
||||||
|
|
||||||
## Installation
|
## Installation
|
||||||
|
|
||||||
### From source
|
### From source
|
||||||
@@ -41,9 +52,12 @@ agcanvas bridges the gap between visual design and code generation. It's not a d
|
|||||||
git clone https://github.com/yourusername/agcanvas.git
|
git clone https://github.com/yourusername/agcanvas.git
|
||||||
cd agcanvas
|
cd agcanvas
|
||||||
cargo build --release
|
cargo build --release
|
||||||
./target/release/agcanvas
|
|
||||||
```
|
```
|
||||||
|
|
||||||
|
This builds two binaries:
|
||||||
|
- `target/release/agcanvas` — The desktop app
|
||||||
|
- `target/release/agcanvas-mcp` — The MCP server bridge
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- Rust 1.70+
|
- Rust 1.70+
|
||||||
@@ -55,35 +69,93 @@ cargo build --release
|
|||||||
|
|
||||||
1. **Open agcanvas**
|
1. **Open agcanvas**
|
||||||
```bash
|
```bash
|
||||||
cargo run --release
|
cargo run --release -p agcanvas
|
||||||
```
|
```
|
||||||
|
|
||||||
2. **Copy SVG from Figma**
|
2. **Draw shapes** — Select a tool from the toolbar (or press a shortcut key) and drag on the canvas
|
||||||
- Select a frame in Figma
|
|
||||||
- Right-click → Copy as SVG (or Cmd+C)
|
|
||||||
|
|
||||||
3. **Paste into agcanvas**
|
3. **Paste SVG from Figma** — Copy a frame in Figma, then Cmd+V
|
||||||
- Cmd+V (or File → Paste SVG)
|
|
||||||
|
|
||||||
4. **Navigate**
|
4. **Render Mermaid** — Click the Mermaid button in the toolbar, write your diagram, click Render
|
||||||
- **Pan**: Middle-click drag, or Cmd+drag
|
|
||||||
- **Zoom**: Scroll wheel
|
|
||||||
- **Reset**: Cmd+0
|
|
||||||
|
|
||||||
5. **Inspect**
|
5. **Navigate** — Pan (middle-click drag or Cmd+drag), Zoom (scroll wheel), Reset (Cmd+0)
|
||||||
- View → Element Tree (hierarchical structure)
|
|
||||||
- View → Description (semantic text)
|
|
||||||
|
|
||||||
### Keyboard shortcuts
|
### Keyboard shortcuts
|
||||||
|
|
||||||
| Action | Shortcut |
|
| Action | Shortcut |
|
||||||
|--------|----------|
|
|--------|----------|
|
||||||
|
| Select tool | V |
|
||||||
|
| Rectangle tool | R |
|
||||||
|
| Ellipse tool | E |
|
||||||
|
| Line tool | L |
|
||||||
|
| Arrow tool | A |
|
||||||
|
| Text tool | T |
|
||||||
|
| Delete selected | Delete / Backspace |
|
||||||
|
| Cancel / back to Select | Escape |
|
||||||
| Paste SVG | Cmd+V |
|
| Paste SVG | Cmd+V |
|
||||||
|
| New Tab | Cmd+T |
|
||||||
|
| Close Tab | Cmd+W |
|
||||||
| Reset zoom | Cmd+0 |
|
| Reset zoom | Cmd+0 |
|
||||||
|
|
||||||
## Agent Protocol
|
## MCP Server (AI Agent Integration)
|
||||||
|
|
||||||
agcanvas exposes a WebSocket server on `ws://127.0.0.1:9876` for AI agents to interact with the canvas.
|
`agcanvas-mcp` is a standalone MCP server that bridges AI coding tools to the agcanvas desktop app. It communicates with agcanvas over WebSocket and exposes canvas data as MCP tools.
|
||||||
|
|
||||||
|
### Setup for Claude Code
|
||||||
|
|
||||||
|
Add to your project's `.mcp.json` (or `~/.claude/mcp.json` for global):
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"agcanvas": {
|
||||||
|
"command": "agcanvas-mcp",
|
||||||
|
"args": ["--port", "9876"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setup for OpenCode
|
||||||
|
|
||||||
|
Add to your `opencode.json`:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"agcanvas": {
|
||||||
|
"command": "agcanvas-mcp",
|
||||||
|
"args": ["--port", "9876"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Setup for Codex
|
||||||
|
|
||||||
|
Same MCP config format — add the `agcanvas` entry to your Codex MCP configuration.
|
||||||
|
|
||||||
|
> **Note:** Make sure `agcanvas-mcp` is in your PATH, or use the full path to the binary (e.g., `/path/to/target/release/agcanvas-mcp`). agcanvas must be running for the MCP tools to work.
|
||||||
|
|
||||||
|
See [`examples/mcp-configs/`](examples/mcp-configs/) for ready-to-copy configuration files.
|
||||||
|
|
||||||
|
### MCP Tools
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `list_sessions` | List all open tabs/sessions in agcanvas |
|
||||||
|
| `get_element_tree` | Get the full parsed SVG element tree (structured JSON) |
|
||||||
|
| `describe_canvas` | Get a human-readable description of the canvas |
|
||||||
|
| `get_element_by_id` | Look up a specific element by ID |
|
||||||
|
| `get_elements_at_point` | Find elements at an (x, y) coordinate |
|
||||||
|
| `get_drawing_elements` | Get all user-drawn shapes (rects, ellipses, lines, arrows, text) |
|
||||||
|
| `generate_code` | Generate code stubs (html, react, tailwind, svelte, vue) |
|
||||||
|
|
||||||
|
All tools accept an optional `session_id` parameter. If omitted, the active session is used.
|
||||||
|
|
||||||
|
## WebSocket Protocol
|
||||||
|
|
||||||
|
agcanvas also exposes a direct WebSocket server on `ws://127.0.0.1:9876` for custom integrations.
|
||||||
|
|
||||||
### Connecting
|
### Connecting
|
||||||
|
|
||||||
@@ -96,45 +168,56 @@ ws = websocket.create_connection("ws://127.0.0.1:9876")
|
|||||||
|
|
||||||
### Requests
|
### Requests
|
||||||
|
|
||||||
#### Get full element tree
|
All requests support an optional `session_id` parameter. If omitted, the active session is used.
|
||||||
|
|
||||||
|
#### List sessions
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"type": "GetTree"}
|
{"type": "ListSessions"}
|
||||||
```
|
```
|
||||||
|
|
||||||
Response:
|
Response:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "Tree",
|
"type": "Sessions",
|
||||||
"tree": {
|
"sessions": [
|
||||||
"root": {
|
{"id": "session-1", "name": "Tab 1", "has_svg": true, "element_count": 15},
|
||||||
"id": "frame-1",
|
{"id": "session-2", "name": "Tab 2", "has_svg": false, "element_count": null}
|
||||||
"kind": {"type": "Group", "name": "Login Form"},
|
],
|
||||||
"bounds": {"x": 0, "y": 0, "width": 400, "height": 600},
|
"active_session": "session-1"
|
||||||
"children": [...]
|
|
||||||
},
|
|
||||||
"metadata": {
|
|
||||||
"source": "svg_paste",
|
|
||||||
"width": 400,
|
|
||||||
"height": 600,
|
|
||||||
"element_count": 15
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Get full element tree
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type": "GetTree"}
|
||||||
|
{"type": "GetTree", "session_id": "session-2"}
|
||||||
|
```
|
||||||
|
|
||||||
#### Get semantic description
|
#### Get semantic description
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"type": "Describe"}
|
{"type": "Describe"}
|
||||||
```
|
```
|
||||||
|
|
||||||
Response:
|
#### Get drawing elements (user-drawn shapes)
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{"type": "GetDrawingElements"}
|
||||||
"type": "Description",
|
{"type": "GetDrawingElements", "session_id": "session-1"}
|
||||||
"text": "- Group 'Login Form'\n - Rectangle (400x600) fill=#ffffff\n - Text 'Welcome Back' (24px)\n - Rectangle (320x48) fill=#f0f0f0\n - Text 'Email' (14px)\n ..."
|
```
|
||||||
}
|
|
||||||
|
#### Get element by ID
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type": "GetElementById", "id": "button-primary"}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Query elements at point
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type": "GetElementsAtPoint", "x": 150.0, "y": 200.0}
|
||||||
```
|
```
|
||||||
|
|
||||||
#### Generate code
|
#### Generate code
|
||||||
@@ -145,33 +228,25 @@ Response:
|
|||||||
|
|
||||||
Targets: `html`, `react`, `tailwind`, `svelte`, `vue`
|
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
|
#### Ping
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{"type": "Ping"}
|
{"type": "Ping"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
### Events (GUI → Agent)
|
||||||
|
|
||||||
|
agcanvas pushes events to all connected agents when state changes:
|
||||||
|
|
||||||
|
| Event | Trigger |
|
||||||
|
|-------|---------|
|
||||||
|
| `Connected` | Agent connects (includes current session state) |
|
||||||
|
| `SessionCreated` | New tab created |
|
||||||
|
| `SessionClosed` | Tab closed |
|
||||||
|
| `SessionActivated` | User switches tabs |
|
||||||
|
| `SvgLoaded` | SVG pasted or loaded |
|
||||||
|
| `SvgCleared` | Canvas cleared |
|
||||||
|
|
||||||
### Element types
|
### Element types
|
||||||
|
|
||||||
```rust
|
```rust
|
||||||
@@ -190,20 +265,33 @@ enum ElementKind {
|
|||||||
## Architecture
|
## Architecture
|
||||||
|
|
||||||
```
|
```
|
||||||
src/
|
crates/
|
||||||
├── main.rs # Entry point, window setup
|
├── agcanvas/ # Desktop app
|
||||||
├── app.rs # Main application state, UI rendering
|
│ └── src/
|
||||||
├── element_tree.rs # Structured element representation
|
│ ├── main.rs # Entry point, window setup
|
||||||
├── clipboard.rs # System clipboard integration
|
│ ├── app.rs # Main app state, UI, toolbar, drawing interaction
|
||||||
├── canvas/
|
│ ├── session.rs # Session/tab state management
|
||||||
│ ├── state.rs # Pan/zoom transformation state
|
│ ├── element_tree.rs # Structured element representation
|
||||||
│ └── interaction.rs # Mouse/keyboard input handling
|
│ ├── clipboard.rs # System clipboard integration
|
||||||
├── svg/
|
│ ├── mermaid.rs # Mermaid → SVG rendering
|
||||||
│ ├── parser.rs # SVG → ElementTree conversion
|
│ ├── drawing/
|
||||||
│ └── renderer.rs # SVG → pixels (resvg/tiny-skia)
|
│ │ ├── element.rs # DrawingElement, Shape, ShapeStyle, hit testing
|
||||||
└── agent/
|
│ │ ├── tool.rs # Tool enum, DragState, ResizeHandle
|
||||||
├── protocol.rs # JSON message types
|
│ │ └── render.rs # Shape rendering via egui Painter
|
||||||
└── server.rs # WebSocket server
|
│ ├── 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
|
||||||
|
└── agcanvas-mcp/ # MCP server bridge
|
||||||
|
└── src/
|
||||||
|
├── main.rs # CLI entry point, stdio transport
|
||||||
|
├── bridge.rs # WebSocket client → agcanvas
|
||||||
|
└── tools.rs # MCP tool definitions
|
||||||
```
|
```
|
||||||
|
|
||||||
### Dependencies
|
### Dependencies
|
||||||
@@ -213,19 +301,26 @@ src/
|
|||||||
| `eframe`/`egui` | GUI framework |
|
| `eframe`/`egui` | GUI framework |
|
||||||
| `usvg` | SVG parsing |
|
| `usvg` | SVG parsing |
|
||||||
| `resvg`/`tiny-skia` | SVG rendering |
|
| `resvg`/`tiny-skia` | SVG rendering |
|
||||||
|
| `mermaid-rs-renderer` | Mermaid → SVG |
|
||||||
| `arboard` | Clipboard access |
|
| `arboard` | Clipboard access |
|
||||||
| `tokio-tungstenite` | WebSocket server |
|
| `tokio-tungstenite` | WebSocket (both server and client) |
|
||||||
|
| `rmcp` | MCP server SDK (Anthropic official) |
|
||||||
| `serde`/`serde_json` | Serialization |
|
| `serde`/`serde_json` | Serialization |
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
|
|
||||||
|
- [x] Multi-frame support (sessions/tabs)
|
||||||
|
- [x] Shape drawing (rectangle, ellipse, line, arrow, text)
|
||||||
|
- [x] Selection, move, resize with handles
|
||||||
|
- [x] Mermaid diagram rendering
|
||||||
|
- [x] MCP server bridge for AI coding tools
|
||||||
- [ ] Real code generation (not just stubs)
|
- [ ] Real code generation (not just stubs)
|
||||||
- [ ] Element selection on canvas
|
|
||||||
- [ ] Agent draw commands (modify canvas from agent)
|
- [ ] Agent draw commands (modify canvas from agent)
|
||||||
- [ ] Multi-frame support
|
|
||||||
- [ ] Export to file
|
- [ ] Export to file
|
||||||
- [ ] Diff view (before/after agent changes)
|
- [ ] Diff view (before/after agent changes)
|
||||||
- [ ] Plugin system for code generators
|
- [ ] Plugin system for code generators
|
||||||
|
- [ ] Undo/redo
|
||||||
|
- [ ] Multi-select and group operations
|
||||||
|
|
||||||
## License
|
## License
|
||||||
|
|
||||||
|
|||||||
23
crates/agcanvas-mcp/Cargo.toml
Normal file
23
crates/agcanvas-mcp/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
|||||||
|
[package]
|
||||||
|
name = "agcanvas-mcp"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description = "MCP server bridge for agcanvas — exposes canvas tools to Claude Code, OpenCode, and Codex"
|
||||||
|
|
||||||
|
[[bin]]
|
||||||
|
name = "agcanvas-mcp"
|
||||||
|
path = "src/main.rs"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
rmcp = { version = "0.14", features = ["server", "macros", "transport-io"] }
|
||||||
|
tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "io-std", "sync"] }
|
||||||
|
tokio-tungstenite = "0.24"
|
||||||
|
futures-util = "0.3"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0"
|
||||||
|
schemars = "1.0"
|
||||||
|
anyhow = "1.0"
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
clap = { version = "4.0", features = ["derive"] }
|
||||||
29
crates/agcanvas-mcp/src/bridge.rs
Normal file
29
crates/agcanvas-mcp/src/bridge.rs
Normal file
@@ -0,0 +1,29 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
|
||||||
|
pub async fn send_request(ws_url: &str, request_json: &str) -> Result<String> {
|
||||||
|
let (ws_stream, _) = tokio_tungstenite::connect_async(ws_url)
|
||||||
|
.await
|
||||||
|
.map_err(|e| {
|
||||||
|
anyhow::anyhow!(
|
||||||
|
"Cannot connect to agcanvas at {}. Is agcanvas running? Error: {}",
|
||||||
|
ws_url,
|
||||||
|
e
|
||||||
|
)
|
||||||
|
})?;
|
||||||
|
|
||||||
|
let (mut write, mut read) = ws_stream.split();
|
||||||
|
|
||||||
|
// Skip the initial Connected event
|
||||||
|
let _connected = read.next().await;
|
||||||
|
|
||||||
|
write.send(Message::Text(request_json.to_string())).await?;
|
||||||
|
|
||||||
|
match read.next().await {
|
||||||
|
Some(Ok(Message::Text(response))) => Ok(response),
|
||||||
|
Some(Ok(other)) => Err(anyhow::anyhow!("Unexpected message type: {:?}", other)),
|
||||||
|
Some(Err(e)) => Err(anyhow::anyhow!("WebSocket error: {}", e)),
|
||||||
|
None => Err(anyhow::anyhow!("Connection closed before response")),
|
||||||
|
}
|
||||||
|
}
|
||||||
38
crates/agcanvas-mcp/src/main.rs
Normal file
38
crates/agcanvas-mcp/src/main.rs
Normal file
@@ -0,0 +1,38 @@
|
|||||||
|
mod bridge;
|
||||||
|
mod tools;
|
||||||
|
|
||||||
|
use anyhow::Result;
|
||||||
|
use clap::Parser;
|
||||||
|
use rmcp::ServiceExt;
|
||||||
|
use tools::AgCanvasServer;
|
||||||
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|
||||||
|
#[derive(Parser)]
|
||||||
|
#[command(name = "agcanvas-mcp", about = "MCP server bridge for agcanvas")]
|
||||||
|
struct Cli {
|
||||||
|
#[arg(long, default_value = "9876")]
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tokio::main]
|
||||||
|
async fn main() -> Result<()> {
|
||||||
|
tracing_subscriber::registry()
|
||||||
|
.with(tracing_subscriber::EnvFilter::new(
|
||||||
|
std::env::var("RUST_LOG").unwrap_or_else(|_| "agcanvas_mcp=info".into()),
|
||||||
|
))
|
||||||
|
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
|
||||||
|
.init();
|
||||||
|
|
||||||
|
let cli = Cli::parse();
|
||||||
|
let ws_url = format!("ws://127.0.0.1:{}", cli.port);
|
||||||
|
|
||||||
|
tracing::info!("Starting agcanvas MCP server, connecting to {}", ws_url);
|
||||||
|
|
||||||
|
let server = AgCanvasServer::new(ws_url);
|
||||||
|
let service = server.serve(rmcp::transport::stdio()).await?;
|
||||||
|
|
||||||
|
tracing::info!("MCP server running on stdio");
|
||||||
|
service.waiting().await?;
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
202
crates/agcanvas-mcp/src/tools.rs
Normal file
202
crates/agcanvas-mcp/src/tools.rs
Normal file
@@ -0,0 +1,202 @@
|
|||||||
|
use crate::bridge::send_request;
|
||||||
|
use rmcp::{
|
||||||
|
ErrorData as McpError, ServerHandler,
|
||||||
|
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
|
||||||
|
model::*,
|
||||||
|
schemars, tool, tool_handler, tool_router,
|
||||||
|
};
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
|
pub struct SessionIdParam {
|
||||||
|
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
|
pub struct GetElementParam {
|
||||||
|
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
#[schemars(description = "Element ID to look up")]
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
|
pub struct GetElementsAtPointParam {
|
||||||
|
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
#[schemars(description = "X coordinate in canvas space")]
|
||||||
|
pub x: f32,
|
||||||
|
#[schemars(description = "Y coordinate in canvas space")]
|
||||||
|
pub y: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
|
pub struct GenerateCodeParam {
|
||||||
|
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
#[schemars(description = "Code generation target: html, react, tailwind, svelte, or vue")]
|
||||||
|
pub target: String,
|
||||||
|
#[schemars(description = "Element ID to generate code for. If omitted, generates for the entire tree.")]
|
||||||
|
pub element_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct AgCanvasServer {
|
||||||
|
ws_url: String,
|
||||||
|
tool_router: ToolRouter<Self>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool_router]
|
||||||
|
impl AgCanvasServer {
|
||||||
|
pub fn new(ws_url: String) -> Self {
|
||||||
|
Self {
|
||||||
|
ws_url,
|
||||||
|
tool_router: Self::tool_router(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(description = "List all open sessions/tabs in agcanvas. Returns session IDs, names, and whether they have SVG or drawing content loaded.")]
|
||||||
|
async fn list_sessions(&self) -> Result<CallToolResult, McpError> {
|
||||||
|
let request = serde_json::json!({"type": "ListSessions"});
|
||||||
|
self.call_agcanvas(&request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(description = "Get the full parsed SVG element tree from the canvas. Returns a hierarchical JSON structure with element types (Group, Rectangle, Circle, Path, Text, etc.), bounds, styles, and children. This is the primary way to understand what design is on the canvas.")]
|
||||||
|
async fn get_element_tree(
|
||||||
|
&self,
|
||||||
|
Parameters(params): Parameters<SessionIdParam>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
let mut request = serde_json::json!({"type": "GetTree"});
|
||||||
|
if let Some(sid) = params.session_id {
|
||||||
|
request["session_id"] = serde_json::Value::String(sid);
|
||||||
|
}
|
||||||
|
self.call_agcanvas(&request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(description = "Get a human-readable semantic description of the canvas content. Returns structured text describing the element hierarchy with types, dimensions, and colors. Useful for quickly understanding a design without parsing JSON.")]
|
||||||
|
async fn describe_canvas(
|
||||||
|
&self,
|
||||||
|
Parameters(params): Parameters<SessionIdParam>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
let mut request = serde_json::json!({"type": "Describe"});
|
||||||
|
if let Some(sid) = params.session_id {
|
||||||
|
request["session_id"] = serde_json::Value::String(sid);
|
||||||
|
}
|
||||||
|
self.call_agcanvas(&request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(description = "Get a specific element by its ID from the SVG element tree.")]
|
||||||
|
async fn get_element_by_id(
|
||||||
|
&self,
|
||||||
|
Parameters(params): Parameters<GetElementParam>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
let mut request = serde_json::json!({"type": "GetElementById", "id": params.id});
|
||||||
|
if let Some(sid) = params.session_id {
|
||||||
|
request["session_id"] = serde_json::Value::String(sid);
|
||||||
|
}
|
||||||
|
self.call_agcanvas(&request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(description = "Query which elements exist at a specific (x, y) coordinate on the canvas.")]
|
||||||
|
async fn get_elements_at_point(
|
||||||
|
&self,
|
||||||
|
Parameters(params): Parameters<GetElementsAtPointParam>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
let mut request =
|
||||||
|
serde_json::json!({"type": "GetElementsAtPoint", "x": params.x, "y": params.y});
|
||||||
|
if let Some(sid) = params.session_id {
|
||||||
|
request["session_id"] = serde_json::Value::String(sid);
|
||||||
|
}
|
||||||
|
self.call_agcanvas(&request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(description = "Get all user-drawn shapes (rectangles, ellipses, lines, arrows, text) from the drawing layer.")]
|
||||||
|
async fn get_drawing_elements(
|
||||||
|
&self,
|
||||||
|
Parameters(params): Parameters<SessionIdParam>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
let mut request = serde_json::json!({"type": "GetDrawingElements"});
|
||||||
|
if let Some(sid) = params.session_id {
|
||||||
|
request["session_id"] = serde_json::Value::String(sid);
|
||||||
|
}
|
||||||
|
self.call_agcanvas(&request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(description = "Generate a code stub from the SVG structure. Targets: html, react, tailwind, svelte, vue. Returns a starting template based on the element hierarchy.")]
|
||||||
|
async fn generate_code(
|
||||||
|
&self,
|
||||||
|
Parameters(params): Parameters<GenerateCodeParam>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
let mut request = serde_json::json!({
|
||||||
|
"type": "GenerateCode",
|
||||||
|
"target": params.target,
|
||||||
|
"element_id": params.element_id,
|
||||||
|
});
|
||||||
|
if let Some(sid) = params.session_id {
|
||||||
|
request["session_id"] = serde_json::Value::String(sid);
|
||||||
|
}
|
||||||
|
self.call_agcanvas(&request).await
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgCanvasServer {
|
||||||
|
async fn call_agcanvas(
|
||||||
|
&self,
|
||||||
|
request: &serde_json::Value,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
let request_str = serde_json::to_string(request)
|
||||||
|
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||||
|
|
||||||
|
match send_request(&self.ws_url, &request_str).await {
|
||||||
|
Ok(response) => {
|
||||||
|
let parsed: serde_json::Value = serde_json::from_str(&response)
|
||||||
|
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||||
|
|
||||||
|
let is_error = parsed
|
||||||
|
.get("type")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
== Some("Error");
|
||||||
|
|
||||||
|
if is_error {
|
||||||
|
let msg = parsed
|
||||||
|
.get("message")
|
||||||
|
.and_then(serde_json::Value::as_str)
|
||||||
|
.unwrap_or("Unknown error from agcanvas");
|
||||||
|
return Ok(CallToolResult::error(vec![Content::text(msg)]));
|
||||||
|
}
|
||||||
|
|
||||||
|
let pretty = serde_json::to_string_pretty(&parsed)
|
||||||
|
.unwrap_or_else(|_| response.clone());
|
||||||
|
Ok(CallToolResult::success(vec![Content::text(pretty)]))
|
||||||
|
}
|
||||||
|
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
|
||||||
|
"Failed to communicate with agcanvas: {}. Make sure agcanvas is running.",
|
||||||
|
e
|
||||||
|
))])),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool_handler]
|
||||||
|
impl ServerHandler for AgCanvasServer {
|
||||||
|
fn get_info(&self) -> ServerInfo {
|
||||||
|
ServerInfo {
|
||||||
|
instructions: Some(
|
||||||
|
"agcanvas MCP server — connects to agcanvas desktop app to query SVG designs, \
|
||||||
|
element trees, and user-drawn shapes. Use describe_canvas to understand the \
|
||||||
|
current design, get_element_tree for structured data, and generate_code for \
|
||||||
|
code stubs. Requires agcanvas to be running."
|
||||||
|
.into(),
|
||||||
|
),
|
||||||
|
capabilities: ServerCapabilities::builder().enable_tools().build(),
|
||||||
|
server_info: Implementation {
|
||||||
|
name: "agcanvas-mcp".into(),
|
||||||
|
version: env!("CARGO_PKG_VERSION").into(),
|
||||||
|
title: None,
|
||||||
|
icons: None,
|
||||||
|
website_url: None,
|
||||||
|
},
|
||||||
|
..Default::default()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
47
crates/agcanvas/Cargo.toml
Normal file
47
crates/agcanvas/Cargo.toml
Normal file
@@ -0,0 +1,47 @@
|
|||||||
|
[package]
|
||||||
|
name = "agcanvas"
|
||||||
|
version.workspace = true
|
||||||
|
edition.workspace = true
|
||||||
|
license.workspace = true
|
||||||
|
description = "Interactive canvas for agent-human collaboration with SVG support"
|
||||||
|
|
||||||
|
[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
|
||||||
|
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"
|
||||||
|
|
||||||
|
# Mermaid diagram rendering
|
||||||
|
mermaid-rs-renderer = { version = "0.1.2", default-features = false }
|
||||||
|
|
||||||
|
# Logging
|
||||||
|
tracing = "0.1"
|
||||||
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
# Error handling
|
||||||
|
anyhow = "1.0"
|
||||||
|
thiserror = "1.0"
|
||||||
|
|
||||||
|
# Image handling
|
||||||
|
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
mod protocol;
|
mod protocol;
|
||||||
mod server;
|
mod server;
|
||||||
|
|
||||||
|
pub use protocol::GuiEvent;
|
||||||
pub use server::AgentServer;
|
pub use server::AgentServer;
|
||||||
126
crates/agcanvas/src/agent/protocol.rs
Normal file
126
crates/agcanvas/src/agent/protocol.rs
Normal file
@@ -0,0 +1,126 @@
|
|||||||
|
use crate::drawing::DrawingElement;
|
||||||
|
use crate::element_tree::{ElementTree, TreeMetadata};
|
||||||
|
use crate::session::SessionInfo;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum GuiEvent {
|
||||||
|
Connected {
|
||||||
|
version: String,
|
||||||
|
sessions: Vec<SessionInfo>,
|
||||||
|
active_session: Option<String>,
|
||||||
|
},
|
||||||
|
SessionCreated {
|
||||||
|
session: SessionInfo,
|
||||||
|
},
|
||||||
|
SessionClosed {
|
||||||
|
session_id: String,
|
||||||
|
},
|
||||||
|
SessionActivated {
|
||||||
|
session_id: String,
|
||||||
|
},
|
||||||
|
SvgLoaded {
|
||||||
|
session_id: String,
|
||||||
|
metadata: TreeMetadata,
|
||||||
|
},
|
||||||
|
SvgCleared {
|
||||||
|
session_id: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum AgentRequest {
|
||||||
|
ListSessions,
|
||||||
|
GetTree {
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
},
|
||||||
|
GetElementById {
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
GetElementsAtPoint {
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
x: f32,
|
||||||
|
y: f32,
|
||||||
|
},
|
||||||
|
Describe {
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
},
|
||||||
|
GenerateCode {
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
target: CodeGenTarget,
|
||||||
|
element_id: Option<String>,
|
||||||
|
},
|
||||||
|
GetDrawingElements {
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
},
|
||||||
|
Ping,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum AgentResponse {
|
||||||
|
Sessions {
|
||||||
|
sessions: Vec<SessionInfo>,
|
||||||
|
active_session: Option<String>,
|
||||||
|
},
|
||||||
|
Tree {
|
||||||
|
session_id: String,
|
||||||
|
tree: ElementTree,
|
||||||
|
},
|
||||||
|
Element {
|
||||||
|
session_id: String,
|
||||||
|
element: Option<crate::element_tree::Element>,
|
||||||
|
},
|
||||||
|
Elements {
|
||||||
|
session_id: String,
|
||||||
|
elements: Vec<crate::element_tree::Element>,
|
||||||
|
},
|
||||||
|
Description {
|
||||||
|
session_id: String,
|
||||||
|
text: String,
|
||||||
|
},
|
||||||
|
Code {
|
||||||
|
session_id: String,
|
||||||
|
code: String,
|
||||||
|
target: CodeGenTarget,
|
||||||
|
},
|
||||||
|
DrawingElements {
|
||||||
|
session_id: String,
|
||||||
|
elements: Vec<DrawingElement>,
|
||||||
|
},
|
||||||
|
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"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
258
crates/agcanvas/src/agent/server.rs
Normal file
258
crates/agcanvas/src/agent/server.rs
Normal file
@@ -0,0 +1,258 @@
|
|||||||
|
use super::protocol::{AgentRequest, AgentResponse, CodeGenTarget, GuiEvent};
|
||||||
|
use crate::session::SessionStore;
|
||||||
|
use anyhow::Result;
|
||||||
|
use futures_util::{SinkExt, StreamExt};
|
||||||
|
use std::sync::Arc;
|
||||||
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
|
use tokio::sync::{broadcast, RwLock};
|
||||||
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
|
||||||
|
const EVENT_CHANNEL_CAPACITY: usize = 64;
|
||||||
|
|
||||||
|
pub struct AgentServer {
|
||||||
|
sessions: Arc<RwLock<SessionStore>>,
|
||||||
|
event_tx: broadcast::Sender<GuiEvent>,
|
||||||
|
port: u16,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl AgentServer {
|
||||||
|
pub fn new(port: u16) -> Self {
|
||||||
|
let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||||
|
Self {
|
||||||
|
sessions: Arc::new(RwLock::new(SessionStore::new())),
|
||||||
|
event_tx,
|
||||||
|
port,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn sessions_handle(&self) -> Arc<RwLock<SessionStore>> {
|
||||||
|
self.sessions.clone()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn event_sender(&self) -> broadcast::Sender<GuiEvent> {
|
||||||
|
self.event_tx.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 sessions = self.sessions.clone();
|
||||||
|
let event_rx = self.event_tx.subscribe();
|
||||||
|
tokio::spawn(async move {
|
||||||
|
if let Err(e) = handle_connection(stream, sessions, event_rx).await {
|
||||||
|
tracing::error!("Connection error: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn handle_connection(
|
||||||
|
stream: TcpStream,
|
||||||
|
sessions: Arc<RwLock<SessionStore>>,
|
||||||
|
mut event_rx: broadcast::Receiver<GuiEvent>,
|
||||||
|
) -> Result<()> {
|
||||||
|
let ws_stream = tokio_tungstenite::accept_async(stream).await?;
|
||||||
|
let (mut write, mut read) = ws_stream.split();
|
||||||
|
|
||||||
|
let connected_event = {
|
||||||
|
let store = sessions.read().await;
|
||||||
|
GuiEvent::Connected {
|
||||||
|
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||||
|
sessions: store.list_sessions(),
|
||||||
|
active_session: store.active_session_id().map(|s| s.to_string()),
|
||||||
|
}
|
||||||
|
};
|
||||||
|
let connected_json = serde_json::to_string(&connected_event)?;
|
||||||
|
write.send(Message::Text(connected_json)).await?;
|
||||||
|
|
||||||
|
loop {
|
||||||
|
tokio::select! {
|
||||||
|
msg = read.next() => {
|
||||||
|
match msg {
|
||||||
|
Some(Ok(Message::Text(text))) => {
|
||||||
|
let response = match serde_json::from_str::<AgentRequest>(&text) {
|
||||||
|
Ok(request) => process_request(request, &sessions).await,
|
||||||
|
Err(e) => AgentResponse::Error {
|
||||||
|
message: format!("Invalid request: {}", e),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
let response_text = serde_json::to_string(&response)?;
|
||||||
|
write.send(Message::Text(response_text)).await?;
|
||||||
|
}
|
||||||
|
Some(Ok(Message::Close(_))) | None => break,
|
||||||
|
Some(Err(e)) => {
|
||||||
|
tracing::warn!("WebSocket error: {}", e);
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event = event_rx.recv() => {
|
||||||
|
match event {
|
||||||
|
Ok(gui_event) => {
|
||||||
|
let event_json = serde_json::to_string(&gui_event)?;
|
||||||
|
if write.send(Message::Text(event_json)).await.is_err() {
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||||
|
tracing::warn!("Agent lagged, skipped {} events", n);
|
||||||
|
}
|
||||||
|
Err(broadcast::error::RecvError::Closed) => break,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
async fn process_request(
|
||||||
|
request: AgentRequest,
|
||||||
|
sessions: &Arc<RwLock<SessionStore>>,
|
||||||
|
) -> AgentResponse {
|
||||||
|
let store = sessions.read().await;
|
||||||
|
|
||||||
|
match request {
|
||||||
|
AgentRequest::Ping => AgentResponse::Pong,
|
||||||
|
|
||||||
|
AgentRequest::ListSessions => AgentResponse::Sessions {
|
||||||
|
sessions: store.list_sessions(),
|
||||||
|
active_session: store.active_session_id().map(|s| s.to_string()),
|
||||||
|
},
|
||||||
|
|
||||||
|
AgentRequest::GetTree { session_id } => {
|
||||||
|
match store.get_tree(session_id.as_deref()) {
|
||||||
|
Some((sid, tree)) => AgentResponse::Tree {
|
||||||
|
session_id: sid,
|
||||||
|
tree: tree.clone(),
|
||||||
|
},
|
||||||
|
None => AgentResponse::Error {
|
||||||
|
message: session_id
|
||||||
|
.map(|id| format!("Session '{}' not found or has no SVG", id))
|
||||||
|
.unwrap_or_else(|| "No active session or no SVG loaded".to_string()),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentRequest::GetElementById { session_id, id } => {
|
||||||
|
match store.get_tree(session_id.as_deref()) {
|
||||||
|
Some((sid, tree)) => AgentResponse::Element {
|
||||||
|
session_id: sid,
|
||||||
|
element: tree.find_by_id(&id).cloned(),
|
||||||
|
},
|
||||||
|
None => AgentResponse::Error {
|
||||||
|
message: "No session or SVG loaded".to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentRequest::GetElementsAtPoint { session_id, x, y } => {
|
||||||
|
match store.get_tree(session_id.as_deref()) {
|
||||||
|
Some((sid, tree)) => AgentResponse::Elements {
|
||||||
|
session_id: sid,
|
||||||
|
elements: tree.find_at_point(x, y).into_iter().cloned().collect(),
|
||||||
|
},
|
||||||
|
None => AgentResponse::Error {
|
||||||
|
message: "No session or SVG loaded".to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentRequest::Describe { session_id } => {
|
||||||
|
match store.get_tree(session_id.as_deref()) {
|
||||||
|
Some((sid, tree)) => AgentResponse::Description {
|
||||||
|
session_id: sid,
|
||||||
|
text: tree.to_semantic_description(),
|
||||||
|
},
|
||||||
|
None => AgentResponse::Error {
|
||||||
|
message: "No session or SVG loaded".to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentRequest::GetDrawingElements { session_id } => {
|
||||||
|
match store.get_drawing_elements(session_id.as_deref()) {
|
||||||
|
Some((sid, elements)) => AgentResponse::DrawingElements {
|
||||||
|
session_id: sid,
|
||||||
|
elements: elements.to_vec(),
|
||||||
|
},
|
||||||
|
None => AgentResponse::Error {
|
||||||
|
message: "No session found".to_string(),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentRequest::GenerateCode { session_id, target, element_id } => {
|
||||||
|
match store.get_tree(session_id.as_deref()) {
|
||||||
|
Some((sid, tree)) => {
|
||||||
|
let element = match &element_id {
|
||||||
|
Some(id) => tree.find_by_id(id),
|
||||||
|
None => Some(&tree.root),
|
||||||
|
};
|
||||||
|
|
||||||
|
match element {
|
||||||
|
Some(el) => {
|
||||||
|
let code = generate_code_stub(el, target);
|
||||||
|
AgentResponse::Code {
|
||||||
|
session_id: sid,
|
||||||
|
code,
|
||||||
|
target,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => AgentResponse::Error {
|
||||||
|
message: format!("Element not found: {:?}", element_id),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => AgentResponse::Error {
|
||||||
|
message: "No session or 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
|
||||||
|
),
|
||||||
|
}
|
||||||
|
}
|
||||||
1011
crates/agcanvas/src/app.rs
Normal file
1011
crates/agcanvas/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
@@ -26,11 +26,6 @@ impl CanvasState {
|
|||||||
Pos2::new(canvas_relative.x, canvas_relative.y)
|
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) {
|
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);
|
let canvas_pos_before = self.screen_to_canvas(screen_pos, canvas_center);
|
||||||
|
|
||||||
@@ -20,10 +20,6 @@ impl ClipboardManager {
|
|||||||
None
|
None
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn get_text(&mut self) -> Option<String> {
|
|
||||||
self.clipboard.get_text().ok()
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
fn is_svg_content(text: &str) -> bool {
|
fn is_svg_content(text: &str) -> bool {
|
||||||
259
crates/agcanvas/src/drawing/element.rs
Normal file
259
crates/agcanvas/src/drawing/element.rs
Normal file
@@ -0,0 +1,259 @@
|
|||||||
|
use egui::{Color32, Pos2};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||||
|
|
||||||
|
static DRAWING_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||||
|
|
||||||
|
pub fn generate_drawing_id() -> String {
|
||||||
|
format!("draw_{}", DRAWING_ID_COUNTER.fetch_add(1, Ordering::SeqCst))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct DrawingElement {
|
||||||
|
pub id: String,
|
||||||
|
pub shape: Shape,
|
||||||
|
pub style: ShapeStyle,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl DrawingElement {
|
||||||
|
pub fn new(shape: Shape, style: ShapeStyle) -> Self {
|
||||||
|
Self {
|
||||||
|
id: generate_drawing_id(),
|
||||||
|
shape,
|
||||||
|
style,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Axis-aligned bounding box in canvas coordinates.
|
||||||
|
pub fn bounding_rect(&self) -> egui::Rect {
|
||||||
|
match &self.shape {
|
||||||
|
Shape::Rectangle { pos, size } => egui::Rect::from_min_size(*pos, *size),
|
||||||
|
Shape::Ellipse { center, radii } => {
|
||||||
|
egui::Rect::from_center_size(*center, egui::vec2(radii.x * 2.0, radii.y * 2.0))
|
||||||
|
}
|
||||||
|
Shape::Line { start, end } | Shape::Arrow { start, end } => {
|
||||||
|
egui::Rect::from_two_pos(*start, *end)
|
||||||
|
}
|
||||||
|
Shape::Text {
|
||||||
|
pos,
|
||||||
|
content: _,
|
||||||
|
font_size,
|
||||||
|
} => {
|
||||||
|
// Approximate: we'll refine during rendering when we know actual text size.
|
||||||
|
let approx_width = 8.0 * font_size * 0.6;
|
||||||
|
let approx_height = *font_size * 1.4;
|
||||||
|
egui::Rect::from_min_size(*pos, egui::vec2(approx_width, approx_height))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Hit-test: does the point (in canvas coords) touch this element?
|
||||||
|
pub fn contains_point(&self, point: Pos2) -> bool {
|
||||||
|
let tolerance = 6.0; // px tolerance for thin shapes like lines
|
||||||
|
|
||||||
|
match &self.shape {
|
||||||
|
Shape::Rectangle { pos, size } => {
|
||||||
|
let rect = egui::Rect::from_min_size(*pos, *size);
|
||||||
|
rect.expand(tolerance).contains(point)
|
||||||
|
}
|
||||||
|
Shape::Ellipse { center, radii } => {
|
||||||
|
let dx = point.x - center.x;
|
||||||
|
let dy = point.y - center.y;
|
||||||
|
let rx = radii.x + tolerance;
|
||||||
|
let ry = radii.y + tolerance;
|
||||||
|
(dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1.0
|
||||||
|
}
|
||||||
|
Shape::Line { start, end } | Shape::Arrow { start, end } => {
|
||||||
|
point_to_segment_distance(point, *start, *end) <= tolerance
|
||||||
|
}
|
||||||
|
Shape::Text {
|
||||||
|
pos,
|
||||||
|
content: _,
|
||||||
|
font_size,
|
||||||
|
} => {
|
||||||
|
let approx_width = 8.0 * font_size * 0.6;
|
||||||
|
let approx_height = *font_size * 1.4;
|
||||||
|
let rect = egui::Rect::from_min_size(*pos, egui::vec2(approx_width, approx_height));
|
||||||
|
rect.expand(tolerance).contains(point)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Translate the element by a delta vector.
|
||||||
|
pub fn translate(&mut self, delta: egui::Vec2) {
|
||||||
|
match &mut self.shape {
|
||||||
|
Shape::Rectangle { pos, .. } => *pos += delta,
|
||||||
|
Shape::Ellipse { center, .. } => *center += delta,
|
||||||
|
Shape::Line { start, end } | Shape::Arrow { start, end } => {
|
||||||
|
*start += delta;
|
||||||
|
*end += delta;
|
||||||
|
}
|
||||||
|
Shape::Text { pos, .. } => *pos += delta,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Resize to fit a new bounding rect, preserving shape semantics.
|
||||||
|
pub fn resize_to(&mut self, new_rect: egui::Rect) {
|
||||||
|
match &mut self.shape {
|
||||||
|
Shape::Rectangle { pos, size } => {
|
||||||
|
*pos = new_rect.min;
|
||||||
|
*size = new_rect.size();
|
||||||
|
}
|
||||||
|
Shape::Ellipse { center, radii } => {
|
||||||
|
*center = new_rect.center();
|
||||||
|
*radii = egui::vec2(new_rect.width() / 2.0, new_rect.height() / 2.0);
|
||||||
|
}
|
||||||
|
Shape::Line { start, end } | Shape::Arrow { start, end } => {
|
||||||
|
*start = new_rect.min;
|
||||||
|
*end = new_rect.max;
|
||||||
|
}
|
||||||
|
Shape::Text { pos, .. } => {
|
||||||
|
*pos = new_rect.min;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum Shape {
|
||||||
|
Rectangle {
|
||||||
|
pos: Pos2,
|
||||||
|
size: egui::Vec2,
|
||||||
|
},
|
||||||
|
Ellipse {
|
||||||
|
center: Pos2,
|
||||||
|
radii: egui::Vec2,
|
||||||
|
},
|
||||||
|
Line {
|
||||||
|
start: Pos2,
|
||||||
|
end: Pos2,
|
||||||
|
},
|
||||||
|
Arrow {
|
||||||
|
start: Pos2,
|
||||||
|
end: Pos2,
|
||||||
|
},
|
||||||
|
Text {
|
||||||
|
pos: Pos2,
|
||||||
|
content: String,
|
||||||
|
font_size: f32,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct ShapeStyle {
|
||||||
|
pub fill: Option<Color32>,
|
||||||
|
pub stroke_color: Color32,
|
||||||
|
pub stroke_width: f32,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for ShapeStyle {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
fill: None,
|
||||||
|
stroke_color: Color32::WHITE,
|
||||||
|
stroke_width: 2.0,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Distance from point `p` to segment `a`–`b`.
|
||||||
|
fn point_to_segment_distance(p: Pos2, a: Pos2, b: Pos2) -> f32 {
|
||||||
|
let ab = b - a;
|
||||||
|
let ap = p - a;
|
||||||
|
let ab_len_sq = ab.length_sq();
|
||||||
|
|
||||||
|
if ab_len_sq < 1e-6 {
|
||||||
|
return ap.length();
|
||||||
|
}
|
||||||
|
|
||||||
|
let t = (ap.x * ab.x + ap.y * ab.y) / ab_len_sq;
|
||||||
|
let t = t.clamp(0.0, 1.0);
|
||||||
|
let closest = a + ab * t;
|
||||||
|
(p - closest).length()
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rectangle_contains_point_inside() {
|
||||||
|
let el = DrawingElement::new(
|
||||||
|
Shape::Rectangle {
|
||||||
|
pos: Pos2::new(10.0, 10.0),
|
||||||
|
size: egui::vec2(100.0, 50.0),
|
||||||
|
},
|
||||||
|
ShapeStyle::default(),
|
||||||
|
);
|
||||||
|
assert!(el.contains_point(Pos2::new(50.0, 30.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn rectangle_does_not_contain_distant_point() {
|
||||||
|
let el = DrawingElement::new(
|
||||||
|
Shape::Rectangle {
|
||||||
|
pos: Pos2::new(10.0, 10.0),
|
||||||
|
size: egui::vec2(100.0, 50.0),
|
||||||
|
},
|
||||||
|
ShapeStyle::default(),
|
||||||
|
);
|
||||||
|
assert!(!el.contains_point(Pos2::new(500.0, 500.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn line_hit_test_near_segment() {
|
||||||
|
let el = DrawingElement::new(
|
||||||
|
Shape::Line {
|
||||||
|
start: Pos2::new(0.0, 0.0),
|
||||||
|
end: Pos2::new(100.0, 0.0),
|
||||||
|
},
|
||||||
|
ShapeStyle::default(),
|
||||||
|
);
|
||||||
|
// Point 3px above the line — within tolerance
|
||||||
|
assert!(el.contains_point(Pos2::new(50.0, 3.0)));
|
||||||
|
// Point 20px above — outside tolerance
|
||||||
|
assert!(!el.contains_point(Pos2::new(50.0, 20.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn ellipse_contains_center() {
|
||||||
|
let el = DrawingElement::new(
|
||||||
|
Shape::Ellipse {
|
||||||
|
center: Pos2::new(50.0, 50.0),
|
||||||
|
radii: egui::vec2(30.0, 20.0),
|
||||||
|
},
|
||||||
|
ShapeStyle::default(),
|
||||||
|
);
|
||||||
|
assert!(el.contains_point(Pos2::new(50.0, 50.0)));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn translate_moves_element() {
|
||||||
|
let mut el = DrawingElement::new(
|
||||||
|
Shape::Rectangle {
|
||||||
|
pos: Pos2::new(10.0, 10.0),
|
||||||
|
size: egui::vec2(100.0, 50.0),
|
||||||
|
},
|
||||||
|
ShapeStyle::default(),
|
||||||
|
);
|
||||||
|
el.translate(egui::vec2(5.0, 5.0));
|
||||||
|
match &el.shape {
|
||||||
|
Shape::Rectangle { pos, .. } => {
|
||||||
|
assert!((pos.x - 15.0).abs() < 0.01);
|
||||||
|
assert!((pos.y - 15.0).abs() < 0.01);
|
||||||
|
}
|
||||||
|
_ => panic!("Expected Rectangle"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn point_to_segment_distance_perpendicular() {
|
||||||
|
let dist = super::point_to_segment_distance(
|
||||||
|
Pos2::new(5.0, 5.0),
|
||||||
|
Pos2::new(0.0, 0.0),
|
||||||
|
Pos2::new(10.0, 0.0),
|
||||||
|
);
|
||||||
|
assert!((dist - 5.0).abs() < 0.01);
|
||||||
|
}
|
||||||
|
}
|
||||||
10
crates/agcanvas/src/drawing/mod.rs
Normal file
10
crates/agcanvas/src/drawing/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
mod element;
|
||||||
|
mod render;
|
||||||
|
mod tool;
|
||||||
|
|
||||||
|
pub use element::{DrawingElement, Shape, ShapeStyle};
|
||||||
|
pub use render::{
|
||||||
|
draw_creation_preview, draw_elements, draw_selection, find_handle_at_screen_pos,
|
||||||
|
screen_to_canvas,
|
||||||
|
};
|
||||||
|
pub use tool::{DragState, Tool};
|
||||||
208
crates/agcanvas/src/drawing/render.rs
Normal file
208
crates/agcanvas/src/drawing/render.rs
Normal file
@@ -0,0 +1,208 @@
|
|||||||
|
use super::element::DrawingElement;
|
||||||
|
use super::element::Shape;
|
||||||
|
use super::tool::{DragState, ResizeHandle, Tool};
|
||||||
|
use egui::{Color32, Painter, Pos2, Stroke, Vec2};
|
||||||
|
|
||||||
|
const HANDLE_RADIUS: f32 = 4.0;
|
||||||
|
const SELECTION_COLOR: Color32 = Color32::from_rgb(59, 130, 246);
|
||||||
|
const CREATION_PREVIEW_COLOR: Color32 = Color32::from_rgba_premultiplied(59, 130, 246, 128);
|
||||||
|
|
||||||
|
pub fn draw_elements(
|
||||||
|
painter: &Painter,
|
||||||
|
elements: &[DrawingElement],
|
||||||
|
canvas_center: Pos2,
|
||||||
|
offset: Vec2,
|
||||||
|
zoom: f32,
|
||||||
|
) {
|
||||||
|
for element in elements {
|
||||||
|
draw_element(painter, element, canvas_center, offset, zoom);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw_element(
|
||||||
|
painter: &Painter,
|
||||||
|
element: &DrawingElement,
|
||||||
|
canvas_center: Pos2,
|
||||||
|
offset: Vec2,
|
||||||
|
zoom: f32,
|
||||||
|
) {
|
||||||
|
let style = &element.style;
|
||||||
|
let stroke = Stroke::new(style.stroke_width * zoom, style.stroke_color);
|
||||||
|
|
||||||
|
match &element.shape {
|
||||||
|
Shape::Rectangle { pos, size } => {
|
||||||
|
let screen_pos = canvas_to_screen(*pos, canvas_center, offset, zoom);
|
||||||
|
let screen_size = *size * zoom;
|
||||||
|
let rect = egui::Rect::from_min_size(screen_pos, screen_size);
|
||||||
|
|
||||||
|
if let Some(fill) = style.fill {
|
||||||
|
painter.rect_filled(rect, 0.0, fill);
|
||||||
|
}
|
||||||
|
painter.rect_stroke(rect, 0.0, stroke);
|
||||||
|
}
|
||||||
|
Shape::Ellipse { center, radii } => {
|
||||||
|
let screen_center = canvas_to_screen(*center, canvas_center, offset, zoom);
|
||||||
|
let screen_radii = *radii * zoom;
|
||||||
|
|
||||||
|
if let Some(fill) = style.fill {
|
||||||
|
painter.circle_filled(screen_center, screen_radii.x.min(screen_radii.y), fill);
|
||||||
|
}
|
||||||
|
|
||||||
|
let n_points = 64;
|
||||||
|
let points: Vec<Pos2> = (0..=n_points)
|
||||||
|
.map(|i| {
|
||||||
|
let angle = i as f32 / n_points as f32 * std::f32::consts::TAU;
|
||||||
|
Pos2::new(
|
||||||
|
screen_center.x + screen_radii.x * angle.cos(),
|
||||||
|
screen_center.y + screen_radii.y * angle.sin(),
|
||||||
|
)
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
painter.add(egui::Shape::line(points, stroke));
|
||||||
|
}
|
||||||
|
Shape::Line { start, end } => {
|
||||||
|
let s = canvas_to_screen(*start, canvas_center, offset, zoom);
|
||||||
|
let e = canvas_to_screen(*end, canvas_center, offset, zoom);
|
||||||
|
painter.line_segment([s, e], stroke);
|
||||||
|
}
|
||||||
|
Shape::Arrow { start, end } => {
|
||||||
|
let s = canvas_to_screen(*start, canvas_center, offset, zoom);
|
||||||
|
let e = canvas_to_screen(*end, canvas_center, offset, zoom);
|
||||||
|
painter.line_segment([s, e], stroke);
|
||||||
|
draw_arrowhead(painter, s, e, stroke);
|
||||||
|
}
|
||||||
|
Shape::Text {
|
||||||
|
pos,
|
||||||
|
content,
|
||||||
|
font_size,
|
||||||
|
} => {
|
||||||
|
let screen_pos = canvas_to_screen(*pos, canvas_center, offset, zoom);
|
||||||
|
let scaled_font_size = font_size * zoom;
|
||||||
|
painter.text(
|
||||||
|
screen_pos,
|
||||||
|
egui::Align2::LEFT_TOP,
|
||||||
|
content,
|
||||||
|
egui::FontId::proportional(scaled_font_size),
|
||||||
|
style.stroke_color,
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw_selection(
|
||||||
|
painter: &Painter,
|
||||||
|
element: &DrawingElement,
|
||||||
|
canvas_center: Pos2,
|
||||||
|
offset: Vec2,
|
||||||
|
zoom: f32,
|
||||||
|
) {
|
||||||
|
let rect = element.bounding_rect();
|
||||||
|
let screen_min = canvas_to_screen(rect.min, canvas_center, offset, zoom);
|
||||||
|
let screen_max = canvas_to_screen(rect.max, canvas_center, offset, zoom);
|
||||||
|
let screen_rect = egui::Rect::from_min_max(screen_min, screen_max);
|
||||||
|
|
||||||
|
painter.rect_stroke(
|
||||||
|
screen_rect.expand(2.0),
|
||||||
|
0.0,
|
||||||
|
Stroke::new(1.0, SELECTION_COLOR),
|
||||||
|
);
|
||||||
|
|
||||||
|
for handle in ResizeHandle::ALL {
|
||||||
|
let pos = handle.position_in_rect(screen_rect);
|
||||||
|
painter.circle_filled(pos, HANDLE_RADIUS, SELECTION_COLOR);
|
||||||
|
painter.circle_stroke(pos, HANDLE_RADIUS, Stroke::new(1.0, Color32::WHITE));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn draw_creation_preview(
|
||||||
|
painter: &Painter,
|
||||||
|
tool: Tool,
|
||||||
|
drag_state: &DragState,
|
||||||
|
canvas_center: Pos2,
|
||||||
|
offset: Vec2,
|
||||||
|
zoom: f32,
|
||||||
|
) {
|
||||||
|
if let DragState::Creating { start, current } = drag_state {
|
||||||
|
let s = canvas_to_screen(*start, canvas_center, offset, zoom);
|
||||||
|
let c = canvas_to_screen(*current, canvas_center, offset, zoom);
|
||||||
|
let preview_stroke = Stroke::new(2.0, CREATION_PREVIEW_COLOR);
|
||||||
|
|
||||||
|
match tool {
|
||||||
|
Tool::Rectangle => {
|
||||||
|
let rect = egui::Rect::from_two_pos(s, c);
|
||||||
|
painter.rect_stroke(rect, 0.0, preview_stroke);
|
||||||
|
}
|
||||||
|
Tool::Ellipse => {
|
||||||
|
let rect = egui::Rect::from_two_pos(s, c);
|
||||||
|
let center = rect.center();
|
||||||
|
let rx = rect.width() / 2.0;
|
||||||
|
let ry = rect.height() / 2.0;
|
||||||
|
|
||||||
|
let n_points = 64;
|
||||||
|
let points: Vec<Pos2> = (0..=n_points)
|
||||||
|
.map(|i| {
|
||||||
|
let angle = i as f32 / n_points as f32 * std::f32::consts::TAU;
|
||||||
|
Pos2::new(center.x + rx * angle.cos(), center.y + ry * angle.sin())
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
painter.add(egui::Shape::line(points, preview_stroke));
|
||||||
|
}
|
||||||
|
Tool::Line => {
|
||||||
|
painter.line_segment([s, c], preview_stroke);
|
||||||
|
}
|
||||||
|
Tool::Arrow => {
|
||||||
|
painter.line_segment([s, c], preview_stroke);
|
||||||
|
draw_arrowhead(painter, s, c, preview_stroke);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn draw_arrowhead(painter: &Painter, from: Pos2, to: Pos2, stroke: Stroke) {
|
||||||
|
let dir = to - from;
|
||||||
|
let len = dir.length();
|
||||||
|
if len < 1.0 {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let dir_norm = dir / len;
|
||||||
|
let perp = Vec2::new(-dir_norm.y, dir_norm.x);
|
||||||
|
let arrow_size = 12.0;
|
||||||
|
|
||||||
|
let left = to - dir_norm * arrow_size + perp * (arrow_size * 0.4);
|
||||||
|
let right = to - dir_norm * arrow_size - perp * (arrow_size * 0.4);
|
||||||
|
|
||||||
|
painter.line_segment([left, to], stroke);
|
||||||
|
painter.line_segment([right, to], stroke);
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn canvas_to_screen(canvas_pos: Pos2, canvas_center: Pos2, offset: Vec2, zoom: f32) -> Pos2 {
|
||||||
|
canvas_center + (canvas_pos.to_vec2() + offset) * zoom
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn screen_to_canvas(screen_pos: Pos2, canvas_center: Pos2, offset: Vec2, zoom: f32) -> Pos2 {
|
||||||
|
let relative = screen_pos - canvas_center;
|
||||||
|
(relative / zoom - offset).to_pos2()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn find_handle_at_screen_pos(
|
||||||
|
element: &DrawingElement,
|
||||||
|
screen_pos: Pos2,
|
||||||
|
canvas_center: Pos2,
|
||||||
|
offset: Vec2,
|
||||||
|
zoom: f32,
|
||||||
|
) -> Option<ResizeHandle> {
|
||||||
|
let rect = element.bounding_rect();
|
||||||
|
let screen_min = canvas_to_screen(rect.min, canvas_center, offset, zoom);
|
||||||
|
let screen_max = canvas_to_screen(rect.max, canvas_center, offset, zoom);
|
||||||
|
let screen_rect = egui::Rect::from_min_max(screen_min, screen_max);
|
||||||
|
|
||||||
|
for handle in ResizeHandle::ALL {
|
||||||
|
let handle_pos = handle.position_in_rect(screen_rect);
|
||||||
|
if (screen_pos - handle_pos).length() <= HANDLE_RADIUS + 4.0 {
|
||||||
|
return Some(handle);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None
|
||||||
|
}
|
||||||
109
crates/agcanvas/src/drawing/tool.rs
Normal file
109
crates/agcanvas/src/drawing/tool.rs
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
use egui::Pos2;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||||
|
pub enum Tool {
|
||||||
|
#[default]
|
||||||
|
Select,
|
||||||
|
Rectangle,
|
||||||
|
Ellipse,
|
||||||
|
Line,
|
||||||
|
Arrow,
|
||||||
|
Text,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Tool {
|
||||||
|
pub fn label(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Tool::Select => "Select",
|
||||||
|
Tool::Rectangle => "Rect",
|
||||||
|
Tool::Ellipse => "Ellipse",
|
||||||
|
Tool::Line => "Line",
|
||||||
|
Tool::Arrow => "Arrow",
|
||||||
|
Tool::Text => "Text",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn shortcut(&self) -> Option<char> {
|
||||||
|
match self {
|
||||||
|
Tool::Select => Some('V'),
|
||||||
|
Tool::Rectangle => Some('R'),
|
||||||
|
Tool::Ellipse => Some('E'),
|
||||||
|
Tool::Line => Some('L'),
|
||||||
|
Tool::Arrow => Some('A'),
|
||||||
|
Tool::Text => Some('T'),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Default)]
|
||||||
|
pub enum DragState {
|
||||||
|
#[default]
|
||||||
|
None,
|
||||||
|
Creating {
|
||||||
|
start: Pos2,
|
||||||
|
current: Pos2,
|
||||||
|
},
|
||||||
|
Moving {
|
||||||
|
element_id: String,
|
||||||
|
},
|
||||||
|
Resizing {
|
||||||
|
handle: ResizeHandle,
|
||||||
|
element_id: String,
|
||||||
|
original_rect: egui::Rect,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
|
pub enum ResizeHandle {
|
||||||
|
TopLeft,
|
||||||
|
TopRight,
|
||||||
|
BottomLeft,
|
||||||
|
BottomRight,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl ResizeHandle {
|
||||||
|
pub const ALL: [ResizeHandle; 4] = [
|
||||||
|
ResizeHandle::TopLeft,
|
||||||
|
ResizeHandle::TopRight,
|
||||||
|
ResizeHandle::BottomLeft,
|
||||||
|
ResizeHandle::BottomRight,
|
||||||
|
];
|
||||||
|
|
||||||
|
pub fn position_in_rect(&self, rect: egui::Rect) -> Pos2 {
|
||||||
|
match self {
|
||||||
|
ResizeHandle::TopLeft => rect.left_top(),
|
||||||
|
ResizeHandle::TopRight => rect.right_top(),
|
||||||
|
ResizeHandle::BottomLeft => rect.left_bottom(),
|
||||||
|
ResizeHandle::BottomRight => rect.right_bottom(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn apply_delta(&self, original: egui::Rect, delta: egui::Vec2) -> egui::Rect {
|
||||||
|
let mut r = original;
|
||||||
|
match self {
|
||||||
|
ResizeHandle::TopLeft => {
|
||||||
|
r.min.x += delta.x;
|
||||||
|
r.min.y += delta.y;
|
||||||
|
}
|
||||||
|
ResizeHandle::TopRight => {
|
||||||
|
r.max.x += delta.x;
|
||||||
|
r.min.y += delta.y;
|
||||||
|
}
|
||||||
|
ResizeHandle::BottomLeft => {
|
||||||
|
r.min.x += delta.x;
|
||||||
|
r.max.y += delta.y;
|
||||||
|
}
|
||||||
|
ResizeHandle::BottomRight => {
|
||||||
|
r.max.x += delta.x;
|
||||||
|
r.max.y += delta.y;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Normalize so min < max
|
||||||
|
let min_x = r.min.x.min(r.max.x);
|
||||||
|
let max_x = r.min.x.max(r.max.x);
|
||||||
|
let min_y = r.min.y.min(r.max.y);
|
||||||
|
let max_y = r.min.y.max(r.max.y);
|
||||||
|
egui::Rect::from_min_max(Pos2::new(min_x, min_y), Pos2::new(max_x, max_y))
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -76,12 +76,6 @@ impl ElementTree {
|
|||||||
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 {
|
pub fn to_semantic_description(&self) -> String {
|
||||||
describe_element(&self.root, 0)
|
describe_element(&self.root, 0)
|
||||||
}
|
}
|
||||||
@@ -113,13 +107,6 @@ fn find_elements_at_point_recursive<'a>(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
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 {
|
fn describe_element(element: &Element, depth: usize) -> String {
|
||||||
let indent = " ".repeat(depth);
|
let indent = " ".repeat(depth);
|
||||||
let mut desc = String::new();
|
let mut desc = String::new();
|
||||||
@@ -1,9 +1,12 @@
|
|||||||
mod app;
|
mod app;
|
||||||
mod canvas;
|
mod canvas;
|
||||||
|
mod drawing;
|
||||||
mod element_tree;
|
mod element_tree;
|
||||||
|
mod mermaid;
|
||||||
mod svg;
|
mod svg;
|
||||||
mod clipboard;
|
mod clipboard;
|
||||||
mod agent;
|
mod agent;
|
||||||
|
mod session;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
24
crates/agcanvas/src/mermaid.rs
Normal file
24
crates/agcanvas/src/mermaid.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use anyhow::Result;
|
||||||
|
|
||||||
|
pub fn render_mermaid_to_svg(mermaid_source: &str) -> Result<String> {
|
||||||
|
let svg = mermaid_rs_renderer::render(mermaid_source)
|
||||||
|
.map_err(|e| anyhow::anyhow!("Mermaid render failed: {}", e))?;
|
||||||
|
Ok(svg)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_simple_flowchart() {
|
||||||
|
let svg = render_mermaid_to_svg("flowchart LR\n A-->B").unwrap();
|
||||||
|
assert!(svg.contains("<svg"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn renders_sequence_diagram() {
|
||||||
|
let svg = render_mermaid_to_svg("sequenceDiagram\n Alice->>Bob: Hello").unwrap();
|
||||||
|
assert!(svg.contains("<svg"));
|
||||||
|
}
|
||||||
|
}
|
||||||
176
crates/agcanvas/src/session.rs
Normal file
176
crates/agcanvas/src/session.rs
Normal file
@@ -0,0 +1,176 @@
|
|||||||
|
use crate::canvas::CanvasState;
|
||||||
|
use crate::drawing::{DragState, DrawingElement, Tool};
|
||||||
|
use crate::element_tree::ElementTree;
|
||||||
|
use crate::svg::SvgRenderer;
|
||||||
|
use egui::TextureHandle;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::collections::HashMap;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SessionInfo {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub has_svg: bool,
|
||||||
|
pub element_count: Option<usize>,
|
||||||
|
pub drawing_element_count: usize,
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct Session {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub canvas_state: CanvasState,
|
||||||
|
pub svg_renderer: Option<SvgRenderer>,
|
||||||
|
pub svg_texture: Option<TextureHandle>,
|
||||||
|
pub element_tree: Option<ElementTree>,
|
||||||
|
pub description_text: String,
|
||||||
|
|
||||||
|
pub drawing_elements: Vec<DrawingElement>,
|
||||||
|
pub selected_element_id: Option<String>,
|
||||||
|
pub active_tool: Tool,
|
||||||
|
pub drag_state: DragState,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Session {
|
||||||
|
pub fn new(id: String, name: String) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
canvas_state: CanvasState::default(),
|
||||||
|
svg_renderer: None,
|
||||||
|
svg_texture: None,
|
||||||
|
element_tree: None,
|
||||||
|
description_text: String::new(),
|
||||||
|
drawing_elements: Vec::new(),
|
||||||
|
selected_element_id: None,
|
||||||
|
active_tool: Tool::default(),
|
||||||
|
drag_state: DragState::default(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn info(&self) -> SessionInfo {
|
||||||
|
SessionInfo {
|
||||||
|
id: self.id.clone(),
|
||||||
|
name: self.name.clone(),
|
||||||
|
has_svg: self.element_tree.is_some(),
|
||||||
|
element_count: self.element_tree.as_ref().map(|t| t.metadata.element_count),
|
||||||
|
drawing_element_count: self.drawing_elements.len(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear(&mut self) {
|
||||||
|
self.svg_renderer = None;
|
||||||
|
self.svg_texture = None;
|
||||||
|
self.element_tree = None;
|
||||||
|
self.description_text.clear();
|
||||||
|
self.drawing_elements.clear();
|
||||||
|
self.selected_element_id = None;
|
||||||
|
self.drag_state = DragState::default();
|
||||||
|
self.canvas_state.reset();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_element(&self) -> Option<&DrawingElement> {
|
||||||
|
self.selected_element_id
|
||||||
|
.as_ref()
|
||||||
|
.and_then(|id| self.drawing_elements.iter().find(|e| e.id == *id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn selected_element_mut(&mut self) -> Option<&mut DrawingElement> {
|
||||||
|
let id = self.selected_element_id.clone();
|
||||||
|
id.and_then(move |id| self.drawing_elements.iter_mut().find(|e| e.id == id))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_selected(&mut self) {
|
||||||
|
if let Some(id) = self.selected_element_id.take() {
|
||||||
|
self.drawing_elements.retain(|e| e.id != id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct SessionData {
|
||||||
|
pub info: SessionInfo,
|
||||||
|
pub tree: Option<ElementTree>,
|
||||||
|
pub drawing_elements: Vec<DrawingElement>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Default)]
|
||||||
|
pub struct SessionStore {
|
||||||
|
sessions: HashMap<String, SessionData>,
|
||||||
|
active_session_id: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SessionStore {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
Self::default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn add_session(&mut self, info: SessionInfo, tree: Option<ElementTree>) {
|
||||||
|
let id = info.id.clone();
|
||||||
|
self.sessions.insert(
|
||||||
|
id.clone(),
|
||||||
|
SessionData {
|
||||||
|
info,
|
||||||
|
tree,
|
||||||
|
drawing_elements: Vec::new(),
|
||||||
|
},
|
||||||
|
);
|
||||||
|
if self.active_session_id.is_none() {
|
||||||
|
self.active_session_id = Some(id);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn remove_session(&mut self, session_id: &str) {
|
||||||
|
self.sessions.remove(session_id);
|
||||||
|
if self.active_session_id.as_deref() == Some(session_id) {
|
||||||
|
self.active_session_id = self.sessions.keys().next().cloned();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_active(&mut self, session_id: &str) {
|
||||||
|
if self.sessions.contains_key(session_id) {
|
||||||
|
self.active_session_id = Some(session_id.to_string());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_tree(&mut self, session_id: &str, tree: Option<ElementTree>) {
|
||||||
|
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||||
|
data.info.has_svg = tree.is_some();
|
||||||
|
data.info.element_count = tree.as_ref().map(|t| t.metadata.element_count);
|
||||||
|
data.tree = tree;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_tree(&self, session_id: Option<&str>) -> Option<(String, &ElementTree)> {
|
||||||
|
let id = session_id
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.or_else(|| self.active_session_id.clone())?;
|
||||||
|
let data = self.sessions.get(&id)?;
|
||||||
|
data.tree.as_ref().map(|t| (id, t))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_drawing_elements(&mut self, session_id: &str, elements: Vec<DrawingElement>) {
|
||||||
|
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||||
|
data.info.drawing_element_count = elements.len();
|
||||||
|
data.drawing_elements = elements;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn get_drawing_elements(
|
||||||
|
&self,
|
||||||
|
session_id: Option<&str>,
|
||||||
|
) -> Option<(String, &[DrawingElement])> {
|
||||||
|
let id = session_id
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.or_else(|| self.active_session_id.clone())?;
|
||||||
|
let data = self.sessions.get(&id)?;
|
||||||
|
Some((id, &data.drawing_elements))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn list_sessions(&self) -> Vec<SessionInfo> {
|
||||||
|
self.sessions.values().map(|d| d.info.clone()).collect()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn active_session_id(&self) -> Option<&str> {
|
||||||
|
self.active_session_id.as_deref()
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -37,7 +37,7 @@ fn parse_group(group: &Group) -> Element {
|
|||||||
height: bbox.height(),
|
height: bbox.height(),
|
||||||
};
|
};
|
||||||
|
|
||||||
let children: Vec<Element> = group.children().iter().map(|c| parse_node(c)).collect();
|
let children: Vec<Element> = group.children().iter().map(parse_node).collect();
|
||||||
|
|
||||||
let id_str = group.id();
|
let id_str = group.id();
|
||||||
let name = if id_str.is_empty() {
|
let name = if id_str.is_empty() {
|
||||||
@@ -46,8 +46,4 @@ impl SvgRenderer {
|
|||||||
let size = self.tree.size();
|
let size = self.tree.size();
|
||||||
(size.width(), size.height())
|
(size.width(), size.height())
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn tree(&self) -> &Tree {
|
|
||||||
&self.tree
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
94
examples/mcp-configs/README.md
Normal file
94
examples/mcp-configs/README.md
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
# MCP Configuration Examples
|
||||||
|
|
||||||
|
These are example configuration files for connecting AI coding tools to agcanvas via the MCP bridge.
|
||||||
|
|
||||||
|
## Prerequisites
|
||||||
|
|
||||||
|
1. Build the MCP server:
|
||||||
|
```bash
|
||||||
|
cargo build --release -p agcanvas-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
2. Make sure `agcanvas-mcp` is in your PATH, or use the full path:
|
||||||
|
```
|
||||||
|
./target/release/agcanvas-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
3. agcanvas must be running (it hosts the WebSocket server on port 9876).
|
||||||
|
|
||||||
|
## Claude Code
|
||||||
|
|
||||||
|
Copy `claude-code.mcp.json` to your project root as `.mcp.json`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp examples/mcp-configs/claude-code.mcp.json /path/to/your/project/.mcp.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Or add to your global Claude Code config at `~/.claude/mcp.json`.
|
||||||
|
|
||||||
|
If `agcanvas-mcp` is not in your PATH, use the full path:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"agcanvas": {
|
||||||
|
"command": "/path/to/agcanvas-mcp",
|
||||||
|
"args": ["--port", "9876"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## OpenCode
|
||||||
|
|
||||||
|
Add the server to your `opencode.json`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cp examples/mcp-configs/opencode.json /path/to/your/project/opencode.json
|
||||||
|
```
|
||||||
|
|
||||||
|
Or merge the `mcpServers` block into your existing config.
|
||||||
|
|
||||||
|
## Codex (OpenAI)
|
||||||
|
|
||||||
|
Codex uses the same MCP config format. Add to your project's MCP config:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"agcanvas": {
|
||||||
|
"command": "agcanvas-mcp",
|
||||||
|
"args": ["--port", "9876"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Custom Port
|
||||||
|
|
||||||
|
If agcanvas is running on a different port, change the `--port` argument:
|
||||||
|
|
||||||
|
```json
|
||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"agcanvas": {
|
||||||
|
"command": "agcanvas-mcp",
|
||||||
|
"args": ["--port", "8080"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Available Tools
|
||||||
|
|
||||||
|
Once connected, the following MCP tools are available:
|
||||||
|
|
||||||
|
| Tool | Description |
|
||||||
|
|------|-------------|
|
||||||
|
| `list_sessions` | List all open tabs/sessions in agcanvas |
|
||||||
|
| `get_element_tree` | Get the full parsed SVG element tree (structured JSON) |
|
||||||
|
| `describe_canvas` | Get a human-readable description of the canvas |
|
||||||
|
| `get_element_by_id` | Look up a specific element by ID |
|
||||||
|
| `get_elements_at_point` | Find elements at an (x, y) coordinate |
|
||||||
|
| `get_drawing_elements` | Get all user-drawn shapes (rects, ellipses, lines, arrows, text) |
|
||||||
|
| `generate_code` | Generate code stubs (html, react, tailwind, svelte, vue) |
|
||||||
8
examples/mcp-configs/claude-code.mcp.json
Normal file
8
examples/mcp-configs/claude-code.mcp.json
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
{
|
||||||
|
"mcpServers": {
|
||||||
|
"agcanvas": {
|
||||||
|
"command": "agcanvas-mcp",
|
||||||
|
"args": ["--port", "9876"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
9
examples/mcp-configs/opencode.json
Normal file
9
examples/mcp-configs/opencode.json
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://opencode.ai/config.schema.json",
|
||||||
|
"mcpServers": {
|
||||||
|
"agcanvas": {
|
||||||
|
"command": "agcanvas-mcp",
|
||||||
|
"args": ["--port", "9876"]
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,68 +0,0 @@
|
|||||||
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"),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
@@ -1,172 +0,0 @@
|
|||||||
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
328
src/app.rs
@@ -1,328 +0,0 @@
|
|||||||
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);
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user