Add drawing tools, Mermaid support, and MCP server bridge

- Convert flat project to Cargo workspace (crates/agcanvas, crates/agcanvas-mcp)
- Add drawing layer: rectangles, ellipses, lines, arrows, text with select/move/resize
- Add Mermaid diagram rendering via mermaid-rs-renderer
- Add agcanvas-mcp: MCP server bridge for Claude Code, OpenCode, and Codex
- Add toolbar UI with keyboard shortcuts (V/R/E/L/A/T) and shape interaction
- Add example MCP configs for Claude Code, OpenCode, and Codex
- Update README with full feature docs, MCP setup, and updated architecture
This commit is contained in:
David Ibia
2026-02-08 22:49:24 +01:00
parent 732e205943
commit d248864ee2
32 changed files with 2833 additions and 733 deletions

View File

@@ -0,0 +1,23 @@
[package]
name = "agcanvas-mcp"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "MCP server bridge for agcanvas — exposes canvas tools to Claude Code, OpenCode, and Codex"
[[bin]]
name = "agcanvas-mcp"
path = "src/main.rs"
[dependencies]
rmcp = { version = "0.14", features = ["server", "macros", "transport-io"] }
tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "io-std", "sync"] }
tokio-tungstenite = "0.24"
futures-util = "0.3"
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
schemars = "1.0"
anyhow = "1.0"
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
clap = { version = "4.0", features = ["derive"] }

View File

@@ -0,0 +1,29 @@
use anyhow::Result;
use futures_util::{SinkExt, StreamExt};
use tokio_tungstenite::tungstenite::Message;
pub async fn send_request(ws_url: &str, request_json: &str) -> Result<String> {
let (ws_stream, _) = tokio_tungstenite::connect_async(ws_url)
.await
.map_err(|e| {
anyhow::anyhow!(
"Cannot connect to agcanvas at {}. Is agcanvas running? Error: {}",
ws_url,
e
)
})?;
let (mut write, mut read) = ws_stream.split();
// Skip the initial Connected event
let _connected = read.next().await;
write.send(Message::Text(request_json.to_string())).await?;
match read.next().await {
Some(Ok(Message::Text(response))) => Ok(response),
Some(Ok(other)) => Err(anyhow::anyhow!("Unexpected message type: {:?}", other)),
Some(Err(e)) => Err(anyhow::anyhow!("WebSocket error: {}", e)),
None => Err(anyhow::anyhow!("Connection closed before response")),
}
}

View File

@@ -0,0 +1,38 @@
mod bridge;
mod tools;
use anyhow::Result;
use clap::Parser;
use rmcp::ServiceExt;
use tools::AgCanvasServer;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
#[derive(Parser)]
#[command(name = "agcanvas-mcp", about = "MCP server bridge for agcanvas")]
struct Cli {
#[arg(long, default_value = "9876")]
port: u16,
}
#[tokio::main]
async fn main() -> Result<()> {
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG").unwrap_or_else(|_| "agcanvas_mcp=info".into()),
))
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
.init();
let cli = Cli::parse();
let ws_url = format!("ws://127.0.0.1:{}", cli.port);
tracing::info!("Starting agcanvas MCP server, connecting to {}", ws_url);
let server = AgCanvasServer::new(ws_url);
let service = server.serve(rmcp::transport::stdio()).await?;
tracing::info!("MCP server running on stdio");
service.waiting().await?;
Ok(())
}

View File

@@ -0,0 +1,202 @@
use crate::bridge::send_request;
use rmcp::{
ErrorData as McpError, ServerHandler,
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
model::*,
schemars, tool, tool_handler, tool_router,
};
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct SessionIdParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct GetElementParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "Element ID to look up")]
pub id: String,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct GetElementsAtPointParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "X coordinate in canvas space")]
pub x: f32,
#[schemars(description = "Y coordinate in canvas space")]
pub y: f32,
}
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
pub struct GenerateCodeParam {
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
pub session_id: Option<String>,
#[schemars(description = "Code generation target: html, react, tailwind, svelte, or vue")]
pub target: String,
#[schemars(description = "Element ID to generate code for. If omitted, generates for the entire tree.")]
pub element_id: Option<String>,
}
#[derive(Debug, Clone)]
pub struct AgCanvasServer {
ws_url: String,
tool_router: ToolRouter<Self>,
}
#[tool_router]
impl AgCanvasServer {
pub fn new(ws_url: String) -> Self {
Self {
ws_url,
tool_router: Self::tool_router(),
}
}
#[tool(description = "List all open sessions/tabs in agcanvas. Returns session IDs, names, and whether they have SVG or drawing content loaded.")]
async fn list_sessions(&self) -> Result<CallToolResult, McpError> {
let request = serde_json::json!({"type": "ListSessions"});
self.call_agcanvas(&request).await
}
#[tool(description = "Get the full parsed SVG element tree from the canvas. Returns a hierarchical JSON structure with element types (Group, Rectangle, Circle, Path, Text, etc.), bounds, styles, and children. This is the primary way to understand what design is on the canvas.")]
async fn get_element_tree(
&self,
Parameters(params): Parameters<SessionIdParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({"type": "GetTree"});
if let Some(sid) = params.session_id {
request["session_id"] = serde_json::Value::String(sid);
}
self.call_agcanvas(&request).await
}
#[tool(description = "Get a human-readable semantic description of the canvas content. Returns structured text describing the element hierarchy with types, dimensions, and colors. Useful for quickly understanding a design without parsing JSON.")]
async fn describe_canvas(
&self,
Parameters(params): Parameters<SessionIdParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({"type": "Describe"});
if let Some(sid) = params.session_id {
request["session_id"] = serde_json::Value::String(sid);
}
self.call_agcanvas(&request).await
}
#[tool(description = "Get a specific element by its ID from the SVG element tree.")]
async fn get_element_by_id(
&self,
Parameters(params): Parameters<GetElementParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({"type": "GetElementById", "id": params.id});
if let Some(sid) = params.session_id {
request["session_id"] = serde_json::Value::String(sid);
}
self.call_agcanvas(&request).await
}
#[tool(description = "Query which elements exist at a specific (x, y) coordinate on the canvas.")]
async fn get_elements_at_point(
&self,
Parameters(params): Parameters<GetElementsAtPointParam>,
) -> Result<CallToolResult, McpError> {
let mut request =
serde_json::json!({"type": "GetElementsAtPoint", "x": params.x, "y": params.y});
if let Some(sid) = params.session_id {
request["session_id"] = serde_json::Value::String(sid);
}
self.call_agcanvas(&request).await
}
#[tool(description = "Get all user-drawn shapes (rectangles, ellipses, lines, arrows, text) from the drawing layer.")]
async fn get_drawing_elements(
&self,
Parameters(params): Parameters<SessionIdParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({"type": "GetDrawingElements"});
if let Some(sid) = params.session_id {
request["session_id"] = serde_json::Value::String(sid);
}
self.call_agcanvas(&request).await
}
#[tool(description = "Generate a code stub from the SVG structure. Targets: html, react, tailwind, svelte, vue. Returns a starting template based on the element hierarchy.")]
async fn generate_code(
&self,
Parameters(params): Parameters<GenerateCodeParam>,
) -> Result<CallToolResult, McpError> {
let mut request = serde_json::json!({
"type": "GenerateCode",
"target": params.target,
"element_id": params.element_id,
});
if let Some(sid) = params.session_id {
request["session_id"] = serde_json::Value::String(sid);
}
self.call_agcanvas(&request).await
}
}
impl AgCanvasServer {
async fn call_agcanvas(
&self,
request: &serde_json::Value,
) -> Result<CallToolResult, McpError> {
let request_str = serde_json::to_string(request)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
match send_request(&self.ws_url, &request_str).await {
Ok(response) => {
let parsed: serde_json::Value = serde_json::from_str(&response)
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
let is_error = parsed
.get("type")
.and_then(serde_json::Value::as_str)
== Some("Error");
if is_error {
let msg = parsed
.get("message")
.and_then(serde_json::Value::as_str)
.unwrap_or("Unknown error from agcanvas");
return Ok(CallToolResult::error(vec![Content::text(msg)]));
}
let pretty = serde_json::to_string_pretty(&parsed)
.unwrap_or_else(|_| response.clone());
Ok(CallToolResult::success(vec![Content::text(pretty)]))
}
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
"Failed to communicate with agcanvas: {}. Make sure agcanvas is running.",
e
))])),
}
}
}
#[tool_handler]
impl ServerHandler for AgCanvasServer {
fn get_info(&self) -> ServerInfo {
ServerInfo {
instructions: Some(
"agcanvas MCP server — connects to agcanvas desktop app to query SVG designs, \
element trees, and user-drawn shapes. Use describe_canvas to understand the \
current design, get_element_tree for structured data, and generate_code for \
code stubs. Requires agcanvas to be running."
.into(),
),
capabilities: ServerCapabilities::builder().enable_tools().build(),
server_info: Implementation {
name: "agcanvas-mcp".into(),
version: env!("CARGO_PKG_VERSION").into(),
title: None,
icons: None,
website_url: None,
},
..Default::default()
}
}
}

