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
21use std::rc::Rc;
22
23use teksilo_canvas::{Canvas, Path, Point, Rect, Size, SizeProposal};
24
25use teksilo_core::accessibility::AccessNodeBuilder;
26use teksilo_core::binding::BindingLevel;
27use teksilo_core::build_context::BuildContext;
28use teksilo_core::color_prop::ColorProp;
29use teksilo_core::widget::{
30    EventContext, LayoutContext, LayoutResponse, PaintContext, Widget, WidgetPlacement,
31};
32use teksilo_core::widget_builder::HandlerSet;
33use teksilo_core::widget_id::WidgetId;
34use teksilo_tokens::{SurfaceRole, TextRole};
35
36use crate::primitives::rect_widget::RectWidget;
37
38/// Small interactive chevron rendered in the leading indent column of a tree row.
39pub struct TwistArrow {
40    size: f32,
41    has_children: bool,
42    expanded: bool,
43    /// Glyph colour. Defaults to [`TextRole::Secondary`] — the muted
44    /// chevron every tree draws — but a row whose selection fills with a
45    /// saturated colour has to move it with the label. See [`Self::color`].
46    color: ColorProp,
47    on_click: Option<Rc<dyn Fn(&mut EventContext)>>,
48}
49
50impl TwistArrow {
51    /// Construct a chevron. `size` is the square side length in logical pixels;
52    /// `has_children` determines whether the glyph is painted; `expanded`
53    /// determines the glyph direction (down = expanded, right/left = collapsed).
54    pub fn new(size: f32, has_children: bool, expanded: bool) -> Self {
55        Self {
56            size,
57            has_children,
58            expanded,
59            color: ColorProp::TextRole(TextRole::Secondary),
60            on_click: None,
61        }
62    }
63
64    /// Override the glyph colour. Accepts a `Color`, a `TextRole`, or a
65    /// `Signal` of either.
66    ///
67    /// The default `TextRole::Secondary` is a muted grey, which is right on
68    /// every row that is not filled. It is *not* right on one that is: a
69    /// design language whose selected row is a solid accent capsule flips
70    /// its label to `TextRole::OnAccent` through
71    /// `StandardItemStyle::selected_label_role`, and a chevron left behind
72    /// at `Secondary` then sits on that capsule at roughly 2.5:1 — under
73    /// WCAG SC 1.4.11's 3:1 floor, and visibly wrong beside a white label.
74    /// `StandardTreeItem` passes the row's own label role here so the two
75    /// always move together.
76    pub fn color(mut self, color: impl Into<ColorProp>) -> Self {
77        self.color = color.into();
78        self
79    }
80
81    /// Install a tap handler. Receives the firing [`EventContext`]
82    /// so consumers can dispatch intents (e.g. lazy-load children on
83    /// expand) or open dialogs from the chevron toggle.
84    pub fn on_click(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
85        self.on_click = Some(Rc::new(f));
86        self
87    }
88}
89
90impl std::fmt::Debug for TwistArrow {
91    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
92        f.debug_struct("TwistArrow")
93            .field("size", &self.size)
94            .field("has_children", &self.has_children)
95            .field("expanded", &self.expanded)
96            .field("color", &self.color)
97            .finish()
98    }
99}
100
101impl Widget for TwistArrow {
102    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
103        // A bound colour has to repaint the glyph when it changes — the
104        // row's label role flips the moment the row becomes selected.
105        self.color.register_if_bound(
106            ctx.self_id(),
107            ctx.binding_registry(),
108            BindingLevel::RepaintOnly,
109        );
110        if let Some(cb) = self.on_click.clone() {
111            let handlers = HandlerSet::new()
112                .on_tap(move |_pos, ctx| {
113                    cb(ctx);
114                })
115                .focusable(false)
116                // The chevron lives inside a reorderable tree row that owns a drag
117                // recognizer. Without this, a press here arms the row's ancestor
118                // drag, and the few px of jitter a real click carries (especially
119                // right after a drag) crosses the drag threshold and steals the
120                // gesture — the toggle never fires and a row drag starts instead.
121                // A gesture dead zone stops ancestor drag-arming at this boundary,
122                // exactly as the docking accordion's trailing controls do.
123                .gesture_dead_zone(true);
124            ctx.apply_self_handlers(handlers);
125        }
126        let rect = ctx.add(RectWidget::new().background(SurfaceRole::Transparent));
127        vec![rect]
128    }
129
130    fn layout_response(&self, _proposal: SizeProposal, _ctx: &LayoutContext) -> LayoutResponse {
131        Size::new(self.size, self.size).into()
132    }
133
134    fn place_children(
135        &self,
136        bounds: Rect,
137        _proposal: SizeProposal,
138        children: &mut [WidgetPlacement],
139        _ctx: &LayoutContext,
140    ) {
141        for child in children.iter_mut() {
142            child.origin = bounds.origin();
143            child.size = bounds.size();
144        }
145    }
146
147    fn paint(&self, bounds: Rect, canvas: &mut Canvas, ctx: &PaintContext) {
148        if !self.has_children {
149            return;
150        }
151        let color = self.color.resolve(ctx.theme, ctx.effective_enabled);
152        let cx = bounds.x + bounds.width / 2.0;
153        let cy = bounds.y + bounds.height / 2.0;
154        let r = bounds.width.min(bounds.height) * 0.4;
155        let mut path = Path::new();
156        if self.expanded {
157            path.move_to(Point::new(cx - r, cy - r * 0.4));
158            path.line_to(Point::new(cx + r, cy - r * 0.4));
159            path.line_to(Point::new(cx, cy + r * 0.6));
160            path.close();
161        } else if ctx.layout_direction == teksilo_core::environment::LayoutDirection::RightToLeft {
162            // Collapsed glyph points toward the leading edge — left under
163            // RTL — so it mirrors the direction the subtree expands into.
164            path.move_to(Point::new(cx + r * 0.4, cy - r));
165            path.line_to(Point::new(cx - r * 0.6, cy));
166            path.line_to(Point::new(cx + r * 0.4, cy + r));
167            path.close();
168        } else {
169            path.move_to(Point::new(cx - r * 0.4, cy - r));
170            path.line_to(Point::new(cx + r * 0.6, cy));
171            path.line_to(Point::new(cx - r * 0.4, cy + r));
172            path.close();
173        }
174        canvas.fill_path(&path, color);
175    }
176
177    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
178        builder.set_hidden();
179    }
180}