Skip to main content

teksilo_platform/
event_translation.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! winit packets in, Teksilo input samples out.
5//!
6//! [`TranslationState`] is the per-window owner of everything a translation
7//! needs to remember: the scale factor, the modifier set, the mouse cursor's
8//! last position, the **live contact set**, the scroll-phase machine, and the
9//! suppressors that keep a dual-stream platform from delivering one physical
10//! touch twice.
11//!
12//! # Two surfaces, one state
13//!
14//! The free `translate_*` functions are the original single-`WidgetEvent`
15//! surface the app event loop uses today. [`PointerBackend::translate`] is the
16//! multi-sample surface that carries touch. Both read the same
17//! [`TranslationState`], so the suppressors cannot disagree between them.
18//!
19//! Mouse translation through the free functions is unchanged, with one
20//! deliberate exception: a `MouseInput` that arrives with **no known cursor
21//! position** is now dropped rather than dispatched at the window origin. A
22//! press at `(0, 0)` is a click on whatever happens to be in the top-left
23//! corner, which is worse than no click at all.
24//!
25//! # Time
26//!
27//! Nothing here reads a clock. [`PointerBackend::translate`] is handed the
28//! caller's [`EventTime`], and [`TranslationState::set_now`] lets a caller on
29//! the free-function surface advance the same field. A state whose time never
30//! advances simply never opens a suppression window — which is exactly the
31//! behaviour an app that has not yet wired touch wants.
32//!
33//! # The kill switch
34//!
35//! [`InputTokens::touch_enabled`] is honoured **here**, at the first point a
36//! finger becomes a Teksilo concept. With it off, a touch packet yields no
37//! sample at all: no id is minted, no contact is tracked, no suppressor arms.
38//! That is the programme's rollback switch, and it has to sit at the producer
39//! for the rollback to be total.
40//!
41//! Reference: `docs/touch-and-pen.md`.
42
43use std::collections::HashMap;
44use std::collections::hash_map::DefaultHasher;
45use std::hash::{Hash, Hasher};
46use std::sync::atomic::{AtomicU64, Ordering};
47use std::time::Duration;
48
49use teksilo_canvas::Point;
50use teksilo_core::event::{ButtonMask, Key, Modifiers, PointerButton, ScrollDelta, WidgetEvent};
51use teksilo_core::gesture::{GestureEvent, TapEvent};
52use teksilo_core::pointer::{
53    BackendDeviceKey, EventTime, PointerId, PointerIdAllocator, PointerInfo, PointerPhase,
54    PointerSample, ScrollPhase, ScrollSample, ScrollSource,
55};
56use teksilo_core::trace_input;
57use teksilo_tokens::{InputTokens, PenKind, PointerKind};
58
59use crate::pen::{PenBatching, PenButtons, PenPacket, PenSource, back_date_into};
60use crate::pointer_backend::{
61    BackendCaps, BackendEvent, InputSample, PlatformKind, PointerBackend,
62};
63use crate::window_system::WindowSystem;
64
65// ---------------------------------------------------------------------------
66// Tuning constants
67// ---------------------------------------------------------------------------
68
69/// How near a lifted contact's position an emulated `CursorMoved` has to be to
70/// count as the ghost the X11 core pointer leaves behind, in logical pixels.
71///
72/// One pixel: the core pointer is *warped* to the contact, so the ghost is at
73/// the lift point exactly. Anything further away is a real mouse the user is
74/// moving, and gets through.
75const PHANTOM_SLOP: f32 = 1.0;
76
77/// How long after the last lift the X11 core pointer's parked position is still
78/// treated as a ghost.
79const PHANTOM_LIFT_WINDOW: Duration = Duration::from_millis(150);
80
81/// How long after the last lift a *button* event is treated as an emulated
82/// click on a platform that promotes touch to mouse.
83///
84/// Longer than [`PHANTOM_LIFT_WINDOW`] because a promoted click is emitted
85/// after the whole tap gesture has been recognised by the OS, not during it.
86const PROMOTED_CLICK_WINDOW: Duration = Duration::from_millis(500);
87
88/// How long after a scroll gesture's `Ended` a fresh `Started` is read as the
89/// OS handing over its own momentum rather than as a new gesture.
90///
91/// This exists because winit 0.30's macOS backend **collapses** `NSEvent`'s
92/// `phase` and `momentumPhase` into one `TouchPhase` (see
93/// `platform_impl/macos/view.rs`, `scrollWheel:`): a momentum `Began` is
94/// indistinguishable from a finger-down `Began` in the event alone. AppKit
95/// hands momentum over in the same run-loop turn as the lift, so a short
96/// window separates the two reliably. Without this, a two-finger flick reads
97/// as two gestures and P12 would add a Teksilo fling on top of the OS's.
98const MOMENTUM_HANDOFF_WINDOW: Duration = Duration::from_millis(100);
99
100/// The device key every pen session is minted under.
101///
102/// A pen does not arrive through winit, so there is no `DeviceId` to hash. One
103/// fixed key plus a process-global session counter is enough: the counter is
104/// what makes two windows' sessions distinct, and the allocator only ever sees
105/// `(PEN_DEVICE, session)` pairs that no window has used before.
106const PEN_DEVICE: BackendDeviceKey = BackendDeviceKey::new(0x7065_6E5F_0000_0001);
107
108/// The next pen proximity session id. Process-global, because
109/// [`PointerIdAllocator`] is, and two windows with a stylus each must not mint
110/// the same key.
111static NEXT_PEN_SESSION: AtomicU64 = AtomicU64::new(1);
112
113// ---------------------------------------------------------------------------
114// Per-window state
115// ---------------------------------------------------------------------------
116
117/// One live touch contact.
118#[derive(Copy, Clone, Debug)]
119struct Contact {
120    /// The identity minted for this press.
121    id: PointerId,
122    /// Where it was last seen, in window-logical coordinates.
123    position: Point,
124    /// Whether it is the primary contact of its sequence — the first one down
125    /// while no other was live. W3C `isPrimary`: once it lifts, no other
126    /// contact is promoted; the next sequence elects a new one.
127    primary: bool,
128}
129
130/// One pen proximity session.
131///
132/// A session begins when the tool comes into range and ends when it leaves;
133/// the tip touching and lifting inside that span are *button* transitions on
134/// one pointer, not two pointers. That is the W3C model, and it is what makes
135/// a hovering stylus drive tooltips and hover visuals the way a mouse does.
136#[derive(Copy, Clone, Debug)]
137struct PenContact {
138    /// The identity minted for this proximity session.
139    id: PointerId,
140    /// The allocator key this session was minted under.
141    session: u64,
142    /// The tool in use. A tool change is a new session, not a mutation: a pen
143    /// flipped to its eraser is a different pointer as far as a drawing
144    /// surface is concerned.
145    tool: PenKind,
146    /// Last reported position, in window-logical coordinates.
147    position: Point,
148    /// Whether the tip is in contact.
149    down: bool,
150    /// The stylus buttons held.
151    buttons: PenButtons,
152    /// Whether this pointer is the primary one — see
153    /// [`TranslationState::begin_pen_session`].
154    primary: bool,
155}
156
157/// Where a wheel/trackpad stream currently sits.
158#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
159enum ScrollStreamState {
160    /// No gesture in progress. A `Moved` here is a discrete wheel notch, which
161    /// is what every scroll in Teksilo was before the touch programme.
162    #[default]
163    Idle,
164    /// Fingers are down and moving.
165    InGesture,
166    /// The fingers lifted and the OS is coasting the content.
167    InMomentum,
168}
169
170/// State tracked during event translation, one per window.
171#[derive(Debug)]
172pub struct TranslationState {
173    scale_factor: f64,
174    cursor_position: Option<Point>,
175    current_modifiers: Modifiers,
176
177    /// Which window system this window actually runs on. Set by the caller
178    /// from `window_system_for_display_handle`; `Unknown` — the default — is
179    /// precisely the set {Windows, macOS, headless}, none of which promote
180    /// touch to mouse, so it is a safe default for the suppressors.
181    window_system: WindowSystem,
182
183    /// The input tokens in force. Carries the `touch_enabled` kill switch and
184    /// `lines_per_notch`. Defaults to [`InputTokens::default`], whose
185    /// `lines_per_notch` is 3.0 — the constant this module used to hardcode.
186    input: InputTokens,
187
188    /// The caller's notion of now. See the module docs.
189    now: EventTime,
190
191    /// Live contacts, keyed the way [`PointerIdAllocator`] keys them.
192    contacts: HashMap<(BackendDeviceKey, u64), Contact>,
193    /// Where and when the most recent contact lifted, for the two suppression
194    /// windows.
195    last_lift: Option<(Point, EventTime)>,
196
197    /// Mouse buttons currently held, in press order. A `Vec` rather than a
198    /// bitmask because [`ButtonMask`] is a union/intersection type with no
199    /// "remove"; five entries is the ceiling.
200    mouse_buttons: Vec<PointerButton>,
201
202    /// The scroll-phase machine.
203    scroll_state: ScrollStreamState,
204    /// When the last gesture `Ended`, for the momentum handoff.
205    scroll_ended_at: Option<EventTime>,
206
207    /// Whether the "dropped a press with no cursor position" note has been
208    /// traced. Once per window is enough to diagnose it; per packet would be a
209    /// flood.
210    warned_press_without_cursor: bool,
211
212    /// The window's pen shim, if it has one. `None` on a platform with no pen
213    /// path, and on a window nobody has attached one to.
214    pen: Option<Box<dyn PenSource>>,
215    /// The live pen proximity session.
216    pen_contact: Option<PenContact>,
217    /// Reused packet buffer, so polling a pen allocates nothing per turn.
218    pen_scratch: Vec<PenPacket>,
219    /// Reused back-dating buffer, the timeline twin of `pen_scratch`.
220    pen_times: Vec<EventTime>,
221    /// Device stamps lifted out of the drained batch, so `back_date_into` can
222    /// read them while `pen_scratch` is borrowed for translation.
223    pen_device_times: Vec<Option<u32>>,
224    /// How a drained pen batch reaches the tree. See [`PenBatching`].
225    pen_batching: PenBatching,
226}
227
228// Every method here carries a doc comment, and that is enforced rather than
229// hoped for. A new item inserted between an existing doc comment and the item it
230// documented — which has happened in this file, to `poll_pen` — leaves the
231// displaced item undocumented, and these are the lints that see it. It is `deny`
232// rather than `warn` because neither crate sets `missing_docs` at the root (97
233// public items would have to be written up first), so a warning here would sit
234// in a build log nobody reads.
235//
236// **Two lints, because one does not cover the class.** `missing_docs` is defined
237// not to fire on a private item, and a displaced neighbour is displaced whatever
238// its visibility — so on a block holding private and `pub(crate)` methods, the
239// lint added to stop the defect stops only the half of it that happens to be
240// `pub`. Clippy's `missing_docs_in_private_items` is the other half; it is a
241// tool lint, so plain `rustc` ignores it and the workspace clippy gate
242// (`-D warnings`) is what makes it bite.
243#[deny(missing_docs, clippy::missing_docs_in_private_items)]
244impl TranslationState {
245    /// A fresh per-window state: scale 1.0, no cursor, no modifiers, no
246    /// contacts, default input tokens, `WindowSystem::Unknown`.
247    pub fn new() -> Self {
248        Self {
249            scale_factor: 1.0,
250            cursor_position: None,
251            current_modifiers: Modifiers::NONE,
252            window_system: WindowSystem::Unknown,
253            input: InputTokens::default(),
254            now: EventTime::ZERO,
255            contacts: HashMap::new(),
256            last_lift: None,
257            mouse_buttons: Vec::new(),
258            scroll_state: ScrollStreamState::default(),
259            scroll_ended_at: None,
260            warned_press_without_cursor: false,
261            pen: None,
262            pen_contact: None,
263            pen_scratch: Vec::new(),
264            pen_times: Vec::new(),
265            pen_device_times: Vec::new(),
266            pen_batching: PenBatching::default(),
267        }
268    }
269
270    /// The window's HiDPI scale, used to convert physical positions to
271    /// logical ones. Set from winit's `ScaleFactorChanged` and at creation.
272    pub fn set_scale_factor(&mut self, factor: f64) {
273        self.scale_factor = factor;
274    }
275
276    /// The window's HiDPI scale, as last set.
277    pub fn scale_factor(&self) -> f64 {
278        self.scale_factor
279    }
280
281    /// The last cursor position this window saw, in logical coordinates.
282    /// `None` until the pointer has been inside it.
283    pub fn cursor_position(&self) -> Option<Point> {
284        self.cursor_position
285    }
286
287    /// Record the modifiers the OS last reported, which every sample
288    /// translated afterwards carries.
289    pub fn set_modifiers(&mut self, modifiers: Modifiers) {
290        self.current_modifiers = modifiers;
291    }
292
293    /// The modifiers last reported by the OS.
294    pub fn modifiers(&self) -> Modifiers {
295        self.current_modifiers
296    }
297
298    /// Tell the translator which window system this window runs on.
299    ///
300    /// Read it from the live window with
301    /// [`window_system_for_display_handle`](crate::window_system::window_system_for_display_handle)
302    /// — never from the environment, which lies in a Wayland session running
303    /// an X11 client.
304    ///
305    /// This selects the touch/mouse dual-stream suppressors; see
306    /// [`BackendCaps::synthesises_mouse_from_touch`].
307    pub fn set_window_system(&mut self, window_system: WindowSystem) {
308        self.window_system = window_system;
309    }
310
311    /// The window system this state is translating for.
312    pub fn window_system(&self) -> WindowSystem {
313        self.window_system
314    }
315
316    /// Install the input tokens in force.
317    ///
318    /// The translator deliberately holds an [`InputTokens`] rather than a
319    /// `Theme`: `teksilo-platform` already depends on `teksilo-tokens`, so this
320    /// costs no new dependency and no dependency inversion (the token crate is
321    /// a leaf and knows nothing of the widget tree). The caller re-installs
322    /// them whenever the theme changes.
323    pub fn set_input_tokens(&mut self, input: InputTokens) {
324        self.input = input;
325    }
326
327    /// The input tokens in force.
328    pub fn input_tokens(&self) -> &InputTokens {
329        &self.input
330    }
331
332    /// Advance the translator's notion of now.
333    ///
334    /// [`PointerBackend::translate`] does this itself from its `now` argument;
335    /// a caller still on the free-function surface calls it once per event
336    /// batch so the suppression windows are measured against the tree's clock
337    /// rather than against nothing.
338    pub fn set_now(&mut self, now: EventTime) {
339        if now > self.now {
340            self.now = now;
341        }
342    }
343
344    /// The translator's notion of now.
345    pub fn now(&self) -> EventTime {
346        self.now
347    }
348
349    /// How many contacts are currently down.
350    pub fn live_contact_count(&self) -> usize {
351        self.contacts.len()
352    }
353
354    /// The capability-matrix row this window belongs to.
355    ///
356    /// `WindowSystem::{X11, Wayland}` are only ever reported for an Xlib, Xcb
357    /// or Wayland display handle, and `active_window_system` is `Unknown` off
358    /// Unix — so a window that knows its window system also knows it is on
359    /// Unix. Deriving the row from that, rather than from
360    /// [`PlatformKind::HOST`] alone, is what keeps the matrix the *pure
361    /// function* its docs promise: the X11 row stays assertable from a Windows
362    /// or macOS host, which is what the phantom-suppression tests and the
363    /// backend-conformance vectors need. `Unknown` carries no such implication
364    /// and falls back to the compile-time host.
365    ///
366    /// In production this is a no-op: the only window system a non-Unix host
367    /// can report is `Unknown`.
368    fn platform(&self) -> PlatformKind {
369        match self.window_system {
370            WindowSystem::X11 | WindowSystem::Wayland => PlatformKind::Unix,
371            WindowSystem::Unknown => PlatformKind::HOST,
372        }
373    }
374
375    /// Whether this window's platform also synthesises a mouse stream from
376    /// touch, so that one of the two must be suppressed.
377    fn promotes_touch_to_mouse(&self) -> bool {
378        BackendCaps::for_platform(self.platform(), self.window_system).synthesises_mouse_from_touch
379    }
380
381    /// Whether a `CursorMoved` at `position` is the emulated pointer following
382    /// a finger rather than a mouse the user is moving.
383    ///
384    /// **While a contact is live, every `CursorMoved` is dropped.** X11 warps
385    /// the virtual core pointer onto the first concurrently-active contact and
386    /// reports it through the *same* virtual device a real mouse uses
387    /// (`util::VIRTUAL_CORE_POINTER`), so the two are indistinguishable at this
388    /// layer — winit filters emulated *buttons* by `XIPointerEmulated` but
389    /// emits this motion itself, deliberately, on every phase of the first
390    /// contact. A rule that only dropped moves *within a pixel* of a contact
391    /// would let every sample of a moving finger through.
392    ///
393    /// After the lift the core pointer stays parked at the lift point, so the
394    /// narrow proximity rule takes over: a move still at that point is the
395    /// ghost, a move anywhere else is a real mouse and gets through at once.
396    ///
397    /// # Residual
398    ///
399    /// winit emits its synthetic `CursorMoved` **before** the `Touch` packet
400    /// that establishes the contact, so the very first move of a touch session
401    /// that follows more than [`PHANTOM_LIFT_WINDOW`] of quiet still leaks one
402    /// sample. Closing it would need one event of lookahead, which would cost
403    /// every real X11 mouse move a frame of latency. Documented rather than
404    /// paid for.
405    fn is_phantom_motion(&self, position: Point) -> bool {
406        if !self.promotes_touch_to_mouse() {
407            return false;
408        }
409        if !self.contacts.is_empty() {
410            return true;
411        }
412        match self.last_lift {
413            Some((lift, at)) if self.now.saturating_since(at) <= PHANTOM_LIFT_WINDOW => {
414                near(lift, position, PHANTOM_SLOP)
415            }
416            _ => false,
417        }
418    }
419
420    /// Whether a mouse button event is the OS's promoted click for a tap that
421    /// already reached the tree as touch.
422    ///
423    /// Defence in depth: winit 0.30 already drops X11's `XIPointerEmulated`
424    /// button events, so on today's backends this window never fires. It is
425    /// here because "the OS also sends a click" is the single most common way
426    /// a touch port double-fires, and because a backend that does *not* filter
427    /// (Android, Web, a future X11 rework) must not be able to introduce it
428    /// silently.
429    fn is_promoted_click(&self) -> bool {
430        if !self.promotes_touch_to_mouse() {
431            return false;
432        }
433        if !self.contacts.is_empty() {
434            return true;
435        }
436        matches!(
437            self.last_lift,
438            Some((_, at)) if self.now.saturating_since(at) <= PROMOTED_CLICK_WINDOW
439        )
440    }
441
442    /// The mouse's pointer identity as of now.
443    fn mouse_pointer(&self) -> PointerInfo {
444        let mut info = PointerInfo::mouse(self.now);
445        info.buttons = self
446            .mouse_buttons
447            .iter()
448            .fold(ButtonMask::NONE, |mask, b| mask.union((*b).into()));
449        info
450    }
451
452    /// Translate one winit `Touch` packet.
453    ///
454    /// Returns `None` — with nothing recorded and no id minted — when touch is
455    /// disabled, or when the packet belongs to a contact this window never saw
456    /// go down (a stream that began before the window was listening, or before
457    /// the kill switch was flipped on). Emitting an `Up` for a `Down` that
458    /// never happened would break the cancel-completeness invariant just as
459    /// surely as dropping one.
460    fn translate_touch(&mut self, touch: &winit::event::Touch) -> Option<PointerSample> {
461        if !self.input.touch_enabled {
462            trace_input!(
463                Samples,
464                "touch dropped: touch_enabled=false (os id {})",
465                touch.id
466            );
467            return None;
468        }
469
470        let device = device_key(touch.device_id);
471        let key = (device, touch.id);
472        let position = Point::new(
473            (touch.location.x / self.scale_factor) as f32,
474            (touch.location.y / self.scale_factor) as f32,
475        );
476
477        let (phase, id, primary) = match touch.phase {
478            winit::event::TouchPhase::Started => {
479                // A fresh identity per press. winit reuses `Touch::id` after a
480                // lift, and a table keyed on the raw id would hand the new
481                // contact the old one's gesture state.
482                let id = PointerIdAllocator::global().begin(device, touch.id);
483                let primary = self.contacts.is_empty();
484                self.contacts.insert(
485                    key,
486                    Contact {
487                        id,
488                        position,
489                        primary,
490                    },
491                );
492                (PointerPhase::Down, id, primary)
493            }
494            winit::event::TouchPhase::Moved => {
495                let contact = self.contacts.get_mut(&key)?;
496                contact.position = position;
497                (PointerPhase::Move, contact.id, contact.primary)
498            }
499            winit::event::TouchPhase::Ended | winit::event::TouchPhase::Cancelled => {
500                let contact = self.contacts.remove(&key)?;
501                PointerIdAllocator::global().end(device, touch.id);
502                self.last_lift = Some((position, self.now));
503                let phase = if matches!(touch.phase, winit::event::TouchPhase::Ended) {
504                    PointerPhase::Up
505                } else {
506                    PointerPhase::Cancel
507                };
508                (phase, contact.id, contact.primary)
509            }
510        };
511
512        let mut pointer = PointerInfo::touch(id, self.now);
513        pointer.primary = primary;
514        // A finger holds the primary "button" for as long as it is down. This
515        // is normative, not cosmetic: every `accept_buttons()` recognizer in
516        // the framework gates on `ButtonMask::PRIMARY`, so a contact that
517        // reported an empty mask would be invisible to tap, drag, long-press
518        // and multi-tap alike.
519        pointer.buttons = match phase {
520            PointerPhase::Down | PointerPhase::Move => ButtonMask::PRIMARY,
521            PointerPhase::Up | PointerPhase::Cancel => ButtonMask::NONE,
522        };
523        pointer.axes.pressure = touch.force.and_then(pressure_from_force);
524        // The contact patch, where a shim can supply one winit cannot. Windows
525        // is the only platform that reports it today: `POINTER_TOUCH_INFO`
526        // carries `rcContact` and `WM_TOUCH` — the path winit 0.30 takes —
527        // does not.
528        pointer.axes.contact = self
529            .pen
530            .as_ref()
531            .and_then(|source| source.touch_contact(touch.id));
532
533        // A direct pointer reports the button that changed on the two phases
534        // that change one. A move never does, and a cancel has no meaningful
535        // end state at all.
536        let button = match phase {
537            PointerPhase::Down | PointerPhase::Up => Some(PointerButton::Primary),
538            _ => None,
539        };
540
541        trace_input!(
542            Samples,
543            "touch {:?} {:?} os_id={} at {:?}",
544            phase,
545            id,
546            touch.id,
547            position
548        );
549
550        Some(PointerSample {
551            pointer,
552            phase,
553            position,
554            button,
555            modifiers: self.current_modifiers,
556            coalesced: Vec::new(),
557        })
558    }
559
560    // -----------------------------------------------------------------
561    // Pen
562    // -----------------------------------------------------------------
563
564    /// Install this window's pen shim.
565    ///
566    /// Build one with [`create_pen_source`](crate::pen::create_pen_source),
567    /// which answers [`NullPenSource`](crate::pen::null::NullPenSource) where
568    /// the platform has no pen path. A window with no source simply never
569    /// produces a pen sample — which is also the pen's rollback switch, since
570    /// `InputTokens::touch_enabled` deliberately does **not** gate it: a
571    /// stylus is not a finger, and rolling touch back must not take the pen
572    /// with it.
573    pub fn set_pen_source(&mut self, source: Box<dyn PenSource>) {
574        self.pen = Some(source);
575    }
576
577    /// Remove and return this window's pen shim.
578    ///
579    /// Any live proximity session is *not* terminated here — call
580    /// [`cancel_all`](PointerBackend::cancel_all) first if the pointer has to
581    /// be ended cleanly.
582    pub fn take_pen_source(&mut self) -> Option<Box<dyn PenSource>> {
583        self.pen.take()
584    }
585
586    /// Whether a pen shim is installed.
587    pub fn has_pen_source(&self) -> bool {
588        self.pen.is_some()
589    }
590
591    /// Whether the installed shim fills its buffer from a thread of its own.
592    ///
593    /// See [`PenSource::polls_off_thread`]: it is what tells the event loop
594    /// whether draining once per turn is enough. `false` with no shim.
595    pub fn pen_polls_off_thread(&self) -> bool {
596        self.pen.as_ref().is_some_and(|p| p.polls_off_thread())
597    }
598
599    /// Whether a tool is currently in proximity — i.e. whether a pen is
600    /// hovering or drawing right now.
601    pub fn pen_in_proximity(&self) -> bool {
602        self.pen_contact.is_some()
603    }
604
605    /// How a drained pen batch reaches the tree. See [`PenBatching`].
606    pub fn pen_batching(&self) -> PenBatching {
607        self.pen_batching
608    }
609
610    /// Choose how a drained pen batch reaches the tree.
611    ///
612    /// Takes effect on the next [`poll_pen`](Self::poll_pen); a drain already
613    /// in flight is unaffected, because there is no such thing — a drain is one
614    /// synchronous call.
615    pub fn set_pen_batching(&mut self, mode: PenBatching) {
616        self.pen_batching = mode;
617    }
618
619    /// Drain the pen shim and translate everything it buffered.
620    ///
621    /// Call once per event-loop turn, alongside the winit events. Cheap and
622    /// allocation-free when no stylus is in use: the shim returns nothing and
623    /// both scratch buffers are reused.
624    ///
625    /// # The batch gets its own timeline
626    ///
627    /// A drain is not one instant. The packets in it are separate digitizer
628    /// frames that happened at separate times, and stamping every one of them
629    /// with the poll's `now` — which is what this did before
630    /// [`PenPacket::device_time_ms`] existed — destroys every velocity,
631    /// smoothing and time-offset computation downstream of it.
632    ///
633    /// So the batch is placed on the tree's timeline by
634    /// [`back_date`](crate::pen::back_date): the newest packet is `now`, and
635    /// each earlier one sits at the device's own delta before the one after
636    /// it. A batch of one is `now` exactly, so the single-packet case — which
637    /// is the steady state at a 4 ms poll — is unchanged.
638    ///
639    /// One clamp is applied on top of the pure rule: no sample is stamped
640    /// earlier than the translator's previous `now`. A shim filling its buffer
641    /// from its own thread (the Wayland one) can hand over a packet the
642    /// compositor stamped *before* the last drain returned, and this window has
643    /// already told the tree that time had reached `now`. Invariant 3 of the
644    /// backend conformance suite — "time is monotone" — is a promise to every
645    /// consumer downstream, and honouring it costs at worst the collapse that
646    /// used to be unconditional.
647    pub fn poll_pen(&mut self, now: EventTime) -> Vec<InputSample> {
648        let floor = self.now;
649        self.set_now(now);
650        // Take the source out so the translation below can borrow `self`
651        // mutably; it goes straight back.
652        let Some(mut source) = self.pen.take() else {
653            return Vec::new();
654        };
655        let mut packets = std::mem::take(&mut self.pen_scratch);
656        packets.clear();
657        source.poll(&mut packets);
658        self.pen = Some(source);
659
660        // Lift the device stamps out first: `packets` is borrowed for the
661        // whole translation loop below, and `back_date_into` needs a slice.
662        let mut device_times = std::mem::take(&mut self.pen_device_times);
663        device_times.clear();
664        device_times.extend(packets.iter().map(|packet| packet.device_time_ms));
665        let mut times = std::mem::take(&mut self.pen_times);
666        back_date_into(self.now, &device_times, &mut times);
667
668        let mut samples = Vec::new();
669        // Indices of samples the fold must leave alone although they look like
670        // pure motion. Today that is exactly the **proximity enter**: a tool
671        // coming into range is a transition, but it is carried as a `Move` with
672        // nothing held (there is no `PointerPhase` for entering — see
673        // `translate_pen_packet`), so `coalesce_pen_moves`'s phase-and-button
674        // test cannot tell it apart and would fold it into the hover that
675        // followed it. The consequence is not
676        // a lost `PointerMove`: the tree derives the hover owner, the cursor and
677        // the tooltip dwell from a sample's `position`, never from its batched
678        // list, so a pen that came into range over one widget and hovered onto
679        // another inside one drain would never enter the first at all.
680        //
681        // Detected from the session rather than announced by a flag: a session
682        // mints a fresh `PointerId`, so the enter is the first sample carrying
683        // an id the previous packet did not have.
684        let mut pinned: Vec<usize> = Vec::new();
685        for (packet, time) in packets.iter().zip(times.iter().copied()) {
686            let id_before = self.pen_contact.map(|contact| contact.id);
687            let start = samples.len();
688            samples.append(&mut self.translate_pen_packet(packet, time.max(floor)));
689            if let Some(fresh) = self.pen_contact.map(|contact| contact.id)
690                && Some(fresh) != id_before
691                && let Some(offset) = samples[start..].iter().position(
692                    |sample| matches!(sample, InputSample::Pointer(p) if p.pointer.id == fresh),
693                )
694            {
695                pinned.push(start + offset);
696            }
697        }
698        if self.pen_batching == PenBatching::Coalesce {
699            coalesce_pen_moves(&mut samples, &pinned);
700        }
701
702        packets.clear();
703        self.pen_scratch = packets;
704        device_times.clear();
705        self.pen_device_times = device_times;
706        times.clear();
707        self.pen_times = times;
708        samples
709    }
710
711    /// Turn one digitizer packet into the samples its transitions imply, all
712    /// stamped `time`.
713    ///
714    /// Public so a replay backend, or a platform shim Teksilo has not met, can
715    /// feed the same state machine without reimplementing it.
716    ///
717    /// `time` is on the **tree's** timeline and is the caller's to supply —
718    /// deliberately, because a packet only carries the device's own counter
719    /// ([`PenPacket::device_time_ms`]), whose epoch is unknown. A caller
720    /// draining a whole batch turns those counters into times with
721    /// [`back_date`](crate::pen::back_date), which is what
722    /// [`poll_pen`](Self::poll_pen) does; a caller with one packet and nothing
723    /// better to say passes its own `now`.
724    ///
725    /// # The state machine
726    ///
727    /// A packet is a *level*; the transitions are derived by comparing it with
728    /// the session's previous state.
729    ///
730    /// | transition | sample |
731    /// | --- | --- |
732    /// | out of range → in range | `Move` (a hover: no buttons, `down` false) |
733    /// | position changed | `Move` |
734    /// | tip touched down | `Down` with [`PointerButton::Primary`] |
735    /// | tip lifted | `Up` with `Primary` |
736    /// | barrel pressed / released | `Down` / `Up` with `Secondary` |
737    /// | second barrel | `Down` / `Up` with `Middle` |
738    /// | in range → out of range | `Cancel` |
739    /// | tool changed mid-session | `Cancel`, then a fresh session |
740    ///
741    /// Within one packet the `Move` is emitted **first**, so a press always
742    /// lands at a position the consumer has already seen.
743    ///
744    /// # Why leaving proximity is a `Cancel`
745    ///
746    /// [`PointerPhase`] has no *leave*, and a tool going out of range
747    /// completes nothing: the completion, if there was one, was the tip's
748    /// `Up`, which has already been delivered. `Cancel` is the phase that says
749    /// "this pointer's life ended without completing an interaction", which is
750    /// exactly what happened — and it is the right thing for the down case
751    /// too, where a stylus yanked off the tablet mid-stroke must not read as a
752    /// deliberate lift.
753    pub fn translate_pen_packet(
754        &mut self,
755        packet: &PenPacket,
756        time: EventTime,
757    ) -> Vec<InputSample> {
758        let mut samples = Vec::new();
759
760        // End the session first when the tool left range, or when the tool
761        // itself changed under us (pen → eraser is a different pointer).
762        if let Some(contact) = self.pen_contact
763            && (!packet.in_proximity || contact.tool != packet.tool)
764        {
765            samples.push(self.end_pen_session(contact, packet.position, time));
766        }
767        if !packet.in_proximity {
768            return samples;
769        }
770
771        let (mut state, just_entered) = match self.pen_contact {
772            Some(contact) => (contact, false),
773            None => {
774                let contact = self.begin_pen_session(packet);
775                // The hover enter: a `Move` with nothing held, at the position
776                // the tool came into range at.
777                samples.push(self.pen_sample(&contact, PointerPhase::Move, None, packet, time));
778                (contact, true)
779            }
780        };
781
782        // Which buttons changed, in a fixed order: the tip first, then the
783        // barrel, so a press-while-moving reads the same way every time.
784        let mut transitions: Vec<(PointerPhase, PointerButton)> = Vec::new();
785        if state.down != packet.down {
786            transitions.push((
787                if packet.down {
788                    PointerPhase::Down
789                } else {
790                    PointerPhase::Up
791                },
792                PointerButton::Primary,
793            ));
794        }
795        for (bit, button) in [
796            (PenButtons::BARREL, PointerButton::Secondary),
797            (PenButtons::SECONDARY_BARREL, PointerButton::Middle),
798        ] {
799            let was = state.buttons.contains(bit);
800            let held = packet.buttons.contains(bit);
801            if was != held {
802                transitions.push((
803                    if held {
804                        PointerPhase::Down
805                    } else {
806                        PointerPhase::Up
807                    },
808                    button,
809                ));
810            }
811        }
812
813        let moved = state.position != packet.position;
814        state.position = packet.position;
815        // A packet with no transition still says something — a pressure ramp,
816        // a tilt change — so it becomes a `Move` even when the position stood
817        // still. The entering packet is the exception: its `Move` has already
818        // been emitted above, and repeating it would double every hover.
819        if !just_entered && (moved || transitions.is_empty()) {
820            samples.push(self.pen_sample(&state, PointerPhase::Move, None, packet, time));
821        }
822        for (phase, button) in transitions {
823            let pressed = phase == PointerPhase::Down;
824            match button {
825                PointerButton::Primary => state.down = pressed,
826                PointerButton::Secondary => {
827                    state.buttons = state.buttons.with(PenButtons::BARREL, pressed);
828                }
829                _ => {
830                    state.buttons = state.buttons.with(PenButtons::SECONDARY_BARREL, pressed);
831                }
832            }
833            samples.push(self.pen_sample(&state, phase, Some(button), packet, time));
834        }
835
836        // Carry the packet's raw flags (the eraser bit among them) forward, so
837        // the next comparison is against what the device actually said.
838        state.buttons = packet.buttons;
839        state.down = packet.down;
840        self.pen_contact = Some(state);
841        samples
842    }
843
844    /// Mint an identity for a tool that just came into range.
845    ///
846    /// Primacy: a pen with no finger on the glass is the primary direct
847    /// pointer. A pen that arrives while contacts are live is not — the
848    /// cross-kind arbitration (pen versus mouse) belongs to the tree's pointer
849    /// table, which can see every live pointer; this only avoids claiming
850    /// primacy the platform layer can already tell is taken.
851    fn begin_pen_session(&mut self, packet: &PenPacket) -> PenContact {
852        let session = NEXT_PEN_SESSION.fetch_add(1, Ordering::Relaxed);
853        let id = PointerIdAllocator::global().begin(PEN_DEVICE, session);
854        let contact = PenContact {
855            id,
856            session,
857            tool: packet.tool,
858            position: packet.position,
859            down: false,
860            buttons: PenButtons::NONE,
861            primary: self.contacts.is_empty(),
862        };
863        trace_input!(
864            Samples,
865            "pen {:?} in proximity ({:?}) at {:?}",
866            id,
867            packet.tool,
868            packet.position
869        );
870        self.pen_contact = Some(contact);
871        contact
872    }
873
874    /// End a proximity session and release its identity.
875    fn end_pen_session(
876        &mut self,
877        contact: PenContact,
878        position: Point,
879        time: EventTime,
880    ) -> InputSample {
881        PointerIdAllocator::global().end(PEN_DEVICE, contact.session);
882        self.pen_contact = None;
883        trace_input!(Samples, "pen {:?} left proximity", contact.id);
884
885        let mut pointer = PointerInfo::touch(contact.id, time);
886        pointer.kind = PointerKind::Pen(contact.tool);
887        pointer.primary = contact.primary;
888        pointer.buttons = ButtonMask::NONE;
889        InputSample::Pointer(PointerSample {
890            pointer,
891            phase: PointerPhase::Cancel,
892            position,
893            button: None,
894            modifiers: self.current_modifiers,
895            coalesced: Vec::new(),
896        })
897    }
898
899    /// One sample for the session's current state.
900    fn pen_sample(
901        &self,
902        contact: &PenContact,
903        phase: PointerPhase,
904        button: Option<PointerButton>,
905        packet: &PenPacket,
906        time: EventTime,
907    ) -> InputSample {
908        let mut pointer = PointerInfo::touch(contact.id, time);
909        pointer.kind = PointerKind::Pen(contact.tool);
910        pointer.primary = contact.primary;
911        pointer.buttons = pen_button_mask(contact.down, contact.buttons);
912        pointer.axes.pressure = Some(packet.pressure.clamp(0.0, 1.0));
913        pointer.axes.tilt = packet.tilt;
914        pointer.axes.twist = packet.twist;
915        InputSample::Pointer(PointerSample {
916            pointer,
917            phase,
918            position: contact.position,
919            button,
920            modifiers: self.current_modifiers,
921            coalesced: Vec::new(),
922        })
923    }
924
925    /// Advance the scroll-phase machine and translate one `MouseWheel` packet.
926    fn translate_scroll(
927        &mut self,
928        delta: winit::event::MouseScrollDelta,
929        winit_phase: winit::event::TouchPhase,
930    ) -> ScrollSample {
931        use winit::event::TouchPhase;
932
933        let in_handoff = matches!(
934            self.scroll_ended_at,
935            Some(at) if self.now.saturating_since(at) <= MOMENTUM_HANDOFF_WINDOW
936        );
937
938        let phase = match (winit_phase, self.scroll_state) {
939            // A `Started` right after an `Ended` is the OS handing over its own
940            // momentum, not a second gesture. See MOMENTUM_HANDOFF_WINDOW.
941            (TouchPhase::Started, _) if in_handoff => {
942                self.scroll_state = ScrollStreamState::InMomentum;
943                ScrollPhase::Momentum
944            }
945            (TouchPhase::Started, _) => {
946                self.scroll_state = ScrollStreamState::InGesture;
947                self.scroll_ended_at = None;
948                ScrollPhase::Began
949            }
950            (TouchPhase::Moved, ScrollStreamState::InGesture) => ScrollPhase::Changed,
951            (TouchPhase::Moved, ScrollStreamState::InMomentum) => ScrollPhase::Momentum,
952            // A wheel notch: winit reports `Moved` with no `Started` before it
953            // on Windows and X11, and for a non-precise wheel on macOS. This is
954            // the arm every mouse in the world takes, and it is `Discrete` —
955            // exactly what a scroll was before the touch programme.
956            (TouchPhase::Moved, ScrollStreamState::Idle) => ScrollPhase::Discrete,
957            (TouchPhase::Ended, ScrollStreamState::InGesture) => {
958                self.scroll_state = ScrollStreamState::Idle;
959                self.scroll_ended_at = Some(self.now);
960                ScrollPhase::Ended
961            }
962            (TouchPhase::Ended, ScrollStreamState::InMomentum) => {
963                self.scroll_state = ScrollStreamState::Idle;
964                self.scroll_ended_at = None;
965                ScrollPhase::MomentumEnded
966            }
967            // An `Ended` with nothing open. macOS emits one for a two-finger
968            // rest that never became a scroll (`NSEventPhase::MayBegin` then
969            // `Cancelled`). Reporting `Ended` would leave a consumer with an
970            // end it never saw a beginning for, so it degrades to a notch.
971            (TouchPhase::Ended, ScrollStreamState::Idle) => ScrollPhase::Discrete,
972            (TouchPhase::Cancelled, _) => {
973                self.scroll_state = ScrollStreamState::Idle;
974                self.scroll_ended_at = None;
975                ScrollPhase::Cancelled
976            }
977        };
978
979        let (scroll_delta, source) = self.scroll_delta(delta);
980        trace_input!(
981            Samples,
982            "scroll {:?} {:?}/{:?}",
983            scroll_delta,
984            phase,
985            source
986        );
987
988        ScrollSample {
989            delta: scroll_delta,
990            // `None` routes by hover, which is what every scroll in Teksilo
991            // has always done and what an *indirect* pointer wants: a mouse
992            // and a trackpad both move the cursor, so the hovered widget is
993            // by construction the one under the gesture. Only a direct
994            // contact — a synthesised touch pan, which lands with the
995            // kinetic-scrolling package — needs positional routing, because a
996            // finger never writes hover.
997            position: None,
998            phase,
999            source,
1000            pointer: self.mouse_pointer(),
1001            modifiers: self.current_modifiers,
1002        }
1003    }
1004
1005    /// The signed delta and the source a winit scroll delta implies.
1006    fn scroll_delta(&self, delta: winit::event::MouseScrollDelta) -> (ScrollDelta, ScrollSource) {
1007        match delta {
1008            winit::event::MouseScrollDelta::LineDelta(x, y) => (
1009                ScrollDelta::Lines {
1010                    x: -x * self.input.lines_per_notch,
1011                    y: -y * self.input.lines_per_notch,
1012                },
1013                ScrollSource::Wheel,
1014            ),
1015            // winit hands over pixel deltas only where the device reports
1016            // precise scrolling (macOS `hasPreciseScrollingDeltas`, a Wayland
1017            // `axis` in surface-local units), which is a trackpad or a
1018            // free-spinning wheel.
1019            winit::event::MouseScrollDelta::PixelDelta(pos) => (
1020                ScrollDelta::Pixels {
1021                    x: -(pos.x / self.scale_factor) as f32,
1022                    y: -(pos.y / self.scale_factor) as f32,
1023                },
1024                ScrollSource::Trackpad,
1025            ),
1026        }
1027    }
1028}
1029
1030impl Default for TranslationState {
1031    fn default() -> Self {
1032        Self::new()
1033    }
1034}
1035
1036// ---------------------------------------------------------------------------
1037// The backend impl
1038// ---------------------------------------------------------------------------
1039
1040impl PointerBackend for TranslationState {
1041    fn translate(&mut self, event: &BackendEvent<'_>, now: EventTime) -> Vec<InputSample> {
1042        self.set_now(now);
1043        let BackendEvent::Winit(event) = *event;
1044
1045        use winit::event::WindowEvent as WE;
1046        match event {
1047            WE::CursorMoved { position, .. } => {
1048                let logical = Point::new(
1049                    (position.x / self.scale_factor) as f32,
1050                    (position.y / self.scale_factor) as f32,
1051                );
1052                if self.is_phantom_motion(logical) {
1053                    trace_input!(
1054                        Samples,
1055                        "cursor move suppressed (emulated) at {:?}",
1056                        logical
1057                    );
1058                    return Vec::new();
1059                }
1060                self.cursor_position = Some(logical);
1061                let mut sample = PointerSample::mouse(PointerPhase::Move, logical, self.now);
1062                sample.pointer = self.mouse_pointer();
1063                sample.modifiers = self.current_modifiers;
1064                vec![InputSample::Pointer(sample)]
1065            }
1066
1067            WE::MouseInput { state, button, .. } => {
1068                let Some(button) = translate_mouse_button(*button) else {
1069                    return Vec::new();
1070                };
1071                let Some(position) = self.cursor_position else {
1072                    self.note_press_without_cursor();
1073                    return Vec::new();
1074                };
1075                if self.is_promoted_click() {
1076                    trace_input!(
1077                        Samples,
1078                        "mouse {:?} suppressed (promoted from touch)",
1079                        button
1080                    );
1081                    return Vec::new();
1082                }
1083                let phase = match state {
1084                    winit::event::ElementState::Pressed => {
1085                        if !self.mouse_buttons.contains(&button) {
1086                            self.mouse_buttons.push(button);
1087                        }
1088                        PointerPhase::Down
1089                    }
1090                    winit::event::ElementState::Released => {
1091                        self.mouse_buttons.retain(|b| *b != button);
1092                        PointerPhase::Up
1093                    }
1094                };
1095                let mut sample = PointerSample::mouse(phase, position, self.now);
1096                sample.pointer = self.mouse_pointer();
1097                sample.button = Some(button);
1098                sample.modifiers = self.current_modifiers;
1099                vec![InputSample::Pointer(sample)]
1100            }
1101
1102            WE::MouseWheel { delta, phase, .. } => {
1103                vec![InputSample::Scroll(self.translate_scroll(*delta, *phase))]
1104            }
1105
1106            WE::Touch(touch) => self
1107                .translate_touch(touch)
1108                .map(InputSample::Pointer)
1109                .into_iter()
1110                .collect(),
1111
1112            WE::PinchGesture { delta, phase, .. } => {
1113                vec![InputSample::Gesture(pinch_gesture(
1114                    *delta,
1115                    *phase,
1116                    self.cursor_position.unwrap_or(Point::ZERO),
1117                ))]
1118            }
1119
1120            WE::RotationGesture { delta, phase, .. } => {
1121                vec![InputSample::Gesture(rotation_gesture(
1122                    *delta,
1123                    *phase,
1124                    self.cursor_position.unwrap_or(Point::ZERO),
1125                ))]
1126            }
1127
1128            WE::DoubleTapGesture { .. } => vec![InputSample::Gesture(double_tap_gesture(
1129                self.cursor_position.unwrap_or(Point::ZERO),
1130                self.current_modifiers,
1131            ))],
1132
1133            _ => Vec::new(),
1134        }
1135    }
1136
1137    fn capabilities(&self) -> BackendCaps {
1138        let mut caps = BackendCaps::for_platform(self.platform(), self.window_system);
1139        // A pen shim adds what winit cannot report; it never takes anything
1140        // away. On a window with no shim this is a no-op and the row is
1141        // exactly the platform's.
1142        if let Some(pen) = &self.pen {
1143            pen.capabilities().apply_to(&mut caps);
1144        }
1145        caps
1146    }
1147
1148    fn cancel_all(&mut self, now: EventTime) -> Vec<InputSample> {
1149        self.set_now(now);
1150
1151        // Drain in mint order so the oldest contact is cancelled first — the
1152        // order a multi-touch consumer's own bookkeeping is in.
1153        let mut contacts: Vec<((BackendDeviceKey, u64), Contact)> = self.contacts.drain().collect();
1154        contacts.sort_by_key(|(_, contact)| contact.id);
1155
1156        let mut samples: Vec<InputSample> = Vec::with_capacity(contacts.len() + 1);
1157        for ((device, os_id), contact) in contacts {
1158            PointerIdAllocator::global().end(device, os_id);
1159            let mut pointer = PointerInfo::touch(contact.id, self.now);
1160            pointer.primary = contact.primary;
1161            pointer.buttons = ButtonMask::NONE;
1162            trace_input!(Samples, "cancel_all {:?}", contact.id);
1163            samples.push(InputSample::Pointer(PointerSample {
1164                pointer,
1165                phase: PointerPhase::Cancel,
1166                position: contact.position,
1167                button: None,
1168                modifiers: self.current_modifiers,
1169                coalesced: Vec::new(),
1170            }));
1171        }
1172
1173        // A pen in proximity is a live pointer too, held or not: the window
1174        // that is losing the stream is the one that was hovering.
1175        if let Some(contact) = self.pen_contact {
1176            let position = contact.position;
1177            samples.push(self.end_pen_session(contact, position, self.now));
1178        }
1179
1180        // A held mouse button is a live pointer too: a window that loses the
1181        // stream mid-drag must not leave the press unterminated either.
1182        if !self.mouse_buttons.is_empty()
1183            && let Some(position) = self.cursor_position
1184        {
1185            self.mouse_buttons.clear();
1186            let mut sample = PointerSample::mouse(PointerPhase::Cancel, position, self.now);
1187            sample.modifiers = self.current_modifiers;
1188            samples.push(InputSample::Pointer(sample));
1189        }
1190
1191        samples
1192    }
1193}
1194
1195impl TranslationState {
1196    /// Trace the dropped press once per window.
1197    fn note_press_without_cursor(&mut self) {
1198        if !self.warned_press_without_cursor {
1199            self.warned_press_without_cursor = true;
1200            trace_input!(
1201                Samples,
1202                "mouse button dropped: no cursor position yet (was dispatched at the \
1203                 window origin before P15)"
1204            );
1205        }
1206    }
1207}
1208
1209// ---------------------------------------------------------------------------
1210// Helpers
1211// ---------------------------------------------------------------------------
1212
1213/// Derive a stable per-device key from winit's opaque `DeviceId`.
1214///
1215/// `DeviceId` is `Hash + Eq` but its inner value is `pub(crate)`, so hashing is
1216/// the only way to get a number out of it. `DefaultHasher::new()` is seeded
1217/// with fixed keys (it is *not* `RandomState`), so the mapping is deterministic
1218/// within a run and reproducible across runs — which matters because the key
1219/// appears in trace output.
1220///
1221/// A hash collision between two devices would merge their contact id spaces.
1222/// With a 64-bit SipHash and a handful of devices the probability is not worth
1223/// a second field: the birthday bound for 100 devices is about 2.7e-16.
1224fn device_key(device_id: winit::event::DeviceId) -> BackendDeviceKey {
1225    let mut hasher = DefaultHasher::new();
1226    device_id.hash(&mut hasher);
1227    BackendDeviceKey::new(hasher.finish())
1228}
1229
1230/// The button mask a pen holds: the tip is `Primary`, the barrel `Secondary`,
1231/// a second barrel `Middle`.
1232///
1233/// The tip mapping is normative rather than cosmetic — every
1234/// `accept_buttons()` recognizer in the framework gates on
1235/// `ButtonMask::PRIMARY`, so a stylus that reported anything else would be
1236/// invisible to tap, drag and long-press alike.
1237fn pen_button_mask(down: bool, buttons: PenButtons) -> ButtonMask {
1238    let mut mask = ButtonMask::NONE;
1239    if down {
1240        mask = mask.union(PointerButton::Primary.into());
1241    }
1242    if buttons.contains(PenButtons::BARREL) {
1243        mask = mask.union(PointerButton::Secondary.into());
1244    }
1245    if buttons.contains(PenButtons::SECONDARY_BARREL) {
1246        mask = mask.union(PointerButton::Middle.into());
1247    }
1248    mask
1249}
1250
1251/// Whether two points are within `slop` logical pixels of each other.
1252fn near(a: Point, b: Point, slop: f32) -> bool {
1253    (a.x - b.x).abs() <= slop && (a.y - b.y).abs() <= slop
1254}
1255
1256/// Normalised tip pressure from a winit `Force`, or `None` when the device's
1257/// numbers cannot produce one.
1258///
1259/// winit's own `Force::normalized()` divides by `sin(altitude_angle)` to
1260/// recover the component perpendicular to the surface, then by
1261/// `max_possible_force`. Both divisors can be zero — a stylus lying flat on the
1262/// glass has `altitude_angle == 0` — and the result is then infinite rather
1263/// than an error. Teksilo re-implements the conversion so those cases become
1264/// "no pressure reported" instead of an infinity in a `PointerAxes`.
1265fn pressure_from_force(force: winit::event::Force) -> Option<f32> {
1266    let normalized = match force {
1267        winit::event::Force::Normalized(value) => value,
1268        winit::event::Force::Calibrated {
1269            force,
1270            max_possible_force,
1271            altitude_angle,
1272        } => {
1273            if max_possible_force <= 0.0 {
1274                return None;
1275            }
1276            let perpendicular = match altitude_angle {
1277                Some(angle) => {
1278                    let sin = angle.sin();
1279                    if sin <= f64::EPSILON {
1280                        return None;
1281                    }
1282                    force / sin
1283                }
1284                None => force,
1285            };
1286            perpendicular / max_possible_force
1287        }
1288    };
1289    if !normalized.is_finite() {
1290        return None;
1291    }
1292    Some((normalized as f32).clamp(0.0, 1.0))
1293}
1294
1295// ---------------------------------------------------------------------------
1296// The free-function surface
1297// ---------------------------------------------------------------------------
1298
1299/// Translate a winit CursorMoved event to a WidgetEvent::PointerMove.
1300///
1301/// Returns `None` when the move is the emulated pointer following a finger —
1302/// see `TranslationState::is_phantom_motion`. On a platform that does not
1303/// promote touch to mouse (everything but X11) that check is a constant
1304/// `false` and the translation is unchanged.
1305pub fn translate_cursor_moved(
1306    physical_x: f64,
1307    physical_y: f64,
1308    state: &mut TranslationState,
1309) -> Option<WidgetEvent> {
1310    let logical_x = (physical_x / state.scale_factor) as f32;
1311    let logical_y = (physical_y / state.scale_factor) as f32;
1312    let position = Point::new(logical_x, logical_y);
1313    if state.is_phantom_motion(position) {
1314        return None;
1315    }
1316    state.cursor_position = Some(position);
1317    // winit's `CursorMoved` *is* the mouse cursor — a contact takes
1318    // `translate_touch` and a pen `poll_pen`, both of which build a real
1319    // `PointerSample` — so this is the mouse, described by the same
1320    // `mouse_pointer()` the sample path uses (its clock and its held buttons)
1321    // rather than by a bare epoch default. The tracked modifier state travels
1322    // with the move because a drag reads Shift and Ctrl from the move, not from
1323    // the press.
1324    let pointer = state.mouse_pointer();
1325    Some(WidgetEvent::PointerMove {
1326        position,
1327        modifiers: state.current_modifiers,
1328        pointer,
1329    })
1330}
1331
1332/// Translate a winit `Ime` event into a teksilo-core `WidgetEvent`.
1333///
1334/// - `Preedit(text, cursor)` → `ImeComposition`. The `cursor` byte indices
1335///   `(begin, end)` index into the preedit `text` and are preserved as a
1336///   `Range`. `None` (hide-cursor) and empty `text` (winit's synthetic
1337///   clear, emitted right before `Commit`) flow through faithfully.
1338/// - `Commit(text)` → `ImeCommit`.
1339/// - `Enabled` / `Disabled` are OS acknowledgements (enablement is driven
1340///   by the focused node's descriptor) and produce no tree event.
1341pub fn translate_ime(ime: winit::event::Ime) -> Option<WidgetEvent> {
1342    match ime {
1343        winit::event::Ime::Preedit(text, cursor) => Some(WidgetEvent::ImeComposition {
1344            text,
1345            cursor: cursor.map(|(begin, end)| begin..end),
1346        }),
1347        winit::event::Ime::Commit(text) => Some(WidgetEvent::ImeCommit { text }),
1348        winit::event::Ime::Enabled | winit::event::Ime::Disabled => None,
1349    }
1350}
1351
1352/// Translate a winit mouse button to a teksilo-core PointerButton.
1353pub fn translate_mouse_button(button: winit::event::MouseButton) -> Option<PointerButton> {
1354    match button {
1355        winit::event::MouseButton::Left => Some(PointerButton::Primary),
1356        winit::event::MouseButton::Right => Some(PointerButton::Secondary),
1357        winit::event::MouseButton::Middle => Some(PointerButton::Middle),
1358        winit::event::MouseButton::Back => Some(PointerButton::Back),
1359        winit::event::MouseButton::Forward => Some(PointerButton::Forward),
1360        // MouseButton::Other(_) — vendor-specific extra buttons we don't
1361        // currently surface. Returning None drops the event.
1362        _ => None,
1363    }
1364}
1365
1366/// Translate a winit ElementState + MouseButton to PointerDown/Up.
1367///
1368/// Returns `None` when no cursor position is known yet. This used to dispatch
1369/// the press at `Point::ZERO`, which is a click on whatever sits in the
1370/// window's top-left corner — a real misfire on every platform that can deliver
1371/// a button before a motion (X11 with a grab, a synthetic click, a window that
1372/// gains the pointer already pressed).
1373pub fn translate_mouse_input(
1374    button_state: winit::event::ElementState,
1375    button: winit::event::MouseButton,
1376    state: &TranslationState,
1377) -> Option<WidgetEvent> {
1378    let pointer_button = translate_mouse_button(button)?;
1379    let position = state.cursor_position?;
1380    if state.is_promoted_click() {
1381        return None;
1382    }
1383    // winit's `MouseInput` is the mouse's own button; a contact's press comes
1384    // through `translate_touch` as a `PointerSample`.
1385    let pointer = state.mouse_pointer();
1386    match button_state {
1387        winit::event::ElementState::Pressed => Some(WidgetEvent::PointerDown {
1388            position,
1389            button: pointer_button,
1390            modifiers: state.current_modifiers,
1391            pointer,
1392        }),
1393        winit::event::ElementState::Released => Some(WidgetEvent::PointerUp {
1394            position,
1395            button: pointer_button,
1396            modifiers: state.current_modifiers,
1397            pointer,
1398        }),
1399    }
1400}
1401
1402/// Translate winit keyboard modifiers to teksilo-core Modifiers.
1403pub fn translate_modifiers(mods: winit::keyboard::ModifiersState) -> Modifiers {
1404    let mut result = Modifiers::NONE;
1405    if mods.control_key() {
1406        result = result | Modifiers::CTRL;
1407    }
1408    if mods.shift_key() {
1409        result = result | Modifiers::SHIFT;
1410    }
1411    if mods.alt_key() {
1412        result = result | Modifiers::ALT;
1413    }
1414    if mods.super_key() {
1415        result = result | Modifiers::SUPER;
1416    }
1417    result
1418}
1419
1420/// Translate a winit logical key to a teksilo-core Key.
1421pub fn translate_key(key: &winit::keyboard::Key) -> Option<Key> {
1422    match key {
1423        winit::keyboard::Key::Named(named) => translate_named_key(*named),
1424        winit::keyboard::Key::Character(c) => {
1425            let ch = c.chars().next()?;
1426            match ch.to_ascii_uppercase() {
1427                'A' => Some(Key::A),
1428                'B' => Some(Key::B),
1429                'C' => Some(Key::C),
1430                'D' => Some(Key::D),
1431                'E' => Some(Key::E),
1432                'F' => Some(Key::F),
1433                'G' => Some(Key::G),
1434                'H' => Some(Key::H),
1435                'I' => Some(Key::I),
1436                'J' => Some(Key::J),
1437                'K' => Some(Key::K),
1438                'L' => Some(Key::L),
1439                'M' => Some(Key::M),
1440                'N' => Some(Key::N),
1441                'O' => Some(Key::O),
1442                'P' => Some(Key::P),
1443                'Q' => Some(Key::Q),
1444                'R' => Some(Key::R),
1445                'S' => Some(Key::S),
1446                'T' => Some(Key::T),
1447                'U' => Some(Key::U),
1448                'V' => Some(Key::V),
1449                'W' => Some(Key::W),
1450                'X' => Some(Key::X),
1451                'Y' => Some(Key::Y),
1452                'Z' => Some(Key::Z),
1453                _ => Some(Key::Character(ch)),
1454            }
1455        }
1456        _ => None,
1457    }
1458}
1459
1460fn translate_named_key(key: winit::keyboard::NamedKey) -> Option<Key> {
1461    use winit::keyboard::NamedKey;
1462    match key {
1463        NamedKey::Space => Some(Key::Space),
1464        NamedKey::Enter => Some(Key::Enter),
1465        NamedKey::Escape => Some(Key::Escape),
1466        NamedKey::Tab => Some(Key::Tab),
1467        NamedKey::Backspace => Some(Key::Backspace),
1468        NamedKey::Delete => Some(Key::Delete),
1469        NamedKey::Insert => Some(Key::Insert),
1470        NamedKey::ArrowUp => Some(Key::ArrowUp),
1471        NamedKey::ArrowDown => Some(Key::ArrowDown),
1472        NamedKey::ArrowLeft => Some(Key::ArrowLeft),
1473        NamedKey::ArrowRight => Some(Key::ArrowRight),
1474        NamedKey::Home => Some(Key::Home),
1475        NamedKey::End => Some(Key::End),
1476        NamedKey::PageUp => Some(Key::PageUp),
1477        NamedKey::PageDown => Some(Key::PageDown),
1478        NamedKey::F1 => Some(Key::F1),
1479        NamedKey::F2 => Some(Key::F2),
1480        NamedKey::F3 => Some(Key::F3),
1481        NamedKey::F4 => Some(Key::F4),
1482        NamedKey::F5 => Some(Key::F5),
1483        NamedKey::F6 => Some(Key::F6),
1484        NamedKey::F7 => Some(Key::F7),
1485        NamedKey::F8 => Some(Key::F8),
1486        NamedKey::F9 => Some(Key::F9),
1487        NamedKey::F10 => Some(Key::F10),
1488        NamedKey::F11 => Some(Key::F11),
1489        NamedKey::F12 => Some(Key::F12),
1490        NamedKey::F13 => Some(Key::F13),
1491        NamedKey::F14 => Some(Key::F14),
1492        NamedKey::F15 => Some(Key::F15),
1493        NamedKey::F16 => Some(Key::F16),
1494        NamedKey::F17 => Some(Key::F17),
1495        NamedKey::F18 => Some(Key::F18),
1496        NamedKey::F19 => Some(Key::F19),
1497        NamedKey::F20 => Some(Key::F20),
1498        NamedKey::F21 => Some(Key::F21),
1499        NamedKey::F22 => Some(Key::F22),
1500        NamedKey::F23 => Some(Key::F23),
1501        NamedKey::F24 => Some(Key::F24),
1502        // Caps Lock arrives as a discrete press/release. winit's
1503        // `ModifiersState` carries no lock state, so the window manager
1504        // tracks the active state itself on the key-down edge (drives
1505        // `WindowState::caps_lock` for the password-field warning).
1506        NamedKey::CapsLock => Some(Key::CapsLock),
1507        // Windows `VK_APPS`, X11/Wayland `keysyms::Menu`. macOS produces this
1508        // zero times, which is why the dispatcher also reserves a chord.
1509        NamedKey::ContextMenu => Some(Key::ContextMenu),
1510        _ => None,
1511    }
1512}
1513
1514/// Translate a winit MouseWheel event to a WidgetEvent::Scroll.
1515///
1516/// The lines-per-notch factor comes from
1517/// [`InputTokens::lines_per_notch`](teksilo_tokens::InputTokens::lines_per_notch)
1518/// on the state's installed tokens; its default is 3.0, the Windows/GTK
1519/// default and the constant this function used to hardcode.
1520pub fn translate_mouse_wheel(
1521    delta: winit::event::MouseScrollDelta,
1522    _phase: winit::event::TouchPhase,
1523    state: &TranslationState,
1524) -> Option<WidgetEvent> {
1525    // Winit uses "natural" sign: positive y = scroll up (content moves down).
1526    // Teksilo's ScrollDelta uses positive y = increase scroll offset (content
1527    // moves up). `scroll_delta` negates both axes to match.
1528    let (scroll_delta, _) = state.scroll_delta(delta);
1529    Some(WidgetEvent::scroll(scroll_delta, state.current_modifiers))
1530}
1531
1532// --- Desktop trackpad gesture passthrough ---
1533// On desktop, most gestures arrive as already-recognized events from the OS
1534// trackpad driver. These functions translate winit's high-level gesture events
1535// into Teksilo GestureEvents. They are reached two ways: as
1536// `WidgetEvent::Gesture` through the free functions below, and as
1537// `InputSample::Gesture` through `PointerBackend::translate`.
1538
1539/// A winit PinchGesture as a Teksilo gesture.
1540///
1541/// winit's `delta` is the change in magnification *for this event*, so
1542/// `1.0 + delta` is already the per-sample factor
1543/// [`GestureEvent::PinchChanged`] asks for — nothing accumulates here.
1544fn pinch_gesture(delta: f64, phase: winit::event::TouchPhase, center: Point) -> GestureEvent {
1545    match phase {
1546        winit::event::TouchPhase::Started => GestureEvent::PinchStarted { center },
1547        winit::event::TouchPhase::Moved => GestureEvent::PinchChanged {
1548            center,
1549            scale: 1.0 + delta as f32,
1550            rotation: 0.0,
1551        },
1552        winit::event::TouchPhase::Ended | winit::event::TouchPhase::Cancelled => {
1553            GestureEvent::PinchEnded
1554        }
1555    }
1556}
1557
1558/// A winit RotationGesture as a Teksilo gesture.
1559///
1560/// **This is where the unit is decided.** winit reports the delta in degrees
1561/// (`NSEvent.rotation` on the one backend that produces the event), and
1562/// [`GestureEvent::PinchChanged`]'s `rotation` is radians, so the conversion
1563/// belongs here — at the seam, where the incoming unit is known — and not in a
1564/// consumer: `on_pinch` has one ingress and potentially several consumers, and
1565/// each converting for itself is how the two would drift apart again.
1566fn rotation_gesture(
1567    delta_degrees: f32,
1568    phase: winit::event::TouchPhase,
1569    center: Point,
1570) -> GestureEvent {
1571    match phase {
1572        winit::event::TouchPhase::Started => GestureEvent::PinchStarted { center },
1573        winit::event::TouchPhase::Moved => GestureEvent::PinchChanged {
1574            center,
1575            scale: 1.0,
1576            rotation: delta_degrees.to_radians(),
1577        },
1578        winit::event::TouchPhase::Ended | winit::event::TouchPhase::Cancelled => {
1579            GestureEvent::PinchEnded
1580        }
1581    }
1582}
1583
1584/// A winit DoubleTapGesture as a Teksilo gesture.
1585fn double_tap_gesture(position: Point, modifiers: Modifiers) -> GestureEvent {
1586    GestureEvent::DoubleTap(TapEvent::new(position, PointerButton::Primary, modifiers))
1587}
1588
1589/// Translate a winit PinchGesture into a Teksilo gesture event.
1590/// Returns PinchStarted on Started phase, PinchChanged on Changed, PinchEnded on Ended.
1591pub fn translate_pinch_gesture(
1592    delta: f64,
1593    phase: winit::event::TouchPhase,
1594    state: &TranslationState,
1595) -> Option<WidgetEvent> {
1596    let center = state.cursor_position.unwrap_or(Point::ZERO);
1597    Some(WidgetEvent::Gesture {
1598        gesture: pinch_gesture(delta, phase, center),
1599    })
1600}
1601
1602/// Translate a winit RotationGesture into a PinchChanged with rotation.
1603/// Rotation gestures are folded into the pinch gesture model since they
1604/// typically co-occur with pinch on trackpads.
1605///
1606/// The delta arrives in degrees and leaves in radians: winit reports the twist
1607/// in degrees and [`GestureEvent::PinchChanged`]'s `rotation` is radians, so the
1608/// conversion is made here, at the seam where the incoming unit is known, rather
1609/// than in each consumer.
1610pub fn translate_rotation_gesture(
1611    delta_degrees: f32,
1612    phase: winit::event::TouchPhase,
1613    state: &TranslationState,
1614) -> Option<WidgetEvent> {
1615    let center = state.cursor_position.unwrap_or(Point::ZERO);
1616    Some(WidgetEvent::Gesture {
1617        gesture: rotation_gesture(delta_degrees, phase, center),
1618    })
1619}
1620
1621/// Translate a winit DoubleTapGesture (trackpad smart magnification).
1622///
1623/// Synthetic OS-driven double-tap: there's no underlying mouse button
1624/// or modifier set the OS hands us, so we attribute it to
1625/// `PointerButton::Primary` with no modifiers. Apps that need richer
1626/// trackpad-gesture metadata should match on `WidgetEvent::Gesture`
1627/// directly rather than hooking `on_double_tap`.
1628pub fn translate_double_tap_gesture(state: &TranslationState) -> Option<WidgetEvent> {
1629    let position = state.cursor_position.unwrap_or(Point::ZERO);
1630    Some(WidgetEvent::Gesture {
1631        gesture: double_tap_gesture(position, state.current_modifiers),
1632    })
1633}
1634
1635/// Fold each run of consecutive pure-motion pen samples into its newest member,
1636/// moving the older positions into
1637/// [`PointerSample::coalesced`](teksilo_core::PointerSample::coalesced).
1638///
1639/// "Pure motion" is a [`PointerPhase::Move`] carrying no button change, **and
1640/// not named in `pinned`**. Everything else — Down, Up, Cancel, a move that
1641/// reports a button, a scroll, an OS gesture — ends the run and is emitted in
1642/// place, so the *sequence* a recognizer sees is the one it saw before, minus
1643/// some of the moves. A hover run and a contact run are therefore never folded
1644/// together: the Down between them is not foldable.
1645///
1646/// # `pinned`
1647///
1648/// Indices into `samples` **as given**, of samples that are transitions wearing
1649/// a `Move`'s clothes. A proximity enter is the one the pen path produces:
1650/// [`PointerPhase`] has no *enter*, so a tool coming into range is carried as a
1651/// move with nothing held, and folding it away costs a `PointerEnter` outright
1652/// — the tree reads the hover owner off `position`, never off the batched list.
1653/// `poll_pen` finds them; this only has to respect them. A pinned sample neither
1654/// absorbs its predecessor nor is absorbed by its successor, so it keeps its
1655/// own dispatch in both directions.
1656///
1657/// A drain holds a handful of packets, so the membership test is a linear scan
1658/// of a list that is empty on all but the entering drain.
1659///
1660/// Each folded position keeps the time and the axes it was sampled with: a
1661/// digitizer varies pressure across a batch, and collapsing that to the newest
1662/// packet's reading is exactly the loss this exists to avoid.
1663///
1664/// A sample that already carries a coalesced list — a source that coalesced for
1665/// itself — keeps it, and it stays ahead of the position it was batched with,
1666/// so the whole list is still oldest-first.
1667///
1668/// Free function rather than a method so it can be tested on a hand-built
1669/// `Vec<InputSample>`, with no shim, no window and no clock.
1670fn coalesce_pen_moves(samples: &mut Vec<InputSample>, pinned: &[usize]) {
1671    let foldable = |index: usize, sample: &InputSample| {
1672        matches!(
1673            sample,
1674            InputSample::Pointer(p) if p.phase == PointerPhase::Move && p.button.is_none()
1675        ) && !pinned.contains(&index)
1676    };
1677
1678    // Nothing to do unless two foldable moves are adjacent. Worth the scan: the
1679    // steady state at a 4 ms poll is a drain of one.
1680    if !samples
1681        .windows(2)
1682        .enumerate()
1683        .any(|(i, w)| foldable(i, &w[0]) && foldable(i + 1, &w[1]))
1684    {
1685        return;
1686    }
1687
1688    // Each entry keeps the index it arrived at, so `pinned` — which names
1689    // positions in the input — stays meaningful as the output shortens.
1690    let mut out: Vec<(usize, InputSample)> = Vec::with_capacity(samples.len());
1691    for (index, sample) in samples.drain(..).enumerate() {
1692        if !foldable(index, &sample) {
1693            out.push((index, sample));
1694            continue;
1695        }
1696        let InputSample::Pointer(mut current) = sample else {
1697            unreachable!("`foldable` matched a pointer sample")
1698        };
1699        if out.last().is_some_and(|(i, s)| foldable(*i, s)) {
1700            let Some((_, InputSample::Pointer(previous))) = out.pop() else {
1701                unreachable!("just matched")
1702            };
1703            // `previous` was the run's newest until now; it becomes a batched
1704            // position of `current`, behind anything it was already carrying
1705            // and ahead of anything `current` was.
1706            let mut merged = previous.coalesced;
1707            merged.push(
1708                teksilo_core::CoalescedSample::new(previous.pointer.time, previous.position)
1709                    .with_axes(previous.pointer.axes),
1710            );
1711            merged.append(&mut current.coalesced);
1712            current.coalesced = merged;
1713        }
1714        out.push((index, InputSample::Pointer(current)));
1715    }
1716    *samples = out.into_iter().map(|(_, sample)| sample).collect();
1717}
1718
1719#[cfg(test)]
1720mod tests;