Skip to main content

rustant_core/canvas/
mod.rs

1//! # Canvas System
2//!
3//! A2UI-inspired protocol for agent-to-UI rich content display.
4//! Supports pushing HTML, Markdown, charts, tables, forms, and diagrams
5//! to connected UI clients (Tauri dashboard, web clients).
6
7pub mod components;
8pub mod protocol;
9pub mod renderer;
10
11pub use components::{ChartDataset, ChartSpec, DiagramSpec, FormField, FormSpec, TableSpec};
12pub use protocol::{CanvasItem, CanvasMessage, CanvasTarget, ContentType};
13pub use renderer::{
14    render_chart_config, render_diagram_mermaid, render_form_html, render_table_html,
15};
16
17use std::collections::HashMap;
18
19/// Maximum content size per canvas item (default 1MB).
20const MAX_CONTENT_SIZE: usize = 1_048_576;
21
22/// Manages canvas state — stores pushed items and provides snapshots.
23#[derive(Debug, Default)]
24pub struct CanvasManager {
25    /// Items organized by target name. "broadcast" is the default target.
26    targets: HashMap<String, Vec<CanvasItem>>,
27    /// Maximum content size in bytes.
28    max_content_size: usize,
29}
30
31impl CanvasManager {
32    pub fn new() -> Self {
33        Self {
34            targets: HashMap::new(),
35            max_content_size: MAX_CONTENT_SIZE,
36        }
37    }
38
39    /// Set the maximum content size per item.
40    pub fn set_max_content_size(&mut self, size: usize) {
41        self.max_content_size = size;
42    }
43
44    /// Push content to a target. Returns the item ID.
45    pub fn push(
46        &mut self,
47        target: &CanvasTarget,
48        content_type: ContentType,
49        content: String,
50    ) -> Result<uuid::Uuid, CanvasError> {
51        if content.len() > self.max_content_size {
52            return Err(CanvasError::ContentTooLarge {
53                size: content.len(),
54                max: self.max_content_size,
55            });
56        }
57        let item = CanvasItem::new(content_type, content);
58        let id = item.id;
59        let key = target_key(target);
60        self.targets.entry(key).or_default().push(item);
61        Ok(id)
62    }
63
64    /// Clear all items from a target.
65    pub fn clear(&mut self, target: &CanvasTarget) {
66        let key = target_key(target);
67        self.targets.remove(&key);
68    }
69
70    /// Get a snapshot of all items for a target.
71    pub fn snapshot(&self, target: &CanvasTarget) -> Vec<&CanvasItem> {
72        let key = target_key(target);
73        self.targets
74            .get(&key)
75            .map(|items| items.iter().collect())
76            .unwrap_or_default()
77    }
78
79    /// Total number of items across all targets.
80    pub fn total_items(&self) -> usize {
81        self.targets.values().map(|v| v.len()).sum()
82    }
83
84    /// Check if a target has any items.
85    pub fn is_empty(&self, target: &CanvasTarget) -> bool {
86        let key = target_key(target);
87        self.targets.get(&key).map(|v| v.is_empty()).unwrap_or(true)
88    }
89}
90
91fn target_key(target: &CanvasTarget) -> String {
92    match target {
93        CanvasTarget::Broadcast => "broadcast".into(),
94        CanvasTarget::Named(name) => name.clone(),
95    }
96}
97
98/// Errors from canvas operations.
99#[derive(Debug, thiserror::Error)]
100pub enum CanvasError {
101    #[error("Content too large: {size} bytes exceeds maximum {max} bytes")]
102    ContentTooLarge { size: usize, max: usize },
103}
104
105#[cfg(test)]
106mod tests {
107    use super::*;
108
109    #[test]
110    fn test_canvas_manager_push_and_snapshot() {
111        let mut mgr = CanvasManager::new();
112        let target = CanvasTarget::Broadcast;
113
114        let id = mgr
115            .push(&target, ContentType::Html, "<h1>Hello</h1>".into())
116            .unwrap();
117        assert!(!id.is_nil());
118
119        let items = mgr.snapshot(&target);
120        assert_eq!(items.len(), 1);
121        assert_eq!(items[0].content, "<h1>Hello</h1>");
122        assert_eq!(items[0].content_type, ContentType::Html);
123    }
124
125    #[test]
126    fn test_canvas_manager_push_multiple() {
127        let mut mgr = CanvasManager::new();
128        let target = CanvasTarget::Broadcast;
129
130        mgr.push(&target, ContentType::Html, "<p>1</p>".into())
131            .unwrap();
132        mgr.push(&target, ContentType::Markdown, "# Title".into())
133            .unwrap();
134        mgr.push(&target, ContentType::Code, "fn main() {}".into())
135            .unwrap();
136
137        assert_eq!(mgr.snapshot(&target).len(), 3);
138        assert_eq!(mgr.total_items(), 3);
139    }
140
141    #[test]
142    fn test_canvas_manager_clear() {
143        let mut mgr = CanvasManager::new();
144        let target = CanvasTarget::Broadcast;
145
146        mgr.push(&target, ContentType::Html, "<p>data</p>".into())
147            .unwrap();
148        assert!(!mgr.is_empty(&target));
149
150        mgr.clear(&target);
151        assert!(mgr.is_empty(&target));
152        assert_eq!(mgr.snapshot(&target).len(), 0);
153    }
154
155    #[test]
156    fn test_canvas_manager_named_targets() {
157        let mut mgr = CanvasManager::new();
158        let main = CanvasTarget::Named("main".into());
159        let sidebar = CanvasTarget::Named("sidebar".into());
160
161        mgr.push(&main, ContentType::Html, "<p>main</p>".into())
162            .unwrap();
163        mgr.push(&sidebar, ContentType::Html, "<p>side</p>".into())
164            .unwrap();
165
166        assert_eq!(mgr.snapshot(&main).len(), 1);
167        assert_eq!(mgr.snapshot(&sidebar).len(), 1);
168        assert_eq!(mgr.total_items(), 2);
169
170        // Clearing main doesn't affect sidebar
171        mgr.clear(&main);
172        assert!(mgr.is_empty(&main));
173        assert!(!mgr.is_empty(&sidebar));
174    }
175
176    #[test]
177    fn test_canvas_manager_max_content_size() {
178        let mut mgr = CanvasManager::new();
179        mgr.set_max_content_size(10);
180
181        let target = CanvasTarget::Broadcast;
182        let result = mgr.push(&target, ContentType::Html, "short".into());
183        assert!(result.is_ok());
184
185        let result = mgr.push(&target, ContentType::Html, "this is way too long".into());
186        assert!(result.is_err());
187        match result {
188            Err(CanvasError::ContentTooLarge { size, max }) => {
189                assert_eq!(max, 10);
190                assert!(size > 10);
191            }
192            _ => panic!("Expected ContentTooLarge"),
193        }
194    }
195
196    #[test]
197    fn test_canvas_snapshot_captures_state() {
198        let mut mgr = CanvasManager::new();
199        let target = CanvasTarget::Broadcast;
200
201        mgr.push(&target, ContentType::Chart, "{\"type\":\"bar\"}".into())
202            .unwrap();
203        mgr.push(&target, ContentType::Table, "{\"headers\":[\"A\"]}".into())
204            .unwrap();
205
206        let snap = mgr.snapshot(&target);
207        assert_eq!(snap.len(), 2);
208        assert_eq!(snap[0].content_type, ContentType::Chart);
209        assert_eq!(snap[1].content_type, ContentType::Table);
210    }
211
212    #[test]
213    fn test_canvas_empty_target() {
214        let mgr = CanvasManager::new();
215        let target = CanvasTarget::Named("nonexistent".into());
216        assert!(mgr.is_empty(&target));
217        assert_eq!(mgr.snapshot(&target).len(), 0);
218    }
219}