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