//! Slideshow Hand - Presentation control capabilities //! //! Provides slideshow control for teaching: //! - next_slide/prev_slide: Navigation //! - goto_slide: Jump to specific slide //! - spotlight: Highlight elements //! - laser: Show laser pointer //! - highlight: Highlight areas //! - play_animation: Trigger animations use async_trait::async_trait; use serde::{Deserialize, Serialize}; use serde_json::Value; use std::sync::Arc; use tokio::sync::RwLock; use zclaw_types::Result; use crate::{Hand, HandConfig, HandContext, HandResult, HandStatus}; /// Slideshow action types #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "action", rename_all = "snake_case")] pub enum SlideshowAction { /// Go to next slide NextSlide, /// Go to previous slide PrevSlide, /// Go to specific slide GotoSlide { slide_number: usize, }, /// Spotlight/highlight an element Spotlight { element_id: String, #[serde(default = "default_spotlight_duration")] duration_ms: u64, }, /// Show laser pointer at position Laser { x: f64, y: f64, #[serde(default = "default_laser_duration")] duration_ms: u64, }, /// Highlight a rectangular area Highlight { x: f64, y: f64, width: f64, height: f64, #[serde(default)] color: Option, #[serde(default = "default_highlight_duration")] duration_ms: u64, }, /// Play animation PlayAnimation { animation_id: String, }, /// Pause auto-play Pause, /// Resume auto-play Resume, /// Start auto-play AutoPlay { #[serde(default = "default_interval")] interval_ms: u64, }, /// Stop auto-play StopAutoPlay, /// Get current state GetState, /// Set slide content (for dynamic slides) SetContent { slide_number: usize, content: SlideContent, }, } fn default_spotlight_duration() -> u64 { 2000 } fn default_laser_duration() -> u64 { 3000 } fn default_highlight_duration() -> u64 { 2000 } fn default_interval() -> u64 { 5000 } /// Slide content structure #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SlideContent { pub title: String, #[serde(default)] pub subtitle: Option, #[serde(default)] pub content: Vec, #[serde(default)] pub notes: Option, #[serde(default)] pub background: Option, } /// Presentation/slideshow rendering content block. Domain-specific for slide content. /// Distinct from zclaw_types::ContentBlock (LLM messages) and zclaw_protocols::ContentBlock (MCP). #[derive(Debug, Clone, Serialize, Deserialize)] #[serde(tag = "type", rename_all = "snake_case")] pub enum ContentBlock { Text { text: String, style: Option }, Image { url: String, alt: Option }, List { items: Vec, ordered: bool }, Code { code: String, language: Option }, Math { latex: String }, Table { headers: Vec, rows: Vec> }, Chart { chart_type: String, data: serde_json::Value }, } /// Text style options #[derive(Debug, Clone, Serialize, Deserialize, Default)] pub struct TextStyle { #[serde(default)] pub bold: bool, #[serde(default)] pub italic: bool, #[serde(default)] pub size: Option, #[serde(default)] pub color: Option, } /// Slideshow state #[derive(Debug, Clone, Serialize, Deserialize)] pub struct SlideshowState { pub current_slide: usize, pub total_slides: usize, pub is_playing: bool, pub auto_play_interval_ms: u64, pub slides: Vec, } impl Default for SlideshowState { fn default() -> Self { Self { current_slide: 0, total_slides: 0, is_playing: false, auto_play_interval_ms: 5000, slides: Vec::new(), } } } /// Slideshow Hand implementation pub struct SlideshowHand { config: HandConfig, state: Arc>, } impl SlideshowHand { /// Create a new slideshow hand pub fn new() -> Self { Self { config: HandConfig { id: "slideshow".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" }, "slide_number": { "type": "integer" }, "element_id": { "type": "string" }, } })), tags: vec!["presentation".to_string(), "education".to_string()], enabled: true, max_concurrent: 0, timeout_secs: 0, }, state: Arc::new(RwLock::new(SlideshowState::default())), } } /// Create with slides (async version) pub async fn with_slides_async(slides: Vec) -> Self { let hand = Self::new(); let mut state = hand.state.write().await; state.total_slides = slides.len(); state.slides = slides; drop(state); hand } /// Execute a slideshow action pub async fn execute_action(&self, action: SlideshowAction) -> Result { let mut state = self.state.write().await; match action { SlideshowAction::NextSlide => { if state.current_slide < state.total_slides.saturating_sub(1) { state.current_slide += 1; } Ok(HandResult::success(serde_json::json!({ "status": "next", "current_slide": state.current_slide, "total_slides": state.total_slides, }))) } SlideshowAction::PrevSlide => { if state.current_slide > 0 { state.current_slide -= 1; } Ok(HandResult::success(serde_json::json!({ "status": "prev", "current_slide": state.current_slide, "total_slides": state.total_slides, }))) } SlideshowAction::GotoSlide { slide_number } => { if slide_number < state.total_slides { state.current_slide = slide_number; Ok(HandResult::success(serde_json::json!({ "status": "goto", "current_slide": state.current_slide, "slide_content": state.slides.get(slide_number), }))) } else { Ok(HandResult::error(format!("Slide {} out of range", slide_number))) } } SlideshowAction::Spotlight { element_id, duration_ms } => { Ok(HandResult::success(serde_json::json!({ "status": "spotlight", "element_id": element_id, "duration_ms": duration_ms, }))) } SlideshowAction::Laser { x, y, duration_ms } => { Ok(HandResult::success(serde_json::json!({ "status": "laser", "x": x, "y": y, "duration_ms": duration_ms, }))) } SlideshowAction::Highlight { x, y, width, height, color, duration_ms } => { Ok(HandResult::success(serde_json::json!({ "status": "highlight", "x": x, "y": y, "width": width, "height": height, "color": color.unwrap_or_else(|| "#ffcc00".to_string()), "duration_ms": duration_ms, }))) } SlideshowAction::PlayAnimation { animation_id } => { Ok(HandResult::success(serde_json::json!({ "status": "animation", "animation_id": animation_id, }))) } SlideshowAction::Pause => { state.is_playing = false; Ok(HandResult::success(serde_json::json!({ "status": "paused", }))) } SlideshowAction::Resume => { state.is_playing = true; Ok(HandResult::success(serde_json::json!({ "status": "resumed", }))) } SlideshowAction::AutoPlay { interval_ms } => { state.is_playing = true; state.auto_play_interval_ms = interval_ms; Ok(HandResult::success(serde_json::json!({ "status": "autoplay", "interval_ms": interval_ms, }))) } SlideshowAction::StopAutoPlay => { state.is_playing = false; Ok(HandResult::success(serde_json::json!({ "status": "stopped", }))) } SlideshowAction::GetState => { Ok(HandResult::success(serde_json::to_value(&*state).unwrap_or(Value::Null))) } SlideshowAction::SetContent { slide_number, content } => { if slide_number < state.slides.len() { state.slides[slide_number] = content.clone(); Ok(HandResult::success(serde_json::json!({ "status": "content_set", "slide_number": slide_number, }))) } else if slide_number == state.slides.len() { state.slides.push(content); state.total_slides = state.slides.len(); Ok(HandResult::success(serde_json::json!({ "status": "slide_added", "slide_number": slide_number, }))) } else { Ok(HandResult::error(format!("Invalid slide number: {}", slide_number))) } } } } /// Get current state pub async fn get_state(&self) -> SlideshowState { self.state.read().await.clone() } /// Add a slide pub async fn add_slide(&self, content: SlideContent) { let mut state = self.state.write().await; state.slides.push(content); state.total_slides = state.slides.len(); } } impl Default for SlideshowHand { fn default() -> Self { Self::new() } } #[async_trait] impl Hand for SlideshowHand { fn config(&self) -> &HandConfig { &self.config } async fn execute(&self, _context: &HandContext, input: Value) -> Result { let action: SlideshowAction = match serde_json::from_value(input) { Ok(a) => a, Err(e) => { return Ok(HandResult::error(format!("Invalid slideshow action: {}", e))); } }; self.execute_action(action).await } fn status(&self) -> HandStatus { HandStatus::Idle } } #[cfg(test)] mod tests { use super::*; use serde_json::json; // === Config & Defaults === #[tokio::test] async fn test_slideshow_creation() { let hand = SlideshowHand::new(); assert_eq!(hand.config().id, "slideshow"); assert_eq!(hand.config().name, "幻灯片"); assert!(!hand.config().needs_approval); assert!(hand.config().enabled); assert!(hand.config().tags.contains(&"presentation".to_string())); } #[test] fn test_default_impl() { let hand = SlideshowHand::default(); assert_eq!(hand.config().id, "slideshow"); } #[test] fn test_needs_approval() { let hand = SlideshowHand::new(); assert!(!hand.needs_approval()); } #[test] fn test_status() { let hand = SlideshowHand::new(); assert_eq!(hand.status(), HandStatus::Idle); } #[test] fn test_default_state() { let state = SlideshowState::default(); assert_eq!(state.current_slide, 0); assert_eq!(state.total_slides, 0); assert!(!state.is_playing); assert_eq!(state.auto_play_interval_ms, 5000); assert!(state.slides.is_empty()); } // === Navigation === #[tokio::test] async fn test_navigation() { let hand = SlideshowHand::with_slides_async(vec![ SlideContent { title: "Slide 1".to_string(), subtitle: None, content: vec![], notes: None, background: None }, SlideContent { title: "Slide 2".to_string(), subtitle: None, content: vec![], notes: None, background: None }, SlideContent { title: "Slide 3".to_string(), subtitle: None, content: vec![], notes: None, background: None }, ]).await; // Next hand.execute_action(SlideshowAction::NextSlide).await.unwrap(); assert_eq!(hand.get_state().await.current_slide, 1); // Goto hand.execute_action(SlideshowAction::GotoSlide { slide_number: 2 }).await.unwrap(); assert_eq!(hand.get_state().await.current_slide, 2); // Prev hand.execute_action(SlideshowAction::PrevSlide).await.unwrap(); assert_eq!(hand.get_state().await.current_slide, 1); } #[tokio::test] async fn test_next_slide_at_end() { let hand = SlideshowHand::with_slides_async(vec![ SlideContent { title: "Only Slide".to_string(), subtitle: None, content: vec![], notes: None, background: None }, ]).await; // At slide 0, should not advance past last slide hand.execute_action(SlideshowAction::NextSlide).await.unwrap(); assert_eq!(hand.get_state().await.current_slide, 0); } #[tokio::test] async fn test_prev_slide_at_beginning() { let hand = SlideshowHand::with_slides_async(vec![ SlideContent { title: "Slide 1".to_string(), subtitle: None, content: vec![], notes: None, background: None }, SlideContent { title: "Slide 2".to_string(), subtitle: None, content: vec![], notes: None, background: None }, ]).await; // At slide 0, should not go below 0 hand.execute_action(SlideshowAction::PrevSlide).await.unwrap(); assert_eq!(hand.get_state().await.current_slide, 0); } #[tokio::test] async fn test_goto_slide_out_of_range() { let hand = SlideshowHand::with_slides_async(vec![ SlideContent { title: "Slide 1".to_string(), subtitle: None, content: vec![], notes: None, background: None }, ]).await; let result = hand.execute_action(SlideshowAction::GotoSlide { slide_number: 5 }).await.unwrap(); assert!(!result.success); } #[tokio::test] async fn test_goto_slide_returns_content() { let hand = SlideshowHand::with_slides_async(vec![ SlideContent { title: "First".to_string(), subtitle: None, content: vec![], notes: None, background: None }, SlideContent { title: "Second".to_string(), subtitle: None, content: vec![], notes: None, background: None }, ]).await; let result = hand.execute_action(SlideshowAction::GotoSlide { slide_number: 1 }).await.unwrap(); assert!(result.success); assert_eq!(result.output["slide_content"]["title"], "Second"); } // === Spotlight & Laser & Highlight === #[tokio::test] async fn test_spotlight() { let hand = SlideshowHand::new(); let action = SlideshowAction::Spotlight { element_id: "title".to_string(), duration_ms: 2000, }; let result = hand.execute_action(action).await.unwrap(); assert!(result.success); assert_eq!(result.output["element_id"], "title"); assert_eq!(result.output["duration_ms"], 2000); } #[tokio::test] async fn test_spotlight_default_duration() { let hand = SlideshowHand::new(); let action = SlideshowAction::Spotlight { element_id: "elem".to_string(), duration_ms: default_spotlight_duration(), }; let result = hand.execute_action(action).await.unwrap(); assert_eq!(result.output["duration_ms"], 2000); } #[tokio::test] async fn test_laser() { let hand = SlideshowHand::new(); let action = SlideshowAction::Laser { x: 100.0, y: 200.0, duration_ms: 3000, }; let result = hand.execute_action(action).await.unwrap(); assert!(result.success); assert_eq!(result.output["x"], 100.0); assert_eq!(result.output["y"], 200.0); } #[tokio::test] async fn test_highlight_default_color() { let hand = SlideshowHand::new(); let action = SlideshowAction::Highlight { x: 10.0, y: 20.0, width: 100.0, height: 50.0, color: None, duration_ms: 2000, }; let result = hand.execute_action(action).await.unwrap(); assert!(result.success); assert_eq!(result.output["color"], "#ffcc00"); } #[tokio::test] async fn test_highlight_custom_color() { let hand = SlideshowHand::new(); let action = SlideshowAction::Highlight { x: 0.0, y: 0.0, width: 50.0, height: 50.0, color: Some("#ff0000".to_string()), duration_ms: 1000, }; let result = hand.execute_action(action).await.unwrap(); assert_eq!(result.output["color"], "#ff0000"); } // === AutoPlay / Pause / Resume === #[tokio::test] async fn test_autoplay_pause_resume() { let hand = SlideshowHand::new(); // AutoPlay let result = hand.execute_action(SlideshowAction::AutoPlay { interval_ms: 3000 }).await.unwrap(); assert!(result.success); assert!(hand.get_state().await.is_playing); assert_eq!(hand.get_state().await.auto_play_interval_ms, 3000); // Pause hand.execute_action(SlideshowAction::Pause).await.unwrap(); assert!(!hand.get_state().await.is_playing); // Resume hand.execute_action(SlideshowAction::Resume).await.unwrap(); assert!(hand.get_state().await.is_playing); // Stop hand.execute_action(SlideshowAction::StopAutoPlay).await.unwrap(); assert!(!hand.get_state().await.is_playing); } #[tokio::test] async fn test_autoplay_default_interval() { let hand = SlideshowHand::new(); hand.execute_action(SlideshowAction::AutoPlay { interval_ms: default_interval() }).await.unwrap(); assert_eq!(hand.get_state().await.auto_play_interval_ms, 5000); } // === PlayAnimation === #[tokio::test] async fn test_play_animation() { let hand = SlideshowHand::new(); let result = hand.execute_action(SlideshowAction::PlayAnimation { animation_id: "fade_in".to_string(), }).await.unwrap(); assert!(result.success); assert_eq!(result.output["animation_id"], "fade_in"); } // === GetState === #[tokio::test] async fn test_get_state() { let hand = SlideshowHand::with_slides_async(vec![ SlideContent { title: "A".to_string(), subtitle: None, content: vec![], notes: None, background: None }, ]).await; let result = hand.execute_action(SlideshowAction::GetState).await.unwrap(); assert!(result.success); assert_eq!(result.output["total_slides"], 1); assert_eq!(result.output["current_slide"], 0); } // === SetContent === #[tokio::test] async fn test_set_content() { let hand = SlideshowHand::new(); let content = SlideContent { title: "Test Slide".to_string(), subtitle: Some("Subtitle".to_string()), content: vec![ContentBlock::Text { text: "Hello".to_string(), style: None, }], notes: Some("Speaker notes".to_string()), background: None, }; let result = hand.execute_action(SlideshowAction::SetContent { slide_number: 0, content, }).await.unwrap(); assert!(result.success); assert_eq!(hand.get_state().await.total_slides, 1); assert_eq!(hand.get_state().await.slides[0].title, "Test Slide"); } #[tokio::test] async fn test_set_content_append() { let hand = SlideshowHand::with_slides_async(vec![ SlideContent { title: "First".to_string(), subtitle: None, content: vec![], notes: None, background: None }, ]).await; let content = SlideContent { title: "Appended".to_string(), subtitle: None, content: vec![], notes: None, background: None, }; let result = hand.execute_action(SlideshowAction::SetContent { slide_number: 1, content, }).await.unwrap(); assert!(result.success); assert_eq!(result.output["status"], "slide_added"); assert_eq!(hand.get_state().await.total_slides, 2); } #[tokio::test] async fn test_set_content_invalid_index() { let hand = SlideshowHand::new(); let content = SlideContent { title: "Gap".to_string(), subtitle: None, content: vec![], notes: None, background: None, }; let result = hand.execute_action(SlideshowAction::SetContent { slide_number: 5, content, }).await.unwrap(); assert!(!result.success); } // === Action Deserialization === #[test] fn test_deserialize_next_slide() { let action: SlideshowAction = serde_json::from_value(json!({"action": "next_slide"})).unwrap(); assert!(matches!(action, SlideshowAction::NextSlide)); } #[test] fn test_deserialize_goto_slide() { let action: SlideshowAction = serde_json::from_value(json!({"action": "goto_slide", "slide_number": 3})).unwrap(); match action { SlideshowAction::GotoSlide { slide_number } => assert_eq!(slide_number, 3), _ => panic!("Expected GotoSlide"), } } #[test] fn test_deserialize_laser() { let action: SlideshowAction = serde_json::from_value(json!({ "action": "laser", "x": 50.0, "y": 75.0 })).unwrap(); match action { SlideshowAction::Laser { x, y, .. } => { assert_eq!(x, 50.0); assert_eq!(y, 75.0); } _ => panic!("Expected Laser"), } } #[test] fn test_deserialize_autoplay() { let action: SlideshowAction = serde_json::from_value(json!({"action": "auto_play"})).unwrap(); match action { SlideshowAction::AutoPlay { interval_ms } => assert_eq!(interval_ms, 5000), _ => panic!("Expected AutoPlay"), } } #[test] fn test_deserialize_invalid_action() { let result = serde_json::from_value::(json!({"action": "nonexistent"})); assert!(result.is_err()); } // === ContentBlock Deserialization === #[test] fn test_content_block_text() { let block: ContentBlock = serde_json::from_value(json!({ "type": "text", "text": "Hello" })).unwrap(); match block { ContentBlock::Text { text, style } => { assert_eq!(text, "Hello"); assert!(style.is_none()); } _ => panic!("Expected Text"), } } #[test] fn test_content_block_list() { let block: ContentBlock = serde_json::from_value(json!({ "type": "list", "items": ["A", "B"], "ordered": true })).unwrap(); match block { ContentBlock::List { items, ordered } => { assert_eq!(items, vec!["A", "B"]); assert!(ordered); } _ => panic!("Expected List"), } } #[test] fn test_content_block_code() { let block: ContentBlock = serde_json::from_value(json!({ "type": "code", "code": "fn main() {}", "language": "rust" })).unwrap(); match block { ContentBlock::Code { code, language } => { assert_eq!(code, "fn main() {}"); assert_eq!(language, Some("rust".to_string())); } _ => panic!("Expected Code"), } } #[test] fn test_content_block_table() { let block: ContentBlock = serde_json::from_value(json!({ "type": "table", "headers": ["Name", "Age"], "rows": [["Alice", "30"]] })).unwrap(); match block { ContentBlock::Table { headers, rows } => { assert_eq!(headers, vec!["Name", "Age"]); assert_eq!(rows, vec![vec!["Alice", "30"]]); } _ => panic!("Expected Table"), } } // === Hand trait via execute === #[tokio::test] async fn test_hand_execute_dispatch() { let hand = SlideshowHand::with_slides_async(vec![ SlideContent { title: "S1".to_string(), subtitle: None, content: vec![], notes: None, background: None }, SlideContent { title: "S2".to_string(), subtitle: None, content: vec![], notes: None, background: None }, ]).await; let ctx = HandContext::default(); let result = hand.execute(&ctx, json!({"action": "next_slide"})).await.unwrap(); assert!(result.success); assert_eq!(result.output["current_slide"], 1); } #[tokio::test] async fn test_hand_execute_invalid_action() { let hand = SlideshowHand::new(); let ctx = HandContext::default(); let result = hand.execute(&ctx, json!({"action": "invalid"})).await.unwrap(); assert!(!result.success); } // === add_slide helper === #[tokio::test] async fn test_add_slide() { let hand = SlideshowHand::new(); hand.add_slide(SlideContent { title: "Dynamic".to_string(), subtitle: None, content: vec![], notes: None, background: None, }).await; hand.add_slide(SlideContent { title: "Dynamic 2".to_string(), subtitle: None, content: vec![], notes: None, background: None, }).await; let state = hand.get_state().await; assert_eq!(state.total_slides, 2); assert_eq!(state.slides.len(), 2); } }