Files
punktfunk/crates/punktfunk-core/src/quic/pen.rs
T
enricobuehlerandClaude Fable 5 aca5aa9993 feat(host+inject): pen P1 — per-session uinput virtual tablet, HOST_CAP_PEN live
The Linux injection leg of design/pen-tablet-input.md: 0xCC/0x05 pen batches now
route through the per-session PenTracker into a lazily-created 'Punktfunk Pen'
uinput tablet (BTN_TOOL_PEN/RUBBER, pressure, tilt-from-polar, ABS_Z barrel
roll, hover distance, INPUT_PROP_DIRECT) — compositors pick it up via libinput
and hand apps zwp_tablet_v2 with full fidelity. The host now advertises
HOST_CAP_PEN when /dev/uinput is accessible (PUNKTFUNK_PEN=0 kill-switch);
transitions group into SYN frames so proximity-enter carries its position.
Stroke failsafe: clients heartbeat ≤100ms while in range (documented wire
contract — capture APIs are silent for a stationary pen); 200ms of silence
force-releases. 'punktfunk-host pen-test' draws a pressure-ramped sine stroke
through the real tracker→uinput chain, no client needed.

Co-Authored-By: Claude Fable 5 <noreply@anthropic.com>
2026-07-23 16:18:31 +02:00

