Skip to main content

teksilo_widgets/primitives/
dead_zone.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`DeadZone`] — a gesture **dead zone** wrapper.
5//!
6//! ## Touch and pen
7//!
8//! The no-op tap and drag pair is **kept**, and it is not redundant with the
9//! `gesture_dead_zone` flag: the flag governs whether an ancestor may enrol this
10//! subtree's press as a member of its own gesture, while the absorbers are what
11//! give the wrapper a gesture arena — and the arena is what stops the bubble for a
12//! press on the wrapper's own bare area, the gap between the controls it protects.
13//! Reviewed under the arbitration package with that conclusion; nothing about it is
14//! pointer-kind-specific.
15
16use teksilo_canvas::{Rect, SizeProposal};
17use teksilo_core::build_context::BuildContext;
18use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
19use teksilo_core::widget_builder::HandlerSet;
20use teksilo_core::widget_id::WidgetId;
21
22/// A layout-transparent wrapper whose subtree is a **gesture dead zone**: a
23/// pointer press inside it never arms a drag/swipe recognizer on any ancestor.
24///
25/// Wrap interactive controls (buttons, a `⋮` options menu, a slider) that sit
26/// **inside a draggable / swipeable container** — a dock-panel header, a card, a
27/// list row, a scene item — so clicking them, *even with the few pixels of
28/// pointer jitter a real click carries*, can never start the ancestor's drag.
29/// The container's own drag still works everywhere outside the dead zone. This
30/// is the framework counterpart of Electron's `-webkit-app-region: no-drag`.
31///
32/// It is robust **structurally**, not by a timing-dependent gesture race: it
33/// sets the node-level [`gesture_dead_zone`](teksilo_core::widget_builder::WidgetBuilder::gesture_dead_zone)
34/// flag, which the framework's drag-arming honours by refusing to arm any
35/// ancestor above this node. (It also carries a no-op tap/drag so a press on the
36/// dead zone's own bare area — a gap between controls — is absorbed too.)
37///
38/// ```ignore
39/// // A draggable dock header whose action buttons don't drag the panel:
40/// HStack::new()
41///     .child(title)
42///     .child(DeadZone::new().child(
43///         HStack::new()
44///             .child(IconButton::new(new_icon).on_activate_fn(..))
45///             .child(options_button),
46///     ))
47/// ```
48///
49/// Layout-transparent: it reports its child's size and fills the child to its
50/// own bounds, so dropping it in is size-neutral.
51pub struct DeadZone {
52    child: Option<WidgetId>,
53    pending: Option<Box<dyn Widget>>,
54}
55
56impl DeadZone {
57    /// A new, empty dead zone. Attach content with [`child`](Self::child) or
58    /// [`child`](Self::child).
59    pub fn new() -> Self {
60        Self {
61            child: None,
62            pending: None,
63        }
64    }
65
66    /// Wrap an inline widget.
67    pub fn child(mut self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
68        match teksilo_core::IntoTeksiChild::into_pending(widget) {
69            teksilo_core::PendingChild::Id(id) => {
70                self.child = Some(id);
71                self
72            }
73            teksilo_core::PendingChild::Deferred(w) => {
74                self.pending = Some(w);
75                self
76            }
77        }
78    }
79    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
80    ///
81    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
82    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
83    /// adds no arena node, so nothing is laid out, painted, or published to the
84    /// accessibility tree, and a stack applies no spacing around it.
85    pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
86        match widget {
87            Some(w) => self.child(w),
88            None => self,
89        }
90    }
91}
92
93impl Default for DeadZone {
94    fn default() -> Self {
95        Self::new()
96    }
97}
98
99impl std::fmt::Debug for DeadZone {
100    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
101        f.debug_struct("DeadZone").finish()
102    }
103}
104
105impl Widget for DeadZone {
106    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
107        if let Some(pending) = self.pending.take() {
108            self.child = Some(ctx.add_boxed(pending));
109        }
110        // The structural block (the flag) handles a press on a descendant
111        // control; the no-op tap/drag absorbs a press on the dead zone's own
112        // bare area (the captured widget is then the dead zone itself, which the
113        // existing innermost-can-drag skip catches).
114        ctx.apply_self_handlers(
115            HandlerSet::new()
116                .gesture_dead_zone(true)
117                .on_tap(|_e, _ctx| {})
118                .on_drag(|_phase, _ctx| {}),
119        );
120        self.child.into_iter().collect()
121    }
122
123    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
124        // Layout-transparent: forward the child's **full** response — grow
125        // weight, shrink weight, and compression floor — not just its size.
126        // Flattening to a bare `Size` (`.into()`) would make the wrapper rigid
127        // (`shrink = 0`), silently swallowing a shrinkable child's shrink weight
128        // — so a shrink-to-fit `Toolbar` wrapped in a `DeadZone` inside a tight
129        // dock header could no longer collapse, over-constraining the header.
130        self.child
131            .and_then(|id| ctx.child_layout_response(id, proposal))
132            .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into())
133    }
134
135    fn place_children(
136        &self,
137        bounds: Rect,
138        _proposal: SizeProposal,
139        children: &mut [WidgetPlacement],
140        _ctx: &LayoutContext,
141    ) {
142        for child in children.iter_mut() {
143            child.origin = bounds.origin();
144            child.size = bounds.size();
145        }
146    }
147
148    fn children(&self) -> Vec<WidgetId> {
149        self.child.into_iter().collect()
150    }
151}
152
153#[cfg(test)]
154mod tests {
155    use super::*;
156    use crate::icon_button::IconButton;
157    use crate::primitives::IconWidget;
158    use std::cell::Cell;
159    use std::rc::Rc;
160    use teksilo_canvas::Point;
161    use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
162    use teksilo_core::widget_builder::WidgetBuilder;
163    use teksilo_core::widget_tree::WidgetTree;
164
165    #[test]
166    fn dead_zone_blocks_ancestor_drag_but_lets_the_button_click() {
167        // A draggable ancestor with a DeadZone-wrapped button inside it: a
168        // jittery press on the button activates it WITHOUT starting the
169        // ancestor's drag.
170        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
171        let dragged = Rc::new(Cell::new(false));
172        let clicked = Rc::new(Cell::new(false));
173        let d = dragged.clone();
174        let c = clicked.clone();
175        let button = tree
176            .add(IconButton::new(IconWidget::checkmark(16.0)).on_activate_fn(move |_| c.set(true)));
177        let dead = tree.add(DeadZone::new().child(button));
178        let ancestor = tree.add(crate::primitives::HStack::new().child(dead).on_drag(
179            move |phase, _ctx| {
180                if let teksilo_core::gesture::DragPhase::Started { .. } = phase {
181                    d.set(true);
182                }
183            },
184        ));
185        tree.layout(SizeProposal::exact(120.0, 60.0));
186
187        let b = tree.bounds(button);
188        let (cx, cy) = (b.x + b.width / 2.0, b.y + b.height / 2.0);
189        // Clean click activates the button.
190        tree.dispatch_event(WidgetEvent::pointer_down(
191            Point::new(cx, cy),
192            PointerButton::Primary,
193            Modifiers::NONE,
194        ));
195        tree.dispatch_event(WidgetEvent::pointer_up(
196            Point::new(cx, cy),
197            PointerButton::Primary,
198            Modifiers::NONE,
199        ));
200        assert!(
201            clicked.get(),
202            "the button inside the dead zone still activates"
203        );
204
205        // A jittery press (down + several small moves + up) must NOT drag the
206        // ancestor.
207        tree.pointer_down_button(Point::new(cx, cy), PointerButton::Primary);
208        for i in 1..=10 {
209            tree.pointer_move(Point::new(cx + (i as f32) * 3.0, cy + 1.0));
210        }
211        tree.pointer_up_button(Point::new(cx + 30.0, cy + 1.0), PointerButton::Primary);
212        assert!(
213            !dragged.get(),
214            "a jittery press on the dead-zone button must not start the ancestor drag"
215        );
216        let _ = ancestor;
217    }
218
219    #[test]
220    fn dead_zone_forwards_shrink_so_a_wrapped_child_still_compresses() {
221        // Regression: `DeadZone` must be layout-transparent for the FULL
222        // response (flex + shrink + min), not just the size. Flattening to a
223        // bare `Size` made the wrapper rigid (`shrink = 0`), which swallowed a
224        // shrinkable child's shrink weight — a shrink-to-fit `Toolbar` wrapped
225        // in a `DeadZone` inside a tight dock header then over-constrained the
226        // header (title pushed out of view) instead of collapsing.
227        use crate::primitives::{FixedSize, HStack, Shrinkable};
228        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
229        // A shrinkable child: natural 100 px wide, compression floor 20 px.
230        let dead = tree.add(
231            DeadZone::new().child(
232                Shrinkable::new()
233                    .min_width(20.0)
234                    .child(FixedSize::new().width(100.0).height(20.0)),
235            ),
236        );
237        // A rigid 100-wide sibling forces the whole deficit onto the dead zone.
238        let rigid = tree.add(FixedSize::new().width(100.0).height(20.0));
239        tree.add(HStack::new().child(rigid).child(dead));
240        // 200 px natural, 120 px offered → 80 px deficit; the shrinkable dead
241        // zone must absorb it (down toward its 20 px floor).
242        tree.layout(SizeProposal::exact(120.0, 20.0));
243        let w = tree.bounds(dead).width;
244        assert!(
245            w < 100.0,
246            "the DeadZone must forward the child's shrink weight (width was {w}, expected < 100)"
247        );
248    }
249}