Skip to main content

teksilo_widgets/
standard_item.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! Canonical row layout for `ListView` / `TreeView` delegates.
5//!
6//! Two widgets:
7//! - [`StandardListItem`] — primary line `[checkbox?] [leading_slot?]
8//!   [center_slot?] [label] [Spacer] [trailing_slot?]` with optional
9//!   subtitle line `[subtitle_leading_slot?] [subtitle] [Spacer]
10//!   [subtitle_trailing_slot?]`.
11//! - [`StandardTreeItem`] — same plus depth-driven indent + chevron
12//!   column (always reserved, even for leaves, so labels at the same
13//!   depth align).
14//!
15//! Selection / hover / pressed background mirrors `MenuItem` /
16//! `ComboBox`: rounded `RectWidget` (`item_corner_radius: 8.0`),
17//! horizontally inset so corners are visible, theme-driven via
18//! `SurfaceRole` so light/dark/custom themes propagate without
19//! rebuild.
20//!
21//! ## Canonical TreeView wiring
22//!
23//! ```ignore
24//! use teksilo::data::{TreeCheckedModel, TreeModel};
25//! use teksilo::widgets::{StandardTreeItem, TreeView};
26//!
27//! let tree: TreeModel<Item> = ...;
28//! let checks = TreeCheckedModel::new(tree.clone());
29//!
30//! TreeView::new_with_context(tree, move |item, entry, selected, ctx| {
31//!     let mut row = StandardTreeItem::new(lit!(item.title.clone()))
32//!         .from_entry(entry)
33//!         .selected(selected)
34//!         .leading_slot(IconWidget::from_svg(FOLDER_ICON).icon_size(16.0))
35//!         .on_toggle_rc(ctx.toggle_callback());
36//!     if entry.has_children {
37//!         row = row.tristate_checkbox(checks.signal_for(entry.node_id));
38//!     } else {
39//!         row = row.checkbox(checks.bool_signal_for(entry.node_id));
40//!     }
41//!     Box::new(row)
42//! })
43//! .row_click_expands(false)   // chevron is the only toggle target
44//! ```
45//!
46//! Wiring rules:
47//! - `TreeView::new_with_context` exposes a `TreeRowContext` that
48//!   yields `toggle_callback()` for chevron clicks. Pair with
49//!   `.row_click_expands(false)` so body clicks don't also toggle.
50//! - For tristate parent rows, bind to `signal_for(node)`. For
51//!   leaves, prefer `bool_signal_for(node)` — the model's bool ↔
52//!   tristate bridge runs ancestor recompute on writes either way.
53//! - `from_entry(&FlatEntry)` is shorthand for
54//!   `.depth(entry.depth).has_children(entry.has_children)
55//!   .is_expanded(entry.is_expanded)`.
56//!
57//! ## Accessibility
58//!
59//! `StandardListItem.accessibility()` sets the row's `name` (label
60//! only) and `description` (subtitle, if any) — structural role +
61//! position/level/expanded/selected come from the parent's
62//! `ListItemA11y` / `TreeRowA11y` wrapper. The embedded `Checkbox`
63//! receives an `access_label*` override carrying the row label so
64//! screen readers announce "checkbox, checked, `[label]`" rather than
65//! a nameless `Role::CheckBox`. The chevron's `TwistArrow` is
66//! decorative (`set_hidden`); the row's expanded state is owned by
67//! the wrapper.
68
69use std::rc::Rc;
70
71use teksilo_canvas::{Rect, SizeProposal};
72use teksilo_core::accessibility::AccessNodeBuilder;
73use teksilo_core::build_context::BuildContext;
74use teksilo_core::signal::{Prop, Signal};
75use teksilo_core::widget::{EventContext, LayoutContext, LayoutResponse, Widget, WidgetPlacement};
76use teksilo_core::widget_id::WidgetId;
77use teksilo_data::{CheckState, FlatEntry};
78
79use teksilo_canvas::TextOverflow;
80use teksilo_core::styles::{SharedStandardItemStyle, StandardItemStyleConfig};
81use teksilo_i18n::LocalizedString;
82use teksilo_tokens::{HAlignment, TextRole, TextStyleRole, VAlignment};
83
84use crate::button::InteractionState;
85use crate::checkbox::Checkbox;
86use crate::primitives::{FixedSize, HStack, Shrinkable, Spacer, TextWidget, TwistArrow, VStack};
87
88// ---------------------------------------------------------------------------
89// CheckboxKind — two-state vs tri-state, last-call-wins on the builder.
90// ---------------------------------------------------------------------------
91
92#[derive(Clone)]
93enum CheckboxKind {
94    TwoState(Signal<bool>),
95    TriState(Signal<CheckState>),
96}
97
98// ---------------------------------------------------------------------------
99// StandardListItem
100// ---------------------------------------------------------------------------
101
102/// Canonical single-line or two-line row layout for use in a `ListView`.
103///
104/// See the [module-level documentation](self) for the full slot layout and
105/// wiring rules.
106pub struct StandardListItem {
107    label: LocalizedString,
108    subtitle: Option<LocalizedString>,
109    leading_slot: Option<Box<dyn Widget>>,
110    center_slot: Option<Box<dyn Widget>>,
111    trailing_slot: Option<Box<dyn Widget>>,
112    subtitle_leading_slot: Option<Box<dyn Widget>>,
113    subtitle_trailing_slot: Option<Box<dyn Widget>>,
114    checkbox: Option<CheckboxKind>,
115    selected: Signal<bool>,
116    enabled: Signal<bool>,
117    label_style: teksilo_core::color_prop::TextStyleProp,
118    subtitle_style: teksilo_core::color_prop::TextStyleProp,
119    /// Per-call label text-color override. `None` ⇒ enabled-derived
120    /// (`Primary` / `Disabled`).
121    label_color: Option<teksilo_core::color_prop::ColorProp>,
122    /// Per-call subtitle text-color override. `None` ⇒ `TextRole::Secondary`.
123    subtitle_color: Option<teksilo_core::color_prop::ColorProp>,
124    /// Per-call label overflow override. `None` ⇒ the `TextWidget` default
125    /// (`TextOverflow::Wrap`).
126    label_overflow: Option<TextOverflow>,
127    /// Drawn in place of the label's text, when a row's label is not plain text.
128    label_slot: Option<Box<dyn Widget>>,
129    /// Per-call subtitle overflow override. `None` ⇒ the `TextWidget` default
130    /// (`TextOverflow::Wrap`).
131    subtitle_overflow: Option<TextOverflow>,
132    interaction: Signal<InteractionState>,
133    style_override: Option<SharedStandardItemStyle>,
134    root_child_id: Option<WidgetId>,
135    /// Optional plain tooltip text shown after a hover delay. Mutually exclusive
136    /// with the rich / composite slots — every setter clears the other two so
137    /// the last call wins.
138    tooltip_text: Option<LocalizedString>,
139    /// Optional rich tooltip source (registry key or inline content).
140    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
141    /// Optional composite tooltip body (arbitrary widget tree).
142    composite_tooltip_content: Option<Box<dyn Widget>>,
143}
144
145impl StandardListItem {
146    /// Create a list item with the given primary label.
147    pub fn new(label: impl Into<LocalizedString>) -> Self {
148        let ls: LocalizedString = label.into();
149        Self {
150            label: ls,
151            subtitle: None,
152            leading_slot: None,
153            center_slot: None,
154            trailing_slot: None,
155            subtitle_leading_slot: None,
156            subtitle_trailing_slot: None,
157            checkbox: None,
158            selected: Signal::new(false),
159            enabled: Signal::new(true),
160            label_style: TextStyleRole::Body.into(),
161            subtitle_style: TextStyleRole::Small.into(),
162            label_color: None,
163            subtitle_color: None,
164            label_overflow: None,
165            label_slot: None,
166            subtitle_overflow: None,
167            interaction: Signal::new(InteractionState::Idle),
168            style_override: None,
169            root_child_id: None,
170            tooltip_text: None,
171            rich_tooltip_source: None,
172            composite_tooltip_content: None,
173        }
174    }
175
176    /// Per-call style override. Replaces the theme-wide default
177    /// `StandardItemStyle` for just this row instance.
178    pub fn style(mut self, style: impl teksilo_core::styles::StandardItemStyle) -> Self {
179        self.style_override = Some(Rc::new(style));
180        self
181    }
182
183    /// Set an optional secondary line below the primary label.
184    pub fn subtitle(mut self, text: impl Into<LocalizedString>) -> Self {
185        let ls: LocalizedString = text.into();
186        self.subtitle = Some(ls);
187        self
188    }
189
190    /// Leading slot — placed AFTER the optional checkbox, BEFORE the
191    /// center slot. Typical: `IconWidget`, avatar, color swatch.
192    pub fn leading_slot(mut self, widget: impl Widget + 'static) -> Self {
193        self.leading_slot = Some(Box::new(widget));
194        self
195    }
196
197    /// `Box<dyn Widget>` variant of [`leading_slot`](Self::leading_slot).
198    pub fn leading_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
199        self.leading_slot = Some(widget);
200        self
201    }
202
203    /// Center slot — placed BETWEEN the leading slot and the label.
204    /// Typical: status dot, colored category bar, drag-handle gripper,
205    /// key-binding chip. Distinct from `leading_slot`: leading is the
206    /// row's icon identity, center is label-adjacent decoration.
207    pub fn center_slot(mut self, widget: impl Widget + 'static) -> Self {
208        self.center_slot = Some(Box::new(widget));
209        self
210    }
211
212    /// `Box<dyn Widget>` variant of [`center_slot`](Self::center_slot).
213    pub fn center_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
214        self.center_slot = Some(widget);
215        self
216    }
217
218    /// Trailing slot — placed AFTER the flex Spacer on the primary
219    /// line. Typical: badge, count, status pill, secondary IconButton.
220    pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
221        self.trailing_slot = Some(Box::new(widget));
222        self
223    }
224
225    /// `Box<dyn Widget>` variant of [`trailing_slot`](Self::trailing_slot).
226    pub fn trailing_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
227        self.trailing_slot = Some(widget);
228        self
229    }
230
231    /// Leading slot for the subtitle line. No-op without `subtitle(...)`.
232    pub fn subtitle_leading_slot(mut self, widget: impl Widget + 'static) -> Self {
233        self.subtitle_leading_slot = Some(Box::new(widget));
234        self
235    }
236
237    /// `Box<dyn Widget>` variant of [`subtitle_leading_slot`](Self::subtitle_leading_slot).
238    pub fn subtitle_leading_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
239        self.subtitle_leading_slot = Some(widget);
240        self
241    }
242
243    /// Trailing slot for the subtitle line. No-op without `subtitle(...)`.
244    pub fn subtitle_trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
245        self.subtitle_trailing_slot = Some(Box::new(widget));
246        self
247    }
248
249    /// `Box<dyn Widget>` variant of [`subtitle_trailing_slot`](Self::subtitle_trailing_slot).
250    pub fn subtitle_trailing_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
251        self.subtitle_trailing_slot = Some(widget);
252        self
253    }
254
255    /// Optional two-state checkbox at the start of the row.
256    /// Mutually exclusive with `tristate_checkbox` — last call wins.
257    pub fn checkbox(mut self, checked: Signal<bool>) -> Self {
258        self.checkbox = Some(CheckboxKind::TwoState(checked));
259        self
260    }
261
262    /// Optional tri-state checkbox bound to `Signal<CheckState>`.
263    /// Cycles `Unchecked → Checked → Indeterminate`. Mutually
264    /// exclusive with `checkbox` — last call wins.
265    pub fn tristate_checkbox(mut self, state: Signal<CheckState>) -> Self {
266        self.checkbox = Some(CheckboxKind::TriState(state));
267        self
268    }
269
270    /// Set the selection state, statically or reactively via a bound
271    /// `Signal<bool>`.
272    pub fn selected(mut self, selected: impl Into<Prop<bool>>) -> Self {
273        self.selected = selected.into().as_signal();
274        self
275    }
276
277    /// Set the enabled state, statically or reactively via a bound
278    /// `Signal<bool>` / `Prop<bool>`.
279    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
280        self.enabled = enabled.into().as_signal();
281        self
282    }
283
284    /// Override the label's text style (font, size, weight). Accepts a
285    /// `TextStyleRole`, a `TextStyle`, or a `Signal` of either. Default is
286    /// `TextStyleRole::Body`.
287    pub fn label_style(
288        mut self,
289        style: impl Into<teksilo_core::color_prop::TextStyleProp>,
290    ) -> Self {
291        self.label_style = style.into();
292        self
293    }
294
295    /// Override the subtitle's text style. Default is `TextStyleRole::Small`.
296    pub fn subtitle_style(
297        mut self,
298        style: impl Into<teksilo_core::color_prop::TextStyleProp>,
299    ) -> Self {
300        self.subtitle_style = style.into();
301        self
302    }
303
304    /// Override the label's text color. Accepts `Color`, a role, or a
305    /// `Signal` of either. Default (unset) is enabled-derived
306    /// (`Primary` / `Disabled`); setting this replaces that cascade.
307    pub fn label_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
308        self.label_color = Some(color.into());
309        self
310    }
311
312    /// Override the subtitle's text color. Default (unset) is
313    /// `TextRole::Secondary`.
314    pub fn subtitle_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
315        self.subtitle_color = Some(color.into());
316        self
317    }
318
319    /// Truncate the primary label instead of wrapping it. Default (unset) is
320    /// `TextOverflow::Wrap`.
321    ///
322    /// A wrapping label reports its full intrinsic width, so on a row too
323    /// narrow to hold it the primary `HStack` is over-constrained and the
324    /// [`trailing_slot`](Self::trailing_slot) is pushed past the row's edge.
325    /// Set `TextOverflow::Ellipsis(..)` on rows whose trailing actions must
326    /// stay reachable: the label then shrinks and truncates within the row.
327    /// **Share the row's interaction state**, so a caller can reveal controls on
328    /// hover.
329    ///
330    /// A row that shows its actions only while the pointer is over it is a standard
331    /// pattern — a search result offering *replace* and *dismiss*, a list offering
332    /// *remove* — and it cannot be built from outside without knowing when the row
333    /// is hovered. The row already tracks that; this is the handle on it.
334    ///
335    /// The signal is written by the row, not read: pass one in, watch it, and gate
336    /// a trailing slot on it. Reserve the space the controls will take, or the row
337    /// reflows under the pointer that is trying to hit them.
338    pub fn interaction_signal(mut self, signal: Signal<InteractionState>) -> Self {
339        self.interaction = signal;
340        self
341    }
342
343    /// **Draw this instead of the label's text**, keeping the label as the row's
344    /// accessible name.
345    ///
346    /// For a row whose label is not plain text: a search result with the matched
347    /// run picked out of its excerpt, a diff line, anything built from runs rather
348    /// than from a string. The label passed to [`new`](Self::new) is still what
349    /// `accessibility` reports, so the row keeps a name a screen reader can read —
350    /// which is the whole reason this is a *replacement for the drawing* and not a
351    /// replacement for the label.
352    ///
353    /// The widget is laid out where the text would have been, so it inherits the
354    /// row's spacing and its place beside the leading and trailing slots.
355    /// [`label_style`](Self::label_style), [`label_color`](Self::label_color) and
356    /// [`label_overflow`](Self::label_overflow) do not reach it: it draws itself.
357    pub fn label_slot(mut self, widget: impl Widget + 'static) -> Self {
358        self.label_slot = Some(Box::new(widget));
359        self
360    }
361
362    pub fn label_overflow(mut self, overflow: TextOverflow) -> Self {
363        self.label_overflow = Some(overflow);
364        self
365    }
366
367    /// Truncate the subtitle instead of wrapping it. Default (unset) is
368    /// `TextOverflow::Wrap`.
369    ///
370    /// Same rationale as [`label_overflow`](Self::label_overflow) — and the
371    /// usual culprit, since subtitles carry long secondary text (file paths,
372    /// URLs). `TextOverflow::Ellipsis(EllipsisMode::Middle)` suits a path: it
373    /// keeps both the root and the file name legible.
374    pub fn subtitle_overflow(mut self, overflow: TextOverflow) -> Self {
375        self.subtitle_overflow = Some(overflow);
376        self
377    }
378
379    /// Attach a plain tooltip shown after the standard hover delay.
380    ///
381    /// Mutually exclusive with [`rich_tooltip`](Self::rich_tooltip),
382    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
383    /// [`composite_tooltip`](Self::composite_tooltip) — the last setter called
384    /// wins and clears the other slots.
385    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
386        self.tooltip_text = Some(text.into());
387        self.rich_tooltip_source = None;
388        self.composite_tooltip_content = None;
389        self
390    }
391
392    /// Attach a rich tooltip looked up from the global tooltip registry by key.
393    ///
394    /// Mutually exclusive with [`tooltip`](Self::tooltip),
395    /// [`rich_tooltip_content`](Self::rich_tooltip_content), and
396    /// [`composite_tooltip`](Self::composite_tooltip) — the last setter called
397    /// wins and clears the other slots.
398    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
399        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
400        self.tooltip_text = None;
401        self.composite_tooltip_content = None;
402        self
403    }
404
405    /// Attach a rich tooltip from an inline [`TooltipContent`](crate::tooltip::TooltipContent)
406    /// value (no registry lookup required).
407    ///
408    /// Mutually exclusive with [`tooltip`](Self::tooltip),
409    /// [`rich_tooltip`](Self::rich_tooltip), and
410    /// [`composite_tooltip`](Self::composite_tooltip) — the last setter called
411    /// wins and clears the other slots.
412    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
413        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
414        self.tooltip_text = None;
415        self.composite_tooltip_content = None;
416        self
417    }
418
419    /// Attach a composite tooltip whose body is an arbitrary widget tree.
420    ///
421    /// Mutually exclusive with [`tooltip`](Self::tooltip),
422    /// [`rich_tooltip`](Self::rich_tooltip), and
423    /// [`rich_tooltip_content`](Self::rich_tooltip_content) — the last setter
424    /// called wins and clears the other slots.
425    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
426        self.composite_tooltip_content = Some(Box::new(content));
427        self.tooltip_text = None;
428        self.rich_tooltip_source = None;
429        self
430    }
431}
432
433impl std::fmt::Debug for StandardListItem {
434    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
435        f.debug_struct("StandardListItem")
436            .field("label", &self.label)
437            .field("subtitle", &self.subtitle)
438            .field("has_checkbox", &self.checkbox.is_some())
439            .finish()
440    }
441}
442
443fn resolve_label_role(enabled: bool) -> TextRole {
444    if enabled {
445        TextRole::Primary
446    } else {
447        TextRole::Disabled
448    }
449}
450
451/// Everything a row's foregrounds need to pick their text role, resolved
452/// **once** per build and shared by the label, the subtitle and the tree
453/// chevron.
454///
455/// The alternative — each of the three resolving the style and rebuilding
456/// the `is_focused AND is_window_active` composite for itself — costs two
457/// extra style lookups and two extra derived signals on every row, and a
458/// virtualized `ListView` realizes and recycles rows constantly.
459struct RowRoles {
460    style: SharedStandardItemStyle,
461    /// The role an *emphasised* row's foregrounds take, per
462    /// [`StandardItemStyle::selected_label_role`](teksilo_core::styles::StandardItemStyle::selected_label_role).
463    /// `None` for a design
464    /// language whose selection is a pale wash (IntUI, Fluent), which is
465    /// also what keeps `emphasised` unbuilt.
466    on_selected: Option<TextRole>,
467    /// Selected **and** view-focused **and** window-active — the same
468    /// condition the chrome uses to pick `SurfaceRole::Selected` over
469    /// `SelectedInactive`. `None` when no style asked for the flip.
470    emphasised: Option<Signal<bool>>,
471}
472
473impl StandardListItem {
474    /// Per-call override > theme slot > the shipped recipe.
475    fn resolve_roles(&self, ctx: &mut BuildContext) -> RowRoles {
476        let style: SharedStandardItemStyle = self
477            .style_override
478            .clone()
479            .or_else(|| ctx.theme().style_slots.standard_item.clone())
480            .unwrap_or_else(|| Rc::new(crate::styles::RecipeStandardItemStyle::default()));
481        let on_selected = style.selected_label_role();
482        let emphasised =
483            on_selected.map(|_| ctx.view_focus_active().and(&ctx.window_active_signal()));
484        RowRoles {
485            style,
486            on_selected,
487            emphasised,
488        }
489    }
490
491    /// The text role a foreground element of this row should paint in.
492    ///
493    /// `rest` is what it reads when the row is not emphasised —
494    /// `TextRole::Primary` for the label, `Secondary` for the subtitle and
495    /// the tree chevron. An emphasised row swaps to
496    /// [`RowRoles::on_selected`] instead, so a solid selection fill and
497    /// the text on it always move together.
498    ///
499    /// Every foreground on the row has to go through this. A label that
500    /// flips while the chevron beside it does not is worse than neither
501    /// flipping — which is exactly the bug `TwistArrow::color` was added
502    /// to fix.
503    fn foreground_role(&self, roles: &RowRoles, rest: TextRole) -> Signal<TextRole> {
504        match (roles.on_selected, &roles.emphasised) {
505            (Some(on_selected), Some(emphasised)) => self
506                .enabled
507                .zip3(&self.selected, emphasised)
508                .map(move |(enabled, selected, emphasised)| {
509                    if !*enabled {
510                        TextRole::Disabled
511                    } else if *selected && *emphasised {
512                        on_selected
513                    } else {
514                        rest
515                    }
516                }),
517            _ => self
518                .enabled
519                .map(move |e| if *e { rest } else { TextRole::Disabled }),
520        }
521    }
522
523    /// Build the row content (HStack of slots + label column) and
524    /// register it. Returns the WidgetId of the content node (not the
525    /// surrounding bg + padding).
526    fn build_content(&mut self, ctx: &mut BuildContext, roles: &RowRoles) -> WidgetId {
527        use crate::styles::recipe_standard_item_style as si;
528
529        // A style whose selected row is a *solid* fill (macOS's accent
530        // capsule) cannot recolour the label from `make_body` — the label
531        // is already built by then — so it declares the role instead.
532        // Styles whose selection is a pale wash (IntUI, Fluent) declare
533        // nothing and the label keeps `TextRole::Primary` throughout.
534        let label_role = self.foreground_role(roles, resolve_label_role(true));
535        let subtitle_role = self.foreground_role(roles, TextRole::Secondary);
536
537        // Label column: either a single TextWidget or a VStack with
538        // label on top and subtitle (with its own slots) below.
539        // A row whose label is not plain text draws its own; the `label` field is
540        // still what `accessibility` reports, so the name survives the substitution.
541        let label_id = match self.label_slot.take() {
542            Some(widget) => ctx.add_boxed(widget),
543            None => {
544                let mut label_widget = TextWidget::new(self.label.clone())
545                    .style(self.label_style.clone())
546                    .a11y_hidden();
547                label_widget = match &self.label_color {
548                    Some(c) => label_widget.color(c.clone()),
549                    None => label_widget.color(label_role.clone()),
550                };
551                if let Some(overflow) = self.label_overflow {
552                    label_widget = label_widget.overflow(overflow);
553                }
554                ctx.add(label_widget)
555            }
556        };
557
558        let label_column_id = if let Some(subtitle) = &self.subtitle {
559            // Two-line: VStack { label, subtitle line }.
560            let mut subtitle_widget = TextWidget::new(subtitle.clone())
561                .style(self.subtitle_style.clone())
562                .a11y_hidden();
563            subtitle_widget = match &self.subtitle_color {
564                Some(c) => subtitle_widget.color(c.clone()),
565                // Follows the label: on a solid selection capsule a
566                // `Secondary` subtitle would be dark grey on saturated
567                // accent. Under a wash-based style this resolves to
568                // `Secondary` exactly as before.
569                None => subtitle_widget.color(subtitle_role.clone()),
570            };
571            if let Some(overflow) = self.subtitle_overflow {
572                subtitle_widget = subtitle_widget.overflow(overflow);
573            }
574            let subtitle_text_id = ctx.add(subtitle_widget);
575
576            // Subtitle HStack: [leading?] subtitle [Spacer] [trailing?].
577            let mut sub_row = HStack::new()
578                .spacing(si::STANDARD_ITEM_SUBTITLE_SLOT_GAP)
579                .alignment(VAlignment::Center);
580            if let Some(w) = self.subtitle_leading_slot.take() {
581                let id = ctx.add_boxed(w);
582                sub_row = sub_row.add_child(id);
583            }
584            sub_row = sub_row
585                .add_child(subtitle_text_id)
586                .add_child(ctx.add(Spacer::new()));
587            if let Some(w) = self.subtitle_trailing_slot.take() {
588                let id = ctx.add_boxed(w);
589                sub_row = sub_row.add_child(id);
590            }
591            let sub_row_id = ctx.add(sub_row);
592
593            ctx.add(
594                VStack::new()
595                    .spacing(si::STANDARD_ITEM_LABEL_SUBTITLE_GAP)
596                    .alignment(HAlignment::Leading)
597                    .add_child(label_id)
598                    .add_child(sub_row_id),
599            )
600        } else {
601            // Single-line: just the label.
602            label_id
603        };
604
605        // An ellipsis-mode `TextWidget` is shrinkable, but that alone does not
606        // reach the primary `HStack`: a stack only advertises shrink on its own
607        // main axis, so the label *column* (a `VStack`) reports rigid against a
608        // horizontal deficit and the trailing slot gets shoved out of the row.
609        // When the caller opted into truncation, make the column itself
610        // shrinkable — the deficit lands here and the elided text absorbs it.
611        let label_column_id = if self.label_overflow.is_some() || self.subtitle_overflow.is_some() {
612            ctx.add(
613                Shrinkable::new()
614                    .min_width(si::STANDARD_ITEM_LABEL_COLUMN_MIN_WIDTH)
615                    .child_id(label_column_id),
616            )
617        } else {
618            label_column_id
619        };
620
621        // Primary HStack: [checkbox?] [leading?] [center?] label_column
622        // [Spacer] [trailing?].
623        let mut row = HStack::new()
624            .spacing(si::STANDARD_ITEM_SLOT_GAP)
625            .alignment(VAlignment::Center);
626
627        if let Some(kind) = self.checkbox.take() {
628            // Propagate the row's label as the checkbox's accessible
629            // name. With `labels_hidden(true)` the visual label is
630            // suppressed; without an `access_label*` override the AT
631            // node would be a nameless `Role::CheckBox`. Using
632            // `access_label` on the WidgetBuilder applies an override
633            // AFTER Checkbox::accessibility runs, so the screen reader
634            // announces e.g. "checkbox, checked, Save" when the user
635            // navigates to it.
636            //
637            // The checkbox publishes its own keyboard toggle (see
638            // `Checkbox::build`), which is what the view's `Space` finds once
639            // the row is out of the Tab order — nothing to wire here.
640            use teksilo_core::widget_builder::WidgetBuilder;
641            let cb = match kind {
642                CheckboxKind::TwoState(s) => Checkbox::new(s),
643                CheckboxKind::TriState(s) => Checkbox::tristate(s),
644            }
645            .labels_hidden(true);
646            let cb_id = ctx.add(cb.access_label(self.label.clone()));
647            row = row.add_child(cb_id);
648        }
649        if let Some(w) = self.leading_slot.take() {
650            let id = ctx.add_boxed(w);
651            row = row.add_child(id);
652        }
653        if let Some(w) = self.center_slot.take() {
654            let id = ctx.add_boxed(w);
655            row = row.add_child(id);
656        }
657        row = row
658            .add_child(label_column_id)
659            .add_child(ctx.add(Spacer::new()));
660        if let Some(w) = self.trailing_slot.take() {
661            let id = ctx.add_boxed(w);
662            row = row.add_child(id);
663        }
664
665        ctx.add(row)
666    }
667
668    /// Wrap an already-composed row content in the active
669    /// `StandardItemStyle` chrome (selection background + corner
670    /// radius + padding) and attach the row-level hover handler.
671    /// Shared by `StandardListItem::build` (passing its inner row)
672    /// and `StandardTreeItem::build` (passing the row prefixed with
673    /// indent + chevron columns).
674    fn build_with_background(
675        &mut self,
676        ctx: &mut BuildContext,
677        content_id: WidgetId,
678        roles: &RowRoles,
679    ) -> WidgetId {
680        // Derive the cfg's boolean signals from the widget's existing
681        // `interaction` + `selected` + `enabled` signals. The recipe
682        // re-evaluates the bg role on any source change.
683        let is_selected = self.selected.clone();
684        let is_disabled = self.enabled.map(|e| !*e);
685        let is_hovered = self
686            .interaction
687            .map(|s| matches!(s, InteractionState::Hovered));
688        let is_pressed = self
689            .interaction
690            .map(|s| matches!(s, InteractionState::Pressed));
691        // Focus-aware selection: `is_focused` tracks whether this item's focus
692        // scope (its nearest focusable ancestor — the enclosing ListView /
693        // TreeView / … or any focusable container) holds keyboard focus. The
694        // recipe paints the active `Selected` chrome while it does and the muted
695        // `SelectedInactive` chrome when focus is elsewhere. Items outside any
696        // focusable scope read a constant `true`, so their selection always
697        // looks active.
698        let is_focused = ctx.view_focus_active();
699        // Keyboard-vs-pointer modality so the recipe shows the focus ring only
700        // during keyboard navigation (`:focus-visible`).
701        let is_focus_visible = ctx.focus_visible();
702
703        let style: SharedStandardItemStyle = roles.style.clone();
704        let cfg = StandardItemStyleConfig {
705            content: content_id,
706            is_selected,
707            is_hovered,
708            is_pressed,
709            is_focused,
710            is_focus_visible,
711            is_disabled,
712            is_window_active: ctx.window_active_signal(),
713        };
714        let root_id = style.make_body(&cfg, ctx);
715
716        // Attach hover handler to the row so hovering anywhere in the
717        // row updates the interaction signal. Disabled rows still
718        // track hover but the recipe's bg cascade short-circuits to
719        // Transparent.
720        use teksilo_core::widget_builder::HandlerSet;
721        let interaction_for_hover = self.interaction.clone();
722        let handlers = HandlerSet::new().on_hover(move |entered: bool, _ctx: &mut EventContext| {
723            interaction_for_hover.set(if entered {
724                InteractionState::Hovered
725            } else {
726                InteractionState::Idle
727            });
728        });
729        ctx.apply_self_handlers(handlers);
730
731        root_id
732    }
733}
734
735impl Widget for StandardListItem {
736    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
737        let self_id = ctx.self_id();
738        // Bridge the widget's owned `self.enabled` signal into the
739        // arena's enabled_state. Now event-gating, focus traversal,
740        // a11y disabled, and the leaves' role-substitution all
741        // observe the same source. Previously `self.enabled` was
742        // widget-internal — events still routed to disabled items,
743        // and external `ctx.enabled_when(item_id, …)` would not
744        // override the local Signal.
745        ctx.enabled_when(self_id, self.enabled.clone());
746        // Resolved once and shared by the label, the subtitle and (for a
747        // tree row) the chevron — see `RowRoles`.
748        let roles = self.resolve_roles(ctx);
749        let content_id = self.build_content(ctx, &roles);
750        let root_id = self.build_with_background(ctx, content_id, &roles);
751        self.root_child_id = Some(root_id);
752
753        // Attach tooltip — mutually exclusive slots, composite wins.
754        // Standard rows stack vertically in a `ListView`/`TreeView`, so the
755        // tooltip opens to the trailing `Side` — a `Below` tooltip would
756        // cover the next row down.
757        let tip_placement = crate::tooltip::TooltipPlacement::Side;
758        if let Some(content) = self.composite_tooltip_content.take() {
759            let delay = ctx.theme().motion.tooltip_delay_heavy;
760            crate::tooltip::attach_composite_tooltip_boxed_with_placement(
761                ctx,
762                root_id,
763                content,
764                delay,
765                tip_placement,
766            );
767        } else if let Some(source) = self.rich_tooltip_source.clone() {
768            let delay = ctx.theme().motion.tooltip_delay;
769            crate::tooltip::attach_rich_tooltip_source_with_placement(
770                ctx,
771                root_id,
772                source,
773                delay,
774                tip_placement,
775            );
776        } else if let Some(text) = self.tooltip_text.clone() {
777            let delay = ctx.theme().motion.tooltip_delay;
778            crate::tooltip::attach_plain_tooltip_with_placement(
779                ctx,
780                root_id,
781                text,
782                delay,
783                tip_placement,
784            );
785        }
786
787        vec![root_id]
788    }
789
790    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
791        use crate::styles::recipe_standard_item_style as si;
792        let min_height = if self.subtitle.is_some() {
793            si::STANDARD_ITEM_MIN_HEIGHT_TWO_LINE
794        } else {
795            si::STANDARD_ITEM_MIN_HEIGHT_SINGLE_LINE
796        };
797        let raw = self
798            .root_child_id
799            .and_then(|id| ctx.child_size(id, proposal))
800            .unwrap_or_else(|| proposal.resolve(0.0, min_height));
801        let height = raw.height.max(min_height);
802        // Honor the proposed width when offered. The inner ZStack reports
803        // only the chrome's natural width (padding insets) under any
804        // proposal, so standalone rows in a VStack would collapse to ~16 px
805        // and the label would render in a zero-width box. Inside a
806        // ListView the row gets an exact-width proposal so this just
807        // reflects that.
808        let width = proposal.width.unwrap_or(raw.width);
809        teksilo_canvas::Size::new(width, height).into()
810    }
811
812    fn place_children(
813        &self,
814        bounds: Rect,
815        _proposal: SizeProposal,
816        children: &mut [WidgetPlacement],
817        _ctx: &LayoutContext,
818    ) {
819        for child in children.iter_mut() {
820            child.origin = bounds.origin();
821            child.size = bounds.size();
822        }
823    }
824
825    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
826        // The row's parent (ListView's `ListItemA11y`, TreeView's
827        // `TreeRowA11y`, TreeTableView's `TreeRowA11y`) already sets the
828        // structural role + position-in-set + selected/expanded
829        // state. We only contribute the row's name + description
830        // here.
831        //
832        // Name = label. Subtitle goes to `description` (a separate
833        // AccessKit field) rather than concatenated into the name —
834        // matches the AccessKit semantic and lets screen readers
835        // present them as primary vs supplementary.
836        builder.set_name(self.label.clone());
837        if let Some(subtitle) = &self.subtitle {
838            builder.set_description(subtitle.clone());
839        }
840        // Mirror enabled state. AccessKit's `set_disabled` is a flag
841        // (no boolean clear); the framework's accessibility-override
842        // layer can clear it via `access_disabled(false)` if needed.
843        // Framework a11y walker calls `set_disabled` from arena state.
844    }
845
846    fn children(&self) -> Vec<WidgetId> {
847        self.root_child_id.into_iter().collect()
848    }
849}
850
851// ---------------------------------------------------------------------------
852// StandardTreeItem
853// ---------------------------------------------------------------------------
854
855/// Canonical row layout for a `TreeView` — [`StandardListItem`] plus
856/// a depth-driven indent column and an always-reserved chevron column.
857///
858/// See the [module-level documentation](self) for the canonical `TreeView`
859/// wiring pattern and wiring rules.
860pub struct StandardTreeItem {
861    inner: StandardListItem,
862    depth: usize,
863    has_children: bool,
864    is_expanded: Prop<bool>,
865    on_toggle: Option<Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>>,
866}
867
868impl StandardTreeItem {
869    /// Create a tree item with the given primary label.
870    pub fn new(label: impl Into<LocalizedString>) -> Self {
871        Self {
872            inner: StandardListItem::new(label),
873            depth: 0,
874            has_children: false,
875            is_expanded: Prop::Static(false),
876            on_toggle: None,
877        }
878    }
879
880    // Forward all StandardListItem builders ----------------------------------
881
882    /// Forwarded to the inner [`StandardListItem`] — see its
883    /// [`subtitle`](StandardListItem::subtitle).
884    /// See [`StandardListItem::interaction_signal`]: the row's own hover/press
885    /// state, for a caller revealing controls on hover.
886    pub fn interaction_signal(mut self, signal: Signal<InteractionState>) -> Self {
887        self.inner = self.inner.interaction_signal(signal);
888        self
889    }
890
891    /// See [`StandardListItem::label_slot`]: draw this instead of the label's
892    /// text, keeping the label as the row's accessible name.
893    pub fn label_slot(mut self, widget: impl Widget + 'static) -> Self {
894        self.inner = self.inner.label_slot(widget);
895        self
896    }
897
898    pub fn subtitle(mut self, text: impl Into<LocalizedString>) -> Self {
899        self.inner = self.inner.subtitle(text);
900        self
901    }
902
903    /// Forwarded to the inner [`StandardListItem`] — see its
904    /// [`leading_slot`](StandardListItem::leading_slot).
905    pub fn leading_slot(mut self, widget: impl Widget + 'static) -> Self {
906        self.inner = self.inner.leading_slot(widget);
907        self
908    }
909
910    /// `Box<dyn Widget>` variant of [`leading_slot`](Self::leading_slot).
911    pub fn leading_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
912        self.inner = self.inner.leading_slot_boxed(widget);
913        self
914    }
915
916    /// Forwarded to the inner [`StandardListItem`] — see its
917    /// [`center_slot`](StandardListItem::center_slot).
918    pub fn center_slot(mut self, widget: impl Widget + 'static) -> Self {
919        self.inner = self.inner.center_slot(widget);
920        self
921    }
922
923    /// `Box<dyn Widget>` variant of [`center_slot`](Self::center_slot).
924    pub fn center_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
925        self.inner = self.inner.center_slot_boxed(widget);
926        self
927    }
928
929    /// Forwarded to the inner [`StandardListItem`] — see its
930    /// [`trailing_slot`](StandardListItem::trailing_slot).
931    pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
932        self.inner = self.inner.trailing_slot(widget);
933        self
934    }
935
936    /// `Box<dyn Widget>` variant of [`trailing_slot`](Self::trailing_slot).
937    pub fn trailing_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
938        self.inner = self.inner.trailing_slot_boxed(widget);
939        self
940    }
941
942    /// Forwarded to the inner [`StandardListItem`] — see its
943    /// [`subtitle_leading_slot`](StandardListItem::subtitle_leading_slot).
944    pub fn subtitle_leading_slot(mut self, widget: impl Widget + 'static) -> Self {
945        self.inner = self.inner.subtitle_leading_slot(widget);
946        self
947    }
948
949    /// `Box<dyn Widget>` variant of
950    /// [`subtitle_leading_slot`](Self::subtitle_leading_slot).
951    pub fn subtitle_leading_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
952        self.inner = self.inner.subtitle_leading_slot_boxed(widget);
953        self
954    }
955
956    /// Forwarded to the inner [`StandardListItem`] — see its
957    /// [`subtitle_trailing_slot`](StandardListItem::subtitle_trailing_slot).
958    pub fn subtitle_trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
959        self.inner = self.inner.subtitle_trailing_slot(widget);
960        self
961    }
962
963    /// `Box<dyn Widget>` variant of
964    /// [`subtitle_trailing_slot`](Self::subtitle_trailing_slot).
965    pub fn subtitle_trailing_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
966        self.inner = self.inner.subtitle_trailing_slot_boxed(widget);
967        self
968    }
969
970    /// Forwarded to the inner [`StandardListItem`] — see its
971    /// [`checkbox`](StandardListItem::checkbox).
972    pub fn checkbox(mut self, checked: Signal<bool>) -> Self {
973        self.inner = self.inner.checkbox(checked);
974        self
975    }
976
977    /// Forwarded to the inner [`StandardListItem`] — see its
978    /// [`tristate_checkbox`](StandardListItem::tristate_checkbox).
979    pub fn tristate_checkbox(mut self, state: Signal<CheckState>) -> Self {
980        self.inner = self.inner.tristate_checkbox(state);
981        self
982    }
983
984    /// Set the selection state, statically or reactively via a bound
985    /// `Signal<bool>`. Forwarded to the inner [`StandardListItem`] — see
986    /// its [`selected`](StandardListItem::selected).
987    pub fn selected(mut self, selected: impl Into<Prop<bool>>) -> Self {
988        self.inner = self.inner.selected(selected);
989        self
990    }
991
992    /// Set the enabled state, statically or reactively via a bound
993    /// `Signal<bool>` / `Prop<bool>`. Forwarded to the inner
994    /// [`StandardListItem`].
995    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
996        self.inner = self.inner.enabled(enabled);
997        self
998    }
999
1000    /// Override the label's text style. Forwarded to the inner
1001    /// [`StandardListItem`] — see its
1002    /// [`label_style`](StandardListItem::label_style).
1003    pub fn label_style(
1004        mut self,
1005        style: impl Into<teksilo_core::color_prop::TextStyleProp>,
1006    ) -> Self {
1007        self.inner = self.inner.label_style(style);
1008        self
1009    }
1010
1011    /// Override the subtitle's text style. Forwarded to the inner
1012    /// [`StandardListItem`] — see its
1013    /// [`subtitle_style`](StandardListItem::subtitle_style).
1014    pub fn subtitle_style(
1015        mut self,
1016        style: impl Into<teksilo_core::color_prop::TextStyleProp>,
1017    ) -> Self {
1018        self.inner = self.inner.subtitle_style(style);
1019        self
1020    }
1021
1022    /// Override the label's text color. Forwarded to the inner
1023    /// [`StandardListItem`] — see its `label_color(...)`.
1024    pub fn label_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
1025        self.inner = self.inner.label_color(color);
1026        self
1027    }
1028
1029    /// Override the subtitle's text color. Forwarded to the inner
1030    /// [`StandardListItem`] — see its `subtitle_color(...)`.
1031    pub fn subtitle_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
1032        self.inner = self.inner.subtitle_color(color);
1033        self
1034    }
1035
1036    /// Truncate the primary label instead of wrapping it. Forwarded to the
1037    /// inner [`StandardListItem`] — see its
1038    /// [`label_overflow`](StandardListItem::label_overflow).
1039    pub fn label_overflow(mut self, overflow: TextOverflow) -> Self {
1040        self.inner = self.inner.label_overflow(overflow);
1041        self
1042    }
1043
1044    /// Truncate the subtitle instead of wrapping it. Forwarded to the inner
1045    /// [`StandardListItem`] — see its
1046    /// [`subtitle_overflow`](StandardListItem::subtitle_overflow).
1047    pub fn subtitle_overflow(mut self, overflow: TextOverflow) -> Self {
1048        self.inner = self.inner.subtitle_overflow(overflow);
1049        self
1050    }
1051
1052    /// Per-call style override for the row chrome. Forwarded to the
1053    /// inner [`StandardListItem`] — see its `style(...)` for the
1054    /// precedence rules (per-call > theme.style_slots.standard_item >
1055    /// `RecipeStandardItemStyle`).
1056    pub fn style(mut self, style: impl teksilo_core::styles::StandardItemStyle) -> Self {
1057        self.inner = self.inner.style(style);
1058        self
1059    }
1060
1061    /// Attach a plain tooltip shown after the standard hover delay.
1062    /// Forwarded to the inner [`StandardListItem`] — see its
1063    /// [`tooltip`](StandardListItem::tooltip).
1064    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
1065        self.inner = self.inner.tooltip(text);
1066        self
1067    }
1068
1069    /// Attach a rich tooltip looked up from the global tooltip registry by key.
1070    /// Forwarded to the inner [`StandardListItem`] — see its
1071    /// [`rich_tooltip`](StandardListItem::rich_tooltip).
1072    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
1073        self.inner = self.inner.rich_tooltip(key);
1074        self
1075    }
1076
1077    /// Attach a rich tooltip from an inline
1078    /// [`TooltipContent`](crate::tooltip::TooltipContent) value.
1079    /// Forwarded to the inner [`StandardListItem`] — see its
1080    /// [`rich_tooltip_content`](StandardListItem::rich_tooltip_content).
1081    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
1082        self.inner = self.inner.rich_tooltip_content(content);
1083        self
1084    }
1085
1086    /// Attach a composite tooltip whose body is an arbitrary widget tree.
1087    /// Forwarded to the inner [`StandardListItem`] — see its
1088    /// [`composite_tooltip`](StandardListItem::composite_tooltip).
1089    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
1090        self.inner = self.inner.composite_tooltip(content);
1091        self
1092    }
1093
1094    // Tree-specific ---------------------------------------------------------
1095
1096    /// Set the indent depth (0 = root level). Each level adds one
1097    /// `STANDARD_ITEM_TREE_INDENT_STEP` of leading whitespace.
1098    pub fn depth(mut self, depth: usize) -> Self {
1099        self.depth = depth;
1100        self
1101    }
1102
1103    /// Declare whether the node has children, which determines whether the
1104    /// chevron column is interactive or decorative-only.
1105    pub fn has_children(mut self, has: bool) -> Self {
1106        self.has_children = has;
1107        self
1108    }
1109
1110    /// Set the expanded state, statically or reactively via a bound
1111    /// `Signal<bool>`.
1112    pub fn is_expanded(mut self, expanded: impl Into<Prop<bool>>) -> Self {
1113        self.is_expanded = expanded.into();
1114        self
1115    }
1116
1117    /// Convenience for the TreeView delegate path:
1118    /// `.from_entry(entry)` sets depth + has_children + is_expanded.
1119    pub fn from_entry(self, entry: &FlatEntry) -> Self {
1120        self.depth(entry.depth)
1121            .has_children(entry.has_children)
1122            .is_expanded(entry.is_expanded)
1123    }
1124
1125    /// Click handler for the chevron. Wired only when `has_children`
1126    /// is true. Typical use: `.on_toggle(ctx.toggle_callback())` from
1127    /// a `TreeRowContext` (see `TreeView::new_with_context`).
1128    ///
1129    /// The callback receives the firing [`EventContext`] so apps can
1130    /// dispatch an intent (e.g. lazy-load children on expand), open
1131    /// a dialog, or otherwise route the toggle through the framework
1132    /// before mutating model state.
1133    pub fn on_toggle(
1134        mut self,
1135        f: impl Fn(&mut teksilo_core::widget::EventContext) + 'static,
1136    ) -> Self {
1137        self.on_toggle = Some(Rc::new(f));
1138        self
1139    }
1140
1141    /// Variant accepting an already-`Rc`'d callback. Useful when the
1142    /// same callback is shared across multiple call sites without an
1143    /// extra clone — e.g. `TreeRowContext::toggle_callback()` returns
1144    /// this shape directly.
1145    pub fn on_toggle_rc(mut self, f: Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>) -> Self {
1146        self.on_toggle = Some(f);
1147        self
1148    }
1149}
1150
1151impl std::fmt::Debug for StandardTreeItem {
1152    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1153        f.debug_struct("StandardTreeItem")
1154            .field("inner", &self.inner)
1155            .field("depth", &self.depth)
1156            .field("has_children", &self.has_children)
1157            .finish()
1158    }
1159}
1160
1161impl Widget for StandardTreeItem {
1162    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1163        use crate::styles::recipe_standard_item_style as si;
1164
1165        // Bridge `self.inner.enabled` into the arena — same pattern
1166        // as StandardListItem. The chevron + indent siblings inherit
1167        // disabled via the ancestor walk.
1168        let self_id = ctx.self_id();
1169        ctx.enabled_when(self_id, self.inner.enabled.clone());
1170
1171        // 1. Build the StandardListItem's inner row (no bg yet).
1172        let roles = self.inner.resolve_roles(ctx);
1173        let inner_content_id = self.inner.build_content(ctx, &roles);
1174
1175        // 2. Indent column — empty FixedSize at `depth * step` width.
1176        let indent_width = self.depth as f32 * si::STANDARD_ITEM_TREE_INDENT_STEP;
1177        let indent_id = ctx.add(FixedSize::new().width(indent_width));
1178
1179        // 3. Chevron column — always reserved width so siblings at
1180        //    the same depth align. `TwistArrow` paints nothing for
1181        //    leaves. The click is wired via `TwistArrow::on_click`
1182        //    (which already installs a transparent hit-target rect
1183        //    + tap recognizer on its own node) — more direct than
1184        //    `FixedSize.on_tap`, which routes taps through the column
1185        //    wrapper and the composed parent chain.
1186        let chevron_size = si::STANDARD_ITEM_CHEVRON_COLUMN_WIDTH;
1187        // The chevron is a *foreground* of the row, so it takes the same
1188        // role the label does — otherwise a style whose selected row is a
1189        // solid accent capsule would flip the label to white and leave the
1190        // chevron a grey smudge on the accent, under WCAG 1.4.11's 3:1
1191        // floor. Under a wash-based style this resolves to `Secondary`
1192        // exactly as before.
1193        let chevron_role = self.inner.foreground_role(&roles, TextRole::Secondary);
1194        let mut chevron = TwistArrow::new(chevron_size, self.has_children, self.is_expanded.get())
1195            .color(chevron_role);
1196        if self.has_children
1197            && let Some(cb) = self.on_toggle.clone()
1198        {
1199            chevron = chevron.on_click(move |ctx| cb(ctx));
1200        }
1201        let chevron_column_id = ctx.add(FixedSize::new().width(chevron_size).child(chevron));
1202
1203        // 4. Outer HStack: indent | chevron column | inner row.
1204        let outer_row_id = ctx.add(
1205            HStack::new()
1206                .spacing(0.0)
1207                .alignment(VAlignment::Center)
1208                .add_child(indent_id)
1209                .add_child(chevron_column_id)
1210                .add_child(inner_content_id),
1211        );
1212
1213        // 5. Wrap with the rounded selection bg + interaction handler
1214        //    via the inner's helper.
1215        let root_id = self.inner.build_with_background(ctx, outer_row_id, &roles);
1216
1217        self.inner.root_child_id = Some(root_id);
1218
1219        // Attach tooltip — forwarded from the inner item's tooltip slots.
1220        // Tree rows stack vertically, so the tooltip opens to the trailing
1221        // `Side` — a `Below` tooltip would cover the next row down.
1222        let tip_placement = crate::tooltip::TooltipPlacement::Side;
1223        if let Some(content) = self.inner.composite_tooltip_content.take() {
1224            let delay = ctx.theme().motion.tooltip_delay_heavy;
1225            crate::tooltip::attach_composite_tooltip_boxed_with_placement(
1226                ctx,
1227                root_id,
1228                content,
1229                delay,
1230                tip_placement,
1231            );
1232        } else if let Some(source) = self.inner.rich_tooltip_source.clone() {
1233            let delay = ctx.theme().motion.tooltip_delay;
1234            crate::tooltip::attach_rich_tooltip_source_with_placement(
1235                ctx,
1236                root_id,
1237                source,
1238                delay,
1239                tip_placement,
1240            );
1241        } else if let Some(text) = self.inner.tooltip_text.clone() {
1242            let delay = ctx.theme().motion.tooltip_delay;
1243            crate::tooltip::attach_plain_tooltip_with_placement(
1244                ctx,
1245                root_id,
1246                text,
1247                delay,
1248                tip_placement,
1249            );
1250        }
1251
1252        vec![root_id]
1253    }
1254
1255    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1256        self.inner.layout_response(proposal, ctx)
1257    }
1258
1259    fn place_children(
1260        &self,
1261        bounds: Rect,
1262        proposal: SizeProposal,
1263        children: &mut [WidgetPlacement],
1264        ctx: &LayoutContext,
1265    ) {
1266        self.inner.place_children(bounds, proposal, children, ctx);
1267    }
1268
1269    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1270        self.inner.accessibility(builder);
1271    }
1272
1273    fn children(&self) -> Vec<WidgetId> {
1274        self.inner.children()
1275    }
1276}
1277
1278// ---------------------------------------------------------------------------
1279// Tests
1280// ---------------------------------------------------------------------------
1281
1282#[cfg(test)]
1283mod tests {
1284    use super::*;
1285    use teksilo_canvas::SizeProposal;
1286    use teksilo_core::Theme;
1287    use teksilo_core::styles::StandardItemStyle;
1288    use teksilo_core::widget_tree::WidgetTree;
1289    use teksilo_i18n::lit;
1290
1291    fn theme() -> Theme {
1292        teksilo_core::presets::intui::light()
1293    }
1294
1295    /// A theme whose `text_on_accent` is distinguishable from
1296    /// `text_primary`.
1297    ///
1298    /// IntUI's are **both black** — it pairs black labels with its teal
1299    /// accent deliberately — so the stock preset cannot tell a flipped
1300    /// label from an unflipped one, and a test written against it would
1301    /// pass no matter what the hook did.
1302    fn discriminating_theme() -> Theme {
1303        let mut t = theme();
1304        t.colors.text_on_accent = teksilo_tokens::Color::WHITE;
1305        assert_ne!(t.colors.text_primary, t.colors.text_on_accent);
1306        t
1307    }
1308
1309    /// Every glyph colour a render pass emitted, quantized to 8-bit.
1310    fn glyph_colors(tree: &mut WidgetTree) -> Vec<[u8; 4]> {
1311        tree.render()
1312            .glyphs
1313            .iter()
1314            .map(|g| {
1315                let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1316                [q(g.color[0]), q(g.color[1]), q(g.color[2]), q(g.color[3])]
1317            })
1318            .collect()
1319    }
1320
1321    fn rgba8(c: teksilo_tokens::Color) -> [u8; 4] {
1322        let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1323        [q(c.r()), q(c.g()), q(c.b()), q(c.a())]
1324    }
1325
1326    /// A style that fills a selected row with a saturated colour has to be
1327    /// able to recolour the label on top of it, and it cannot do that from
1328    /// `make_body` — the label is built first. `selected_label_role` is
1329    /// the hook; this is both halves of it.
1330    #[derive(Debug, Default, Clone, Copy)]
1331    struct OnAccentSelectionStyle;
1332
1333    impl StandardItemStyle for OnAccentSelectionStyle {
1334        fn make_body(
1335            &self,
1336            cfg: &StandardItemStyleConfig,
1337            ctx: &mut teksilo_core::build_context::BuildContext,
1338        ) -> WidgetId {
1339            crate::styles::RecipeStandardItemStyle::default().make_body(cfg, ctx)
1340        }
1341
1342        fn selected_label_role(&self) -> Option<TextRole> {
1343            Some(TextRole::OnAccent)
1344        }
1345    }
1346
1347    /// The default is `None`, and a row under it keeps `TextRole::Primary`
1348    /// whether or not it is selected — the behaviour every existing style
1349    /// relies on.
1350    #[test]
1351    fn a_style_without_the_hook_leaves_the_selected_label_alone() {
1352        let t = discriminating_theme();
1353        let primary = rgba8(t.colors.text_primary);
1354        let on_accent = rgba8(t.colors.text_on_accent);
1355
1356        let mut tree = WidgetTree::new()
1357            .with_theme(t)
1358            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1359                teksilo_canvas::MockTextBackend::new(),
1360            )));
1361        tree.add(StandardListItem::new(lit!("Row")).selected(Signal::new(true)));
1362        tree.layout(SizeProposal::exact(300.0, 40.0));
1363        let colors = glyph_colors(&mut tree);
1364        assert!(colors.contains(&primary));
1365        assert!(!colors.contains(&on_accent));
1366    }
1367
1368    /// …and a style that declares the hook flips it, but only while the
1369    /// row is *emphasised*.
1370    #[test]
1371    fn the_hook_flips_the_label_of_an_emphasised_row() {
1372        let t = discriminating_theme();
1373        let on_accent = rgba8(t.colors.text_on_accent);
1374
1375        let mut tree = WidgetTree::new()
1376            .with_theme(t)
1377            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1378                teksilo_canvas::MockTextBackend::new(),
1379            )));
1380        tree.add(
1381            StandardListItem::new(lit!("Row"))
1382                .selected(Signal::new(true))
1383                .style(OnAccentSelectionStyle),
1384        );
1385        tree.layout(SizeProposal::exact(300.0, 40.0));
1386        assert!(glyph_colors(&mut tree).contains(&on_accent));
1387    }
1388
1389    /// An *unselected* row must keep its normal label even under a style
1390    /// that declares the hook — otherwise every row in the list would read
1391    /// as chosen.
1392    #[test]
1393    fn the_hook_does_not_touch_an_unselected_row() {
1394        let t = discriminating_theme();
1395        let primary = rgba8(t.colors.text_primary);
1396        let on_accent = rgba8(t.colors.text_on_accent);
1397
1398        let mut tree = WidgetTree::new()
1399            .with_theme(t)
1400            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1401                teksilo_canvas::MockTextBackend::new(),
1402            )));
1403        tree.add(StandardListItem::new(lit!("Row")).style(OnAccentSelectionStyle));
1404        tree.layout(SizeProposal::exact(300.0, 40.0));
1405        let colors = glyph_colors(&mut tree);
1406        assert!(colors.contains(&primary));
1407        assert!(!colors.contains(&on_accent));
1408    }
1409
1410    /// Every foreground on a tree row has to flip together.
1411    ///
1412    /// The chevron is painted by `TwistArrow`, which defaulted to a
1413    /// hardcoded `TextRole::Secondary`. Under a style whose selected row
1414    /// is a solid accent capsule, the label flipped to white and the
1415    /// chevron stayed a grey smudge on the accent — under WCAG 1.4.11's
1416    /// 3:1 floor, and visibly wrong beside the flipped label. The chevron
1417    /// paints a `Path`, not glyphs, so it shows up in `shapes`.
1418    #[test]
1419    fn the_hook_flips_a_tree_rows_chevron_with_its_label() {
1420        let t = discriminating_theme();
1421        let secondary = rgba8(t.colors.text_secondary);
1422        let on_accent = rgba8(t.colors.text_on_accent);
1423
1424        let mut tree = WidgetTree::new()
1425            .with_theme(t)
1426            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1427                teksilo_canvas::MockTextBackend::new(),
1428            )));
1429        tree.add(
1430            StandardTreeItem::new(lit!("Node"))
1431                .has_children(true)
1432                .selected(Signal::new(true))
1433                .style(OnAccentSelectionStyle),
1434        );
1435        tree.layout(SizeProposal::exact(300.0, 40.0));
1436
1437        let shapes: Vec<[u8; 4]> = tree
1438            .render()
1439            .shapes
1440            .iter()
1441            .map(|s| {
1442                let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1443                [q(s.color[0]), q(s.color[1]), q(s.color[2]), q(s.color[3])]
1444            })
1445            .collect();
1446        let paths: Vec<[u8; 4]> = tree
1447            .render()
1448            .paths
1449            .iter()
1450            .map(|p| {
1451                let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1452                [q(p.color[0]), q(p.color[1]), q(p.color[2]), q(p.color[3])]
1453            })
1454            .collect();
1455        let painted: Vec<[u8; 4]> = shapes.into_iter().chain(paths).collect();
1456
1457        assert!(
1458            painted.contains(&on_accent),
1459            "the chevron did not flip with the label; painted {painted:?}"
1460        );
1461        assert!(
1462            !painted.contains(&secondary),
1463            "the chevron is still painting the muted role on an accent capsule"
1464        );
1465    }
1466
1467    /// …and a tree row under a style *without* the hook keeps the muted
1468    /// chevron every other theme expects.
1469    #[test]
1470    fn a_tree_rows_chevron_is_muted_without_the_hook() {
1471        let t = discriminating_theme();
1472        let secondary = rgba8(t.colors.text_secondary);
1473
1474        let mut tree = WidgetTree::new()
1475            .with_theme(t)
1476            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1477                teksilo_canvas::MockTextBackend::new(),
1478            )));
1479        tree.add(
1480            StandardTreeItem::new(lit!("Node"))
1481                .has_children(true)
1482                .selected(Signal::new(true)),
1483        );
1484        tree.layout(SizeProposal::exact(300.0, 40.0));
1485        let paths: Vec<[u8; 4]> = tree
1486            .render()
1487            .paths
1488            .iter()
1489            .map(|p| {
1490                let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1491                [q(p.color[0]), q(p.color[1]), q(p.color[2]), q(p.color[3])]
1492            })
1493            .collect();
1494        assert!(paths.contains(&secondary), "painted {paths:?}");
1495    }
1496
1497    /// A row whose window has gone inactive falls back to the muted
1498    /// `SelectedInactive` capsule, so its label has to fall back too — an
1499    /// on-accent label on a neutral grey would be the worst of both.
1500    #[test]
1501    fn the_hook_reverts_when_the_row_stops_being_emphasised() {
1502        let t = discriminating_theme();
1503        let primary = rgba8(t.colors.text_primary);
1504        let on_accent = rgba8(t.colors.text_on_accent);
1505
1506        let mut tree = WidgetTree::new()
1507            .with_theme(t)
1508            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1509                teksilo_canvas::MockTextBackend::new(),
1510            )));
1511        tree.add(
1512            StandardListItem::new(lit!("Row"))
1513                .selected(Signal::new(true))
1514                .style(OnAccentSelectionStyle),
1515        );
1516        tree.layout(SizeProposal::exact(300.0, 40.0));
1517        assert!(glyph_colors(&mut tree).contains(&on_accent));
1518
1519        tree.set_window_active(false);
1520        tree.layout(SizeProposal::exact(300.0, 40.0));
1521        let colors = glyph_colors(&mut tree);
1522        assert!(
1523            colors.contains(&primary),
1524            "an inactive window's selected row kept its on-accent label"
1525        );
1526        assert!(!colors.contains(&on_accent));
1527    }
1528
1529    #[test]
1530    fn list_item_layout_single_line() {
1531        let mut tree = WidgetTree::new().with_theme(theme());
1532        let id = tree.add(StandardListItem::new(lit!("Hello")));
1533        tree.layout(SizeProposal {
1534            width: Some(300.0),
1535            height: None,
1536        });
1537        let b = tree.bounds(id);
1538        use crate::styles::recipe_standard_item_style as si;
1539        assert!(b.height >= si::STANDARD_ITEM_MIN_HEIGHT_SINGLE_LINE - 0.5);
1540    }
1541
1542    /// A long subtitle in the default `Wrap` mode reports its full intrinsic
1543    /// width, over-constraining the primary `HStack` — the trailing slot is
1544    /// pushed past the row's right edge and out of the containing card. This
1545    /// pins the behaviour `subtitle_overflow` exists to escape.
1546    #[test]
1547    fn a_wrapping_subtitle_pushes_the_trailing_slot_out_of_the_row() {
1548        const ROW_W: f32 = 680.0;
1549        let mut tree = WidgetTree::new().with_theme(theme());
1550        let row = tree.add(
1551            StandardListItem::new(lit!("2026-07-14 10:05"))
1552                .subtitle(lit!(
1553                    "11 KB · /home/user/Nextcloud/Documents/Books/backups/novel-20260714-100528.skrib"
1554                ))
1555                .trailing_slot(crate::button::Button::new(lit!("Open"))),
1556        );
1557        tree.layout(SizeProposal::exact(ROW_W, 56.0));
1558
1559        let button = tree.find_by_label("Open").expect("trailing button");
1560        assert!(
1561            tree.bounds(button).right() > tree.bounds(row).right(),
1562            "a wrapping subtitle should overflow the row (got button right={}, row right={})",
1563            tree.bounds(button).right(),
1564            tree.bounds(row).right(),
1565        );
1566    }
1567
1568    /// With an ellipsis overflow the subtitle shrinks and truncates inside the
1569    /// row instead, so the trailing actions stay reachable within it.
1570    #[test]
1571    fn an_eliding_subtitle_keeps_the_trailing_slot_inside_the_row() {
1572        const ROW_W: f32 = 680.0;
1573        let mut tree = WidgetTree::new().with_theme(theme());
1574        let row = tree.add(
1575            StandardListItem::new(lit!("2026-07-14 10:05"))
1576                .subtitle(lit!(
1577                    "11 KB · /home/user/Nextcloud/Documents/Books/backups/novel-20260714-100528.skrib"
1578                ))
1579                .subtitle_overflow(TextOverflow::Ellipsis(teksilo_canvas::EllipsisMode::Middle))
1580                .trailing_slot(crate::button::Button::new(lit!("Open"))),
1581        );
1582        tree.layout(SizeProposal::exact(ROW_W, 56.0));
1583
1584        let button = tree.find_by_label("Open").expect("trailing button");
1585        assert!(
1586            tree.bounds(button).right() <= tree.bounds(row).right() + 0.5,
1587            "an elided subtitle must keep the trailing slot inside the row \
1588             (got button right={}, row right={})",
1589            tree.bounds(button).right(),
1590            tree.bounds(row).right(),
1591        );
1592    }
1593
1594    /// The same lever on the tree row, forwarded to the inner list item.
1595    #[test]
1596    fn tree_item_forwards_the_overflow_levers() {
1597        const ROW_W: f32 = 400.0;
1598        let mut tree = WidgetTree::new().with_theme(theme());
1599        let row = tree.add(
1600            StandardTreeItem::new(lit!(
1601                "A very long chapter title that cannot possibly fit this row"
1602            ))
1603            .label_overflow(TextOverflow::Ellipsis(
1604                teksilo_canvas::EllipsisMode::Trailing,
1605            ))
1606            .trailing_slot(crate::button::Button::new(lit!("Open"))),
1607        );
1608        tree.layout(SizeProposal::exact(ROW_W, 56.0));
1609
1610        let button = tree.find_by_label("Open").expect("trailing button");
1611        assert!(
1612            tree.bounds(button).right() <= tree.bounds(row).right() + 0.5,
1613            "an elided label must keep the tree row's trailing slot inside it \
1614             (got button right={}, row right={})",
1615            tree.bounds(button).right(),
1616            tree.bounds(row).right(),
1617        );
1618    }
1619
1620    #[test]
1621    fn selected_item_draws_focus_colour_boundary() {
1622        // WCAG 1.4.1 / 1.4.11 (audit G13): a selected item draws a
1623        // non-color-alone boundary in the focus/accent colour, so selection is
1624        // perceivable beyond the low-contrast surface_selected wash.
1625        let t = theme();
1626        let border = t.colors.border_focused.to_array();
1627        // The boundary is a stroked rounded-rect (a ShapeQuad with stroke_width
1628        // > 0 in the border colour), not a fill.
1629        let has_boundary = |frame: &teksilo_canvas::RenderFrame| {
1630            frame
1631                .shapes
1632                .iter()
1633                .any(|s| s.color == border && s.stroke_width > 0.0)
1634        };
1635
1636        let mut sel = WidgetTree::new().with_theme(t.clone());
1637        sel.add(StandardListItem::new(lit!("X")).selected(true));
1638        sel.layout(SizeProposal::exact(200.0, 40.0));
1639        assert!(
1640            has_boundary(&sel.render()),
1641            "selected item must draw a boundary in the focus/accent colour"
1642        );
1643
1644        let mut plain = WidgetTree::new().with_theme(t);
1645        plain.add(StandardListItem::new(lit!("X")).selected(false));
1646        plain.layout(SizeProposal::exact(200.0, 40.0));
1647        assert!(
1648            !has_boundary(&plain.render()),
1649            "an unselected item draws no such boundary"
1650        );
1651    }
1652
1653    #[test]
1654    fn list_item_layout_two_line() {
1655        let mut tree = WidgetTree::new().with_theme(theme());
1656        let id = tree.add(StandardListItem::new(lit!("Title")).subtitle(lit!("Subtitle text")));
1657        tree.layout(SizeProposal {
1658            width: Some(300.0),
1659            height: None,
1660        });
1661        let b = tree.bounds(id);
1662        use crate::styles::recipe_standard_item_style as si;
1663        assert!(
1664            b.height >= si::STANDARD_ITEM_MIN_HEIGHT_TWO_LINE - 0.5,
1665            "two-line height {} < expected {}",
1666            b.height,
1667            si::STANDARD_ITEM_MIN_HEIGHT_TWO_LINE
1668        );
1669    }
1670
1671    #[test]
1672    fn list_item_a11y_name_is_label_only() {
1673        // Subtitle goes to `description`, not concatenated into the
1674        // name. Lets screen readers present primary vs supplementary
1675        // info distinctly.
1676        let mut tree = WidgetTree::new().with_theme(theme());
1677        let id = tree.add(StandardListItem::new(lit!("Title")).subtitle(lit!("Subtitle")));
1678        tree.layout(SizeProposal::exact(300.0, 100.0));
1679        let info = tree.accessibility_node(id);
1680        assert_eq!(info.name(), Some("Title"));
1681    }
1682
1683    /// **A caller can see when the row is hovered**, which is what lets it reveal
1684    /// controls there. The row writes the signal; the caller only watches it.
1685    #[test]
1686    fn a_shared_interaction_signal_reports_the_rows_hover() {
1687        let state = Signal::new(InteractionState::Idle);
1688        let mut tree = WidgetTree::new().with_theme(theme());
1689        let id =
1690            tree.add(StandardListItem::new(lit!("A result")).interaction_signal(state.clone()));
1691        tree.layout(SizeProposal::exact(300.0, 40.0));
1692        let _ = tree.render();
1693        assert_eq!(state.get(), InteractionState::Idle);
1694
1695        let b = tree.bounds(id);
1696        tree.dispatch_event(teksilo_core::WidgetEvent::PointerMove {
1697            position: teksilo_canvas::Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0),
1698        });
1699        tree.layout(SizeProposal::exact(300.0, 40.0));
1700        let _ = tree.render();
1701        assert_eq!(
1702            state.get(),
1703            InteractionState::Hovered,
1704            "the row shares its own hover state with whoever asked for it"
1705        );
1706    }
1707
1708    /// **A row that draws its own label still has a name.**
1709    ///
1710    /// `label_slot` replaces the drawing, not the label: a search result picking
1711    /// the matched run out of its excerpt is built from runs and cannot be a
1712    /// string, but a row a screen reader cannot name is not an acceptable price
1713    /// for that. The text passed to `new` stays the accessible name.
1714    #[test]
1715    fn a_row_that_draws_its_own_label_keeps_its_accessible_name() {
1716        let mut tree = WidgetTree::new().with_theme(theme());
1717        let id = tree.add(
1718            StandardListItem::new(lit!("she walked across the ice"))
1719                .label_slot(TextWidget::new(lit!("…across the ice"))),
1720        );
1721        tree.layout(SizeProposal::exact(300.0, 100.0));
1722        let info = tree.accessibility_node(id);
1723        assert_eq!(
1724            info.name(),
1725            Some("she walked across the ice"),
1726            "the name comes from the label, not from what was drawn instead of it"
1727        );
1728    }
1729
1730    #[test]
1731    fn list_item_a11y_name_no_subtitle() {
1732        let mut tree = WidgetTree::new().with_theme(theme());
1733        let id = tree.add(StandardListItem::new(lit!("Just a title")));
1734        tree.layout(SizeProposal::exact(300.0, 100.0));
1735        let info = tree.accessibility_node(id);
1736        assert_eq!(info.name(), Some("Just a title"));
1737    }
1738
1739    #[test]
1740    fn list_item_with_checkbox_two_state() {
1741        use teksilo_core::signal::Signal;
1742        let checked = Signal::new(false);
1743        let mut tree = WidgetTree::new().with_theme(theme());
1744        let _id =
1745            tree.add(StandardListItem::new(lit!("Item with checkbox")).checkbox(checked.clone()));
1746        tree.layout(SizeProposal::exact(300.0, 100.0));
1747        // Just verify the build succeeds with the checkbox attached.
1748        // Toggle behavior is exercised by Checkbox's own tests.
1749        assert!(!checked.get());
1750    }
1751
1752    #[test]
1753    fn list_item_with_tristate_checkbox() {
1754        use teksilo_core::signal::Signal;
1755        let state = Signal::new(CheckState::Indeterminate);
1756        let mut tree = WidgetTree::new().with_theme(theme());
1757        let id = tree.add(StandardListItem::new(lit!("Folder")).tristate_checkbox(state.clone()));
1758        tree.layout(SizeProposal::exact(300.0, 100.0));
1759        let b = tree.bounds(id);
1760        assert!(b.width > 0.0);
1761    }
1762
1763    #[test]
1764    fn tree_item_chevron_reserved_for_leaf() {
1765        // Leaf and branch at same depth should produce identical
1766        // outer widths (chevron column reserved).
1767        let mut tree = WidgetTree::new().with_theme(theme());
1768        let leaf = tree.add(
1769            StandardTreeItem::new(lit!("file"))
1770                .depth(1)
1771                .has_children(false),
1772        );
1773        let branch = tree.add(
1774            StandardTreeItem::new(lit!("folder"))
1775                .depth(1)
1776                .has_children(true),
1777        );
1778        tree.layout(SizeProposal::exact(400.0, 200.0));
1779        let bl = tree.bounds(leaf);
1780        let bb = tree.bounds(branch);
1781        assert!((bl.width - bb.width).abs() < 0.5);
1782    }
1783
1784    #[test]
1785    fn twist_arrow_on_click_baseline() {
1786        // Ensure TwistArrow's own on_click(Fn() + 'static) wiring
1787        // works in isolation. If this fires but the StandardTreeItem
1788        // chevron path doesn't, the bug is in the StandardTreeItem
1789        // composition, not in the underlying widgets.
1790        use std::cell::Cell;
1791        use std::rc::Rc;
1792        use teksilo_canvas::Point;
1793        let fired = Rc::new(Cell::new(0u32));
1794        let f = fired.clone();
1795        let mut tree = WidgetTree::new().with_theme(theme());
1796        let id =
1797            tree.add(TwistArrow::new(20.0, true, false).on_click(move |_ctx| f.set(f.get() + 1)));
1798        tree.layout(SizeProposal::exact(40.0, 40.0));
1799        let b = tree.bounds(id);
1800        dispatch_tap(
1801            &mut tree,
1802            Point::new(b.x + b.width * 0.5, b.y + b.height * 0.5),
1803        );
1804        assert_eq!(fired.get(), 1, "TwistArrow.on_click() must fire on tap");
1805    }
1806
1807    #[test]
1808    fn fixed_size_wrapping_twist_arrow_on_tap_baseline() {
1809        // If on_tap on a FixedSize that wraps a TwistArrow works
1810        // here, the issue with StandardTreeItem's chevron is
1811        // composition (parent siblings) — not the chevron-column
1812        // shape itself.
1813        use std::cell::Cell;
1814        use std::rc::Rc;
1815        use teksilo_canvas::Point;
1816        use teksilo_core::widget_builder::WidgetBuilder;
1817        let fired = Rc::new(Cell::new(0u32));
1818        let f = fired.clone();
1819        let mut tree = WidgetTree::new().with_theme(theme());
1820        let id = tree.add(
1821            FixedSize::new()
1822                .width(20.0_f32)
1823                .child(TwistArrow::new(20.0, true, false))
1824                .on_tap(move |_, _| f.set(f.get() + 1)),
1825        );
1826        tree.layout(SizeProposal::exact(40.0, 40.0));
1827        let b = tree.bounds(id);
1828        dispatch_tap(
1829            &mut tree,
1830            Point::new(b.x + b.width * 0.5, b.y + b.height * 0.5),
1831        );
1832        assert_eq!(fired.get(), 1);
1833    }
1834
1835    #[test]
1836    fn fixed_size_on_tap_baseline() {
1837        // Sanity check: confirm `FixedSize::new().on_tap(...)` even
1838        // fires when constructed via the WidgetBuilder chain. If this
1839        // breaks, the StandardTreeItem chevron-tap path is doomed.
1840        use std::cell::Cell;
1841        use std::rc::Rc;
1842        use teksilo_canvas::Point;
1843        use teksilo_core::widget_builder::WidgetBuilder;
1844        let fired = Rc::new(Cell::new(0u32));
1845        let f = fired.clone();
1846        let mut tree = WidgetTree::new().with_theme(theme());
1847        let id = tree.add(
1848            FixedSize::new()
1849                .width(40.0_f32)
1850                .height(40.0_f32)
1851                .child(TextWidget::new(lit!("x")))
1852                .on_tap(move |_, _| f.set(f.get() + 1)),
1853        );
1854        tree.layout(SizeProposal::exact(200.0, 200.0));
1855        let b = tree.bounds(id);
1856        dispatch_tap(&mut tree, Point::new(b.x + 20.0, b.y + 20.0));
1857        assert_eq!(fired.get(), 1);
1858    }
1859
1860    fn dispatch_tap(tree: &mut WidgetTree, position: teksilo_canvas::Point) {
1861        use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
1862        tree.dispatch_event(WidgetEvent::PointerDown {
1863            position,
1864            button: PointerButton::Primary,
1865            modifiers: Modifiers::NONE,
1866        });
1867        tree.dispatch_event(WidgetEvent::PointerUp {
1868            position,
1869            button: PointerButton::Primary,
1870            modifiers: Modifiers::NONE,
1871        });
1872    }
1873
1874    #[test]
1875    fn list_item_checkbox_two_state_toggles_via_tap() {
1876        use teksilo_canvas::Point;
1877        let checked = Signal::new(false);
1878        let mut tree = WidgetTree::new().with_theme(theme());
1879        let id = tree.add(StandardListItem::new(lit!("Row")).checkbox(checked.clone()));
1880        tree.layout(SizeProposal::exact(400.0, 60.0));
1881        let bounds = tree.bounds(id);
1882        use crate::styles::recipe_standard_item_style as si;
1883        // Checkbox sits at the row's leading edge, just inside the
1884        // bg_horizontal_inset + padding_horizontal. Tap a few pixels
1885        // in from there so we land on the box visual.
1886        let cb_x = bounds.x
1887            + si::STANDARD_ITEM_BG_HORIZONTAL_INSET
1888            + si::STANDARD_ITEM_PADDING_HORIZONTAL
1889            + 4.0;
1890        let cb_y = bounds.y + bounds.height * 0.5;
1891        dispatch_tap(&mut tree, Point::new(cb_x, cb_y));
1892        assert!(
1893            checked.get(),
1894            "tap on checkbox should flip the bound signal"
1895        );
1896        dispatch_tap(&mut tree, Point::new(cb_x, cb_y));
1897        assert!(!checked.get(), "second tap should flip back");
1898    }
1899
1900    #[test]
1901    fn list_item_row_tap_outside_checkbox_does_not_toggle() {
1902        use teksilo_canvas::Point;
1903        let checked = Signal::new(false);
1904        let mut tree = WidgetTree::new().with_theme(theme());
1905        let id = tree.add(
1906            StandardListItem::new(lit!("A long-enough label so the tap target lands on text"))
1907                .checkbox(checked.clone()),
1908        );
1909        tree.layout(SizeProposal::exact(400.0, 60.0));
1910        let bounds = tree.bounds(id);
1911        // Tap far to the right of the checkbox (well past the
1912        // checkbox column) — should land on the label area.
1913        let label_x = bounds.x + bounds.width * 0.7;
1914        let label_y = bounds.y + bounds.height * 0.5;
1915        dispatch_tap(&mut tree, Point::new(label_x, label_y));
1916        assert!(
1917            !checked.get(),
1918            "tap on row body must not toggle the embedded checkbox"
1919        );
1920    }
1921
1922    #[test]
1923    fn tree_item_chevron_tap_fires_on_toggle() {
1924        use std::cell::Cell;
1925        use std::rc::Rc;
1926        use teksilo_canvas::Point;
1927        let fired = Rc::new(Cell::new(0u32));
1928        let fired_clone = fired.clone();
1929        let mut tree = WidgetTree::new().with_theme(theme());
1930        let id = tree.add(
1931            StandardTreeItem::new(lit!("Folder"))
1932                .depth(0)
1933                .has_children(true)
1934                .is_expanded(false)
1935                .on_toggle(move |_ctx| fired_clone.set(fired_clone.get() + 1)),
1936        );
1937        tree.layout(SizeProposal::exact(400.0, 60.0));
1938        let bounds = tree.bounds(id);
1939        use crate::styles::recipe_standard_item_style as si;
1940        // Inside the row's content padding the chevron column sits at
1941        // `padding_horizontal` (depth=0 → indent=0). Sample its
1942        // center.
1943        let cx = bounds.x
1944            + si::STANDARD_ITEM_PADDING_HORIZONTAL
1945            + si::STANDARD_ITEM_CHEVRON_COLUMN_WIDTH * 0.5;
1946        let cy = bounds.y + bounds.height * 0.5;
1947        dispatch_tap(&mut tree, Point::new(cx, cy));
1948        assert_eq!(
1949            fired.get(),
1950            1,
1951            "tap on chevron column should fire on_toggle exactly once"
1952        );
1953    }
1954
1955    #[test]
1956    fn tristate_checkbox_user_click_never_sets_indeterminate() {
1957        // The user can't set a checkbox to "half" by clicking. The
1958        // tristate cycle on user input is Unchecked ↔ Checked;
1959        // Indeterminate is reserved for model-driven aggregation.
1960        use teksilo_canvas::Point;
1961        let state = Signal::new(CheckState::Unchecked);
1962        let mut tree = WidgetTree::new().with_theme(theme());
1963        let id = tree.add(StandardListItem::new(lit!("Folder")).tristate_checkbox(state.clone()));
1964        tree.layout(SizeProposal::exact(400.0, 60.0));
1965        let bounds = tree.bounds(id);
1966        use crate::styles::recipe_standard_item_style as si;
1967        let cx = bounds.x + si::STANDARD_ITEM_PADDING_HORIZONTAL + 8.0;
1968        let cy = bounds.y + bounds.height * 0.5;
1969        // Click 1: Unchecked → Checked
1970        dispatch_tap(&mut tree, Point::new(cx, cy));
1971        assert_eq!(state.get(), CheckState::Checked);
1972        // Click 2: Checked → Unchecked (NOT Indeterminate)
1973        dispatch_tap(&mut tree, Point::new(cx, cy));
1974        assert_eq!(state.get(), CheckState::Unchecked);
1975        // Click 3: Unchecked → Checked again
1976        dispatch_tap(&mut tree, Point::new(cx, cy));
1977        assert_eq!(state.get(), CheckState::Checked);
1978    }
1979
1980    #[test]
1981    fn tristate_checkbox_user_click_from_indeterminate_goes_to_checked() {
1982        // Common in tree-folder selection: when the parent shows
1983        // partial state (some children checked) and the user clicks
1984        // it, the whole subtree should become checked.
1985        use teksilo_canvas::Point;
1986        let state = Signal::new(CheckState::Indeterminate);
1987        let mut tree = WidgetTree::new().with_theme(theme());
1988        let id = tree.add(StandardListItem::new(lit!("Folder")).tristate_checkbox(state.clone()));
1989        tree.layout(SizeProposal::exact(400.0, 60.0));
1990        let bounds = tree.bounds(id);
1991        use crate::styles::recipe_standard_item_style as si;
1992        let cx = bounds.x + si::STANDARD_ITEM_PADDING_HORIZONTAL + 8.0;
1993        let cy = bounds.y + bounds.height * 0.5;
1994        dispatch_tap(&mut tree, Point::new(cx, cy));
1995        assert_eq!(state.get(), CheckState::Checked);
1996    }
1997
1998    #[test]
1999    fn tree_item_no_toggle_when_no_children() {
2000        use std::cell::Cell;
2001        use std::rc::Rc;
2002        use teksilo_canvas::Point;
2003        let fired = Rc::new(Cell::new(0u32));
2004        let fired_clone = fired.clone();
2005        let mut tree = WidgetTree::new().with_theme(theme());
2006        let id = tree.add(
2007            StandardTreeItem::new(lit!("Leaf"))
2008                .depth(0)
2009                .has_children(false)
2010                .on_toggle(move |_ctx| fired_clone.set(fired_clone.get() + 1)),
2011        );
2012        tree.layout(SizeProposal::exact(400.0, 60.0));
2013        let bounds = tree.bounds(id);
2014        use crate::styles::recipe_standard_item_style as si;
2015        let cx = bounds.x
2016            + si::STANDARD_ITEM_PADDING_HORIZONTAL
2017            + si::STANDARD_ITEM_CHEVRON_COLUMN_WIDTH * 0.5;
2018        let cy = bounds.y + bounds.height * 0.5;
2019        dispatch_tap(&mut tree, Point::new(cx, cy));
2020        assert_eq!(
2021            fired.get(),
2022            0,
2023            "leaf rows must not wire on_toggle even if a callback was set"
2024        );
2025    }
2026
2027    #[test]
2028    fn tree_item_from_entry_sets_depth_and_state() {
2029        use teksilo_data::TreeModel;
2030        let m = TreeModel::<&str>::new();
2031        let root = m.insert_root(0, "r");
2032        let _child = m.insert_child(root, 0, "c");
2033
2034        let entry = FlatEntry {
2035            node_id: root,
2036            depth: 1,
2037            has_children: true,
2038            is_expanded: true,
2039        };
2040        let mut tree = WidgetTree::new().with_theme(theme());
2041        let id = tree.add(StandardTreeItem::new(lit!("x")).from_entry(&entry));
2042        tree.layout(SizeProposal::exact(400.0, 100.0));
2043        assert!(tree.bounds(id).width > 0.0);
2044    }
2045
2046    #[test]
2047    fn list_item_tooltip_appears_on_hover() {
2048        let mut tree = WidgetTree::new().with_theme(theme());
2049        let id = tree.add(StandardListItem::new(lit!("Row")).tooltip(lit!("Tip")));
2050        tree.layout(SizeProposal::exact(300.0, 200.0));
2051        tree.pointer_move(tree.bounds(id).center());
2052        tree.advance_time(std::time::Duration::from_secs(1));
2053        assert_eq!(
2054            tree.active_overlays().len(),
2055            1,
2056            "tooltip should appear on hover"
2057        );
2058        assert!(tree.find_by_label("Tip").is_some());
2059    }
2060
2061    #[test]
2062    fn tree_item_tooltip_appears_on_hover() {
2063        let mut tree = WidgetTree::new().with_theme(theme());
2064        let id = tree.add(StandardTreeItem::new(lit!("Node")).tooltip(lit!("TreeTip")));
2065        tree.layout(SizeProposal::exact(300.0, 200.0));
2066        tree.pointer_move(tree.bounds(id).center());
2067        tree.advance_time(std::time::Duration::from_secs(1));
2068        assert_eq!(
2069            tree.active_overlays().len(),
2070            1,
2071            "tooltip should appear on hover"
2072        );
2073        assert!(tree.find_by_label("TreeTip").is_some());
2074    }
2075}