teksilo_widgets/primitives/
touch_target.rs1use 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
13pub 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 pub fn new() -> Self {
72 Self {
73 size: None,
74 reserve_space: true,
75 child: None,
76 pending: None,
77 }
78 }
79
80 pub fn size(mut self, dp: f32) -> Self {
83 self.size = Some(dp);
84 self
85 }
86
87 pub fn reserve_space(mut self, reserve: bool) -> Self {
90 self.reserve_space = reserve;
91 self
92 }
93
94 pub fn child(mut self, widget: impl Widget + 'static) -> Self {
96 self.pending = Some(Box::new(widget));
97 self
98 }
99
100 pub fn child_id(mut self, id: WidgetId) -> Self {
102 self.child = Some(id);
103 self
104 }
105
106 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 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 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 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 #[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 #[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 #[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 #[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 #[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 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 #[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}