teksilo_widgets/primitives/
twist_arrow.rs1use std::rc::Rc;
32
33use teksilo_canvas::{Canvas, Path, Point, Rect, Size, SizeProposal};
34
35use teksilo_core::accessibility::AccessNodeBuilder;
36use teksilo_core::binding::BindingLevel;
37use teksilo_core::build_context::BuildContext;
38use teksilo_core::color_prop::ColorProp;
39use teksilo_core::widget::{
40 EventContext, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
41};
42use teksilo_core::widget_builder::HandlerSet;
43use teksilo_core::widget_id::WidgetId;
44use teksilo_tokens::{SurfaceRole, TextRole};
45
46use crate::primitives::rect_widget::RectWidget;
47
48pub struct TwistArrow {
50 size: f32,
51 has_children: bool,
52 expanded: bool,
53 color: ColorProp,
57 on_click: Option<Rc<dyn Fn(&mut EventContext)>>,
58}
59
60impl TwistArrow {
61 pub fn new(size: f32, has_children: bool, expanded: bool) -> Self {
65 Self {
66 size,
67 has_children,
68 expanded,
69 color: ColorProp::TextRole(TextRole::Secondary),
70 on_click: None,
71 }
72 }
73
74 pub fn color(mut self, color: impl Into<ColorProp>) -> Self {
87 self.color = color.into();
88 self
89 }
90
91 pub fn on_click(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
95 self.on_click = Some(Rc::new(f));
96 self
97 }
98}
99
100impl std::fmt::Debug for TwistArrow {
101 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
102 f.debug_struct("TwistArrow")
103 .field("size", &self.size)
104 .field("has_children", &self.has_children)
105 .field("expanded", &self.expanded)
106 .field("color", &self.color)
107 .finish()
108 }
109}
110
111impl Widget for TwistArrow {
112 fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
113 self.color.register_if_bound(
116 ctx.self_id(),
117 ctx.binding_registry(),
118 BindingLevel::RepaintOnly,
119 );
120 if let Some(cb) = self.on_click.clone() {
121 let handlers = HandlerSet::new()
122 .on_tap(move |_pos, ctx| {
123 cb(ctx);
124 })
125 .focusable(false)
126 .gesture_dead_zone(true);
134 ctx.apply_self_handlers(handlers);
135 }
136 let rect = ctx.add(RectWidget::new().background(SurfaceRole::Transparent));
137 vec![rect]
138 }
139
140 fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
141 Size::new(self.size, self.size).into()
142 }
143
144 fn place_children(
145 &self,
146 bounds: Rect,
147 _proposal: SizeProposal,
148 children: &mut [WidgetPlacement],
149 _ctx: &LayoutContext,
150 ) {
151 for child in children.iter_mut() {
152 child.origin = bounds.origin();
153 child.size = bounds.size();
154 }
155 }
156
157 fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
158 if !self.has_children {
159 return;
160 }
161 let color = self.color.resolve(ctx.theme, ctx.effective_enabled);
162 let cx = bounds.x + bounds.width / 2.0;
163 let cy = bounds.y + bounds.height / 2.0;
164 let r = bounds.width.min(bounds.height) * 0.4;
165 let mut path = Path::new();
166 if self.expanded {
167 path.move_to(Point::new(cx - r, cy - r * 0.4));
168 path.line_to(Point::new(cx + r, cy - r * 0.4));
169 path.line_to(Point::new(cx, cy + r * 0.6));
170 path.close();
171 } else if ctx.layout_direction == teksilo_core::environment::LayoutDirection::RightToLeft {
172 path.move_to(Point::new(cx + r * 0.4, cy - r));
175 path.line_to(Point::new(cx - r * 0.6, cy));
176 path.line_to(Point::new(cx + r * 0.4, cy + r));
177 path.close();
178 } else {
179 path.move_to(Point::new(cx - r * 0.4, cy - r));
180 path.line_to(Point::new(cx + r * 0.6, cy));
181 path.line_to(Point::new(cx - r * 0.4, cy + r));
182 path.close();
183 }
184 canvas.fill_path(&path, color);
185 }
186
187 fn hit_outset(
203 &self,
204 kind: teksilo_tokens::PointerKind,
205 tokens: &teksilo_tokens::InputTokens,
206 ) -> teksilo_canvas::EdgeInsets {
207 if !self.has_children || self.on_click.is_none() {
208 return teksilo_canvas::EdgeInsets::ZERO;
209 }
210 crate::button::target_outset(Size::new(self.size, self.size), kind, tokens)
211 }
212
213 fn accessibility(&self, builder: &mut AccessNodeBuilder) {
214 builder.set_hidden();
215 }
216}
217
218#[cfg(test)]
219mod tests {
220 use super::*;
221 use std::cell::Cell;
222 use teksilo_core::pointer::PointerPhase;
223 use teksilo_core::widget_tree::WidgetTree;
224 use teksilo_tokens::{InputTokens, PointerKind};
225
226 use crate::button::press_test_support::{finger, touch};
227 use crate::primitives::{HStack, TextWidget};
228
229 fn row_with_chevron(
233 toggles: std::rc::Rc<Cell<u32>>,
234 row_taps: std::rc::Rc<Cell<u32>>,
235 ) -> (WidgetTree, WidgetId) {
236 use teksilo_core::widget_builder::WidgetBuilder;
237 let mut tree = WidgetTree::new().with_theme(teksilo_core::presets::intui::light());
238 let arrow = tree.add(
239 TwistArrow::new(12.0, true, false).on_click(move |_| toggles.set(toggles.get() + 1)),
240 );
241 let label = tree.add(TextWidget::new(teksilo_i18n::lit!("Documents")));
242 let _row = tree.add(
243 HStack::new()
244 .child(arrow)
245 .child(label)
246 .on_tap(move |_e, _c| row_taps.set(row_taps.get() + 1)),
247 );
248 tree.layout(SizeProposal::exact(240.0, 28.0));
249 (tree, arrow)
250 }
251
252 #[test]
254 fn a_finger_just_outside_the_chevron_still_toggles() {
255 let toggles = std::rc::Rc::new(Cell::new(0));
256 let row_taps = std::rc::Rc::new(Cell::new(0));
257 let (mut tree, arrow) = row_with_chevron(toggles.clone(), row_taps.clone());
258 let b = tree.bounds(arrow);
259 let at = teksilo_canvas::Point::new(b.x + b.width + 4.0, b.center().y);
262 let id = finger();
263 tree.dispatch_pointer(touch(id, PointerPhase::Down, at, 0));
264 tree.dispatch_pointer(touch(id, PointerPhase::Up, at, 30));
265 assert_eq!(
266 toggles.get(),
267 1,
268 "the chevron's outset did not take the press"
269 );
270 assert_eq!(row_taps.get(), 0, "and the row must not have taken it too");
271 }
272
273 #[test]
276 fn a_mouse_just_outside_the_chevron_lands_on_the_row() {
277 let toggles = std::rc::Rc::new(Cell::new(0));
278 let row_taps = std::rc::Rc::new(Cell::new(0));
279 let (mut tree, arrow) = row_with_chevron(toggles.clone(), row_taps.clone());
280 let b = tree.bounds(arrow);
281 let at = teksilo_canvas::Point::new(b.x + b.width + 4.0, b.center().y);
282 tree.dispatch_event(teksilo_core::event::WidgetEvent::pointer_down(
283 at,
284 teksilo_core::event::PointerButton::Primary,
285 teksilo_core::event::Modifiers::NONE,
286 ));
287 tree.dispatch_event(teksilo_core::event::WidgetEvent::pointer_up(
288 at,
289 teksilo_core::event::PointerButton::Primary,
290 teksilo_core::event::Modifiers::NONE,
291 ));
292 assert_eq!(
293 toggles.get(),
294 0,
295 "a mouse must not be given the chevron's outset"
296 );
297 assert_eq!(row_taps.get(), 1);
298 }
299
300 #[test]
303 fn an_inert_chevron_declares_no_outset() {
304 let tokens = InputTokens::default();
305 let leaf = TwistArrow::new(12.0, false, false).on_click(|_| {});
306 assert_eq!(
307 leaf.hit_outset(PointerKind::Touch, &tokens),
308 teksilo_canvas::EdgeInsets::ZERO,
309 );
310 let decorative = TwistArrow::new(12.0, true, false);
311 assert_eq!(
312 decorative.hit_outset(PointerKind::Touch, &tokens),
313 teksilo_canvas::EdgeInsets::ZERO,
314 );
315 }
316
317 #[test]
320 fn the_outset_lifts_the_chevron_to_the_density_target() {
321 let arrow = TwistArrow::new(12.0, true, false).on_click(|_| {});
322 for density in [
323 teksilo_tokens::TargetDensity::Compact,
324 teksilo_tokens::TargetDensity::Comfortable,
325 teksilo_tokens::TargetDensity::Touch,
326 ] {
327 let tokens = InputTokens::for_density(density);
328 let out = arrow.hit_outset(PointerKind::Touch, &tokens);
329 assert_eq!(
330 12.0 + out.horizontal(),
331 tokens.target_size,
332 "{density:?} horizontal",
333 );
334 assert_eq!(
335 12.0 + out.vertical(),
336 tokens.target_size,
337 "{density:?} vertical"
338 );
339 assert_eq!(
340 arrow.hit_outset(PointerKind::Mouse, &tokens),
341 teksilo_canvas::EdgeInsets::ZERO,
342 "{density:?} mouse",
343 );
344 }
345 }
346}