Skip to main content

teksilo_widgets/primitives/
touch_target.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! [`TouchTarget`] — the last resort of the three hit-targeting mechanisms:
5//! the one that actually moves things.
6
7use teksilo_canvas::{EdgeInsets, Point, Rect, Size, SizeProposal};
8use teksilo_core::build_context::BuildContext;
9use teksilo_core::widget::{LayoutContext, LayoutResponse, Widget, WidgetPlacement};
10use teksilo_core::widget_id::WidgetId;
11use teksilo_tokens::{InputTokens, PointerKind, TargetDensity};
12
13/// Give an undersized control a conforming touch target, growing the layout
14/// around it when nothing cheaper will do — and **only at
15/// [`TargetDensity::Touch`]**.
16///
17/// # When you need this, and when you do not
18///
19/// Teksilo has three ways to make a target reachable, and they are ordered by
20/// how much they disturb:
21///
22/// 1. **`Widget::hit_outset`** — a thin grip claims the space around it.
23///    Hit-only; nothing moves. This is what a splitter gutter or a column
24///    resize strip uses.
25/// 2. **The miss-only slop pass** — an isolated small control catches a near
26///    miss. Hit-only; nothing moves. This is what a radio dot or a chart mark
27///    uses.
28/// 3. **`TouchTarget`** — this. The control genuinely needs *room*, because the
29///    thing beside it is also a target and there is no space to borrow. It
30///    changes layout, so siblings reflow.
31///
32/// Reach for (3) only when (1) and (2) cannot work: when a control sits in a
33/// tight row of other controls, so widening its hit area would take presses
34/// from its neighbours rather than from empty space. Everything else the
35/// density sweep already handles by projecting the recipes.
36///
37/// # Why Touch only
38///
39/// At `Compact` and `Comfortable` this wrapper is the **identity**: it reports
40/// its child's own response, unchanged, and adds no hit outset. Compact is the
41/// density every existing layout was designed at and every layout golden was
42/// recorded at, and `Comfortable` is served by the recipes' own density
43/// projection, which raises a control's *own* dimensions rather than padding
44/// around it. `Touch` is the ladder where a 24 dp control still falls 20 dp
45/// short of the target and no recipe can close the gap from inside.
46///
47/// ```ignore
48/// // A 16 dp close affordance in a dense tab strip: at Touch it is given a
49/// // 44 dp slot and centred in it; at Compact nothing changes at all.
50/// TouchTarget::new().child(close_button)
51/// ```
52///
53/// # `reserve_space`
54///
55/// * `reserve_space(true)` (**the default**) — the slot reports at least
56///   `size` on both axes and centres the child in it. Siblings reflow.
57/// * `reserve_space(false)` — the slot reports the child's own size and
58///   declares a [`Widget::hit_outset`] that brings the *hit* area up to `size`
59///   instead. Nothing moves; the target overlaps whatever is beside it. Use it
60///   when the row has slack in one direction but you cannot spend it.
61pub struct TouchTarget {
62    size: Option<f32>,
63    reserve_space: bool,
64    child: Option<WidgetId>,
65    pending: Option<Box<dyn Widget>>,
66}
67
68impl TouchTarget {
69    /// A new wrapper at the density's own `target_size`. Attach content with
70    /// [`child`](Self::child) or [`child_id`](Self::child_id).
71    pub fn new() -> Self {
72        Self {
73            size: None,
74            reserve_space: true,
75            child: None,
76            pending: None,
77        }
78    }
79
80    /// Override the target size, in dp. Defaults to the density's
81    /// `InputTokens::target_size` (44 dp at Touch).
82    pub fn size(mut self, dp: f32) -> Self {
83        self.size = Some(dp);
84        self
85    }
86
87    /// Whether the slot takes the room it needs (`true`, the default) or widens
88    /// only the hit area (`false`). See the type docs.
89    pub fn reserve_space(mut self, reserve: bool) -> Self {
90        self.reserve_space = reserve;
91        self
92    }
93
94    /// Wrap an inline widget.
95    pub fn child(mut self, widget: impl Widget + 'static) -> Self {
96        self.pending = Some(Box::new(widget));
97        self
98    }
99
100    /// Wrap a pre-registered widget by id.
101    pub fn child_id(mut self, id: WidgetId) -> Self {
102        self.child = Some(id);
103        self
104    }
105
106    /// The target this wrapper aims for under `tokens`, or `None` when it is
107    /// inert (any density but `Touch`).
108    fn target(&self, tokens: &InputTokens) -> Option<f32> {
109        if tokens.density != TargetDensity::Touch {
110            return None;
111        }
112        let size = self.size.unwrap_or(tokens.target_size);
113        (size.is_finite() && size > 0.0).then_some(size)
114    }
115}
116
117impl Default for TouchTarget {
118    fn default() -> Self {
119        Self::new()
120    }
121}
122
123impl std::fmt::Debug for TouchTarget {
124    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
125        f.debug_struct("TouchTarget")
126            .field("size", &self.size)
127            .field("reserve_space", &self.reserve_space)
128            .finish()
129    }
130}
131
132impl Widget for TouchTarget {
133    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
134        if let Some(pending) = self.pending.take() {
135            self.child = Some(ctx.add_boxed(pending));
136        }
137        self.child.into_iter().collect()
138    }
139
140    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
141        let child = self
142            .child
143            .and_then(|id| ctx.child_layout_response(id, proposal))
144            .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into());
145        // Inert at every density but Touch, and inert whenever the caller asked
146        // for hit-only widening: forward the child's FULL response — grow
147        // weight, shrink weight and compression floor — so wrapping a
148        // shrinkable child does not silently make it rigid.
149        let Some(target) = self.target(&ctx.theme.input) else {
150            return child;
151        };
152        if !self.reserve_space {
153            return child;
154        }
155        let size = Size::new(child.size.width.max(target), child.size.height.max(target));
156        LayoutResponse {
157            size,
158            flex: child.flex,
159            min: Size::new(child.min.width.max(target), child.min.height.max(target)),
160            shrink: child.shrink,
161        }
162    }
163
164    fn place_children(
165        &self,
166        bounds: Rect,
167        proposal: SizeProposal,
168        children: &mut [WidgetPlacement],
169        ctx: &LayoutContext,
170    ) {
171        for child in children.iter_mut() {
172            // The child keeps its own size and is centred in whatever slot the
173            // wrapper was given; it is the SLOT that grew, never the control.
174            let natural = self
175                .child
176                .and_then(|id| ctx.child_size(id, proposal))
177                .unwrap_or(bounds.size());
178            let size = Size::new(
179                natural.width.min(bounds.width),
180                natural.height.min(bounds.height),
181            );
182            child.origin = Point::new(
183                bounds.x + (bounds.width - size.width) / 2.0,
184                bounds.y + (bounds.height - size.height) / 2.0,
185            );
186            child.size = size;
187        }
188    }
189
190    fn children(&self) -> Vec<WidgetId> {
191        self.child.into_iter().collect()
192    }
193
194    fn hit_outset(&self, kind: PointerKind, tokens: &InputTokens) -> EdgeInsets {
195        // The `reserve_space(false)` half of the contract: no layout moves, so
196        // the shortfall is made up between the pointer and the arena instead.
197        if self.reserve_space || !kind.is_direct() {
198            return EdgeInsets::ZERO;
199        }
200        match self.target(tokens) {
201            Some(target) => EdgeInsets::uniform(target / 2.0),
202            None => EdgeInsets::ZERO,
203        }
204    }
205}
206
207#[cfg(test)]
208mod tests {
209    use super::*;
210    use crate::primitives::{FixedSize, HStack, Shrinkable};
211    use teksilo_core::widget_builder::WidgetBuilder;
212    use teksilo_core::widget_tree::WidgetTree;
213
214    fn tree_at(density: TargetDensity) -> WidgetTree {
215        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
216        tree.set_input_density(density);
217        tree
218    }
219
220    /// Compact renders identically: the wrapper reports the child's size and
221    /// places it exactly where an unwrapped child would sit.
222    #[test]
223    fn compact_and_comfortable_are_the_identity() {
224        for density in [TargetDensity::Compact, TargetDensity::Comfortable] {
225            let mut tree = tree_at(density);
226            let inner = tree.add(FixedSize::new().width(16.0).height(16.0));
227            let slot = tree.add(TouchTarget::new().child_id(inner));
228            tree.layout(SizeProposal::unspecified());
229            assert_eq!(
230                tree.bounds(slot).size(),
231                Size::new(16.0, 16.0),
232                "{density:?} must not grow the slot"
233            );
234            assert_eq!(tree.bounds(inner).size(), Size::new(16.0, 16.0));
235        }
236    }
237
238    /// At Touch the slot reaches the target and the child is centred in it —
239    /// the control itself never grows.
240    #[test]
241    fn touch_gives_the_slot_the_target_and_centres_the_child() {
242        let mut tree = tree_at(TargetDensity::Touch);
243        let inner = tree.add(FixedSize::new().width(16.0).height(16.0));
244        let slot = tree.add(TouchTarget::new().child_id(inner));
245        tree.layout(SizeProposal::unspecified());
246        assert_eq!(tree.bounds(slot).size(), Size::new(44.0, 44.0));
247        assert_eq!(tree.bounds(inner).size(), Size::new(16.0, 16.0));
248        assert_eq!(tree.bounds(inner).center(), tree.bounds(slot).center());
249    }
250
251    /// An explicit size overrides the density's own.
252    #[test]
253    fn an_explicit_size_wins_over_the_density() {
254        let mut tree = tree_at(TargetDensity::Touch);
255        let inner = tree.add(FixedSize::new().width(16.0).height(16.0));
256        let slot = tree.add(TouchTarget::new().size(48.0).child_id(inner));
257        tree.layout(SizeProposal::unspecified());
258        assert_eq!(tree.bounds(slot).size(), Size::new(48.0, 48.0));
259    }
260
261    /// A control already at or beyond the target is left alone.
262    #[test]
263    fn a_conforming_child_is_untouched() {
264        let mut tree = tree_at(TargetDensity::Touch);
265        let inner = tree.add(FixedSize::new().width(60.0).height(50.0));
266        let slot = tree.add(TouchTarget::new().child_id(inner));
267        tree.layout(SizeProposal::unspecified());
268        assert_eq!(tree.bounds(slot).size(), Size::new(60.0, 50.0));
269    }
270
271    /// `reserve_space(false)` moves nothing and widens the hit area instead.
272    #[test]
273    fn reserve_space_false_widens_the_hit_area_without_moving_anything() {
274        let mut tree = tree_at(TargetDensity::Touch);
275        let inner = tree.add(FixedSize::new().width(16.0).height(16.0));
276        let slot = tree.add(
277            TouchTarget::new()
278                .reserve_space(false)
279                .child_id(inner)
280                .on_tap(|_e, _c| {}),
281        );
282        tree.layout(SizeProposal::unspecified());
283        assert_eq!(
284            tree.bounds(slot).size(),
285            Size::new(16.0, 16.0),
286            "nothing may move"
287        );
288        let finger = teksilo_core::pointer::PointerInfo::touch(
289            teksilo_core::pointer::PointerId::MOUSE,
290            teksilo_core::pointer::EventTime::ZERO,
291        );
292        // 22 dp of outset on every edge: a press 10 dp past the child's edge
293        // still reaches it, and a mouse press does not.
294        assert_eq!(
295            tree.hit_test_for(Point::new(26.0, 8.0), &finger),
296            Some(slot)
297        );
298        assert_ne!(tree.hit_test(Point::new(26.0, 8.0)), Some(slot));
299    }
300
301    /// Layout-transparency for the FULL response: wrapping a shrinkable child
302    /// must not make it rigid — the `DeadZone` lesson.
303    #[test]
304    fn the_wrapper_forwards_its_child_shrink_weight() {
305        let mut tree = tree_at(TargetDensity::Compact);
306        let slot = tree.add(
307            TouchTarget::new().child(
308                Shrinkable::new()
309                    .min_width(20.0)
310                    .child(FixedSize::new().width(100.0).height(20.0)),
311            ),
312        );
313        let rigid = tree.add(FixedSize::new().width(100.0).height(20.0));
314        tree.add(HStack::new().add_child(rigid).add_child(slot));
315        tree.layout(SizeProposal::exact(120.0, 20.0));
316        let w = tree.bounds(slot).width;
317        assert!(
318            w < 100.0,
319            "the TouchTarget must forward the child's shrink weight (width was {w})"
320        );
321    }
322}