Skip to main content

canvas_core/
fusion.rs

1//! # Input Fusion
2//!
3//! Fuses touch and voice inputs into unified intents.
4//!
5//! When a user touches the canvas while speaking, both inputs are combined
6//! to create a spatially-aware voice command. For example:
7//!
8//! ```text
9//! User touches element X while saying "Make this red"
10//!   → FusedIntent { element: X, command: "Make this red" }
11//! ```
12
13use crate::element::ElementId;
14use crate::event::{InputEvent, TouchEvent, VoiceEvent};
15use serde::{Deserialize, Serialize};
16use std::time::{Duration, Instant};
17
18/// A fused intent combining touch and voice.
19#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct FusedIntent {
21    /// The voice transcript.
22    pub transcript: String,
23    /// Touch location (x, y).
24    pub location: (f32, f32),
25    /// Target element if touch hit an element.
26    pub element_id: Option<ElementId>,
27    /// Confidence of the voice recognition.
28    pub confidence: f32,
29    /// Timestamp of the fusion.
30    pub timestamp_ms: u64,
31}
32
33/// A voice-only intent (no touch context).
34#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct VoiceOnlyIntent {
36    /// The voice transcript.
37    pub transcript: String,
38    /// Confidence of the voice recognition.
39    pub confidence: f32,
40    /// Timestamp.
41    pub timestamp_ms: u64,
42}
43
44/// Result of processing an input event.
45#[derive(Debug, Clone, PartialEq)]
46pub enum FusionResult {
47    /// Touch and voice were fused.
48    Fused(FusedIntent),
49    /// Voice-only command.
50    VoiceOnly(VoiceOnlyIntent),
51    /// Input was stored for potential fusion.
52    Pending,
53    /// No action needed.
54    None,
55}
56
57/// Configuration for input fusion.
58#[derive(Debug, Clone)]
59pub struct FusionConfig {
60    /// Time window for fusion (how long touch waits for voice).
61    pub fusion_window: Duration,
62    /// Minimum confidence for voice recognition.
63    pub min_confidence: f32,
64}
65
66impl Default for FusionConfig {
67    fn default() -> Self {
68        Self {
69            fusion_window: Duration::from_millis(2000),
70            min_confidence: 0.5,
71        }
72    }
73}
74
75/// Input fusion processor.
76///
77/// Combines touch and voice inputs that occur within a configurable time window.
78#[derive(Debug)]
79pub struct InputFusion {
80    /// Pending touch event waiting for voice.
81    pending_touch: Option<PendingTouch>,
82    /// Configuration.
83    config: FusionConfig,
84}
85
86#[derive(Debug, Clone)]
87struct PendingTouch {
88    /// Touch location.
89    location: (f32, f32),
90    /// Target element.
91    element_id: Option<ElementId>,
92    /// When the touch occurred.
93    timestamp: Instant,
94}
95
96impl InputFusion {
97    /// Create a new input fusion processor with default config.
98    #[must_use]
99    pub fn new() -> Self {
100        Self::with_config(FusionConfig::default())
101    }
102
103    /// Create with custom configuration.
104    #[must_use]
105    pub fn with_config(config: FusionConfig) -> Self {
106        Self {
107            pending_touch: None,
108            config,
109        }
110    }
111
112    /// Get the current configuration.
113    #[must_use]
114    pub const fn config(&self) -> &FusionConfig {
115        &self.config
116    }
117
118    /// Update the configuration.
119    pub fn set_config(&mut self, config: FusionConfig) {
120        self.config = config;
121    }
122
123    /// Process a touch event.
124    ///
125    /// Stores the touch for potential fusion with upcoming voice.
126    pub fn process_touch(&mut self, touch: &TouchEvent) -> FusionResult {
127        // Only process touch start events
128        if touch.phase != crate::event::TouchPhase::Start {
129            return FusionResult::None;
130        }
131
132        // Get primary touch point
133        let Some(point) = touch.primary_touch() else {
134            return FusionResult::None;
135        };
136
137        // Store touch for potential fusion
138        self.pending_touch = Some(PendingTouch {
139            location: (point.x, point.y),
140            element_id: touch.target_element,
141            timestamp: Instant::now(),
142        });
143
144        FusionResult::Pending
145    }
146
147    /// Process a voice event.
148    ///
149    /// If a touch is pending within the fusion window, creates a fused intent.
150    pub fn process_voice(&mut self, voice: &VoiceEvent) -> FusionResult {
151        // Only process final transcriptions
152        if !voice.is_final {
153            return FusionResult::None;
154        }
155
156        // Check confidence threshold
157        if voice.confidence < self.config.min_confidence {
158            return FusionResult::None;
159        }
160
161        // Check for pending touch
162        if let Some(pending) = self.pending_touch.take() {
163            // Check if within fusion window
164            if pending.timestamp.elapsed() <= self.config.fusion_window {
165                return FusionResult::Fused(FusedIntent {
166                    transcript: voice.transcript.clone(),
167                    location: pending.location,
168                    element_id: pending.element_id,
169                    confidence: voice.confidence,
170                    timestamp_ms: voice.timestamp_ms,
171                });
172            }
173        }
174
175        // Voice-only intent
176        FusionResult::VoiceOnly(VoiceOnlyIntent {
177            transcript: voice.transcript.clone(),
178            confidence: voice.confidence,
179            timestamp_ms: voice.timestamp_ms,
180        })
181    }
182
183    /// Process any input event.
184    pub fn process(&mut self, event: &InputEvent) -> FusionResult {
185        match event {
186            InputEvent::Touch(touch) => self.process_touch(touch),
187            InputEvent::Voice(voice) => self.process_voice(voice),
188            _ => FusionResult::None,
189        }
190    }
191
192    /// Check if there's a pending touch.
193    #[must_use]
194    pub fn has_pending_touch(&self) -> bool {
195        self.pending_touch.is_some()
196    }
197
198    /// Check if pending touch is still within fusion window.
199    #[must_use]
200    pub fn is_touch_valid(&self) -> bool {
201        self.pending_touch
202            .as_ref()
203            .is_some_and(|p| p.timestamp.elapsed() <= self.config.fusion_window)
204    }
205
206    /// Clear any pending touch.
207    pub fn clear_pending(&mut self) {
208        self.pending_touch = None;
209    }
210
211    /// Get time remaining in fusion window for pending touch.
212    #[must_use]
213    pub fn time_remaining(&self) -> Option<Duration> {
214        self.pending_touch.as_ref().and_then(|p| {
215            let elapsed = p.timestamp.elapsed();
216            self.config.fusion_window.checked_sub(elapsed)
217        })
218    }
219}
220
221impl Default for InputFusion {
222    fn default() -> Self {
223        Self::new()
224    }
225}
226
227#[cfg(test)]
228mod tests {
229    use super::*;
230    use crate::event::{TouchPhase, TouchPoint};
231
232    fn create_touch_event(x: f32, y: f32, element: Option<ElementId>) -> TouchEvent {
233        TouchEvent {
234            phase: TouchPhase::Start,
235            touches: vec![TouchPoint {
236                id: 0,
237                x,
238                y,
239                pressure: Some(1.0),
240                radius: None,
241            }],
242            timestamp_ms: 1000,
243            target_element: element,
244        }
245    }
246
247    fn create_voice_event(transcript: &str, is_final: bool) -> VoiceEvent {
248        VoiceEvent {
249            transcript: transcript.to_string(),
250            confidence: 0.95,
251            is_final,
252            timestamp_ms: 2000,
253        }
254    }
255
256    #[test]
257    fn test_fusion_new() {
258        let fusion = InputFusion::new();
259        assert!(!fusion.has_pending_touch());
260        assert!(!fusion.is_touch_valid());
261    }
262
263    #[test]
264    fn test_fusion_config() {
265        let config = FusionConfig {
266            fusion_window: Duration::from_millis(3000),
267            min_confidence: 0.7,
268        };
269        let fusion = InputFusion::with_config(config);
270        assert_eq!(fusion.config().fusion_window.as_millis(), 3000);
271    }
272
273    #[test]
274    fn test_process_touch_stores_pending() {
275        let mut fusion = InputFusion::new();
276        let touch = create_touch_event(100.0, 200.0, None);
277
278        let result = fusion.process_touch(&touch);
279
280        assert!(matches!(result, FusionResult::Pending));
281        assert!(fusion.has_pending_touch());
282        assert!(fusion.is_touch_valid());
283    }
284
285    #[test]
286    fn test_process_touch_only_start_phase() {
287        let mut fusion = InputFusion::new();
288        let mut touch = create_touch_event(100.0, 200.0, None);
289        touch.phase = TouchPhase::Move;
290
291        let result = fusion.process_touch(&touch);
292
293        assert!(matches!(result, FusionResult::None));
294        assert!(!fusion.has_pending_touch());
295    }
296
297    #[test]
298    fn test_voice_only_without_touch() {
299        let mut fusion = InputFusion::new();
300        let voice = create_voice_event("Make it red", true);
301
302        let result = fusion.process_voice(&voice);
303
304        match result {
305            FusionResult::VoiceOnly(intent) => {
306                assert_eq!(intent.transcript, "Make it red");
307                assert!((intent.confidence - 0.95).abs() < f32::EPSILON);
308            }
309            _ => panic!("Expected VoiceOnly result"),
310        }
311    }
312
313    #[test]
314    fn test_voice_ignores_interim() {
315        let mut fusion = InputFusion::new();
316        let voice = create_voice_event("Make it", false);
317
318        let result = fusion.process_voice(&voice);
319
320        assert!(matches!(result, FusionResult::None));
321    }
322
323    #[test]
324    fn test_voice_ignores_low_confidence() {
325        let mut fusion = InputFusion::new();
326        let mut voice = create_voice_event("Make it red", true);
327        voice.confidence = 0.3;
328
329        let result = fusion.process_voice(&voice);
330
331        assert!(matches!(result, FusionResult::None));
332    }
333
334    #[test]
335    fn test_fusion_touch_then_voice() {
336        let mut fusion = InputFusion::new();
337        let element_id = ElementId::new();
338        let touch = create_touch_event(100.0, 200.0, Some(element_id));
339        let voice = create_voice_event("Make this red", true);
340
341        // Process touch first
342        let _ = fusion.process_touch(&touch);
343        assert!(fusion.has_pending_touch());
344
345        // Then voice
346        let result = fusion.process_voice(&voice);
347
348        match result {
349            FusionResult::Fused(intent) => {
350                assert_eq!(intent.transcript, "Make this red");
351                assert_eq!(intent.location, (100.0, 200.0));
352                assert_eq!(intent.element_id, Some(element_id));
353            }
354            _ => panic!("Expected Fused result"),
355        }
356
357        // Touch should be consumed
358        assert!(!fusion.has_pending_touch());
359    }
360
361    #[test]
362    fn test_fusion_clears_pending() {
363        let mut fusion = InputFusion::new();
364        let touch = create_touch_event(100.0, 200.0, None);
365
366        let _ = fusion.process_touch(&touch);
367        assert!(fusion.has_pending_touch());
368
369        fusion.clear_pending();
370        assert!(!fusion.has_pending_touch());
371    }
372
373    #[test]
374    fn test_time_remaining() {
375        let mut fusion = InputFusion::new();
376        let touch = create_touch_event(100.0, 200.0, None);
377
378        let _ = fusion.process_touch(&touch);
379
380        let remaining = fusion.time_remaining();
381        assert!(remaining.is_some());
382        assert!(remaining.unwrap() > Duration::from_millis(1900)); // Should be close to 2s
383    }
384
385    #[test]
386    fn test_time_remaining_none_without_pending() {
387        let fusion = InputFusion::new();
388        assert!(fusion.time_remaining().is_none());
389    }
390
391    #[test]
392    fn test_default_impl() {
393        let fusion = InputFusion::default();
394        assert!(!fusion.has_pending_touch());
395    }
396
397    #[test]
398    fn test_set_config() {
399        let mut fusion = InputFusion::new();
400        fusion.set_config(FusionConfig {
401            fusion_window: Duration::from_millis(5000),
402            min_confidence: 0.8,
403        });
404        assert_eq!(fusion.config().fusion_window.as_millis(), 5000);
405    }
406
407    #[test]
408    fn test_fused_intent_fields() {
409        let intent = FusedIntent {
410            transcript: "test".to_string(),
411            location: (10.0, 20.0),
412            element_id: None,
413            confidence: 0.9,
414            timestamp_ms: 1234,
415        };
416        assert_eq!(intent.transcript, "test");
417        assert!((intent.location.0 - 10.0).abs() < f32::EPSILON);
418        assert!((intent.confidence - 0.9).abs() < f32::EPSILON);
419    }
420
421    #[test]
422    fn test_voice_only_intent_fields() {
423        let intent = VoiceOnlyIntent {
424            transcript: "undo".to_string(),
425            confidence: 0.85,
426            timestamp_ms: 5678,
427        };
428        assert_eq!(intent.transcript, "undo");
429        assert_eq!(intent.timestamp_ms, 5678);
430    }
431
432    #[test]
433    fn test_voice_event_fields() {
434        let voice = VoiceEvent {
435            transcript: "hello".to_string(),
436            confidence: 0.99,
437            is_final: true,
438            timestamp_ms: 9999,
439        };
440        assert!(voice.is_final);
441        assert_eq!(voice.transcript, "hello");
442    }
443}