Compare commits

..

4 Commits

Author SHA1 Message Date
David Ibia
e8ec44d961 docs: add Augmented Canvas full name and macOS .app bundle instructions 2026-02-09 21:20:28 +01:00
David Ibia
233cb5798c Add session persistence and Cmd+K command palette
Workspace auto-saves every 30s and on exit, restores all tabs on
launch. Sessions persist drawing elements, SVG source, canvas state,
and metadata to ~/Library/Application Support/agcanvas/workspace.json.
Manual save via Cmd+S.

Command palette (Cmd+K) with fuzzy search over all commands: session
management, tool switching, view toggles, canvas operations. Arrow
keys to navigate, Enter to execute, Esc to dismiss.
2026-02-09 17:44:22 +01:00
David Ibia
43f1beea16 Add session metadata: creator tracking, descriptions, timestamps, and sorting
Sessions now track who created them (Human vs Agent with name),
optional descriptions, and creation timestamps. Agents can create
and update sessions via WebSocket and MCP. ListSessions supports
sorting by name, created_at, created_by, or element_count.

New MCP tools: create_session, update_session. Updated list_sessions
with sort_by/sort_order params. Tab bar shows robot icon for
agent-created sessions with hover tooltips.
2026-02-09 17:20:50 +01:00
David Ibia
b140d93163 Add agent drawing commands, session management, and MCP write tools
- Add CreateDrawingElement, UpdateDrawingElement, DeleteDrawingElement,
  ClearDrawingElements to WebSocket protocol and MCP bridge
- Add multi-session/tab support with SessionStore shared state
- Add bidirectional agent-GUI sync via broadcast + mpsc channels
- Update README with correct OpenCode MCP config format and new tools
- Fix dead code warning, clean up gitignore
2026-02-09 15:38:34 +01:00
15 changed files with 2225 additions and 113 deletions

3
.gitignore vendored
View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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(),

View File

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

View File

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

View 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));
}
}

View File

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

View 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")
);
}
}

View File

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

View File

@@ -1,9 +0,0 @@
{
"$schema": "https://opencode.ai/config.schema.json",
"mcpServers": {
"agcanvas": {
"command": "agcanvas-mcp",
"args": ["--port", "9876"]
}
}
}