1use crate::element::ElementId;
14use crate::event::{InputEvent, TouchEvent, VoiceEvent};
15use serde::{Deserialize, Serialize};
16use std::time::{Duration, Instant};
17
18#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
20pub struct FusedIntent {
21 pub transcript: String,
23 pub location: (f32, f32),
25 pub element_id: Option<ElementId>,
27 pub confidence: f32,
29 pub timestamp_ms: u64,
31}
32
33#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
35pub struct VoiceOnlyIntent {
36 pub transcript: String,
38 pub confidence: f32,
40 pub timestamp_ms: u64,
42}
43
44#[derive(Debug, Clone, PartialEq)]
46pub enum FusionResult {
47 Fused(FusedIntent),
49 VoiceOnly(VoiceOnlyIntent),
51 Pending,
53 None,
55}
56
57#[derive(Debug, Clone)]
59pub struct FusionConfig {
60 pub fusion_window: Duration,
62 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#[derive(Debug)]
79pub struct InputFusion {
80 pending_touch: Option<PendingTouch>,
82 config: FusionConfig,
84}
85
86#[derive(Debug, Clone)]
87struct PendingTouch {
88 location: (f32, f32),
90 element_id: Option<ElementId>,
92 timestamp: Instant,
94}
95
96impl InputFusion {
97 #[must_use]
99 pub fn new() -> Self {
100 Self::with_config(FusionConfig::default())
101 }
102
103 #[must_use]
105 pub fn with_config(config: FusionConfig) -> Self {
106 Self {
107 pending_touch: None,
108 config,
109 }
110 }
111
112 #[must_use]
114 pub const fn config(&self) -> &FusionConfig {
115 &self.config
116 }
117
118 pub fn set_config(&mut self, config: FusionConfig) {
120 self.config = config;
121 }
122
123 pub fn process_touch(&mut self, touch: &TouchEvent) -> FusionResult {
127 if touch.phase != crate::event::TouchPhase::Start {
129 return FusionResult::None;
130 }
131
132 let Some(point) = touch.primary_touch() else {
134 return FusionResult::None;
135 };
136
137 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 pub fn process_voice(&mut self, voice: &VoiceEvent) -> FusionResult {
151 if !voice.is_final {
153 return FusionResult::None;
154 }
155
156 if voice.confidence < self.config.min_confidence {
158 return FusionResult::None;
159 }
160
161 if let Some(pending) = self.pending_touch.take() {
163 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 FusionResult::VoiceOnly(VoiceOnlyIntent {
177 transcript: voice.transcript.clone(),
178 confidence: voice.confidence,
179 timestamp_ms: voice.timestamp_ms,
180 })
181 }
182
183 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 #[must_use]
194 pub fn has_pending_touch(&self) -> bool {
195 self.pending_touch.is_some()
196 }
197
198 #[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 pub fn clear_pending(&mut self) {
208 self.pending_touch = None;
209 }
210
211 #[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 let _ = fusion.process_touch(&touch);
343 assert!(fusion.has_pending_touch());
344
345 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 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)); }
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}