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`](Self::child).
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 teksilo_core::IntoTeksiChild) -> Self {
96        match teksilo_core::IntoTeksiChild::into_pending(widget) {
97            teksilo_core::PendingChild::Id(id) => {
98                self.child = Some(id);
99                self
100            }
101            teksilo_core::PendingChild::Deferred(w) => {
102                self.pending = Some(w);
103                self
104            }
105        }
106    }
107    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
108    ///
109    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
110    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
111    /// adds no arena node, so nothing is laid out, painted, or published to the
112    /// accessibility tree, and a stack applies no spacing around it.
113    pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
114        match widget {
115            Some(w) => self.child(w),
116            None => self,
117        }
118    }
119
120    /// The target this wrapper aims for under `tokens`, or `None` when it is
121    /// inert (any density but `Touch`).
122    fn target(&self, tokens: &InputTokens) -> Option<f32> {
123        if tokens.density != TargetDensity::Touch {
124            return None;
125        }
126        let size = self.size.unwrap_or(tokens.target_size);
127        (size.is_finite() && size > 0.0).then_some(size)
128    }
129}
130
131impl Default for TouchTarget {
132    fn default() -> Self {
133        Self::new()
134    }
135}
136
137impl std::fmt::Debug for TouchTarget {
138    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
139        f.debug_struct("TouchTarget")
140            .field("size", &self.size)
141            .field("reserve_space", &self.reserve_space)
142            .finish()
143    }
144}
145
146impl Widget for TouchTarget {
147    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
148        if let Some(pending) = self.pending.take() {
149            self.child = Some(ctx.add_boxed(pending));
150        }
151        self.child.into_iter().collect()
152    }
153
154    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
155        let child = self
156            .child
157            .and_then(|id| ctx.child_layout_response(id, proposal))
158            .unwrap_or_else(|| proposal.resolve(0.0, 0.0).into());
159        // Inert at every density but Touch, and inert whenever the caller asked
160        // for hit-only widening: forward the child's FULL response — grow
161        // weight, shrink weight and compression floor — so wrapping a
162        // shrinkable child does not silently make it rigid.
163        let Some(target) = self.target(&ctx.theme.input) else {
164            return child;
165        };
166        if !self.reserve_space {
167            return child;
168        }
169        let size = Size::new(child.size.width.max(target), child.size.height.max(target));
170        LayoutResponse {
171            size,
172            flex: child.flex,
173            min: Size::new(child.min.width.max(target), child.min.height.max(target)),
174            shrink: child.shrink,
175        }
176    }
177
178    fn place_children(
179        &self,
180        bounds: Rect,
181        proposal: SizeProposal,
182        children: &mut [WidgetPlacement],
183        ctx: &LayoutContext,
184    ) {
185        for child in children.iter_mut() {
186            // The child keeps its own size and is centred in whatever slot the
187            // wrapper was given; it is the SLOT that grew, never the control.
188            let natural = self
189                .child
190                .and_then(|id| ctx.child_size(id, proposal))
191                .unwrap_or(bounds.size());
192            let size = Size::new(
193                natural.width.min(bounds.width),
194                natural.height.min(bounds.height),
195            );
196            child.origin = Point::new(
197                bounds.x + (bounds.width - size.width) / 2.0,
198                bounds.y + (bounds.height - size.height) / 2.0,
199            );
200            child.size = size;
201        }
202    }
203
204    fn children(&self) -> Vec<WidgetId> {
205        self.child.into_iter().collect()
206    }
207
208    fn hit_outset(&self, kind: PointerKind, tokens: &InputTokens) -> EdgeInsets {
209        // The `reserve_space(false)` half of the contract: no layout moves, so
210        // the shortfall is made up between the pointer and the arena instead.
211        if self.reserve_space || !kind.is_direct() {
212            return EdgeInsets::ZERO;
213        }
214        match self.target(tokens) {
215            Some(target) => EdgeInsets::uniform(target / 2.0),
216            None => EdgeInsets::ZERO,
217        }
218    }
219}
220
221#[cfg(test)]
222mod tests {
223    use super::*;
224    use crate::primitives::{FixedSize, HStack, Shrinkable};
225    use teksilo_core::widget_builder::WidgetBuilder;
226    use teksilo_core::widget_tree::WidgetTree;
227
228    fn tree_at(density: TargetDensity) -> WidgetTree {
229        let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
230        tree.set_input_density(density);
231        tree
232    }
233
234    /// Compact renders identically: the wrapper reports the child's size and
235    /// places it exactly where an unwrapped child would sit.
236    #[test]
237    fn compact_and_comfortable_are_the_identity() {
238        for density in [TargetDensity::Compact, TargetDensity::Comfortable] {
239            let mut tree = tree_at(density);
240            let inner = tree.add(FixedSize::new().width(16.0).height(16.0));
241            let slot = tree.add(TouchTarget::new().child(inner));
242            tree.layout(SizeProposal::unspecified());
243            assert_eq!(
244                tree.bounds(slot).size(),
245                Size::new(16.0, 16.0),
246                "{density:?} must not grow the slot"
247            );
248            assert_eq!(tree.bounds(inner).size(), Size::new(16.0, 16.0));
249        }
250    }
251
252    /// At Touch the slot reaches the target and the child is centred in it —
253    /// the control itself never grows.
254    #[test]
255    fn touch_gives_the_slot_the_target_and_centres_the_child() {
256        let mut tree = tree_at(TargetDensity::Touch);
257        let inner = tree.add(FixedSize::new().width(16.0).height(16.0));
258        let slot = tree.add(TouchTarget::new().child(inner));
259        tree.layout(SizeProposal::unspecified());
260        assert_eq!(tree.bounds(slot).size(), Size::new(44.0, 44.0));
261        assert_eq!(tree.bounds(inner).size(), Size::new(16.0, 16.0));
262        assert_eq!(tree.bounds(inner).center(), tree.bounds(slot).center());
263    }
264
265    /// An explicit size overrides the density's own.
266    #[test]
267    fn an_explicit_size_wins_over_the_density() {
268        let mut tree = tree_at(TargetDensity::Touch);
269        let inner = tree.add(FixedSize::new().width(16.0).height(16.0));
270        let slot = tree.add(TouchTarget::new().size(48.0).child(inner));
271        tree.layout(SizeProposal::unspecified());
272        assert_eq!(tree.bounds(slot).size(), Size::new(48.0, 48.0));
273    }
274
275    /// A control already at or beyond the target is left alone.
276    #[test]
277    fn a_conforming_child_is_untouched() {
278        let mut tree = tree_at(TargetDensity::Touch);
279        let inner = tree.add(FixedSize::new().width(60.0).height(50.0));
280        let slot = tree.add(TouchTarget::new().child(inner));
281        tree.layout(SizeProposal::unspecified());
282        assert_eq!(tree.bounds(slot).size(), Size::new(60.0, 50.0));
283    }
284
285    /// `reserve_space(false)` moves nothing and widens the hit area instead.
286    #[test]
287    fn reserve_space_false_widens_the_hit_area_without_moving_anything() {
288        let mut tree = tree_at(TargetDensity::Touch);
289        let inner = tree.add(FixedSize::new().width(16.0).height(16.0));
290        let slot = tree.add(
291            TouchTarget::new()
292                .reserve_space(false)
293                .child(inner)
294                .on_tap(|_e, _c| {}),
295        );
296        tree.layout(SizeProposal::unspecified());
297        assert_eq!(
298            tree.bounds(slot).size(),
299            Size::new(16.0, 16.0),
300            "nothing may move"
301        );
302        let finger = teksilo_core::pointer::PointerInfo::touch(
303            teksilo_core::pointer::PointerId::MOUSE,
304            teksilo_core::pointer::EventTime::ZERO,
305        );
306        // 22 dp of outset on every edge: a press 10 dp past the child's edge
307        // still reaches it, and a mouse press does not.
308        assert_eq!(
309            tree.hit_test_for(Point::new(26.0, 8.0), &finger),
310            Some(slot)
311        );
312        assert_ne!(tree.hit_test(Point::new(26.0, 8.0)), Some(slot));
313    }
314
315    /// Layout-transparency for the FULL response: wrapping a shrinkable child
316    /// must not make it rigid — the `DeadZone` lesson.
317    #[test]
318    fn the_wrapper_forwards_its_child_shrink_weight() {
319        let mut tree = tree_at(TargetDensity::Compact);
320        let slot = tree.add(
321            TouchTarget::new().child(
322                Shrinkable::new()
323                    .min_width(20.0)
324                    .child(FixedSize::new().width(100.0).height(20.0)),
325            ),
326        );
327        let rigid = tree.add(FixedSize::new().width(100.0).height(20.0));
328        tree.add(HStack::new().child(rigid).child(slot));
329        tree.layout(SizeProposal::exact(120.0, 20.0));
330        let w = tree.bounds(slot).width;
331        assert!(
332            w < 100.0,
333            "the TouchTarget must forward the child's shrink weight (width was {w})"
334        );
335    }
336}