Skip to main content

sim_lib_intent/
wrist.rs

1//! Wrist raw-input reduction into ordinary Intent values.
2//!
3//! Watch hardware reports small physical events: buttons, touch, tap or raise
4//! patterns, and sometimes a crown. This module keeps those inputs as local
5//! pre-Intent data and reduces them to the same baseline Intent values used by
6//! every other surface. Voice is intentionally absent here; speech arrives as an
7//! Intent from an ASR model site.
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 wrist profile.
16///
17/// Callers can pass a `DeviceProfile`'s `input` symbols without making this
18/// crate depend on the device-profile crate.
19pub trait WristInputCapabilities {
20    /// Returns true when the profile advertises an input token.
21    fn has_input(&self, name: &str) -> bool;
22}
23
24impl WristInputCapabilities 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 WristInputCapabilities for Vec<Symbol> {
32    fn has_input(&self, name: &str) -> bool {
33        self.as_slice().has_input(name)
34    }
35}
36
37impl<T: WristInputCapabilities + ?Sized> WristInputCapabilities for &T {
38    fn has_input(&self, name: &str) -> bool {
39        (**self).has_input(name)
40    }
41}
42
43/// Thresholds used while composing physical wrist input.
44#[derive(Clone, Copy, Debug, PartialEq, Eq)]
45pub struct WristInputTiming {
46    /// Minimum spacing between accepted physical inputs.
47    pub debounce_ms: u64,
48    /// Maximum span for a single, double, or triple tap pattern.
49    pub tap_sequence_ms: u64,
50    /// Minimum button hold duration that becomes a dismiss intent.
51    pub long_press_ms: u64,
52    /// Minimum stable raise duration that becomes a selection intent.
53    pub raise_stable_ms: u64,
54}
55
56impl Default for WristInputTiming {
57    fn default() -> Self {
58        Self {
59            debounce_ms: 80,
60            tap_sequence_ms: 450,
61            long_press_ms: 650,
62            raise_stable_ms: 180,
63        }
64    }
65}
66
67/// A physical watch input before it has Intent meaning.
68#[derive(Clone, Debug, PartialEq)]
69pub enum WristRawInput {
70    /// A T-Rex button press.
71    Button {
72        /// Physical button id.
73        id: Symbol,
74        /// How long the button was held.
75        held_ms: u64,
76        /// Monotonic event timestamp.
77        at_ms: u64,
78    },
79    /// A crown or rotary input, only accepted when the profile advertises it.
80    Crown {
81        /// Rotation delta; zero with `press == false` is jitter.
82        delta: i32,
83        /// Whether the crown was pressed.
84        press: bool,
85        /// Current focus target.
86        target: Expr,
87        /// Monotonic event timestamp.
88        at_ms: u64,
89    },
90    /// A single, double, or triple tap pattern.
91    Tap {
92        /// Tap count in the recognized pattern.
93        count: u8,
94        /// Current focus target.
95        target: Expr,
96        /// Pattern span in milliseconds.
97        span_ms: u64,
98        /// Monotonic event timestamp.
99        at_ms: u64,
100    },
101    /// A raise pattern after motion filtering.
102    Raise {
103        /// Current focus target.
104        target: Expr,
105        /// Stable raise duration in milliseconds.
106        stable_ms: u64,
107        /// Monotonic event timestamp.
108        at_ms: u64,
109    },
110    /// A touch hit on the watch surface.
111    Touch {
112        /// Hit-test result on the current glance.
113        hit: Hit,
114        /// Monotonic event timestamp.
115        at_ms: u64,
116    },
117}
118
119/// Stateful reducer for wrist input debouncing and Intent assignment.
120#[derive(Clone, Debug, Default)]
121pub struct WristIntentReducer {
122    timing: WristInputTiming,
123    last_accepted_ms: Option<u64>,
124}
125
126impl WristIntentReducer {
127    /// Creates a reducer with default thresholds.
128    pub fn new() -> Self {
129        Self::default()
130    }
131
132    /// Creates a reducer with explicit thresholds.
133    pub fn with_timing(timing: WristInputTiming) -> Self {
134        Self {
135            timing,
136            last_accepted_ms: None,
137        }
138    }
139
140    /// Reduces one wrist input to a standard Intent, or `None` for jitter,
141    /// debounced repeats, unsupported inputs, and meaningless patterns.
142    pub fn reduce<C: WristInputCapabilities + ?Sized>(
143        &mut self,
144        origin: Origin,
145        raw: WristRawInput,
146        profile: &C,
147    ) -> Option<Expr> {
148        let at_ms = raw.at_ms();
149        let intent = match raw {
150            WristRawInput::Button { id, held_ms, .. } if profile.has_input("button") => {
151                if held_ms >= self.timing.long_press_ms {
152                    Some(intent("dismiss", origin, vec![]))
153                } else {
154                    Some(invoke(
155                        origin,
156                        Expr::Symbol(id.clone()),
157                        "button-press",
158                        vec![Expr::Symbol(id)],
159                    ))
160                }
161            }
162            WristRawInput::Crown {
163                delta,
164                press,
165                target,
166                ..
167            } if has_any_input(profile, &["crown", "rotary"]) => {
168                if press {
169                    Some(invoke(origin, target, "crown-press", vec![]))
170                } else if delta != 0 {
171                    Some(move_selection(origin, target, delta))
172                } else {
173                    None
174                }
175            }
176            WristRawInput::Tap {
177                count,
178                target,
179                span_ms,
180                ..
181            } if profile.has_input("tap") && span_ms <= self.timing.tap_sequence_ms => {
182                tap_pattern(origin, count, target)
183            }
184            WristRawInput::Raise {
185                target, stable_ms, ..
186            } if profile.has_input("raise") && stable_ms >= self.timing.raise_stable_ms => {
187                Some(select_one(origin, target))
188            }
189            WristRawInput::Touch { hit, .. } if profile.has_input("touch") => hit
190                .target
191                .clone()
192                .map(|target| move_selection(origin, target, 1)),
193            _ => None,
194        }?;
195        self.accepts(at_ms).then_some(intent)
196    }
197
198    fn accepts(&mut self, at_ms: u64) -> bool {
199        if let Some(last) = self.last_accepted_ms
200            && at_ms.saturating_sub(last) < self.timing.debounce_ms
201        {
202            return false;
203        }
204        self.last_accepted_ms = Some(at_ms);
205        true
206    }
207}
208
209impl WristRawInput {
210    fn at_ms(&self) -> u64 {
211        match self {
212            WristRawInput::Button { at_ms, .. }
213            | WristRawInput::Crown { at_ms, .. }
214            | WristRawInput::Tap { at_ms, .. }
215            | WristRawInput::Raise { at_ms, .. }
216            | WristRawInput::Touch { at_ms, .. } => *at_ms,
217        }
218    }
219}
220
221fn has_any_input<C: WristInputCapabilities + ?Sized>(profile: &C, names: &[&str]) -> bool {
222    names.iter().any(|name| profile.has_input(name))
223}
224
225fn tap_pattern(origin: Origin, count: u8, target: Expr) -> Option<Expr> {
226    match count {
227        1 => Some(select_one(origin, target)),
228        2 => Some(invoke(origin, target, "double-tap", vec![])),
229        3 => Some(intent(
230            "edit",
231            origin,
232            vec![("target", target), ("path", Expr::List(Vec::new()))],
233        )),
234        _ => None,
235    }
236}
237
238fn select_one(origin: Origin, target: Expr) -> Expr {
239    intent(
240        "select",
241        origin,
242        vec![("targets", Expr::List(vec![target]))],
243    )
244}
245
246fn invoke(origin: Origin, target: Expr, op: &str, args: Vec<Expr>) -> Expr {
247    intent(
248        "invoke",
249        origin,
250        vec![
251            ("target", target),
252            ("op", Expr::Symbol(Symbol::qualified("watch/input", op))),
253            ("args", Expr::List(args)),
254        ],
255    )
256}
257
258fn move_selection(origin: Origin, target: Expr, delta: i32) -> Expr {
259    intent(
260        "move",
261        origin,
262        vec![
263            ("node", target),
264            (
265                "at",
266                build::map(vec![("selection-delta", build::float(f64::from(delta)))]),
267            ),
268        ],
269    )
270}