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;