Skip to main content

teksilo_platform/
pointer_backend.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! The seam between an OS input backend and Teksilo's pointer vocabulary.
5//!
6//! Everything above this module speaks [`PointerSample`] / [`ScrollSample`] /
7//! [`GestureEvent`]. Everything below it speaks whatever the window system
8//! speaks. [`PointerBackend`] is the one door between the two, and
9//! [`BackendCaps`] is the honest declaration of what the backend on the other
10//! side can and cannot report.
11//!
12//! # Why a trait rather than a function
13//!
14//! Three reasons, in the order they will bite:
15//!
16//! 1. **The winit 0.31 upgrade.** winit 0.30 models touch as
17//!    `WindowEvent::Touch { id: u64, phase: TouchPhase, force, .. }`; 0.31
18//!    replaces it with a unified pointer API (`PointerKind`, `PointerSource`,
19//!    `FingerId`, the `TabletTool*` family). That is a rewrite of one
20//!    implementation of this trait, not of the framework.
21//! 2. **Testing without an OS.** The conformance suite
22//!    (`tests/backend_conformance.rs`) drives *recorded* event vectors through
23//!    this trait and checks six invariants. A future backend earns its trust
24//!    by passing the same suite.
25//! 3. **Honesty about capability.** A consumer that must know whether cancels
26//!    are reported — or whether a finger can drag the window — should ask,
27//!    not guess from `cfg!(target_os = ...)`. [`BackendCaps`] is that answer,
28//!    and every `false` in it is a documented platform fact rather than a
29//!    to-do.
30//!
31//! Reference: `docs/touch-and-pen.md`, "Platform capabilities".
32
33use teksilo_core::gesture::GestureEvent;
34use teksilo_core::pointer::{EventTime, PointerSample, ScrollSample};
35
36use crate::window_system::WindowSystem;
37
38// ---------------------------------------------------------------------------
39// The samples a backend produces
40// ---------------------------------------------------------------------------
41
42/// One translated input sample, ready to enter a widget tree.
43///
44/// A single OS packet can produce zero, one or several of these — a winit
45/// `CursorMoved` that the X11 phantom-motion filter drops produces none, a
46/// [`PointerBackend::cancel_all`] with three fingers down produces three.
47#[derive(Clone, Debug)]
48pub enum InputSample {
49    /// Route through
50    /// [`WidgetTree::dispatch_pointer`](teksilo_core::WidgetTree::dispatch_pointer).
51    Pointer(PointerSample),
52    /// Route through
53    /// [`WidgetTree::dispatch_scroll`](teksilo_core::WidgetTree::dispatch_scroll).
54    Scroll(ScrollSample),
55    /// An already-recognised gesture handed over by the OS — a trackpad pinch,
56    /// rotation or smart-magnification double tap. Teksilo does not
57    /// re-recognise these: the OS driver has better data (raw touch on the
58    /// trackpad surface) than the framework ever will.
59    Gesture(GestureEvent),
60}
61
62impl InputSample {
63    /// The pointer sample, if this is one. Convenience for the conformance
64    /// harness and for a caller that only cares about one arm.
65    pub fn as_pointer(&self) -> Option<&PointerSample> {
66        match self {
67            Self::Pointer(sample) => Some(sample),
68            _ => None,
69        }
70    }
71
72    /// The scroll sample, if this is one.
73    pub fn as_scroll(&self) -> Option<&ScrollSample> {
74        match self {
75            Self::Scroll(sample) => Some(sample),
76            _ => None,
77        }
78    }
79
80    /// The gesture, if this is one.
81    pub fn as_gesture(&self) -> Option<&GestureEvent> {
82        match self {
83            Self::Gesture(gesture) => Some(gesture),
84            _ => None,
85        }
86    }
87}
88
89// ---------------------------------------------------------------------------
90// The events a backend consumes
91// ---------------------------------------------------------------------------
92
93/// One OS packet on its way into [`PointerBackend::translate`].
94///
95/// This crate already re-exports winit types across its whole event-translation
96/// surface (`translate_mouse_button`, `translate_ime`, `translate_key` all take
97/// them), so borrowing a `winit::event::WindowEvent` here leaks nothing new. It
98/// is an enum rather than a bare reference so that a backend for a different
99/// window system — or a replay harness reading a recorded trace — can be added
100/// as a variant without breaking the trait.
101///
102/// `#[non_exhaustive]`: adding a variant must not be a breaking change.
103#[non_exhaustive]
104#[derive(Debug)]
105pub enum BackendEvent<'a> {
106    /// A winit window event, borrowed from the event loop.
107    Winit(&'a winit::event::WindowEvent),
108}
109
110impl<'a> From<&'a winit::event::WindowEvent> for BackendEvent<'a> {
111    fn from(event: &'a winit::event::WindowEvent) -> Self {
112        Self::Winit(event)
113    }
114}
115
116// ---------------------------------------------------------------------------
117// Capabilities
118// ---------------------------------------------------------------------------
119
120/// How a platform can be asked to raise the on-screen keyboard.
121///
122/// Re-exported from `teksilo-core`, where it lives so that
123/// [`WindowOps::soft_keyboard_support`](teksilo_core::window::WindowOps::soft_keyboard_support)
124/// can carry it to a widget without the widget layer depending on this crate.
125/// The per-platform values, and the reason behind each, are in
126/// [`crate::soft_keyboard`].
127pub use teksilo_core::window::SoftKeyboardSupport;
128
129/// Which OS a [`BackendCaps`] row describes.
130///
131/// A plain enum rather than `cfg!` so the capability matrix is a *pure
132/// function* and every row of it can be asserted from any host — a Linux CI
133/// runner tests the macOS and Windows rows too. [`Self::HOST`] is the row for
134/// the machine this binary was compiled for.
135#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug)]
136pub enum PlatformKind {
137    /// Windows 8 or later.
138    Windows,
139    /// macOS.
140    MacOs,
141    /// Linux, the BSDs, and anything else winit drives through its
142    /// Wayland/X11 backends.
143    Unix,
144}
145
146impl PlatformKind {
147    /// The platform this binary targets.
148    pub const HOST: Self = {
149        #[cfg(target_os = "windows")]
150        {
151            Self::Windows
152        }
153        #[cfg(target_os = "macos")]
154        {
155            Self::MacOs
156        }
157        #[cfg(not(any(target_os = "windows", target_os = "macos")))]
158        {
159            Self::Unix
160        }
161    };
162}
163
164/// What a backend can actually report.
165///
166/// Every field is a *platform fact* established by reading the backend, not an
167/// aspiration. The per-OS matrix and its citations are in
168/// `docs/touch-and-pen.md`, "Platform capabilities"; the machine-readable
169/// version is [`BackendCaps::for_platform`].
170///
171/// `#[non_exhaustive]`: construct one with
172/// [`for_platform`](Self::for_platform) and adjust, so a later capability
173/// cannot break a call site.
174#[non_exhaustive]
175#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug, Default)]
176pub struct BackendCaps {
177    /// The backend reports a *cancel* distinct from a lift. When `false`, a
178    /// revoked contact arrives as an ordinary `Ended` (or as nothing at all)
179    /// and the framework has to infer revocation from focus loss.
180    pub reports_cancel: bool,
181    /// Contacts carry a tip pressure.
182    pub reports_pressure: bool,
183    /// Stylus tilt is reported.
184    pub reports_tilt: bool,
185    /// Stylus barrel rotation is reported.
186    pub reports_twist: bool,
187    /// The backend distinguishes a pen tip from an eraser.
188    pub reports_pen_kind: bool,
189    /// The backend flags a contact the digitizer classified as a palm.
190    pub reports_palm: bool,
191    /// Scroll samples carry a begin/change/end structure rather than being
192    /// bare notches.
193    pub reports_scroll_phase: bool,
194    /// The OS produces the momentum (inertial) part of a scroll itself, so the
195    /// framework must **not** add a fling of its own on top.
196    pub reports_os_momentum: bool,
197    /// The OS recognises pinch/rotate on the trackpad and hands over the
198    /// result.
199    pub reports_os_pinch: bool,
200    /// The OS also synthesises a mouse stream from touch, so the translator
201    /// must suppress one of the two.
202    pub synthesises_mouse_from_touch: bool,
203    /// A finger can start an OS window move/resize.
204    pub touch_window_drag: bool,
205    /// How the on-screen keyboard can be reached.
206    pub osk: SoftKeyboardSupport,
207}
208
209impl BackendCaps {
210    /// The capability row for one platform, and — on Unix — one window system.
211    ///
212    /// `window_system` is ignored off Unix: `WindowSystem` only ever resolves
213    /// to `Wayland` or `X11` from a live Linux/BSD display handle, and
214    /// [`WindowSystem::Unknown`] is precisely the set {Windows, macOS,
215    /// headless}. That is what makes `Unknown` a safe default for the
216    /// mouse-promotion suppressors — none of those three platforms promote.
217    ///
218    /// Every value here is cited in `docs/touch-and-pen.md`.
219    pub const fn for_platform(platform: PlatformKind, window_system: WindowSystem) -> Self {
220        match platform {
221            // winit registers `RegisterTouchWindow(hwnd, TWF_WANTPALM)` and
222            // handles `WM_TOUCH` *and* the `WM_POINTER*` family, returning 0
223            // without calling `DefWindowProc` — so Windows never promotes a
224            // finger to a mouse click, and there is no dual stream to fight.
225            //
226            // `WM_TOUCH` reports `force: None`; the `WM_POINTER*` path
227            // normalises `POINTER_TOUCH_INFO::pressure` / `POINTER_PEN_INFO::
228            // pressure` over 1..=1024, so pressure is reachable. Tilt, twist,
229            // eraser and the palm flag all exist in `POINTER_PEN_INFO` /
230            // `TOUCH_FLAG_PALM` but winit 0.30 does not surface them.
231            PlatformKind::Windows => Self {
232                reports_cancel: false,
233                reports_pressure: true,
234                reports_tilt: false,
235                reports_twist: false,
236                reports_pen_kind: false,
237                reports_palm: false,
238                // `MouseWheel` always arrives with `TouchPhase::Moved`;
239                // precision-touchpad phase information is not surfaced.
240                reports_scroll_phase: false,
241                reports_os_momentum: false,
242                reports_os_pinch: false,
243                synthesises_mouse_from_touch: false,
244                // `WM_NCLBUTTONDOWN`-based drag is a mouse path; winit's
245                // `drag_window` sends it and a finger does not reach it.
246                touch_window_drag: false,
247                osk: crate::soft_keyboard::support_for(PlatformKind::Windows),
248            },
249            // macOS delivers **no touch at all**: `WindowEvent::Touch` is
250            // documented "macOS: Unsupported". The trackpad arrives as
251            // `PinchGesture` / `RotationGesture` / `DoubleTapGesture`, and a
252            // two-finger pan as `MouseWheel` pixel deltas whose phase winit
253            // folds momentum into (see `TranslationState`'s scroll machine).
254            PlatformKind::MacOs => Self {
255                reports_cancel: false,
256                reports_pressure: false,
257                reports_tilt: false,
258                reports_twist: false,
259                reports_pen_kind: false,
260                reports_palm: false,
261                reports_scroll_phase: true,
262                reports_os_momentum: true,
263                reports_os_pinch: true,
264                synthesises_mouse_from_touch: false,
265                touch_window_drag: false,
266                osk: crate::soft_keyboard::support_for(PlatformKind::MacOs),
267            },
268            PlatformKind::Unix => match window_system {
269                // `wl_touch.cancel` is the only desktop source of a real
270                // cancel in winit 0.30. Wayland's `wl_touch.down/motion/up`
271                // carry no pressure and no tool axes.
272                //
273                // `touch_window_drag` is **false**, and this one is worth
274                // stating plainly: `xdg_toplevel::move` needs a serial from an
275                // input event on a toplevel the compositor agrees the client
276                // owns, and winit 0.30's `drag_window` harvests a *pointer*
277                // serial internally. A finger therefore cannot start a window
278                // move under winit 0.30, however the app asks.
279                WindowSystem::Wayland => Self {
280                    reports_cancel: true,
281                    reports_pressure: false,
282                    reports_tilt: false,
283                    reports_twist: false,
284                    reports_pen_kind: false,
285                    reports_palm: false,
286                    reports_scroll_phase: true,
287                    reports_os_momentum: false,
288                    reports_os_pinch: false,
289                    synthesises_mouse_from_touch: false,
290                    touch_window_drag: false,
291                    osk: crate::soft_keyboard::support_for(PlatformKind::Unix),
292                },
293                // XI2 touch. winit filters *emulated button* events
294                // (`XIPointerEmulated`) but synthesises a `CursorMoved` of its
295                // own for the first concurrently-active contact, on every
296                // phase of it — so the emulated motion stream is real and must
297                // be suppressed here.
298                //
299                // No `XI_TouchCancel` handling: winit 0.30 never emits
300                // `TouchPhase::Cancelled` on X11. `force: None // TODO`.
301                WindowSystem::X11 => Self {
302                    reports_cancel: false,
303                    reports_pressure: false,
304                    reports_tilt: false,
305                    reports_twist: false,
306                    reports_pen_kind: false,
307                    reports_palm: false,
308                    reports_scroll_phase: false,
309                    reports_os_momentum: false,
310                    reports_os_pinch: false,
311                    synthesises_mouse_from_touch: true,
312                    touch_window_drag: false,
313                    osk: crate::soft_keyboard::support_for(PlatformKind::Unix),
314                },
315                // A headless or not-yet-created window. Report nothing: an
316                // unknown backend must not be credited with a capability.
317                WindowSystem::Unknown => Self {
318                    reports_cancel: false,
319                    reports_pressure: false,
320                    reports_tilt: false,
321                    reports_twist: false,
322                    reports_pen_kind: false,
323                    reports_palm: false,
324                    reports_scroll_phase: false,
325                    reports_os_momentum: false,
326                    reports_os_pinch: false,
327                    synthesises_mouse_from_touch: false,
328                    touch_window_drag: false,
329                    osk: crate::soft_keyboard::support_for(PlatformKind::Unix),
330                },
331            },
332        }
333    }
334}
335
336// ---------------------------------------------------------------------------
337// The trait
338// ---------------------------------------------------------------------------
339
340/// Turns OS packets into [`InputSample`]s.
341///
342/// One instance per window: a backend owns per-window state (the live contact
343/// set, the scroll-phase machine, the modifier and scale factor) and two
344/// windows must not share it.
345///
346/// # Contract
347///
348/// - Every contact that produces a [`PointerPhase::Down`] sample is terminated
349///   by exactly one [`Up`](teksilo_core::PointerPhase::Up) or one
350///   [`Cancel`](teksilo_core::PointerPhase::Cancel) — never both, never
351///   neither. [`cancel_all`](Self::cancel_all) exists so a caller can honour
352///   that when the window goes away.
353/// - The [`EventTime`]s a backend stamps are monotone non-decreasing within
354///   one stream. `now` is supplied by the caller from the tree's one clock, so
355///   a backend never reads `Instant::now()`.
356/// - A backend never allocates a [`PointerId`](teksilo_core::PointerId)
357///   itself: it mints through
358///   [`PointerIdAllocator`](teksilo_core::PointerIdAllocator), which is what
359///   makes a reused OS contact id resolve to a fresh identity.
360///
361/// [`PointerPhase::Down`]: teksilo_core::PointerPhase::Down
362pub trait PointerBackend {
363    /// Translate one OS packet. Returns every sample it implies, in dispatch
364    /// order; an empty vector is a normal, common answer (a filtered phantom,
365    /// a touch packet with the kill switch off, an unmapped mouse button).
366    fn translate(&mut self, event: &BackendEvent<'_>, now: EventTime) -> Vec<InputSample>;
367
368    /// What this backend can report. See [`BackendCaps`].
369    fn capabilities(&self) -> BackendCaps;
370
371    /// Terminate every live contact with a
372    /// [`Cancel`](teksilo_core::PointerPhase::Cancel) sample and forget it.
373    ///
374    /// Called when the window loses the input it was receiving — focus loss, a
375    /// close, a compositor grab — so that the "every Down is terminated"
376    /// contract survives a stream that simply stops.
377    fn cancel_all(&mut self, now: EventTime) -> Vec<InputSample>;
378}
379
380#[cfg(test)]
381mod tests {
382    use super::*;
383
384    /// The one capability the programme most wants to be wrong about. It is
385    /// not: winit 0.30's `drag_window` harvests a pointer serial, so no
386    /// platform can start a window drag from a finger.
387    #[test]
388    fn no_platform_offers_touch_window_drag() {
389        for platform in [PlatformKind::Windows, PlatformKind::MacOs] {
390            assert!(!BackendCaps::for_platform(platform, WindowSystem::Unknown).touch_window_drag);
391        }
392        for ws in [
393            WindowSystem::Wayland,
394            WindowSystem::X11,
395            WindowSystem::Unknown,
396        ] {
397            assert!(!BackendCaps::for_platform(PlatformKind::Unix, ws).touch_window_drag);
398        }
399    }
400
401    /// Wayland is the only desktop backend that reports a real cancel.
402    #[test]
403    fn only_wayland_reports_cancel() {
404        assert!(
405            BackendCaps::for_platform(PlatformKind::Unix, WindowSystem::Wayland).reports_cancel
406        );
407        assert!(!BackendCaps::for_platform(PlatformKind::Unix, WindowSystem::X11).reports_cancel);
408        assert!(
409            !BackendCaps::for_platform(PlatformKind::Windows, WindowSystem::Unknown).reports_cancel
410        );
411        assert!(
412            !BackendCaps::for_platform(PlatformKind::MacOs, WindowSystem::Unknown).reports_cancel
413        );
414    }
415
416    /// X11 is the only backend whose emulated pointer has to be suppressed.
417    #[test]
418    fn only_x11_promotes_touch_to_mouse() {
419        assert!(
420            BackendCaps::for_platform(PlatformKind::Unix, WindowSystem::X11)
421                .synthesises_mouse_from_touch
422        );
423        for (platform, ws) in [
424            (PlatformKind::Unix, WindowSystem::Wayland),
425            (PlatformKind::Unix, WindowSystem::Unknown),
426            (PlatformKind::Windows, WindowSystem::Unknown),
427            (PlatformKind::MacOs, WindowSystem::Unknown),
428        ] {
429            assert!(!BackendCaps::for_platform(platform, ws).synthesises_mouse_from_touch);
430        }
431    }
432
433    /// macOS owns the momentum, so P12 must never add a fling there.
434    #[test]
435    fn only_macos_owns_the_momentum() {
436        assert!(
437            BackendCaps::for_platform(PlatformKind::MacOs, WindowSystem::Unknown)
438                .reports_os_momentum
439        );
440        assert!(
441            !BackendCaps::for_platform(PlatformKind::Unix, WindowSystem::Wayland)
442                .reports_os_momentum
443        );
444        assert!(
445            !BackendCaps::for_platform(PlatformKind::Windows, WindowSystem::Unknown)
446                .reports_os_momentum
447        );
448    }
449
450    /// Windows is the only desktop backend that reaches a soft keyboard.
451    ///
452    /// It was `ViaAccessibility` while nothing could ask for the keyboard: the
453    /// Windows touch keyboard does rise for a UIA text pattern under touch
454    /// focus, and that was the whole of what the framework could claim. It is
455    /// [`SoftKeyboardSupport::Explicit`] now that
456    /// [`crate::soft_keyboard::set_visible`] exists and honours **both**
457    /// directions — which is the bar `Explicit` sets, and why a `Toggle`-only
458    /// COM call needs the visibility probe beside it.
459    #[test]
460    fn the_soft_keyboard_is_windows_only_and_explicit() {
461        assert_eq!(
462            BackendCaps::for_platform(PlatformKind::Windows, WindowSystem::Unknown).osk,
463            SoftKeyboardSupport::Explicit
464        );
465        assert_eq!(
466            BackendCaps::for_platform(PlatformKind::Unix, WindowSystem::Wayland).osk,
467            SoftKeyboardSupport::None
468        );
469        assert_eq!(SoftKeyboardSupport::default(), SoftKeyboardSupport::None);
470    }
471
472    /// An unknown window system is credited with nothing.
473    #[test]
474    fn an_unknown_backend_claims_nothing() {
475        assert_eq!(
476            BackendCaps::for_platform(PlatformKind::Unix, WindowSystem::Unknown),
477            BackendCaps::default()
478        );
479    }
480}