frust_shell_common/resample.rs
1//! Pointer-event resampling + deadline-aware pacing helpers shared by the
2//! mobile shells.
3//!
4//! # What lives here
5//!
6//! - [`PointerResampler`] — a pure-logic, host-testable buffer of raw pointer
7//! samples (logical coords + a shell-supplied monotonic timestamp) that emits
8//! an interpolated `Move` position at each frame boundary
9//! (`frame_time − `[`SAMPLE_OFFSET_NANOS`]) with a Flutter-parity
10//! [half-frame prediction window](PREDICTION_WINDOW_NANOS). `Down`/`Up`/
11//! `Cancel` phase transitions pass through **losslessly** — never synthesized,
12//! never dropped, never repositioned — so only `Move` positions are ever
13//! resampled (Flutter's `PointerEventResampler` contract, adapted). Samples
14//! are kept in one lane per [`PointerId`](frust_core::event::PointerId), so
15//! simultaneous contacts are each resampled along their own path and never
16//! mix.
17//! - [`frame_interval_nanos`] / [`deadline_overrun`] — the deadline-aware
18//! scheduling helpers: estimate a frame-target budget from the
19//! tick-to-tick timestamp delta, and decide whether a frame's measured work
20//! overran it. **Instrumentation only** — no work-dropping heuristics live
21//! here.
22//!
23//! # Layering choice
24//!
25//! Like [`crate::frame_gate`] and [`crate::perf`], this is shell-owned by
26//! design and lives in `frust-shell-common`: it is platform-agnostic, contains
27//! no `unsafe`, no FFI, and no clock read of its own — every timestamp is
28//! handed in by the shell (which owns the monotonic clock), keeping this whole
29//! module deterministically unit-testable on the host. It compiles unchanged on
30//! every target (host / `aarch64-linux-android` / iOS), preserving the crate's
31//! zero-`unsafe`, compiles-everywhere charter (see `docs/ARCHITECTURE.md`'s
32//! Layer Dependencies).
33//!
34//! # Clock domain
35//!
36//! The resampler is domain-agnostic: it only ever *differences* two timestamps,
37//! so a shell may stamp both the raw samples ([`PointerResampler::push`]) and
38//! the per-frame sample query ([`PointerResampler::resample`]) from any single
39//! monotonic source of its choosing, as long as **both come from the same
40//! source**. The mobile shells use a per-handle `Instant` epoch for this
41//! (decoupled from the vsync `FrameTime` clock that drives animation), so a
42//! sample stamped at touch arrival and the frame's sample-time are always
43//! comparable.
44
45use std::collections::VecDeque;
46use std::time::Duration;
47
48use frust_core::event::{PointerButton, PointerEvent, PointerId, PointerPhase};
49use kurbo::Point;
50
51/// The kill-switch environment/compile-time variable: when set to any
52/// non-`"0"` value, [`PointerResampler::new`] yields a **disabled** resampler
53/// that delivers every raw sample straight through in arrival order (pre-
54/// resampling behavior verbatim). Mirrors
55/// [`FRUST_NO_FRAME_GATE`](crate::frame_gate::NO_FRAME_GATE_VAR)'s compile-time-
56/// or-runtime parsing exactly.
57pub const NO_RESAMPLE_VAR: &str = "FRUST_NO_RESAMPLE";
58
59/// How far behind the frame deadline pointer positions are sampled, in
60/// nanoseconds: a `Move` is emitted at `frame_time − SAMPLE_OFFSET`, slightly
61/// in the past so the two raw samples bracketing that instant are usually
62/// already in hand (**interpolation**, not extrapolation) at the common
63/// touch/display cadence.
64///
65/// **Community-approximate** (see `docs/CODE_STANDARDS.md`): Flutter's
66/// `GestureBinding` resamples at a negative `samplingOffset`, but the exact
67/// default has drifted across engine versions and is not a published constant.
68/// ~5ms is the modest interpolate-slightly-in-the-past value community
69/// reimplementations converge on — small enough to keep input latency
70/// imperceptible, large enough to bracket a newer sample most frames.
71pub const SAMPLE_OFFSET_NANOS: u64 = 5_000_000;
72
73/// The forward-prediction clamp, in nanoseconds: when the sample instant runs
74/// *past* the newest buffered sample (the finger paused, or its samples lag the
75/// display), the position is extrapolated along the last segment's velocity but
76/// never more than this far ahead of the newest sample.
77///
78/// **Community-approximate**: Flutter caps pointer prediction at roughly one
79/// half-refresh window to keep a paused/again-moving finger from overshooting;
80/// half of a 60Hz frame (~8.33ms) is that Flutter-parity half-frame window.
81pub const PREDICTION_WINDOW_NANOS: u64 = 8_333_333;
82
83/// Fallback frame-target interval (60Hz) used by [`frame_interval_nanos`] when
84/// there is no prior tick or the tick-to-tick delta is implausible.
85pub const DEFAULT_REFRESH_INTERVAL_NANOS: u64 = 16_666_667;
86
87/// Lower plausibility bound for a tick-to-tick interval (1ms ≈ a 1000Hz
88/// ceiling): a smaller delta is treated as a clock glitch and replaced by
89/// [`DEFAULT_REFRESH_INTERVAL_NANOS`].
90pub const MIN_PLAUSIBLE_INTERVAL_NANOS: u64 = 1_000_000;
91
92/// Upper plausibility bound for a tick-to-tick interval (100ms ≈ a 10Hz floor):
93/// a larger delta (a long idle across skipped ticks, a resumed app) is treated
94/// as non-representative and replaced by [`DEFAULT_REFRESH_INTERVAL_NANOS`].
95pub const MAX_PLAUSIBLE_INTERVAL_NANOS: u64 = 100_000_000;
96
97/// One raw pointer contact as delivered by a platform touch entry point, before
98/// resampling: which contact it is, the phase transition, the **logical**
99/// (density-independent) position the shell already converted, the button
100/// (always [`PointerButton::Primary`] for touch), and a shell-supplied monotonic
101/// timestamp (see the module's *Clock domain* note).
102#[derive(Debug, Clone, Copy, PartialEq)]
103pub struct RawPointerSample {
104 /// Which contact the sample belongs to — selects its resampling lane.
105 pub pointer_id: PointerId,
106 pub phase: PointerPhase,
107 pub position: Point,
108 pub button: PointerButton,
109 pub time_nanos: u64,
110}
111
112impl RawPointerSample {
113 /// The raw sample as a [`PointerEvent`] with its reported position (the
114 /// verbatim form used on the disabled/direct-delivery path and for phase
115 /// transitions).
116 fn as_event(&self) -> PointerEvent {
117 PointerEvent {
118 phase: self.phase,
119 position: self.position,
120 button: self.button,
121 }
122 }
123}
124
125/// One resampled event and the contact it belongs to — what
126/// [`PointerResampler::resample`] emits, so the shell can rebuild the
127/// [`InputEvent::PointerContact`](frust_core::event::InputEvent::PointerContact)
128/// carrier for it.
129#[derive(Debug, Clone, Copy, PartialEq)]
130pub struct ResampledPointer {
131 /// The contact the event belongs to.
132 pub pointer_id: PointerId,
133 /// The (possibly resampled) event.
134 pub event: PointerEvent,
135}
136
137/// A buffered raw sample plus its global arrival sequence number — the
138/// tiebreak that keeps the merged output of several lanes in arrival order.
139#[derive(Debug, Clone, Copy)]
140struct Queued {
141 seq: u64,
142 sample: RawPointerSample,
143}
144
145/// One contact's resampling state: its own buffered samples and the position it
146/// last emitted. Lanes never share samples or positions, so two fingers moving
147/// at once are each interpolated along their own path.
148#[derive(Debug)]
149struct Lane {
150 pointer_id: PointerId,
151 /// This contact's raw samples in arrival (== timestamp) order, drained up to
152 /// each frame's sample instant.
153 queue: VecDeque<Queued>,
154 /// The position of the last event this lane emitted, so a `Move`
155 /// interpolation dedups a no-op re-emit and a query with no bracketing pair
156 /// can hold the pointer where it was. Cleared to `None` on an `Up`/`Cancel`
157 /// (the contact ended — no position to hold), which is also what lets the
158 /// lane be retired once its queue is empty.
159 last_emitted: Option<Point>,
160}
161
162/// One emitted event awaiting the cross-lane merge, keyed by when it happened:
163/// `(time, seq)` of the raw sample it came from (for a coalesced `Move`, the
164/// last move sample of its run).
165type Keyed = ((u64, u64), ResampledPointer);
166
167/// Buffers raw pointer samples and emits frame-boundary-resampled events, one
168/// independent **lane per [`PointerId`]**. See the module docs for the
169/// interpolation/prediction contract; construct one per app handle and drive it
170/// from the shell's touch and frame paths.
171///
172/// A lane opens with its contact's first sample and ends once its `Up`/`Cancel`
173/// has been emitted. Each lane resamples exactly as a single-pointer resampler
174/// would; the events of several lanes are merged back into arrival order.
175#[derive(Debug)]
176pub struct PointerResampler {
177 /// When `false`, [`resample`](Self::resample) drains every buffered sample
178 /// verbatim in arrival order — the [`NO_RESAMPLE_VAR`] kill switch and
179 /// [`disabled`](Self::disabled) path (pre-resampling behavior verbatim).
180 enabled: bool,
181 /// The live lanes, in the order their contacts first appeared.
182 lanes: Vec<Lane>,
183 /// The next arrival sequence number [`push`](Self::push) hands out.
184 next_seq: u64,
185 /// The lanes' keyed output before the merge, reused across frames (cleared,
186 /// not reallocated) so a drag's per-frame resample allocates nothing.
187 merge: Vec<Keyed>,
188}
189
190impl PointerResampler {
191 /// A resampler honoring the [`NO_RESAMPLE_VAR`] kill switch — what every
192 /// shell constructs. When the variable is set (compile-time `--define` or
193 /// runtime env, any non-`"0"` value), this is equivalent to
194 /// [`disabled`](Self::disabled).
195 pub fn new() -> Self {
196 Self::with_enabled(!kill_switch_engaged())
197 }
198
199 /// A resampler that always delivers raw samples straight through — the
200 /// explicit disabled/kill-switch form (and a test seam bypassing the env
201 /// read). Mirrors [`Self::new`]'s behavior when [`NO_RESAMPLE_VAR`] is set.
202 pub fn disabled() -> Self {
203 Self::with_enabled(false)
204 }
205
206 /// Construct with an explicit enabled flag, bypassing the env read — the
207 /// test/advanced seam (mirrors [`crate::frame_gate::FrameGate::with_enabled`]).
208 pub fn with_enabled(enabled: bool) -> Self {
209 Self {
210 enabled,
211 lanes: Vec::new(),
212 next_seq: 0,
213 merge: Vec::new(),
214 }
215 }
216
217 /// Whether resampling is active. `false` for a [`disabled`](Self::disabled)
218 /// resampler or when the kill switch is engaged — in which case the shell
219 /// should deliver touches directly rather than buffering them here.
220 pub fn is_enabled(&self) -> bool {
221 self.enabled
222 }
223
224 /// Whether any raw sample is still buffered, in any lane. The shell ORs
225 /// this into its frame-gate input (`events_since_last_frame`) so a frame
226 /// that could not yet drain a too-new sample still runs on the next tick —
227 /// the "pending buffered input never starves the gate" contract (see
228 /// `docs/CODE_STANDARDS.md`'s default-to-run rule).
229 pub fn has_pending(&self) -> bool {
230 self.lanes.iter().any(|lane| !lane.queue.is_empty())
231 }
232
233 /// Buffer one raw platform sample in its contact's lane (a touch entry
234 /// point calls this per contact). Each contact's samples must be pushed in
235 /// nondecreasing timestamp order (the natural arrival order of one
236 /// pointer's stream).
237 pub fn push(&mut self, sample: RawPointerSample) {
238 let seq = self.next_seq;
239 self.next_seq = self.next_seq.wrapping_add(1);
240 let queued = Queued { seq, sample };
241 match self
242 .lanes
243 .iter_mut()
244 .find(|lane| lane.pointer_id == sample.pointer_id)
245 {
246 Some(lane) => lane.queue.push_back(queued),
247 None => self.lanes.push(Lane {
248 pointer_id: sample.pointer_id,
249 queue: VecDeque::from([queued]),
250 last_emitted: None,
251 }),
252 }
253 }
254
255 /// Drain the buffered samples up to this frame's sample instant into `out`,
256 /// appending the resampled events — each tagged with its contact — that the
257 /// shell should feed into the tree this frame, in order. `out` is appended
258 /// to, not cleared — the caller owns/reuses the buffer.
259 ///
260 /// `frame_time_nanos` is the frame's sample query time in the shell's chosen
261 /// monotonic domain (the same domain [`push`](Self::push) stamped with).
262 ///
263 /// On a **disabled** resampler every buffered sample is emitted verbatim in
264 /// arrival order (direct delivery). On an enabled one, each lane
265 /// independently:
266 /// - emits `Down`/`Up`/`Cancel` whose timestamp has reached the sample
267 /// instant **losslessly** in order, each at its raw reported position;
268 /// - coalesces a run of `Move` samples up to the sample instant into a
269 /// single `Move` at the position interpolated at
270 /// `frame_time − `[`SAMPLE_OFFSET_NANOS`] (or extrapolated within the
271 /// [`PREDICTION_WINDOW_NANOS`] clamp when the finger has outrun its
272 /// samples);
273 /// - leaves samples still ahead of the sample instant buffered (see
274 /// [`has_pending`](Self::has_pending)).
275 ///
276 /// The lanes' events are then merged by the arrival order of the samples
277 /// they came from, so a second finger's `Down` lands after the first
278 /// finger's `Down` that preceded it. A lane whose `Up`/`Cancel` was emitted
279 /// and that has nothing left buffered is retired.
280 pub fn resample(&mut self, frame_time_nanos: u64, out: &mut Vec<ResampledPointer>) {
281 if !self.enabled {
282 // Verbatim, in arrival order across every lane.
283 self.merge.clear();
284 for lane in &mut self.lanes {
285 self.merge.extend(lane.queue.drain(..).map(|queued| {
286 (
287 (0, queued.seq),
288 ResampledPointer {
289 pointer_id: queued.sample.pointer_id,
290 event: queued.sample.as_event(),
291 },
292 )
293 }));
294 }
295 self.merge.sort_by_key(|(key, _)| *key);
296 out.extend(self.merge.drain(..).map(|(_, event)| event));
297 self.lanes.clear();
298 return;
299 }
300
301 let sample_time = frame_time_nanos.saturating_sub(SAMPLE_OFFSET_NANOS);
302 self.merge.clear();
303 for lane in &mut self.lanes {
304 lane.resample(sample_time, &mut self.merge);
305 }
306 // Stable, and each lane's own events are already in key order, so this
307 // only interleaves lanes — it never reorders within one (and a single
308 // lane, every gesture today, comes out exactly as it went in).
309 if self.lanes.len() > 1 {
310 self.merge.sort_by_key(|(key, _)| *key);
311 }
312 out.extend(self.merge.drain(..).map(|(_, event)| event));
313 self.lanes
314 .retain(|lane| !lane.queue.is_empty() || lane.last_emitted.is_some());
315 }
316}
317
318impl Lane {
319 /// This lane's half of [`PointerResampler::resample`]: drain its samples up
320 /// to `sample_time` into `out`, each keyed for the cross-lane merge.
321 fn resample(&mut self, sample_time: u64, out: &mut Vec<Keyed>) {
322 // Position at the sample instant, computed from the pre-drain queue
323 // snapshot so every coalesced Move this frame lands on the same point.
324 let sample_pos = self.position_at(sample_time);
325
326 // The last move sample of the run being coalesced, if any.
327 let mut pending_move: Option<Queued> = None;
328
329 // Copy the front's timestamp out so the immutable `front()` borrow
330 // ends before the body pops/mutates.
331 while let Some(time_nanos) = self.queue.front().map(|q| q.sample.time_nanos) {
332 if time_nanos > sample_time {
333 break; // not yet reached — leave it (and everything after) buffered
334 }
335 let queued = self.queue.pop_front().expect("front was just observed");
336 match queued.sample.phase {
337 PointerPhase::Move => {
338 pending_move = Some(queued);
339 }
340 PointerPhase::Down => {
341 // A transition ends any coalesced Move run before it (a
342 // well-formed stream never nests one, but keep ordering
343 // lossless regardless).
344 self.flush_move(pending_move.take(), sample_pos, out);
345 self.emit(queued, out);
346 self.last_emitted = Some(queued.sample.position);
347 }
348 PointerPhase::Up | PointerPhase::Cancel => {
349 // Bring the pointer to its resampled position first, then
350 // lift/cancel at the raw reported position.
351 self.flush_move(pending_move.take(), sample_pos, out);
352 self.emit(queued, out);
353 self.last_emitted = None; // contact ended — nothing to hold
354 }
355 }
356 }
357
358 // Trailing coalesced Moves become one interpolated Move at the sample
359 // position.
360 self.flush_move(pending_move, sample_pos, out);
361 }
362
363 /// Emit one raw transition verbatim.
364 fn emit(&self, queued: Queued, out: &mut Vec<Keyed>) {
365 out.push((
366 (queued.sample.time_nanos, queued.seq),
367 ResampledPointer {
368 pointer_id: self.pointer_id,
369 event: queued.sample.as_event(),
370 },
371 ));
372 }
373
374 /// Emit the single coalesced `Move` for a run of buffered move samples
375 /// (`last` is the run's final sample), at the frame's resampled position —
376 /// skipped when there was no move, no resolvable position, or the position
377 /// is unchanged from the last emit.
378 fn flush_move(
379 &mut self,
380 last: Option<Queued>,
381 sample_pos: Option<Point>,
382 out: &mut Vec<Keyed>,
383 ) {
384 let Some(last) = last else {
385 return;
386 };
387 if let Some(pos) = sample_pos
388 && self.last_emitted != Some(pos)
389 {
390 out.push((
391 (last.sample.time_nanos, last.seq),
392 ResampledPointer {
393 pointer_id: self.pointer_id,
394 event: PointerEvent {
395 phase: PointerPhase::Move,
396 position: pos,
397 button: last.sample.button,
398 },
399 },
400 ));
401 self.last_emitted = Some(pos);
402 }
403 }
404
405 /// The interpolated (or clamped-extrapolated) pointer position at
406 /// `sample_time`, from the current queue snapshot. `None` only when there is
407 /// no sample and no prior emit to hold onto.
408 fn position_at(&self, sample_time: u64) -> Option<Point> {
409 if self.queue.is_empty() {
410 return self.last_emitted;
411 }
412
413 // The last sample at/before the instant, and the first strictly after.
414 let mut before: Option<&RawPointerSample> = None;
415 let mut after: Option<&RawPointerSample> = None;
416 for queued in &self.queue {
417 let sample = &queued.sample;
418 if sample.time_nanos <= sample_time {
419 before = Some(sample);
420 } else {
421 after = Some(sample);
422 break;
423 }
424 }
425
426 match (before, after) {
427 // Bracketed: linear interpolation between the two.
428 (Some(a), Some(b)) => Some(lerp_point(
429 a.position,
430 b.position,
431 fraction(a.time_nanos, b.time_nanos, sample_time),
432 )),
433 // The instant is past the newest sample: predict forward, clamped.
434 (Some(a), None) => Some(self.predict_forward(a, sample_time)),
435 // The instant precedes the first sample: hold at the last emit (or
436 // the first sample if nothing was ever emitted).
437 (None, Some(b)) => self.last_emitted.or(Some(b.position)),
438 (None, None) => self.last_emitted,
439 }
440 }
441
442 /// Extrapolate past `newest` along the last segment's velocity, clamped so
443 /// the prediction never runs more than [`PREDICTION_WINDOW_NANOS`] ahead of
444 /// `newest`. Falls back to holding at `newest.position` when there is no
445 /// prior sample to derive a velocity from (a single-sample queue).
446 fn predict_forward(&self, newest: &RawPointerSample, sample_time: u64) -> Point {
447 // The sample immediately before `newest` (the second-to-last element).
448 match self.queue.iter().rev().nth(1).map(|queued| &queued.sample) {
449 Some(prior) if newest.time_nanos > prior.time_nanos => {
450 let ahead = (sample_time - newest.time_nanos).min(PREDICTION_WINDOW_NANOS);
451 let span = newest.time_nanos - prior.time_nanos;
452 let t = ahead as f64 / span as f64;
453 Point::new(
454 newest.position.x + (newest.position.x - prior.position.x) * t,
455 newest.position.y + (newest.position.y - prior.position.y) * t,
456 )
457 }
458 _ => newest.position,
459 }
460 }
461}
462
463impl Default for PointerResampler {
464 fn default() -> Self {
465 Self::new()
466 }
467}
468
469/// The normalized position of `t` within `[a, b]` (`0.0` at `a`, `1.0` at `b`),
470/// guarding a zero-width span (two samples at the same timestamp) by returning
471/// `1.0` so the later sample wins.
472fn fraction(a: u64, b: u64, t: u64) -> f64 {
473 let span = b.saturating_sub(a);
474 if span == 0 {
475 return 1.0;
476 }
477 (t.saturating_sub(a)) as f64 / span as f64
478}
479
480/// Linear interpolation between two points at normalized `t`.
481fn lerp_point(a: Point, b: Point, t: f64) -> Point {
482 Point::new(a.x + (b.x - a.x) * t, a.y + (b.y - a.y) * t)
483}
484
485/// Estimate this frame's deadline budget (the frame-target interval) from two
486/// consecutive tick timestamps. Returns the tick-to-tick
487/// delta when it is plausible (`[`[`MIN_PLAUSIBLE_INTERVAL_NANOS`]`,
488/// `[`MAX_PLAUSIBLE_INTERVAL_NANOS`]`]`), else [`DEFAULT_REFRESH_INTERVAL_NANOS`]
489/// (60Hz) — covering the first tick (no prior), a clock glitch, and a long idle
490/// across skipped ticks.
491pub fn frame_interval_nanos(prev_tick: Option<u64>, cur_tick: u64) -> u64 {
492 match prev_tick {
493 Some(prev) if cur_tick > prev => {
494 let delta = cur_tick - prev;
495 if (MIN_PLAUSIBLE_INTERVAL_NANOS..=MAX_PLAUSIBLE_INTERVAL_NANOS).contains(&delta) {
496 delta
497 } else {
498 DEFAULT_REFRESH_INTERVAL_NANOS
499 }
500 }
501 _ => DEFAULT_REFRESH_INTERVAL_NANOS,
502 }
503}
504
505/// Whether a frame's measured `work` overran its `budget_nanos` deadline.
506/// **Instrumentation only** — the shell records the overrun
507/// (a counter, gated behind `perf::enabled()`); it never drops or reshapes work
508/// on the strength of this.
509pub fn deadline_overrun(work: Duration, budget_nanos: u64) -> bool {
510 (work.as_nanos() as u64) > budget_nanos
511}
512
513/// Reads the [`NO_RESAMPLE_VAR`] kill switch from the compile-time define and
514/// the process environment, mirroring [`crate::frame_gate`]'s
515/// `kill_switch_engaged`: either source set to a non-`"0"` value engages it.
516fn kill_switch_engaged() -> bool {
517 kill_switch(
518 option_env!("FRUST_NO_RESAMPLE"),
519 std::env::var(NO_RESAMPLE_VAR).ok().as_deref(),
520 )
521}
522
523/// The pure decision [`kill_switch_engaged`] wraps: a non-empty, non-`"0"`
524/// value from either the compile-time or runtime source engages the switch.
525/// Split out so it is directly unit-testable without touching the process
526/// environment (see [`crate::frame_gate`]'s `kill_switch`).
527fn kill_switch(compile_time: Option<&str>, runtime: Option<&str>) -> bool {
528 fn is_set_non_zero(value: Option<&str>) -> bool {
529 matches!(value, Some(v) if v != "0")
530 }
531 is_set_non_zero(compile_time) || is_set_non_zero(runtime)
532}
533
534#[cfg(test)]
535mod tests {
536 use super::*;
537
538 fn sample(phase: PointerPhase, x: f64, y: f64, time_nanos: u64) -> RawPointerSample {
539 sample_for(PointerId::touch(0), phase, x, y, time_nanos)
540 }
541
542 fn sample_for(
543 pointer_id: PointerId,
544 phase: PointerPhase,
545 x: f64,
546 y: f64,
547 time_nanos: u64,
548 ) -> RawPointerSample {
549 RawPointerSample {
550 pointer_id,
551 phase,
552 position: Point::new(x, y),
553 button: PointerButton::Primary,
554 time_nanos,
555 }
556 }
557
558 /// A frame time whose sample instant (`frame_time − SAMPLE_OFFSET`) is
559 /// exactly `sample_time` — the tests reason in sample-instant terms.
560 fn frame_time_for(sample_time: u64) -> u64 {
561 sample_time + SAMPLE_OFFSET_NANOS
562 }
563
564 /// The resampled events of one frame, with their contact ids.
565 fn drain_tagged(resampler: &mut PointerResampler, sample_time: u64) -> Vec<ResampledPointer> {
566 let mut out = Vec::new();
567 resampler.resample(frame_time_for(sample_time), &mut out);
568 out
569 }
570
571 /// The resampled events of one frame — the single-contact tests' view.
572 fn drain(resampler: &mut PointerResampler, sample_time: u64) -> Vec<PointerEvent> {
573 drain_tagged(resampler, sample_time)
574 .into_iter()
575 .map(|r| r.event)
576 .collect()
577 }
578
579 // -----------------------------------------------------------------
580 // kill_switch (pure) — mirrors frame_gate's coverage
581 // -----------------------------------------------------------------
582
583 #[test]
584 fn kill_switch_off_when_neither_set() {
585 assert!(!kill_switch(None, None));
586 }
587
588 #[test]
589 fn kill_switch_on_when_either_source_wins() {
590 assert!(kill_switch(Some("1"), None));
591 assert!(kill_switch(None, Some("1")));
592 assert!(kill_switch(Some("0"), Some("1")));
593 assert!(kill_switch(Some("1"), Some("0")));
594 }
595
596 #[test]
597 fn kill_switch_off_when_either_is_literal_zero_and_other_unset() {
598 assert!(!kill_switch(Some("0"), None));
599 assert!(!kill_switch(None, Some("0")));
600 }
601
602 // -----------------------------------------------------------------
603 // Kill-switch off path: direct verbatim delivery
604 // -----------------------------------------------------------------
605
606 #[test]
607 fn disabled_resampler_delivers_every_sample_verbatim_in_order() {
608 let mut r = PointerResampler::disabled();
609 assert!(!r.is_enabled());
610 r.push(sample(PointerPhase::Down, 1.0, 2.0, 0));
611 r.push(sample(PointerPhase::Move, 3.0, 4.0, 5));
612 r.push(sample(PointerPhase::Up, 5.0, 6.0, 10));
613
614 // Sample instant is irrelevant when disabled — everything drains.
615 let out = drain(&mut r, 0);
616 assert_eq!(out.len(), 3);
617 assert_eq!(out[0].phase, PointerPhase::Down);
618 assert_eq!(out[0].position, Point::new(1.0, 2.0));
619 assert_eq!(out[1].phase, PointerPhase::Move);
620 assert_eq!(out[1].position, Point::new(3.0, 4.0));
621 assert_eq!(out[2].phase, PointerPhase::Up);
622 assert_eq!(out[2].position, Point::new(5.0, 6.0));
623 assert!(!r.has_pending());
624 }
625
626 // -----------------------------------------------------------------
627 // Interpolation math
628 // -----------------------------------------------------------------
629
630 #[test]
631 fn move_position_interpolated_between_bracketing_samples() {
632 let mut r = PointerResampler::with_enabled(true);
633 // Two moves 10ms apart along x; sample the midpoint (5ms).
634 r.push(sample(PointerPhase::Move, 0.0, 0.0, 0));
635 r.push(sample(PointerPhase::Move, 10.0, 0.0, 10_000_000));
636
637 let out = drain(&mut r, 5_000_000);
638 assert_eq!(out.len(), 1);
639 assert_eq!(out[0].phase, PointerPhase::Move);
640 assert_eq!(out[0].position, Point::new(5.0, 0.0));
641 // The later sample is still ahead of the instant → buffered.
642 assert!(r.has_pending());
643 }
644
645 #[test]
646 fn interpolation_fraction_is_time_weighted() {
647 let mut r = PointerResampler::with_enabled(true);
648 r.push(sample(PointerPhase::Move, 0.0, 0.0, 0));
649 r.push(sample(PointerPhase::Move, 100.0, 40.0, 10_000_000));
650 // 25% of the way through the segment.
651 let out = drain(&mut r, 2_500_000);
652 assert_eq!(out[0].position, Point::new(25.0, 10.0));
653 }
654
655 // -----------------------------------------------------------------
656 // Phase-transition passthrough (lossless, raw positions)
657 // -----------------------------------------------------------------
658
659 #[test]
660 fn down_move_up_pass_transitions_through_losslessly() {
661 let mut r = PointerResampler::with_enabled(true);
662 r.push(sample(PointerPhase::Down, 0.0, 0.0, 0));
663 r.push(sample(PointerPhase::Move, 4.0, 0.0, 4_000_000));
664 r.push(sample(PointerPhase::Up, 8.0, 0.0, 8_000_000));
665
666 // Sample instant reaches the Up.
667 let out = drain(&mut r, 8_000_000);
668 let phases: Vec<PointerPhase> = out.iter().map(|e| e.phase).collect();
669 assert_eq!(
670 phases,
671 vec![PointerPhase::Down, PointerPhase::Move, PointerPhase::Up]
672 );
673 // Down and Up keep their raw positions (only Move is resampled).
674 assert_eq!(out.first().unwrap().position, Point::new(0.0, 0.0));
675 assert_eq!(out.last().unwrap().position, Point::new(8.0, 0.0));
676 assert!(!r.has_pending());
677 }
678
679 #[test]
680 fn cancel_passes_through_and_ends_the_gesture() {
681 let mut r = PointerResampler::with_enabled(true);
682 r.push(sample(PointerPhase::Down, 1.0, 1.0, 0));
683 r.push(sample(PointerPhase::Cancel, 1.0, 1.0, 2_000_000));
684 let out = drain(&mut r, 2_000_000);
685 assert_eq!(out.len(), 2);
686 assert_eq!(out[1].phase, PointerPhase::Cancel);
687 assert_eq!(out[1].position, Point::new(1.0, 1.0));
688 assert!(!r.has_pending());
689 }
690
691 #[test]
692 fn too_new_transition_stays_buffered_until_its_instant_arrives() {
693 let mut r = PointerResampler::with_enabled(true);
694 r.push(sample(PointerPhase::Down, 0.0, 0.0, 0));
695 r.push(sample(PointerPhase::Up, 0.0, 0.0, 10_000_000));
696
697 // Instant only reaches the Down — the Up is not yet due, never dropped.
698 let out = drain(&mut r, 0);
699 assert_eq!(out.len(), 1);
700 assert_eq!(out[0].phase, PointerPhase::Down);
701 assert!(r.has_pending());
702
703 // A later frame whose instant reaches the Up delivers it.
704 let out = drain(&mut r, 10_000_000);
705 assert_eq!(out.len(), 1);
706 assert_eq!(out[0].phase, PointerPhase::Up);
707 assert!(!r.has_pending());
708 }
709
710 // -----------------------------------------------------------------
711 // Prediction clamp
712 // -----------------------------------------------------------------
713
714 #[test]
715 fn prediction_extrapolates_within_the_window() {
716 let mut r = PointerResampler::with_enabled(true);
717 // 10px over 10ms → 1px/ms. Sample 4ms past the newest → +4px (< window).
718 r.push(sample(PointerPhase::Move, 0.0, 0.0, 0));
719 r.push(sample(PointerPhase::Move, 10.0, 0.0, 10_000_000));
720 let out = drain(&mut r, 14_000_000);
721 assert_eq!(out.len(), 1);
722 assert_eq!(out[0].position, Point::new(14.0, 0.0));
723 assert!(!r.has_pending(), "both samples were at/before the instant");
724 }
725
726 #[test]
727 fn prediction_is_clamped_to_the_half_frame_window() {
728 let mut r = PointerResampler::with_enabled(true);
729 // 10px/10ms again, but sample far past the newest (50ms ahead): the
730 // extrapolation clamps at PREDICTION_WINDOW_NANOS (~8.33ms → +8.33px),
731 // NOT the full 50px an unclamped predictor would give.
732 r.push(sample(PointerPhase::Move, 0.0, 0.0, 0));
733 r.push(sample(PointerPhase::Move, 10.0, 0.0, 10_000_000));
734 let out = drain(&mut r, 60_000_000);
735 assert_eq!(out.len(), 1);
736 let predicted_x = out[0].position.x;
737 let expected = 10.0 + PREDICTION_WINDOW_NANOS as f64 / 1_000_000.0;
738 assert!(
739 (predicted_x - expected).abs() < 1e-6,
740 "clamped prediction {predicted_x} should be {expected}"
741 );
742 }
743
744 // -----------------------------------------------------------------
745 // Empty / one-sample edge cases
746 // -----------------------------------------------------------------
747
748 #[test]
749 fn empty_queue_resamples_to_nothing() {
750 let mut r = PointerResampler::with_enabled(true);
751 let out = drain(&mut r, 1_000_000);
752 assert!(out.is_empty());
753 assert!(!r.has_pending());
754 }
755
756 #[test]
757 fn single_move_sample_holds_its_position() {
758 let mut r = PointerResampler::with_enabled(true);
759 // One move; the instant is past it → predict_forward with no prior
760 // sample holds at the sample's own position.
761 r.push(sample(PointerPhase::Move, 7.0, 3.0, 0));
762 let out = drain(&mut r, 5_000_000);
763 assert_eq!(out.len(), 1);
764 assert_eq!(out[0].position, Point::new(7.0, 3.0));
765 }
766
767 #[test]
768 fn move_before_first_sample_instant_stays_buffered() {
769 let mut r = PointerResampler::with_enabled(true);
770 // The only sample is newer than the instant → nothing drains yet.
771 r.push(sample(PointerPhase::Move, 2.0, 2.0, 10_000_000));
772 let out = drain(&mut r, 0);
773 assert!(out.is_empty());
774 assert!(r.has_pending());
775 }
776
777 #[test]
778 fn stationary_finger_does_not_re_emit_the_same_move() {
779 let mut r = PointerResampler::with_enabled(true);
780 r.push(sample(PointerPhase::Down, 5.0, 5.0, 0));
781 // Two moves at the same position → the coalesced move equals the Down's
782 // recorded position, so no redundant Move is emitted.
783 r.push(sample(PointerPhase::Move, 5.0, 5.0, 2_000_000));
784 r.push(sample(PointerPhase::Move, 5.0, 5.0, 4_000_000));
785 let out = drain(&mut r, 4_000_000);
786 assert_eq!(out.len(), 1, "only the Down; the no-op moves are deduped");
787 assert_eq!(out[0].phase, PointerPhase::Down);
788 }
789
790 // -----------------------------------------------------------------
791 // Per-contact lanes
792 // -----------------------------------------------------------------
793
794 #[test]
795 fn two_interleaved_contacts_resample_in_separate_lanes() {
796 let a = PointerId::touch(0);
797 let b = PointerId::touch(1);
798 let mut r = PointerResampler::with_enabled(true);
799 // Two fingers moving in opposite directions, samples interleaved in
800 // arrival order. A shared lane would interpolate between the two
801 // fingers' positions; separate lanes keep each on its own path.
802 r.push(sample_for(a, PointerPhase::Down, 0.0, 0.0, 0));
803 r.push(sample_for(b, PointerPhase::Down, 100.0, 0.0, 1_000_000));
804 r.push(sample_for(a, PointerPhase::Move, 10.0, 0.0, 10_000_000));
805 r.push(sample_for(b, PointerPhase::Move, 90.0, 0.0, 11_000_000));
806 r.push(sample_for(a, PointerPhase::Move, 20.0, 0.0, 20_000_000));
807 r.push(sample_for(b, PointerPhase::Move, 80.0, 0.0, 21_000_000));
808
809 // Sample at 15ms: lane a interpolates halfway between 10 and 20; lane b
810 // between its own 11ms/21ms samples (90 → 80, 40% of the way).
811 let out = drain_tagged(&mut r, 15_000_000);
812 let ids: Vec<PointerId> = out.iter().map(|e| e.pointer_id).collect();
813 let phases: Vec<PointerPhase> = out.iter().map(|e| e.event.phase).collect();
814 assert_eq!(ids, vec![a, b, a, b], "merged back into arrival order");
815 assert_eq!(
816 phases,
817 vec![
818 PointerPhase::Down,
819 PointerPhase::Down,
820 PointerPhase::Move,
821 PointerPhase::Move
822 ]
823 );
824 assert_eq!(out[0].event.position, Point::new(0.0, 0.0));
825 assert_eq!(out[1].event.position, Point::new(100.0, 0.0));
826 assert_eq!(out[2].event.position, Point::new(15.0, 0.0));
827 let b_x = out[3].event.position.x;
828 assert!((b_x - 86.0).abs() < 1e-9, "lane b moved to {b_x}, not 86");
829 assert!(r.has_pending(), "both lanes still hold a too-new sample");
830 }
831
832 #[test]
833 fn a_lane_ends_on_its_own_up_without_ending_the_other() {
834 let a = PointerId::touch(0);
835 let b = PointerId::touch(1);
836 let mut r = PointerResampler::with_enabled(true);
837 r.push(sample_for(a, PointerPhase::Down, 0.0, 0.0, 0));
838 r.push(sample_for(b, PointerPhase::Down, 50.0, 50.0, 1_000_000));
839 r.push(sample_for(b, PointerPhase::Up, 50.0, 50.0, 2_000_000));
840 let out = drain_tagged(&mut r, 2_000_000);
841 assert_eq!(out.len(), 3);
842 assert_eq!(
843 (out[2].pointer_id, out[2].event.phase),
844 (b, PointerPhase::Up)
845 );
846 assert_eq!(r.lanes.len(), 1, "b's lane retired on its Up; a's lives on");
847 assert_eq!(r.lanes[0].pointer_id, a);
848
849 // Lane a still holds its own position: a later move interpolates from
850 // a's Down, never from b's.
851 r.push(sample_for(a, PointerPhase::Move, 10.0, 0.0, 10_000_000));
852 let out = drain_tagged(&mut r, 10_000_000);
853 assert_eq!(out.len(), 1);
854 assert_eq!(out[0].pointer_id, a);
855 assert_eq!(out[0].event.position, Point::new(10.0, 0.0));
856
857 r.push(sample_for(a, PointerPhase::Cancel, 10.0, 0.0, 12_000_000));
858 let out = drain_tagged(&mut r, 12_000_000);
859 assert_eq!(out[0].event.phase, PointerPhase::Cancel);
860 assert!(r.lanes.is_empty(), "a's lane retired on its Cancel");
861 }
862
863 #[test]
864 fn disabled_resampler_keeps_arrival_order_across_contacts() {
865 let a = PointerId::touch(0);
866 let b = PointerId::touch(1);
867 let mut r = PointerResampler::disabled();
868 r.push(sample_for(a, PointerPhase::Down, 0.0, 0.0, 0));
869 r.push(sample_for(b, PointerPhase::Down, 9.0, 9.0, 1));
870 r.push(sample_for(a, PointerPhase::Move, 1.0, 0.0, 2));
871 r.push(sample_for(b, PointerPhase::Up, 9.0, 9.0, 3));
872 let out = drain_tagged(&mut r, 0);
873 let order: Vec<(PointerId, PointerPhase)> =
874 out.iter().map(|e| (e.pointer_id, e.event.phase)).collect();
875 assert_eq!(
876 order,
877 vec![
878 (a, PointerPhase::Down),
879 (b, PointerPhase::Down),
880 (a, PointerPhase::Move),
881 (b, PointerPhase::Up),
882 ]
883 );
884 assert!(!r.has_pending());
885 }
886
887 // -----------------------------------------------------------------
888 // Deadline helpers
889 // -----------------------------------------------------------------
890
891 #[test]
892 fn frame_interval_uses_plausible_tick_delta() {
893 // A clean 60Hz tick delta passes through.
894 assert_eq!(frame_interval_nanos(Some(0), 16_666_667), 16_666_667);
895 }
896
897 #[test]
898 fn frame_interval_falls_back_on_no_prior_or_implausible_delta() {
899 assert_eq!(
900 frame_interval_nanos(None, 1_000),
901 DEFAULT_REFRESH_INTERVAL_NANOS
902 );
903 // Non-monotonic / equal ticks → fallback.
904 assert_eq!(
905 frame_interval_nanos(Some(100), 100),
906 DEFAULT_REFRESH_INTERVAL_NANOS
907 );
908 // Too small (sub-1ms) and too large (>100ms idle) → fallback.
909 assert_eq!(
910 frame_interval_nanos(Some(0), 500),
911 DEFAULT_REFRESH_INTERVAL_NANOS
912 );
913 assert_eq!(
914 frame_interval_nanos(Some(0), 200_000_000),
915 DEFAULT_REFRESH_INTERVAL_NANOS
916 );
917 }
918
919 #[test]
920 fn deadline_overrun_compares_work_to_budget() {
921 let budget = 16_666_667;
922 assert!(deadline_overrun(Duration::from_millis(20), budget));
923 assert!(!deadline_overrun(Duration::from_millis(10), budget));
924 // Exactly at budget is not an overrun.
925 assert!(!deadline_overrun(Duration::from_nanos(budget), budget));
926 }
927}