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;