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}