teksilo_platform/pen.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Pen and stylus input, ahead of winit.
5//!
6//! winit 0.30 exposes **no pen API at all**. On Windows its `WM_POINTER` arm
7//! already decodes pen packets and hands the app nothing; on Wayland
8//! `zwp_tablet_v2` is simply never bound. A pen therefore reaches a winit 0.30
9//! client either as a mouse (Windows, via the OS's own promotion) or as
10//! nothing (Wayland). Neither carries pressure, tilt, twist, the eraser end, or
11//! the fact that the tool is *hovering*.
12//!
13//! This module is the shim that fills that gap without waiting for the winit
14//! 0.31 upgrade, behind one seam:
15//!
16//! ```text
17//! OS PenSource::poll TranslationState
18//! zwp_tablet_tool_v2 ─┐
19//! WM_POINTER* ────────┼─▶ Vec<PenPacket> ─▶ Vec<PointerSample>
20//! (nothing) ──────────┘ PointerKind::Pen(tool)
21//! ```
22//!
23//! [`PenSource`] is *pulled*, not pushed: both backends buffer packets off the
24//! event path (a Wayland dispatch thread, a Win32 subclass proc) and the caller
25//! drains them once per event-loop turn through
26//! [`TranslationState::poll_pen`](crate::event_translation::TranslationState::poll_pen).
27//! That keeps the OS callbacks free of Teksilo state and gives the translator
28//! the caller's clock, as the one-clock rule requires.
29//!
30//! # Support matrix
31//!
32//! | | pen at all | tool kind | pressure | tilt | twist | contact patch |
33//! | --- | --- | --- | --- | --- | --- | --- |
34//! | Wayland | [`wayland`] | yes | yes | yes | yes | — |
35//! | Windows | [`windows`] | yes | yes | yes | yes | yes (touch) |
36//! | X11 | [`null`] | no | no | no | no | no |
37//! | macOS | [`null`] | no | no | no | no | no |
38//!
39//! X11 and macOS report their absence through [`BackendCaps`] rather than
40//! pretending: `reports_pen_kind` and friends stay `false`, and a consumer that
41//! must know asks instead of guessing from `cfg!(target_os = ...)`.
42//!
43//! # Proximity is a first-class state
44//!
45//! A pen in proximity with no contact is a **hovering pointer**: it moves,
46//! drives hover visuals, tooltips and the cursor exactly as a mouse does, and
47//! it does so with `down: false`. That is why [`PointerKind::hovers`] is true
48//! for `Pen` and false for `Touch`. The proximity → contact → proximity-out
49//! machine lives in the translator (`event_translation.rs`); a source's job is
50//! only to report `in_proximity` and `down` truthfully per packet.
51//!
52//! # Buttons, normatively
53//!
54//! - Pen **contact** is [`PointerButton::Primary`](teksilo_core::event::PointerButton::Primary).
55//! - The **barrel** button is
56//! [`Secondary`](teksilo_core::event::PointerButton::Secondary), matching
57//! W3C Pointer Events (pen barrel → `button` 2, `buttons` bit 2).
58//! - A second barrel button, where the hardware has one, is
59//! [`Middle`](teksilo_core::event::PointerButton::Middle).
60//! - The **eraser is a tool kind** ([`PenKind::Eraser`]), never a button. A
61//! digitizer that reports the eraser as a flag has that flag folded into the
62//! tool before it leaves the source; [`PenButtons::ERASER`] exists only so a
63//! backend can carry the raw bit faithfully.
64//!
65//! # This module has an expiry date
66//!
67//! winit 0.31 supersedes both shims with its own `TabletTool*` events and
68//! `PointerSource::Tablet`. At that upgrade [`wayland`] and [`windows`] are
69//! **deleted**, `create_pen_source` returns [`null::NullPenSource`] everywhere,
70//! and the translator reads the pen off winit like every other device. Nothing
71//! above this seam changes.
72//!
73//! Reference: `docs/touch-and-pen.md`, "Pen and stylus".
74//!
75//! [`BackendCaps`]: crate::pointer_backend::BackendCaps
76//! [`PointerKind::hovers`]: teksilo_tokens::PointerKind::hovers
77
78// The support matrix above links `wayland`, which is `#[cfg]`-ed away off Unix
79// — so on Windows and macOS that link cannot resolve and a local
80// `RUSTDOCFLAGS="-D warnings"` run fails on a link that is correct by
81// construction. The docs that ship are built on Linux (both `ci.yml`'s doc gate
82// and `docs.yml` are `runs-on: ubuntu-latest`), where the module exists, the
83// link resolves, and every link in this module is still checked under
84// `-D warnings`. Scoped to the hosts that cannot have the module, so a
85// genuinely broken link here still fails the gate on the host that enforces it.
86#![cfg_attr(
87 not(all(unix, not(target_os = "macos"))),
88 allow(rustdoc::broken_intra_doc_links)
89)]
90
91pub mod null;
92#[cfg(all(unix, not(target_os = "macos")))]
93pub mod wayland;
94// Compiled on **every** target on purpose: the `POINTER_PEN_INFO` /
95// `POINTER_TOUCH_INFO` decoder inside is pure byte-slice arithmetic, and it is
96// tested from recorded layouts on hosts that have no Windows. Only the
97// subclass shim that feeds it is `#[cfg(target_os = "windows")]`.
98pub mod windows;
99
100use teksilo_canvas::{Point, Size};
101use teksilo_core::pointer::EventTime;
102use teksilo_core::raw_handle::ParentHandle;
103use teksilo_tokens::PenKind;
104
105use crate::pointer_backend::BackendCaps;
106
107// ---------------------------------------------------------------------------
108// Buttons
109// ---------------------------------------------------------------------------
110
111/// The stylus buttons a packet reports as held.
112///
113/// A bitset rather than a `Vec` because the set is small, fixed and copied on
114/// every packet. See the module docs for the normative mapping onto
115/// [`PointerButton`](teksilo_core::event::PointerButton) — in particular, the
116/// tip is **not** in here (it is `down`), and [`ERASER`](Self::ERASER) is a
117/// carried flag rather than a button Teksilo dispatches.
118#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug, Default)]
119pub struct PenButtons(u8);
120
121impl PenButtons {
122 /// No stylus button held.
123 pub const NONE: Self = Self(0);
124 /// The barrel button — the one every stylus has. Dispatched as
125 /// `PointerButton::Secondary`.
126 pub const BARREL: Self = Self(1 << 0);
127 /// A second barrel button, where the hardware has one (Wayland's
128 /// `BTN_STYLUS2`). Dispatched as `PointerButton::Middle`.
129 pub const SECONDARY_BARREL: Self = Self(1 << 1);
130 /// The digitizer's "eraser" flag. Carried for fidelity and folded into
131 /// [`PenKind::Eraser`] by the source; never dispatched as a button.
132 pub const ERASER: Self = Self(1 << 2);
133
134 /// The raw bits, for a backend that must store the set compactly.
135 pub const fn bits(self) -> u8 {
136 self.0
137 }
138
139 /// Whether every button in `other` is held.
140 pub const fn contains(self, other: Self) -> bool {
141 self.0 & other.0 == other.0
142 }
143
144 /// Nothing held.
145 pub const fn is_empty(self) -> bool {
146 self.0 == 0
147 }
148
149 /// The union of two sets.
150 pub const fn union(self, other: Self) -> Self {
151 Self(self.0 | other.0)
152 }
153
154 /// `other` removed from this set.
155 pub const fn without(self, other: Self) -> Self {
156 Self(self.0 & !other.0)
157 }
158
159 /// `other` added to or removed from this set.
160 pub const fn with(self, other: Self, held: bool) -> Self {
161 if held {
162 self.union(other)
163 } else {
164 self.without(other)
165 }
166 }
167}
168
169// ---------------------------------------------------------------------------
170// Capabilities
171// ---------------------------------------------------------------------------
172
173/// What a [`PenSource`] can actually report.
174///
175/// Folded into the window's [`BackendCaps`] by
176/// [`apply_to`](Self::apply_to), so a consumer keeps asking one question
177/// ("does this window report tilt?") whether the answer comes from winit or
178/// from a shim.
179#[derive(Copy, Clone, PartialEq, Eq, Hash, Debug, Default)]
180pub struct PenCaps {
181 /// The source distinguishes a pen tip from an eraser (and the other
182 /// [`PenKind`]s).
183 pub tool_kind: bool,
184 /// Tip pressure is reported.
185 pub pressure: bool,
186 /// Tilt is reported.
187 pub tilt: bool,
188 /// Barrel rotation is reported.
189 pub twist: bool,
190 /// The source reports a touch contact patch (Windows `rcContact`).
191 pub touch_contact: bool,
192}
193
194impl PenCaps {
195 /// A source that reports nothing — the honest answer on X11 and macOS.
196 pub const NONE: Self = Self {
197 tool_kind: false,
198 pressure: false,
199 tilt: false,
200 twist: false,
201 touch_contact: false,
202 };
203
204 /// Everything a full digitizer reports, minus the contact patch.
205 pub const FULL_PEN: Self = Self {
206 tool_kind: true,
207 pressure: true,
208 tilt: true,
209 twist: true,
210 touch_contact: false,
211 };
212
213 /// Raise the matching flags on a window's platform capabilities.
214 ///
215 /// Only ever *raises* them: a shim adds a capability winit lacks, it never
216 /// takes one away.
217 pub fn apply_to(self, caps: &mut BackendCaps) {
218 caps.reports_pen_kind |= self.tool_kind;
219 caps.reports_pressure |= self.pressure;
220 caps.reports_tilt |= self.tilt;
221 caps.reports_twist |= self.twist;
222 }
223}
224
225// ---------------------------------------------------------------------------
226// Packets
227// ---------------------------------------------------------------------------
228
229/// One digitizer packet, normalised.
230///
231/// A packet is a *level*, not an edge: it describes the tool's complete state
232/// at one instant, and the translator derives the transitions (enter, down, up,
233/// leave, button changes) by comparing consecutive packets. That is the shape
234/// both backends produce naturally — Wayland accumulates axes and commits them
235/// on `frame`, Win32 fills one `POINTER_PEN_INFO` per message — and it means a
236/// dropped packet costs a sample, never a stuck button.
237#[derive(Copy, Clone, PartialEq, Debug)]
238pub struct PenPacket {
239 /// The tool the digitizer says is in use. The eraser end is a tool, not a
240 /// button.
241 pub tool: PenKind,
242 /// Window-logical position, already divided by the window's scale factor.
243 pub position: Point,
244 /// Normalised tip pressure, `0.0..=1.0`. `0.0` while hovering.
245 pub pressure: f32,
246 /// `(tilt_x, tilt_y)` in degrees, each `-90.0..=90.0`, or `None` when the
247 /// tool has no tilt axis.
248 pub tilt: Option<(f32, f32)>,
249 /// Barrel rotation in degrees, `0.0..=359.0`, or `None` when the tool has
250 /// no rotation axis.
251 pub twist: Option<f32>,
252 /// The stylus buttons held.
253 pub buttons: PenButtons,
254 /// Whether the tool is within the digitizer's detection range. A packet
255 /// with `in_proximity: false` ends the hover session.
256 pub in_proximity: bool,
257 /// Whether the tip is touching the surface.
258 pub down: bool,
259 /// The backend's timestamp **on the tree's timeline**, or
260 /// [`EventTime::ZERO`] when it has none.
261 ///
262 /// Both shipped shims stamp `ZERO`: Wayland's `frame` time and Win32's
263 /// `dwTime` are millisecond counters on device clocks with no known offset
264 /// from the tree's epoch, and inventing one would be a lie dressed as
265 /// precision. The translator then stamps the poll's `now`, which is the
266 /// tree's own clock and is never more than one event-loop turn late.
267 pub time: EventTime,
268}
269
270impl PenPacket {
271 /// A hovering packet: in proximity, tip up, no pressure, no buttons.
272 pub fn hovering(tool: PenKind, position: Point) -> Self {
273 Self {
274 tool,
275 position,
276 pressure: 0.0,
277 tilt: None,
278 twist: None,
279 buttons: PenButtons::NONE,
280 in_proximity: true,
281 down: false,
282 time: EventTime::ZERO,
283 }
284 }
285
286 /// The packet a tool leaving the digitizer's range produces. Position is
287 /// the last known one — the tool did not move, it stopped being seen.
288 pub fn out_of_proximity(tool: PenKind, position: Point) -> Self {
289 Self {
290 in_proximity: false,
291 ..Self::hovering(tool, position)
292 }
293 }
294
295 /// This packet with the tip in contact at `pressure`.
296 pub fn down_at(mut self, pressure: f32) -> Self {
297 self.down = true;
298 self.pressure = pressure.clamp(0.0, 1.0);
299 self
300 }
301}
302
303// ---------------------------------------------------------------------------
304// The source
305// ---------------------------------------------------------------------------
306
307/// A buffered supply of [`PenPacket`]s.
308///
309/// One instance per window. Implementations are expected to be cheap to poll
310/// and to return promptly with nothing when the user is not holding a stylus,
311/// because the caller polls once per event-loop turn.
312///
313/// `Debug` is a supertrait so that a `TranslationState` holding one stays
314/// `Debug` — the whole per-window translator is dumped in traces and in the
315/// inspector.
316pub trait PenSource: std::fmt::Debug {
317 /// Append every packet buffered since the last poll to `out`, oldest
318 /// first, and clear the buffer.
319 ///
320 /// Appending rather than returning a `Vec` lets the caller reuse one
321 /// scratch buffer for the life of the window.
322 fn poll(&mut self, out: &mut Vec<PenPacket>);
323
324 /// What this source reports. Defaults to [`PenCaps::NONE`], which is the
325 /// correct answer for a source that yields no packets.
326 fn capabilities(&self) -> PenCaps {
327 PenCaps::NONE
328 }
329
330 /// Whether this source fills its buffer from a thread of its own.
331 ///
332 /// The event loop needs to know, because it decides whether draining once
333 /// per turn is enough. A shim that reads on the **winit thread** — the
334 /// Windows `WM_POINTER` subclass — has already filled its buffer by the
335 /// time the turn that carried the message reaches the pump, so one drain
336 /// per turn sees everything. A shim that reads on its **own** thread — the
337 /// Wayland tablet listener — has not: the compositor event that woke the
338 /// loop and the packet the shim will make of it are up to one of that
339 /// listener's dispatch intervals apart, so the loop has to look again. The
340 /// catch-up look is armed at [`PEN_POLL_INTERVAL`], which is the interval
341 /// that applies once a tool has been announced; a session with none is on
342 /// a slower tier, and cannot deliver a packet at all until `tool_added`
343 /// has moved it to the fast one.
344 ///
345 /// Defaults to `false`, which is the answer for a source with no thread.
346 fn polls_off_thread(&self) -> bool {
347 false
348 }
349
350 /// The contact patch most recently reported for an OS touch contact id, in
351 /// logical pixels.
352 ///
353 /// This is not pen data, and it is here for one reason: on Windows the
354 /// `WM_POINTER` family carries `POINTER_TOUCH_INFO::rcContact`, which
355 /// `WM_TOUCH` — the path winit 0.30 takes — does not. The same subclass
356 /// that reads pen packets can read it, and the palm heuristic and the
357 /// finger-avoiding overlay placement both want it. Every other source
358 /// returns `None`.
359 ///
360 /// Keyed by the raw OS contact id (Windows' `pointerId`), because that is
361 /// what winit puts in `Touch::id` on the `WM_POINTER` path.
362 fn touch_contact(&self, _os_contact_id: u64) -> Option<Size> {
363 None
364 }
365}
366
367/// The pen source for a window, or [`null::NullPenSource`] where the platform
368/// has none.
369///
370/// Never fails: a window whose tablet manager is missing, whose subclass would
371/// not install, or which runs on a platform with no pen path at all, gets the
372/// null source and reports its absence through [`PenCaps::NONE`].
373pub fn create_pen_source(parent: &ParentHandle) -> Box<dyn PenSource> {
374 #[cfg(all(unix, not(target_os = "macos")))]
375 {
376 if let Some(source) = wayland::WaylandPenSource::attach(parent) {
377 return Box::new(source);
378 }
379 }
380 #[cfg(target_os = "windows")]
381 {
382 if let Some(source) = windows::WindowsPenSource::attach(parent) {
383 return Box::new(source);
384 }
385 }
386 let _ = parent;
387 Box::new(null::NullPenSource::new())
388}
389
390/// How often a pen shim that reads on its own thread looks at the digitizer.
391///
392/// The Wayland shim's own sleep interval, published so the event loop can pace
393/// its catch-up look to it rather than guessing. The bound that makes a value
394/// wrong is the velocity tracker's
395/// [`STOP_GAP`](teksilo_core::kinetic::velocity::STOP_GAP): a gap that long
396/// between samples is read as the stroke having paused and clears the history,
397/// so a shim looking that rarely would turn one continuous stroke into a
398/// sequence of standing starts. That relation is asserted in this module's
399/// tests rather than left to this sentence.
400pub const PEN_POLL_INTERVAL: std::time::Duration = std::time::Duration::from_millis(4);
401
402#[cfg(test)]
403mod tests {
404 use super::*;
405 use crate::pointer_backend::{PlatformKind, PointerBackend};
406 use crate::window_system::WindowSystem;
407
408 #[test]
409 fn buttons_are_a_set() {
410 let held = PenButtons::NONE.with(PenButtons::BARREL, true);
411 assert!(held.contains(PenButtons::BARREL));
412 assert!(!held.contains(PenButtons::SECONDARY_BARREL));
413 assert!(!held.is_empty());
414 assert!(held.without(PenButtons::BARREL).is_empty());
415 // Removing something that was never held is not an error.
416 assert_eq!(held.without(PenButtons::ERASER), held);
417 }
418
419 #[test]
420 fn caps_only_raise_never_lower() {
421 // macOS reports no pressure; a source that does must not be able to
422 // *unset* a capability winit already claimed either.
423 let mut caps = BackendCaps::for_platform(PlatformKind::Windows, WindowSystem::Unknown);
424 assert!(caps.reports_pressure);
425 PenCaps::NONE.apply_to(&mut caps);
426 assert!(caps.reports_pressure, "NONE must not clear a set flag");
427 assert!(!caps.reports_tilt);
428 PenCaps::FULL_PEN.apply_to(&mut caps);
429 assert!(caps.reports_tilt && caps.reports_twist && caps.reports_pen_kind);
430 }
431
432 /// The one property that makes [`PEN_POLL_INTERVAL`] right or wrong.
433 ///
434 /// A shim reading on its own thread hands the translator samples no fresher
435 /// than one interval. If that interval reached the velocity tracker's
436 /// `STOP_GAP`, every sample would look to the tracker like the resumption
437 /// of a stroke that had stopped, and a pen fling would be estimated from
438 /// standing starts. Well under it is the requirement; the exact figure is
439 /// the Wayland shim's sleep.
440 #[test]
441 fn the_poll_interval_stays_under_the_velocity_stop_gap() {
442 use teksilo_core::kinetic::velocity::STOP_GAP;
443 assert!(
444 PEN_POLL_INTERVAL < STOP_GAP,
445 "a poll interval at or past the {STOP_GAP:?} stop gap clears the \
446 velocity history between samples"
447 );
448 }
449
450 #[test]
451 fn the_null_source_reports_no_pen_and_yields_nothing() {
452 let mut source = null::NullPenSource::new();
453 let mut out = Vec::new();
454 source.poll(&mut out);
455 assert!(out.is_empty(), "the null source must yield no packets");
456 assert_eq!(source.capabilities(), PenCaps::NONE);
457 assert_eq!(source.touch_contact(1), None);
458
459 // And a translator carrying it advertises no pen either.
460 let mut state = crate::event_translation::TranslationState::new();
461 state.set_pen_source(Box::new(null::NullPenSource::new()));
462 let caps = state.capabilities();
463 assert!(!caps.reports_pen_kind);
464 assert!(!caps.reports_tilt);
465 assert!(!caps.reports_twist);
466 assert!(
467 state.poll_pen(EventTime::from_millis(10)).is_empty(),
468 "no packets in, no samples out"
469 );
470 }
471}