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:
23
crates/agcanvas-mcp/Cargo.toml
Normal file
23
crates/agcanvas-mcp/Cargo.toml
Normal file
@@ -0,0 +1,23 @@
|
||||
[package]
|
||||
name = "agcanvas-mcp"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "MCP server bridge for agcanvas — exposes canvas tools to Claude Code, OpenCode, and Codex"
|
||||
|
||||
[[bin]]
|
||||
name = "agcanvas-mcp"
|
||||
path = "src/main.rs"
|
||||
|
||||
[dependencies]
|
||||
rmcp = { version = "0.14", features = ["server", "macros", "transport-io"] }
|
||||
tokio = { version = "1.0", features = ["rt-multi-thread", "macros", "io-std", "sync"] }
|
||||
tokio-tungstenite = "0.24"
|
||||
futures-util = "0.3"
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
schemars = "1.0"
|
||||
anyhow = "1.0"
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
clap = { version = "4.0", features = ["derive"] }
|
||||
29
crates/agcanvas-mcp/src/bridge.rs
Normal file
29
crates/agcanvas-mcp/src/bridge.rs
Normal file
@@ -0,0 +1,29 @@
|
||||
use anyhow::Result;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
pub async fn send_request(ws_url: &str, request_json: &str) -> Result<String> {
|
||||
let (ws_stream, _) = tokio_tungstenite::connect_async(ws_url)
|
||||
.await
|
||||
.map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"Cannot connect to agcanvas at {}. Is agcanvas running? Error: {}",
|
||||
ws_url,
|
||||
e
|
||||
)
|
||||
})?;
|
||||
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
// Skip the initial Connected event
|
||||
let _connected = read.next().await;
|
||||
|
||||
write.send(Message::Text(request_json.to_string())).await?;
|
||||
|
||||
match read.next().await {
|
||||
Some(Ok(Message::Text(response))) => Ok(response),
|
||||
Some(Ok(other)) => Err(anyhow::anyhow!("Unexpected message type: {:?}", other)),
|
||||
Some(Err(e)) => Err(anyhow::anyhow!("WebSocket error: {}", e)),
|
||||
None => Err(anyhow::anyhow!("Connection closed before response")),
|
||||
}
|
||||
}
|
||||
38
crates/agcanvas-mcp/src/main.rs
Normal file
38
crates/agcanvas-mcp/src/main.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
mod bridge;
|
||||
mod tools;
|
||||
|
||||
use anyhow::Result;
|
||||
use clap::Parser;
|
||||
use rmcp::ServiceExt;
|
||||
use tools::AgCanvasServer;
|
||||
use tracing_subscriber::{layer::SubscriberExt, util::SubscriberInitExt};
|
||||
|
||||
#[derive(Parser)]
|
||||
#[command(name = "agcanvas-mcp", about = "MCP server bridge for agcanvas")]
|
||||
struct Cli {
|
||||
#[arg(long, default_value = "9876")]
|
||||
port: u16,
|
||||
}
|
||||
|
||||
#[tokio::main]
|
||||
async fn main() -> Result<()> {
|
||||
tracing_subscriber::registry()
|
||||
.with(tracing_subscriber::EnvFilter::new(
|
||||
std::env::var("RUST_LOG").unwrap_or_else(|_| "agcanvas_mcp=info".into()),
|
||||
))
|
||||
.with(tracing_subscriber::fmt::layer().with_writer(std::io::stderr))
|
||||
.init();
|
||||
|
||||
let cli = Cli::parse();
|
||||
let ws_url = format!("ws://127.0.0.1:{}", cli.port);
|
||||
|
||||
tracing::info!("Starting agcanvas MCP server, connecting to {}", ws_url);
|
||||
|
||||
let server = AgCanvasServer::new(ws_url);
|
||||
let service = server.serve(rmcp::transport::stdio()).await?;
|
||||
|
||||
tracing::info!("MCP server running on stdio");
|
||||
service.waiting().await?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
202
crates/agcanvas-mcp/src/tools.rs
Normal file
202
crates/agcanvas-mcp/src/tools.rs
Normal file
@@ -0,0 +1,202 @@
|
||||
use crate::bridge::send_request;
|
||||
use rmcp::{
|
||||
ErrorData as McpError, ServerHandler,
|
||||
handler::server::{router::tool::ToolRouter, wrapper::Parameters},
|
||||
model::*,
|
||||
schemars, tool, tool_handler, tool_router,
|
||||
};
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct SessionIdParam {
|
||||
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
||||
pub session_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct GetElementParam {
|
||||
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
||||
pub session_id: Option<String>,
|
||||
#[schemars(description = "Element ID to look up")]
|
||||
pub id: String,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct GetElementsAtPointParam {
|
||||
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
||||
pub session_id: Option<String>,
|
||||
#[schemars(description = "X coordinate in canvas space")]
|
||||
pub x: f32,
|
||||
#[schemars(description = "Y coordinate in canvas space")]
|
||||
pub y: f32,
|
||||
}
|
||||
|
||||
#[derive(Debug, serde::Deserialize, schemars::JsonSchema)]
|
||||
pub struct GenerateCodeParam {
|
||||
#[schemars(description = "Session ID to target. If omitted, uses the active session.")]
|
||||
pub session_id: Option<String>,
|
||||
#[schemars(description = "Code generation target: html, react, tailwind, svelte, or vue")]
|
||||
pub target: String,
|
||||
#[schemars(description = "Element ID to generate code for. If omitted, generates for the entire tree.")]
|
||||
pub element_id: Option<String>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct AgCanvasServer {
|
||||
ws_url: String,
|
||||
tool_router: ToolRouter<Self>,
|
||||
}
|
||||
|
||||
#[tool_router]
|
||||
impl AgCanvasServer {
|
||||
pub fn new(ws_url: String) -> Self {
|
||||
Self {
|
||||
ws_url,
|
||||
tool_router: Self::tool_router(),
|
||||
}
|
||||
}
|
||||
|
||||
#[tool(description = "List all open sessions/tabs in agcanvas. Returns session IDs, names, and whether they have SVG or drawing content loaded.")]
|
||||
async fn list_sessions(&self) -> Result<CallToolResult, McpError> {
|
||||
let request = serde_json::json!({"type": "ListSessions"});
|
||||
self.call_agcanvas(&request).await
|
||||
}
|
||||
|
||||
#[tool(description = "Get the full parsed SVG element tree from the canvas. Returns a hierarchical JSON structure with element types (Group, Rectangle, Circle, Path, Text, etc.), bounds, styles, and children. This is the primary way to understand what design is on the canvas.")]
|
||||
async fn get_element_tree(
|
||||
&self,
|
||||
Parameters(params): Parameters<SessionIdParam>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut request = serde_json::json!({"type": "GetTree"});
|
||||
if let Some(sid) = params.session_id {
|
||||
request["session_id"] = serde_json::Value::String(sid);
|
||||
}
|
||||
self.call_agcanvas(&request).await
|
||||
}
|
||||
|
||||
#[tool(description = "Get a human-readable semantic description of the canvas content. Returns structured text describing the element hierarchy with types, dimensions, and colors. Useful for quickly understanding a design without parsing JSON.")]
|
||||
async fn describe_canvas(
|
||||
&self,
|
||||
Parameters(params): Parameters<SessionIdParam>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut request = serde_json::json!({"type": "Describe"});
|
||||
if let Some(sid) = params.session_id {
|
||||
request["session_id"] = serde_json::Value::String(sid);
|
||||
}
|
||||
self.call_agcanvas(&request).await
|
||||
}
|
||||
|
||||
#[tool(description = "Get a specific element by its ID from the SVG element tree.")]
|
||||
async fn get_element_by_id(
|
||||
&self,
|
||||
Parameters(params): Parameters<GetElementParam>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut request = serde_json::json!({"type": "GetElementById", "id": params.id});
|
||||
if let Some(sid) = params.session_id {
|
||||
request["session_id"] = serde_json::Value::String(sid);
|
||||
}
|
||||
self.call_agcanvas(&request).await
|
||||
}
|
||||
|
||||
#[tool(description = "Query which elements exist at a specific (x, y) coordinate on the canvas.")]
|
||||
async fn get_elements_at_point(
|
||||
&self,
|
||||
Parameters(params): Parameters<GetElementsAtPointParam>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut request =
|
||||
serde_json::json!({"type": "GetElementsAtPoint", "x": params.x, "y": params.y});
|
||||
if let Some(sid) = params.session_id {
|
||||
request["session_id"] = serde_json::Value::String(sid);
|
||||
}
|
||||
self.call_agcanvas(&request).await
|
||||
}
|
||||
|
||||
#[tool(description = "Get all user-drawn shapes (rectangles, ellipses, lines, arrows, text) from the drawing layer.")]
|
||||
async fn get_drawing_elements(
|
||||
&self,
|
||||
Parameters(params): Parameters<SessionIdParam>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut request = serde_json::json!({"type": "GetDrawingElements"});
|
||||
if let Some(sid) = params.session_id {
|
||||
request["session_id"] = serde_json::Value::String(sid);
|
||||
}
|
||||
self.call_agcanvas(&request).await
|
||||
}
|
||||
|
||||
#[tool(description = "Generate a code stub from the SVG structure. Targets: html, react, tailwind, svelte, vue. Returns a starting template based on the element hierarchy.")]
|
||||
async fn generate_code(
|
||||
&self,
|
||||
Parameters(params): Parameters<GenerateCodeParam>,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let mut request = serde_json::json!({
|
||||
"type": "GenerateCode",
|
||||
"target": params.target,
|
||||
"element_id": params.element_id,
|
||||
});
|
||||
if let Some(sid) = params.session_id {
|
||||
request["session_id"] = serde_json::Value::String(sid);
|
||||
}
|
||||
self.call_agcanvas(&request).await
|
||||
}
|
||||
}
|
||||
|
||||
impl AgCanvasServer {
|
||||
async fn call_agcanvas(
|
||||
&self,
|
||||
request: &serde_json::Value,
|
||||
) -> Result<CallToolResult, McpError> {
|
||||
let request_str = serde_json::to_string(request)
|
||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||
|
||||
match send_request(&self.ws_url, &request_str).await {
|
||||
Ok(response) => {
|
||||
let parsed: serde_json::Value = serde_json::from_str(&response)
|
||||
.map_err(|e| McpError::internal_error(e.to_string(), None))?;
|
||||
|
||||
let is_error = parsed
|
||||
.get("type")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
== Some("Error");
|
||||
|
||||
if is_error {
|
||||
let msg = parsed
|
||||
.get("message")
|
||||
.and_then(serde_json::Value::as_str)
|
||||
.unwrap_or("Unknown error from agcanvas");
|
||||
return Ok(CallToolResult::error(vec![Content::text(msg)]));
|
||||
}
|
||||
|
||||
let pretty = serde_json::to_string_pretty(&parsed)
|
||||
.unwrap_or_else(|_| response.clone());
|
||||
Ok(CallToolResult::success(vec![Content::text(pretty)]))
|
||||
}
|
||||
Err(e) => Ok(CallToolResult::error(vec![Content::text(format!(
|
||||
"Failed to communicate with agcanvas: {}. Make sure agcanvas is running.",
|
||||
e
|
||||
))])),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[tool_handler]
|
||||
impl ServerHandler for AgCanvasServer {
|
||||
fn get_info(&self) -> ServerInfo {
|
||||
ServerInfo {
|
||||
instructions: Some(
|
||||
"agcanvas MCP server — connects to agcanvas desktop app to query SVG designs, \
|
||||
element trees, and user-drawn shapes. Use describe_canvas to understand the \
|
||||
current design, get_element_tree for structured data, and generate_code for \
|
||||
code stubs. Requires agcanvas to be running."
|
||||
.into(),
|
||||
),
|
||||
capabilities: ServerCapabilities::builder().enable_tools().build(),
|
||||
server_info: Implementation {
|
||||
name: "agcanvas-mcp".into(),
|
||||
version: env!("CARGO_PKG_VERSION").into(),
|
||||
title: None,
|
||||
icons: None,
|
||||
website_url: None,
|
||||
},
|
||||
..Default::default()
|
||||
}
|
||||
}
|
||||
}
|
||||
47
crates/agcanvas/Cargo.toml
Normal file
47
crates/agcanvas/Cargo.toml
Normal file
@@ -0,0 +1,47 @@
|
||||
[package]
|
||||
name = "agcanvas"
|
||||
version.workspace = true
|
||||
edition.workspace = true
|
||||
license.workspace = true
|
||||
description = "Interactive canvas for agent-human collaboration with SVG support"
|
||||
|
||||
[dependencies]
|
||||
# GUI
|
||||
eframe = { version = "0.29", default-features = false, features = [
|
||||
"default_fonts",
|
||||
"glow",
|
||||
"persistence",
|
||||
] }
|
||||
egui = "0.29"
|
||||
egui_extras = { version = "0.29", features = ["image"] }
|
||||
|
||||
# SVG parsing and rendering
|
||||
usvg = "0.44"
|
||||
resvg = "0.44"
|
||||
tiny-skia = "0.11"
|
||||
|
||||
# Clipboard
|
||||
arboard = "3.4"
|
||||
|
||||
# Serialization
|
||||
serde = { version = "1.0", features = ["derive"] }
|
||||
serde_json = "1.0"
|
||||
|
||||
# Agent communication
|
||||
tokio = { version = "1.0", features = ["rt-multi-thread", "sync", "macros"] }
|
||||
tokio-tungstenite = "0.24"
|
||||
futures-util = "0.3"
|
||||
|
||||
# Mermaid diagram rendering
|
||||
mermaid-rs-renderer = { version = "0.1.2", default-features = false }
|
||||
|
||||
# Logging
|
||||
tracing = "0.1"
|
||||
tracing-subscriber = { version = "0.3", features = ["env-filter"] }
|
||||
|
||||
# Error handling
|
||||
anyhow = "1.0"
|
||||
thiserror = "1.0"
|
||||
|
||||
# Image handling
|
||||
image = { version = "0.25", default-features = false, features = ["png"] }
|
||||
5
crates/agcanvas/src/agent/mod.rs
Normal file
5
crates/agcanvas/src/agent/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod protocol;
|
||||
mod server;
|
||||
|
||||
pub use protocol::GuiEvent;
|
||||
pub use server::AgentServer;
|
||||
126
crates/agcanvas/src/agent/protocol.rs
Normal file
126
crates/agcanvas/src/agent/protocol.rs
Normal file
@@ -0,0 +1,126 @@
|
||||
use crate::drawing::DrawingElement;
|
||||
use crate::element_tree::{ElementTree, TreeMetadata};
|
||||
use crate::session::SessionInfo;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum GuiEvent {
|
||||
Connected {
|
||||
version: String,
|
||||
sessions: Vec<SessionInfo>,
|
||||
active_session: Option<String>,
|
||||
},
|
||||
SessionCreated {
|
||||
session: SessionInfo,
|
||||
},
|
||||
SessionClosed {
|
||||
session_id: String,
|
||||
},
|
||||
SessionActivated {
|
||||
session_id: String,
|
||||
},
|
||||
SvgLoaded {
|
||||
session_id: String,
|
||||
metadata: TreeMetadata,
|
||||
},
|
||||
SvgCleared {
|
||||
session_id: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum AgentRequest {
|
||||
ListSessions,
|
||||
GetTree {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
},
|
||||
GetElementById {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
id: String,
|
||||
},
|
||||
GetElementsAtPoint {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
x: f32,
|
||||
y: f32,
|
||||
},
|
||||
Describe {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
},
|
||||
GenerateCode {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
target: CodeGenTarget,
|
||||
element_id: Option<String>,
|
||||
},
|
||||
GetDrawingElements {
|
||||
#[serde(default)]
|
||||
session_id: Option<String>,
|
||||
},
|
||||
Ping,
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum AgentResponse {
|
||||
Sessions {
|
||||
sessions: Vec<SessionInfo>,
|
||||
active_session: Option<String>,
|
||||
},
|
||||
Tree {
|
||||
session_id: String,
|
||||
tree: ElementTree,
|
||||
},
|
||||
Element {
|
||||
session_id: String,
|
||||
element: Option<crate::element_tree::Element>,
|
||||
},
|
||||
Elements {
|
||||
session_id: String,
|
||||
elements: Vec<crate::element_tree::Element>,
|
||||
},
|
||||
Description {
|
||||
session_id: String,
|
||||
text: String,
|
||||
},
|
||||
Code {
|
||||
session_id: String,
|
||||
code: String,
|
||||
target: CodeGenTarget,
|
||||
},
|
||||
DrawingElements {
|
||||
session_id: String,
|
||||
elements: Vec<DrawingElement>,
|
||||
},
|
||||
Pong,
|
||||
Error {
|
||||
message: String,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, Serialize, Deserialize, PartialEq, Eq)]
|
||||
#[serde(rename_all = "lowercase")]
|
||||
pub enum CodeGenTarget {
|
||||
Html,
|
||||
React,
|
||||
Tailwind,
|
||||
Svelte,
|
||||
Vue,
|
||||
}
|
||||
|
||||
impl std::fmt::Display for CodeGenTarget {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
CodeGenTarget::Html => write!(f, "html"),
|
||||
CodeGenTarget::React => write!(f, "react"),
|
||||
CodeGenTarget::Tailwind => write!(f, "tailwind"),
|
||||
CodeGenTarget::Svelte => write!(f, "svelte"),
|
||||
CodeGenTarget::Vue => write!(f, "vue"),
|
||||
}
|
||||
}
|
||||
}
|
||||
258
crates/agcanvas/src/agent/server.rs
Normal file
258
crates/agcanvas/src/agent/server.rs
Normal file
@@ -0,0 +1,258 @@
|
||||
use super::protocol::{AgentRequest, AgentResponse, CodeGenTarget, GuiEvent};
|
||||
use crate::session::SessionStore;
|
||||
use anyhow::Result;
|
||||
use futures_util::{SinkExt, StreamExt};
|
||||
use std::sync::Arc;
|
||||
use tokio::net::{TcpListener, TcpStream};
|
||||
use tokio::sync::{broadcast, RwLock};
|
||||
use tokio_tungstenite::tungstenite::Message;
|
||||
|
||||
const EVENT_CHANNEL_CAPACITY: usize = 64;
|
||||
|
||||
pub struct AgentServer {
|
||||
sessions: Arc<RwLock<SessionStore>>,
|
||||
event_tx: broadcast::Sender<GuiEvent>,
|
||||
port: u16,
|
||||
}
|
||||
|
||||
impl AgentServer {
|
||||
pub fn new(port: u16) -> Self {
|
||||
let (event_tx, _) = broadcast::channel(EVENT_CHANNEL_CAPACITY);
|
||||
Self {
|
||||
sessions: Arc::new(RwLock::new(SessionStore::new())),
|
||||
event_tx,
|
||||
port,
|
||||
}
|
||||
}
|
||||
|
||||
pub fn sessions_handle(&self) -> Arc<RwLock<SessionStore>> {
|
||||
self.sessions.clone()
|
||||
}
|
||||
|
||||
pub fn event_sender(&self) -> broadcast::Sender<GuiEvent> {
|
||||
self.event_tx.clone()
|
||||
}
|
||||
|
||||
pub async fn run(&self) -> Result<()> {
|
||||
let addr = format!("127.0.0.1:{}", self.port);
|
||||
let listener = TcpListener::bind(&addr).await?;
|
||||
tracing::info!("Agent server listening on ws://{}", addr);
|
||||
|
||||
while let Ok((stream, peer)) = listener.accept().await {
|
||||
tracing::info!("Agent connected from {}", peer);
|
||||
let sessions = self.sessions.clone();
|
||||
let event_rx = self.event_tx.subscribe();
|
||||
tokio::spawn(async move {
|
||||
if let Err(e) = handle_connection(stream, sessions, event_rx).await {
|
||||
tracing::error!("Connection error: {}", e);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
async fn handle_connection(
|
||||
stream: TcpStream,
|
||||
sessions: Arc<RwLock<SessionStore>>,
|
||||
mut event_rx: broadcast::Receiver<GuiEvent>,
|
||||
) -> Result<()> {
|
||||
let ws_stream = tokio_tungstenite::accept_async(stream).await?;
|
||||
let (mut write, mut read) = ws_stream.split();
|
||||
|
||||
let connected_event = {
|
||||
let store = sessions.read().await;
|
||||
GuiEvent::Connected {
|
||||
version: env!("CARGO_PKG_VERSION").to_string(),
|
||||
sessions: store.list_sessions(),
|
||||
active_session: store.active_session_id().map(|s| s.to_string()),
|
||||
}
|
||||
};
|
||||
let connected_json = serde_json::to_string(&connected_event)?;
|
||||
write.send(Message::Text(connected_json)).await?;
|
||||
|
||||
loop {
|
||||
tokio::select! {
|
||||
msg = read.next() => {
|
||||
match msg {
|
||||
Some(Ok(Message::Text(text))) => {
|
||||
let response = match serde_json::from_str::<AgentRequest>(&text) {
|
||||
Ok(request) => process_request(request, &sessions).await,
|
||||
Err(e) => AgentResponse::Error {
|
||||
message: format!("Invalid request: {}", e),
|
||||
},
|
||||
};
|
||||
let response_text = serde_json::to_string(&response)?;
|
||||
write.send(Message::Text(response_text)).await?;
|
||||
}
|
||||
Some(Ok(Message::Close(_))) | None => break,
|
||||
Some(Err(e)) => {
|
||||
tracing::warn!("WebSocket error: {}", e);
|
||||
break;
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
event = event_rx.recv() => {
|
||||
match event {
|
||||
Ok(gui_event) => {
|
||||
let event_json = serde_json::to_string(&gui_event)?;
|
||||
if write.send(Message::Text(event_json)).await.is_err() {
|
||||
break;
|
||||
}
|
||||
}
|
||||
Err(broadcast::error::RecvError::Lagged(n)) => {
|
||||
tracing::warn!("Agent lagged, skipped {} events", n);
|
||||
}
|
||||
Err(broadcast::error::RecvError::Closed) => break,
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
Ok(())
|
||||
}
|
||||
|
||||
async fn process_request(
|
||||
request: AgentRequest,
|
||||
sessions: &Arc<RwLock<SessionStore>>,
|
||||
) -> AgentResponse {
|
||||
let store = sessions.read().await;
|
||||
|
||||
match request {
|
||||
AgentRequest::Ping => AgentResponse::Pong,
|
||||
|
||||
AgentRequest::ListSessions => AgentResponse::Sessions {
|
||||
sessions: store.list_sessions(),
|
||||
active_session: store.active_session_id().map(|s| s.to_string()),
|
||||
},
|
||||
|
||||
AgentRequest::GetTree { session_id } => {
|
||||
match store.get_tree(session_id.as_deref()) {
|
||||
Some((sid, tree)) => AgentResponse::Tree {
|
||||
session_id: sid,
|
||||
tree: tree.clone(),
|
||||
},
|
||||
None => AgentResponse::Error {
|
||||
message: session_id
|
||||
.map(|id| format!("Session '{}' not found or has no SVG", id))
|
||||
.unwrap_or_else(|| "No active session or no SVG loaded".to_string()),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
AgentRequest::GetElementById { session_id, id } => {
|
||||
match store.get_tree(session_id.as_deref()) {
|
||||
Some((sid, tree)) => AgentResponse::Element {
|
||||
session_id: sid,
|
||||
element: tree.find_by_id(&id).cloned(),
|
||||
},
|
||||
None => AgentResponse::Error {
|
||||
message: "No session or SVG loaded".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
AgentRequest::GetElementsAtPoint { session_id, x, y } => {
|
||||
match store.get_tree(session_id.as_deref()) {
|
||||
Some((sid, tree)) => AgentResponse::Elements {
|
||||
session_id: sid,
|
||||
elements: tree.find_at_point(x, y).into_iter().cloned().collect(),
|
||||
},
|
||||
None => AgentResponse::Error {
|
||||
message: "No session or SVG loaded".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
AgentRequest::Describe { session_id } => {
|
||||
match store.get_tree(session_id.as_deref()) {
|
||||
Some((sid, tree)) => AgentResponse::Description {
|
||||
session_id: sid,
|
||||
text: tree.to_semantic_description(),
|
||||
},
|
||||
None => AgentResponse::Error {
|
||||
message: "No session or SVG loaded".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
AgentRequest::GetDrawingElements { session_id } => {
|
||||
match store.get_drawing_elements(session_id.as_deref()) {
|
||||
Some((sid, elements)) => AgentResponse::DrawingElements {
|
||||
session_id: sid,
|
||||
elements: elements.to_vec(),
|
||||
},
|
||||
None => AgentResponse::Error {
|
||||
message: "No session found".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
AgentRequest::GenerateCode { session_id, target, element_id } => {
|
||||
match store.get_tree(session_id.as_deref()) {
|
||||
Some((sid, tree)) => {
|
||||
let element = match &element_id {
|
||||
Some(id) => tree.find_by_id(id),
|
||||
None => Some(&tree.root),
|
||||
};
|
||||
|
||||
match element {
|
||||
Some(el) => {
|
||||
let code = generate_code_stub(el, target);
|
||||
AgentResponse::Code {
|
||||
session_id: sid,
|
||||
code,
|
||||
target,
|
||||
}
|
||||
}
|
||||
None => AgentResponse::Error {
|
||||
message: format!("Element not found: {:?}", element_id),
|
||||
},
|
||||
}
|
||||
}
|
||||
None => AgentResponse::Error {
|
||||
message: "No session or SVG loaded".to_string(),
|
||||
},
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn generate_code_stub(element: &crate::element_tree::Element, target: CodeGenTarget) -> String {
|
||||
let description = crate::element_tree::ElementTree {
|
||||
root: element.clone(),
|
||||
metadata: crate::element_tree::TreeMetadata {
|
||||
source: "code_gen".to_string(),
|
||||
width: element.bounds.width,
|
||||
height: element.bounds.height,
|
||||
element_count: 1,
|
||||
},
|
||||
}
|
||||
.to_semantic_description();
|
||||
|
||||
match target {
|
||||
CodeGenTarget::Html => format!(
|
||||
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<div class=\"container\">\n <!-- TODO: Implement based on structure -->\n</div>",
|
||||
element.id, description
|
||||
),
|
||||
CodeGenTarget::React => format!(
|
||||
"// Generated from SVG element: {}\n// Structure:\n// {}\n\nexport function Component() {{\n return (\n <div className=\"container\">\n {{/* TODO: Implement based on structure */}}\n </div>\n );\n}}",
|
||||
element.id,
|
||||
description.replace('\n', "\n// ")
|
||||
),
|
||||
CodeGenTarget::Tailwind => format!(
|
||||
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<div class=\"flex flex-col\">\n <!-- TODO: Implement with Tailwind classes -->\n</div>",
|
||||
element.id, description
|
||||
),
|
||||
CodeGenTarget::Svelte => format!(
|
||||
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<script>\n // Component logic\n</script>\n\n<div class=\"container\">\n <!-- TODO: Implement -->\n</div>",
|
||||
element.id, description
|
||||
),
|
||||
CodeGenTarget::Vue => format!(
|
||||
"<!-- Generated from SVG element: {} -->\n<!-- Structure:\n{}\n-->\n<template>\n <div class=\"container\">\n <!-- TODO: Implement -->\n </div>\n</template>\n\n<script setup>\n// Component logic\n</script>",
|
||||
element.id, description
|
||||
),
|
||||
}
|
||||
}
|
||||
1011
crates/agcanvas/src/app.rs
Normal file
1011
crates/agcanvas/src/app.rs
Normal file
File diff suppressed because it is too large
Load Diff
38
crates/agcanvas/src/canvas/interaction.rs
Normal file
38
crates/agcanvas/src/canvas/interaction.rs
Normal file
@@ -0,0 +1,38 @@
|
||||
use super::CanvasState;
|
||||
use egui::{Response, Sense, Ui};
|
||||
|
||||
pub struct CanvasInteraction;
|
||||
|
||||
impl CanvasInteraction {
|
||||
pub fn handle(ui: &Ui, response: &Response, state: &mut CanvasState) {
|
||||
if response.dragged_by(egui::PointerButton::Middle)
|
||||
|| (response.dragged_by(egui::PointerButton::Primary)
|
||||
&& ui.input(|i| i.modifiers.command))
|
||||
{
|
||||
state.pan(response.drag_delta());
|
||||
}
|
||||
|
||||
if response.hovered() {
|
||||
let scroll_delta = ui.input(|i| i.smooth_scroll_delta.y);
|
||||
if scroll_delta != 0.0 {
|
||||
let zoom_factor = 1.0 + scroll_delta * 0.001;
|
||||
if let Some(pointer_pos) = response.hover_pos() {
|
||||
state.zoom_at(pointer_pos, response.rect.center(), zoom_factor);
|
||||
}
|
||||
}
|
||||
|
||||
ui.input(|i| {
|
||||
if i.key_pressed(egui::Key::Num0) && i.modifiers.command {
|
||||
state.reset();
|
||||
}
|
||||
});
|
||||
}
|
||||
}
|
||||
|
||||
pub fn allocate_canvas(ui: &mut Ui) -> Response {
|
||||
let available_size = ui.available_size();
|
||||
let (rect, response) = ui.allocate_exact_size(available_size, Sense::click_and_drag());
|
||||
ui.set_clip_rect(rect);
|
||||
response
|
||||
}
|
||||
}
|
||||
5
crates/agcanvas/src/canvas/mod.rs
Normal file
5
crates/agcanvas/src/canvas/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod state;
|
||||
mod interaction;
|
||||
|
||||
pub use state::CanvasState;
|
||||
pub use interaction::CanvasInteraction;
|
||||
53
crates/agcanvas/src/canvas/state.rs
Normal file
53
crates/agcanvas/src/canvas/state.rs
Normal 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;
|
||||
}
|
||||
}
|
||||
28
crates/agcanvas/src/clipboard.rs
Normal file
28
crates/agcanvas/src/clipboard.rs
Normal 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")
|
||||
}
|
||||
259
crates/agcanvas/src/drawing/element.rs
Normal file
259
crates/agcanvas/src/drawing/element.rs
Normal file
@@ -0,0 +1,259 @@
|
||||
use egui::{Color32, Pos2};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
|
||||
static DRAWING_ID_COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
pub fn generate_drawing_id() -> String {
|
||||
format!("draw_{}", DRAWING_ID_COUNTER.fetch_add(1, Ordering::SeqCst))
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct DrawingElement {
|
||||
pub id: String,
|
||||
pub shape: Shape,
|
||||
pub style: ShapeStyle,
|
||||
}
|
||||
|
||||
impl DrawingElement {
|
||||
pub fn new(shape: Shape, style: ShapeStyle) -> Self {
|
||||
Self {
|
||||
id: generate_drawing_id(),
|
||||
shape,
|
||||
style,
|
||||
}
|
||||
}
|
||||
|
||||
/// Axis-aligned bounding box in canvas coordinates.
|
||||
pub fn bounding_rect(&self) -> egui::Rect {
|
||||
match &self.shape {
|
||||
Shape::Rectangle { pos, size } => egui::Rect::from_min_size(*pos, *size),
|
||||
Shape::Ellipse { center, radii } => {
|
||||
egui::Rect::from_center_size(*center, egui::vec2(radii.x * 2.0, radii.y * 2.0))
|
||||
}
|
||||
Shape::Line { start, end } | Shape::Arrow { start, end } => {
|
||||
egui::Rect::from_two_pos(*start, *end)
|
||||
}
|
||||
Shape::Text {
|
||||
pos,
|
||||
content: _,
|
||||
font_size,
|
||||
} => {
|
||||
// Approximate: we'll refine during rendering when we know actual text size.
|
||||
let approx_width = 8.0 * font_size * 0.6;
|
||||
let approx_height = *font_size * 1.4;
|
||||
egui::Rect::from_min_size(*pos, egui::vec2(approx_width, approx_height))
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Hit-test: does the point (in canvas coords) touch this element?
|
||||
pub fn contains_point(&self, point: Pos2) -> bool {
|
||||
let tolerance = 6.0; // px tolerance for thin shapes like lines
|
||||
|
||||
match &self.shape {
|
||||
Shape::Rectangle { pos, size } => {
|
||||
let rect = egui::Rect::from_min_size(*pos, *size);
|
||||
rect.expand(tolerance).contains(point)
|
||||
}
|
||||
Shape::Ellipse { center, radii } => {
|
||||
let dx = point.x - center.x;
|
||||
let dy = point.y - center.y;
|
||||
let rx = radii.x + tolerance;
|
||||
let ry = radii.y + tolerance;
|
||||
(dx * dx) / (rx * rx) + (dy * dy) / (ry * ry) <= 1.0
|
||||
}
|
||||
Shape::Line { start, end } | Shape::Arrow { start, end } => {
|
||||
point_to_segment_distance(point, *start, *end) <= tolerance
|
||||
}
|
||||
Shape::Text {
|
||||
pos,
|
||||
content: _,
|
||||
font_size,
|
||||
} => {
|
||||
let approx_width = 8.0 * font_size * 0.6;
|
||||
let approx_height = *font_size * 1.4;
|
||||
let rect = egui::Rect::from_min_size(*pos, egui::vec2(approx_width, approx_height));
|
||||
rect.expand(tolerance).contains(point)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Translate the element by a delta vector.
|
||||
pub fn translate(&mut self, delta: egui::Vec2) {
|
||||
match &mut self.shape {
|
||||
Shape::Rectangle { pos, .. } => *pos += delta,
|
||||
Shape::Ellipse { center, .. } => *center += delta,
|
||||
Shape::Line { start, end } | Shape::Arrow { start, end } => {
|
||||
*start += delta;
|
||||
*end += delta;
|
||||
}
|
||||
Shape::Text { pos, .. } => *pos += delta,
|
||||
}
|
||||
}
|
||||
|
||||
/// Resize to fit a new bounding rect, preserving shape semantics.
|
||||
pub fn resize_to(&mut self, new_rect: egui::Rect) {
|
||||
match &mut self.shape {
|
||||
Shape::Rectangle { pos, size } => {
|
||||
*pos = new_rect.min;
|
||||
*size = new_rect.size();
|
||||
}
|
||||
Shape::Ellipse { center, radii } => {
|
||||
*center = new_rect.center();
|
||||
*radii = egui::vec2(new_rect.width() / 2.0, new_rect.height() / 2.0);
|
||||
}
|
||||
Shape::Line { start, end } | Shape::Arrow { start, end } => {
|
||||
*start = new_rect.min;
|
||||
*end = new_rect.max;
|
||||
}
|
||||
Shape::Text { pos, .. } => {
|
||||
*pos = new_rect.min;
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
#[serde(tag = "type")]
|
||||
pub enum Shape {
|
||||
Rectangle {
|
||||
pos: Pos2,
|
||||
size: egui::Vec2,
|
||||
},
|
||||
Ellipse {
|
||||
center: Pos2,
|
||||
radii: egui::Vec2,
|
||||
},
|
||||
Line {
|
||||
start: Pos2,
|
||||
end: Pos2,
|
||||
},
|
||||
Arrow {
|
||||
start: Pos2,
|
||||
end: Pos2,
|
||||
},
|
||||
Text {
|
||||
pos: Pos2,
|
||||
content: String,
|
||||
font_size: f32,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct ShapeStyle {
|
||||
pub fill: Option<Color32>,
|
||||
pub stroke_color: Color32,
|
||||
pub stroke_width: f32,
|
||||
}
|
||||
|
||||
impl Default for ShapeStyle {
|
||||
fn default() -> Self {
|
||||
Self {
|
||||
fill: None,
|
||||
stroke_color: Color32::WHITE,
|
||||
stroke_width: 2.0,
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Distance from point `p` to segment `a`–`b`.
|
||||
fn point_to_segment_distance(p: Pos2, a: Pos2, b: Pos2) -> f32 {
|
||||
let ab = b - a;
|
||||
let ap = p - a;
|
||||
let ab_len_sq = ab.length_sq();
|
||||
|
||||
if ab_len_sq < 1e-6 {
|
||||
return ap.length();
|
||||
}
|
||||
|
||||
let t = (ap.x * ab.x + ap.y * ab.y) / ab_len_sq;
|
||||
let t = t.clamp(0.0, 1.0);
|
||||
let closest = a + ab * t;
|
||||
(p - closest).length()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn rectangle_contains_point_inside() {
|
||||
let el = DrawingElement::new(
|
||||
Shape::Rectangle {
|
||||
pos: Pos2::new(10.0, 10.0),
|
||||
size: egui::vec2(100.0, 50.0),
|
||||
},
|
||||
ShapeStyle::default(),
|
||||
);
|
||||
assert!(el.contains_point(Pos2::new(50.0, 30.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn rectangle_does_not_contain_distant_point() {
|
||||
let el = DrawingElement::new(
|
||||
Shape::Rectangle {
|
||||
pos: Pos2::new(10.0, 10.0),
|
||||
size: egui::vec2(100.0, 50.0),
|
||||
},
|
||||
ShapeStyle::default(),
|
||||
);
|
||||
assert!(!el.contains_point(Pos2::new(500.0, 500.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn line_hit_test_near_segment() {
|
||||
let el = DrawingElement::new(
|
||||
Shape::Line {
|
||||
start: Pos2::new(0.0, 0.0),
|
||||
end: Pos2::new(100.0, 0.0),
|
||||
},
|
||||
ShapeStyle::default(),
|
||||
);
|
||||
// Point 3px above the line — within tolerance
|
||||
assert!(el.contains_point(Pos2::new(50.0, 3.0)));
|
||||
// Point 20px above — outside tolerance
|
||||
assert!(!el.contains_point(Pos2::new(50.0, 20.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn ellipse_contains_center() {
|
||||
let el = DrawingElement::new(
|
||||
Shape::Ellipse {
|
||||
center: Pos2::new(50.0, 50.0),
|
||||
radii: egui::vec2(30.0, 20.0),
|
||||
},
|
||||
ShapeStyle::default(),
|
||||
);
|
||||
assert!(el.contains_point(Pos2::new(50.0, 50.0)));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn translate_moves_element() {
|
||||
let mut el = DrawingElement::new(
|
||||
Shape::Rectangle {
|
||||
pos: Pos2::new(10.0, 10.0),
|
||||
size: egui::vec2(100.0, 50.0),
|
||||
},
|
||||
ShapeStyle::default(),
|
||||
);
|
||||
el.translate(egui::vec2(5.0, 5.0));
|
||||
match &el.shape {
|
||||
Shape::Rectangle { pos, .. } => {
|
||||
assert!((pos.x - 15.0).abs() < 0.01);
|
||||
assert!((pos.y - 15.0).abs() < 0.01);
|
||||
}
|
||||
_ => panic!("Expected Rectangle"),
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn point_to_segment_distance_perpendicular() {
|
||||
let dist = super::point_to_segment_distance(
|
||||
Pos2::new(5.0, 5.0),
|
||||
Pos2::new(0.0, 0.0),
|
||||
Pos2::new(10.0, 0.0),
|
||||
);
|
||||
assert!((dist - 5.0).abs() < 0.01);
|
||||
}
|
||||
}
|
||||
10
crates/agcanvas/src/drawing/mod.rs
Normal file
10
crates/agcanvas/src/drawing/mod.rs
Normal file
@@ -0,0 +1,10 @@
|
||||
mod element;
|
||||
mod render;
|
||||
mod tool;
|
||||
|
||||
pub use element::{DrawingElement, Shape, ShapeStyle};
|
||||
pub use render::{
|
||||
draw_creation_preview, draw_elements, draw_selection, find_handle_at_screen_pos,
|
||||
screen_to_canvas,
|
||||
};
|
||||
pub use tool::{DragState, Tool};
|
||||
208
crates/agcanvas/src/drawing/render.rs
Normal file
208
crates/agcanvas/src/drawing/render.rs
Normal file
@@ -0,0 +1,208 @@
|
||||
use super::element::DrawingElement;
|
||||
use super::element::Shape;
|
||||
use super::tool::{DragState, ResizeHandle, Tool};
|
||||
use egui::{Color32, Painter, Pos2, Stroke, Vec2};
|
||||
|
||||
const HANDLE_RADIUS: f32 = 4.0;
|
||||
const SELECTION_COLOR: Color32 = Color32::from_rgb(59, 130, 246);
|
||||
const CREATION_PREVIEW_COLOR: Color32 = Color32::from_rgba_premultiplied(59, 130, 246, 128);
|
||||
|
||||
pub fn draw_elements(
|
||||
painter: &Painter,
|
||||
elements: &[DrawingElement],
|
||||
canvas_center: Pos2,
|
||||
offset: Vec2,
|
||||
zoom: f32,
|
||||
) {
|
||||
for element in elements {
|
||||
draw_element(painter, element, canvas_center, offset, zoom);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_element(
|
||||
painter: &Painter,
|
||||
element: &DrawingElement,
|
||||
canvas_center: Pos2,
|
||||
offset: Vec2,
|
||||
zoom: f32,
|
||||
) {
|
||||
let style = &element.style;
|
||||
let stroke = Stroke::new(style.stroke_width * zoom, style.stroke_color);
|
||||
|
||||
match &element.shape {
|
||||
Shape::Rectangle { pos, size } => {
|
||||
let screen_pos = canvas_to_screen(*pos, canvas_center, offset, zoom);
|
||||
let screen_size = *size * zoom;
|
||||
let rect = egui::Rect::from_min_size(screen_pos, screen_size);
|
||||
|
||||
if let Some(fill) = style.fill {
|
||||
painter.rect_filled(rect, 0.0, fill);
|
||||
}
|
||||
painter.rect_stroke(rect, 0.0, stroke);
|
||||
}
|
||||
Shape::Ellipse { center, radii } => {
|
||||
let screen_center = canvas_to_screen(*center, canvas_center, offset, zoom);
|
||||
let screen_radii = *radii * zoom;
|
||||
|
||||
if let Some(fill) = style.fill {
|
||||
painter.circle_filled(screen_center, screen_radii.x.min(screen_radii.y), fill);
|
||||
}
|
||||
|
||||
let n_points = 64;
|
||||
let points: Vec<Pos2> = (0..=n_points)
|
||||
.map(|i| {
|
||||
let angle = i as f32 / n_points as f32 * std::f32::consts::TAU;
|
||||
Pos2::new(
|
||||
screen_center.x + screen_radii.x * angle.cos(),
|
||||
screen_center.y + screen_radii.y * angle.sin(),
|
||||
)
|
||||
})
|
||||
.collect();
|
||||
painter.add(egui::Shape::line(points, stroke));
|
||||
}
|
||||
Shape::Line { start, end } => {
|
||||
let s = canvas_to_screen(*start, canvas_center, offset, zoom);
|
||||
let e = canvas_to_screen(*end, canvas_center, offset, zoom);
|
||||
painter.line_segment([s, e], stroke);
|
||||
}
|
||||
Shape::Arrow { start, end } => {
|
||||
let s = canvas_to_screen(*start, canvas_center, offset, zoom);
|
||||
let e = canvas_to_screen(*end, canvas_center, offset, zoom);
|
||||
painter.line_segment([s, e], stroke);
|
||||
draw_arrowhead(painter, s, e, stroke);
|
||||
}
|
||||
Shape::Text {
|
||||
pos,
|
||||
content,
|
||||
font_size,
|
||||
} => {
|
||||
let screen_pos = canvas_to_screen(*pos, canvas_center, offset, zoom);
|
||||
let scaled_font_size = font_size * zoom;
|
||||
painter.text(
|
||||
screen_pos,
|
||||
egui::Align2::LEFT_TOP,
|
||||
content,
|
||||
egui::FontId::proportional(scaled_font_size),
|
||||
style.stroke_color,
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_selection(
|
||||
painter: &Painter,
|
||||
element: &DrawingElement,
|
||||
canvas_center: Pos2,
|
||||
offset: Vec2,
|
||||
zoom: f32,
|
||||
) {
|
||||
let rect = element.bounding_rect();
|
||||
let screen_min = canvas_to_screen(rect.min, canvas_center, offset, zoom);
|
||||
let screen_max = canvas_to_screen(rect.max, canvas_center, offset, zoom);
|
||||
let screen_rect = egui::Rect::from_min_max(screen_min, screen_max);
|
||||
|
||||
painter.rect_stroke(
|
||||
screen_rect.expand(2.0),
|
||||
0.0,
|
||||
Stroke::new(1.0, SELECTION_COLOR),
|
||||
);
|
||||
|
||||
for handle in ResizeHandle::ALL {
|
||||
let pos = handle.position_in_rect(screen_rect);
|
||||
painter.circle_filled(pos, HANDLE_RADIUS, SELECTION_COLOR);
|
||||
painter.circle_stroke(pos, HANDLE_RADIUS, Stroke::new(1.0, Color32::WHITE));
|
||||
}
|
||||
}
|
||||
|
||||
pub fn draw_creation_preview(
|
||||
painter: &Painter,
|
||||
tool: Tool,
|
||||
drag_state: &DragState,
|
||||
canvas_center: Pos2,
|
||||
offset: Vec2,
|
||||
zoom: f32,
|
||||
) {
|
||||
if let DragState::Creating { start, current } = drag_state {
|
||||
let s = canvas_to_screen(*start, canvas_center, offset, zoom);
|
||||
let c = canvas_to_screen(*current, canvas_center, offset, zoom);
|
||||
let preview_stroke = Stroke::new(2.0, CREATION_PREVIEW_COLOR);
|
||||
|
||||
match tool {
|
||||
Tool::Rectangle => {
|
||||
let rect = egui::Rect::from_two_pos(s, c);
|
||||
painter.rect_stroke(rect, 0.0, preview_stroke);
|
||||
}
|
||||
Tool::Ellipse => {
|
||||
let rect = egui::Rect::from_two_pos(s, c);
|
||||
let center = rect.center();
|
||||
let rx = rect.width() / 2.0;
|
||||
let ry = rect.height() / 2.0;
|
||||
|
||||
let n_points = 64;
|
||||
let points: Vec<Pos2> = (0..=n_points)
|
||||
.map(|i| {
|
||||
let angle = i as f32 / n_points as f32 * std::f32::consts::TAU;
|
||||
Pos2::new(center.x + rx * angle.cos(), center.y + ry * angle.sin())
|
||||
})
|
||||
.collect();
|
||||
painter.add(egui::Shape::line(points, preview_stroke));
|
||||
}
|
||||
Tool::Line => {
|
||||
painter.line_segment([s, c], preview_stroke);
|
||||
}
|
||||
Tool::Arrow => {
|
||||
painter.line_segment([s, c], preview_stroke);
|
||||
draw_arrowhead(painter, s, c, preview_stroke);
|
||||
}
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn draw_arrowhead(painter: &Painter, from: Pos2, to: Pos2, stroke: Stroke) {
|
||||
let dir = to - from;
|
||||
let len = dir.length();
|
||||
if len < 1.0 {
|
||||
return;
|
||||
}
|
||||
|
||||
let dir_norm = dir / len;
|
||||
let perp = Vec2::new(-dir_norm.y, dir_norm.x);
|
||||
let arrow_size = 12.0;
|
||||
|
||||
let left = to - dir_norm * arrow_size + perp * (arrow_size * 0.4);
|
||||
let right = to - dir_norm * arrow_size - perp * (arrow_size * 0.4);
|
||||
|
||||
painter.line_segment([left, to], stroke);
|
||||
painter.line_segment([right, to], stroke);
|
||||
}
|
||||
|
||||
pub fn canvas_to_screen(canvas_pos: Pos2, canvas_center: Pos2, offset: Vec2, zoom: f32) -> Pos2 {
|
||||
canvas_center + (canvas_pos.to_vec2() + offset) * zoom
|
||||
}
|
||||
|
||||
pub fn screen_to_canvas(screen_pos: Pos2, canvas_center: Pos2, offset: Vec2, zoom: f32) -> Pos2 {
|
||||
let relative = screen_pos - canvas_center;
|
||||
(relative / zoom - offset).to_pos2()
|
||||
}
|
||||
|
||||
pub fn find_handle_at_screen_pos(
|
||||
element: &DrawingElement,
|
||||
screen_pos: Pos2,
|
||||
canvas_center: Pos2,
|
||||
offset: Vec2,
|
||||
zoom: f32,
|
||||
) -> Option<ResizeHandle> {
|
||||
let rect = element.bounding_rect();
|
||||
let screen_min = canvas_to_screen(rect.min, canvas_center, offset, zoom);
|
||||
let screen_max = canvas_to_screen(rect.max, canvas_center, offset, zoom);
|
||||
let screen_rect = egui::Rect::from_min_max(screen_min, screen_max);
|
||||
|
||||
for handle in ResizeHandle::ALL {
|
||||
let handle_pos = handle.position_in_rect(screen_rect);
|
||||
if (screen_pos - handle_pos).length() <= HANDLE_RADIUS + 4.0 {
|
||||
return Some(handle);
|
||||
}
|
||||
}
|
||||
None
|
||||
}
|
||||
109
crates/agcanvas/src/drawing/tool.rs
Normal file
109
crates/agcanvas/src/drawing/tool.rs
Normal file
@@ -0,0 +1,109 @@
|
||||
use egui::Pos2;
|
||||
use serde::{Deserialize, Serialize};
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default, Serialize, Deserialize)]
|
||||
pub enum Tool {
|
||||
#[default]
|
||||
Select,
|
||||
Rectangle,
|
||||
Ellipse,
|
||||
Line,
|
||||
Arrow,
|
||||
Text,
|
||||
}
|
||||
|
||||
impl Tool {
|
||||
pub fn label(&self) -> &'static str {
|
||||
match self {
|
||||
Tool::Select => "Select",
|
||||
Tool::Rectangle => "Rect",
|
||||
Tool::Ellipse => "Ellipse",
|
||||
Tool::Line => "Line",
|
||||
Tool::Arrow => "Arrow",
|
||||
Tool::Text => "Text",
|
||||
}
|
||||
}
|
||||
|
||||
pub fn shortcut(&self) -> Option<char> {
|
||||
match self {
|
||||
Tool::Select => Some('V'),
|
||||
Tool::Rectangle => Some('R'),
|
||||
Tool::Ellipse => Some('E'),
|
||||
Tool::Line => Some('L'),
|
||||
Tool::Arrow => Some('A'),
|
||||
Tool::Text => Some('T'),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Default)]
|
||||
pub enum DragState {
|
||||
#[default]
|
||||
None,
|
||||
Creating {
|
||||
start: Pos2,
|
||||
current: Pos2,
|
||||
},
|
||||
Moving {
|
||||
element_id: String,
|
||||
},
|
||||
Resizing {
|
||||
handle: ResizeHandle,
|
||||
element_id: String,
|
||||
original_rect: egui::Rect,
|
||||
},
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||
pub enum ResizeHandle {
|
||||
TopLeft,
|
||||
TopRight,
|
||||
BottomLeft,
|
||||
BottomRight,
|
||||
}
|
||||
|
||||
impl ResizeHandle {
|
||||
pub const ALL: [ResizeHandle; 4] = [
|
||||
ResizeHandle::TopLeft,
|
||||
ResizeHandle::TopRight,
|
||||
ResizeHandle::BottomLeft,
|
||||
ResizeHandle::BottomRight,
|
||||
];
|
||||
|
||||
pub fn position_in_rect(&self, rect: egui::Rect) -> Pos2 {
|
||||
match self {
|
||||
ResizeHandle::TopLeft => rect.left_top(),
|
||||
ResizeHandle::TopRight => rect.right_top(),
|
||||
ResizeHandle::BottomLeft => rect.left_bottom(),
|
||||
ResizeHandle::BottomRight => rect.right_bottom(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_delta(&self, original: egui::Rect, delta: egui::Vec2) -> egui::Rect {
|
||||
let mut r = original;
|
||||
match self {
|
||||
ResizeHandle::TopLeft => {
|
||||
r.min.x += delta.x;
|
||||
r.min.y += delta.y;
|
||||
}
|
||||
ResizeHandle::TopRight => {
|
||||
r.max.x += delta.x;
|
||||
r.min.y += delta.y;
|
||||
}
|
||||
ResizeHandle::BottomLeft => {
|
||||
r.min.x += delta.x;
|
||||
r.max.y += delta.y;
|
||||
}
|
||||
ResizeHandle::BottomRight => {
|
||||
r.max.x += delta.x;
|
||||
r.max.y += delta.y;
|
||||
}
|
||||
}
|
||||
// Normalize so min < max
|
||||
let min_x = r.min.x.min(r.max.x);
|
||||
let max_x = r.min.x.max(r.max.x);
|
||||
let min_y = r.min.y.min(r.max.y);
|
||||
let max_y = r.min.y.max(r.max.y);
|
||||
egui::Rect::from_min_max(Pos2::new(min_x, min_y), Pos2::new(max_x, max_y))
|
||||
}
|
||||
}
|
||||
160
crates/agcanvas/src/element_tree.rs
Normal file
160
crates/agcanvas/src/element_tree.rs
Normal 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])
|
||||
}
|
||||
}
|
||||
36
crates/agcanvas/src/main.rs
Normal file
36
crates/agcanvas/src/main.rs
Normal 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))
|
||||
}
|
||||
24
crates/agcanvas/src/mermaid.rs
Normal file
24
crates/agcanvas/src/mermaid.rs
Normal file
@@ -0,0 +1,24 @@
|
||||
use anyhow::Result;
|
||||
|
||||
pub fn render_mermaid_to_svg(mermaid_source: &str) -> Result<String> {
|
||||
let svg = mermaid_rs_renderer::render(mermaid_source)
|
||||
.map_err(|e| anyhow::anyhow!("Mermaid render failed: {}", e))?;
|
||||
Ok(svg)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn renders_simple_flowchart() {
|
||||
let svg = render_mermaid_to_svg("flowchart LR\n A-->B").unwrap();
|
||||
assert!(svg.contains("<svg"));
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn renders_sequence_diagram() {
|
||||
let svg = render_mermaid_to_svg("sequenceDiagram\n Alice->>Bob: Hello").unwrap();
|
||||
assert!(svg.contains("<svg"));
|
||||
}
|
||||
}
|
||||
176
crates/agcanvas/src/session.rs
Normal file
176
crates/agcanvas/src/session.rs
Normal file
@@ -0,0 +1,176 @@
|
||||
use crate::canvas::CanvasState;
|
||||
use crate::drawing::{DragState, DrawingElement, Tool};
|
||||
use crate::element_tree::ElementTree;
|
||||
use crate::svg::SvgRenderer;
|
||||
use egui::TextureHandle;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
#[derive(Debug, Clone, Serialize, Deserialize)]
|
||||
pub struct SessionInfo {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub has_svg: bool,
|
||||
pub element_count: Option<usize>,
|
||||
pub drawing_element_count: usize,
|
||||
}
|
||||
|
||||
pub struct Session {
|
||||
pub id: String,
|
||||
pub name: String,
|
||||
pub canvas_state: CanvasState,
|
||||
pub svg_renderer: Option<SvgRenderer>,
|
||||
pub svg_texture: Option<TextureHandle>,
|
||||
pub element_tree: Option<ElementTree>,
|
||||
pub description_text: String,
|
||||
|
||||
pub drawing_elements: Vec<DrawingElement>,
|
||||
pub selected_element_id: Option<String>,
|
||||
pub active_tool: Tool,
|
||||
pub drag_state: DragState,
|
||||
}
|
||||
|
||||
impl Session {
|
||||
pub fn new(id: String, name: String) -> Self {
|
||||
Self {
|
||||
id,
|
||||
name,
|
||||
canvas_state: CanvasState::default(),
|
||||
svg_renderer: None,
|
||||
svg_texture: None,
|
||||
element_tree: None,
|
||||
description_text: String::new(),
|
||||
drawing_elements: Vec::new(),
|
||||
selected_element_id: None,
|
||||
active_tool: Tool::default(),
|
||||
drag_state: DragState::default(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn info(&self) -> SessionInfo {
|
||||
SessionInfo {
|
||||
id: self.id.clone(),
|
||||
name: self.name.clone(),
|
||||
has_svg: self.element_tree.is_some(),
|
||||
element_count: self.element_tree.as_ref().map(|t| t.metadata.element_count),
|
||||
drawing_element_count: self.drawing_elements.len(),
|
||||
}
|
||||
}
|
||||
|
||||
pub fn clear(&mut self) {
|
||||
self.svg_renderer = None;
|
||||
self.svg_texture = None;
|
||||
self.element_tree = None;
|
||||
self.description_text.clear();
|
||||
self.drawing_elements.clear();
|
||||
self.selected_element_id = None;
|
||||
self.drag_state = DragState::default();
|
||||
self.canvas_state.reset();
|
||||
}
|
||||
|
||||
pub fn selected_element(&self) -> Option<&DrawingElement> {
|
||||
self.selected_element_id
|
||||
.as_ref()
|
||||
.and_then(|id| self.drawing_elements.iter().find(|e| e.id == *id))
|
||||
}
|
||||
|
||||
pub fn selected_element_mut(&mut self) -> Option<&mut DrawingElement> {
|
||||
let id = self.selected_element_id.clone();
|
||||
id.and_then(move |id| self.drawing_elements.iter_mut().find(|e| e.id == id))
|
||||
}
|
||||
|
||||
pub fn delete_selected(&mut self) {
|
||||
if let Some(id) = self.selected_element_id.take() {
|
||||
self.drawing_elements.retain(|e| e.id != id);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct SessionData {
|
||||
pub info: SessionInfo,
|
||||
pub tree: Option<ElementTree>,
|
||||
pub drawing_elements: Vec<DrawingElement>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub struct SessionStore {
|
||||
sessions: HashMap<String, SessionData>,
|
||||
active_session_id: Option<String>,
|
||||
}
|
||||
|
||||
impl SessionStore {
|
||||
pub fn new() -> Self {
|
||||
Self::default()
|
||||
}
|
||||
|
||||
pub fn add_session(&mut self, info: SessionInfo, tree: Option<ElementTree>) {
|
||||
let id = info.id.clone();
|
||||
self.sessions.insert(
|
||||
id.clone(),
|
||||
SessionData {
|
||||
info,
|
||||
tree,
|
||||
drawing_elements: Vec::new(),
|
||||
},
|
||||
);
|
||||
if self.active_session_id.is_none() {
|
||||
self.active_session_id = Some(id);
|
||||
}
|
||||
}
|
||||
|
||||
pub fn remove_session(&mut self, session_id: &str) {
|
||||
self.sessions.remove(session_id);
|
||||
if self.active_session_id.as_deref() == Some(session_id) {
|
||||
self.active_session_id = self.sessions.keys().next().cloned();
|
||||
}
|
||||
}
|
||||
|
||||
pub fn set_active(&mut self, session_id: &str) {
|
||||
if self.sessions.contains_key(session_id) {
|
||||
self.active_session_id = Some(session_id.to_string());
|
||||
}
|
||||
}
|
||||
|
||||
pub fn update_tree(&mut self, session_id: &str, tree: Option<ElementTree>) {
|
||||
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||
data.info.has_svg = tree.is_some();
|
||||
data.info.element_count = tree.as_ref().map(|t| t.metadata.element_count);
|
||||
data.tree = tree;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_tree(&self, session_id: Option<&str>) -> Option<(String, &ElementTree)> {
|
||||
let id = session_id
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| self.active_session_id.clone())?;
|
||||
let data = self.sessions.get(&id)?;
|
||||
data.tree.as_ref().map(|t| (id, t))
|
||||
}
|
||||
|
||||
pub fn update_drawing_elements(&mut self, session_id: &str, elements: Vec<DrawingElement>) {
|
||||
if let Some(data) = self.sessions.get_mut(session_id) {
|
||||
data.info.drawing_element_count = elements.len();
|
||||
data.drawing_elements = elements;
|
||||
}
|
||||
}
|
||||
|
||||
pub fn get_drawing_elements(
|
||||
&self,
|
||||
session_id: Option<&str>,
|
||||
) -> Option<(String, &[DrawingElement])> {
|
||||
let id = session_id
|
||||
.map(|s| s.to_string())
|
||||
.or_else(|| self.active_session_id.clone())?;
|
||||
let data = self.sessions.get(&id)?;
|
||||
Some((id, &data.drawing_elements))
|
||||
}
|
||||
|
||||
pub fn list_sessions(&self) -> Vec<SessionInfo> {
|
||||
self.sessions.values().map(|d| d.info.clone()).collect()
|
||||
}
|
||||
|
||||
pub fn active_session_id(&self) -> Option<&str> {
|
||||
self.active_session_id.as_deref()
|
||||
}
|
||||
}
|
||||
5
crates/agcanvas/src/svg/mod.rs
Normal file
5
crates/agcanvas/src/svg/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
||||
mod parser;
|
||||
mod renderer;
|
||||
|
||||
pub use parser::parse_svg;
|
||||
pub use renderer::SvgRenderer;
|
||||
266
crates/agcanvas/src/svg/parser.rs
Normal file
266
crates/agcanvas/src/svg/parser.rs
Normal file
@@ -0,0 +1,266 @@
|
||||
use crate::element_tree::{Bounds, Element, ElementKind, ElementStyle, ElementTree, TreeMetadata};
|
||||
use anyhow::Result;
|
||||
use std::collections::HashMap;
|
||||
use std::sync::atomic::{AtomicUsize, Ordering};
|
||||
use usvg::{Group, Node, Tree};
|
||||
|
||||
static ELEMENT_COUNTER: AtomicUsize = AtomicUsize::new(0);
|
||||
|
||||
fn generate_id() -> String {
|
||||
format!("el_{}", ELEMENT_COUNTER.fetch_add(1, Ordering::SeqCst))
|
||||
}
|
||||
|
||||
pub fn parse_svg(svg_data: &str) -> Result<(ElementTree, Tree)> {
|
||||
let options = usvg::Options::default();
|
||||
let tree = Tree::from_str(svg_data, &options)?;
|
||||
|
||||
let size = tree.size();
|
||||
let root = parse_group(tree.root());
|
||||
let element_count = count_elements(&root);
|
||||
|
||||
let metadata = TreeMetadata {
|
||||
source: "svg_paste".to_string(),
|
||||
width: size.width(),
|
||||
height: size.height(),
|
||||
element_count,
|
||||
};
|
||||
|
||||
Ok((ElementTree { root, metadata }, tree))
|
||||
}
|
||||
|
||||
fn parse_group(group: &Group) -> Element {
|
||||
let bbox = group.bounding_box();
|
||||
let bounds = Bounds {
|
||||
x: bbox.left(),
|
||||
y: bbox.top(),
|
||||
width: bbox.width(),
|
||||
height: bbox.height(),
|
||||
};
|
||||
|
||||
let children: Vec<Element> = group.children().iter().map(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>()
|
||||
}
|
||||
49
crates/agcanvas/src/svg/renderer.rs
Normal file
49
crates/agcanvas/src/svg/renderer.rs
Normal 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())
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user