Skip to main content

teksilo_widgets/primitives/
twist_arrow.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! TwistArrow — a small chevron that indicates and toggles a tree node's expansion.
5//!
6//! Renders a right-pointing arrow when collapsed and a down-pointing arrow when
7//! expanded; a leaf node (where `has_children` is false) paints nothing but
8//! reserves its slot so the indent column stays aligned across all rows.
9//! The glyph flips direction under right-to-left layout.
10//! Accessibility-decorative: the chevron hides itself from the AT tree and
11//! the parent row's node owns `set_expanded`.
12//!
13//! ```ignore
14//! // TwistArrow is typically instantiated by TreeView row delegates and requires
15//! // an EventContext to wire the tap callback. The snippet below shows the
16//! // construction pattern used inside a custom tree-row build().
17//! let arrow = TwistArrow::new(16.0, true, false)
18//!     .on_click(|ctx| ctx.send_intent(teksilo_core::Intent::new("tree.toggle")));
19//! ```
20//!
21//! ## Touch and pen
22//!
23//! A 12 dp chevron is half the 24 dp target floor and cannot grow — the indent
24//! column is the tree's own geometry. It declares a `Widget::hit_outset`
25//! instead, which is the one mechanism that can win here: the chevron's
26//! neighbour is the row, the row takes presses, and a point inside the row is
27//! at distance zero from it, so the miss-only slop pass could never reach the
28//! chevron. Zero for a leaf chevron and for a decorative one, which take no
29//! press.
30
31use 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
48/// Small interactive chevron rendered in the leading indent column of a tree row.
49pub struct TwistArrow {
50    size: f32,
51    has_children: bool,
52    expanded: bool,
53    /// Glyph colour. Defaults to [`TextRole::Secondary`] — the muted
54    /// chevron every tree draws — but a row whose selection fills with a
55    /// saturated colour has to move it with the label. See [`Self::color`].
56    color: ColorProp,
57    on_click: Option<Rc<dyn Fn(&mut EventContext)>>,
58}
59
60impl TwistArrow {
61    /// Construct a chevron. `size` is the square side length in logical pixels;
62    /// `has_children` determines whether the glyph is painted; `expanded`
63    /// determines the glyph direction (down = expanded, right/left = collapsed).
64    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    /// Override the glyph colour. Accepts a `Color`, a `TextRole`, or a
75    /// `Signal` of either.
76    ///
77    /// The default `TextRole::Secondary` is a muted grey, which is right on
78    /// every row that is not filled. It is *not* right on one that is: a
79    /// design language whose selected row is a solid accent capsule flips
80    /// its label to `TextRole::OnAccent` through
81    /// `StandardItemStyle::selected_label_role`, and a chevron left behind
82    /// at `Secondary` then sits on that capsule at roughly 2.5:1 — under
83    /// WCAG SC 1.4.11's 3:1 floor, and visibly wrong beside a white label.
84    /// `StandardTreeItem` passes the row's own label role here so the two
85    /// always move together.
86    pub fn color(mut self, color: impl Into<ColorProp>) -> Self {
87        self.color = color.into();
88        self
89    }
90
91    /// Install a tap handler. Receives the firing [`EventContext`]
92    /// so consumers can dispatch intents (e.g. lazy-load children on
93    /// expand) or open dialogs from the chevron toggle.
94    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        // A bound colour has to repaint the glyph when it changes — the
114        // row's label role flips the moment the row becomes selected.
115        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                // The chevron lives inside a reorderable tree row that owns a drag
127                // recognizer. Without this, a press here arms the row's ancestor
128                // drag, and the few px of jitter a real click carries (especially
129                // right after a drag) crosses the drag threshold and steals the
130                // gesture — the toggle never fires and a row drag starts instead.
131                // A gesture dead zone stops ancestor drag-arming at this boundary,
132                // exactly as the docking accordion's trailing controls do.
133                .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            // Collapsed glyph points toward the leading edge — left under
173            // RTL — so it mirrors the direction the subtree expands into.
174            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    /// A 12 dp chevron is half the 24 dp conformance floor, and it cannot grow:
188    /// the indent column it sits in is the tree's own geometry, and widening it
189    /// at Compact would move every row's label.
190    ///
191    /// So the shortfall is made up between the pointer and the arena. This is
192    /// the one place it *has* to be an outset rather than the miss-only slop
193    /// pass: the chevron's neighbour is the row, the row takes presses, and a
194    /// point inside the row is at distance zero from it — so the slop pass, which
195    /// only re-attributes to a candidate strictly closer than the bubble owner,
196    /// can never reach the chevron. An outset is tested inside the exact pass
197    /// and wins.
198    ///
199    /// Zero for a chevron that would refuse the press anyway — no children, or
200    /// no `on_click` — because a widened node that then ignores the press is a
201    /// hole punched in the row behind it.
202    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    /// A 12 dp chevron beside a 200 dp row label, both inside a row that takes
230    /// taps of its own: the row is the shape the census names as the reason the
231    /// slop pass cannot serve the chevron.
232    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    /// The outset takes a finger that landed beside the glyph, inside the row.
253    #[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        // 4 dp past the glyph's trailing edge — outside the 12 dp box, inside
260        // the 24 dp target the outset earns it.
261        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    /// A mouse is exact: the same press lands on the row, exactly as it did
274    /// before the touch programme.
275    #[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    /// A chevron that paints nothing and does nothing claims no space: a leaf
301    /// row's indent slot must stay transparent to the row behind it.
302    #[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    /// The outset reaches exactly the conformance floor at Compact and the
318    /// density's own target above it — and never applies to a mouse.
319    #[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}