Compare commits
4 Commits
d248864ee2
...
e8ec44d961
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e8ec44d961 | ||
|
|
233cb5798c | ||
|
|
43f1beea16 | ||
|
|
b140d93163 |
3
.gitignore
vendored
3
.gitignore
vendored
@@ -1,5 +1,8 @@
|
|||||||
/target
|
/target
|
||||||
|
crates/*/target
|
||||||
Cargo.lock
|
Cargo.lock
|
||||||
*.swp
|
*.swp
|
||||||
*.swo
|
*.swo
|
||||||
.DS_Store
|
.DS_Store
|
||||||
|
opencode.jsonc
|
||||||
|
.opencode
|
||||||
|
|||||||
138
README.md
138
README.md
@@ -1,10 +1,10 @@
|
|||||||
# agcanvas
|
# agcanvas — Augmented Canvas
|
||||||
|
|
||||||
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.
|
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 a **collaboration surface** for humans and AI agents — draw your ideas, paste designs, and let agents see exactly what's on the canvas.
|
agcanvas (short for **Augmented Canvas**) 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.
|
||||||
|
|
||||||
```
|
```
|
||||||
┌──────────────────────────────────────────────────────────────┐
|
┌──────────────────────────────────────────────────────────────┐
|
||||||
@@ -33,8 +33,10 @@ agcanvas bridges the gap between visual design and code generation. It's a **col
|
|||||||
- **Shape Drawing** — Rectangles, ellipses, lines, arrows, text directly on canvas
|
- **Shape Drawing** — Rectangles, ellipses, lines, arrows, text directly on canvas
|
||||||
- **Selection & Editing** — Select, move, resize shapes with corner handles
|
- **Selection & Editing** — Select, move, resize shapes with corner handles
|
||||||
- **Mermaid Diagrams** — Write Mermaid syntax, render as SVG on canvas
|
- **Mermaid Diagrams** — Write Mermaid syntax, render as SVG on canvas
|
||||||
- **Sessions/Tabs** — Multiple canvases in tabs, each with independent state
|
- **Sessions/Tabs** — Multiple canvases in tabs, each with independent state, creator tracking (human vs agent), descriptions, and timestamps
|
||||||
- **Pan/Zoom** — Smooth canvas navigation
|
- **Pan/Zoom** — Smooth canvas navigation
|
||||||
|
- **Session Persistence** — Auto-saves workspace to `~/Library/Application Support/agcanvas/`, restores all tabs on launch
|
||||||
|
- **Command Palette** — Cmd+K to search and execute any command with fuzzy matching
|
||||||
|
|
||||||
### AI Agent Integration
|
### AI Agent Integration
|
||||||
- **MCP Server** — `agcanvas-mcp` bridge for Claude Code, OpenCode, and Codex
|
- **MCP Server** — `agcanvas-mcp` bridge for Claude Code, OpenCode, and Codex
|
||||||
@@ -58,6 +60,76 @@ This builds two binaries:
|
|||||||
- `target/release/agcanvas` — The desktop app
|
- `target/release/agcanvas` — The desktop app
|
||||||
- `target/release/agcanvas-mcp` — The MCP server bridge
|
- `target/release/agcanvas-mcp` — The MCP server bridge
|
||||||
|
|
||||||
|
### Install to PATH
|
||||||
|
|
||||||
|
After building, symlink (or copy) the binaries so they're available system-wide:
|
||||||
|
|
||||||
|
**macOS / Linux:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
sudo ln -sf "$(pwd)/target/release/agcanvas" /usr/local/bin/agcanvas
|
||||||
|
sudo ln -sf "$(pwd)/target/release/agcanvas-mcp" /usr/local/bin/agcanvas-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
Or install to a user-local directory (no sudo):
|
||||||
|
|
||||||
|
```bash
|
||||||
|
mkdir -p ~/.local/bin
|
||||||
|
ln -sf "$(pwd)/target/release/agcanvas" ~/.local/bin/agcanvas
|
||||||
|
ln -sf "$(pwd)/target/release/agcanvas-mcp" ~/.local/bin/agcanvas-mcp
|
||||||
|
```
|
||||||
|
|
||||||
|
> Make sure `~/.local/bin` is in your `PATH`. Add `export PATH="$HOME/.local/bin:$PATH"` to your `~/.zshrc` or `~/.bashrc` if needed.
|
||||||
|
|
||||||
|
**Windows (PowerShell, run as Administrator):**
|
||||||
|
|
||||||
|
```powershell
|
||||||
|
New-Item -ItemType SymbolicLink -Path "$env:USERPROFILE\.local\bin\agcanvas.exe" -Target "$(Get-Location)\target\release\agcanvas.exe" -Force
|
||||||
|
New-Item -ItemType SymbolicLink -Path "$env:USERPROFILE\.local\bin\agcanvas-mcp.exe" -Target "$(Get-Location)\target\release\agcanvas-mcp.exe" -Force
|
||||||
|
```
|
||||||
|
|
||||||
|
> Make sure `%USERPROFILE%\.local\bin` is in your system `PATH`. Or use an existing PATH directory like `C:\Users\<you>\AppData\Local\Microsoft\WindowsApps`.
|
||||||
|
|
||||||
|
**Verify:**
|
||||||
|
|
||||||
|
```bash
|
||||||
|
agcanvas --help
|
||||||
|
agcanvas-mcp --help
|
||||||
|
```
|
||||||
|
|
||||||
|
### macOS `.app` Bundle
|
||||||
|
|
||||||
|
agcanvas compiles into a native macOS application. The release binary (`target/release/agcanvas`) runs directly, but if you want a proper `.app` bundle you can double-click in Finder or drag to `/Applications`, use [`cargo-bundle`](https://github.com/burtonageo/cargo-bundle):
|
||||||
|
|
||||||
|
1. **Install cargo-bundle:**
|
||||||
|
```bash
|
||||||
|
cargo install cargo-bundle
|
||||||
|
```
|
||||||
|
|
||||||
|
2. **Add bundle metadata** to `crates/agcanvas/Cargo.toml`:
|
||||||
|
```toml
|
||||||
|
[package.metadata.bundle]
|
||||||
|
name = "Augmented Canvas"
|
||||||
|
identifier = "com.agcanvas.app"
|
||||||
|
icon = ["assets/icon.icns"]
|
||||||
|
category = "public.app-category.developer-tools"
|
||||||
|
short_description = "Interactive canvas for agent-human collaboration"
|
||||||
|
```
|
||||||
|
|
||||||
|
3. **Bundle it:**
|
||||||
|
```bash
|
||||||
|
cargo bundle --release -p agcanvas
|
||||||
|
```
|
||||||
|
|
||||||
|
This produces `target/release/bundle/osx/Augmented Canvas.app`.
|
||||||
|
|
||||||
|
4. **Install:**
|
||||||
|
```bash
|
||||||
|
cp -r "target/release/bundle/osx/Augmented Canvas.app" /Applications/
|
||||||
|
```
|
||||||
|
|
||||||
|
> **Note:** The raw `cargo build --release` binary already works as a native macOS app — `cargo-bundle` just wraps it in a `.app` bundle with an icon, metadata, and Finder integration. No code changes are needed.
|
||||||
|
|
||||||
### Requirements
|
### Requirements
|
||||||
|
|
||||||
- Rust 1.70+
|
- Rust 1.70+
|
||||||
@@ -95,6 +167,8 @@ This builds two binaries:
|
|||||||
| Paste SVG | Cmd+V |
|
| Paste SVG | Cmd+V |
|
||||||
| New Tab | Cmd+T |
|
| New Tab | Cmd+T |
|
||||||
| Close Tab | Cmd+W |
|
| Close Tab | Cmd+W |
|
||||||
|
| Save workspace | Cmd+S |
|
||||||
|
| Command palette | Cmd+K |
|
||||||
| Reset zoom | Cmd+0 |
|
| Reset zoom | Cmd+0 |
|
||||||
|
|
||||||
## MCP Server (AI Agent Integration)
|
## MCP Server (AI Agent Integration)
|
||||||
@@ -118,14 +192,15 @@ Add to your project's `.mcp.json` (or `~/.claude/mcp.json` for global):
|
|||||||
|
|
||||||
### Setup for OpenCode
|
### Setup for OpenCode
|
||||||
|
|
||||||
Add to your `opencode.json`:
|
Add to your `opencode.json` (project-level) or `~/.config/opencode/opencode.json` (global):
|
||||||
|
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"mcpServers": {
|
"mcp": {
|
||||||
"agcanvas": {
|
"agcanvas": {
|
||||||
"command": "agcanvas-mcp",
|
"type": "local",
|
||||||
"args": ["--port", "9876"]
|
"command": ["agcanvas-mcp", "--port", "9876"],
|
||||||
|
"enabled": true
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -135,21 +210,25 @@ Add to your `opencode.json`:
|
|||||||
|
|
||||||
Same MCP config format — add the `agcanvas` entry to your Codex MCP configuration.
|
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.
|
> **Note:** Make sure `agcanvas-mcp` is in your PATH (e.g., `~/.local/bin`), or use the full path to the binary. 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
|
### MCP Tools
|
||||||
|
|
||||||
| Tool | Description |
|
| Tool | Description |
|
||||||
|------|-------------|
|
|------|-------------|
|
||||||
| `list_sessions` | List all open tabs/sessions in agcanvas |
|
| `list_sessions` | List all open tabs/sessions with creator info, descriptions, timestamps. Supports sorting by name, created_at, created_by, element_count |
|
||||||
|
| `create_session` | Create a new session/tab from an agent, with name, description, and creator identity |
|
||||||
|
| `update_session` | Update an existing session's name or description |
|
||||||
| `get_element_tree` | Get the full parsed SVG element tree (structured JSON) |
|
| `get_element_tree` | Get the full parsed SVG element tree (structured JSON) |
|
||||||
| `describe_canvas` | Get a human-readable description of the canvas |
|
| `describe_canvas` | Get a human-readable description of the canvas |
|
||||||
| `get_element_by_id` | Look up a specific element by ID |
|
| `get_element_by_id` | Look up a specific element by ID |
|
||||||
| `get_elements_at_point` | Find elements at an (x, y) coordinate |
|
| `get_elements_at_point` | Find elements at an (x, y) coordinate |
|
||||||
| `get_drawing_elements` | Get all user-drawn shapes (rects, ellipses, lines, arrows, text) |
|
| `get_drawing_elements` | Get all user-drawn shapes (rects, ellipses, lines, arrows, text) |
|
||||||
| `generate_code` | Generate code stubs (html, react, tailwind, svelte, vue) |
|
| `generate_code` | Generate code stubs (html, react, tailwind, svelte, vue) |
|
||||||
|
| `create_drawing_element` | Create a shape on the canvas (Rectangle, Ellipse, Line, Arrow, Text) |
|
||||||
|
| `update_drawing_element` | Update an existing drawing element's shape or style |
|
||||||
|
| `delete_drawing_element` | Delete a drawing element by ID |
|
||||||
|
| `clear_drawing_elements` | Clear all drawing elements from the canvas |
|
||||||
|
|
||||||
All tools accept an optional `session_id` parameter. If omitted, the active session is used.
|
All tools accept an optional `session_id` parameter. If omitted, the active session is used.
|
||||||
|
|
||||||
@@ -174,20 +253,45 @@ All requests support an optional `session_id` parameter. If omitted, the active
|
|||||||
|
|
||||||
```json
|
```json
|
||||||
{"type": "ListSessions"}
|
{"type": "ListSessions"}
|
||||||
|
{"type": "ListSessions", "sort_by": "created_at", "sort_order": "desc"}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Sort fields: `name`, `created_at` (default), `created_by`, `element_count`. Order: `asc` (default), `desc`.
|
||||||
|
|
||||||
Response:
|
Response:
|
||||||
```json
|
```json
|
||||||
{
|
{
|
||||||
"type": "Sessions",
|
"type": "Sessions",
|
||||||
"sessions": [
|
"sessions": [
|
||||||
{"id": "session-1", "name": "Tab 1", "has_svg": true, "element_count": 15},
|
{"id": "session-1", "name": "Tab 1", "has_svg": true, "element_count": 15, "description": null, "created_by": {"type": "Human"}, "created_at": 1707500000},
|
||||||
{"id": "session-2", "name": "Tab 2", "has_svg": false, "element_count": null}
|
{"id": "session-2", "name": "Agent Work", "has_svg": false, "element_count": null, "description": "Architecture diagram", "created_by": {"type": "Agent", "name": "Claude"}, "created_at": 1707500100}
|
||||||
],
|
],
|
||||||
"active_session": "session-1"
|
"active_session": "session-1"
|
||||||
}
|
}
|
||||||
```
|
```
|
||||||
|
|
||||||
|
#### Create session (agent)
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type": "CreateSession", "name": "My Session", "description": "Working on auth flow", "created_by_name": "Claude"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{"type": "SessionCreated", "session": {"id": "session-3", "name": "My Session", ...}}
|
||||||
|
```
|
||||||
|
|
||||||
|
#### Update session
|
||||||
|
|
||||||
|
```json
|
||||||
|
{"type": "UpdateSession", "session_id": "session-1", "name": "Renamed", "description": "Updated description"}
|
||||||
|
```
|
||||||
|
|
||||||
|
Response:
|
||||||
|
```json
|
||||||
|
{"type": "SessionUpdated", "session": {"id": "session-1", "name": "Renamed", ...}}
|
||||||
|
```
|
||||||
|
|
||||||
#### Get full element tree
|
#### Get full element tree
|
||||||
|
|
||||||
```json
|
```json
|
||||||
@@ -271,6 +375,8 @@ crates/
|
|||||||
│ ├── main.rs # Entry point, window setup
|
│ ├── main.rs # Entry point, window setup
|
||||||
│ ├── app.rs # Main app state, UI, toolbar, drawing interaction
|
│ ├── app.rs # Main app state, UI, toolbar, drawing interaction
|
||||||
│ ├── session.rs # Session/tab state management
|
│ ├── session.rs # Session/tab state management
|
||||||
|
│ ├── persistence.rs # Workspace save/load (~/.agcanvas/)
|
||||||
|
│ ├── command_palette.rs # Cmd+K command palette with fuzzy search
|
||||||
│ ├── element_tree.rs # Structured element representation
|
│ ├── element_tree.rs # Structured element representation
|
||||||
│ ├── clipboard.rs # System clipboard integration
|
│ ├── clipboard.rs # System clipboard integration
|
||||||
│ ├── mermaid.rs # Mermaid → SVG rendering
|
│ ├── mermaid.rs # Mermaid → SVG rendering
|
||||||
@@ -305,6 +411,7 @@ crates/
|
|||||||
| `arboard` | Clipboard access |
|
| `arboard` | Clipboard access |
|
||||||
| `tokio-tungstenite` | WebSocket (both server and client) |
|
| `tokio-tungstenite` | WebSocket (both server and client) |
|
||||||
| `rmcp` | MCP server SDK (Anthropic official) |
|
| `rmcp` | MCP server SDK (Anthropic official) |
|
||||||
|
| `dirs` | Platform data directory paths |
|
||||||
| `serde`/`serde_json` | Serialization |
|
| `serde`/`serde_json` | Serialization |
|
||||||
|
|
||||||
## Roadmap
|
## Roadmap
|
||||||
@@ -314,8 +421,11 @@ crates/
|
|||||||
- [x] Selection, move, resize with handles
|
- [x] Selection, move, resize with handles
|
||||||
- [x] Mermaid diagram rendering
|
- [x] Mermaid diagram rendering
|
||||||
- [x] MCP server bridge for AI coding tools
|
- [x] MCP server bridge for AI coding tools
|
||||||
|
- [x] Agent draw commands (modify canvas from agent)
|
||||||
|
- [x] Session metadata (creator tracking, descriptions, timestamps, sorting)
|
||||||
|
- [x] Session persistence (auto-save/restore workspace)
|
||||||
|
- [x] Command palette (Cmd+K)
|
||||||
- [ ] Real code generation (not just stubs)
|
- [ ] Real code generation (not just stubs)
|
||||||
- [ ] Agent draw commands (modify canvas from agent)
|
|
||||||
- [ ] 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
|
||||||
|
|||||||
@@ -1,9 +1,8 @@
|
|||||||
use crate::bridge::send_request;
|
use crate::bridge::send_request;
|
||||||
use rmcp::{
|
use rmcp::{
|
||||||
ErrorData as McpError, ServerHandler,
|
|
||||||
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
|
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
|
||||||
model::*,
|
model::*,
|
||||||
schemars, tool, tool_handler, tool_router,
|
schemars, tool, tool_handler, tool_router, ErrorData as McpError, ServerHandler,
|
||||||
};
|
};
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
@@ -12,6 +11,36 @@ pub struct SessionIdParam {
|
|||||||
pub session_id: Option<String>,
|
pub session_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
|
pub struct ListSessionsParam {
|
||||||
|
#[schemars(
|
||||||
|
description = "Sort field: 'name', 'created_at' (default), 'created_by', or 'element_count'"
|
||||||
|
)]
|
||||||
|
pub sort_by: Option<String>,
|
||||||
|
#[schemars(description = "Sort order: 'asc' (default) or 'desc'")]
|
||||||
|
pub sort_order: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
|
pub struct CreateSessionParam {
|
||||||
|
#[schemars(description = "Session name. If omitted, auto-generated.")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[schemars(description = "Session description.")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[schemars(description = "Name of the agent creating the session (identifies the creator).")]
|
||||||
|
pub created_by_name: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
|
pub struct UpdateSessionParam {
|
||||||
|
#[schemars(description = "ID of the session to update.")]
|
||||||
|
pub session_id: String,
|
||||||
|
#[schemars(description = "New session name.")]
|
||||||
|
pub name: Option<String>,
|
||||||
|
#[schemars(description = "New session description.")]
|
||||||
|
pub description: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
pub struct GetElementParam {
|
pub struct GetElementParam {
|
||||||
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
||||||
@@ -36,10 +65,110 @@ pub struct GenerateCodeParam {
|
|||||||
pub session_id: Option<String>,
|
pub session_id: Option<String>,
|
||||||
#[schemars(description = "Code generation target: html, react, tailwind, svelte, or vue")]
|
#[schemars(description = "Code generation target: html, react, tailwind, svelte, or vue")]
|
||||||
pub target: String,
|
pub target: String,
|
||||||
#[schemars(description = "Element ID to generate code for. If omitted, generates for the entire tree.")]
|
#[schemars(
|
||||||
|
description = "Element ID to generate code for. If omitted, generates for the entire tree."
|
||||||
|
)]
|
||||||
pub element_id: Option<String>,
|
pub element_id: Option<String>,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
|
pub struct CreateDrawingElementParam {
|
||||||
|
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
#[schemars(description = "Shape type: Rectangle, Ellipse, Line, Arrow, or Text")]
|
||||||
|
pub shape_type: String,
|
||||||
|
#[schemars(
|
||||||
|
description = "X position (Rectangle/Text top-left X, or Line/Arrow start X via x1)"
|
||||||
|
)]
|
||||||
|
pub x: Option<f32>,
|
||||||
|
#[schemars(
|
||||||
|
description = "Y position (Rectangle/Text top-left Y, or Line/Arrow start Y via y1)"
|
||||||
|
)]
|
||||||
|
pub y: Option<f32>,
|
||||||
|
#[schemars(description = "Width (Rectangle only)")]
|
||||||
|
pub width: Option<f32>,
|
||||||
|
#[schemars(description = "Height (Rectangle only)")]
|
||||||
|
pub height: Option<f32>,
|
||||||
|
#[schemars(description = "Center X (Ellipse only; falls back to x)")]
|
||||||
|
pub center_x: Option<f32>,
|
||||||
|
#[schemars(description = "Center Y (Ellipse only; falls back to y)")]
|
||||||
|
pub center_y: Option<f32>,
|
||||||
|
#[schemars(description = "X radius (Ellipse only, default 50)")]
|
||||||
|
pub radius_x: Option<f32>,
|
||||||
|
#[schemars(description = "Y radius (Ellipse only, default 50)")]
|
||||||
|
pub radius_y: Option<f32>,
|
||||||
|
#[schemars(description = "Start X (Line/Arrow; falls back to x)")]
|
||||||
|
pub x1: Option<f32>,
|
||||||
|
#[schemars(description = "Start Y (Line/Arrow; falls back to y)")]
|
||||||
|
pub y1: Option<f32>,
|
||||||
|
#[schemars(description = "End X (Line/Arrow)")]
|
||||||
|
pub x2: Option<f32>,
|
||||||
|
#[schemars(description = "End Y (Line/Arrow)")]
|
||||||
|
pub y2: Option<f32>,
|
||||||
|
#[schemars(description = "Text content (Text shape only)")]
|
||||||
|
pub text: Option<String>,
|
||||||
|
#[schemars(description = "Font size in pixels (Text shape, default 20)")]
|
||||||
|
pub font_size: Option<f32>,
|
||||||
|
#[schemars(description = "Fill color as hex e.g. '#ff0000', or null for no fill")]
|
||||||
|
pub fill: Option<String>,
|
||||||
|
#[schemars(description = "Stroke color as hex e.g. '#ffffff' (default white)")]
|
||||||
|
pub stroke_color: Option<String>,
|
||||||
|
#[schemars(description = "Stroke width in pixels (default 2.0)")]
|
||||||
|
pub stroke_width: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
|
pub struct UpdateDrawingElementParam {
|
||||||
|
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
#[schemars(description = "ID of the drawing element to update")]
|
||||||
|
pub id: String,
|
||||||
|
#[schemars(description = "New shape type (provide with coordinates to replace shape)")]
|
||||||
|
pub shape_type: Option<String>,
|
||||||
|
#[schemars(description = "X position")]
|
||||||
|
pub x: Option<f32>,
|
||||||
|
#[schemars(description = "Y position")]
|
||||||
|
pub y: Option<f32>,
|
||||||
|
#[schemars(description = "Width (Rectangle)")]
|
||||||
|
pub width: Option<f32>,
|
||||||
|
#[schemars(description = "Height (Rectangle)")]
|
||||||
|
pub height: Option<f32>,
|
||||||
|
#[schemars(description = "Center X (Ellipse)")]
|
||||||
|
pub center_x: Option<f32>,
|
||||||
|
#[schemars(description = "Center Y (Ellipse)")]
|
||||||
|
pub center_y: Option<f32>,
|
||||||
|
#[schemars(description = "X radius (Ellipse)")]
|
||||||
|
pub radius_x: Option<f32>,
|
||||||
|
#[schemars(description = "Y radius (Ellipse)")]
|
||||||
|
pub radius_y: Option<f32>,
|
||||||
|
#[schemars(description = "Start X (Line/Arrow)")]
|
||||||
|
pub x1: Option<f32>,
|
||||||
|
#[schemars(description = "Start Y (Line/Arrow)")]
|
||||||
|
pub y1: Option<f32>,
|
||||||
|
#[schemars(description = "End X (Line/Arrow)")]
|
||||||
|
pub x2: Option<f32>,
|
||||||
|
#[schemars(description = "End Y (Line/Arrow)")]
|
||||||
|
pub y2: Option<f32>,
|
||||||
|
#[schemars(description = "Text content (Text shape)")]
|
||||||
|
pub text: Option<String>,
|
||||||
|
#[schemars(description = "Font size (Text shape)")]
|
||||||
|
pub font_size: Option<f32>,
|
||||||
|
#[schemars(description = "Fill color as hex e.g. '#ff0000'")]
|
||||||
|
pub fill: Option<String>,
|
||||||
|
#[schemars(description = "Stroke color as hex e.g. '#ffffff'")]
|
||||||
|
pub stroke_color: Option<String>,
|
||||||
|
#[schemars(description = "Stroke width in pixels")]
|
||||||
|
pub stroke_width: Option<f32>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||||
|
pub struct DeleteDrawingElementParam {
|
||||||
|
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
||||||
|
pub session_id: Option<String>,
|
||||||
|
#[schemars(description = "ID of the drawing element to delete")]
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone)]
|
#[derive(Debug, Clone)]
|
||||||
pub struct AgCanvasServer {
|
pub struct AgCanvasServer {
|
||||||
ws_url: String,
|
ws_url: String,
|
||||||
@@ -55,13 +184,69 @@ impl AgCanvasServer {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(description = "List all open sessions/tabs in agcanvas. Returns session IDs, names, and whether they have SVG or drawing content loaded.")]
|
#[tool(
|
||||||
async fn list_sessions(&self) -> Result<CallToolResult, McpError> {
|
description = "List all open sessions/tabs in agcanvas. Returns session IDs, names, creator info, description, timestamps, and whether they have SVG or drawing content loaded. Supports sorting."
|
||||||
let request = serde_json::json!({"type": "ListSessions"});
|
)]
|
||||||
|
async fn list_sessions(
|
||||||
|
&self,
|
||||||
|
Parameters(params): Parameters<ListSessionsParam>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
let mut request = serde_json::json!({"type": "ListSessions"});
|
||||||
|
let obj = request.as_object_mut().unwrap();
|
||||||
|
if let Some(v) = params.sort_by {
|
||||||
|
obj.insert("sort_by".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.sort_order {
|
||||||
|
obj.insert("sort_order".into(), v.into());
|
||||||
|
}
|
||||||
self.call_agcanvas(&request).await
|
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.")]
|
#[tool(
|
||||||
|
description = "Create a new session/tab in agcanvas. The session is created by an agent. Returns the created session with its ID and metadata."
|
||||||
|
)]
|
||||||
|
async fn create_session(
|
||||||
|
&self,
|
||||||
|
Parameters(params): Parameters<CreateSessionParam>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
let mut request = serde_json::json!({"type": "CreateSession"});
|
||||||
|
let obj = request.as_object_mut().unwrap();
|
||||||
|
if let Some(v) = params.name {
|
||||||
|
obj.insert("name".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.description {
|
||||||
|
obj.insert("description".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.created_by_name {
|
||||||
|
obj.insert("created_by_name".into(), v.into());
|
||||||
|
}
|
||||||
|
self.call_agcanvas(&request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
description = "Update an existing session's name or description. Only provided fields are changed."
|
||||||
|
)]
|
||||||
|
async fn update_session(
|
||||||
|
&self,
|
||||||
|
Parameters(params): Parameters<UpdateSessionParam>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
let mut request = serde_json::json!({
|
||||||
|
"type": "UpdateSession",
|
||||||
|
"session_id": params.session_id,
|
||||||
|
});
|
||||||
|
let obj = request.as_object_mut().unwrap();
|
||||||
|
if let Some(v) = params.name {
|
||||||
|
obj.insert("name".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.description {
|
||||||
|
obj.insert("description".into(), v.into());
|
||||||
|
}
|
||||||
|
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(
|
async fn get_element_tree(
|
||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<SessionIdParam>,
|
Parameters(params): Parameters<SessionIdParam>,
|
||||||
@@ -73,7 +258,9 @@ impl AgCanvasServer {
|
|||||||
self.call_agcanvas(&request).await
|
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.")]
|
#[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(
|
async fn describe_canvas(
|
||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<SessionIdParam>,
|
Parameters(params): Parameters<SessionIdParam>,
|
||||||
@@ -97,7 +284,9 @@ impl AgCanvasServer {
|
|||||||
self.call_agcanvas(&request).await
|
self.call_agcanvas(&request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(description = "Query which elements exist at a specific (x, y) coordinate on the canvas.")]
|
#[tool(
|
||||||
|
description = "Query which elements exist at a specific (x, y) coordinate on the canvas."
|
||||||
|
)]
|
||||||
async fn get_elements_at_point(
|
async fn get_elements_at_point(
|
||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<GetElementsAtPointParam>,
|
Parameters(params): Parameters<GetElementsAtPointParam>,
|
||||||
@@ -110,7 +299,9 @@ impl AgCanvasServer {
|
|||||||
self.call_agcanvas(&request).await
|
self.call_agcanvas(&request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
#[tool(description = "Get all user-drawn shapes (rectangles, ellipses, lines, arrows, text) from the drawing layer.")]
|
#[tool(
|
||||||
|
description = "Get all user-drawn shapes (rectangles, ellipses, lines, arrows, text) from the drawing layer."
|
||||||
|
)]
|
||||||
async fn get_drawing_elements(
|
async fn get_drawing_elements(
|
||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<SessionIdParam>,
|
Parameters(params): Parameters<SessionIdParam>,
|
||||||
@@ -122,7 +313,9 @@ impl AgCanvasServer {
|
|||||||
self.call_agcanvas(&request).await
|
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.")]
|
#[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(
|
async fn generate_code(
|
||||||
&self,
|
&self,
|
||||||
Parameters(params): Parameters<GenerateCodeParam>,
|
Parameters(params): Parameters<GenerateCodeParam>,
|
||||||
@@ -137,13 +330,178 @@ impl AgCanvasServer {
|
|||||||
}
|
}
|
||||||
self.call_agcanvas(&request).await
|
self.call_agcanvas(&request).await
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
description = "Create a drawing element on the canvas. Shape types: Rectangle (x,y,width,height), Ellipse (center_x,center_y,radius_x,radius_y), Line (x1,y1,x2,y2), Arrow (x1,y1,x2,y2), Text (x,y,text,font_size). Colors are hex strings like '#ff0000'. Returns the created element with its auto-generated ID."
|
||||||
|
)]
|
||||||
|
async fn create_drawing_element(
|
||||||
|
&self,
|
||||||
|
Parameters(params): Parameters<CreateDrawingElementParam>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
let mut request = serde_json::json!({
|
||||||
|
"type": "CreateDrawingElement",
|
||||||
|
"shape_type": params.shape_type,
|
||||||
|
});
|
||||||
|
let obj = request.as_object_mut().unwrap();
|
||||||
|
if let Some(v) = params.session_id {
|
||||||
|
obj.insert("session_id".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.x {
|
||||||
|
obj.insert("x".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.y {
|
||||||
|
obj.insert("y".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.width {
|
||||||
|
obj.insert("width".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.height {
|
||||||
|
obj.insert("height".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.center_x {
|
||||||
|
obj.insert("center_x".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.center_y {
|
||||||
|
obj.insert("center_y".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.radius_x {
|
||||||
|
obj.insert("radius_x".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.radius_y {
|
||||||
|
obj.insert("radius_y".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.x1 {
|
||||||
|
obj.insert("x1".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.y1 {
|
||||||
|
obj.insert("y1".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.x2 {
|
||||||
|
obj.insert("x2".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.y2 {
|
||||||
|
obj.insert("y2".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.text {
|
||||||
|
obj.insert("text".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.font_size {
|
||||||
|
obj.insert("font_size".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.fill {
|
||||||
|
obj.insert("fill".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.stroke_color {
|
||||||
|
obj.insert("stroke_color".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.stroke_width {
|
||||||
|
obj.insert("stroke_width".into(), v.into());
|
||||||
|
}
|
||||||
|
self.call_agcanvas(&request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(
|
||||||
|
description = "Update an existing drawing element. Provide shape_type + coordinates to replace the shape. Provide fill/stroke_color/stroke_width to update the style. Only provided fields are changed."
|
||||||
|
)]
|
||||||
|
async fn update_drawing_element(
|
||||||
|
&self,
|
||||||
|
Parameters(params): Parameters<UpdateDrawingElementParam>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
let mut request = serde_json::json!({
|
||||||
|
"type": "UpdateDrawingElement",
|
||||||
|
"id": params.id,
|
||||||
|
});
|
||||||
|
let obj = request.as_object_mut().unwrap();
|
||||||
|
if let Some(v) = params.session_id {
|
||||||
|
obj.insert("session_id".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.shape_type {
|
||||||
|
obj.insert("shape_type".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.x {
|
||||||
|
obj.insert("x".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.y {
|
||||||
|
obj.insert("y".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.width {
|
||||||
|
obj.insert("width".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.height {
|
||||||
|
obj.insert("height".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.center_x {
|
||||||
|
obj.insert("center_x".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.center_y {
|
||||||
|
obj.insert("center_y".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.radius_x {
|
||||||
|
obj.insert("radius_x".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.radius_y {
|
||||||
|
obj.insert("radius_y".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.x1 {
|
||||||
|
obj.insert("x1".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.y1 {
|
||||||
|
obj.insert("y1".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.x2 {
|
||||||
|
obj.insert("x2".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.y2 {
|
||||||
|
obj.insert("y2".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.text {
|
||||||
|
obj.insert("text".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.font_size {
|
||||||
|
obj.insert("font_size".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.fill {
|
||||||
|
obj.insert("fill".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.stroke_color {
|
||||||
|
obj.insert("stroke_color".into(), v.into());
|
||||||
|
}
|
||||||
|
if let Some(v) = params.stroke_width {
|
||||||
|
obj.insert("stroke_width".into(), v.into());
|
||||||
|
}
|
||||||
|
self.call_agcanvas(&request).await
|
||||||
|
}
|
||||||
|
|
||||||
|
#[tool(description = "Delete a drawing element by its ID.")]
|
||||||
|
async fn delete_drawing_element(
|
||||||
|
&self,
|
||||||
|
Parameters(params): Parameters<DeleteDrawingElementParam>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
let mut request = serde_json::json!({
|
||||||
|
"type": "DeleteDrawingElement",
|
||||||
|
"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 = "Clear all drawing elements from the canvas.")]
|
||||||
|
async fn clear_drawing_elements(
|
||||||
|
&self,
|
||||||
|
Parameters(params): Parameters<SessionIdParam>,
|
||||||
|
) -> Result<CallToolResult, McpError> {
|
||||||
|
let mut request = serde_json::json!({"type": "ClearDrawingElements"});
|
||||||
|
if let Some(sid) = params.session_id {
|
||||||
|
request["session_id"] = serde_json::Value::String(sid);
|
||||||
|
}
|
||||||
|
self.call_agcanvas(&request).await
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgCanvasServer {
|
impl AgCanvasServer {
|
||||||
async fn call_agcanvas(
|
async fn call_agcanvas(&self, request: &serde_json::Value) -> Result<CallToolResult, McpError> {
|
||||||
&self,
|
|
||||||
request: &serde_json::Value,
|
|
||||||
) -> Result<CallToolResult, McpError> {
|
|
||||||
let request_str = serde_json::to_string(request)
|
let request_str = serde_json::to_string(request)
|
||||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||||
|
|
||||||
@@ -152,10 +510,8 @@ impl AgCanvasServer {
|
|||||||
let parsed: serde_json::Value = serde_json::from_str(&response)
|
let parsed: serde_json::Value = serde_json::from_str(&response)
|
||||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||||
|
|
||||||
let is_error = parsed
|
let is_error =
|
||||||
.get("type")
|
parsed.get("type").and_then(serde_json::Value::as_str) == Some("Error");
|
||||||
.and_then(serde_json::Value::as_str)
|
|
||||||
== Some("Error");
|
|
||||||
|
|
||||||
if is_error {
|
if is_error {
|
||||||
let msg = parsed
|
let msg = parsed
|
||||||
@@ -165,8 +521,8 @@ impl AgCanvasServer {
|
|||||||
return Ok(CallToolResult::error(vec![Content::text(msg)]));
|
return Ok(CallToolResult::error(vec![Content::text(msg)]));
|
||||||
}
|
}
|
||||||
|
|
||||||
let pretty = serde_json::to_string_pretty(&parsed)
|
let pretty =
|
||||||
.unwrap_or_else(|_| response.clone());
|
serde_json::to_string_pretty(&parsed).unwrap_or_else(|_| response.clone());
|
||||||
Ok(CallToolResult::success(vec![Content::text(pretty)]))
|
Ok(CallToolResult::success(vec![Content::text(pretty)]))
|
||||||
}
|
}
|
||||||
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
|
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
|
||||||
|
|||||||
@@ -39,6 +39,9 @@ mermaid-rs-renderer = { version = "0.1.2", default-features = false }
|
|||||||
tracing = "0.1"
|
tracing = "0.1"
|
||||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||||
|
|
||||||
|
# Filesystem paths
|
||||||
|
dirs = "5.0"
|
||||||
|
|
||||||
# Error handling
|
# Error handling
|
||||||
anyhow = "1.0"
|
anyhow = "1.0"
|
||||||
thiserror = "1.0"
|
thiserror = "1.0"
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
mod protocol;
|
mod protocol;
|
||||||
mod server;
|
mod server;
|
||||||
|
|
||||||
pub use protocol::GuiEvent;
|
pub use protocol::{DrawingCommand, GuiEvent, SessionCommand};
|
||||||
pub use server::AgentServer;
|
pub use server::AgentServer;
|
||||||
|
|||||||
@@ -1,8 +1,13 @@
|
|||||||
use crate::drawing::DrawingElement;
|
use crate::drawing::{DrawingElement, Shape, ShapeStyle};
|
||||||
use crate::element_tree::{ElementTree, TreeMetadata};
|
use crate::element_tree::{ElementTree, TreeMetadata};
|
||||||
use crate::session::SessionInfo;
|
use crate::session::{SessionCreator, SessionInfo, SessionSortField, SortOrder};
|
||||||
|
use egui::{Color32, Pos2, Vec2};
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// GUI → Agent events (broadcast to all connected agents)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum GuiEvent {
|
pub enum GuiEvent {
|
||||||
@@ -27,12 +32,51 @@ pub enum GuiEvent {
|
|||||||
SvgCleared {
|
SvgCleared {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
},
|
},
|
||||||
|
DrawingElementCreated {
|
||||||
|
session_id: String,
|
||||||
|
element: DrawingElement,
|
||||||
|
},
|
||||||
|
DrawingElementUpdated {
|
||||||
|
session_id: String,
|
||||||
|
element: DrawingElement,
|
||||||
|
},
|
||||||
|
DrawingElementDeleted {
|
||||||
|
session_id: String,
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
DrawingElementsCleared {
|
||||||
|
session_id: String,
|
||||||
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Agent → Server requests
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum AgentRequest {
|
pub enum AgentRequest {
|
||||||
ListSessions,
|
ListSessions {
|
||||||
|
#[serde(default)]
|
||||||
|
sort_by: Option<SessionSortField>,
|
||||||
|
#[serde(default)]
|
||||||
|
sort_order: Option<SortOrder>,
|
||||||
|
},
|
||||||
|
CreateSession {
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
created_by_name: Option<String>,
|
||||||
|
},
|
||||||
|
UpdateSession {
|
||||||
|
session_id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
description: Option<String>,
|
||||||
|
},
|
||||||
GetTree {
|
GetTree {
|
||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
session_id: Option<String>,
|
session_id: Option<String>,
|
||||||
@@ -62,9 +106,105 @@ pub enum AgentRequest {
|
|||||||
#[serde(default)]
|
#[serde(default)]
|
||||||
session_id: Option<String>,
|
session_id: Option<String>,
|
||||||
},
|
},
|
||||||
|
|
||||||
|
// ---- Drawing mutations ----
|
||||||
|
CreateDrawingElement {
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
shape_type: String,
|
||||||
|
#[serde(default)]
|
||||||
|
x: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
y: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
width: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
height: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
center_x: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
center_y: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
radius_x: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
radius_y: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
x1: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
y1: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
x2: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
y2: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
text: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
font_size: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
fill: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
stroke_color: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
stroke_width: Option<f32>,
|
||||||
|
},
|
||||||
|
UpdateDrawingElement {
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
id: String,
|
||||||
|
#[serde(default)]
|
||||||
|
shape_type: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
x: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
y: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
width: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
height: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
center_x: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
center_y: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
radius_x: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
radius_y: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
x1: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
y1: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
x2: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
y2: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
text: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
font_size: Option<f32>,
|
||||||
|
#[serde(default)]
|
||||||
|
fill: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
stroke_color: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
stroke_width: Option<f32>,
|
||||||
|
},
|
||||||
|
DeleteDrawingElement {
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
ClearDrawingElements {
|
||||||
|
#[serde(default)]
|
||||||
|
session_id: Option<String>,
|
||||||
|
},
|
||||||
|
|
||||||
Ping,
|
Ping,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Server → Agent responses
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
#[serde(tag = "type")]
|
#[serde(tag = "type")]
|
||||||
pub enum AgentResponse {
|
pub enum AgentResponse {
|
||||||
@@ -72,6 +212,12 @@ pub enum AgentResponse {
|
|||||||
sessions: Vec<SessionInfo>,
|
sessions: Vec<SessionInfo>,
|
||||||
active_session: Option<String>,
|
active_session: Option<String>,
|
||||||
},
|
},
|
||||||
|
SessionCreated {
|
||||||
|
session: SessionInfo,
|
||||||
|
},
|
||||||
|
SessionUpdated {
|
||||||
|
session: SessionInfo,
|
||||||
|
},
|
||||||
Tree {
|
Tree {
|
||||||
session_id: String,
|
session_id: String,
|
||||||
tree: ElementTree,
|
tree: ElementTree,
|
||||||
@@ -97,12 +243,73 @@ pub enum AgentResponse {
|
|||||||
session_id: String,
|
session_id: String,
|
||||||
elements: Vec<DrawingElement>,
|
elements: Vec<DrawingElement>,
|
||||||
},
|
},
|
||||||
|
DrawingElementCreated {
|
||||||
|
session_id: String,
|
||||||
|
element: DrawingElement,
|
||||||
|
},
|
||||||
|
DrawingElementUpdated {
|
||||||
|
session_id: String,
|
||||||
|
element: DrawingElement,
|
||||||
|
},
|
||||||
|
DrawingElementDeleted {
|
||||||
|
session_id: String,
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
DrawingElementsCleared {
|
||||||
|
session_id: String,
|
||||||
|
},
|
||||||
Pong,
|
Pong,
|
||||||
Error {
|
Error {
|
||||||
message: String,
|
message: String,
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Agent → GUI drawing commands (reverse sync channel)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum DrawingCommand {
|
||||||
|
Create {
|
||||||
|
session_id: String,
|
||||||
|
element: DrawingElement,
|
||||||
|
},
|
||||||
|
Update {
|
||||||
|
session_id: String,
|
||||||
|
element: DrawingElement,
|
||||||
|
},
|
||||||
|
Delete {
|
||||||
|
session_id: String,
|
||||||
|
id: String,
|
||||||
|
},
|
||||||
|
Clear {
|
||||||
|
session_id: String,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Agent → GUI session commands (reverse sync channel for session management)
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub enum SessionCommand {
|
||||||
|
Create {
|
||||||
|
id: String,
|
||||||
|
name: String,
|
||||||
|
description: Option<String>,
|
||||||
|
created_by: SessionCreator,
|
||||||
|
},
|
||||||
|
Update {
|
||||||
|
session_id: String,
|
||||||
|
name: Option<String>,
|
||||||
|
description: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Code generation targets
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||||
#[serde(rename_all = "lowercase")]
|
#[serde(rename_all = "lowercase")]
|
||||||
pub enum CodeGenTarget {
|
pub enum CodeGenTarget {
|
||||||
@@ -124,3 +331,106 @@ impl std::fmt::Display for CodeGenTarget {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Conversion: flat agent-friendly params → internal drawing types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
/// Parse a hex color string like "#ff0000" or "#f00" into a Color32.
|
||||||
|
pub fn parse_hex_color(hex: &str) -> Option<Color32> {
|
||||||
|
let hex = hex.trim_start_matches('#');
|
||||||
|
match hex.len() {
|
||||||
|
3 => {
|
||||||
|
let r = u8::from_str_radix(&hex[0..1].repeat(2), 16).ok()?;
|
||||||
|
let g = u8::from_str_radix(&hex[1..2].repeat(2), 16).ok()?;
|
||||||
|
let b = u8::from_str_radix(&hex[2..3].repeat(2), 16).ok()?;
|
||||||
|
Some(Color32::from_rgb(r, g, b))
|
||||||
|
}
|
||||||
|
6 => {
|
||||||
|
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
||||||
|
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
||||||
|
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
||||||
|
Some(Color32::from_rgb(r, g, b))
|
||||||
|
}
|
||||||
|
8 => {
|
||||||
|
let r = u8::from_str_radix(&hex[0..2], 16).ok()?;
|
||||||
|
let g = u8::from_str_radix(&hex[2..4], 16).ok()?;
|
||||||
|
let b = u8::from_str_radix(&hex[4..6], 16).ok()?;
|
||||||
|
let a = u8::from_str_radix(&hex[6..8], 16).ok()?;
|
||||||
|
Some(Color32::from_rgba_unmultiplied(r, g, b, a))
|
||||||
|
}
|
||||||
|
_ => None,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `Shape` from flat agent-friendly parameters.
|
||||||
|
#[allow(clippy::too_many_arguments)]
|
||||||
|
pub fn build_shape(
|
||||||
|
shape_type: &str,
|
||||||
|
x: Option<f32>,
|
||||||
|
y: Option<f32>,
|
||||||
|
width: Option<f32>,
|
||||||
|
height: Option<f32>,
|
||||||
|
center_x: Option<f32>,
|
||||||
|
center_y: Option<f32>,
|
||||||
|
radius_x: Option<f32>,
|
||||||
|
radius_y: Option<f32>,
|
||||||
|
x1: Option<f32>,
|
||||||
|
y1: Option<f32>,
|
||||||
|
x2: Option<f32>,
|
||||||
|
y2: Option<f32>,
|
||||||
|
text: Option<String>,
|
||||||
|
font_size: Option<f32>,
|
||||||
|
) -> Result<Shape, String> {
|
||||||
|
match shape_type {
|
||||||
|
"Rectangle" | "rectangle" | "rect" | "Rect" => Ok(Shape::Rectangle {
|
||||||
|
pos: Pos2::new(x.unwrap_or(0.0), y.unwrap_or(0.0)),
|
||||||
|
size: Vec2::new(width.unwrap_or(100.0), height.unwrap_or(100.0)),
|
||||||
|
}),
|
||||||
|
"Ellipse" | "ellipse" => Ok(Shape::Ellipse {
|
||||||
|
center: Pos2::new(center_x.or(x).unwrap_or(0.0), center_y.or(y).unwrap_or(0.0)),
|
||||||
|
radii: Vec2::new(radius_x.unwrap_or(50.0), radius_y.unwrap_or(50.0)),
|
||||||
|
}),
|
||||||
|
"Line" | "line" => {
|
||||||
|
let sx = x1.or(x).unwrap_or(0.0);
|
||||||
|
let sy = y1.or(y).unwrap_or(0.0);
|
||||||
|
Ok(Shape::Line {
|
||||||
|
start: Pos2::new(sx, sy),
|
||||||
|
end: Pos2::new(x2.unwrap_or(sx + 100.0), y2.unwrap_or(sy)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"Arrow" | "arrow" => {
|
||||||
|
let sx = x1.or(x).unwrap_or(0.0);
|
||||||
|
let sy = y1.or(y).unwrap_or(0.0);
|
||||||
|
Ok(Shape::Arrow {
|
||||||
|
start: Pos2::new(sx, sy),
|
||||||
|
end: Pos2::new(x2.unwrap_or(sx + 100.0), y2.unwrap_or(sy)),
|
||||||
|
})
|
||||||
|
}
|
||||||
|
"Text" | "text" => Ok(Shape::Text {
|
||||||
|
pos: Pos2::new(x.unwrap_or(0.0), y.unwrap_or(0.0)),
|
||||||
|
content: text.unwrap_or_else(|| "Text".to_string()),
|
||||||
|
font_size: font_size.unwrap_or(20.0),
|
||||||
|
}),
|
||||||
|
other => Err(format!(
|
||||||
|
"Unknown shape_type '{}'. Expected: Rectangle, Ellipse, Line, Arrow, or Text",
|
||||||
|
other
|
||||||
|
)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Build a `ShapeStyle` from optional hex color strings.
|
||||||
|
pub fn build_style(
|
||||||
|
fill: Option<String>,
|
||||||
|
stroke_color: Option<String>,
|
||||||
|
stroke_width: Option<f32>,
|
||||||
|
) -> ShapeStyle {
|
||||||
|
ShapeStyle {
|
||||||
|
fill: fill.as_deref().and_then(parse_hex_color),
|
||||||
|
stroke_color: stroke_color
|
||||||
|
.as_deref()
|
||||||
|
.and_then(parse_hex_color)
|
||||||
|
.unwrap_or(Color32::WHITE),
|
||||||
|
stroke_width: stroke_width.unwrap_or(2.0),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,10 +1,14 @@
|
|||||||
use super::protocol::{AgentRequest, AgentResponse, CodeGenTarget, GuiEvent};
|
use super::protocol::{
|
||||||
use crate::session::SessionStore;
|
build_shape, build_style, AgentRequest, AgentResponse, CodeGenTarget, DrawingCommand, GuiEvent,
|
||||||
|
SessionCommand,
|
||||||
|
};
|
||||||
|
use crate::drawing::DrawingElement;
|
||||||
|
use crate::session::{SessionCreator, SessionStore};
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use futures_util::{SinkExt, StreamExt};
|
use futures_util::{SinkExt, StreamExt};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::net::{TcpListener, TcpStream};
|
use tokio::net::{TcpListener, TcpStream};
|
||||||
use tokio::sync::{broadcast, RwLock};
|
use tokio::sync::{broadcast, mpsc, RwLock};
|
||||||
use tokio_tungstenite::tungstenite::Message;
|
use tokio_tungstenite::tungstenite::Message;
|
||||||
|
|
||||||
const EVENT_CHANNEL_CAPACITY: usize = 64;
|
const EVENT_CHANNEL_CAPACITY: usize = 64;
|
||||||
@@ -12,15 +16,23 @@ const EVENT_CHANNEL_CAPACITY: usize = 64;
|
|||||||
pub struct AgentServer {
|
pub struct AgentServer {
|
||||||
sessions: Arc<RwLock<SessionStore>>,
|
sessions: Arc<RwLock<SessionStore>>,
|
||||||
event_tx: broadcast::Sender<GuiEvent>,
|
event_tx: broadcast::Sender<GuiEvent>,
|
||||||
|
command_tx: mpsc::UnboundedSender<DrawingCommand>,
|
||||||
|
session_command_tx: mpsc::UnboundedSender<SessionCommand>,
|
||||||
port: u16,
|
port: u16,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgentServer {
|
impl AgentServer {
|
||||||
pub fn new(port: u16) -> Self {
|
pub fn new(
|
||||||
|
port: u16,
|
||||||
|
command_tx: mpsc::UnboundedSender<DrawingCommand>,
|
||||||
|
session_command_tx: mpsc::UnboundedSender<SessionCommand>,
|
||||||
|
) -> Self {
|
||||||
let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||||
Self {
|
Self {
|
||||||
sessions: Arc::new(RwLock::new(SessionStore::new())),
|
sessions: Arc::new(RwLock::new(SessionStore::new())),
|
||||||
event_tx,
|
event_tx,
|
||||||
|
command_tx,
|
||||||
|
session_command_tx,
|
||||||
port,
|
port,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -41,9 +53,21 @@ impl AgentServer {
|
|||||||
while let Ok((stream, peer)) = listener.accept().await {
|
while let Ok((stream, peer)) = listener.accept().await {
|
||||||
tracing::info!("Agent connected from {}", peer);
|
tracing::info!("Agent connected from {}", peer);
|
||||||
let sessions = self.sessions.clone();
|
let sessions = self.sessions.clone();
|
||||||
|
let event_tx = self.event_tx.clone();
|
||||||
let event_rx = self.event_tx.subscribe();
|
let event_rx = self.event_tx.subscribe();
|
||||||
|
let command_tx = self.command_tx.clone();
|
||||||
|
let session_command_tx = self.session_command_tx.clone();
|
||||||
tokio::spawn(async move {
|
tokio::spawn(async move {
|
||||||
if let Err(e) = handle_connection(stream, sessions, event_rx).await {
|
if let Err(e) = handle_connection(
|
||||||
|
stream,
|
||||||
|
sessions,
|
||||||
|
event_rx,
|
||||||
|
event_tx,
|
||||||
|
command_tx,
|
||||||
|
session_command_tx,
|
||||||
|
)
|
||||||
|
.await
|
||||||
|
{
|
||||||
tracing::error!("Connection error: {}", e);
|
tracing::error!("Connection error: {}", e);
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
@@ -57,6 +81,9 @@ async fn handle_connection(
|
|||||||
stream: TcpStream,
|
stream: TcpStream,
|
||||||
sessions: Arc<RwLock<SessionStore>>,
|
sessions: Arc<RwLock<SessionStore>>,
|
||||||
mut event_rx: broadcast::Receiver<GuiEvent>,
|
mut event_rx: broadcast::Receiver<GuiEvent>,
|
||||||
|
event_tx: broadcast::Sender<GuiEvent>,
|
||||||
|
command_tx: mpsc::UnboundedSender<DrawingCommand>,
|
||||||
|
session_command_tx: mpsc::UnboundedSender<SessionCommand>,
|
||||||
) -> Result<()> {
|
) -> Result<()> {
|
||||||
let ws_stream = tokio_tungstenite::accept_async(stream).await?;
|
let ws_stream = tokio_tungstenite::accept_async(stream).await?;
|
||||||
let (mut write, mut read) = ws_stream.split();
|
let (mut write, mut read) = ws_stream.split();
|
||||||
@@ -78,7 +105,11 @@ async fn handle_connection(
|
|||||||
match msg {
|
match msg {
|
||||||
Some(Ok(Message::Text(text))) => {
|
Some(Ok(Message::Text(text))) => {
|
||||||
let response = match serde_json::from_str::<AgentRequest>(&text) {
|
let response = match serde_json::from_str::<AgentRequest>(&text) {
|
||||||
Ok(request) => process_request(request, &sessions).await,
|
Ok(request) => {
|
||||||
|
process_request(
|
||||||
|
request, &sessions, &event_tx, &command_tx, &session_command_tx,
|
||||||
|
).await
|
||||||
|
}
|
||||||
Err(e) => AgentResponse::Error {
|
Err(e) => AgentResponse::Error {
|
||||||
message: format!("Invalid request: {}", e),
|
message: format!("Invalid request: {}", e),
|
||||||
},
|
},
|
||||||
@@ -117,18 +148,102 @@ async fn handle_connection(
|
|||||||
async fn process_request(
|
async fn process_request(
|
||||||
request: AgentRequest,
|
request: AgentRequest,
|
||||||
sessions: &Arc<RwLock<SessionStore>>,
|
sessions: &Arc<RwLock<SessionStore>>,
|
||||||
|
event_tx: &broadcast::Sender<GuiEvent>,
|
||||||
|
command_tx: &mpsc::UnboundedSender<DrawingCommand>,
|
||||||
|
session_command_tx: &mpsc::UnboundedSender<SessionCommand>,
|
||||||
) -> AgentResponse {
|
) -> AgentResponse {
|
||||||
let store = sessions.read().await;
|
|
||||||
|
|
||||||
match request {
|
match request {
|
||||||
AgentRequest::Ping => AgentResponse::Pong,
|
AgentRequest::Ping => AgentResponse::Pong,
|
||||||
|
|
||||||
AgentRequest::ListSessions => AgentResponse::Sessions {
|
AgentRequest::ListSessions {
|
||||||
sessions: store.list_sessions(),
|
sort_by,
|
||||||
|
sort_order,
|
||||||
|
} => {
|
||||||
|
let store = sessions.read().await;
|
||||||
|
let sessions_list = store
|
||||||
|
.list_sessions_sorted(sort_by.unwrap_or_default(), sort_order.unwrap_or_default());
|
||||||
|
AgentResponse::Sessions {
|
||||||
|
sessions: sessions_list,
|
||||||
active_session: store.active_session_id().map(|s| s.to_string()),
|
active_session: store.active_session_id().map(|s| s.to_string()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentRequest::CreateSession {
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
created_by_name,
|
||||||
|
} => {
|
||||||
|
let mut store = sessions.write().await;
|
||||||
|
let id = store.next_session_id();
|
||||||
|
let session_name = name.unwrap_or_else(|| format!("Agent Session {}", &id));
|
||||||
|
let created_by = SessionCreator::Agent {
|
||||||
|
name: created_by_name,
|
||||||
|
};
|
||||||
|
|
||||||
|
let info = crate::session::SessionInfo {
|
||||||
|
id: id.clone(),
|
||||||
|
name: session_name.clone(),
|
||||||
|
has_svg: false,
|
||||||
|
element_count: None,
|
||||||
|
drawing_element_count: 0,
|
||||||
|
description: description.clone(),
|
||||||
|
created_by: created_by.clone(),
|
||||||
|
created_at: std::time::SystemTime::now()
|
||||||
|
.duration_since(std::time::UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0),
|
||||||
|
};
|
||||||
|
|
||||||
|
store.add_session(info.clone(), None);
|
||||||
|
drop(store);
|
||||||
|
|
||||||
|
let _ = session_command_tx.send(SessionCommand::Create {
|
||||||
|
id: id.clone(),
|
||||||
|
name: session_name,
|
||||||
|
description,
|
||||||
|
created_by,
|
||||||
|
});
|
||||||
|
let _ = event_tx.send(GuiEvent::SessionCreated {
|
||||||
|
session: info.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
AgentResponse::SessionCreated { session: info }
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentRequest::UpdateSession {
|
||||||
|
session_id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
} => {
|
||||||
|
let mut store = sessions.write().await;
|
||||||
|
let desc_update = description.as_ref().map(|d| Some(d.clone()));
|
||||||
|
if !store.update_session_meta(&session_id, name.clone(), desc_update) {
|
||||||
|
return AgentResponse::Error {
|
||||||
|
message: format!("Session '{}' not found", session_id),
|
||||||
|
};
|
||||||
|
}
|
||||||
|
let info = store
|
||||||
|
.list_sessions()
|
||||||
|
.into_iter()
|
||||||
|
.find(|s| s.id == session_id);
|
||||||
|
drop(store);
|
||||||
|
|
||||||
|
let _ = session_command_tx.send(SessionCommand::Update {
|
||||||
|
session_id: session_id.clone(),
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
});
|
||||||
|
|
||||||
|
match info {
|
||||||
|
Some(session) => AgentResponse::SessionUpdated { session },
|
||||||
|
None => AgentResponse::Error {
|
||||||
|
message: format!("Session '{}' not found after update", session_id),
|
||||||
},
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
AgentRequest::GetTree { session_id } => {
|
AgentRequest::GetTree { session_id } => {
|
||||||
|
let store = sessions.read().await;
|
||||||
match store.get_tree(session_id.as_deref()) {
|
match store.get_tree(session_id.as_deref()) {
|
||||||
Some((sid, tree)) => AgentResponse::Tree {
|
Some((sid, tree)) => AgentResponse::Tree {
|
||||||
session_id: sid,
|
session_id: sid,
|
||||||
@@ -143,6 +258,7 @@ async fn process_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AgentRequest::GetElementById { session_id, id } => {
|
AgentRequest::GetElementById { session_id, id } => {
|
||||||
|
let store = sessions.read().await;
|
||||||
match store.get_tree(session_id.as_deref()) {
|
match store.get_tree(session_id.as_deref()) {
|
||||||
Some((sid, tree)) => AgentResponse::Element {
|
Some((sid, tree)) => AgentResponse::Element {
|
||||||
session_id: sid,
|
session_id: sid,
|
||||||
@@ -155,6 +271,7 @@ async fn process_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AgentRequest::GetElementsAtPoint { session_id, x, y } => {
|
AgentRequest::GetElementsAtPoint { session_id, x, y } => {
|
||||||
|
let store = sessions.read().await;
|
||||||
match store.get_tree(session_id.as_deref()) {
|
match store.get_tree(session_id.as_deref()) {
|
||||||
Some((sid, tree)) => AgentResponse::Elements {
|
Some((sid, tree)) => AgentResponse::Elements {
|
||||||
session_id: sid,
|
session_id: sid,
|
||||||
@@ -167,6 +284,7 @@ async fn process_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AgentRequest::Describe { session_id } => {
|
AgentRequest::Describe { session_id } => {
|
||||||
|
let store = sessions.read().await;
|
||||||
match store.get_tree(session_id.as_deref()) {
|
match store.get_tree(session_id.as_deref()) {
|
||||||
Some((sid, tree)) => AgentResponse::Description {
|
Some((sid, tree)) => AgentResponse::Description {
|
||||||
session_id: sid,
|
session_id: sid,
|
||||||
@@ -179,6 +297,7 @@ async fn process_request(
|
|||||||
}
|
}
|
||||||
|
|
||||||
AgentRequest::GetDrawingElements { session_id } => {
|
AgentRequest::GetDrawingElements { session_id } => {
|
||||||
|
let store = sessions.read().await;
|
||||||
match store.get_drawing_elements(session_id.as_deref()) {
|
match store.get_drawing_elements(session_id.as_deref()) {
|
||||||
Some((sid, elements)) => AgentResponse::DrawingElements {
|
Some((sid, elements)) => AgentResponse::DrawingElements {
|
||||||
session_id: sid,
|
session_id: sid,
|
||||||
@@ -190,7 +309,12 @@ async fn process_request(
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
AgentRequest::GenerateCode { session_id, target, element_id } => {
|
AgentRequest::GenerateCode {
|
||||||
|
session_id,
|
||||||
|
target,
|
||||||
|
element_id,
|
||||||
|
} => {
|
||||||
|
let store = sessions.read().await;
|
||||||
match store.get_tree(session_id.as_deref()) {
|
match store.get_tree(session_id.as_deref()) {
|
||||||
Some((sid, tree)) => {
|
Some((sid, tree)) => {
|
||||||
let element = match &element_id {
|
let element = match &element_id {
|
||||||
@@ -217,6 +341,201 @@ async fn process_request(
|
|||||||
},
|
},
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
AgentRequest::CreateDrawingElement {
|
||||||
|
session_id,
|
||||||
|
shape_type,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
center_x,
|
||||||
|
center_y,
|
||||||
|
radius_x,
|
||||||
|
radius_y,
|
||||||
|
x1,
|
||||||
|
y1,
|
||||||
|
x2,
|
||||||
|
y2,
|
||||||
|
text,
|
||||||
|
font_size,
|
||||||
|
fill,
|
||||||
|
stroke_color,
|
||||||
|
stroke_width,
|
||||||
|
} => {
|
||||||
|
let shape = match build_shape(
|
||||||
|
&shape_type,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
center_x,
|
||||||
|
center_y,
|
||||||
|
radius_x,
|
||||||
|
radius_y,
|
||||||
|
x1,
|
||||||
|
y1,
|
||||||
|
x2,
|
||||||
|
y2,
|
||||||
|
text,
|
||||||
|
font_size,
|
||||||
|
) {
|
||||||
|
Ok(s) => s,
|
||||||
|
Err(msg) => return AgentResponse::Error { message: msg },
|
||||||
|
};
|
||||||
|
let style = build_style(fill, stroke_color, stroke_width);
|
||||||
|
let element = DrawingElement::new(shape, style);
|
||||||
|
|
||||||
|
let mut store = sessions.write().await;
|
||||||
|
let sid = match store.resolve_session_id(session_id.as_deref()) {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
return AgentResponse::Error {
|
||||||
|
message: "No session found".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
store.add_drawing_element(&sid, element.clone());
|
||||||
|
let _ = command_tx.send(DrawingCommand::Create {
|
||||||
|
session_id: sid.clone(),
|
||||||
|
element: element.clone(),
|
||||||
|
});
|
||||||
|
let _ = event_tx.send(GuiEvent::DrawingElementCreated {
|
||||||
|
session_id: sid.clone(),
|
||||||
|
element: element.clone(),
|
||||||
|
});
|
||||||
|
|
||||||
|
AgentResponse::DrawingElementCreated {
|
||||||
|
session_id: sid,
|
||||||
|
element,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentRequest::UpdateDrawingElement {
|
||||||
|
session_id,
|
||||||
|
id,
|
||||||
|
shape_type,
|
||||||
|
x,
|
||||||
|
y,
|
||||||
|
width,
|
||||||
|
height,
|
||||||
|
center_x,
|
||||||
|
center_y,
|
||||||
|
radius_x,
|
||||||
|
radius_y,
|
||||||
|
x1,
|
||||||
|
y1,
|
||||||
|
x2,
|
||||||
|
y2,
|
||||||
|
text,
|
||||||
|
font_size,
|
||||||
|
fill,
|
||||||
|
stroke_color,
|
||||||
|
stroke_width,
|
||||||
|
} => {
|
||||||
|
let new_shape = if let Some(st) = shape_type {
|
||||||
|
match build_shape(
|
||||||
|
&st, x, y, width, height, center_x, center_y, radius_x, radius_y, x1, y1, x2,
|
||||||
|
y2, text, font_size,
|
||||||
|
) {
|
||||||
|
Ok(s) => Some(s),
|
||||||
|
Err(msg) => return AgentResponse::Error { message: msg },
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let has_style_change =
|
||||||
|
fill.is_some() || stroke_color.is_some() || stroke_width.is_some();
|
||||||
|
let new_style = if has_style_change {
|
||||||
|
Some(build_style(fill, stroke_color, stroke_width))
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut store = sessions.write().await;
|
||||||
|
let sid = match store.resolve_session_id(session_id.as_deref()) {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
return AgentResponse::Error {
|
||||||
|
message: "No session found".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
match store.update_drawing_element(&sid, &id, new_shape, new_style) {
|
||||||
|
Some(updated) => {
|
||||||
|
let _ = command_tx.send(DrawingCommand::Update {
|
||||||
|
session_id: sid.clone(),
|
||||||
|
element: updated.clone(),
|
||||||
|
});
|
||||||
|
let _ = event_tx.send(GuiEvent::DrawingElementUpdated {
|
||||||
|
session_id: sid.clone(),
|
||||||
|
element: updated.clone(),
|
||||||
|
});
|
||||||
|
AgentResponse::DrawingElementUpdated {
|
||||||
|
session_id: sid,
|
||||||
|
element: updated,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
None => AgentResponse::Error {
|
||||||
|
message: format!("Element '{}' not found in session", id),
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentRequest::DeleteDrawingElement { session_id, id } => {
|
||||||
|
let mut store = sessions.write().await;
|
||||||
|
let sid = match store.resolve_session_id(session_id.as_deref()) {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
return AgentResponse::Error {
|
||||||
|
message: "No session found".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
if store.delete_drawing_element(&sid, &id) {
|
||||||
|
let _ = command_tx.send(DrawingCommand::Delete {
|
||||||
|
session_id: sid.clone(),
|
||||||
|
id: id.clone(),
|
||||||
|
});
|
||||||
|
let _ = event_tx.send(GuiEvent::DrawingElementDeleted {
|
||||||
|
session_id: sid.clone(),
|
||||||
|
id: id.clone(),
|
||||||
|
});
|
||||||
|
AgentResponse::DrawingElementDeleted {
|
||||||
|
session_id: sid,
|
||||||
|
id,
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
AgentResponse::Error {
|
||||||
|
message: format!("Element '{}' not found in session", id),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
AgentRequest::ClearDrawingElements { session_id } => {
|
||||||
|
let mut store = sessions.write().await;
|
||||||
|
let sid = match store.resolve_session_id(session_id.as_deref()) {
|
||||||
|
Some(id) => id,
|
||||||
|
None => {
|
||||||
|
return AgentResponse::Error {
|
||||||
|
message: "No session found".to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
store.clear_drawing_elements(&sid);
|
||||||
|
let _ = command_tx.send(DrawingCommand::Clear {
|
||||||
|
session_id: sid.clone(),
|
||||||
|
});
|
||||||
|
let _ = event_tx.send(GuiEvent::DrawingElementsCleared {
|
||||||
|
session_id: sid.clone(),
|
||||||
|
});
|
||||||
|
AgentResponse::DrawingElementsCleared { session_id: sid }
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -1,17 +1,19 @@
|
|||||||
use crate::agent::{AgentServer, GuiEvent};
|
use crate::agent::{AgentServer, DrawingCommand, GuiEvent, SessionCommand};
|
||||||
use crate::canvas::{CanvasInteraction, CanvasState};
|
use crate::canvas::{CanvasInteraction, CanvasState};
|
||||||
use crate::clipboard::ClipboardManager;
|
use crate::clipboard::ClipboardManager;
|
||||||
|
use crate::command_palette::{CommandId, CommandPalette};
|
||||||
use crate::drawing::{
|
use crate::drawing::{
|
||||||
draw_creation_preview, draw_elements, draw_selection, find_handle_at_screen_pos,
|
draw_creation_preview, draw_elements, draw_selection, find_handle_at_screen_pos,
|
||||||
screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle, Tool,
|
screen_to_canvas, DragState, DrawingElement, Shape, ShapeStyle, Tool,
|
||||||
};
|
};
|
||||||
use crate::mermaid::render_mermaid_to_svg;
|
use crate::mermaid::render_mermaid_to_svg;
|
||||||
use crate::session::{Session, SessionStore};
|
use crate::persistence::{self, SavedSession, SavedWorkspace};
|
||||||
|
use crate::session::{Session, SessionCreator, SessionStore};
|
||||||
use crate::svg::{parse_svg, SvgRenderer};
|
use crate::svg::{parse_svg, SvgRenderer};
|
||||||
use egui::{Color32, ColorImage, TextureOptions};
|
use egui::{Color32, ColorImage, TextureOptions};
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
use tokio::runtime::Runtime;
|
use tokio::runtime::Runtime;
|
||||||
use tokio::sync::{broadcast, RwLock};
|
use tokio::sync::{broadcast, mpsc, RwLock};
|
||||||
|
|
||||||
const AGENT_PORT: u16 = 9876;
|
const AGENT_PORT: u16 = 9876;
|
||||||
const MIN_SHAPE_SIZE: f32 = 5.0;
|
const MIN_SHAPE_SIZE: f32 = 5.0;
|
||||||
@@ -22,6 +24,8 @@ pub struct AgCanvasApp {
|
|||||||
session_counter: usize,
|
session_counter: usize,
|
||||||
sessions_handle: Arc<RwLock<SessionStore>>,
|
sessions_handle: Arc<RwLock<SessionStore>>,
|
||||||
event_tx: broadcast::Sender<GuiEvent>,
|
event_tx: broadcast::Sender<GuiEvent>,
|
||||||
|
command_rx: mpsc::UnboundedReceiver<DrawingCommand>,
|
||||||
|
session_command_rx: mpsc::UnboundedReceiver<SessionCommand>,
|
||||||
clipboard: Option<ClipboardManager>,
|
clipboard: Option<ClipboardManager>,
|
||||||
show_tree_panel: bool,
|
show_tree_panel: bool,
|
||||||
show_description: bool,
|
show_description: bool,
|
||||||
@@ -33,6 +37,9 @@ pub struct AgCanvasApp {
|
|||||||
show_text_input: bool,
|
show_text_input: bool,
|
||||||
text_input_buffer: String,
|
text_input_buffer: String,
|
||||||
text_input_pos: Option<egui::Pos2>,
|
text_input_pos: Option<egui::Pos2>,
|
||||||
|
last_drawing_sync: std::time::Instant,
|
||||||
|
last_auto_save: std::time::Instant,
|
||||||
|
command_palette: CommandPalette,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl AgCanvasApp {
|
impl AgCanvasApp {
|
||||||
@@ -40,7 +47,9 @@ impl AgCanvasApp {
|
|||||||
configure_fonts(&cc.egui_ctx);
|
configure_fonts(&cc.egui_ctx);
|
||||||
|
|
||||||
let runtime = Runtime::new().expect("Failed to create tokio runtime");
|
let runtime = Runtime::new().expect("Failed to create tokio runtime");
|
||||||
let server = AgentServer::new(AGENT_PORT);
|
let (command_tx, command_rx) = mpsc::unbounded_channel();
|
||||||
|
let (session_command_tx, session_command_rx) = mpsc::unbounded_channel();
|
||||||
|
let server = AgentServer::new(AGENT_PORT, command_tx, session_command_tx);
|
||||||
let sessions_handle = server.sessions_handle();
|
let sessions_handle = server.sessions_handle();
|
||||||
let event_tx = server.event_sender();
|
let event_tx = server.event_sender();
|
||||||
|
|
||||||
@@ -58,6 +67,8 @@ impl AgCanvasApp {
|
|||||||
session_counter: 0,
|
session_counter: 0,
|
||||||
sessions_handle,
|
sessions_handle,
|
||||||
event_tx,
|
event_tx,
|
||||||
|
command_rx,
|
||||||
|
session_command_rx,
|
||||||
clipboard,
|
clipboard,
|
||||||
show_tree_panel: false,
|
show_tree_panel: false,
|
||||||
show_description: false,
|
show_description: false,
|
||||||
@@ -68,9 +79,14 @@ impl AgCanvasApp {
|
|||||||
show_text_input: false,
|
show_text_input: false,
|
||||||
text_input_buffer: String::new(),
|
text_input_buffer: String::new(),
|
||||||
text_input_pos: None,
|
text_input_pos: None,
|
||||||
|
last_drawing_sync: std::time::Instant::now(),
|
||||||
|
last_auto_save: std::time::Instant::now(),
|
||||||
|
command_palette: CommandPalette::new(),
|
||||||
};
|
};
|
||||||
|
|
||||||
|
if !app.restore_workspace(&cc.egui_ctx) {
|
||||||
app.create_session();
|
app.create_session();
|
||||||
|
}
|
||||||
app
|
app
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -78,7 +94,7 @@ impl AgCanvasApp {
|
|||||||
self.session_counter += 1;
|
self.session_counter += 1;
|
||||||
let id = format!("session-{}", self.session_counter);
|
let id = format!("session-{}", self.session_counter);
|
||||||
let name = format!("Tab {}", self.session_counter);
|
let name = format!("Tab {}", self.session_counter);
|
||||||
let session = Session::new(id.clone(), name);
|
let session = Session::new(id.clone(), name, SessionCreator::Human);
|
||||||
let info = session.info();
|
let info = session.info();
|
||||||
|
|
||||||
self.sessions.push(session);
|
self.sessions.push(session);
|
||||||
@@ -94,7 +110,9 @@ impl AgCanvasApp {
|
|||||||
store.add_session(info_clone.clone(), None);
|
store.add_session(info_clone.clone(), None);
|
||||||
store.set_active(&info_clone.id);
|
store.set_active(&info_clone.id);
|
||||||
});
|
});
|
||||||
let _ = event_tx.send(GuiEvent::SessionCreated { session: info_clone });
|
let _ = event_tx.send(GuiEvent::SessionCreated {
|
||||||
|
session: info_clone,
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
self.set_status("Created new session".to_string());
|
self.set_status("Created new session".to_string());
|
||||||
@@ -170,11 +188,11 @@ impl AgCanvasApp {
|
|||||||
session.element_tree = Some(tree.clone());
|
session.element_tree = Some(tree.clone());
|
||||||
session.description_text = tree.to_semantic_description();
|
session.description_text = tree.to_semantic_description();
|
||||||
session.svg_renderer = Some(SvgRenderer::new(usvg_tree));
|
session.svg_renderer = Some(SvgRenderer::new(usvg_tree));
|
||||||
|
session.svg_source = Some(svg_data.to_string());
|
||||||
session.svg_texture = None;
|
session.svg_texture = None;
|
||||||
session.canvas_state.fit_to_rect(
|
session
|
||||||
egui::vec2(width, height),
|
.canvas_state
|
||||||
ctx.screen_rect().size() * 0.8,
|
.fit_to_rect(egui::vec2(width, height), ctx.screen_rect().size() * 0.8);
|
||||||
);
|
|
||||||
|
|
||||||
let session_id = session.id.clone();
|
let session_id = session.id.clone();
|
||||||
let metadata = tree.metadata.clone();
|
let metadata = tree.metadata.clone();
|
||||||
@@ -232,7 +250,9 @@ impl AgCanvasApp {
|
|||||||
let pixels: Vec<Color32> = pixmap
|
let pixels: Vec<Color32> = pixmap
|
||||||
.pixels()
|
.pixels()
|
||||||
.iter()
|
.iter()
|
||||||
.map(|p| Color32::from_rgba_premultiplied(p.red(), p.green(), p.blue(), p.alpha()))
|
.map(|p| {
|
||||||
|
Color32::from_rgba_premultiplied(p.red(), p.green(), p.blue(), p.alpha())
|
||||||
|
})
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
let image = ColorImage { size, pixels };
|
let image = ColorImage { size, pixels };
|
||||||
@@ -269,6 +289,236 @@ impl AgCanvasApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn drain_drawing_commands(&mut self) {
|
||||||
|
while let Ok(cmd) = self.command_rx.try_recv() {
|
||||||
|
match cmd {
|
||||||
|
DrawingCommand::Create {
|
||||||
|
session_id,
|
||||||
|
element,
|
||||||
|
} => {
|
||||||
|
if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
|
||||||
|
session.drawing_elements.push(element);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DrawingCommand::Update {
|
||||||
|
session_id,
|
||||||
|
element,
|
||||||
|
} => {
|
||||||
|
if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
|
||||||
|
if let Some(el) = session
|
||||||
|
.drawing_elements
|
||||||
|
.iter_mut()
|
||||||
|
.find(|e| e.id == element.id)
|
||||||
|
{
|
||||||
|
el.shape = element.shape;
|
||||||
|
el.style = element.style;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DrawingCommand::Delete { session_id, id } => {
|
||||||
|
if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
|
||||||
|
session.drawing_elements.retain(|e| e.id != id);
|
||||||
|
if session.selected_element_id.as_deref() == Some(&id) {
|
||||||
|
session.selected_element_id = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
DrawingCommand::Clear { session_id } => {
|
||||||
|
if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
|
||||||
|
session.drawing_elements.clear();
|
||||||
|
session.selected_element_id = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn drain_session_commands(&mut self) {
|
||||||
|
while let Ok(cmd) = self.session_command_rx.try_recv() {
|
||||||
|
match cmd {
|
||||||
|
SessionCommand::Create {
|
||||||
|
id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
created_by,
|
||||||
|
} => {
|
||||||
|
let session =
|
||||||
|
Session::new(id.clone(), name, created_by).with_description(description);
|
||||||
|
self.sessions.push(session);
|
||||||
|
self.active_session_idx = self.sessions.len() - 1;
|
||||||
|
|
||||||
|
if let Some(num) = id.strip_prefix("session-") {
|
||||||
|
if let Ok(n) = num.parse::<usize>() {
|
||||||
|
if n >= self.session_counter {
|
||||||
|
self.session_counter = n;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
SessionCommand::Update {
|
||||||
|
session_id,
|
||||||
|
name,
|
||||||
|
description,
|
||||||
|
} => {
|
||||||
|
if let Some(session) = self.sessions.iter_mut().find(|s| s.id == session_id) {
|
||||||
|
if let Some(n) = name {
|
||||||
|
session.name = n;
|
||||||
|
}
|
||||||
|
if let Some(d) = description {
|
||||||
|
session.description = Some(d);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn sync_drawing_elements_to_store(&self) {
|
||||||
|
let sessions_handle = self.sessions_handle.clone();
|
||||||
|
let elements_by_session: Vec<(String, Vec<DrawingElement>)> = self
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.map(|s| (s.id.clone(), s.drawing_elements.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let rt = Runtime::new().unwrap();
|
||||||
|
rt.block_on(async {
|
||||||
|
let mut store = sessions_handle.write().await;
|
||||||
|
for (sid, elements) in elements_by_session {
|
||||||
|
store.update_drawing_elements(&sid, elements);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_saved_workspace(&self) -> SavedWorkspace {
|
||||||
|
let sessions: Vec<SavedSession> = self
|
||||||
|
.sessions
|
||||||
|
.iter()
|
||||||
|
.map(|s| SavedSession {
|
||||||
|
id: s.id.clone(),
|
||||||
|
name: s.name.clone(),
|
||||||
|
canvas_state: s.canvas_state.clone(),
|
||||||
|
svg_source: s.svg_source.clone(),
|
||||||
|
drawing_elements: s.drawing_elements.clone(),
|
||||||
|
description: s.description.clone(),
|
||||||
|
created_by: s.created_by.clone(),
|
||||||
|
created_at: s.created_at,
|
||||||
|
})
|
||||||
|
.collect();
|
||||||
|
SavedWorkspace::new(self.active_session_idx, self.session_counter, sessions)
|
||||||
|
}
|
||||||
|
|
||||||
|
fn save_workspace(&mut self) {
|
||||||
|
let workspace = self.build_saved_workspace();
|
||||||
|
match persistence::save_workspace(&workspace) {
|
||||||
|
Ok(()) => self.set_status(format!("Saved {} session(s)", workspace.sessions.len())),
|
||||||
|
Err(e) => self.set_status(format!("Save failed: {}", e)),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn restore_workspace(&mut self, ctx: &egui::Context) -> bool {
|
||||||
|
let workspace = match persistence::load_workspace() {
|
||||||
|
Ok(Some(w)) if !w.sessions.is_empty() => w,
|
||||||
|
Ok(_) => return false,
|
||||||
|
Err(e) => {
|
||||||
|
tracing::warn!("Failed to load workspace: {}", e);
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
self.session_counter = workspace.session_counter;
|
||||||
|
|
||||||
|
for saved in &workspace.sessions {
|
||||||
|
let mut session = Session::new(
|
||||||
|
saved.id.clone(),
|
||||||
|
saved.name.clone(),
|
||||||
|
saved.created_by.clone(),
|
||||||
|
);
|
||||||
|
session.canvas_state = saved.canvas_state.clone();
|
||||||
|
session.drawing_elements = saved.drawing_elements.clone();
|
||||||
|
session.description = saved.description.clone();
|
||||||
|
session.created_at = saved.created_at;
|
||||||
|
|
||||||
|
if let Some(svg_data) = &saved.svg_source {
|
||||||
|
if let Ok((tree, usvg_tree)) = parse_svg(svg_data) {
|
||||||
|
session.description_text = tree.to_semantic_description();
|
||||||
|
session.svg_renderer = Some(SvgRenderer::new(usvg_tree));
|
||||||
|
session.element_tree = Some(tree);
|
||||||
|
session.svg_source = Some(svg_data.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let info = session.info();
|
||||||
|
let tree_clone = session.element_tree.clone();
|
||||||
|
self.sessions.push(session);
|
||||||
|
|
||||||
|
let sessions_handle = self.sessions_handle.clone();
|
||||||
|
let info_clone = info;
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let rt = Runtime::new().unwrap();
|
||||||
|
rt.block_on(async {
|
||||||
|
let mut store = sessions_handle.write().await;
|
||||||
|
store.add_session(info_clone, tree_clone);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
self.active_session_idx = workspace
|
||||||
|
.active_session_idx
|
||||||
|
.min(self.sessions.len().saturating_sub(1));
|
||||||
|
|
||||||
|
if let Some(session) = self.sessions.get(self.active_session_idx) {
|
||||||
|
let sid = session.id.clone();
|
||||||
|
let sessions_handle = self.sessions_handle.clone();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let rt = Runtime::new().unwrap();
|
||||||
|
rt.block_on(async {
|
||||||
|
let mut store = sessions_handle.write().await;
|
||||||
|
store.set_active(&sid);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
let count = self.sessions.len();
|
||||||
|
self.set_status(format!("Restored {} session(s)", count));
|
||||||
|
ctx.request_repaint();
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
fn execute_command(&mut self, cmd: CommandId, ctx: &egui::Context) {
|
||||||
|
match cmd {
|
||||||
|
CommandId::NewTab => self.create_session(),
|
||||||
|
CommandId::CloseTab => {
|
||||||
|
let idx = self.active_session_idx;
|
||||||
|
self.close_session(idx);
|
||||||
|
}
|
||||||
|
CommandId::SaveWorkspace => self.save_workspace(),
|
||||||
|
CommandId::ClearCanvas => self.clear_canvas(),
|
||||||
|
CommandId::PasteSvg => self.handle_paste(ctx),
|
||||||
|
CommandId::PasteMermaid => self.show_mermaid_dialog = true,
|
||||||
|
CommandId::ToolSelect => self.active_session_mut().active_tool = Tool::Select,
|
||||||
|
CommandId::ToolRectangle => self.active_session_mut().active_tool = Tool::Rectangle,
|
||||||
|
CommandId::ToolEllipse => self.active_session_mut().active_tool = Tool::Ellipse,
|
||||||
|
CommandId::ToolLine => self.active_session_mut().active_tool = Tool::Line,
|
||||||
|
CommandId::ToolArrow => self.active_session_mut().active_tool = Tool::Arrow,
|
||||||
|
CommandId::ToolText => self.active_session_mut().active_tool = Tool::Text,
|
||||||
|
CommandId::ResetZoom => self.active_session_mut().canvas_state.reset(),
|
||||||
|
CommandId::FitToView => {
|
||||||
|
let session = self.active_session_mut();
|
||||||
|
if let Some(renderer) = &session.svg_renderer {
|
||||||
|
let (w, h) = renderer.size();
|
||||||
|
session
|
||||||
|
.canvas_state
|
||||||
|
.fit_to_rect(egui::vec2(w, h), ctx.screen_rect().size() * 0.8);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
CommandId::ToggleTreePanel => self.show_tree_panel = !self.show_tree_panel,
|
||||||
|
CommandId::ToggleDescription => self.show_description = !self.show_description,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_drawing_input(&mut self, response: &egui::Response, canvas_center: egui::Pos2) {
|
fn handle_drawing_input(&mut self, response: &egui::Response, canvas_center: egui::Pos2) {
|
||||||
let session = self.active_session_mut();
|
let session = self.active_session_mut();
|
||||||
let offset = session.canvas_state.offset;
|
let offset = session.canvas_state.offset;
|
||||||
@@ -331,9 +581,7 @@ fn handle_select_tool(
|
|||||||
if let Some(el) = hit {
|
if let Some(el) = hit {
|
||||||
let eid = el.id.clone();
|
let eid = el.id.clone();
|
||||||
session.selected_element_id = Some(eid.clone());
|
session.selected_element_id = Some(eid.clone());
|
||||||
session.drag_state = DragState::Moving {
|
session.drag_state = DragState::Moving { element_id: eid };
|
||||||
element_id: eid,
|
|
||||||
};
|
|
||||||
} else {
|
} else {
|
||||||
session.selected_element_id = None;
|
session.selected_element_id = None;
|
||||||
session.drag_state = DragState::None;
|
session.drag_state = DragState::None;
|
||||||
@@ -464,13 +712,27 @@ fn handle_shape_tool(
|
|||||||
|
|
||||||
impl eframe::App for AgCanvasApp {
|
impl eframe::App for AgCanvasApp {
|
||||||
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
fn update(&mut self, ctx: &egui::Context, _frame: &mut eframe::Frame) {
|
||||||
|
self.drain_drawing_commands();
|
||||||
|
self.drain_session_commands();
|
||||||
|
|
||||||
let mut paste = false;
|
let mut paste = false;
|
||||||
let mut new_tab = false;
|
let mut new_tab = false;
|
||||||
let mut close_tab = false;
|
let mut close_tab = false;
|
||||||
|
let mut save_workspace = false;
|
||||||
|
let mut toggle_palette = false;
|
||||||
let mut delete_selected = false;
|
let mut delete_selected = false;
|
||||||
let mut tool_switch: Option<Tool> = None;
|
let mut tool_switch: Option<Tool> = None;
|
||||||
|
|
||||||
|
let palette_open = self.command_palette.visible;
|
||||||
|
|
||||||
ctx.input(|i| {
|
ctx.input(|i| {
|
||||||
|
if i.modifiers.command && i.key_pressed(egui::Key::K) {
|
||||||
|
toggle_palette = true;
|
||||||
|
}
|
||||||
|
if i.modifiers.command && i.key_pressed(egui::Key::S) {
|
||||||
|
save_workspace = true;
|
||||||
|
}
|
||||||
|
if !palette_open {
|
||||||
if i.modifiers.command && i.key_pressed(egui::Key::V) {
|
if i.modifiers.command && i.key_pressed(egui::Key::V) {
|
||||||
paste = true;
|
paste = true;
|
||||||
}
|
}
|
||||||
@@ -503,8 +765,16 @@ impl eframe::App for AgCanvasApp {
|
|||||||
tool_switch = Some(Tool::Arrow);
|
tool_switch = Some(Tool::Arrow);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if toggle_palette {
|
||||||
|
self.command_palette.toggle();
|
||||||
|
}
|
||||||
|
if save_workspace {
|
||||||
|
self.save_workspace();
|
||||||
|
}
|
||||||
|
|
||||||
if paste {
|
if paste {
|
||||||
self.handle_paste(ctx);
|
self.handle_paste(ctx);
|
||||||
}
|
}
|
||||||
@@ -607,10 +877,15 @@ impl eframe::App for AgCanvasApp {
|
|||||||
let is_active = idx == self.active_session_idx;
|
let is_active = idx == self.active_session_idx;
|
||||||
let has_content =
|
let has_content =
|
||||||
session.element_tree.is_some() || !session.drawing_elements.is_empty();
|
session.element_tree.is_some() || !session.drawing_elements.is_empty();
|
||||||
|
|
||||||
|
let creator_icon = match &session.created_by {
|
||||||
|
SessionCreator::Human => "",
|
||||||
|
SessionCreator::Agent { .. } => "\u{1F916} ",
|
||||||
|
};
|
||||||
let label = if has_content {
|
let label = if has_content {
|
||||||
format!("{} *", session.name)
|
format!("{}{} *", creator_icon, session.name)
|
||||||
} else {
|
} else {
|
||||||
session.name.clone()
|
format!("{}{}", creator_icon, session.name)
|
||||||
};
|
};
|
||||||
|
|
||||||
let button = egui::Button::new(&label).fill(if is_active {
|
let button = egui::Button::new(&label).fill(if is_active {
|
||||||
@@ -624,6 +899,14 @@ impl eframe::App for AgCanvasApp {
|
|||||||
switch_idx = Some(idx);
|
switch_idx = Some(idx);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let mut tooltip_parts = vec![format!("Created by: {}", session.created_by)];
|
||||||
|
if let Some(desc) = &session.description {
|
||||||
|
if !desc.is_empty() {
|
||||||
|
tooltip_parts.push(desc.clone());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
tab_response.clone().on_hover_text(tooltip_parts.join("\n"));
|
||||||
|
|
||||||
if self.sessions.len() > 1 {
|
if self.sessions.len() > 1 {
|
||||||
tab_response.context_menu(|ui| {
|
tab_response.context_menu(|ui| {
|
||||||
if ui.button("Close").clicked() {
|
if ui.button("Close").clicked() {
|
||||||
@@ -929,8 +1212,44 @@ impl eframe::App for AgCanvasApp {
|
|||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
|
if let Some(cmd) = self.command_palette.show(ctx) {
|
||||||
|
self.execute_command(cmd, ctx);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.last_drawing_sync.elapsed().as_millis() > 500 {
|
||||||
|
self.last_drawing_sync = std::time::Instant::now();
|
||||||
|
self.sync_drawing_elements_to_store();
|
||||||
|
|
||||||
|
let sessions_handle = self.sessions_handle.clone();
|
||||||
|
let counter = self.session_counter;
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
let rt = Runtime::new().unwrap();
|
||||||
|
rt.block_on(async {
|
||||||
|
let mut store = sessions_handle.write().await;
|
||||||
|
store.set_counter_minimum(counter);
|
||||||
|
});
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.last_auto_save.elapsed().as_secs() > 30 {
|
||||||
|
self.last_auto_save = std::time::Instant::now();
|
||||||
|
let workspace = self.build_saved_workspace();
|
||||||
|
std::thread::spawn(move || {
|
||||||
|
if let Err(e) = persistence::save_workspace(&workspace) {
|
||||||
|
tracing::warn!("Auto-save failed: {}", e);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
ctx.request_repaint();
|
ctx.request_repaint();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn on_exit(&mut self, _gl: Option<&eframe::glow::Context>) {
|
||||||
|
let workspace = self.build_saved_workspace();
|
||||||
|
if let Err(e) = persistence::save_workspace(&workspace) {
|
||||||
|
tracing::error!("Failed to save workspace on exit: {}", e);
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn configure_fonts(ctx: &egui::Context) {
|
fn configure_fonts(ctx: &egui::Context) {
|
||||||
@@ -979,14 +1298,26 @@ fn draw_grid(painter: &egui::Painter, rect: &egui::Rect, state: &CanvasState) {
|
|||||||
fn render_tree_ui(ui: &mut egui::Ui, element: &crate::element_tree::Element, depth: usize) {
|
fn render_tree_ui(ui: &mut egui::Ui, element: &crate::element_tree::Element, depth: usize) {
|
||||||
let kind_name = match &element.kind {
|
let kind_name = match &element.kind {
|
||||||
crate::element_tree::ElementKind::Group { name } => {
|
crate::element_tree::ElementKind::Group { name } => {
|
||||||
format!("Group{}", name.as_ref().map(|n| format!(" '{}'", n)).unwrap_or_default())
|
format!(
|
||||||
|
"Group{}",
|
||||||
|
name.as_ref()
|
||||||
|
.map(|n| format!(" '{}'", n))
|
||||||
|
.unwrap_or_default()
|
||||||
|
)
|
||||||
}
|
}
|
||||||
crate::element_tree::ElementKind::Rectangle { .. } => "Rect".to_string(),
|
crate::element_tree::ElementKind::Rectangle { .. } => "Rect".to_string(),
|
||||||
crate::element_tree::ElementKind::Circle { .. } => "Circle".to_string(),
|
crate::element_tree::ElementKind::Circle { .. } => "Circle".to_string(),
|
||||||
crate::element_tree::ElementKind::Ellipse { .. } => "Ellipse".to_string(),
|
crate::element_tree::ElementKind::Ellipse { .. } => "Ellipse".to_string(),
|
||||||
crate::element_tree::ElementKind::Path { .. } => "Path".to_string(),
|
crate::element_tree::ElementKind::Path { .. } => "Path".to_string(),
|
||||||
crate::element_tree::ElementKind::Text { content, .. } => {
|
crate::element_tree::ElementKind::Text { content, .. } => {
|
||||||
format!("Text '{}'", if content.len() > 15 { &content[..15] } else { content })
|
format!(
|
||||||
|
"Text '{}'",
|
||||||
|
if content.len() > 15 {
|
||||||
|
&content[..15]
|
||||||
|
} else {
|
||||||
|
content
|
||||||
|
}
|
||||||
|
)
|
||||||
}
|
}
|
||||||
crate::element_tree::ElementKind::Image { .. } => "Image".to_string(),
|
crate::element_tree::ElementKind::Image { .. } => "Image".to_string(),
|
||||||
crate::element_tree::ElementKind::Line { .. } => "Line".to_string(),
|
crate::element_tree::ElementKind::Line { .. } => "Line".to_string(),
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
mod state;
|
|
||||||
mod interaction;
|
mod interaction;
|
||||||
|
mod state;
|
||||||
|
|
||||||
pub use state::CanvasState;
|
|
||||||
pub use interaction::CanvasInteraction;
|
pub use interaction::CanvasInteraction;
|
||||||
|
pub use state::CanvasState;
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
use egui::{Pos2, Vec2};
|
use egui::{Pos2, Vec2};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
|
||||||
#[derive(Clone)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct CanvasState {
|
pub struct CanvasState {
|
||||||
pub offset: Vec2,
|
pub offset: Vec2,
|
||||||
pub zoom: f32,
|
pub zoom: f32,
|
||||||
|
|||||||
353
crates/agcanvas/src/command_palette.rs
Normal file
353
crates/agcanvas/src/command_palette.rs
Normal file
@@ -0,0 +1,353 @@
|
|||||||
|
use egui::{Color32, Key};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
|
||||||
|
pub enum CommandId {
|
||||||
|
NewTab,
|
||||||
|
CloseTab,
|
||||||
|
SaveWorkspace,
|
||||||
|
ClearCanvas,
|
||||||
|
PasteSvg,
|
||||||
|
PasteMermaid,
|
||||||
|
ToolSelect,
|
||||||
|
ToolRectangle,
|
||||||
|
ToolEllipse,
|
||||||
|
ToolLine,
|
||||||
|
ToolArrow,
|
||||||
|
ToolText,
|
||||||
|
ResetZoom,
|
||||||
|
FitToView,
|
||||||
|
ToggleTreePanel,
|
||||||
|
ToggleDescription,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct PaletteCommand {
|
||||||
|
pub id: CommandId,
|
||||||
|
pub label: String,
|
||||||
|
pub shortcut: Option<String>,
|
||||||
|
pub category: &'static str,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl PaletteCommand {
|
||||||
|
pub fn new(id: CommandId, label: &str, shortcut: Option<&str>, category: &'static str) -> Self {
|
||||||
|
Self {
|
||||||
|
id,
|
||||||
|
label: label.to_string(),
|
||||||
|
shortcut: shortcut.map(|s| s.to_string()),
|
||||||
|
category,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn all_commands() -> Vec<PaletteCommand> {
|
||||||
|
vec![
|
||||||
|
PaletteCommand::new(CommandId::NewTab, "New Tab", Some("Cmd+T"), "Session"),
|
||||||
|
PaletteCommand::new(CommandId::CloseTab, "Close Tab", Some("Cmd+W"), "Session"),
|
||||||
|
PaletteCommand::new(
|
||||||
|
CommandId::SaveWorkspace,
|
||||||
|
"Save Workspace",
|
||||||
|
Some("Cmd+S"),
|
||||||
|
"Session",
|
||||||
|
),
|
||||||
|
PaletteCommand::new(CommandId::ClearCanvas, "Clear Canvas", None, "Canvas"),
|
||||||
|
PaletteCommand::new(CommandId::PasteSvg, "Paste SVG", Some("Cmd+V"), "Canvas"),
|
||||||
|
PaletteCommand::new(
|
||||||
|
CommandId::PasteMermaid,
|
||||||
|
"Paste Mermaid Diagram",
|
||||||
|
None,
|
||||||
|
"Canvas",
|
||||||
|
),
|
||||||
|
PaletteCommand::new(CommandId::ToolSelect, "Select Tool", Some("V"), "Tool"),
|
||||||
|
PaletteCommand::new(
|
||||||
|
CommandId::ToolRectangle,
|
||||||
|
"Rectangle Tool",
|
||||||
|
Some("R"),
|
||||||
|
"Tool",
|
||||||
|
),
|
||||||
|
PaletteCommand::new(CommandId::ToolEllipse, "Ellipse Tool", Some("E"), "Tool"),
|
||||||
|
PaletteCommand::new(CommandId::ToolLine, "Line Tool", Some("L"), "Tool"),
|
||||||
|
PaletteCommand::new(CommandId::ToolArrow, "Arrow Tool", Some("A"), "Tool"),
|
||||||
|
PaletteCommand::new(CommandId::ToolText, "Text Tool", Some("T"), "Tool"),
|
||||||
|
PaletteCommand::new(CommandId::ResetZoom, "Reset Zoom", Some("Cmd+0"), "View"),
|
||||||
|
PaletteCommand::new(CommandId::FitToView, "Fit to View", None, "View"),
|
||||||
|
PaletteCommand::new(
|
||||||
|
CommandId::ToggleTreePanel,
|
||||||
|
"Toggle Element Tree Panel",
|
||||||
|
None,
|
||||||
|
"View",
|
||||||
|
),
|
||||||
|
PaletteCommand::new(
|
||||||
|
CommandId::ToggleDescription,
|
||||||
|
"Toggle Description Panel",
|
||||||
|
None,
|
||||||
|
"View",
|
||||||
|
),
|
||||||
|
]
|
||||||
|
}
|
||||||
|
|
||||||
|
pub struct CommandPalette {
|
||||||
|
pub visible: bool,
|
||||||
|
pub query: String,
|
||||||
|
pub selected_idx: usize,
|
||||||
|
commands: Vec<PaletteCommand>,
|
||||||
|
filtered: Vec<usize>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl CommandPalette {
|
||||||
|
pub fn new() -> Self {
|
||||||
|
let commands = all_commands();
|
||||||
|
let filtered: Vec<usize> = (0..commands.len()).collect();
|
||||||
|
Self {
|
||||||
|
visible: false,
|
||||||
|
query: String::new(),
|
||||||
|
selected_idx: 0,
|
||||||
|
commands,
|
||||||
|
filtered,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn open(&mut self) {
|
||||||
|
self.visible = true;
|
||||||
|
self.query.clear();
|
||||||
|
self.selected_idx = 0;
|
||||||
|
self.update_filter();
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn close(&mut self) {
|
||||||
|
self.visible = false;
|
||||||
|
self.query.clear();
|
||||||
|
self.selected_idx = 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn toggle(&mut self) {
|
||||||
|
if self.visible {
|
||||||
|
self.close();
|
||||||
|
} else {
|
||||||
|
self.open();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn update_filter(&mut self) {
|
||||||
|
if self.query.is_empty() {
|
||||||
|
self.filtered = (0..self.commands.len()).collect();
|
||||||
|
} else {
|
||||||
|
let query_lower = self.query.to_lowercase();
|
||||||
|
self.filtered = self
|
||||||
|
.commands
|
||||||
|
.iter()
|
||||||
|
.enumerate()
|
||||||
|
.filter(|(_, cmd)| fuzzy_match(&cmd.label, &query_lower))
|
||||||
|
.map(|(i, _)| i)
|
||||||
|
.collect();
|
||||||
|
}
|
||||||
|
if self.selected_idx >= self.filtered.len() {
|
||||||
|
self.selected_idx = 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn show(&mut self, ctx: &egui::Context) -> Option<CommandId> {
|
||||||
|
if !self.visible {
|
||||||
|
return None;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut executed: Option<CommandId> = None;
|
||||||
|
let mut should_close = false;
|
||||||
|
|
||||||
|
let screen = ctx.screen_rect();
|
||||||
|
let palette_width = (screen.width() * 0.5).clamp(300.0, 500.0);
|
||||||
|
let palette_x = (screen.width() - palette_width) / 2.0;
|
||||||
|
let palette_y = screen.height() * 0.15;
|
||||||
|
|
||||||
|
egui::Area::new(egui::Id::new("command_palette_backdrop"))
|
||||||
|
.fixed_pos(screen.min)
|
||||||
|
.order(egui::Order::Foreground)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
let response = ui.allocate_response(screen.size(), egui::Sense::click());
|
||||||
|
if response.clicked() {
|
||||||
|
should_close = true;
|
||||||
|
}
|
||||||
|
ui.painter().rect_filled(
|
||||||
|
screen,
|
||||||
|
0.0,
|
||||||
|
Color32::from_rgba_unmultiplied(0, 0, 0, 120),
|
||||||
|
);
|
||||||
|
});
|
||||||
|
|
||||||
|
egui::Area::new(egui::Id::new("command_palette"))
|
||||||
|
.fixed_pos(egui::pos2(palette_x, palette_y))
|
||||||
|
.order(egui::Order::Foreground)
|
||||||
|
.show(ctx, |ui| {
|
||||||
|
egui::Frame::popup(ui.style())
|
||||||
|
.fill(Color32::from_gray(30))
|
||||||
|
.stroke(egui::Stroke::new(1.0, Color32::from_gray(60)))
|
||||||
|
.rounding(8.0)
|
||||||
|
.inner_margin(8.0)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(palette_width);
|
||||||
|
|
||||||
|
let text_edit = egui::TextEdit::singleline(&mut self.query)
|
||||||
|
.desired_width(palette_width - 16.0)
|
||||||
|
.font(egui::TextStyle::Body)
|
||||||
|
.hint_text("Type a command...");
|
||||||
|
let response = ui.add(text_edit);
|
||||||
|
response.request_focus();
|
||||||
|
|
||||||
|
if response.changed() {
|
||||||
|
self.update_filter();
|
||||||
|
}
|
||||||
|
|
||||||
|
ctx.input(|i| {
|
||||||
|
if i.key_pressed(Key::Escape) {
|
||||||
|
should_close = true;
|
||||||
|
}
|
||||||
|
if i.key_pressed(Key::ArrowDown) && !self.filtered.is_empty() {
|
||||||
|
self.selected_idx = (self.selected_idx + 1) % self.filtered.len();
|
||||||
|
}
|
||||||
|
if i.key_pressed(Key::ArrowUp) && !self.filtered.is_empty() {
|
||||||
|
self.selected_idx = if self.selected_idx == 0 {
|
||||||
|
self.filtered.len() - 1
|
||||||
|
} else {
|
||||||
|
self.selected_idx - 1
|
||||||
|
};
|
||||||
|
}
|
||||||
|
if i.key_pressed(Key::Enter) && !self.filtered.is_empty() {
|
||||||
|
let cmd_idx = self.filtered[self.selected_idx];
|
||||||
|
executed = Some(self.commands[cmd_idx].id);
|
||||||
|
should_close = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(4.0);
|
||||||
|
ui.separator();
|
||||||
|
|
||||||
|
let max_visible = 10;
|
||||||
|
egui::ScrollArea::vertical()
|
||||||
|
.max_height(max_visible as f32 * 28.0)
|
||||||
|
.show(ui, |ui| {
|
||||||
|
if self.filtered.is_empty() {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new("No matching commands")
|
||||||
|
.color(Color32::from_gray(100)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
for (display_idx, &cmd_idx) in self.filtered.iter().enumerate() {
|
||||||
|
let cmd = &self.commands[cmd_idx];
|
||||||
|
let is_selected = display_idx == self.selected_idx;
|
||||||
|
|
||||||
|
let bg = if is_selected {
|
||||||
|
Color32::from_gray(50)
|
||||||
|
} else {
|
||||||
|
Color32::TRANSPARENT
|
||||||
|
};
|
||||||
|
|
||||||
|
let frame = egui::Frame::none()
|
||||||
|
.fill(bg)
|
||||||
|
.rounding(4.0)
|
||||||
|
.inner_margin(egui::Margin::symmetric(8.0, 4.0));
|
||||||
|
|
||||||
|
let resp = frame
|
||||||
|
.show(ui, |ui| {
|
||||||
|
ui.set_width(palette_width - 32.0);
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(&cmd.label)
|
||||||
|
.color(Color32::WHITE),
|
||||||
|
);
|
||||||
|
ui.with_layout(
|
||||||
|
egui::Layout::right_to_left(
|
||||||
|
egui::Align::Center,
|
||||||
|
),
|
||||||
|
|ui| {
|
||||||
|
if let Some(shortcut) = &cmd.shortcut {
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(shortcut)
|
||||||
|
.small()
|
||||||
|
.color(Color32::from_gray(100)),
|
||||||
|
);
|
||||||
|
}
|
||||||
|
ui.label(
|
||||||
|
egui::RichText::new(cmd.category)
|
||||||
|
.small()
|
||||||
|
.color(Color32::from_gray(80)),
|
||||||
|
);
|
||||||
|
},
|
||||||
|
);
|
||||||
|
});
|
||||||
|
})
|
||||||
|
.response;
|
||||||
|
|
||||||
|
if resp.clicked() {
|
||||||
|
executed = Some(cmd.id);
|
||||||
|
should_close = true;
|
||||||
|
}
|
||||||
|
if resp.hovered() {
|
||||||
|
self.selected_idx = display_idx;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
if should_close {
|
||||||
|
self.close();
|
||||||
|
}
|
||||||
|
|
||||||
|
executed
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn fuzzy_match(text: &str, query: &str) -> bool {
|
||||||
|
let text_lower = text.to_lowercase();
|
||||||
|
let mut text_chars = text_lower.chars();
|
||||||
|
for qchar in query.chars() {
|
||||||
|
loop {
|
||||||
|
match text_chars.next() {
|
||||||
|
Some(tc) if tc == qchar => break,
|
||||||
|
Some(_) => continue,
|
||||||
|
None => return false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
true
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fuzzy_match_exact() {
|
||||||
|
assert!(fuzzy_match("New Tab", "new tab"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fuzzy_match_subsequence() {
|
||||||
|
assert!(fuzzy_match("New Tab", "ntb"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fuzzy_match_no_match() {
|
||||||
|
assert!(!fuzzy_match("New Tab", "xyz"));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn fuzzy_match_empty_query() {
|
||||||
|
assert!(fuzzy_match("Anything", ""));
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn palette_filters_commands() {
|
||||||
|
let mut palette = CommandPalette::new();
|
||||||
|
palette.open();
|
||||||
|
palette.query = "rect".to_string();
|
||||||
|
palette.update_filter();
|
||||||
|
assert!(palette.filtered.len() >= 1);
|
||||||
|
let matched: Vec<_> = palette
|
||||||
|
.filtered
|
||||||
|
.iter()
|
||||||
|
.map(|&i| palette.commands[i].id)
|
||||||
|
.collect();
|
||||||
|
assert!(matched.contains(&CommandId::ToolRectangle));
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1,12 +1,14 @@
|
|||||||
|
mod agent;
|
||||||
mod app;
|
mod app;
|
||||||
mod canvas;
|
mod canvas;
|
||||||
|
mod clipboard;
|
||||||
|
mod command_palette;
|
||||||
mod drawing;
|
mod drawing;
|
||||||
mod element_tree;
|
mod element_tree;
|
||||||
mod mermaid;
|
mod mermaid;
|
||||||
mod svg;
|
mod persistence;
|
||||||
mod clipboard;
|
|
||||||
mod agent;
|
|
||||||
mod session;
|
mod session;
|
||||||
|
mod svg;
|
||||||
|
|
||||||
use anyhow::Result;
|
use anyhow::Result;
|
||||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||||
|
|||||||
132
crates/agcanvas/src/persistence.rs
Normal file
132
crates/agcanvas/src/persistence.rs
Normal file
@@ -0,0 +1,132 @@
|
|||||||
|
use crate::canvas::CanvasState;
|
||||||
|
use crate::drawing::DrawingElement;
|
||||||
|
use crate::session::SessionCreator;
|
||||||
|
use anyhow::Result;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::path::PathBuf;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SavedSession {
|
||||||
|
pub id: String,
|
||||||
|
pub name: String,
|
||||||
|
pub canvas_state: CanvasState,
|
||||||
|
pub svg_source: Option<String>,
|
||||||
|
pub drawing_elements: Vec<DrawingElement>,
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub created_by: SessionCreator,
|
||||||
|
pub created_at: i64,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
|
pub struct SavedWorkspace {
|
||||||
|
pub version: u32,
|
||||||
|
pub active_session_idx: usize,
|
||||||
|
pub session_counter: usize,
|
||||||
|
pub sessions: Vec<SavedSession>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl SavedWorkspace {
|
||||||
|
const CURRENT_VERSION: u32 = 1;
|
||||||
|
|
||||||
|
pub fn new(
|
||||||
|
active_session_idx: usize,
|
||||||
|
session_counter: usize,
|
||||||
|
sessions: Vec<SavedSession>,
|
||||||
|
) -> Self {
|
||||||
|
Self {
|
||||||
|
version: Self::CURRENT_VERSION,
|
||||||
|
active_session_idx,
|
||||||
|
session_counter,
|
||||||
|
sessions,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn data_dir() -> Result<PathBuf> {
|
||||||
|
let base = dirs::data_dir()
|
||||||
|
.or_else(dirs::home_dir)
|
||||||
|
.ok_or_else(|| anyhow::anyhow!("Cannot determine home directory"))?;
|
||||||
|
Ok(base.join("agcanvas"))
|
||||||
|
}
|
||||||
|
|
||||||
|
fn workspace_path() -> Result<PathBuf> {
|
||||||
|
Ok(data_dir()?.join("workspace.json"))
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn save_workspace(workspace: &SavedWorkspace) -> Result<()> {
|
||||||
|
let path = workspace_path()?;
|
||||||
|
if let Some(parent) = path.parent() {
|
||||||
|
std::fs::create_dir_all(parent)?;
|
||||||
|
}
|
||||||
|
|
||||||
|
let tmp_path = path.with_extension("json.tmp");
|
||||||
|
let json = serde_json::to_string_pretty(workspace)?;
|
||||||
|
std::fs::write(&tmp_path, &json)?;
|
||||||
|
std::fs::rename(&tmp_path, &path)?;
|
||||||
|
|
||||||
|
tracing::debug!("Saved workspace: {} sessions", workspace.sessions.len());
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn load_workspace() -> Result<Option<SavedWorkspace>> {
|
||||||
|
let path = workspace_path()?;
|
||||||
|
if !path.exists() {
|
||||||
|
return Ok(None);
|
||||||
|
}
|
||||||
|
|
||||||
|
let json = std::fs::read_to_string(&path)?;
|
||||||
|
let workspace: SavedWorkspace = serde_json::from_str(&json)?;
|
||||||
|
|
||||||
|
if workspace.version > SavedWorkspace::CURRENT_VERSION {
|
||||||
|
tracing::warn!(
|
||||||
|
"Workspace file version {} is newer than supported version {}",
|
||||||
|
workspace.version,
|
||||||
|
SavedWorkspace::CURRENT_VERSION
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
tracing::info!("Loaded workspace: {} sessions", workspace.sessions.len());
|
||||||
|
Ok(Some(workspace))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[cfg(test)]
|
||||||
|
mod tests {
|
||||||
|
use super::*;
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_empty_workspace() {
|
||||||
|
let workspace = SavedWorkspace::new(0, 1, Vec::new());
|
||||||
|
let json = serde_json::to_string(&workspace).unwrap();
|
||||||
|
let loaded: SavedWorkspace = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(loaded.version, 1);
|
||||||
|
assert_eq!(loaded.sessions.len(), 0);
|
||||||
|
assert_eq!(loaded.session_counter, 1);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn round_trip_workspace_with_session() {
|
||||||
|
let session = SavedSession {
|
||||||
|
id: "session-1".to_string(),
|
||||||
|
name: "Test".to_string(),
|
||||||
|
canvas_state: CanvasState::default(),
|
||||||
|
svg_source: Some("<svg></svg>".to_string()),
|
||||||
|
drawing_elements: Vec::new(),
|
||||||
|
description: Some("A test session".to_string()),
|
||||||
|
created_by: SessionCreator::Human,
|
||||||
|
created_at: 1234567890,
|
||||||
|
};
|
||||||
|
let workspace = SavedWorkspace::new(0, 1, vec![session]);
|
||||||
|
let json = serde_json::to_string(&workspace).unwrap();
|
||||||
|
let loaded: SavedWorkspace = serde_json::from_str(&json).unwrap();
|
||||||
|
assert_eq!(loaded.sessions.len(), 1);
|
||||||
|
assert_eq!(loaded.sessions[0].name, "Test");
|
||||||
|
assert_eq!(
|
||||||
|
loaded.sessions[0].svg_source.as_deref(),
|
||||||
|
Some("<svg></svg>")
|
||||||
|
);
|
||||||
|
assert_eq!(
|
||||||
|
loaded.sessions[0].description.as_deref(),
|
||||||
|
Some("A test session")
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -5,6 +5,61 @@ use crate::svg::SvgRenderer;
|
|||||||
use egui::TextureHandle;
|
use egui::TextureHandle;
|
||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use std::collections::HashMap;
|
use std::collections::HashMap;
|
||||||
|
use std::time::{SystemTime, UNIX_EPOCH};
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// Session metadata types
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(tag = "type")]
|
||||||
|
pub enum SessionCreator {
|
||||||
|
#[default]
|
||||||
|
Human,
|
||||||
|
Agent {
|
||||||
|
#[serde(default)]
|
||||||
|
name: Option<String>,
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
impl std::fmt::Display for SessionCreator {
|
||||||
|
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||||
|
match self {
|
||||||
|
SessionCreator::Human => write!(f, "Human"),
|
||||||
|
SessionCreator::Agent { name: Some(n) } => write!(f, "Agent: {}", n),
|
||||||
|
SessionCreator::Agent { name: None } => write!(f, "Agent"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SessionSortField {
|
||||||
|
Name,
|
||||||
|
#[default]
|
||||||
|
CreatedAt,
|
||||||
|
CreatedBy,
|
||||||
|
ElementCount,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq, Default)]
|
||||||
|
#[serde(rename_all = "snake_case")]
|
||||||
|
pub enum SortOrder {
|
||||||
|
#[default]
|
||||||
|
Asc,
|
||||||
|
Desc,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn unix_now() -> i64 {
|
||||||
|
SystemTime::now()
|
||||||
|
.duration_since(UNIX_EPOCH)
|
||||||
|
.map(|d| d.as_secs() as i64)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
// SessionInfo — lightweight summary sent over the wire
|
||||||
|
// ---------------------------------------------------------------------------
|
||||||
|
|
||||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||||
pub struct SessionInfo {
|
pub struct SessionInfo {
|
||||||
@@ -13,6 +68,12 @@ pub struct SessionInfo {
|
|||||||
pub has_svg: bool,
|
pub has_svg: bool,
|
||||||
pub element_count: Option<usize>,
|
pub element_count: Option<usize>,
|
||||||
pub drawing_element_count: usize,
|
pub drawing_element_count: usize,
|
||||||
|
#[serde(default)]
|
||||||
|
pub description: Option<String>,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_by: SessionCreator,
|
||||||
|
#[serde(default)]
|
||||||
|
pub created_at: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct Session {
|
pub struct Session {
|
||||||
@@ -22,16 +83,21 @@ pub struct Session {
|
|||||||
pub svg_renderer: Option<SvgRenderer>,
|
pub svg_renderer: Option<SvgRenderer>,
|
||||||
pub svg_texture: Option<TextureHandle>,
|
pub svg_texture: Option<TextureHandle>,
|
||||||
pub element_tree: Option<ElementTree>,
|
pub element_tree: Option<ElementTree>,
|
||||||
|
pub svg_source: Option<String>,
|
||||||
pub description_text: String,
|
pub description_text: String,
|
||||||
|
|
||||||
pub drawing_elements: Vec<DrawingElement>,
|
pub drawing_elements: Vec<DrawingElement>,
|
||||||
pub selected_element_id: Option<String>,
|
pub selected_element_id: Option<String>,
|
||||||
pub active_tool: Tool,
|
pub active_tool: Tool,
|
||||||
pub drag_state: DragState,
|
pub drag_state: DragState,
|
||||||
|
|
||||||
|
pub description: Option<String>,
|
||||||
|
pub created_by: SessionCreator,
|
||||||
|
pub created_at: i64,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Session {
|
impl Session {
|
||||||
pub fn new(id: String, name: String) -> Self {
|
pub fn new(id: String, name: String, created_by: SessionCreator) -> Self {
|
||||||
Self {
|
Self {
|
||||||
id,
|
id,
|
||||||
name,
|
name,
|
||||||
@@ -39,14 +105,23 @@ impl Session {
|
|||||||
svg_renderer: None,
|
svg_renderer: None,
|
||||||
svg_texture: None,
|
svg_texture: None,
|
||||||
element_tree: None,
|
element_tree: None,
|
||||||
|
svg_source: None,
|
||||||
description_text: String::new(),
|
description_text: String::new(),
|
||||||
drawing_elements: Vec::new(),
|
drawing_elements: Vec::new(),
|
||||||
selected_element_id: None,
|
selected_element_id: None,
|
||||||
active_tool: Tool::default(),
|
active_tool: Tool::default(),
|
||||||
drag_state: DragState::default(),
|
drag_state: DragState::default(),
|
||||||
|
description: None,
|
||||||
|
created_by,
|
||||||
|
created_at: unix_now(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn with_description(mut self, description: Option<String>) -> Self {
|
||||||
|
self.description = description;
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn info(&self) -> SessionInfo {
|
pub fn info(&self) -> SessionInfo {
|
||||||
SessionInfo {
|
SessionInfo {
|
||||||
id: self.id.clone(),
|
id: self.id.clone(),
|
||||||
@@ -54,6 +129,9 @@ impl Session {
|
|||||||
has_svg: self.element_tree.is_some(),
|
has_svg: self.element_tree.is_some(),
|
||||||
element_count: self.element_tree.as_ref().map(|t| t.metadata.element_count),
|
element_count: self.element_tree.as_ref().map(|t| t.metadata.element_count),
|
||||||
drawing_element_count: self.drawing_elements.len(),
|
drawing_element_count: self.drawing_elements.len(),
|
||||||
|
description: self.description.clone(),
|
||||||
|
created_by: self.created_by.clone(),
|
||||||
|
created_at: self.created_at,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -61,6 +139,7 @@ impl Session {
|
|||||||
self.svg_renderer = None;
|
self.svg_renderer = None;
|
||||||
self.svg_texture = None;
|
self.svg_texture = None;
|
||||||
self.element_tree = None;
|
self.element_tree = None;
|
||||||
|
self.svg_source = None;
|
||||||
self.description_text.clear();
|
self.description_text.clear();
|
||||||
self.drawing_elements.clear();
|
self.drawing_elements.clear();
|
||||||
self.selected_element_id = None;
|
self.selected_element_id = None;
|
||||||
@@ -74,6 +153,7 @@ impl Session {
|
|||||||
.and_then(|id| self.drawing_elements.iter().find(|e| e.id == *id))
|
.and_then(|id| self.drawing_elements.iter().find(|e| e.id == *id))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[allow(dead_code)]
|
||||||
pub fn selected_element_mut(&mut self) -> Option<&mut DrawingElement> {
|
pub fn selected_element_mut(&mut self) -> Option<&mut DrawingElement> {
|
||||||
let id = self.selected_element_id.clone();
|
let id = self.selected_element_id.clone();
|
||||||
id.and_then(move |id| self.drawing_elements.iter_mut().find(|e| e.id == id))
|
id.and_then(move |id| self.drawing_elements.iter_mut().find(|e| e.id == id))
|
||||||
@@ -97,6 +177,7 @@ pub struct SessionData {
|
|||||||
pub struct SessionStore {
|
pub struct SessionStore {
|
||||||
sessions: HashMap<String, SessionData>,
|
sessions: HashMap<String, SessionData>,
|
||||||
active_session_id: Option<String>,
|
active_session_id: Option<String>,
|
||||||
|
session_counter: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl SessionStore {
|
impl SessionStore {
|
||||||
@@ -166,10 +247,130 @@ impl SessionStore {
|
|||||||
Some((id, &data.drawing_elements))
|
Some((id, &data.drawing_elements))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn add_drawing_element(&mut self, session_id: &str, element: DrawingElement) -> bool {
|
||||||
|
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||||
|
data.drawing_elements.push(element);
|
||||||
|
data.info.drawing_element_count = data.drawing_elements.len();
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_drawing_element(
|
||||||
|
&mut self,
|
||||||
|
session_id: &str,
|
||||||
|
element_id: &str,
|
||||||
|
new_shape: Option<crate::drawing::Shape>,
|
||||||
|
new_style: Option<crate::drawing::ShapeStyle>,
|
||||||
|
) -> Option<DrawingElement> {
|
||||||
|
let data = self.sessions.get_mut(session_id)?;
|
||||||
|
let el = data
|
||||||
|
.drawing_elements
|
||||||
|
.iter_mut()
|
||||||
|
.find(|e| e.id == element_id)?;
|
||||||
|
if let Some(shape) = new_shape {
|
||||||
|
el.shape = shape;
|
||||||
|
}
|
||||||
|
if let Some(style) = new_style {
|
||||||
|
el.style = style;
|
||||||
|
}
|
||||||
|
Some(el.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn delete_drawing_element(&mut self, session_id: &str, element_id: &str) -> bool {
|
||||||
|
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||||
|
let before = data.drawing_elements.len();
|
||||||
|
data.drawing_elements.retain(|e| e.id != element_id);
|
||||||
|
data.info.drawing_element_count = data.drawing_elements.len();
|
||||||
|
data.drawing_elements.len() < before
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn clear_drawing_elements(&mut self, session_id: &str) -> bool {
|
||||||
|
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||||
|
data.drawing_elements.clear();
|
||||||
|
data.info.drawing_element_count = 0;
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn resolve_session_id(&self, session_id: Option<&str>) -> Option<String> {
|
||||||
|
let id = session_id
|
||||||
|
.map(|s| s.to_string())
|
||||||
|
.or_else(|| self.active_session_id.clone())?;
|
||||||
|
if self.sessions.contains_key(&id) {
|
||||||
|
Some(id)
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn list_sessions(&self) -> Vec<SessionInfo> {
|
pub fn list_sessions(&self) -> Vec<SessionInfo> {
|
||||||
self.sessions.values().map(|d| d.info.clone()).collect()
|
self.sessions.values().map(|d| d.info.clone()).collect()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn list_sessions_sorted(
|
||||||
|
&self,
|
||||||
|
sort_by: SessionSortField,
|
||||||
|
order: SortOrder,
|
||||||
|
) -> Vec<SessionInfo> {
|
||||||
|
let mut sessions: Vec<SessionInfo> = self.list_sessions();
|
||||||
|
sessions.sort_by(|a, b| {
|
||||||
|
let cmp = match sort_by {
|
||||||
|
SessionSortField::Name => a.name.to_lowercase().cmp(&b.name.to_lowercase()),
|
||||||
|
SessionSortField::CreatedAt => a.created_at.cmp(&b.created_at),
|
||||||
|
SessionSortField::CreatedBy => {
|
||||||
|
a.created_by.to_string().cmp(&b.created_by.to_string())
|
||||||
|
}
|
||||||
|
SessionSortField::ElementCount => {
|
||||||
|
let ac = a.element_count.unwrap_or(0) + a.drawing_element_count;
|
||||||
|
let bc = b.element_count.unwrap_or(0) + b.drawing_element_count;
|
||||||
|
ac.cmp(&bc)
|
||||||
|
}
|
||||||
|
};
|
||||||
|
match order {
|
||||||
|
SortOrder::Asc => cmp,
|
||||||
|
SortOrder::Desc => cmp.reverse(),
|
||||||
|
}
|
||||||
|
});
|
||||||
|
sessions
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn next_session_id(&mut self) -> String {
|
||||||
|
self.session_counter += 1;
|
||||||
|
format!("session-{}", self.session_counter)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_counter_minimum(&mut self, min: usize) {
|
||||||
|
if min > self.session_counter {
|
||||||
|
self.session_counter = min;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn update_session_meta(
|
||||||
|
&mut self,
|
||||||
|
session_id: &str,
|
||||||
|
name: Option<String>,
|
||||||
|
description: Option<Option<String>>,
|
||||||
|
) -> bool {
|
||||||
|
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||||
|
if let Some(n) = name {
|
||||||
|
data.info.name = n;
|
||||||
|
}
|
||||||
|
if let Some(d) = description {
|
||||||
|
data.info.description = d;
|
||||||
|
}
|
||||||
|
true
|
||||||
|
} else {
|
||||||
|
false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
pub fn active_session_id(&self) -> Option<&str> {
|
pub fn active_session_id(&self) -> Option<&str> {
|
||||||
self.active_session_id.as_deref()
|
self.active_session_id.as_deref()
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,9 +0,0 @@
|
|||||||
{
|
|
||||||
"$schema": "https://opencode.ai/config.schema.json",
|
|
||||||
"mcpServers": {
|
|
||||||
"agcanvas": {
|
|
||||||
"command": "agcanvas-mcp",
|
|
||||||
"args": ["--port", "9876"]
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Reference in New Issue
Block a user