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}