View File

@@ -0,0 +1,47 @@
[package]
name = "agcanvas"
version.workspace = true
edition.workspace = true
license.workspace = true
description = "Interactive canvas for agent-human collaboration with SVG support"
[dependencies]
# GUI
eframe = { version = "0.29", default-features = false, features = [
"default_fonts",
"glow",
"persistence",
] }
egui = "0.29"
egui_extras = { version = "0.29", features = ["image"] }
# SVG parsing and rendering
usvg = "0.44"
resvg = "0.44"
tiny-skia = "0.11"
# Clipboard
arboard = "3.4"
# Serialization
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
# Agent communication
tokio = { version = "1.0", features = ["rt-multi-thread", "sync", "macros"] }
tokio-tungstenite = "0.24"
futures-util = "0.3"
# Mermaid diagram rendering
mermaid-rs-renderer = { version = "0.1.2", default-features = false }
# Logging
tracing = "0.1"
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
# Error handling
anyhow = "1.0"
thiserror = "1.0"
# Image handling
image = { version = "0.25", default-features = false, features = ["png"] }

View File

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

View File

@@ -0,0 +1,126 @@
use crate::drawing::DrawingElement;
use crate::element_tree::{ElementTree, TreeMetadata};
use crate::session::SessionInfo;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum GuiEvent {
Connected {
version: String,
sessions: Vec<SessionInfo>,
active_session: Option<String>,
},
SessionCreated {
session: SessionInfo,
},
SessionClosed {
session_id: String,
},
SessionActivated {
session_id: String,
},
SvgLoaded {
session_id: String,
metadata: TreeMetadata,
},
SvgCleared {
session_id: String,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum AgentRequest {
ListSessions,
GetTree {
#[serde(default)]
session_id: Option<String>,
},
GetElementById {
#[serde(default)]
session_id: Option<String>,
id: String,
},
GetElementsAtPoint {
#[serde(default)]
session_id: Option<String>,
x: f32,
y: f32,
},
Describe {
#[serde(default)]
session_id: Option<String>,
},
GenerateCode {
#[serde(default)]
session_id: Option<String>,
target: CodeGenTarget,
element_id: Option<String>,
},
GetDrawingElements {
#[serde(default)]
session_id: Option<String>,
},
Ping,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum AgentResponse {
Sessions {
sessions: Vec<SessionInfo>,
active_session: Option<String>,
},
Tree {
session_id: String,
tree: ElementTree,
},
Element {
session_id: String,
element: Option<crate::element_tree::Element>,
},
Elements {
session_id: String,
elements: Vec<crate::element_tree::Element>,
},
Description {
session_id: String,
text: String,
},
Code {
session_id: String,
code: String,
target: CodeGenTarget,
},
DrawingElements {
session_id: String,
elements: Vec<DrawingElement>,
},
Pong,
Error {
message: String,
},
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
#[serde(rename_all = "lowercase")]
pub enum CodeGenTarget {
Html,
React,
Tailwind,
Svelte,
Vue,
}
impl std::fmt::Display for CodeGenTarget {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self {
CodeGenTarget::Html => write!(f, "html"),
CodeGenTarget::React => write!(f, "react"),
CodeGenTarget::Tailwind => write!(f, "tailwind"),
CodeGenTarget::Svelte => write!(f, "svelte"),
CodeGenTarget::Vue => write!(f, "vue"),
}
}
}

View File

@@ -0,0 +1,258 @@
use super::protocol::{AgentRequest, AgentResponse, CodeGenTarget, GuiEvent};
use crate::session::SessionStore;
use anyhow::Result;
use futures_util::{SinkExt, StreamExt};
use std::sync::Arc;
use tokio::net::{TcpListener, TcpStream};
use tokio::sync::{broadcast, RwLock};
use tokio_tungstenite::tungstenite::Message;
const EVENT_CHANNEL_CAPACITY: usize = 64;
pub struct AgentServer {
sessions: Arc<RwLock<SessionStore>>,
event_tx: broadcast::Sender<GuiEvent>,
port: u16,
}
impl AgentServer {
pub fn new(port: u16) -> Self {
let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
Self {
sessions: Arc::new(RwLock::new(SessionStore::new())),
event_tx,
port,
}
}
pub fn sessions_handle(&self) -> Arc<RwLock<SessionStore>> {
self.sessions.clone()
}
pub fn event_sender(&self) -> broadcast::Sender<GuiEvent> {
self.event_tx.clone()
}
pub async fn run(&self) -> Result<()> {
let addr = format!("127.0.0.1:{}", self.port);
let listener = TcpListener::bind(&addr).await?;
tracing::info!("Agent server listening on ws://{}", addr);
while let Ok((stream, peer)) = listener.accept().await {
tracing::info!("Agent connected from {}", peer);
let sessions = self.sessions.clone();
let event_rx = self.event_tx.subscribe();
tokio::spawn(async move {
if let Err(e) = handle_connection(stream, sessions, event_rx).await {
tracing::error!("Connection error: {}", e);
}
});
}
Ok(())
}
}
async fn handle_connection(
stream: TcpStream,
sessions: Arc<RwLock<SessionStore>>,
mut event_rx: broadcast::Receiver<GuiEvent>,
) -> Result<()> {
let ws_stream = tokio_tungstenite::accept_async(stream).await?;
let (mut write, mut read) = ws_stream.split();
let connected_event = {
let store = sessions.read().await;
GuiEvent::Connected {
version: env!("CARGO_PKG_VERSION").to_string(),
sessions: store.list_sessions(),
active_session: store.active_session_id().map(|s| s.to_string()),
}
};
let connected_json = serde_json::to_string(&connected_event)?;
write.send(Message::Text(connected_json)).await?;
loop {
tokio::select! {
msg = read.next() => {
match msg {
Some(Ok(Message::Text(text))) => {
let response = match serde_json::from_str::<AgentRequest>(&text) {
Ok(request) => process_request(request, &sessions).await,
Err(e) => AgentResponse::Error {
message: format!("Invalid request: {}", e),
},
};
let response_text = serde_json::to_string(&response)?;
write.send(Message::Text(response_text)).await?;
}
Some(Ok(Message::Close(_))) | None => break,
Some(Err(e)) => {
tracing::warn!("WebSocket error: {}", e);
break;
}
_ => {}
}
}
event = event_rx.recv() => {
match event {
Ok(gui_event) => {
let event_json = serde_json::to_string(&gui_event)?;
if write.send(Message::Text(event_json)).await.is_err() {
break;
}
}
Err(broadcast::error::RecvError::Lagged(n)) => {
tracing::warn!("Agent lagged, skipped {} events", n);
}
Err(broadcast::error::RecvError::Closed) => break,
}
}
}
}
Ok(())
}
async fn process_request(
request: AgentRequest,
sessions: &Arc<RwLock<SessionStore>>,
) -> AgentResponse {
let store = sessions.read().await;
match request {
AgentRequest::Ping => AgentResponse::Pong,
AgentRequest::ListSessions => AgentResponse::Sessions {
sessions: store.list_sessions(),
active_session: store.active_session_id().map(|s| s.to_string()),
},
AgentRequest::GetTree { session_id } => {
match store.get_tree(session_id.as_deref()) {
Some((sid, tree)) => AgentResponse::Tree {
session_id: sid,
tree: tree.clone(),
},
None => AgentResponse::Error {
message: session_id
.map(|id| format!("Session '{}' not found or has no SVG", id))
.unwrap_or_else(|| "No active session or no SVG loaded".to_string()),
},
}
}
AgentRequest::GetElementById { session_id, id } => {
match store.get_tree(session_id.as_deref()) {
Some((sid, tree)) => AgentResponse::Element {
session_id: sid,
element: tree.find_by_id(&id).cloned(),
},
None => AgentResponse::Error {
message: "No session or SVG loaded".to_string(),
},
}
}
AgentRequest::GetElementsAtPoint { session_id, x, y } => {
match store.get_tree(session_id.as_deref()) {
Some((sid, tree)) => AgentResponse::Elements {
session_id: sid,
elements: tree.find_at_point(x, y).into_iter().cloned().collect(),
},
None => AgentResponse::Error {
message: "No session or SVG loaded".to_string(),
},
}
}
AgentRequest::Describe { session_id } => {
match store.get_tree(session_id.as_deref()) {
Some((sid, tree)) => AgentResponse::Description {
session_id: sid,
text: tree.to_semantic_description(),
},
None => AgentResponse::Error {
message: "No session or SVG loaded".to_string(),
},
}
}
AgentRequest::GetDrawingElements { session_id } => {
match store.get_drawing_elements(session_id.as_deref()) {
Some((sid, elements)) => AgentResponse::DrawingElements {
session_id: sid,
elements: elements.to_vec(),
},
None => AgentResponse::Error {
message: "No session found".to_string(),
},
}
}
AgentRequest::GenerateCode { session_id, target, element_id } => {
match store.get_tree(session_id.as_deref()) {
Some((sid, tree)) => {
let element = match &element_id {
Some(id) => tree.find_by_id(id),
None => Some(&tree.root),
};
match element {
Some(el) => {
let code = generate_code_stub(el, target);
AgentResponse::Code {
session_id: sid,
code,
target,
}
}
None => AgentResponse::Error {
message: format!("Element not found: {:?}", element_id),
},
}
}
None => AgentResponse::Error {
message: "No session or SVG loaded".to_string(),
},
}
}
}
}
fn generate_code_stub(element: &crate::element_tree::Element, target: CodeGenTarget) -> String {
let description = crate::element_tree::ElementTree {
root: element.clone(),
metadata: crate::element_tree::TreeMetadata {
source: "code_gen".to_string(),
width: element.bounds.width,
height: element.bounds.height,
element_count: 1,
},
}
.to_semantic_description();
match target {
CodeGenTarget::Html => format!(
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<div class=\"container\">\n <!-- TODO: Implement based on structure -->\n</div>",
element.id, description
),
CodeGenTarget::React => format!(
"// Generated from SVG element: {}\n// Structure:\n// {}\n\nexport function Component() {{\n return (\n <div className=\"container\">\n {{/* TODO: Implement based on structure */}}\n </div>\n );\n}}",
element.id,
description.replace('\n', "\n// ")
),
CodeGenTarget::Tailwind => format!(
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<div class=\"flex flex-col\">\n <!-- TODO: Implement with Tailwind classes -->\n</div>",
element.id, description
),
CodeGenTarget::Svelte => format!(
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<script>\n // Component logic\n</script>\n\n<div class=\"container\">\n <!-- TODO: Implement -->\n</div>",
element.id, description
),
CodeGenTarget::Vue => format!(
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<template>\n <div class=\"container\">\n <!-- TODO: Implement -->\n </div>\n</template>\n\n<script setup>\n// Component logic\n</script>",
element.id, description
),
}
}

1011
crates/agcanvas/src/app.rs Normal file

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,38 @@
use super::CanvasState;
use egui::{Response, Sense, Ui};
pub struct CanvasInteraction;
impl CanvasInteraction {
pub fn handle(ui: &Ui, response: &Response, state: &mut CanvasState) {
if response.dragged_by(egui::PointerButton::Middle)
|| (response.dragged_by(egui::PointerButton::Primary)
&& ui.input(|i| i.modifiers.command))
{
state.pan(response.drag_delta());
}
if response.hovered() {
let scroll_delta = ui.input(|i| i.smooth_scroll_delta.y);
if scroll_delta != 0.0 {
let zoom_factor = 1.0 + scroll_delta * 0.001;
if let Some(pointer_pos) = response.hover_pos() {
state.zoom_at(pointer_pos, response.rect.center(), zoom_factor);
}
}
ui.input(|i| {
if i.key_pressed(egui::Key::Num0) && i.modifiers.command {
state.reset();
}
});
}
}
pub fn allocate_canvas(ui: &mut Ui) -> Response {
let available_size = ui.available_size();
let (rect, response) = ui.allocate_exact_size(available_size, Sense::click_and_drag());
ui.set_clip_rect(rect);
response
}
}

View File

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

View File

@@ -0,0 +1,53 @@
use egui::{Pos2, Vec2};
#[derive(Clone)]
pub struct CanvasState {
pub offset: Vec2,
pub zoom: f32,
pub zoom_min: f32,
pub zoom_max: f32,
}
impl Default for CanvasState {
fn default() -> Self {
Self {
offset: Vec2::ZERO,
zoom: 1.0,
zoom_min: 0.1,
zoom_max: 10.0,
}
}
}
impl CanvasState {
pub fn screen_to_canvas(&self, screen_pos: Pos2, canvas_center: Pos2) -> Pos2 {
let relative = screen_pos - canvas_center;
let canvas_relative = relative / self.zoom - self.offset;
Pos2::new(canvas_relative.x, canvas_relative.y)
}
pub fn zoom_at(&mut self, screen_pos: Pos2, canvas_center: Pos2, zoom_delta: f32) {
let canvas_pos_before = self.screen_to_canvas(screen_pos, canvas_center);
self.zoom = (self.zoom * zoom_delta).clamp(self.zoom_min, self.zoom_max);
let canvas_pos_after = self.screen_to_canvas(screen_pos, canvas_center);
self.offset += canvas_pos_after - canvas_pos_before;
}
pub fn pan(&mut self, delta: Vec2) {
self.offset += delta / self.zoom;
}
pub fn reset(&mut self) {
*self = Self::default();
}
pub fn fit_to_rect(&mut self, content_size: Vec2, viewport_size: Vec2) {
let scale_x = viewport_size.x / content_size.x;
let scale_y = viewport_size.y / content_size.y;
self.zoom = scale_x.min(scale_y) * 0.9;
self.zoom = self.zoom.clamp(self.zoom_min, self.zoom_max);
self.offset = Vec2::ZERO;
}
}

View File

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

View File

@@ -0,0 +1,259 @@
use egui::{Color32, Pos2};
use serde::{Deserialize, Serialize};
use std::sync::atomic::{AtomicUsize, Ordering};
static DRAWING_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
pub fn generate_drawing_id() -> String {
format!("draw_{}", DRAWING_ID_COUNTER.fetch_add(1, Ordering::SeqCst))
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct DrawingElement {
pub id: String,
pub shape: Shape,
pub style: ShapeStyle,
}
impl DrawingElement {
pub fn new(shape: Shape, style: ShapeStyle) -> Self {
Self {
id: generate_drawing_id(),
shape,
style,
}
}
/// Axis-aligned bounding box in canvas coordinates.
pub fn bounding_rect(&self) -> egui::Rect {
match &self.shape {
Shape::Rectangle { pos, size } => egui::Rect::from_min_size(*pos, *size),
Shape::Ellipse { center, radii } => {
egui::Rect::from_center_size(*center, egui::vec2(radii.x * 2.0, radii.y * 2.0))
}
Shape::Line { start, end } | Shape::Arrow { start, end } => {
egui::Rect::from_two_pos(*start, *end)
}
Shape::Text {
pos,
content: _,
font_size,
} => {
// Approximate: we'll refine during rendering when we know actual text size.
let approx_width = 8.0 * font_size * 0.6;
let approx_height = *font_size * 1.4;
egui::Rect::from_min_size(*pos, egui::vec2(approx_width, approx_height))
}
}
}
/// Hit-test: does the point (in canvas coords) touch this element?
pub fn contains_point(&self, point: Pos2) -> bool {
let tolerance = 6.0; // px tolerance for thin shapes like lines
match &self.shape {
Shape::Rectangle { pos, size } => {
let rect = egui::Rect::from_min_size(*pos, *size);
rect.expand(tolerance).contains(point)
}
Shape::Ellipse { center, radii } => {
let dx = point.x - center.x;
let dy = point.y - center.y;
let rx = radii.x + tolerance;
let ry = radii.y + tolerance;
(dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1.0
}
Shape::Line { start, end } | Shape::Arrow { start, end } => {
point_to_segment_distance(point, *start, *end) <= tolerance
}
Shape::Text {
pos,
content: _,
font_size,
} => {
let approx_width = 8.0 * font_size * 0.6;
let approx_height = *font_size * 1.4;
let rect = egui::Rect::from_min_size(*pos, egui::vec2(approx_width, approx_height));
rect.expand(tolerance).contains(point)
}
}
}
/// Translate the element by a delta vector.
pub fn translate(&mut self, delta: egui::Vec2) {
match &mut self.shape {
Shape::Rectangle { pos, .. } => *pos += delta,
Shape::Ellipse { center, .. } => *center += delta,
Shape::Line { start, end } | Shape::Arrow { start, end } => {
*start += delta;
*end += delta;
}
Shape::Text { pos, .. } => *pos += delta,
}
}
/// Resize to fit a new bounding rect, preserving shape semantics.
pub fn resize_to(&mut self, new_rect: egui::Rect) {
match &mut self.shape {
Shape::Rectangle { pos, size } => {
*pos = new_rect.min;
*size = new_rect.size();
}
Shape::Ellipse { center, radii } => {
*center = new_rect.center();
*radii = egui::vec2(new_rect.width() / 2.0, new_rect.height() / 2.0);
}
Shape::Line { start, end } | Shape::Arrow { start, end } => {
*start = new_rect.min;
*end = new_rect.max;
}
Shape::Text { pos, .. } => {
*pos = new_rect.min;
}
}
}
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum Shape {
Rectangle {
pos: Pos2,
size: egui::Vec2,
},
Ellipse {
center: Pos2,
radii: egui::Vec2,
},
Line {
start: Pos2,
end: Pos2,
},
Arrow {
start: Pos2,
end: Pos2,
},
Text {
pos: Pos2,
content: String,
font_size: f32,
},
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ShapeStyle {
pub fill: Option<Color32>,
pub stroke_color: Color32,
pub stroke_width: f32,
}
impl Default for ShapeStyle {
fn default() -> Self {
Self {
fill: None,
stroke_color: Color32::WHITE,
stroke_width: 2.0,
}
}
}
/// Distance from point `p` to segment `a``b`.
fn point_to_segment_distance(p: Pos2, a: Pos2, b: Pos2) -> f32 {
let ab = b - a;
let ap = p - a;
let ab_len_sq = ab.length_sq();
if ab_len_sq < 1e-6 {
return ap.length();
}
let t = (ap.x * ab.x + ap.y * ab.y) / ab_len_sq;
let t = t.clamp(0.0, 1.0);
let closest = a + ab * t;
(p - closest).length()
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn rectangle_contains_point_inside() {
let el = DrawingElement::new(
Shape::Rectangle {
pos: Pos2::new(10.0, 10.0),
size: egui::vec2(100.0, 50.0),
},
ShapeStyle::default(),
);
assert!(el.contains_point(Pos2::new(50.0, 30.0)));
}
#[test]
fn rectangle_does_not_contain_distant_point() {
let el = DrawingElement::new(
Shape::Rectangle {
pos: Pos2::new(10.0, 10.0),
size: egui::vec2(100.0, 50.0),
},
ShapeStyle::default(),
);
assert!(!el.contains_point(Pos2::new(500.0, 500.0)));
}
#[test]
fn line_hit_test_near_segment() {
let el = DrawingElement::new(
Shape::Line {
start: Pos2::new(0.0, 0.0),
end: Pos2::new(100.0, 0.0),
},
ShapeStyle::default(),
);
// Point 3px above the line — within tolerance
assert!(el.contains_point(Pos2::new(50.0, 3.0)));
// Point 20px above — outside tolerance
assert!(!el.contains_point(Pos2::new(50.0, 20.0)));
}
#[test]
fn ellipse_contains_center() {
let el = DrawingElement::new(
Shape::Ellipse {
center: Pos2::new(50.0, 50.0),
radii: egui::vec2(30.0, 20.0),
},
ShapeStyle::default(),
);
assert!(el.contains_point(Pos2::new(50.0, 50.0)));
}
#[test]
fn translate_moves_element() {
let mut el = DrawingElement::new(
Shape::Rectangle {
pos: Pos2::new(10.0, 10.0),
size: egui::vec2(100.0, 50.0),
},
ShapeStyle::default(),
);
el.translate(egui::vec2(5.0, 5.0));
match &el.shape {
Shape::Rectangle { pos, .. } => {
assert!((pos.x - 15.0).abs() < 0.01);
assert!((pos.y - 15.0).abs() < 0.01);
}
_ => panic!("Expected Rectangle"),
}
}
#[test]
fn point_to_segment_distance_perpendicular() {
let dist = super::point_to_segment_distance(
Pos2::new(5.0, 5.0),
Pos2::new(0.0, 0.0),
Pos2::new(10.0, 0.0),
);
assert!((dist - 5.0).abs() < 0.01);
}
}

View File

@@ -0,0 +1,10 @@
mod element;
mod render;
mod tool;
pub use element::{DrawingElement, Shape, ShapeStyle};
pub use render::{
draw_creation_preview, draw_elements, draw_selection, find_handle_at_screen_pos,
screen_to_canvas,
};
pub use tool::{DragState, Tool};

View File

@@ -0,0 +1,208 @@
use super::element::DrawingElement;
use super::element::Shape;
use super::tool::{DragState, ResizeHandle, Tool};
use egui::{Color32, Painter, Pos2, Stroke, Vec2};
const HANDLE_RADIUS: f32 = 4.0;
const SELECTION_COLOR: Color32 = Color32::from_rgb(59, 130, 246);
const CREATION_PREVIEW_COLOR: Color32 = Color32::from_rgba_premultiplied(59, 130, 246, 128);
pub fn draw_elements(
painter: &Painter,
elements: &[DrawingElement],
canvas_center: Pos2,
offset: Vec2,
zoom: f32,
) {
for element in elements {
draw_element(painter, element, canvas_center, offset, zoom);
}
}
pub fn draw_element(
painter: &Painter,
element: &DrawingElement,
canvas_center: Pos2,
offset: Vec2,
zoom: f32,
) {
let style = &element.style;
let stroke = Stroke::new(style.stroke_width * zoom, style.stroke_color);
match &element.shape {
Shape::Rectangle { pos, size } => {
let screen_pos = canvas_to_screen(*pos, canvas_center, offset, zoom);
let screen_size = *size * zoom;
let rect = egui::Rect::from_min_size(screen_pos, screen_size);
if let Some(fill) = style.fill {
painter.rect_filled(rect, 0.0, fill);
}
painter.rect_stroke(rect, 0.0, stroke);
}
Shape::Ellipse { center, radii } => {
let screen_center = canvas_to_screen(*center, canvas_center, offset, zoom);
let screen_radii = *radii * zoom;
if let Some(fill) = style.fill {
painter.circle_filled(screen_center, screen_radii.x.min(screen_radii.y), fill);
}
let n_points = 64;
let points: Vec<Pos2> = (0..=n_points)
.map(|i| {
let angle = i as f32 / n_points as f32 * std::f32::consts::TAU;
Pos2::new(
screen_center.x + screen_radii.x * angle.cos(),
screen_center.y + screen_radii.y * angle.sin(),
)
})
.collect();
painter.add(egui::Shape::line(points, stroke));
}
Shape::Line { start, end } => {
let s = canvas_to_screen(*start, canvas_center, offset, zoom);
let e = canvas_to_screen(*end, canvas_center, offset, zoom);
painter.line_segment([s, e], stroke);
}
Shape::Arrow { start, end } => {
let s = canvas_to_screen(*start, canvas_center, offset, zoom);
let e = canvas_to_screen(*end, canvas_center, offset, zoom);
painter.line_segment([s, e], stroke);
draw_arrowhead(painter, s, e, stroke);
}
Shape::Text {
pos,
content,
font_size,
} => {
let screen_pos = canvas_to_screen(*pos, canvas_center, offset, zoom);
let scaled_font_size = font_size * zoom;
painter.text(
screen_pos,
egui::Align2::LEFT_TOP,
content,
egui::FontId::proportional(scaled_font_size),
style.stroke_color,
);
}
}
}
pub fn draw_selection(
painter: &Painter,
element: &DrawingElement,
canvas_center: Pos2,
offset: Vec2,
zoom: f32,
) {
let rect = element.bounding_rect();
let screen_min = canvas_to_screen(rect.min, canvas_center, offset, zoom);
let screen_max = canvas_to_screen(rect.max, canvas_center, offset, zoom);
let screen_rect = egui::Rect::from_min_max(screen_min, screen_max);
painter.rect_stroke(
screen_rect.expand(2.0),
0.0,
Stroke::new(1.0, SELECTION_COLOR),
);
for handle in ResizeHandle::ALL {
let pos = handle.position_in_rect(screen_rect);
painter.circle_filled(pos, HANDLE_RADIUS, SELECTION_COLOR);
painter.circle_stroke(pos, HANDLE_RADIUS, Stroke::new(1.0, Color32::WHITE));
}
}
pub fn draw_creation_preview(
painter: &Painter,
tool: Tool,
drag_state: &DragState,
canvas_center: Pos2,
offset: Vec2,
zoom: f32,
) {
if let DragState::Creating { start, current } = drag_state {
let s = canvas_to_screen(*start, canvas_center, offset, zoom);
let c = canvas_to_screen(*current, canvas_center, offset, zoom);
let preview_stroke = Stroke::new(2.0, CREATION_PREVIEW_COLOR);
match tool {
Tool::Rectangle => {
let rect = egui::Rect::from_two_pos(s, c);
painter.rect_stroke(rect, 0.0, preview_stroke);
}
Tool::Ellipse => {
let rect = egui::Rect::from_two_pos(s, c);
let center = rect.center();
let rx = rect.width() / 2.0;
let ry = rect.height() / 2.0;
let n_points = 64;
let points: Vec<Pos2> = (0..=n_points)
.map(|i| {
let angle = i as f32 / n_points as f32 * std::f32::consts::TAU;
Pos2::new(center.x + rx * angle.cos(), center.y + ry * angle.sin())
})
.collect();
painter.add(egui::Shape::line(points, preview_stroke));
}
Tool::Line => {
painter.line_segment([s, c], preview_stroke);
}
Tool::Arrow => {
painter.line_segment([s, c], preview_stroke);
draw_arrowhead(painter, s, c, preview_stroke);
}
_ => {}
}
}
}
fn draw_arrowhead(painter: &Painter, from: Pos2, to: Pos2, stroke: Stroke) {
let dir = to - from;
let len = dir.length();
if len < 1.0 {
return;
}
let dir_norm = dir / len;
let perp = Vec2::new(-dir_norm.y, dir_norm.x);
let arrow_size = 12.0;
let left = to - dir_norm * arrow_size + perp * (arrow_size * 0.4);
let right = to - dir_norm * arrow_size - perp * (arrow_size * 0.4);
painter.line_segment([left, to], stroke);
painter.line_segment([right, to], stroke);
}
pub fn canvas_to_screen(canvas_pos: Pos2, canvas_center: Pos2, offset: Vec2, zoom: f32) -> Pos2 {
canvas_center + (canvas_pos.to_vec2() + offset) * zoom
}
pub fn screen_to_canvas(screen_pos: Pos2, canvas_center: Pos2, offset: Vec2, zoom: f32) -> Pos2 {
let relative = screen_pos - canvas_center;
(relative / zoom - offset).to_pos2()
}
pub fn find_handle_at_screen_pos(
element: &DrawingElement,
screen_pos: Pos2,
canvas_center: Pos2,
offset: Vec2,
zoom: f32,
) -> Option<ResizeHandle> {
let rect = element.bounding_rect();
let screen_min = canvas_to_screen(rect.min, canvas_center, offset, zoom);
let screen_max = canvas_to_screen(rect.max, canvas_center, offset, zoom);
let screen_rect = egui::Rect::from_min_max(screen_min, screen_max);
for handle in ResizeHandle::ALL {
let handle_pos = handle.position_in_rect(screen_rect);
if (screen_pos - handle_pos).length() <= HANDLE_RADIUS + 4.0 {
return Some(handle);
}
}
None
}

View File

@@ -0,0 +1,109 @@
use egui::Pos2;
use serde::{Deserialize, Serialize};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
pub enum Tool {
#[default]
Select,
Rectangle,
Ellipse,
Line,
Arrow,
Text,
}
impl Tool {
pub fn label(&self) -> &'static str {
match self {
Tool::Select => "Select",
Tool::Rectangle => "Rect",
Tool::Ellipse => "Ellipse",
Tool::Line => "Line",
Tool::Arrow => "Arrow",
Tool::Text => "Text",
}
}
pub fn shortcut(&self) -> Option<char> {
match self {
Tool::Select => Some('V'),
Tool::Rectangle => Some('R'),
Tool::Ellipse => Some('E'),
Tool::Line => Some('L'),
Tool::Arrow => Some('A'),
Tool::Text => Some('T'),
}
}
}
#[derive(Debug, Clone, Default)]
pub enum DragState {
#[default]
None,
Creating {
start: Pos2,
current: Pos2,
},
Moving {
element_id: String,
},
Resizing {
handle: ResizeHandle,
element_id: String,
original_rect: egui::Rect,
},
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ResizeHandle {
TopLeft,
TopRight,
BottomLeft,
BottomRight,
}
impl ResizeHandle {
pub const ALL: [ResizeHandle; 4] = [
ResizeHandle::TopLeft,
ResizeHandle::TopRight,
ResizeHandle::BottomLeft,
ResizeHandle::BottomRight,
];
pub fn position_in_rect(&self, rect: egui::Rect) -> Pos2 {
match self {
ResizeHandle::TopLeft => rect.left_top(),
ResizeHandle::TopRight => rect.right_top(),
ResizeHandle::BottomLeft => rect.left_bottom(),
ResizeHandle::BottomRight => rect.right_bottom(),
}
}
pub fn apply_delta(&self, original: egui::Rect, delta: egui::Vec2) -> egui::Rect {
let mut r = original;
match self {
ResizeHandle::TopLeft => {
r.min.x += delta.x;
r.min.y += delta.y;
}
ResizeHandle::TopRight => {
r.max.x += delta.x;
r.min.y += delta.y;
}
ResizeHandle::BottomLeft => {
r.min.x += delta.x;
r.max.y += delta.y;
}
ResizeHandle::BottomRight => {
r.max.x += delta.x;
r.max.y += delta.y;
}
}
// Normalize so min < max
let min_x = r.min.x.min(r.max.x);
let max_x = r.min.x.max(r.max.x);
let min_y = r.min.y.min(r.max.y);
let max_y = r.min.y.max(r.max.y);
egui::Rect::from_min_max(Pos2::new(min_x, min_y), Pos2::new(max_x, max_y))
}
}

View File

@@ -0,0 +1,160 @@
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct ElementTree {
pub root: Element,
pub metadata: TreeMetadata,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct TreeMetadata {
pub source: String,
pub width: f32,
pub height: f32,
pub element_count: usize,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct Element {
pub id: String,
pub kind: ElementKind,
pub bounds: Bounds,
pub style: ElementStyle,
pub children: Vec<Element>,
pub attributes: HashMap<String, String>,
}
#[derive(Debug, Clone, Serialize, Deserialize)]
#[serde(tag = "type")]
pub enum ElementKind {
Group { name: Option<String> },
Rectangle { rx: Option<f32>, ry: Option<f32> },
Circle { cx: f32, cy: f32, r: f32 },
Ellipse { cx: f32, cy: f32, rx: f32, ry: f32 },
Path { d: String },
Text { content: String, font_size: f32 },
Image { href: String },
Line { x1: f32, y1: f32, x2: f32, y2: f32 },
Unknown { tag: String },
}
#[derive(Debug, Clone, Copy, Serialize, Deserialize, Default)]
pub struct Bounds {
pub x: f32,
pub y: f32,
pub width: f32,
pub height: f32,
}
impl Bounds {
pub fn contains(&self, x: f32, y: f32) -> bool {
x >= self.x && x <= self.x + self.width && y >= self.y && y <= self.y + self.height
}
pub fn center(&self) -> (f32, f32) {
(self.x + self.width / 2.0, self.y + self.height / 2.0)
}
}
#[derive(Debug, Clone, Serialize, Deserialize, Default)]
pub struct ElementStyle {
pub fill: Option<String>,
pub stroke: Option<String>,
pub stroke_width: Option<f32>,
pub opacity: Option<f32>,
}
impl ElementTree {
pub fn find_by_id(&self, id: &str) -> Option<&Element> {
find_element_recursive(&self.root, id)
}
pub fn find_at_point(&self, x: f32, y: f32) -> Vec<&Element> {
let mut results = Vec::new();
find_elements_at_point_recursive(&self.root, x, y, &mut results);
results
}
pub fn to_semantic_description(&self) -> String {
describe_element(&self.root, 0)
}
}
fn find_element_recursive<'a>(element: &'a Element, id: &str) -> Option<&'a Element> {
if element.id == id {
return Some(element);
}
for child in &element.children {
if let Some(found) = find_element_recursive(child, id) {
return Some(found);
}
}
None
}
fn find_elements_at_point_recursive<'a>(
element: &'a Element,
x: f32,
y: f32,
results: &mut Vec<&'a Element>,
) {
if element.bounds.contains(x, y) {
results.push(element);
}
for child in &element.children {
find_elements_at_point_recursive(child, x, y, results);
}
}
fn describe_element(element: &Element, depth: usize) -> String {
let indent = " ".repeat(depth);
let mut desc = String::new();
let kind_desc = match &element.kind {
ElementKind::Group { name } => {
format!(
"Group{}",
name.as_ref()
.map(|n| format!(" '{}'", n))
.unwrap_or_default()
)
}
ElementKind::Rectangle { .. } => format!(
"Rectangle ({}x{})",
element.bounds.width as i32, element.bounds.height as i32
),
ElementKind::Circle { r, .. } => format!("Circle (r={})", r),
ElementKind::Ellipse { rx, ry, .. } => format!("Ellipse ({}x{})", rx, ry),
ElementKind::Path { .. } => "Path".to_string(),
ElementKind::Text { content, font_size } => {
format!("Text '{}' ({}px)", truncate(content, 30), font_size)
}
ElementKind::Image { .. } => "Image".to_string(),
ElementKind::Line { .. } => "Line".to_string(),
ElementKind::Unknown { tag } => format!("Unknown <{}>", tag),
};
let style_info = element
.style
.fill
.as_ref()
.map(|f| format!(" fill={}", f))
.unwrap_or_default();
desc.push_str(&format!("{}- {}{}\n", indent, kind_desc, style_info));
for child in &element.children {
desc.push_str(&describe_element(child, depth + 1));
}
desc
}
fn truncate(s: &str, max_len: usize) -> String {
if s.len() <= max_len {
s.to_string()
} else {
format!("{}...", &s[..max_len - 3])
}
}

View File

@@ -0,0 +1,36 @@
mod app;
mod canvas;
mod drawing;
mod element_tree;
mod mermaid;
mod svg;
mod clipboard;
mod agent;
mod session;
use anyhow::Result;
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
fn main() -> Result<()> {
tracing_subscriber::registry()
.with(tracing_subscriber::EnvFilter::new(
std::env::var("RUST_LOG").unwrap_or_else(|_| "agcanvas=debug".into()),
))
.with(tracing_subscriber::fmt::layer())
.init();
let native_options = eframe::NativeOptions {
viewport: egui::ViewportBuilder::default()
.with_inner_size([1400.0, 900.0])
.with_min_inner_size([800.0, 600.0])
.with_title("agcanvas"),
..Default::default()
};
eframe::run_native(
"agcanvas",
native_options,
Box::new(|cc| Ok(Box::new(app::AgCanvasApp::new(cc)))),
)
.map_err(|e| anyhow::anyhow!("Failed to run eframe: {}", e))
}

View File

@@ -0,0 +1,24 @@
use anyhow::Result;
pub fn render_mermaid_to_svg(mermaid_source: &str) -> Result<String> {
let svg = mermaid_rs_renderer::render(mermaid_source)
.map_err(|e| anyhow::anyhow!("Mermaid render failed: {}", e))?;
Ok(svg)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn renders_simple_flowchart() {
let svg = render_mermaid_to_svg("flowchart LR\n A-->B").unwrap();
assert!(svg.contains("<svg"));
}
#[test]
fn renders_sequence_diagram() {
let svg = render_mermaid_to_svg("sequenceDiagram\n Alice->>Bob: Hello").unwrap();
assert!(svg.contains("<svg"));
}
}

View File

@@ -0,0 +1,176 @@
use crate::canvas::CanvasState;
use crate::drawing::{DragState, DrawingElement, Tool};
use crate::element_tree::ElementTree;
use crate::svg::SvgRenderer;
use egui::TextureHandle;
use serde::{Deserialize, Serialize};
use std::collections::HashMap;
#[derive(Debug, Clone, Serialize, Deserialize)]
pub struct SessionInfo {
pub id: String,
pub name: String,
pub has_svg: bool,
pub element_count: Option<usize>,
pub drawing_element_count: usize,
}
pub struct Session {
pub id: String,
pub name: String,
pub canvas_state: CanvasState,
pub svg_renderer: Option<SvgRenderer>,
pub svg_texture: Option<TextureHandle>,
pub element_tree: Option<ElementTree>,
pub description_text: String,
pub drawing_elements: Vec<DrawingElement>,
pub selected_element_id: Option<String>,
pub active_tool: Tool,
pub drag_state: DragState,
}
impl Session {
pub fn new(id: String, name: String) -> Self {
Self {
id,
name,
canvas_state: CanvasState::default(),
svg_renderer: None,
svg_texture: None,
element_tree: None,
description_text: String::new(),
drawing_elements: Vec::new(),
selected_element_id: None,
active_tool: Tool::default(),
drag_state: DragState::default(),
}
}
pub fn info(&self) -> SessionInfo {
SessionInfo {
id: self.id.clone(),
name: self.name.clone(),
has_svg: self.element_tree.is_some(),
element_count: self.element_tree.as_ref().map(|t| t.metadata.element_count),
drawing_element_count: self.drawing_elements.len(),
}
}
pub fn clear(&mut self) {
self.svg_renderer = None;
self.svg_texture = None;
self.element_tree = None;
self.description_text.clear();
self.drawing_elements.clear();
self.selected_element_id = None;
self.drag_state = DragState::default();
self.canvas_state.reset();
}
pub fn selected_element(&self) -> Option<&DrawingElement> {
self.selected_element_id
.as_ref()
.and_then(|id| self.drawing_elements.iter().find(|e| e.id == *id))
}
pub fn selected_element_mut(&mut self) -> Option<&mut DrawingElement> {
let id = self.selected_element_id.clone();
id.and_then(move |id| self.drawing_elements.iter_mut().find(|e| e.id == id))
}
pub fn delete_selected(&mut self) {
if let Some(id) = self.selected_element_id.take() {
self.drawing_elements.retain(|e| e.id != id);
}
}
}
#[derive(Debug, Clone)]
pub struct SessionData {
pub info: SessionInfo,
pub tree: Option<ElementTree>,
pub drawing_elements: Vec<DrawingElement>,
}
#[derive(Default)]
pub struct SessionStore {
sessions: HashMap<String, SessionData>,
active_session_id: Option<String>,
}
impl SessionStore {
pub fn new() -> Self {
Self::default()
}
pub fn add_session(&mut self, info: SessionInfo, tree: Option<ElementTree>) {
let id = info.id.clone();
self.sessions.insert(
id.clone(),
SessionData {
info,
tree,
drawing_elements: Vec::new(),
},
);
if self.active_session_id.is_none() {
self.active_session_id = Some(id);
}
}
pub fn remove_session(&mut self, session_id: &str) {
self.sessions.remove(session_id);
if self.active_session_id.as_deref() == Some(session_id) {
self.active_session_id = self.sessions.keys().next().cloned();
}
}
pub fn set_active(&mut self, session_id: &str) {
if self.sessions.contains_key(session_id) {
self.active_session_id = Some(session_id.to_string());
}
}
pub fn update_tree(&mut self, session_id: &str, tree: Option<ElementTree>) {
if let Some(data) = self.sessions.get_mut(session_id) {
data.info.has_svg = tree.is_some();
data.info.element_count = tree.as_ref().map(|t| t.metadata.element_count);
data.tree = tree;
}
}
pub fn get_tree(&self, session_id: Option<&str>) -> Option<(String, &ElementTree)> {
let id = session_id
.map(|s| s.to_string())
.or_else(|| self.active_session_id.clone())?;
let data = self.sessions.get(&id)?;
data.tree.as_ref().map(|t| (id, t))
}
pub fn update_drawing_elements(&mut self, session_id: &str, elements: Vec<DrawingElement>) {
if let Some(data) = self.sessions.get_mut(session_id) {
data.info.drawing_element_count = elements.len();
data.drawing_elements = elements;
}
}
pub fn get_drawing_elements(
&self,
session_id: Option<&str>,
) -> Option<(String, &[DrawingElement])> {
let id = session_id
.map(|s| s.to_string())
.or_else(|| self.active_session_id.clone())?;
let data = self.sessions.get(&id)?;
Some((id, &data.drawing_elements))
}
pub fn list_sessions(&self) -> Vec<SessionInfo> {
self.sessions.values().map(|d| d.info.clone()).collect()
}
pub fn active_session_id(&self) -> Option<&str> {
self.active_session_id.as_deref()
}
}

View File

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

View File

@@ -0,0 +1,266 @@
use crate::element_tree::{Bounds, Element, ElementKind, ElementStyle, ElementTree, TreeMetadata};
use anyhow::Result;
use std::collections::HashMap;
use std::sync::atomic::{AtomicUsize, Ordering};
use usvg::{Group, Node, Tree};
static ELEMENT_COUNTER: AtomicUsize = AtomicUsize::new(0);
fn generate_id() -> String {
format!("el_{}", ELEMENT_COUNTER.fetch_add(1, Ordering::SeqCst))
}
pub fn parse_svg(svg_data: &str) -> Result<(ElementTree, Tree)> {
let options = usvg::Options::default();
let tree = Tree::from_str(svg_data, &options)?;
let size = tree.size();
let root = parse_group(tree.root());
let element_count = count_elements(&root);
let metadata = TreeMetadata {
source: "svg_paste".to_string(),
width: size.width(),
height: size.height(),
element_count,
};
Ok((ElementTree { root, metadata }, tree))
}
fn parse_group(group: &Group) -> Element {
let bbox = group.bounding_box();
let bounds = Bounds {
x: bbox.left(),
y: bbox.top(),
width: bbox.width(),
height: bbox.height(),
};
let children: Vec<Element> = group.children().iter().map(parse_node).collect();
let id_str = group.id();
let name = if id_str.is_empty() {
None
} else {
Some(id_str.to_string())
};
Element {
id: if id_str.is_empty() {
generate_id()
} else {
id_str.to_string()
},
kind: ElementKind::Group { name },
bounds,
style: ElementStyle::default(),
children,
attributes: HashMap::new(),
}
}
fn parse_node(node: &Node) -> Element {
match node {
Node::Group(group) => parse_group(group),
Node::Path(path) => {
let bbox = path.bounding_box();
let bounds = Bounds {
x: bbox.left(),
y: bbox.top(),
width: bbox.width(),
height: bbox.height(),
};
let style = extract_path_style(path);
let kind = classify_path(path, bounds);
let id_str = path.id();
Element {
id: if id_str.is_empty() {
generate_id()
} else {
id_str.to_string()
},
kind,
bounds,
style,
children: vec![],
attributes: HashMap::new(),
}
}
Node::Image(img) => {
let size = img.size();
let bounds = Bounds {
x: 0.0,
y: 0.0,
width: size.width(),
height: size.height(),
};
let id_str = img.id();
Element {
id: if id_str.is_empty() {
generate_id()
} else {
id_str.to_string()
},
kind: ElementKind::Image {
href: "embedded".to_string(),
},
bounds,
style: ElementStyle::default(),
children: vec![],
attributes: HashMap::new(),
}
}
Node::Text(text) => {
let bbox = text.bounding_box();
let bounds = Bounds {
x: bbox.left(),
y: bbox.top(),
width: bbox.width(),
height: bbox.height(),
};
let content = extract_text_content(text);
let font_size = extract_font_size(text);
let id_str = text.id();
Element {
id: if id_str.is_empty() {
generate_id()
} else {
id_str.to_string()
},
kind: ElementKind::Text { content, font_size },
bounds,
style: ElementStyle::default(),
children: vec![],
attributes: HashMap::new(),
}
}
}
}
fn extract_path_style(path: &usvg::Path) -> ElementStyle {
let fill = path.fill().and_then(|f| match f.paint() {
usvg::Paint::Color(c) => Some(format!("#{:02x}{:02x}{:02x}", c.red, c.green, c.blue)),
_ => None,
});
let (stroke, stroke_width) = path
.stroke()
.map(|s| {
let color = match s.paint() {
usvg::Paint::Color(c) => {
Some(format!("#{:02x}{:02x}{:02x}", c.red, c.green, c.blue))
}
_ => None,
};
(color, Some(s.width().get()))
})
.unwrap_or((None, None));
let opacity = path.fill().map(|f| f.opacity().get());
ElementStyle {
fill,
stroke,
stroke_width,
opacity,
}
}
fn classify_path(path: &usvg::Path, bounds: Bounds) -> ElementKind {
let d = path_to_d_string(path);
if is_rectangle_path(path) {
return ElementKind::Rectangle { rx: None, ry: None };
}
if is_circle_like(bounds) {
let r = bounds.width / 2.0;
let (cx, cy) = bounds.center();
return ElementKind::Circle { cx, cy, r };
}
ElementKind::Path { d }
}
fn path_to_d_string(path: &usvg::Path) -> String {
use std::fmt::Write;
let mut d = String::new();
for segment in path.data().segments() {
match segment {
usvg::tiny_skia_path::PathSegment::MoveTo(p) => {
write!(d, "M{:.2},{:.2} ", p.x, p.y).unwrap();
}
usvg::tiny_skia_path::PathSegment::LineTo(p) => {
write!(d, "L{:.2},{:.2} ", p.x, p.y).unwrap();
}
usvg::tiny_skia_path::PathSegment::QuadTo(p1, p) => {
write!(d, "Q{:.2},{:.2} {:.2},{:.2} ", p1.x, p1.y, p.x, p.y).unwrap();
}
usvg::tiny_skia_path::PathSegment::CubicTo(p1, p2, p) => {
write!(
d,
"C{:.2},{:.2} {:.2},{:.2} {:.2},{:.2} ",
p1.x, p1.y, p2.x, p2.y, p.x, p.y
)
.unwrap();
}
usvg::tiny_skia_path::PathSegment::Close => {
d.push_str("Z ");
}
}
}
d.trim().to_string()
}
fn is_rectangle_path(path: &usvg::Path) -> bool {
let segments: Vec<_> = path.data().segments().collect();
if segments.len() < 4 {
return false;
}
let mut line_count = 0;
for segment in &segments {
match segment {
usvg::tiny_skia_path::PathSegment::LineTo(_) => line_count += 1,
usvg::tiny_skia_path::PathSegment::MoveTo(_) => {}
usvg::tiny_skia_path::PathSegment::Close => {}
_ => return false,
}
}
line_count == 4
}
fn is_circle_like(bounds: Bounds) -> bool {
let aspect_ratio = bounds.width / bounds.height.max(0.001);
(0.95..=1.05).contains(&aspect_ratio)
}
fn extract_text_content(text: &usvg::Text) -> String {
text.chunks()
.iter()
.map(|chunk| chunk.text())
.collect::<Vec<_>>()
.join("")
}
fn extract_font_size(text: &usvg::Text) -> f32 {
text.chunks()
.iter()
.flat_map(|chunk| chunk.spans().iter())
.next()
.map(|span| span.font_size().get())
.unwrap_or(16.0)
}
fn count_elements(element: &Element) -> usize {
1 + element.children.iter().map(count_elements).sum::<usize>()
}

View File

@@ -0,0 +1,49 @@
use anyhow::Result;
use tiny_skia::Pixmap;
use usvg::Tree;
pub struct SvgRenderer {
tree: Tree,
pixmap: Option<Pixmap>,
scale: f32,
}
impl SvgRenderer {
pub fn new(tree: Tree) -> Self {
Self {
tree,
pixmap: None,
scale: 1.0,
}
}
pub fn render(&mut self, scale: f32) -> Result<&Pixmap> {
let needs_rerender = self.pixmap.is_none() || (self.scale - scale).abs() > 0.001;
if needs_rerender {
self.scale = scale;
let size = self.tree.size();
let width = (size.width() * scale) as u32;
let height = (size.height() * scale) as u32;
let mut pixmap = Pixmap::new(width.max(1), height.max(1))
.ok_or_else(|| anyhow::anyhow!("Failed to create pixmap"))?;
pixmap.fill(tiny_skia::Color::TRANSPARENT);
let transform = tiny_skia::Transform::from_scale(scale, scale);
resvg::render(&self.tree, transform, &mut pixmap.as_mut());
self.pixmap = Some(pixmap);
}
self.pixmap
.as_ref()
.ok_or_else(|| anyhow::anyhow!("Pixmap not available"))
}
pub fn size(&self) -> (f32, f32) {
let size = self.tree.size();
(size.width(), size.height())
}
}