Skip to main content

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}