692 lines
27 KiB
Rust
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
//! The stylus plane: full-fidelity pen input on the 0xCC rich-input datagram
//! (design/pen-tablet-input.md).
//!
//! A pen datagram is a batch of **state-full samples**: every sample carries the *complete*
//! pen state — in-range/touching/buttons/tool plus all axes — never an edge ("down"/"up")
//! event. The host diffs each sample against its own tracked state ([`PenTracker`]) and
//! synthesizes the transitions, so a lost datagram self-heals on the next sample: a dropped
//! "first contact" batch becomes a tip-down when the next in-contact sample arrives, and a
//! dropped lift heals on the next hover/out-of-range sample (with the
//! [`PEN_TOUCH_TIMEOUT_MS`] failsafe for a client that dies mid-stroke). That is what makes
//! the lossy datagram plane sound for a 240 Hz stylus without any reliable-delivery
//! machinery — the same idempotent-snapshot argument as [`GamepadSnapshot`]
//! (crate::input::GamepadSnapshot) and [`RichInput::HidReport`](super::RichInput).
//!
//! Batches are ordered by a wrapping `u16` sequence number and dropped **whole** when stale
//! ([`pen_seq_newer`]) — applying a stale state-full sample would rewind the stroke.
//!
//! Clients send this only after the host advertised [`HOST_CAP_PEN`](super::HOST_CAP_PEN);
//! a pre-pen host drops the unknown 0xCC kind by the plane's documented forward-compat rule.
use super::datagram::{RICH_INPUT_MAGIC, RICH_PEN};
/// [`PenSample::state`] bit: the pen is in the hover range of the surface. Implied by
/// [`PEN_TOUCHING`] (decode normalizes, so a client that only sets TOUCHING still produces a
/// coherent contact).
pub const PEN_IN_RANGE: u8 = 0x01;
/// [`PenSample::state`] bit: the tip is in contact with the surface.
pub const PEN_TOUCHING: u8 = 0x02;
/// [`PenSample::state`] bit: the primary barrel button (or the client's squeeze mapping) is held.
pub const PEN_BARREL1: u8 = 0x04;
/// [`PenSample::state`] bit: the secondary barrel button (or the client's double-tap mapping)
/// is held.
pub const PEN_BARREL2: u8 = 0x08;
/// [`PenSample::state`] bit, RESERVED: a predicted (not yet observed) sample. Never sent v1;
/// receivers MUST ignore samples carrying it until a capability negotiates otherwise
/// (design/pen-tablet-input.md §8).
pub const PEN_PREDICTED: u8 = 0x80;
/// The button subset of [`PenSample::state`].
const PEN_BUTTONS_MASK: u8 = PEN_BARREL1 | PEN_BARREL2;
/// [`PenSample::tilt_deg`] sentinel: the client has no tilt sensor / no reading.
pub const PEN_TILT_UNKNOWN: u8 = 0xFF;
/// [`PenSample::azimuth_deg`] / [`PenSample::roll_deg`] sentinel: no reading.
pub const PEN_ANGLE_UNKNOWN: u16 = 0xFFFF;
/// [`PenSample::distance`] sentinel: no hover-distance reading.
pub const PEN_DISTANCE_UNKNOWN: u16 = 0xFFFF;
/// Most samples one [`PenBatch`] can carry. Sized for coalesced capture at video-frame cadence
/// (240 Hz pen ÷ 30 fps = 8); a client producing more splits into consecutive batches.
pub const PEN_BATCH_MAX: usize = 8;
/// Wire length of one encoded [`PenSample`].
pub const PEN_SAMPLE_WIRE_LEN: usize = 21;
/// `[0xCC][0x05][flags][count][u16 seq LE]` — bytes before the first sample.
const PEN_HEADER_LEN: usize = 6;
/// Host-side failsafe (design/pen-tablet-input.md §2): a tracker still in range after this
/// many ms without a sample force-releases ([`PenTracker::force_release`]) — a client that
/// died mid-stroke must not leave the host's virtual pen inked-down forever. This makes the
/// **client heartbeat a wire contract**: capture APIs only fire on change, so a stationary
/// pen is naturally silent — senders MUST repeat the last sample at least every ~100 ms while
/// the pen is in range or touching (it re-decodes as pure Motion, harmless), keeping a live
/// stationary stroke two heartbeats clear of the deadline.
pub const PEN_TOUCH_TIMEOUT_MS: u32 = 200;
/// Which end of the stylus (or which mapped mode) a sample describes. A tool *switch* while in
/// range is a physical re-entry — [`PenTracker`] emits a full release + re-proximity, matching
/// how a real tablet treats each tool as its own proximity session.
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
#[repr(u8)]
pub enum PenTool {
#[default]
Pen = 0,
Eraser = 1,
/// An unrecognized wire value (a future tool from a newer client) — injectors treat it as
/// [`PenTool::Pen`]. Inside a proximity session it inherits the session's tool instead of
/// forcing a spurious re-entry.
Unknown = 0xFF,
}
impl PenTool {
fn from_u8(v: u8) -> PenTool {
match v {
0 => PenTool::Pen,
1 => PenTool::Eraser,
_ => PenTool::Unknown,
}
}
}
/// One complete stylus state at one instant. All axes ride every sample (state-full — see the
/// module doc); unknown axes carry their sentinel, never 0.
///
/// `x`/`y` are normalized `0.0..=1.0` in **video-frame space** — the client maps its letterbox
/// / viewport before sending (exactly as its wire touches already do), so the host scales
/// straight to the streamed output. f32 keeps sub-pixel precision at any resolution.
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PenSample {
/// Bitfield of `PEN_*` state bits ([`PEN_IN_RANGE`] … [`PEN_PREDICTED`]).
pub state: u8,
/// Active tool. Meaningful while in range; [`PenTool::Unknown`] inherits (see [`PenTool`]).
pub tool: PenTool,
/// Normalized `0.0..=1.0` across the video frame (decode clamps; see [`PenBatch::decode`]).
pub x: f32,
/// Normalized `0.0..=1.0` across the video frame.
pub y: f32,
/// Tip force, `0..=65535` full scale, `0` while hovering. Injectors rescale (Windows pens
/// are 0..1024, uinput declares its own range) — full u16 keeps every source's precision.
pub pressure: u16,
/// Hover distance, `0..=65534` normalized (0 = touching the hover floor), or
/// [`PEN_DISTANCE_UNKNOWN`].
pub distance: u16,
/// Tilt from the surface normal, degrees `0..=90`, or [`PEN_TILT_UNKNOWN`]. Polar form —
/// what Apple capture and the GameStream wire both produce; injectors needing tiltX/tiltY
/// convert (design/pen-tablet-input.md §2).
pub tilt_deg: u8,
/// Tilt azimuth, degrees `0..=359` clockwise from north, or [`PEN_ANGLE_UNKNOWN`].
pub azimuth_deg: u16,
/// Barrel roll (Apple Pencil Pro `rollAngle`), degrees `0..=359`, or [`PEN_ANGLE_UNKNOWN`].
pub roll_deg: u16,
/// µs since the previous sample in the same batch (`0` for the first) — preserves the
/// coalesced capture spacing for injectors/consumers that pace.
pub dt_us: u16,
}
impl Default for PenSample {
fn default() -> PenSample {
PenSample {
state: 0,
tool: PenTool::Pen,
x: 0.0,
y: 0.0,
pressure: 0,
distance: PEN_DISTANCE_UNKNOWN,
tilt_deg: PEN_TILT_UNKNOWN,
azimuth_deg: PEN_ANGLE_UNKNOWN,
roll_deg: PEN_ANGLE_UNKNOWN,
dt_us: 0,
}
}
}
impl PenSample {
fn encode_into(&self, out: &mut Vec<u8>) {
out.push(self.state);
out.push(self.tool as u8);
out.extend_from_slice(&self.x.to_le_bytes());
out.extend_from_slice(&self.y.to_le_bytes());
out.extend_from_slice(&self.pressure.to_le_bytes());
out.extend_from_slice(&self.distance.to_le_bytes());
out.push(self.tilt_deg);
out.extend_from_slice(&self.azimuth_deg.to_le_bytes());
out.extend_from_slice(&self.roll_deg.to_le_bytes());
out.extend_from_slice(&self.dt_us.to_le_bytes());
}
/// Decode one sample from exactly [`PEN_SAMPLE_WIRE_LEN`] bytes (caller bounds-checks).
/// `None` on a non-finite coordinate — an attacker-forged NaN/∞ must never reach an
/// injector's pixel scaling. Finite out-of-range coordinates clamp to `0.0..=1.0` (a
/// stroke drifting a hair past the letterbox edge is real input, not corruption).
fn decode(b: &[u8]) -> Option<PenSample> {
let f32at = |o: usize| f32::from_le_bytes([b[o], b[o + 1], b[o + 2], b[o + 3]]);
let u16at = |o: usize| u16::from_le_bytes([b[o], b[o + 1]]);
let (x, y) = (f32at(2), f32at(6));
if !x.is_finite() || !y.is_finite() {
return None;
}
Some(PenSample {
state: b[0],
tool: PenTool::from_u8(b[1]),
x: x.clamp(0.0, 1.0),
y: y.clamp(0.0, 1.0),
pressure: u16at(10),
distance: u16at(12),
tilt_deg: b[14],
azimuth_deg: u16at(15),
roll_deg: u16at(17),
dt_us: u16at(19),
})
}
}
/// One pen datagram: `[0xCC][0x05][flags][count][u16 seq LE]` + `count` ×
/// [`PEN_SAMPLE_WIRE_LEN`]-byte samples, oldest first. `flags` is reserved (sent 0, ignored on
/// decode — semantic changes take a new 0xCC kind, never a flag reinterpretation). `seq` is the
/// sender's wrapping batch counter, the reorder gate ([`pen_seq_newer`]).
#[derive(Clone, Copy, Debug, PartialEq)]
pub struct PenBatch {
pub seq: u16,
count: u8,
samples: [PenSample; PEN_BATCH_MAX],
}
impl PenBatch {
/// Build a batch from up to [`PEN_BATCH_MAX`] samples (a longer slice truncates — senders
/// with more coalesced samples split into consecutive batches so nothing is lost).
pub fn new(seq: u16, samples: &[PenSample]) -> PenBatch {
let count = samples.len().min(PEN_BATCH_MAX);
let mut buf = [PenSample::default(); PEN_BATCH_MAX];
buf[..count].copy_from_slice(&samples[..count]);
PenBatch {
seq,
count: count as u8,
samples: buf,
}
}
/// The batch's samples, oldest first.
pub fn samples(&self) -> &[PenSample] {
&self.samples[..self.count as usize]
}
pub fn encode(&self) -> Vec<u8> {
let n = self.count as usize;
let mut out = Vec::with_capacity(PEN_HEADER_LEN + n * PEN_SAMPLE_WIRE_LEN);
out.extend_from_slice(&[RICH_INPUT_MAGIC, RICH_PEN, 0, self.count]);
out.extend_from_slice(&self.seq.to_le_bytes());
for s in self.samples() {
s.encode_into(&mut out);
}
out
}
/// Parse a pen datagram. `None` on bad tag/kind, an empty batch, or a forged coordinate
/// (see [`PenSample::decode`]). Every read is bounded: `count` clamps to the declared
/// value, the fixed maximum, AND what the buffer actually holds — a torn datagram yields
/// the complete samples that arrived, never an over-read (the
/// [`RichInput::HidReport`](super::RichInput) truncation contract).
pub fn decode(b: &[u8]) -> Option<PenBatch> {
if b.len() < PEN_HEADER_LEN || b[0] != RICH_INPUT_MAGIC || b[1] != RICH_PEN {
return None;
}
let count = (b[3] as usize)
.min(PEN_BATCH_MAX)
.min((b.len() - PEN_HEADER_LEN) / PEN_SAMPLE_WIRE_LEN);
if count == 0 {
return None;
}
let mut samples = [PenSample::default(); PEN_BATCH_MAX];
for (i, slot) in samples.iter_mut().enumerate().take(count) {
let o = PEN_HEADER_LEN + i * PEN_SAMPLE_WIRE_LEN;
*slot = PenSample::decode(&b[o..o + PEN_SAMPLE_WIRE_LEN])?;
}
Some(PenBatch {
seq: u16::from_le_bytes([b[4], b[5]]),
count: count as u8,
samples,
})
}
}
/// The batch reorder gate: is `new` strictly newer than `last` on the wrapping u16 circle?
/// `None` (nothing applied yet) always passes. The u16 analog of
/// [`GamepadSnapshot::seq_newer`](crate::input::GamepadSnapshot::seq_newer): newer ⇔ the
/// forward distance is `1..=0x7FFF`, so reordered stale batches drop and a wrap (65535 → 0)
/// still counts as newer.
pub fn pen_seq_newer(new: u16, last: Option<u16>) -> bool {
match last {
None => true,
Some(last) => (new.wrapping_sub(last) as i16) > 0,
}
}
/// One synthesized stroke transition, in the order [`PenTracker`] emits them for a sample:
/// `ProximityIn?` → `Motion` → `TipDown?` → `ButtonsChanged?` → `TipUp?` → `ProximityOut?`.
/// `Motion` precedes `TipDown` so the contact lands where the sample says; a release
/// ([`PenTracker::force_release`] or an out-of-range sample) orders
/// `ButtonsChanged?` → `TipUp?` → `ProximityOut` so nothing is left held.
#[derive(Clone, Copy, Debug, PartialEq)]
pub enum PenTransition {
/// The pen entered hover range. Injectors map [`PenTool::Unknown`] to a plain pen.
ProximityIn { tool: PenTool },
/// Position + all axes moved to this sample's values (emitted for every in-range sample).
Motion { sample: PenSample },
/// The tip made contact.
TipDown,
/// Barrel buttons changed: `pressed` / `released` are disjoint `PEN_BARREL*` subsets.
ButtonsChanged { pressed: u8, released: u8 },
/// The tip lifted.
TipUp,
/// The pen left hover range.
ProximityOut,
}
/// The host-side stroke state machine (one per session): diffs state-full [`PenSample`]s
/// against tracked state and appends the synthesized [`PenTransition`]s. Pure and clock-free —
/// the owner arms its own [`PEN_TOUCH_TIMEOUT_MS`] timer over [`PenTracker::is_active`] and
/// calls [`PenTracker::force_release`] when it fires (and on session teardown).
#[derive(Debug, Default)]
pub struct PenTracker {
last_seq: Option<u16>,
in_range: bool,
touching: bool,
buttons: u8,
tool: PenTool,
}
impl PenTracker {
/// Apply one decoded batch, appending transitions to `out` (callers reuse the buffer). A
/// stale batch ([`pen_seq_newer`]) is dropped whole with no transitions. Samples carrying
/// the reserved [`PEN_PREDICTED`] bit are skipped (never injected — module doc).
pub fn apply(&mut self, batch: &PenBatch, out: &mut Vec<PenTransition>) {
if !pen_seq_newer(batch.seq, self.last_seq) {
return;
}
self.last_seq = Some(batch.seq);
for s in batch.samples() {
if s.state & PEN_PREDICTED != 0 {
continue;
}
self.apply_sample(s, out);
}
}
/// The tracker holds live state a dead client could leave stuck (in range, or mid-stroke).
pub fn is_active(&self) -> bool {
self.in_range || self.touching
}
/// Release everything held (buttons → tip → proximity) — the [`PEN_TOUCH_TIMEOUT_MS`]
/// failsafe and session teardown. Keeps the seq gate armed so a late stale datagram from
/// the dead stroke cannot re-apply after the release.
pub fn force_release(&mut self, out: &mut Vec<PenTransition>) {
if self.buttons != 0 {
out.push(PenTransition::ButtonsChanged {
pressed: 0,
released: self.buttons,
});
self.buttons = 0;
}
if self.touching {
out.push(PenTransition::TipUp);
self.touching = false;
}
if self.in_range {
out.push(PenTransition::ProximityOut);
self.in_range = false;
}
}
fn apply_sample(&mut self, s: &PenSample, out: &mut Vec<PenTransition>) {
let touching = s.state & PEN_TOUCHING != 0;
// Touching implies in-range (a contact IS a proximity) — normalize here once so every
// consumer sees coherent states.
let in_range = touching || s.state & PEN_IN_RANGE != 0;
if !in_range {
self.force_release(out);
return;
}
// Unknown inherits the session's tool (a newer client's future tool must not thrash
// proximity); outside a session it grounds to the default.
let tool = match s.tool {
PenTool::Unknown if self.in_range => self.tool,
t => t,
};
// A tool switch mid-session is a physical re-entry (see [`PenTool`]).
if self.in_range && tool != self.tool {
self.force_release(out);
}
if !self.in_range {
out.push(PenTransition::ProximityIn { tool });
self.in_range = true;
}
self.tool = tool;
out.push(PenTransition::Motion { sample: *s });
if touching && !self.touching {
out.push(PenTransition::TipDown);
self.touching = true;
}
let buttons = s.state & PEN_BUTTONS_MASK;
if buttons != self.buttons {
out.push(PenTransition::ButtonsChanged {
pressed: buttons & !self.buttons,
released: self.buttons & !buttons,
});
self.buttons = buttons;
}
if !touching && self.touching {
out.push(PenTransition::TipUp);
self.touching = false;
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::quic::RichInput;
fn hover(x: f32, y: f32) -> PenSample {
PenSample {
state: PEN_IN_RANGE,
x,
y,
distance: 300,
..Default::default()
}
}
fn touch(x: f32, y: f32, pressure: u16) -> PenSample {
PenSample {
state: PEN_IN_RANGE | PEN_TOUCHING,
x,
y,
pressure,
distance: 0,
tilt_deg: 35,
azimuth_deg: 180,
roll_deg: 90,
..Default::default()
}
}
#[test]
fn pen_batch_roundtrip_and_truncation() {
let samples = [hover(0.25, 0.5), touch(0.26, 0.5, 40000), {
let mut s = touch(0.27, 0.51, 42000);
s.state |= PEN_BARREL1;
s.dt_us = 4167;
s
}];
let b = PenBatch::new(7, &samples);
let d = b.encode();
assert_eq!(d[0], RICH_INPUT_MAGIC);
assert_eq!(d.len(), 6 + 3 * PEN_SAMPLE_WIRE_LEN);
let back = PenBatch::decode(&d).unwrap();
assert_eq!(back.seq, 7);
assert_eq!(back.samples(), &samples);
// A torn datagram yields exactly the complete samples that arrived — never an
// over-read, never a partial sample.
let torn = PenBatch::decode(&d[..6 + 2 * PEN_SAMPLE_WIRE_LEN + 5]).unwrap();
assert_eq!(torn.samples(), &samples[..2]);
// Header-only / empty batches and short buffers are rejected whole.
assert!(PenBatch::decode(&d[..PEN_HEADER_LEN]).is_none());
assert!(PenBatch::decode(&PenBatch::new(0, &[]).encode()).is_none());
// Wrong tag / wrong kind are None before any read.
let mut bad = d.clone();
bad[0] = 0xC8;
assert!(PenBatch::decode(&bad).is_none());
let mut bad = d;
bad[1] = 0x01; // RICH_TOUCHPAD
assert!(PenBatch::decode(&bad).is_none());
}
#[test]
fn pen_batch_oversize_truncates_and_flags_reserved() {
// 10 samples truncate to PEN_BATCH_MAX on construction (senders split instead).
let many: Vec<PenSample> = (0..10).map(|i| hover(i as f32 / 10.0, 0.5)).collect();
let b = PenBatch::new(1, &many);
assert_eq!(b.samples().len(), PEN_BATCH_MAX);
// A declared count larger than the payload clamps to what arrived.
let mut d = b.encode();
d[3] = 200;
assert_eq!(PenBatch::decode(&d).unwrap().samples().len(), PEN_BATCH_MAX);
// A nonzero reserved flags byte still parses (receivers MUST ignore).
d[2] = 0xAA;
assert!(PenBatch::decode(&d).is_some());
}
#[test]
fn pen_batch_rejects_forged_floats_and_clamps_stragglers() {
// NaN / ∞ coordinates kill the whole batch — nothing legitimate produces them.
for forged in [f32::NAN, f32::INFINITY, f32::NEG_INFINITY] {
let mut s = touch(0.5, 0.5, 1);
s.x = forged;
assert!(PenBatch::decode(&PenBatch::new(0, &[s]).encode()).is_none());
}
// A finite coordinate a hair outside the letterbox clamps instead (real input).
let mut s = hover(0.5, 0.5);
s.x = -0.01;
s.y = 1.25;
let back = PenBatch::decode(&PenBatch::new(0, &[s]).encode()).unwrap();
assert_eq!((back.samples()[0].x, back.samples()[0].y), (0.0, 1.0));
}
#[test]
fn pen_plane_is_disjoint_from_rich_input() {
// A pen datagram shares the 0xCC tag but is NOT a RichInput: a pre-pen host takes the
// documented unknown-kind drop, and the pen decoder rejects every RichInput kind.
let d = PenBatch::new(3, &[touch(0.5, 0.5, 100)]).encode();
assert!(RichInput::decode(&d).is_none());
let rich = RichInput::Touchpad {
pad: 0,
finger: 0,
active: true,
x: 1,
y: 2,
}
.encode();
assert!(PenBatch::decode(&rich).is_none());
}
#[test]
fn seq_gate_wraps_and_drops_stale() {
assert!(pen_seq_newer(0, None));
assert!(pen_seq_newer(6, Some(5)));
assert!(!pen_seq_newer(5, Some(5)));
assert!(!pen_seq_newer(4, Some(5)));
assert!(pen_seq_newer(2, Some(0xFFFE))); // wrap
assert!(!pen_seq_newer(0xFFFE, Some(2))); // stale across the wrap
}
/// Drives a tracker and returns the transitions of one batch.
fn run(t: &mut PenTracker, seq: u16, samples: &[PenSample]) -> Vec<PenTransition> {
let mut out = Vec::new();
t.apply(&PenBatch::new(seq, samples), &mut out);
out
}
#[test]
fn tracker_full_stroke_lifecycle() {
let mut t = PenTracker::default();
// Hover in → ProximityIn + Motion.
let h = hover(0.2, 0.2);
assert_eq!(
run(&mut t, 0, &[h]),
vec![
PenTransition::ProximityIn { tool: PenTool::Pen },
PenTransition::Motion { sample: h },
]
);
// Contact: Motion precedes TipDown so ink lands at the sample's position.
let c = touch(0.21, 0.2, 30000);
assert_eq!(
run(&mut t, 1, &[c]),
vec![PenTransition::Motion { sample: c }, PenTransition::TipDown]
);
assert!(t.is_active());
// Drag: motion only.
let m = touch(0.3, 0.25, 45000);
assert_eq!(
run(&mut t, 2, &[m]),
vec![PenTransition::Motion { sample: m }]
);
// Lift back to hover, then leave range: buttons(none) → TipUp, then ProximityOut.
let l = hover(0.3, 0.25);
assert_eq!(
run(&mut t, 3, &[l]),
vec![PenTransition::Motion { sample: l }, PenTransition::TipUp]
);
let gone = PenSample::default(); // state 0 = out of range
assert_eq!(run(&mut t, 4, &[gone]), vec![PenTransition::ProximityOut]);
assert!(!t.is_active());
}
#[test]
fn tracker_self_heals_lost_transitions() {
let mut t = PenTracker::default();
// The hover batch AND the tip-down batch were lost: the first surviving mid-stroke
// sample synthesizes the whole entry.
let m = touch(0.5, 0.5, 20000);
assert_eq!(
run(&mut t, 10, &[m]),
vec![
PenTransition::ProximityIn { tool: PenTool::Pen },
PenTransition::Motion { sample: m },
PenTransition::TipDown,
]
);
// The lift batch was lost; the next out-of-range sample heals it fully.
assert_eq!(
run(&mut t, 11, &[PenSample::default()]),
vec![PenTransition::TipUp, PenTransition::ProximityOut]
);
}
#[test]
fn tracker_drops_stale_batches_whole() {
let mut t = PenTracker::default();
let c = touch(0.5, 0.5, 100);
assert!(!run(&mut t, 5, &[c]).is_empty());
// A reordered older batch (a hover from before the contact) must not rewind the stroke.
assert!(run(&mut t, 4, &[hover(0.4, 0.4)]).is_empty());
assert!(t.is_active());
}
#[test]
fn tracker_buttons_and_eraser_reentry() {
let mut t = PenTracker::default();
let mut held = touch(0.5, 0.5, 100);
held.state |= PEN_BARREL1;
// Buttons apply after TipDown on entry…
assert_eq!(
run(&mut t, 0, &[held]),
vec![
PenTransition::ProximityIn { tool: PenTool::Pen },
PenTransition::Motion { sample: held },
PenTransition::TipDown,
PenTransition::ButtonsChanged {
pressed: PEN_BARREL1,
released: 0
},
]
);
// …swap BARREL1 → BARREL2 in one sample: one delta, both directions.
let mut swapped = held;
swapped.state = (swapped.state & !PEN_BARREL1) | PEN_BARREL2;
assert_eq!(
run(&mut t, 1, &[swapped]),
vec![
PenTransition::Motion { sample: swapped },
PenTransition::ButtonsChanged {
pressed: PEN_BARREL2,
released: PEN_BARREL1
},
]
);
// Tool switch to eraser mid-contact = full release + re-entry as the eraser, with the
// held button released first so nothing sticks across tools.
let mut erase = touch(0.5, 0.5, 200);
erase.tool = PenTool::Eraser;
assert_eq!(
run(&mut t, 2, &[erase]),
vec![
PenTransition::ButtonsChanged {
pressed: 0,
released: PEN_BARREL2
},
PenTransition::TipUp,
PenTransition::ProximityOut,
PenTransition::ProximityIn {
tool: PenTool::Eraser
},
PenTransition::Motion { sample: erase },
PenTransition::TipDown,
]
);
// Unknown tool inside the session inherits (no re-entry thrash from a newer client).
let mut unk = touch(0.51, 0.5, 210);
unk.tool = PenTool::Unknown;
assert_eq!(
run(&mut t, 3, &[unk]),
vec![PenTransition::Motion { sample: unk }]
);
}
#[test]
fn tracker_force_release_and_late_stale_datagram() {
let mut t = PenTracker::default();
let mut held = touch(0.5, 0.5, 100);
held.state |= PEN_BARREL2;
run(&mut t, 100, &[held]);
// The 200 ms failsafe / teardown: buttons → tip → proximity, all released.
let mut out = Vec::new();
t.force_release(&mut out);
assert_eq!(
out,
vec![
PenTransition::ButtonsChanged {
pressed: 0,
released: PEN_BARREL2
},
PenTransition::TipUp,
PenTransition::ProximityOut,
]
);
assert!(!t.is_active());
// Idempotent.
let mut out = Vec::new();
t.force_release(&mut out);
assert!(out.is_empty());
// The seq gate survives the release: a late datagram from the dead stroke is stale.
assert!(run(&mut t, 99, &[held]).is_empty());
assert!(!t.is_active());
// But the stroke after it proceeds normally.
assert!(!run(&mut t, 101, &[held]).is_empty());
assert!(t.is_active());
}
#[test]
fn tracker_skips_reserved_predicted_samples() {
let mut t = PenTracker::default();
let mut p = touch(0.5, 0.5, 100);
p.state |= PEN_PREDICTED;
// A v1 host must never inject a predicted sample — and skipping must not corrupt
// tracking for the real samples around it.
let real = touch(0.6, 0.5, 120);
let out = run(&mut t, 0, &[p, real]);
assert_eq!(
out,
vec![
PenTransition::ProximityIn { tool: PenTool::Pen },
PenTransition::Motion { sample: real },
PenTransition::TipDown,
]
);
}
}