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::{PenButtons, PenPacket, PenSource};
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}
220
221impl TranslationState {
222    /// A fresh per-window state: scale 1.0, no cursor, no modifiers, no
223    /// contacts, default input tokens, `WindowSystem::Unknown`.
224    pub fn new() -> Self {
225        Self {
226            scale_factor: 1.0,
227            cursor_position: None,
228            current_modifiers: Modifiers::NONE,
229            window_system: WindowSystem::Unknown,
230            input: InputTokens::default(),
231            now: EventTime::ZERO,
232            contacts: HashMap::new(),
233            last_lift: None,
234            mouse_buttons: Vec::new(),
235            scroll_state: ScrollStreamState::default(),
236            scroll_ended_at: None,
237            warned_press_without_cursor: false,
238            pen: None,
239            pen_contact: None,
240            pen_scratch: Vec::new(),
241        }
242    }
243
244    pub fn set_scale_factor(&mut self, factor: f64) {
245        self.scale_factor = factor;
246    }
247
248    pub fn scale_factor(&self) -> f64 {
249        self.scale_factor
250    }
251
252    pub fn cursor_position(&self) -> Option<Point> {
253        self.cursor_position
254    }
255
256    pub fn set_modifiers(&mut self, modifiers: Modifiers) {
257        self.current_modifiers = modifiers;
258    }
259
260    /// The modifiers last reported by the OS.
261    pub fn modifiers(&self) -> Modifiers {
262        self.current_modifiers
263    }
264
265    /// Tell the translator which window system this window runs on.
266    ///
267    /// Read it from the live window with
268    /// [`window_system_for_display_handle`](crate::window_system::window_system_for_display_handle)
269    /// — never from the environment, which lies in a Wayland session running
270    /// an X11 client.
271    ///
272    /// This selects the touch/mouse dual-stream suppressors; see
273    /// [`BackendCaps::synthesises_mouse_from_touch`].
274    pub fn set_window_system(&mut self, window_system: WindowSystem) {
275        self.window_system = window_system;
276    }
277
278    /// The window system this state is translating for.
279    pub fn window_system(&self) -> WindowSystem {
280        self.window_system
281    }
282
283    /// Install the input tokens in force.
284    ///
285    /// The translator deliberately holds an [`InputTokens`] rather than a
286    /// `Theme`: `teksilo-platform` already depends on `teksilo-tokens`, so this
287    /// costs no new dependency and no dependency inversion (the token crate is
288    /// a leaf and knows nothing of the widget tree). The caller re-installs
289    /// them whenever the theme changes.
290    pub fn set_input_tokens(&mut self, input: InputTokens) {
291        self.input = input;
292    }
293
294    /// The input tokens in force.
295    pub fn input_tokens(&self) -> &InputTokens {
296        &self.input
297    }
298
299    /// Advance the translator's notion of now.
300    ///
301    /// [`PointerBackend::translate`] does this itself from its `now` argument;
302    /// a caller still on the free-function surface calls it once per event
303    /// batch so the suppression windows are measured against the tree's clock
304    /// rather than against nothing.
305    pub fn set_now(&mut self, now: EventTime) {
306        if now > self.now {
307            self.now = now;
308        }
309    }
310
311    /// The translator's notion of now.
312    pub fn now(&self) -> EventTime {
313        self.now
314    }
315
316    /// How many contacts are currently down.
317    pub fn live_contact_count(&self) -> usize {
318        self.contacts.len()
319    }
320
321    /// The capability-matrix row this window belongs to.
322    ///
323    /// `WindowSystem::{X11, Wayland}` are only ever reported for an Xlib, Xcb
324    /// or Wayland display handle, and `active_window_system` is `Unknown` off
325    /// Unix — so a window that knows its window system also knows it is on
326    /// Unix. Deriving the row from that, rather than from
327    /// [`PlatformKind::HOST`] alone, is what keeps the matrix the *pure
328    /// function* its docs promise: the X11 row stays assertable from a Windows
329    /// or macOS host, which is what the phantom-suppression tests and the
330    /// backend-conformance vectors need. `Unknown` carries no such implication
331    /// and falls back to the compile-time host.
332    ///
333    /// In production this is a no-op: the only window system a non-Unix host
334    /// can report is `Unknown`.
335    fn platform(&self) -> PlatformKind {
336        match self.window_system {
337            WindowSystem::X11 | WindowSystem::Wayland => PlatformKind::Unix,
338            WindowSystem::Unknown => PlatformKind::HOST,
339        }
340    }
341
342    /// Whether this window's platform also synthesises a mouse stream from
343    /// touch, so that one of the two must be suppressed.
344    fn promotes_touch_to_mouse(&self) -> bool {
345        BackendCaps::for_platform(self.platform(), self.window_system).synthesises_mouse_from_touch
346    }
347
348    /// Whether a `CursorMoved` at `position` is the emulated pointer following
349    /// a finger rather than a mouse the user is moving.
350    ///
351    /// **While a contact is live, every `CursorMoved` is dropped.** X11 warps
352    /// the virtual core pointer onto the first concurrently-active contact and
353    /// reports it through the *same* virtual device a real mouse uses
354    /// (`util::VIRTUAL_CORE_POINTER`), so the two are indistinguishable at this
355    /// layer — winit filters emulated *buttons* by `XIPointerEmulated` but
356    /// emits this motion itself, deliberately, on every phase of the first
357    /// contact. A rule that only dropped moves *within a pixel* of a contact
358    /// would let every sample of a moving finger through.
359    ///
360    /// After the lift the core pointer stays parked at the lift point, so the
361    /// narrow proximity rule takes over: a move still at that point is the
362    /// ghost, a move anywhere else is a real mouse and gets through at once.
363    ///
364    /// # Residual
365    ///
366    /// winit emits its synthetic `CursorMoved` **before** the `Touch` packet
367    /// that establishes the contact, so the very first move of a touch session
368    /// that follows more than [`PHANTOM_LIFT_WINDOW`] of quiet still leaks one
369    /// sample. Closing it would need one event of lookahead, which would cost
370    /// every real X11 mouse move a frame of latency. Documented rather than
371    /// paid for.
372    fn is_phantom_motion(&self, position: Point) -> bool {
373        if !self.promotes_touch_to_mouse() {
374            return false;
375        }
376        if !self.contacts.is_empty() {
377            return true;
378        }
379        match self.last_lift {
380            Some((lift, at)) if self.now.saturating_since(at) <= PHANTOM_LIFT_WINDOW => {
381                near(lift, position, PHANTOM_SLOP)
382            }
383            _ => false,
384        }
385    }
386
387    /// Whether a mouse button event is the OS's promoted click for a tap that
388    /// already reached the tree as touch.
389    ///
390    /// Defence in depth: winit 0.30 already drops X11's `XIPointerEmulated`
391    /// button events, so on today's backends this window never fires. It is
392    /// here because "the OS also sends a click" is the single most common way
393    /// a touch port double-fires, and because a backend that does *not* filter
394    /// (Android, Web, a future X11 rework) must not be able to introduce it
395    /// silently.
396    fn is_promoted_click(&self) -> bool {
397        if !self.promotes_touch_to_mouse() {
398            return false;
399        }
400        if !self.contacts.is_empty() {
401            return true;
402        }
403        matches!(
404            self.last_lift,
405            Some((_, at)) if self.now.saturating_since(at) <= PROMOTED_CLICK_WINDOW
406        )
407    }
408
409    /// The mouse's pointer identity as of now.
410    fn mouse_pointer(&self) -> PointerInfo {
411        let mut info = PointerInfo::mouse(self.now);
412        info.buttons = self
413            .mouse_buttons
414            .iter()
415            .fold(ButtonMask::NONE, |mask, b| mask.union((*b).into()));
416        info
417    }
418
419    /// Translate one winit `Touch` packet.
420    ///
421    /// Returns `None` — with nothing recorded and no id minted — when touch is
422    /// disabled, or when the packet belongs to a contact this window never saw
423    /// go down (a stream that began before the window was listening, or before
424    /// the kill switch was flipped on). Emitting an `Up` for a `Down` that
425    /// never happened would break the cancel-completeness invariant just as
426    /// surely as dropping one.
427    fn translate_touch(&mut self, touch: &winit::event::Touch) -> Option<PointerSample> {
428        if !self.input.touch_enabled {
429            trace_input!(
430                Samples,
431                "touch dropped: touch_enabled=false (os id {})",
432                touch.id
433            );
434            return None;
435        }
436
437        let device = device_key(touch.device_id);
438        let key = (device, touch.id);
439        let position = Point::new(
440            (touch.location.x / self.scale_factor) as f32,
441            (touch.location.y / self.scale_factor) as f32,
442        );
443
444        let (phase, id, primary) = match touch.phase {
445            winit::event::TouchPhase::Started => {
446                // A fresh identity per press. winit reuses `Touch::id` after a
447                // lift, and a table keyed on the raw id would hand the new
448                // contact the old one's gesture state.
449                let id = PointerIdAllocator::global().begin(device, touch.id);
450                let primary = self.contacts.is_empty();
451                self.contacts.insert(
452                    key,
453                    Contact {
454                        id,
455                        position,
456                        primary,
457                    },
458                );
459                (PointerPhase::Down, id, primary)
460            }
461            winit::event::TouchPhase::Moved => {
462                let contact = self.contacts.get_mut(&key)?;
463                contact.position = position;
464                (PointerPhase::Move, contact.id, contact.primary)
465            }
466            winit::event::TouchPhase::Ended | winit::event::TouchPhase::Cancelled => {
467                let contact = self.contacts.remove(&key)?;
468                PointerIdAllocator::global().end(device, touch.id);
469                self.last_lift = Some((position, self.now));
470                let phase = if matches!(touch.phase, winit::event::TouchPhase::Ended) {
471                    PointerPhase::Up
472                } else {
473                    PointerPhase::Cancel
474                };
475                (phase, contact.id, contact.primary)
476            }
477        };
478
479        let mut pointer = PointerInfo::touch(id, self.now);
480        pointer.primary = primary;
481        // A finger holds the primary "button" for as long as it is down. This
482        // is normative, not cosmetic: every `accept_buttons()` recognizer in
483        // the framework gates on `ButtonMask::PRIMARY`, so a contact that
484        // reported an empty mask would be invisible to tap, drag, long-press
485        // and multi-tap alike.
486        pointer.buttons = match phase {
487            PointerPhase::Down | PointerPhase::Move => ButtonMask::PRIMARY,
488            PointerPhase::Up | PointerPhase::Cancel => ButtonMask::NONE,
489        };
490        pointer.axes.pressure = touch.force.and_then(pressure_from_force);
491        // The contact patch, where a shim can supply one winit cannot. Windows
492        // is the only platform that reports it today: `POINTER_TOUCH_INFO`
493        // carries `rcContact` and `WM_TOUCH` — the path winit 0.30 takes —
494        // does not.
495        pointer.axes.contact = self
496            .pen
497            .as_ref()
498            .and_then(|source| source.touch_contact(touch.id));
499
500        // A direct pointer reports the button that changed on the two phases
501        // that change one. A move never does, and a cancel has no meaningful
502        // end state at all.
503        let button = match phase {
504            PointerPhase::Down | PointerPhase::Up => Some(PointerButton::Primary),
505            _ => None,
506        };
507
508        trace_input!(
509            Samples,
510            "touch {:?} {:?} os_id={} at {:?}",
511            phase,
512            id,
513            touch.id,
514            position
515        );
516
517        Some(PointerSample {
518            pointer,
519            phase,
520            position,
521            button,
522            modifiers: self.current_modifiers,
523            coalesced: Vec::new(),
524        })
525    }
526
527    // -----------------------------------------------------------------
528    // Pen
529    // -----------------------------------------------------------------
530
531    /// Install this window's pen shim.
532    ///
533    /// Build one with [`create_pen_source`](crate::pen::create_pen_source),
534    /// which answers [`NullPenSource`](crate::pen::null::NullPenSource) where
535    /// the platform has no pen path. A window with no source simply never
536    /// produces a pen sample — which is also the pen's rollback switch, since
537    /// `InputTokens::touch_enabled` deliberately does **not** gate it: a
538    /// stylus is not a finger, and rolling touch back must not take the pen
539    /// with it.
540    pub fn set_pen_source(&mut self, source: Box<dyn PenSource>) {
541        self.pen = Some(source);
542    }
543
544    /// Remove and return this window's pen shim.
545    ///
546    /// Any live proximity session is *not* terminated here — call
547    /// [`cancel_all`](PointerBackend::cancel_all) first if the pointer has to
548    /// be ended cleanly.
549    pub fn take_pen_source(&mut self) -> Option<Box<dyn PenSource>> {
550        self.pen.take()
551    }
552
553    /// Whether a pen shim is installed.
554    pub fn has_pen_source(&self) -> bool {
555        self.pen.is_some()
556    }
557
558    /// Whether the installed shim fills its buffer from a thread of its own.
559    ///
560    /// See [`PenSource::polls_off_thread`]: it is what tells the event loop
561    /// whether draining once per turn is enough. `false` with no shim.
562    pub fn pen_polls_off_thread(&self) -> bool {
563        self.pen.as_ref().is_some_and(|p| p.polls_off_thread())
564    }
565
566    /// Whether a tool is currently in proximity — i.e. whether a pen is
567    /// hovering or drawing right now.
568    pub fn pen_in_proximity(&self) -> bool {
569        self.pen_contact.is_some()
570    }
571
572    /// Drain the pen shim and translate everything it buffered.
573    ///
574    /// Call once per event-loop turn, alongside the winit events. Cheap and
575    /// allocation-free when no stylus is in use: the shim returns nothing and
576    /// the packet buffer is reused.
577    pub fn poll_pen(&mut self, now: EventTime) -> Vec<InputSample> {
578        self.set_now(now);
579        // Take the source out so the translation below can borrow `self`
580        // mutably; it goes straight back.
581        let Some(mut source) = self.pen.take() else {
582            return Vec::new();
583        };
584        let mut packets = std::mem::take(&mut self.pen_scratch);
585        packets.clear();
586        source.poll(&mut packets);
587        self.pen = Some(source);
588
589        let mut samples = Vec::new();
590        for packet in &packets {
591            samples.append(&mut self.translate_pen_packet(packet));
592        }
593        packets.clear();
594        self.pen_scratch = packets;
595        samples
596    }
597
598    /// Turn one digitizer packet into the samples its transitions imply.
599    ///
600    /// Public so a replay backend, or a platform shim Teksilo has not met, can
601    /// feed the same state machine without reimplementing it.
602    ///
603    /// # The state machine
604    ///
605    /// A packet is a *level*; the transitions are derived by comparing it with
606    /// the session's previous state.
607    ///
608    /// | transition | sample |
609    /// | --- | --- |
610    /// | out of range → in range | `Move` (a hover: no buttons, `down` false) |
611    /// | position changed | `Move` |
612    /// | tip touched down | `Down` with [`PointerButton::Primary`] |
613    /// | tip lifted | `Up` with `Primary` |
614    /// | barrel pressed / released | `Down` / `Up` with `Secondary` |
615    /// | second barrel | `Down` / `Up` with `Middle` |
616    /// | in range → out of range | `Cancel` |
617    /// | tool changed mid-session | `Cancel`, then a fresh session |
618    ///
619    /// Within one packet the `Move` is emitted **first**, so a press always
620    /// lands at a position the consumer has already seen.
621    ///
622    /// # Why leaving proximity is a `Cancel`
623    ///
624    /// [`PointerPhase`] has no *leave*, and a tool going out of range
625    /// completes nothing: the completion, if there was one, was the tip's
626    /// `Up`, which has already been delivered. `Cancel` is the phase that says
627    /// "this pointer's life ended without completing an interaction", which is
628    /// exactly what happened — and it is the right thing for the down case
629    /// too, where a stylus yanked off the tablet mid-stroke must not read as a
630    /// deliberate lift.
631    pub fn translate_pen_packet(&mut self, packet: &PenPacket) -> Vec<InputSample> {
632        let time = if packet.time == EventTime::ZERO {
633            self.now
634        } else {
635            packet.time
636        };
637        let mut samples = Vec::new();
638
639        // End the session first when the tool left range, or when the tool
640        // itself changed under us (pen → eraser is a different pointer).
641        if let Some(contact) = self.pen_contact
642            && (!packet.in_proximity || contact.tool != packet.tool)
643        {
644            samples.push(self.end_pen_session(contact, packet.position, time));
645        }
646        if !packet.in_proximity {
647            return samples;
648        }
649
650        let (mut state, just_entered) = match self.pen_contact {
651            Some(contact) => (contact, false),
652            None => {
653                let contact = self.begin_pen_session(packet);
654                // The hover enter: a `Move` with nothing held, at the position
655                // the tool came into range at.
656                samples.push(self.pen_sample(&contact, PointerPhase::Move, None, packet, time));
657                (contact, true)
658            }
659        };
660
661        // Which buttons changed, in a fixed order: the tip first, then the
662        // barrel, so a press-while-moving reads the same way every time.
663        let mut transitions: Vec<(PointerPhase, PointerButton)> = Vec::new();
664        if state.down != packet.down {
665            transitions.push((
666                if packet.down {
667                    PointerPhase::Down
668                } else {
669                    PointerPhase::Up
670                },
671                PointerButton::Primary,
672            ));
673        }
674        for (bit, button) in [
675            (PenButtons::BARREL, PointerButton::Secondary),
676            (PenButtons::SECONDARY_BARREL, PointerButton::Middle),
677        ] {
678            let was = state.buttons.contains(bit);
679            let held = packet.buttons.contains(bit);
680            if was != held {
681                transitions.push((
682                    if held {
683                        PointerPhase::Down
684                    } else {
685                        PointerPhase::Up
686                    },
687                    button,
688                ));
689            }
690        }
691
692        let moved = state.position != packet.position;
693        state.position = packet.position;
694        // A packet with no transition still says something — a pressure ramp,
695        // a tilt change — so it becomes a `Move` even when the position stood
696        // still. The entering packet is the exception: its `Move` has already
697        // been emitted above, and repeating it would double every hover.
698        if !just_entered && (moved || transitions.is_empty()) {
699            samples.push(self.pen_sample(&state, PointerPhase::Move, None, packet, time));
700        }
701        for (phase, button) in transitions {
702            let pressed = phase == PointerPhase::Down;
703            match button {
704                PointerButton::Primary => state.down = pressed,
705                PointerButton::Secondary => {
706                    state.buttons = state.buttons.with(PenButtons::BARREL, pressed);
707                }
708                _ => {
709                    state.buttons = state.buttons.with(PenButtons::SECONDARY_BARREL, pressed);
710                }
711            }
712            samples.push(self.pen_sample(&state, phase, Some(button), packet, time));
713        }
714
715        // Carry the packet's raw flags (the eraser bit among them) forward, so
716        // the next comparison is against what the device actually said.
717        state.buttons = packet.buttons;
718        state.down = packet.down;
719        self.pen_contact = Some(state);
720        samples
721    }
722
723    /// Mint an identity for a tool that just came into range.
724    ///
725    /// Primacy: a pen with no finger on the glass is the primary direct
726    /// pointer. A pen that arrives while contacts are live is not — the
727    /// cross-kind arbitration (pen versus mouse) belongs to the tree's pointer
728    /// table, which can see every live pointer; this only avoids claiming
729    /// primacy the platform layer can already tell is taken.
730    fn begin_pen_session(&mut self, packet: &PenPacket) -> PenContact {
731        let session = NEXT_PEN_SESSION.fetch_add(1, Ordering::Relaxed);
732        let id = PointerIdAllocator::global().begin(PEN_DEVICE, session);
733        let contact = PenContact {
734            id,
735            session,
736            tool: packet.tool,
737            position: packet.position,
738            down: false,
739            buttons: PenButtons::NONE,
740            primary: self.contacts.is_empty(),
741        };
742        trace_input!(
743            Samples,
744            "pen {:?} in proximity ({:?}) at {:?}",
745            id,
746            packet.tool,
747            packet.position
748        );
749        self.pen_contact = Some(contact);
750        contact
751    }
752
753    /// End a proximity session and release its identity.
754    fn end_pen_session(
755        &mut self,
756        contact: PenContact,
757        position: Point,
758        time: EventTime,
759    ) -> InputSample {
760        PointerIdAllocator::global().end(PEN_DEVICE, contact.session);
761        self.pen_contact = None;
762        trace_input!(Samples, "pen {:?} left proximity", contact.id);
763
764        let mut pointer = PointerInfo::touch(contact.id, time);
765        pointer.kind = PointerKind::Pen(contact.tool);
766        pointer.primary = contact.primary;
767        pointer.buttons = ButtonMask::NONE;
768        InputSample::Pointer(PointerSample {
769            pointer,
770            phase: PointerPhase::Cancel,
771            position,
772            button: None,
773            modifiers: self.current_modifiers,
774            coalesced: Vec::new(),
775        })
776    }
777
778    /// One sample for the session's current state.
779    fn pen_sample(
780        &self,
781        contact: &PenContact,
782        phase: PointerPhase,
783        button: Option<PointerButton>,
784        packet: &PenPacket,
785        time: EventTime,
786    ) -> InputSample {
787        let mut pointer = PointerInfo::touch(contact.id, time);
788        pointer.kind = PointerKind::Pen(contact.tool);
789        pointer.primary = contact.primary;
790        pointer.buttons = pen_button_mask(contact.down, contact.buttons);
791        pointer.axes.pressure = Some(packet.pressure.clamp(0.0, 1.0));
792        pointer.axes.tilt = packet.tilt;
793        pointer.axes.twist = packet.twist;
794        InputSample::Pointer(PointerSample {
795            pointer,
796            phase,
797            position: contact.position,
798            button,
799            modifiers: self.current_modifiers,
800            coalesced: Vec::new(),
801        })
802    }
803
804    /// Advance the scroll-phase machine and translate one `MouseWheel` packet.
805    fn translate_scroll(
806        &mut self,
807        delta: winit::event::MouseScrollDelta,
808        winit_phase: winit::event::TouchPhase,
809    ) -> ScrollSample {
810        use winit::event::TouchPhase;
811
812        let in_handoff = matches!(
813            self.scroll_ended_at,
814            Some(at) if self.now.saturating_since(at) <= MOMENTUM_HANDOFF_WINDOW
815        );
816
817        let phase = match (winit_phase, self.scroll_state) {
818            // A `Started` right after an `Ended` is the OS handing over its own
819            // momentum, not a second gesture. See MOMENTUM_HANDOFF_WINDOW.
820            (TouchPhase::Started, _) if in_handoff => {
821                self.scroll_state = ScrollStreamState::InMomentum;
822                ScrollPhase::Momentum
823            }
824            (TouchPhase::Started, _) => {
825                self.scroll_state = ScrollStreamState::InGesture;
826                self.scroll_ended_at = None;
827                ScrollPhase::Began
828            }
829            (TouchPhase::Moved, ScrollStreamState::InGesture) => ScrollPhase::Changed,
830            (TouchPhase::Moved, ScrollStreamState::InMomentum) => ScrollPhase::Momentum,
831            // A wheel notch: winit reports `Moved` with no `Started` before it
832            // on Windows and X11, and for a non-precise wheel on macOS. This is
833            // the arm every mouse in the world takes, and it is `Discrete` —
834            // exactly what a scroll was before the touch programme.
835            (TouchPhase::Moved, ScrollStreamState::Idle) => ScrollPhase::Discrete,
836            (TouchPhase::Ended, ScrollStreamState::InGesture) => {
837                self.scroll_state = ScrollStreamState::Idle;
838                self.scroll_ended_at = Some(self.now);
839                ScrollPhase::Ended
840            }
841            (TouchPhase::Ended, ScrollStreamState::InMomentum) => {
842                self.scroll_state = ScrollStreamState::Idle;
843                self.scroll_ended_at = None;
844                ScrollPhase::MomentumEnded
845            }
846            // An `Ended` with nothing open. macOS emits one for a two-finger
847            // rest that never became a scroll (`NSEventPhase::MayBegin` then
848            // `Cancelled`). Reporting `Ended` would leave a consumer with an
849            // end it never saw a beginning for, so it degrades to a notch.
850            (TouchPhase::Ended, ScrollStreamState::Idle) => ScrollPhase::Discrete,
851            (TouchPhase::Cancelled, _) => {
852                self.scroll_state = ScrollStreamState::Idle;
853                self.scroll_ended_at = None;
854                ScrollPhase::Cancelled
855            }
856        };
857
858        let (scroll_delta, source) = self.scroll_delta(delta);
859        trace_input!(
860            Samples,
861            "scroll {:?} {:?}/{:?}",
862            scroll_delta,
863            phase,
864            source
865        );
866
867        ScrollSample {
868            delta: scroll_delta,
869            // `None` routes by hover, which is what every scroll in Teksilo
870            // has always done and what an *indirect* pointer wants: a mouse
871            // and a trackpad both move the cursor, so the hovered widget is
872            // by construction the one under the gesture. Only a direct
873            // contact — a synthesised touch pan, which lands with the
874            // kinetic-scrolling package — needs positional routing, because a
875            // finger never writes hover.
876            position: None,
877            phase,
878            source,
879            pointer: self.mouse_pointer(),
880            modifiers: self.current_modifiers,
881        }
882    }
883
884    /// The signed delta and the source a winit scroll delta implies.
885    fn scroll_delta(&self, delta: winit::event::MouseScrollDelta) -> (ScrollDelta, ScrollSource) {
886        match delta {
887            winit::event::MouseScrollDelta::LineDelta(x, y) => (
888                ScrollDelta::Lines {
889                    x: -x * self.input.lines_per_notch,
890                    y: -y * self.input.lines_per_notch,
891                },
892                ScrollSource::Wheel,
893            ),
894            // winit hands over pixel deltas only where the device reports
895            // precise scrolling (macOS `hasPreciseScrollingDeltas`, a Wayland
896            // `axis` in surface-local units), which is a trackpad or a
897            // free-spinning wheel.
898            winit::event::MouseScrollDelta::PixelDelta(pos) => (
899                ScrollDelta::Pixels {
900                    x: -(pos.x / self.scale_factor) as f32,
901                    y: -(pos.y / self.scale_factor) as f32,
902                },
903                ScrollSource::Trackpad,
904            ),
905        }
906    }
907}
908
909impl Default for TranslationState {
910    fn default() -> Self {
911        Self::new()
912    }
913}
914
915// ---------------------------------------------------------------------------
916// The backend impl
917// ---------------------------------------------------------------------------
918
919impl PointerBackend for TranslationState {
920    fn translate(&mut self, event: &BackendEvent<'_>, now: EventTime) -> Vec<InputSample> {
921        self.set_now(now);
922        let BackendEvent::Winit(event) = *event;
923
924        use winit::event::WindowEvent as WE;
925        match event {
926            WE::CursorMoved { position, .. } => {
927                let logical = Point::new(
928                    (position.x / self.scale_factor) as f32,
929                    (position.y / self.scale_factor) as f32,
930                );
931                if self.is_phantom_motion(logical) {
932                    trace_input!(
933                        Samples,
934                        "cursor move suppressed (emulated) at {:?}",
935                        logical
936                    );
937                    return Vec::new();
938                }
939                self.cursor_position = Some(logical);
940                let mut sample = PointerSample::mouse(PointerPhase::Move, logical, self.now);
941                sample.pointer = self.mouse_pointer();
942                sample.modifiers = self.current_modifiers;
943                vec![InputSample::Pointer(sample)]
944            }
945
946            WE::MouseInput { state, button, .. } => {
947                let Some(button) = translate_mouse_button(*button) else {
948                    return Vec::new();
949                };
950                let Some(position) = self.cursor_position else {
951                    self.note_press_without_cursor();
952                    return Vec::new();
953                };
954                if self.is_promoted_click() {
955                    trace_input!(
956                        Samples,
957                        "mouse {:?} suppressed (promoted from touch)",
958                        button
959                    );
960                    return Vec::new();
961                }
962                let phase = match state {
963                    winit::event::ElementState::Pressed => {
964                        if !self.mouse_buttons.contains(&button) {
965                            self.mouse_buttons.push(button);
966                        }
967                        PointerPhase::Down
968                    }
969                    winit::event::ElementState::Released => {
970                        self.mouse_buttons.retain(|b| *b != button);
971                        PointerPhase::Up
972                    }
973                };
974                let mut sample = PointerSample::mouse(phase, position, self.now);
975                sample.pointer = self.mouse_pointer();
976                sample.button = Some(button);
977                sample.modifiers = self.current_modifiers;
978                vec![InputSample::Pointer(sample)]
979            }
980
981            WE::MouseWheel { delta, phase, .. } => {
982                vec![InputSample::Scroll(self.translate_scroll(*delta, *phase))]
983            }
984
985            WE::Touch(touch) => self
986                .translate_touch(touch)
987                .map(InputSample::Pointer)
988                .into_iter()
989                .collect(),
990
991            WE::PinchGesture { delta, phase, .. } => {
992                vec![InputSample::Gesture(pinch_gesture(
993                    *delta,
994                    *phase,
995                    self.cursor_position.unwrap_or(Point::ZERO),
996                ))]
997            }
998
999            WE::RotationGesture { delta, phase, .. } => {
1000                vec![InputSample::Gesture(rotation_gesture(
1001                    *delta,
1002                    *phase,
1003                    self.cursor_position.unwrap_or(Point::ZERO),
1004                ))]
1005            }
1006
1007            WE::DoubleTapGesture { .. } => vec![InputSample::Gesture(double_tap_gesture(
1008                self.cursor_position.unwrap_or(Point::ZERO),
1009                self.current_modifiers,
1010            ))],
1011
1012            _ => Vec::new(),
1013        }
1014    }
1015
1016    fn capabilities(&self) -> BackendCaps {
1017        let mut caps = BackendCaps::for_platform(self.platform(), self.window_system);
1018        // A pen shim adds what winit cannot report; it never takes anything
1019        // away. On a window with no shim this is a no-op and the row is
1020        // exactly the platform's.
1021        if let Some(pen) = &self.pen {
1022            pen.capabilities().apply_to(&mut caps);
1023        }
1024        caps
1025    }
1026
1027    fn cancel_all(&mut self, now: EventTime) -> Vec<InputSample> {
1028        self.set_now(now);
1029
1030        // Drain in mint order so the oldest contact is cancelled first — the
1031        // order a multi-touch consumer's own bookkeeping is in.
1032        let mut contacts: Vec<((BackendDeviceKey, u64), Contact)> = self.contacts.drain().collect();
1033        contacts.sort_by_key(|(_, contact)| contact.id);
1034
1035        let mut samples: Vec<InputSample> = Vec::with_capacity(contacts.len() + 1);
1036        for ((device, os_id), contact) in contacts {
1037            PointerIdAllocator::global().end(device, os_id);
1038            let mut pointer = PointerInfo::touch(contact.id, self.now);
1039            pointer.primary = contact.primary;
1040            pointer.buttons = ButtonMask::NONE;
1041            trace_input!(Samples, "cancel_all {:?}", contact.id);
1042            samples.push(InputSample::Pointer(PointerSample {
1043                pointer,
1044                phase: PointerPhase::Cancel,
1045                position: contact.position,
1046                button: None,
1047                modifiers: self.current_modifiers,
1048                coalesced: Vec::new(),
1049            }));
1050        }
1051
1052        // A pen in proximity is a live pointer too, held or not: the window
1053        // that is losing the stream is the one that was hovering.
1054        if let Some(contact) = self.pen_contact {
1055            let position = contact.position;
1056            samples.push(self.end_pen_session(contact, position, self.now));
1057        }
1058
1059        // A held mouse button is a live pointer too: a window that loses the
1060        // stream mid-drag must not leave the press unterminated either.
1061        if !self.mouse_buttons.is_empty()
1062            && let Some(position) = self.cursor_position
1063        {
1064            self.mouse_buttons.clear();
1065            let mut sample = PointerSample::mouse(PointerPhase::Cancel, position, self.now);
1066            sample.modifiers = self.current_modifiers;
1067            samples.push(InputSample::Pointer(sample));
1068        }
1069
1070        samples
1071    }
1072}
1073
1074impl TranslationState {
1075    /// Trace the dropped press once per window.
1076    fn note_press_without_cursor(&mut self) {
1077        if !self.warned_press_without_cursor {
1078            self.warned_press_without_cursor = true;
1079            trace_input!(
1080                Samples,
1081                "mouse button dropped: no cursor position yet (was dispatched at the \
1082                 window origin before P15)"
1083            );
1084        }
1085    }
1086}
1087
1088// ---------------------------------------------------------------------------
1089// Helpers
1090// ---------------------------------------------------------------------------
1091
1092/// Derive a stable per-device key from winit's opaque `DeviceId`.
1093///
1094/// `DeviceId` is `Hash + Eq` but its inner value is `pub(crate)`, so hashing is
1095/// the only way to get a number out of it. `DefaultHasher::new()` is seeded
1096/// with fixed keys (it is *not* `RandomState`), so the mapping is deterministic
1097/// within a run and reproducible across runs — which matters because the key
1098/// appears in trace output.
1099///
1100/// A hash collision between two devices would merge their contact id spaces.
1101/// With a 64-bit SipHash and a handful of devices the probability is not worth
1102/// a second field: the birthday bound for 100 devices is about 2.7e-16.
1103fn device_key(device_id: winit::event::DeviceId) -> BackendDeviceKey {
1104    let mut hasher = DefaultHasher::new();
1105    device_id.hash(&mut hasher);
1106    BackendDeviceKey::new(hasher.finish())
1107}
1108
1109/// The button mask a pen holds: the tip is `Primary`, the barrel `Secondary`,
1110/// a second barrel `Middle`.
1111///
1112/// The tip mapping is normative rather than cosmetic — every
1113/// `accept_buttons()` recognizer in the framework gates on
1114/// `ButtonMask::PRIMARY`, so a stylus that reported anything else would be
1115/// invisible to tap, drag and long-press alike.
1116fn pen_button_mask(down: bool, buttons: PenButtons) -> ButtonMask {
1117    let mut mask = ButtonMask::NONE;
1118    if down {
1119        mask = mask.union(PointerButton::Primary.into());
1120    }
1121    if buttons.contains(PenButtons::BARREL) {
1122        mask = mask.union(PointerButton::Secondary.into());
1123    }
1124    if buttons.contains(PenButtons::SECONDARY_BARREL) {
1125        mask = mask.union(PointerButton::Middle.into());
1126    }
1127    mask
1128}
1129
1130/// Whether two points are within `slop` logical pixels of each other.
1131fn near(a: Point, b: Point, slop: f32) -> bool {
1132    (a.x - b.x).abs() <= slop && (a.y - b.y).abs() <= slop
1133}
1134
1135/// Normalised tip pressure from a winit `Force`, or `None` when the device's
1136/// numbers cannot produce one.
1137///
1138/// winit's own `Force::normalized()` divides by `sin(altitude_angle)` to
1139/// recover the component perpendicular to the surface, then by
1140/// `max_possible_force`. Both divisors can be zero — a stylus lying flat on the
1141/// glass has `altitude_angle == 0` — and the result is then infinite rather
1142/// than an error. Teksilo re-implements the conversion so those cases become
1143/// "no pressure reported" instead of an infinity in a `PointerAxes`.
1144fn pressure_from_force(force: winit::event::Force) -> Option<f32> {
1145    let normalized = match force {
1146        winit::event::Force::Normalized(value) => value,
1147        winit::event::Force::Calibrated {
1148            force,
1149            max_possible_force,
1150            altitude_angle,
1151        } => {
1152            if max_possible_force <= 0.0 {
1153                return None;
1154            }
1155            let perpendicular = match altitude_angle {
1156                Some(angle) => {
1157                    let sin = angle.sin();
1158                    if sin <= f64::EPSILON {
1159                        return None;
1160                    }
1161                    force / sin
1162                }
1163                None => force,
1164            };
1165            perpendicular / max_possible_force
1166        }
1167    };
1168    if !normalized.is_finite() {
1169        return None;
1170    }
1171    Some((normalized as f32).clamp(0.0, 1.0))
1172}
1173
1174// ---------------------------------------------------------------------------
1175// The free-function surface
1176// ---------------------------------------------------------------------------
1177
1178/// Translate a winit CursorMoved event to a WidgetEvent::PointerMove.
1179///
1180/// Returns `None` when the move is the emulated pointer following a finger —
1181/// see `TranslationState::is_phantom_motion`. On a platform that does not
1182/// promote touch to mouse (everything but X11) that check is a constant
1183/// `false` and the translation is unchanged.
1184pub fn translate_cursor_moved(
1185    physical_x: f64,
1186    physical_y: f64,
1187    state: &mut TranslationState,
1188) -> Option<WidgetEvent> {
1189    let logical_x = (physical_x / state.scale_factor) as f32;
1190    let logical_y = (physical_y / state.scale_factor) as f32;
1191    let position = Point::new(logical_x, logical_y);
1192    if state.is_phantom_motion(position) {
1193        return None;
1194    }
1195    state.cursor_position = Some(position);
1196    // winit's `CursorMoved` *is* the mouse cursor — a contact takes
1197    // `translate_touch` and a pen `poll_pen`, both of which build a real
1198    // `PointerSample` — so this is the mouse, described by the same
1199    // `mouse_pointer()` the sample path uses (its clock and its held buttons)
1200    // rather than by a bare epoch default. The tracked modifier state travels
1201    // with the move because a drag reads Shift and Ctrl from the move, not from
1202    // the press.
1203    let pointer = state.mouse_pointer();
1204    Some(WidgetEvent::PointerMove {
1205        position,
1206        modifiers: state.current_modifiers,
1207        pointer,
1208    })
1209}
1210
1211/// Translate a winit `Ime` event into a teksilo-core `WidgetEvent`.
1212///
1213/// - `Preedit(text, cursor)` → `ImeComposition`. The `cursor` byte indices
1214///   `(begin, end)` index into the preedit `text` and are preserved as a
1215///   `Range`. `None` (hide-cursor) and empty `text` (winit's synthetic
1216///   clear, emitted right before `Commit`) flow through faithfully.
1217/// - `Commit(text)` → `ImeCommit`.
1218/// - `Enabled` / `Disabled` are OS acknowledgements (enablement is driven
1219///   by the focused node's descriptor) and produce no tree event.
1220pub fn translate_ime(ime: winit::event::Ime) -> Option<WidgetEvent> {
1221    match ime {
1222        winit::event::Ime::Preedit(text, cursor) => Some(WidgetEvent::ImeComposition {
1223            text,
1224            cursor: cursor.map(|(begin, end)| begin..end),
1225        }),
1226        winit::event::Ime::Commit(text) => Some(WidgetEvent::ImeCommit { text }),
1227        winit::event::Ime::Enabled | winit::event::Ime::Disabled => None,
1228    }
1229}
1230
1231/// Translate a winit mouse button to a teksilo-core PointerButton.
1232pub fn translate_mouse_button(button: winit::event::MouseButton) -> Option<PointerButton> {
1233    match button {
1234        winit::event::MouseButton::Left => Some(PointerButton::Primary),
1235        winit::event::MouseButton::Right => Some(PointerButton::Secondary),
1236        winit::event::MouseButton::Middle => Some(PointerButton::Middle),
1237        winit::event::MouseButton::Back => Some(PointerButton::Back),
1238        winit::event::MouseButton::Forward => Some(PointerButton::Forward),
1239        // MouseButton::Other(_) — vendor-specific extra buttons we don't
1240        // currently surface. Returning None drops the event.
1241        _ => None,
1242    }
1243}
1244
1245/// Translate a winit ElementState + MouseButton to PointerDown/Up.
1246///
1247/// Returns `None` when no cursor position is known yet. This used to dispatch
1248/// the press at `Point::ZERO`, which is a click on whatever sits in the
1249/// window's top-left corner — a real misfire on every platform that can deliver
1250/// a button before a motion (X11 with a grab, a synthetic click, a window that
1251/// gains the pointer already pressed).
1252pub fn translate_mouse_input(
1253    button_state: winit::event::ElementState,
1254    button: winit::event::MouseButton,
1255    state: &TranslationState,
1256) -> Option<WidgetEvent> {
1257    let pointer_button = translate_mouse_button(button)?;
1258    let position = state.cursor_position?;
1259    if state.is_promoted_click() {
1260        return None;
1261    }
1262    // winit's `MouseInput` is the mouse's own button; a contact's press comes
1263    // through `translate_touch` as a `PointerSample`.
1264    let pointer = state.mouse_pointer();
1265    match button_state {
1266        winit::event::ElementState::Pressed => Some(WidgetEvent::PointerDown {
1267            position,
1268            button: pointer_button,
1269            modifiers: state.current_modifiers,
1270            pointer,
1271        }),
1272        winit::event::ElementState::Released => Some(WidgetEvent::PointerUp {
1273            position,
1274            button: pointer_button,
1275            modifiers: state.current_modifiers,
1276            pointer,
1277        }),
1278    }
1279}
1280
1281/// Translate winit keyboard modifiers to teksilo-core Modifiers.
1282pub fn translate_modifiers(mods: winit::keyboard::ModifiersState) -> Modifiers {
1283    let mut result = Modifiers::NONE;
1284    if mods.control_key() {
1285        result = result | Modifiers::CTRL;
1286    }
1287    if mods.shift_key() {
1288        result = result | Modifiers::SHIFT;
1289    }
1290    if mods.alt_key() {
1291        result = result | Modifiers::ALT;
1292    }
1293    if mods.super_key() {
1294        result = result | Modifiers::SUPER;
1295    }
1296    result
1297}
1298
1299/// Translate a winit logical key to a teksilo-core Key.
1300pub fn translate_key(key: &winit::keyboard::Key) -> Option<Key> {
1301    match key {
1302        winit::keyboard::Key::Named(named) => translate_named_key(*named),
1303        winit::keyboard::Key::Character(c) => {
1304            let ch = c.chars().next()?;
1305            match ch.to_ascii_uppercase() {
1306                'A' => Some(Key::A),
1307                'B' => Some(Key::B),
1308                'C' => Some(Key::C),
1309                'D' => Some(Key::D),
1310                'E' => Some(Key::E),
1311                'F' => Some(Key::F),
1312                'G' => Some(Key::G),
1313                'H' => Some(Key::H),
1314                'I' => Some(Key::I),
1315                'J' => Some(Key::J),
1316                'K' => Some(Key::K),
1317                'L' => Some(Key::L),
1318                'M' => Some(Key::M),
1319                'N' => Some(Key::N),
1320                'O' => Some(Key::O),
1321                'P' => Some(Key::P),
1322                'Q' => Some(Key::Q),
1323                'R' => Some(Key::R),
1324                'S' => Some(Key::S),
1325                'T' => Some(Key::T),
1326                'U' => Some(Key::U),
1327                'V' => Some(Key::V),
1328                'W' => Some(Key::W),
1329                'X' => Some(Key::X),
1330                'Y' => Some(Key::Y),
1331                'Z' => Some(Key::Z),
1332                _ => Some(Key::Character(ch)),
1333            }
1334        }
1335        _ => None,
1336    }
1337}
1338
1339fn translate_named_key(key: winit::keyboard::NamedKey) -> Option<Key> {
1340    use winit::keyboard::NamedKey;
1341    match key {
1342        NamedKey::Space => Some(Key::Space),
1343        NamedKey::Enter => Some(Key::Enter),
1344        NamedKey::Escape => Some(Key::Escape),
1345        NamedKey::Tab => Some(Key::Tab),
1346        NamedKey::Backspace => Some(Key::Backspace),
1347        NamedKey::Delete => Some(Key::Delete),
1348        NamedKey::Insert => Some(Key::Insert),
1349        NamedKey::ArrowUp => Some(Key::ArrowUp),
1350        NamedKey::ArrowDown => Some(Key::ArrowDown),
1351        NamedKey::ArrowLeft => Some(Key::ArrowLeft),
1352        NamedKey::ArrowRight => Some(Key::ArrowRight),
1353        NamedKey::Home => Some(Key::Home),
1354        NamedKey::End => Some(Key::End),
1355        NamedKey::PageUp => Some(Key::PageUp),
1356        NamedKey::PageDown => Some(Key::PageDown),
1357        NamedKey::F1 => Some(Key::F1),
1358        NamedKey::F2 => Some(Key::F2),
1359        NamedKey::F3 => Some(Key::F3),
1360        NamedKey::F4 => Some(Key::F4),
1361        NamedKey::F5 => Some(Key::F5),
1362        NamedKey::F6 => Some(Key::F6),
1363        NamedKey::F7 => Some(Key::F7),
1364        NamedKey::F8 => Some(Key::F8),
1365        NamedKey::F9 => Some(Key::F9),
1366        NamedKey::F10 => Some(Key::F10),
1367        NamedKey::F11 => Some(Key::F11),
1368        NamedKey::F12 => Some(Key::F12),
1369        NamedKey::F13 => Some(Key::F13),
1370        NamedKey::F14 => Some(Key::F14),
1371        NamedKey::F15 => Some(Key::F15),
1372        NamedKey::F16 => Some(Key::F16),
1373        NamedKey::F17 => Some(Key::F17),
1374        NamedKey::F18 => Some(Key::F18),
1375        NamedKey::F19 => Some(Key::F19),
1376        NamedKey::F20 => Some(Key::F20),
1377        NamedKey::F21 => Some(Key::F21),
1378        NamedKey::F22 => Some(Key::F22),
1379        NamedKey::F23 => Some(Key::F23),
1380        NamedKey::F24 => Some(Key::F24),
1381        // Caps Lock arrives as a discrete press/release. winit's
1382        // `ModifiersState` carries no lock state, so the window manager
1383        // tracks the active state itself on the key-down edge (drives
1384        // `WindowState::caps_lock` for the password-field warning).
1385        NamedKey::CapsLock => Some(Key::CapsLock),
1386        // Windows `VK_APPS`, X11/Wayland `keysyms::Menu`. macOS produces this
1387        // zero times, which is why the dispatcher also reserves a chord.
1388        NamedKey::ContextMenu => Some(Key::ContextMenu),
1389        _ => None,
1390    }
1391}
1392
1393/// Translate a winit MouseWheel event to a WidgetEvent::Scroll.
1394///
1395/// The lines-per-notch factor comes from
1396/// [`InputTokens::lines_per_notch`](teksilo_tokens::InputTokens::lines_per_notch)
1397/// on the state's installed tokens; its default is 3.0, the Windows/GTK
1398/// default and the constant this function used to hardcode.
1399pub fn translate_mouse_wheel(
1400    delta: winit::event::MouseScrollDelta,
1401    _phase: winit::event::TouchPhase,
1402    state: &TranslationState,
1403) -> Option<WidgetEvent> {
1404    // Winit uses "natural" sign: positive y = scroll up (content moves down).
1405    // Teksilo's ScrollDelta uses positive y = increase scroll offset (content
1406    // moves up). `scroll_delta` negates both axes to match.
1407    let (scroll_delta, _) = state.scroll_delta(delta);
1408    Some(WidgetEvent::scroll(scroll_delta, state.current_modifiers))
1409}
1410
1411// --- Desktop trackpad gesture passthrough ---
1412// On desktop, most gestures arrive as already-recognized events from the OS
1413// trackpad driver. These functions translate winit's high-level gesture events
1414// into Teksilo GestureEvents. They are reached two ways: as
1415// `WidgetEvent::Gesture` through the free functions below, and as
1416// `InputSample::Gesture` through `PointerBackend::translate`.
1417
1418/// A winit PinchGesture as a Teksilo gesture.
1419///
1420/// winit's `delta` is the change in magnification *for this event*, so
1421/// `1.0 + delta` is already the per-sample factor
1422/// [`GestureEvent::PinchChanged`] asks for — nothing accumulates here.
1423fn pinch_gesture(delta: f64, phase: winit::event::TouchPhase, center: Point) -> GestureEvent {
1424    match phase {
1425        winit::event::TouchPhase::Started => GestureEvent::PinchStarted { center },
1426        winit::event::TouchPhase::Moved => GestureEvent::PinchChanged {
1427            center,
1428            scale: 1.0 + delta as f32,
1429            rotation: 0.0,
1430        },
1431        winit::event::TouchPhase::Ended | winit::event::TouchPhase::Cancelled => {
1432            GestureEvent::PinchEnded
1433        }
1434    }
1435}
1436
1437/// A winit RotationGesture as a Teksilo gesture.
1438///
1439/// **This is where the unit is decided.** winit reports the delta in degrees
1440/// (`NSEvent.rotation` on the one backend that produces the event), and
1441/// [`GestureEvent::PinchChanged`]'s `rotation` is radians, so the conversion
1442/// belongs here — at the seam, where the incoming unit is known — and not in a
1443/// consumer: `on_pinch` has one ingress and potentially several consumers, and
1444/// each converting for itself is how the two would drift apart again.
1445fn rotation_gesture(
1446    delta_degrees: f32,
1447    phase: winit::event::TouchPhase,
1448    center: Point,
1449) -> GestureEvent {
1450    match phase {
1451        winit::event::TouchPhase::Started => GestureEvent::PinchStarted { center },
1452        winit::event::TouchPhase::Moved => GestureEvent::PinchChanged {
1453            center,
1454            scale: 1.0,
1455            rotation: delta_degrees.to_radians(),
1456        },
1457        winit::event::TouchPhase::Ended | winit::event::TouchPhase::Cancelled => {
1458            GestureEvent::PinchEnded
1459        }
1460    }
1461}
1462
1463/// A winit DoubleTapGesture as a Teksilo gesture.
1464fn double_tap_gesture(position: Point, modifiers: Modifiers) -> GestureEvent {
1465    GestureEvent::DoubleTap(TapEvent::new(position, PointerButton::Primary, modifiers))
1466}
1467
1468/// Translate a winit PinchGesture into a Teksilo gesture event.
1469/// Returns PinchStarted on Started phase, PinchChanged on Changed, PinchEnded on Ended.
1470pub fn translate_pinch_gesture(
1471    delta: f64,
1472    phase: winit::event::TouchPhase,
1473    state: &TranslationState,
1474) -> Option<WidgetEvent> {
1475    let center = state.cursor_position.unwrap_or(Point::ZERO);
1476    Some(WidgetEvent::Gesture {
1477        gesture: pinch_gesture(delta, phase, center),
1478    })
1479}
1480
1481/// Translate a winit RotationGesture into a PinchChanged with rotation.
1482/// Rotation gestures are folded into the pinch gesture model since they
1483/// typically co-occur with pinch on trackpads.
1484///
1485/// The delta arrives in degrees and leaves in radians: winit reports the twist
1486/// in degrees and [`GestureEvent::PinchChanged`]'s `rotation` is radians, so the
1487/// conversion is made here, at the seam where the incoming unit is known, rather
1488/// than in each consumer.
1489pub fn translate_rotation_gesture(
1490    delta_degrees: f32,
1491    phase: winit::event::TouchPhase,
1492    state: &TranslationState,
1493) -> Option<WidgetEvent> {
1494    let center = state.cursor_position.unwrap_or(Point::ZERO);
1495    Some(WidgetEvent::Gesture {
1496        gesture: rotation_gesture(delta_degrees, phase, center),
1497    })
1498}
1499
1500/// Translate a winit DoubleTapGesture (trackpad smart magnification).
1501///
1502/// Synthetic OS-driven double-tap: there's no underlying mouse button
1503/// or modifier set the OS hands us, so we attribute it to
1504/// `PointerButton::Primary` with no modifiers. Apps that need richer
1505/// trackpad-gesture metadata should match on `WidgetEvent::Gesture`
1506/// directly rather than hooking `on_double_tap`.
1507pub fn translate_double_tap_gesture(state: &TranslationState) -> Option<WidgetEvent> {
1508    let position = state.cursor_position.unwrap_or(Point::ZERO);
1509    Some(WidgetEvent::Gesture {
1510        gesture: double_tap_gesture(position, state.current_modifiers),
1511    })
1512}
1513
1514#[cfg(test)]
1515mod tests;