Skip to main content

sim_lib_intent/
glasses.rs

1//! Glasses raw-input reduction into ordinary Intent values.
2//!
3//! Glasses report local physical input: gaze stability, head gestures, hand
4//! rays, pinches, taps, buttons, and controller actions. This module keeps those
5//! inputs as pre-Intent data and reduces them to the same baseline Intent values
6//! used by every other surface. Speech is intentionally absent here; ASR sites
7//! produce already-formed Intents.
8
9use sim_kernel::{Expr, Symbol};
10use sim_value::build;
11
12use crate::gesture::Hit;
13use crate::model::{Origin, intent};
14
15/// Input capability lookup for a glasses profile.
16///
17/// Callers can pass a `DeviceProfile`'s `input` symbols without making this
18/// crate depend on the device-profile crate.
19pub trait GlassesInputCapabilities {
20    /// Returns true when the profile advertises an input token.
21    fn has_input(&self, name: &str) -> bool;
22}
23
24impl GlassesInputCapabilities for [Symbol] {
25    fn has_input(&self, name: &str) -> bool {
26        self.iter()
27            .any(|symbol| symbol.namespace.is_none() && symbol.name.as_ref() == name)
28    }
29}
30
31impl GlassesInputCapabilities for Vec<Symbol> {
32    fn has_input(&self, name: &str) -> bool {
33        self.as_slice().has_input(name)
34    }
35}
36
37impl<T: GlassesInputCapabilities + ?Sized> GlassesInputCapabilities for &T {
38    fn has_input(&self, name: &str) -> bool {
39        (**self).has_input(name)
40    }
41}
42
43/// Thresholds used while composing physical glasses input.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub struct GlassesInputTiming {
46    /// Minimum spacing between accepted physical inputs.
47    pub debounce_ms: u64,
48    /// Minimum stable gaze time for a focus move on enter or hold.
49    pub gaze_stable_ms: u64,
50    /// Minimum stable gaze time for a selection dwell.
51    pub gaze_dwell_ms: u64,
52    /// Minimum stable head-tracking time for head gestures.
53    pub head_stable_ms: u64,
54    /// Minimum stable hand-tracking time for rays and pinches.
55    pub hand_stable_ms: u64,
56    /// Maximum span for a single or double tap pattern.
57    pub tap_sequence_ms: u64,
58    /// Minimum held duration that turns a tap or button into edit/dismiss.
59    pub long_press_ms: u64,
60}
61
62impl Default for GlassesInputTiming {
63    fn default() -> Self {
64        Self {
65            debounce_ms: 80,
66            gaze_stable_ms: 120,
67            gaze_dwell_ms: 550,
68            head_stable_ms: 140,
69            hand_stable_ms: 80,
70            tap_sequence_ms: 450,
71            long_press_ms: 650,
72        }
73    }
74}
75
76/// Gaze phases after local tracking has identified a hit.
77#[derive(Clone, Copy, Debug, PartialEq, Eq)]
78pub enum GazePhase {
79    /// Gaze has entered a target and stayed there long enough to move focus.
80    Enter,
81    /// Gaze remains stable on a target but has not become a dwell selection.
82    Hold,
83    /// Gaze has stayed on a target long enough to select it.
84    Dwell,
85}
86
87/// Stable head gestures reported by glasses tracking.
88#[derive(Clone, Copy, Debug, PartialEq, Eq)]
89pub enum HeadGesture {
90    /// A nod, used as a direct invoke.
91    Nod,
92    /// A shake, used as dismiss.
93    Shake,
94    /// A left tilt, used as a focus move.
95    TiltLeft,
96    /// A right tilt, used as a focus move.
97    TiltRight,
98    /// An upward tilt, used as a focus move.
99    TiltUp,
100    /// A downward tilt, used as a focus move.
101    TiltDown,
102}
103
104impl HeadGesture {
105    fn token(self) -> &'static str {
106        match self {
107            HeadGesture::Nod => "head-nod",
108            HeadGesture::Shake => "head-shake",
109            HeadGesture::TiltLeft => "head-tilt-left",
110            HeadGesture::TiltRight => "head-tilt-right",
111            HeadGesture::TiltUp => "head-tilt-up",
112            HeadGesture::TiltDown => "head-tilt-down",
113        }
114    }
115}
116
117/// Controller action reported by an accessory or glasses controller.
118#[derive(Clone, Copy, Debug, PartialEq, Eq)]
119pub enum ControllerAction {
120    /// A primary press.
121    Press,
122    /// A directional move.
123    Move {
124        /// Horizontal step count.
125        dx: i32,
126        /// Vertical step count.
127        dy: i32,
128    },
129    /// A request to edit the focused target.
130    Edit,
131}
132
133/// A glasses input before it has Intent meaning.
134#[derive(Clone, Debug, PartialEq)]
135pub enum GlassesRawInput {
136    /// A gaze phase over a hit target.
137    Gaze {
138        /// The recognized gaze phase.
139        phase: GazePhase,
140        /// Hit-test result under gaze.
141        hit: Hit,
142        /// How long the gaze has stayed on the hit.
143        stable_ms: u64,
144        /// Whether visual-inertial tracking is stable enough to trust the hit.
145        vio_stable: bool,
146        /// Monotonic event timestamp.
147        at_ms: u64,
148    },
149    /// A stable head gesture.
150    Head {
151        /// The recognized head gesture.
152        kind: HeadGesture,
153        /// Current focus target.
154        target: Expr,
155        /// How long the gesture has stayed stable.
156        stable_ms: u64,
157        /// Whether visual-inertial tracking is stable enough to trust the target.
158        vio_stable: bool,
159        /// Monotonic event timestamp.
160        at_ms: u64,
161    },
162    /// A hand ray over a hit target.
163    HandRay {
164        /// Hit-test result under the ray.
165        hit: Hit,
166        /// How long the ray has stayed stable.
167        stable_ms: u64,
168        /// Whether visual-inertial tracking is stable enough to trust the hit.
169        vio_stable: bool,
170        /// Monotonic event timestamp.
171        at_ms: u64,
172    },
173    /// A pinch over a hit target.
174    Pinch {
175        /// Hit-test result under the pinch.
176        hit: Hit,
177        /// How long the pinch has stayed stable.
178        stable_ms: u64,
179        /// Whether visual-inertial tracking is stable enough to trust the hit.
180        vio_stable: bool,
181        /// Monotonic event timestamp.
182        at_ms: u64,
183    },
184    /// A single, double, or long tap pattern.
185    Tap {
186        /// Tap count in the recognized pattern.
187        count: u8,
188        /// Current focus target.
189        target: Expr,
190        /// Pattern span in milliseconds.
191        span_ms: u64,
192        /// Duration of the final held tap.
193        held_ms: u64,
194        /// Monotonic event timestamp.
195        at_ms: u64,
196    },
197    /// A button on the glasses or an accessory.
198    Button {
199        /// Physical button id.
200        id: Symbol,
201        /// Current focus target, or `None` when the button is targetless.
202        target: Option<Expr>,
203        /// How long the button was held.
204        held_ms: u64,
205        /// Monotonic event timestamp.
206        at_ms: u64,
207    },
208    /// A controller or accessory action.
209    Controller {
210        /// Controller id.
211        id: Symbol,
212        /// Recognized controller action.
213        action: ControllerAction,
214        /// Current focus target.
215        target: Expr,
216        /// Monotonic event timestamp.
217        at_ms: u64,
218    },
219}
220
221/// Stateful reducer for glasses input debouncing and Intent assignment.
222#[derive(Clone, Debug, Default)]
223pub struct GlassesIntentReducer {
224    timing: GlassesInputTiming,
225    last_accepted_ms: Option<u64>,
226}
227
228impl GlassesIntentReducer {
229    /// Creates a reducer with default thresholds.
230    pub fn new() -> Self {
231        Self::default()
232    }
233
234    /// Creates a reducer with explicit thresholds.
235    pub fn with_timing(timing: GlassesInputTiming) -> Self {
236        Self {
237            timing,
238            last_accepted_ms: None,
239        }
240    }
241
242    /// Reduces one glasses input to a standard Intent, or `None` for jitter,
243    /// debounced repeats, unsupported inputs, and meaningless patterns.
244    pub fn reduce<C: GlassesInputCapabilities + ?Sized>(
245        &mut self,
246        origin: Origin,
247        raw: GlassesRawInput,
248        profile: &C,
249    ) -> Option<Expr> {
250        let at_ms = raw.at_ms();
251        let intent = match raw {
252            GlassesRawInput::Gaze {
253                phase,
254                hit,
255                stable_ms,
256                vio_stable,
257                ..
258            } if profile.has_input("gaze") && vio_stable => {
259                self.gaze_intent(origin, phase, hit, stable_ms)
260            }
261            GlassesRawInput::Head {
262                kind,
263                target,
264                stable_ms,
265                vio_stable,
266                ..
267            } if profile.has_input("head")
268                && vio_stable
269                && stable_ms >= self.timing.head_stable_ms =>
270            {
271                head_intent(origin, kind, target)
272            }
273            GlassesRawInput::HandRay {
274                hit,
275                stable_ms,
276                vio_stable,
277                ..
278            } if has_any_input(profile, &["hand", "hand-ray"])
279                && vio_stable
280                && stable_ms >= self.timing.hand_stable_ms =>
281            {
282                hit.target
283                    .clone()
284                    .map(|target| move_focus(origin, target, "hand-ray"))
285            }
286            GlassesRawInput::Pinch {
287                hit,
288                stable_ms,
289                vio_stable,
290                ..
291            } if has_any_input(profile, &["hand", "pinch"])
292                && vio_stable
293                && stable_ms >= self.timing.hand_stable_ms =>
294            {
295                hit.target
296                    .clone()
297                    .map(|target| invoke(origin, target, "pinch", vec![]))
298            }
299            GlassesRawInput::Tap {
300                count,
301                target,
302                span_ms,
303                held_ms,
304                ..
305            } if profile.has_input("tap") => {
306                tap_intent(origin, count, target, span_ms, held_ms, self.timing)
307            }
308            GlassesRawInput::Button {
309                id,
310                target,
311                held_ms,
312                ..
313            } if profile.has_input("button") => {
314                if held_ms >= self.timing.long_press_ms {
315                    Some(intent("dismiss", origin, vec![]))
316                } else {
317                    let target = target.unwrap_or_else(|| Expr::Symbol(id.clone()));
318                    Some(invoke(
319                        origin,
320                        target,
321                        "button-press",
322                        vec![Expr::Symbol(id)],
323                    ))
324                }
325            }
326            GlassesRawInput::Controller {
327                id, action, target, ..
328            } if profile.has_input("controller") => controller_intent(origin, id, action, target),
329            _ => None,
330        }?;
331        self.accepts(at_ms).then_some(intent)
332    }
333
334    fn gaze_intent(
335        &self,
336        origin: Origin,
337        phase: GazePhase,
338        hit: Hit,
339        stable_ms: u64,
340    ) -> Option<Expr> {
341        let target = hit.target?;
342        match phase {
343            GazePhase::Dwell if stable_ms >= self.timing.gaze_dwell_ms => {
344                Some(select_one(origin, target))
345            }
346            GazePhase::Enter | GazePhase::Hold if stable_ms >= self.timing.gaze_stable_ms => {
347                Some(move_focus(origin, target, phase.token()))
348            }
349            _ => None,
350        }
351    }
352
353    fn accepts(&mut self, at_ms: u64) -> bool {
354        if let Some(last) = self.last_accepted_ms
355            && at_ms.saturating_sub(last) < self.timing.debounce_ms
356        {
357            return false;
358        }
359        self.last_accepted_ms = Some(at_ms);
360        true
361    }
362}
363
364impl GazePhase {
365    fn token(self) -> &'static str {
366        match self {
367            GazePhase::Enter => "gaze-enter",
368            GazePhase::Hold => "gaze-hold",
369            GazePhase::Dwell => "gaze-dwell",
370        }
371    }
372}
373
374impl GlassesRawInput {
375    fn at_ms(&self) -> u64 {
376        match self {
377            GlassesRawInput::Gaze { at_ms, .. }
378            | GlassesRawInput::Head { at_ms, .. }
379            | GlassesRawInput::HandRay { at_ms, .. }
380            | GlassesRawInput::Pinch { at_ms, .. }
381            | GlassesRawInput::Tap { at_ms, .. }
382            | GlassesRawInput::Button { at_ms, .. }
383            | GlassesRawInput::Controller { at_ms, .. } => *at_ms,
384        }
385    }
386}
387
388fn has_any_input<C: GlassesInputCapabilities + ?Sized>(profile: &C, names: &[&str]) -> bool {
389    names.iter().any(|name| profile.has_input(name))
390}
391
392fn head_intent(origin: Origin, kind: HeadGesture, target: Expr) -> Option<Expr> {
393    match kind {
394        HeadGesture::Nod => Some(invoke(origin, target, kind.token(), vec![])),
395        HeadGesture::Shake => Some(intent("dismiss", origin, vec![])),
396        HeadGesture::TiltLeft
397        | HeadGesture::TiltRight
398        | HeadGesture::TiltUp
399        | HeadGesture::TiltDown => Some(move_focus(origin, target, kind.token())),
400    }
401}
402
403fn tap_intent(
404    origin: Origin,
405    count: u8,
406    target: Expr,
407    span_ms: u64,
408    held_ms: u64,
409    timing: GlassesInputTiming,
410) -> Option<Expr> {
411    if count == 1 && held_ms >= timing.long_press_ms {
412        return Some(edit(origin, target));
413    }
414    if span_ms > timing.tap_sequence_ms {
415        return None;
416    }
417    match count {
418        1 => Some(select_one(origin, target)),
419        2 => Some(invoke(origin, target, "double-tap", vec![])),
420        _ => None,
421    }
422}
423
424fn controller_intent(
425    origin: Origin,
426    id: Symbol,
427    action: ControllerAction,
428    target: Expr,
429) -> Option<Expr> {
430    match action {
431        ControllerAction::Press => Some(invoke(
432            origin,
433            target,
434            "controller-press",
435            vec![Expr::Symbol(id)],
436        )),
437        ControllerAction::Move { dx, dy } if dx != 0 || dy != 0 => {
438            Some(move_by(origin, target, "controller-move", dx, dy))
439        }
440        ControllerAction::Edit => Some(edit(origin, target)),
441        _ => None,
442    }
443}
444
445fn select_one(origin: Origin, target: Expr) -> Expr {
446    intent(
447        "select",
448        origin,
449        vec![("targets", Expr::List(vec![target]))],
450    )
451}
452
453fn invoke(origin: Origin, target: Expr, op: &str, args: Vec<Expr>) -> Expr {
454    intent(
455        "invoke",
456        origin,
457        vec![
458            ("target", target),
459            ("op", Expr::Symbol(Symbol::qualified("glasses/input", op))),
460            ("args", Expr::List(args)),
461        ],
462    )
463}
464
465fn edit(origin: Origin, target: Expr) -> Expr {
466    intent(
467        "edit",
468        origin,
469        vec![("target", target), ("path", Expr::List(Vec::new()))],
470    )
471}
472
473fn move_focus(origin: Origin, target: Expr, source: &str) -> Expr {
474    intent(
475        "move",
476        origin,
477        vec![
478            ("node", target),
479            ("at", build::map(vec![("source", build::sym(source))])),
480        ],
481    )
482}
483
484fn move_by(origin: Origin, target: Expr, source: &str, dx: i32, dy: i32) -> Expr {
485    intent(
486        "move",
487        origin,
488        vec![
489            ("node", target),
490            (
491                "at",
492                build::map(vec![
493                    ("source", build::sym(source)),
494                    ("dx", build::float(f64::from(dx))),
495                    ("dy", build::float(f64::from(dy))),
496                ]),
497            ),
498        ],
499    )
500}