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}