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 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 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 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 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 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 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 #[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 #[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 #[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 #[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 #[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 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 #[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}