teksilo_core/pointer/touch_action.rs
1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Touch-action and pan-claim vocabulary: what a subtree permits a direct
5//! pointer to do to it, and who along the ancestor chain wants to pan.
6//!
7//! Modelled on the CSS `touch-action` property: a node's own declaration is
8//! intersected with every ancestor's on the way down, so an ancestor can only
9//! ever *narrow* what a descendant permits, never widen it. [`TouchAction`] is
10//! the declaration; [`PanClaim`] is a *separate* declaration — "I am a pan
11//! surface" — that a scrollable makes independently of what `TouchAction` its
12//! subtree allows. The two folds a consumer needs
13//! (`WidgetTree::effective_touch_action` / `WidgetTree::pan_candidates`) live
14//! in `widget_tree/pointer_state.rs`, next to the rest of the pointer/hover/
15//! capture bookkeeping.
16//!
17//! Both are read at dispatch time. [`TouchAction`] gates the pan claimants a
18//! press enrols (`WidgetTree::begin_sequence`) and whether a subtree admits a
19//! two-contact pinch at all; [`PanClaim`] is what
20//! `WidgetTree::pan_candidates` collects into the chain a synthesised pan is
21//! delivered along. See `widget_tree::pan_arbiter`.
22//!
23//! # Mouse is unaffected
24//!
25//! A mouse never consults [`TouchAction`] at all — it has no contact patch to
26//! restrict and it already scrolls with the wheel, not by dragging content.
27//! [`PanClaim::devices`] defaults to [`PointerKindMask::DIRECT`], which
28//! excludes [`teksilo_tokens::PointerKind::Mouse`] — this is the field that
29//! keeps a mouse from ever being treated as a panning pointer, however a
30//! widget declares its claim. See `docs/events-and-gestures.md` "Touch action
31//! and pan claims".
32
33use teksilo_tokens::PointerKindMask;
34
35/// Which axis a pan or a touch-action permission is about.
36///
37/// `teksilo-core` has no existing public `Axis`/`Orientation` type that fits
38/// here (`teksilo_tokens::Orientation` names a *widget* layout axis, and
39/// `teksilo-widgets`' private `Axis` isn't reachable from this crate), so this
40/// module declares its own.
41#[derive(Copy, Clone, PartialEq, Eq, Debug)]
42pub enum Axis {
43 /// The horizontal axis.
44 X,
45 /// The vertical axis.
46 Y,
47}
48
49/// What a direct pointer (touch, pen) is permitted to do to a subtree,
50/// declared per node and intersected down the tree — the CSS `touch-action`
51/// model.
52///
53/// A bitset over three permissions (pan-x, pan-y, pinch-zoom), plus the two
54/// absorbing/identity extremes [`AUTO`](Self::AUTO) (everything permitted —
55/// the default) and [`NONE`](Self::NONE) (nothing permitted). `AUTO` occupies
56/// every bit of the backing `u8`, not just the three named ones, so it stays
57/// the identity element for [`intersect`](Self::intersect) even if a later
58/// package adds a fourth permission bit: intersecting anything with a value
59/// that has every bit set can never clear a bit the other side already had.
60///
61/// A mouse never reads this type at all — see the module docs.
62#[derive(Copy, Clone, PartialEq, Eq, Debug)]
63pub struct TouchAction(u8);
64
65impl TouchAction {
66 const PAN_X_BIT: u8 = 1 << 0;
67 const PAN_Y_BIT: u8 = 1 << 1;
68 const PINCH_ZOOM_BIT: u8 = 1 << 2;
69
70 /// Every default touch behaviour is permitted. The identity element for
71 /// [`intersect`](Self::intersect) and [`Default`].
72 pub const AUTO: Self = Self(u8::MAX);
73 /// No default touch behaviour is permitted — the subtree wants every
74 /// contact reserved for its own gesture handling. The absorbing element
75 /// for [`intersect`](Self::intersect): once any ancestor declares `NONE`,
76 /// nothing below it can un-forbid anything.
77 pub const NONE: Self = Self(0);
78 /// Horizontal panning only.
79 pub const PAN_X: Self = Self(Self::PAN_X_BIT);
80 /// Vertical panning only.
81 pub const PAN_Y: Self = Self(Self::PAN_Y_BIT);
82 /// Panning on either axis (`PAN_X | PAN_Y`).
83 pub const PAN: Self = Self(Self::PAN_X_BIT | Self::PAN_Y_BIT);
84 /// Pinch-to-zoom only.
85 pub const PINCH_ZOOM: Self = Self(Self::PINCH_ZOOM_BIT);
86 /// Panning and pinch-zoom, but no other browser-style default gesture
87 /// (`PAN | PINCH_ZOOM`).
88 pub const MANIPULATION: Self = Self(Self::PAN_X_BIT | Self::PAN_Y_BIT | Self::PINCH_ZOOM_BIT);
89
90 /// The permissions both sides agree on — bitwise AND. Associative,
91 /// commutative, `AUTO` is the identity, `NONE` is absorbing (all tested
92 /// by `intersect_forms_a_commutative_monoid_with_auto_and_none_at_its_poles`
93 /// below).
94 pub const fn intersect(self, other: Self) -> Self {
95 Self(self.0 & other.0)
96 }
97
98 /// The permissions either side allows — bitwise OR.
99 pub const fn union(self, other: Self) -> Self {
100 Self(self.0 | other.0)
101 }
102
103 /// Whether panning is permitted on `axis`.
104 pub const fn allows_pan(self, axis: Axis) -> bool {
105 let bit = match axis {
106 Axis::X => Self::PAN_X_BIT,
107 Axis::Y => Self::PAN_Y_BIT,
108 };
109 self.0 & bit != 0
110 }
111
112 /// Whether horizontal panning is permitted. Sugar for
113 /// `allows_pan(Axis::X)`.
114 pub const fn allows_pan_x(self) -> bool {
115 self.allows_pan(Axis::X)
116 }
117
118 /// Whether vertical panning is permitted. Sugar for
119 /// `allows_pan(Axis::Y)`.
120 pub const fn allows_pan_y(self) -> bool {
121 self.allows_pan(Axis::Y)
122 }
123
124 /// Whether pinch-to-zoom is permitted.
125 pub const fn allows_pinch(self) -> bool {
126 self.0 & Self::PINCH_ZOOM_BIT != 0
127 }
128
129 /// Whether any delayed gesture recognition (long-press, a slop-gated
130 /// drag) may still run on this subtree — true unless the whole action is
131 /// [`NONE`](Self::NONE). A subtree that reserves every touch behaviour for
132 /// itself gets its response immediately, with no arbitration delay.
133 pub const fn allows_delayed_gestures(self) -> bool {
134 !self.is_none()
135 }
136
137 /// Whether this is [`NONE`](Self::NONE) — nothing permitted.
138 pub const fn is_none(self) -> bool {
139 self.0 == Self::NONE.0
140 }
141}
142
143impl std::ops::BitOr for TouchAction {
144 type Output = Self;
145 fn bitor(self, rhs: Self) -> Self {
146 self.union(rhs)
147 }
148}
149
150impl std::ops::BitAnd for TouchAction {
151 type Output = Self;
152 fn bitand(self, rhs: Self) -> Self {
153 self.intersect(rhs)
154 }
155}
156
157impl Default for TouchAction {
158 /// [`Self::AUTO`] — a node that declares nothing permits everything,
159 /// exactly like the CSS property it mirrors.
160 fn default() -> Self {
161 Self::AUTO
162 }
163}
164
165/// Which axes a [`PanClaim`] wants to pan on.
166#[derive(Copy, Clone, PartialEq, Eq, Debug, Default)]
167pub struct PanAxes(u8);
168
169impl PanAxes {
170 const X_BIT: u8 = 1 << 0;
171 const Y_BIT: u8 = 1 << 1;
172
173 /// Neither axis. The default.
174 pub const NONE: Self = Self(0);
175 /// Horizontal only.
176 pub const X: Self = Self(Self::X_BIT);
177 /// Vertical only.
178 pub const Y: Self = Self(Self::Y_BIT);
179 /// Both axes.
180 pub const BOTH: Self = Self(Self::X_BIT | Self::Y_BIT);
181
182 /// Whether `axis` is one of the claimed axes.
183 pub const fn contains(self, axis: Axis) -> bool {
184 let bit = match axis {
185 Axis::X => Self::X_BIT,
186 Axis::Y => Self::Y_BIT,
187 };
188 self.0 & bit != 0
189 }
190}
191
192/// A node's declaration that it is a **pan surface**: it wants to consume a
193/// direct pointer's drag as content panning rather than let it arm a drag/
194/// swipe recognizer or fall through.
195///
196/// Declared independently of [`TouchAction`] — a scrollable states "I pan"
197/// via `PanClaim` regardless of what its own `touch_action` permits;
198/// `TouchAction` is what the *arbitration* consults to decide whether a claim
199/// further down the chain is still reachable. See
200/// `WidgetTree::pan_candidates`.
201///
202/// Declaring a pan claim is orthogonal to having an `on_scroll` handler: a
203/// `SpinBox` increments on wheel, a `TabBar` remaps wheel to horizontal tab
204/// scroll, and `SceneView` zooms on Ctrl-wheel — none of those is a pan, and
205/// none of them declares a `PanClaim`. A scroll container declares itself
206/// explicitly via [`scroll_container`](crate::widget_builder::HandlerSet::scroll_container).
207#[derive(Copy, Clone, PartialEq, Debug)]
208pub struct PanClaim {
209 /// Which axes this surface wants to pan on.
210 pub axes: PanAxes,
211 /// Which pointer kinds this claim applies to. Defaults to
212 /// [`PointerKindMask::DIRECT`] (touch + pen, never mouse) — see the
213 /// module docs on why a mouse must never be treated as a panning
214 /// pointer.
215 pub devices: PointerKindMask,
216 /// Whether a release should hand off to a fling/settle simulation. `false`
217 /// by default; [`scroll_container`](crate::widget_builder::HandlerSet::scroll_container)'s
218 /// sugar turns it on.
219 pub kinetic: bool,
220}
221
222impl PanClaim {
223 /// A vertical-only claim, direct pointers only, no kinetic hand-off.
224 pub fn vertical() -> Self {
225 Self {
226 axes: PanAxes::Y,
227 ..Self::default()
228 }
229 }
230
231 /// A horizontal-only claim, direct pointers only, no kinetic hand-off.
232 pub fn horizontal() -> Self {
233 Self {
234 axes: PanAxes::X,
235 ..Self::default()
236 }
237 }
238
239 /// A both-axes claim, direct pointers only, no kinetic hand-off.
240 pub fn both() -> Self {
241 Self {
242 axes: PanAxes::BOTH,
243 ..Self::default()
244 }
245 }
246}
247
248impl Default for PanClaim {
249 /// No axes claimed, [`PointerKindMask::DIRECT`] devices, not kinetic.
250 /// [`vertical`](Self::vertical) / [`horizontal`](Self::horizontal) /
251 /// [`both`](Self::both) build on this rather than repeating the device
252 /// mask.
253 fn default() -> Self {
254 Self {
255 axes: PanAxes::NONE,
256 devices: PointerKindMask::DIRECT,
257 kinetic: false,
258 }
259 }
260}
261
262#[cfg(test)]
263mod tests {
264 use super::*;
265
266 // --- TouchAction algebra ----------------------------------------------
267
268 #[test]
269 fn intersect_forms_a_commutative_monoid_with_auto_and_none_at_its_poles() {
270 let values = [
271 TouchAction::AUTO,
272 TouchAction::NONE,
273 TouchAction::PAN_X,
274 TouchAction::PAN_Y,
275 TouchAction::PAN,
276 TouchAction::PINCH_ZOOM,
277 TouchAction::MANIPULATION,
278 ];
279
280 for &a in &values {
281 // AUTO is the identity.
282 assert_eq!(
283 a.intersect(TouchAction::AUTO),
284 a,
285 "AUTO must be an identity"
286 );
287 assert_eq!(
288 TouchAction::AUTO.intersect(a),
289 a,
290 "AUTO must be an identity"
291 );
292 // NONE is absorbing.
293 assert_eq!(
294 a.intersect(TouchAction::NONE),
295 TouchAction::NONE,
296 "NONE must absorb"
297 );
298 assert_eq!(
299 TouchAction::NONE.intersect(a),
300 TouchAction::NONE,
301 "NONE must absorb"
302 );
303
304 for &b in &values {
305 // Commutative.
306 assert_eq!(
307 a.intersect(b),
308 b.intersect(a),
309 "intersect must be commutative"
310 );
311
312 for &c in &values {
313 // Associative.
314 assert_eq!(
315 a.intersect(b).intersect(c),
316 a.intersect(b.intersect(c)),
317 "intersect must be associative"
318 );
319 }
320 }
321 }
322 }
323
324 #[test]
325 fn union_is_the_dual_operator() {
326 assert_eq!(
327 TouchAction::PAN_X.union(TouchAction::PAN_Y),
328 TouchAction::PAN
329 );
330 assert_eq!(
331 TouchAction::PAN.union(TouchAction::PINCH_ZOOM),
332 TouchAction::MANIPULATION
333 );
334 assert_eq!(TouchAction::PAN_X | TouchAction::PAN_Y, TouchAction::PAN);
335 assert_eq!(TouchAction::PAN & TouchAction::PAN_X, TouchAction::PAN_X);
336 }
337
338 #[test]
339 fn default_is_auto() {
340 assert_eq!(TouchAction::default(), TouchAction::AUTO);
341 }
342
343 /// `allows_pan` per axis, for every named constant.
344 #[test]
345 fn allows_pan_matches_the_named_constants() {
346 assert!(TouchAction::AUTO.allows_pan_x() && TouchAction::AUTO.allows_pan_y());
347 assert!(TouchAction::AUTO.allows_pinch());
348 assert!(!TouchAction::NONE.allows_pan_x() && !TouchAction::NONE.allows_pan_y());
349 assert!(!TouchAction::NONE.allows_pinch());
350
351 assert!(TouchAction::PAN_X.allows_pan_x());
352 assert!(!TouchAction::PAN_X.allows_pan_y());
353 assert!(!TouchAction::PAN_X.allows_pinch());
354
355 assert!(TouchAction::PAN_Y.allows_pan_y());
356 assert!(!TouchAction::PAN_Y.allows_pan_x());
357 assert!(!TouchAction::PAN_Y.allows_pinch());
358
359 assert!(TouchAction::PAN.allows_pan_x() && TouchAction::PAN.allows_pan_y());
360 assert!(!TouchAction::PAN.allows_pinch());
361
362 assert!(TouchAction::PINCH_ZOOM.allows_pinch());
363 assert!(!TouchAction::PINCH_ZOOM.allows_pan_x() && !TouchAction::PINCH_ZOOM.allows_pan_y());
364
365 assert!(
366 TouchAction::MANIPULATION.allows_pan_x() && TouchAction::MANIPULATION.allows_pan_y()
367 );
368 assert!(TouchAction::MANIPULATION.allows_pinch());
369 }
370
371 #[test]
372 fn allows_delayed_gestures_is_false_only_for_none() {
373 assert!(TouchAction::AUTO.allows_delayed_gestures());
374 assert!(TouchAction::PAN_X.allows_delayed_gestures());
375 assert!(!TouchAction::NONE.allows_delayed_gestures());
376 assert!(TouchAction::NONE.is_none());
377 assert!(!TouchAction::AUTO.is_none());
378 }
379
380 // --- PanAxes ------------------------------------------------------------
381
382 #[test]
383 fn pan_axes_contains_per_axis() {
384 assert!(PanAxes::BOTH.contains(Axis::X) && PanAxes::BOTH.contains(Axis::Y));
385 assert!(PanAxes::X.contains(Axis::X) && !PanAxes::X.contains(Axis::Y));
386 assert!(PanAxes::Y.contains(Axis::Y) && !PanAxes::Y.contains(Axis::X));
387 assert!(!PanAxes::NONE.contains(Axis::X) && !PanAxes::NONE.contains(Axis::Y));
388 assert_eq!(PanAxes::default(), PanAxes::NONE);
389 }
390
391 // --- PanClaim ------------------------------------------------------------
392
393 /// This is the field that keeps a mouse from ever being read as a
394 /// panning pointer — load-bearing for "mouse behaves as today".
395 #[test]
396 fn pan_claim_default_devices_excludes_mouse() {
397 let claim = PanClaim::default();
398 assert!(!claim.devices.contains(teksilo_tokens::PointerKind::Mouse));
399 assert!(claim.devices.contains(teksilo_tokens::PointerKind::Touch));
400 assert!(claim.devices.contains(teksilo_tokens::PointerKind::Pen(
401 teksilo_tokens::PenKind::Pen
402 )));
403 assert!(!claim.kinetic);
404 assert_eq!(claim.axes, PanAxes::NONE);
405 }
406
407 #[test]
408 fn vertical_horizontal_both_helpers_set_only_their_axes() {
409 assert_eq!(PanClaim::vertical().axes, PanAxes::Y);
410 assert_eq!(PanClaim::horizontal().axes, PanAxes::X);
411 assert_eq!(PanClaim::both().axes, PanAxes::BOTH);
412 // The device mask and kinetic flag still come from Default.
413 assert_eq!(PanClaim::vertical().devices, PointerKindMask::DIRECT);
414 assert!(!PanClaim::vertical().kinetic);
415 }
416}