//! Whiteboard Hand - Drawing and annotation capabilities //! //! Provides whiteboard drawing actions for teaching: //! - draw_text: Draw text on the whiteboard //! - draw_shape: Draw shapes (rectangle, circle, arrow, etc.) //! - draw_line: Draw lines and curves //! - draw_chart: Draw charts (bar, line, pie) //! - draw_latex: Render LaTeX formulas //! - draw_table: Draw data tables //! - clear: Clear the whiteboard //! - export: Export as image use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::Value; use zclaw_types::Result; use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus}; /// Whiteboard action types #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "action", rename_all = "snake_case")] pub enum WhiteboardAction { /// Draw text DrawText { x: f64, y: f64, text: String, #[serde(default = "default_font_size")] font_size: u32, #[serde(default)] color: Option, #[serde(default)] font_family: Option, }, /// Draw a shape DrawShape { shape: ShapeType, x: f64, y: f64, width: f64, height: f64, #[serde(default)] fill: Option, #[serde(default)] stroke: Option, #[serde(default = "default_stroke_width")] stroke_width: u32, }, /// Draw a line DrawLine { points: Vec, #[serde(default)] color: Option, #[serde(default = "default_stroke_width")] stroke_width: u32, }, /// Draw a chart DrawChart { chart_type: ChartType, data: ChartData, x: f64, y: f64, width: f64, height: f64, #[serde(default)] title: Option, }, /// Draw LaTeX formula DrawLatex { latex: String, x: f64, y: f64, #[serde(default = "default_font_size")] font_size: u32, #[serde(default)] color: Option, }, /// Draw a table DrawTable { headers: Vec, rows: Vec>, x: f64, y: f64, #[serde(default)] column_widths: Option>, }, /// Erase area Erase { x: f64, y: f64, width: f64, height: f64, }, /// Clear whiteboard Clear, /// Undo last action Undo, /// Redo last undone action Redo, /// Export as image Export { #[serde(default = "default_export_format")] format: String, }, } fn default_font_size() -> u32 { 16 } fn default_stroke_width() -> u32 { 2 } fn default_export_format() -> String { "png".to_string() } /// Shape types #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ShapeType { Rectangle, RoundedRectangle, Circle, Ellipse, Triangle, Arrow, Star, Checkmark, Cross, } /// Point for line drawing #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Point { pub x: f64, pub y: f64, } /// Chart types #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(rename_all = "snake_case")] pub enum ChartType { Bar, Line, Pie, Scatter, Area, Radar, } /// Chart data #[derive(Debug, Clone, Serialize, Deserialize)] pub struct ChartData { pub labels: Vec, pub datasets: Vec, } /// Dataset for charts #[derive(Debug, Clone, Serialize, Deserialize)] pub struct Dataset { pub label: String, pub values: Vec, #[serde(default)] pub color: Option, } /// Whiteboard state (for undo/redo) #[derive(Debug, Clone, Default)] pub struct WhiteboardState { pub actions: Vec, pub undone: Vec, pub canvas_width: f64, pub canvas_height: f64, } /// Whiteboard Hand implementation pub struct WhiteboardHand { config: HandConfig, state: std::sync::Arc>, } impl WhiteboardHand { /// Create a new whiteboard hand pub fn new() -> Self { Self { config: HandConfig { id: "whiteboard".to_string(), name: "白板".to_string(), description: "在虚拟白板上绘制和标注".to_string(), needs_approval: false, dependencies: vec![], input_schema: Some(serde_json::json!({ "type": "object", "properties": { "action": { "type": "string" }, "x": { "type": "number" }, "y": { "type": "number" }, "text": { "type": "string" }, } })), tags: vec!["presentation".to_string(), "education".to_string()], enabled: true, max_concurrent: 0, timeout_secs: 0, }, state: std::sync::Arc::new(tokio::sync::RwLock::new(WhiteboardState { canvas_width: 1920.0, canvas_height: 1080.0, ..Default::default() })), } } /// Create with custom canvas size pub fn with_size(width: f64, height: f64) -> Self { let hand = Self::new(); let mut state = hand.state.blocking_write(); state.canvas_width = width; state.canvas_height = height; drop(state); hand } /// Execute a whiteboard action pub async fn execute_action(&self, action: WhiteboardAction) -> Result { let mut state = self.state.write().await; match &action { WhiteboardAction::Clear => { state.actions.clear(); state.undone.clear(); return Ok(HandResult::success(serde_json::json!({ "status": "cleared", "action_count": 0 }))); } WhiteboardAction::Undo => { if let Some(last) = state.actions.pop() { state.undone.push(last); return Ok(HandResult::success(serde_json::json!({ "status": "undone", "remaining_actions": state.actions.len() }))); } return Ok(HandResult::success(serde_json::json!({ "status": "no_action_to_undo" }))); } WhiteboardAction::Redo => { if let Some(redone) = state.undone.pop() { state.actions.push(redone); return Ok(HandResult::success(serde_json::json!({ "status": "redone", "total_actions": state.actions.len() }))); } return Ok(HandResult::success(serde_json::json!({ "status": "no_action_to_redo" }))); } WhiteboardAction::Export { format } => { // In real implementation, would render to image return Ok(HandResult::success(serde_json::json!({ "status": "exported", "format": format, "data_url": format!("data:image/{};base64,", format) }))); } _ => { // Regular drawing action state.actions.push(action.clone()); return Ok(HandResult::success(serde_json::json!({ "status": "drawn", "action": action, "total_actions": state.actions.len() }))); } } } /// Get current state pub async fn get_state(&self) -> WhiteboardState { self.state.read().await.clone() } /// Get all actions pub async fn get_actions(&self) -> Vec { self.state.read().await.actions.clone() } } impl Default for WhiteboardHand { fn default() -> Self { Self::new() } } #[async_trait] impl Hand for WhiteboardHand { fn config(&self) -> &HandConfig { &self.config } async fn execute(&self, _context: &HandContext, input: Value) -> Result { // Parse action from input let action: WhiteboardAction = match serde_json::from_value(input.clone()) { Ok(a) => a, Err(e) => { return Ok(HandResult::error(format!("Invalid whiteboard action: {}", e))); } }; self.execute_action(action).await } fn status(&self) -> HandStatus { // Check if there are any actions HandStatus::Idle } } #[cfg(test)] mod tests { use super::*; #[tokio::test] async fn test_whiteboard_creation() { let hand = WhiteboardHand::new(); assert_eq!(hand.config().id, "whiteboard"); } #[tokio::test] async fn test_draw_text() { let hand = WhiteboardHand::new(); let action = WhiteboardAction::DrawText { x: 100.0, y: 100.0, text: "Hello World".to_string(), font_size: 24, color: Some("#333333".to_string()), font_family: None, }; let result = hand.execute_action(action).await.unwrap(); assert!(result.success); let state = hand.get_state().await; assert_eq!(state.actions.len(), 1); } #[tokio::test] async fn test_draw_shape() { let hand = WhiteboardHand::new(); let action = WhiteboardAction::DrawShape { shape: ShapeType::Rectangle, x: 50.0, y: 50.0, width: 200.0, height: 100.0, fill: Some("#4CAF50".to_string()), stroke: None, stroke_width: 2, }; let result = hand.execute_action(action).await.unwrap(); assert!(result.success); } #[tokio::test] async fn test_undo_redo() { let hand = WhiteboardHand::new(); // Draw something hand.execute_action(WhiteboardAction::DrawText { x: 0.0, y: 0.0, text: "Test".to_string(), font_size: 16, color: None, font_family: None, }).await.unwrap(); // Undo let result = hand.execute_action(WhiteboardAction::Undo).await.unwrap(); assert!(result.success); assert_eq!(hand.get_state().await.actions.len(), 0); // Redo let result = hand.execute_action(WhiteboardAction::Redo).await.unwrap(); assert!(result.success); assert_eq!(hand.get_state().await.actions.len(), 1); } #[tokio::test] async fn test_clear() { let hand = WhiteboardHand::new(); // Draw something hand.execute_action(WhiteboardAction::DrawText { x: 0.0, y: 0.0, text: "Test".to_string(), font_size: 16, color: None, font_family: None, }).await.unwrap(); // Clear let result = hand.execute_action(WhiteboardAction::Clear).await.unwrap(); assert!(result.success); assert_eq!(hand.get_state().await.actions.len(), 0); } #[tokio::test] async fn test_chart() { let hand = WhiteboardHand::new(); let action = WhiteboardAction::DrawChart { chart_type: ChartType::Bar, data: ChartData { labels: vec!["A".to_string(), "B".to_string(), "C".to_string()], datasets: vec![Dataset { label: "Values".to_string(), values: vec![10.0, 20.0, 15.0], color: Some("#2196F3".to_string()), }], }, x: 100.0, y: 100.0, width: 400.0, height: 300.0, title: Some("Test Chart".to_string()), }; let result = hand.execute_action(action).await.unwrap(); assert!(result.success); } }