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_literal` on the WidgetBuilder applies an
633            // override AFTER Checkbox::accessibility runs, so the
634            // screen reader announces e.g. "checkbox, checked, Save"
635            // when the user navigates to it.
636            use teksilo_core::widget_builder::WidgetBuilder;
637            let cb = match kind {
638                CheckboxKind::TwoState(s) => Checkbox::new(s),
639                CheckboxKind::TriState(s) => Checkbox::tristate(s),
640            }
641            .labels_hidden(true);
642            let cb_id = ctx.add(cb.access_label(self.label.clone()));
643            row = row.add_child(cb_id);
644        }
645        if let Some(w) = self.leading_slot.take() {
646            let id = ctx.add_boxed(w);
647            row = row.add_child(id);
648        }
649        if let Some(w) = self.center_slot.take() {
650            let id = ctx.add_boxed(w);
651            row = row.add_child(id);
652        }
653        row = row
654            .add_child(label_column_id)
655            .add_child(ctx.add(Spacer::new()));
656        if let Some(w) = self.trailing_slot.take() {
657            let id = ctx.add_boxed(w);
658            row = row.add_child(id);
659        }
660
661        ctx.add(row)
662    }
663
664    /// Wrap an already-composed row content in the active
665    /// `StandardItemStyle` chrome (selection background + corner
666    /// radius + padding) and attach the row-level hover handler.
667    /// Shared by `StandardListItem::build` (passing its inner row)
668    /// and `StandardTreeItem::build` (passing the row prefixed with
669    /// indent + chevron columns).
670    fn build_with_background(
671        &mut self,
672        ctx: &mut BuildContext,
673        content_id: WidgetId,
674        roles: &RowRoles,
675    ) -> WidgetId {
676        // Derive the cfg's boolean signals from the widget's existing
677        // `interaction` + `selected` + `enabled` signals. The recipe
678        // re-evaluates the bg role on any source change.
679        let is_selected = self.selected.clone();
680        let is_disabled = self.enabled.map(|e| !*e);
681        let is_hovered = self
682            .interaction
683            .map(|s| matches!(s, InteractionState::Hovered));
684        let is_pressed = self
685            .interaction
686            .map(|s| matches!(s, InteractionState::Pressed));
687        // Focus-aware selection: `is_focused` tracks whether this item's focus
688        // scope (its nearest focusable ancestor — the enclosing ListView /
689        // TreeView / … or any focusable container) holds keyboard focus. The
690        // recipe paints the active `Selected` chrome while it does and the muted
691        // `SelectedInactive` chrome when focus is elsewhere. Items outside any
692        // focusable scope read a constant `true`, so their selection always
693        // looks active.
694        let is_focused = ctx.view_focus_active();
695        // Keyboard-vs-pointer modality so the recipe shows the focus ring only
696        // during keyboard navigation (`:focus-visible`).
697        let is_focus_visible = ctx.focus_visible();
698
699        let style: SharedStandardItemStyle = roles.style.clone();
700        let cfg = StandardItemStyleConfig {
701            content: content_id,
702            is_selected,
703            is_hovered,
704            is_pressed,
705            is_focused,
706            is_focus_visible,
707            is_disabled,
708            is_window_active: ctx.window_active_signal(),
709        };
710        let root_id = style.make_body(&cfg, ctx);
711
712        // Attach hover handler to the row so hovering anywhere in the
713        // row updates the interaction signal. Disabled rows still
714        // track hover but the recipe's bg cascade short-circuits to
715        // Transparent.
716        use teksilo_core::widget_builder::HandlerSet;
717        let interaction_for_hover = self.interaction.clone();
718        let handlers = HandlerSet::new().on_hover(move |entered: bool, _ctx: &mut EventContext| {
719            interaction_for_hover.set(if entered {
720                InteractionState::Hovered
721            } else {
722                InteractionState::Idle
723            });
724        });
725        ctx.apply_self_handlers(handlers);
726
727        root_id
728    }
729}
730
731impl Widget for StandardListItem {
732    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
733        let self_id = ctx.self_id();
734        // Bridge the widget's owned `self.enabled` signal into the
735        // arena's enabled_state. Now event-gating, focus traversal,
736        // a11y disabled, and the leaves' role-substitution all
737        // observe the same source. Previously `self.enabled` was
738        // widget-internal — events still routed to disabled items,
739        // and external `ctx.enabled_when(item_id, …)` would not
740        // override the local Signal.
741        ctx.enabled_when(self_id, self.enabled.clone());
742        // Resolved once and shared by the label, the subtitle and (for a
743        // tree row) the chevron — see `RowRoles`.
744        let roles = self.resolve_roles(ctx);
745        let content_id = self.build_content(ctx, &roles);
746        let root_id = self.build_with_background(ctx, content_id, &roles);
747        self.root_child_id = Some(root_id);
748
749        // Attach tooltip — mutually exclusive slots, composite wins.
750        // Standard rows stack vertically in a `ListView`/`TreeView`, so the
751        // tooltip opens to the trailing `Side` — a `Below` tooltip would
752        // cover the next row down.
753        let tip_placement = crate::tooltip::TooltipPlacement::Side;
754        if let Some(content) = self.composite_tooltip_content.take() {
755            let delay = ctx.theme().motion.tooltip_delay_heavy;
756            crate::tooltip::attach_composite_tooltip_boxed_with_placement(
757                ctx,
758                root_id,
759                content,
760                delay,
761                tip_placement,
762            );
763        } else if let Some(source) = self.rich_tooltip_source.clone() {
764            let delay = ctx.theme().motion.tooltip_delay;
765            crate::tooltip::attach_rich_tooltip_source_with_placement(
766                ctx,
767                root_id,
768                source,
769                delay,
770                tip_placement,
771            );
772        } else if let Some(text) = self.tooltip_text.clone() {
773            let delay = ctx.theme().motion.tooltip_delay;
774            crate::tooltip::attach_plain_tooltip_with_placement(
775                ctx,
776                root_id,
777                text,
778                delay,
779                tip_placement,
780            );
781        }
782
783        vec![root_id]
784    }
785
786    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
787        use crate::styles::recipe_standard_item_style as si;
788        let min_height = if self.subtitle.is_some() {
789            si::STANDARD_ITEM_MIN_HEIGHT_TWO_LINE
790        } else {
791            si::STANDARD_ITEM_MIN_HEIGHT_SINGLE_LINE
792        };
793        let raw = self
794            .root_child_id
795            .and_then(|id| ctx.child_size(id, proposal))
796            .unwrap_or_else(|| proposal.resolve(0.0, min_height));
797        let height = raw.height.max(min_height);
798        // Honor the proposed width when offered. The inner ZStack reports
799        // only the chrome's natural width (padding insets) under any
800        // proposal, so standalone rows in a VStack would collapse to ~16 px
801        // and the label would render in a zero-width box. Inside a
802        // ListView the row gets an exact-width proposal so this just
803        // reflects that.
804        let width = proposal.width.unwrap_or(raw.width);
805        teksilo_canvas::Size::new(width, height).into()
806    }
807
808    fn place_children(
809        &self,
810        bounds: Rect,
811        _proposal: SizeProposal,
812        children: &mut [WidgetPlacement],
813        _ctx: &LayoutContext,
814    ) {
815        for child in children.iter_mut() {
816            child.origin = bounds.origin();
817            child.size = bounds.size();
818        }
819    }
820
821    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
822        // The row's parent (ListView's `ListItemA11y`, TreeView's
823        // `TreeRowA11y`, TreeTableView's `TreeRowA11y`) already sets the
824        // structural role + position-in-set + selected/expanded
825        // state. We only contribute the row's name + description
826        // here.
827        //
828        // Name = label. Subtitle goes to `description` (a separate
829        // AccessKit field) rather than concatenated into the name —
830        // matches the AccessKit semantic and lets screen readers
831        // present them as primary vs supplementary.
832        builder.set_name(self.label.clone());
833        if let Some(subtitle) = &self.subtitle {
834            builder.set_description(subtitle.clone());
835        }
836        // Mirror enabled state. AccessKit's `set_disabled` is a flag
837        // (no boolean clear); the framework's accessibility-override
838        // layer can clear it via `access_disabled(false)` if needed.
839        // Framework a11y walker calls `set_disabled` from arena state.
840    }
841
842    fn children(&self) -> Vec<WidgetId> {
843        self.root_child_id.into_iter().collect()
844    }
845}
846
847// ---------------------------------------------------------------------------
848// StandardTreeItem
849// ---------------------------------------------------------------------------
850
851/// Canonical row layout for a `TreeView` — [`StandardListItem`] plus
852/// a depth-driven indent column and an always-reserved chevron column.
853///
854/// See the [module-level documentation](self) for the canonical `TreeView`
855/// wiring pattern and wiring rules.
856pub struct StandardTreeItem {
857    inner: StandardListItem,
858    depth: usize,
859    has_children: bool,
860    is_expanded: Prop<bool>,
861    on_toggle: Option<Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>>,
862}
863
864impl StandardTreeItem {
865    /// Create a tree item with the given primary label.
866    pub fn new(label: impl Into<LocalizedString>) -> Self {
867        Self {
868            inner: StandardListItem::new(label),
869            depth: 0,
870            has_children: false,
871            is_expanded: Prop::Static(false),
872            on_toggle: None,
873        }
874    }
875
876    // Forward all StandardListItem builders ----------------------------------
877
878    /// Forwarded to the inner [`StandardListItem`] — see its
879    /// [`subtitle`](StandardListItem::subtitle).
880    /// See [`StandardListItem::interaction_signal`]: the row's own hover/press
881    /// state, for a caller revealing controls on hover.
882    pub fn interaction_signal(mut self, signal: Signal<InteractionState>) -> Self {
883        self.inner = self.inner.interaction_signal(signal);
884        self
885    }
886
887    /// See [`StandardListItem::label_slot`]: draw this instead of the label's
888    /// text, keeping the label as the row's accessible name.
889    pub fn label_slot(mut self, widget: impl Widget + 'static) -> Self {
890        self.inner = self.inner.label_slot(widget);
891        self
892    }
893
894    pub fn subtitle(mut self, text: impl Into<LocalizedString>) -> Self {
895        self.inner = self.inner.subtitle(text);
896        self
897    }
898
899    /// Forwarded to the inner [`StandardListItem`] — see its
900    /// [`leading_slot`](StandardListItem::leading_slot).
901    pub fn leading_slot(mut self, widget: impl Widget + 'static) -> Self {
902        self.inner = self.inner.leading_slot(widget);
903        self
904    }
905
906    /// `Box<dyn Widget>` variant of [`leading_slot`](Self::leading_slot).
907    pub fn leading_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
908        self.inner = self.inner.leading_slot_boxed(widget);
909        self
910    }
911
912    /// Forwarded to the inner [`StandardListItem`] — see its
913    /// [`center_slot`](StandardListItem::center_slot).
914    pub fn center_slot(mut self, widget: impl Widget + 'static) -> Self {
915        self.inner = self.inner.center_slot(widget);
916        self
917    }
918
919    /// `Box<dyn Widget>` variant of [`center_slot`](Self::center_slot).
920    pub fn center_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
921        self.inner = self.inner.center_slot_boxed(widget);
922        self
923    }
924
925    /// Forwarded to the inner [`StandardListItem`] — see its
926    /// [`trailing_slot`](StandardListItem::trailing_slot).
927    pub fn trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
928        self.inner = self.inner.trailing_slot(widget);
929        self
930    }
931
932    /// `Box<dyn Widget>` variant of [`trailing_slot`](Self::trailing_slot).
933    pub fn trailing_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
934        self.inner = self.inner.trailing_slot_boxed(widget);
935        self
936    }
937
938    /// Forwarded to the inner [`StandardListItem`] — see its
939    /// [`subtitle_leading_slot`](StandardListItem::subtitle_leading_slot).
940    pub fn subtitle_leading_slot(mut self, widget: impl Widget + 'static) -> Self {
941        self.inner = self.inner.subtitle_leading_slot(widget);
942        self
943    }
944
945    /// `Box<dyn Widget>` variant of
946    /// [`subtitle_leading_slot`](Self::subtitle_leading_slot).
947    pub fn subtitle_leading_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
948        self.inner = self.inner.subtitle_leading_slot_boxed(widget);
949        self
950    }
951
952    /// Forwarded to the inner [`StandardListItem`] — see its
953    /// [`subtitle_trailing_slot`](StandardListItem::subtitle_trailing_slot).
954    pub fn subtitle_trailing_slot(mut self, widget: impl Widget + 'static) -> Self {
955        self.inner = self.inner.subtitle_trailing_slot(widget);
956        self
957    }
958
959    /// `Box<dyn Widget>` variant of
960    /// [`subtitle_trailing_slot`](Self::subtitle_trailing_slot).
961    pub fn subtitle_trailing_slot_boxed(mut self, widget: Box<dyn Widget>) -> Self {
962        self.inner = self.inner.subtitle_trailing_slot_boxed(widget);
963        self
964    }
965
966    /// Forwarded to the inner [`StandardListItem`] — see its
967    /// [`checkbox`](StandardListItem::checkbox).
968    pub fn checkbox(mut self, checked: Signal<bool>) -> Self {
969        self.inner = self.inner.checkbox(checked);
970        self
971    }
972
973    /// Forwarded to the inner [`StandardListItem`] — see its
974    /// [`tristate_checkbox`](StandardListItem::tristate_checkbox).
975    pub fn tristate_checkbox(mut self, state: Signal<CheckState>) -> Self {
976        self.inner = self.inner.tristate_checkbox(state);
977        self
978    }
979
980    /// Set the selection state, statically or reactively via a bound
981    /// `Signal<bool>`. Forwarded to the inner [`StandardListItem`] — see
982    /// its [`selected`](StandardListItem::selected).
983    pub fn selected(mut self, selected: impl Into<Prop<bool>>) -> Self {
984        self.inner = self.inner.selected(selected);
985        self
986    }
987
988    /// Set the enabled state, statically or reactively via a bound
989    /// `Signal<bool>` / `Prop<bool>`. Forwarded to the inner
990    /// [`StandardListItem`].
991    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
992        self.inner = self.inner.enabled(enabled);
993        self
994    }
995
996    /// Override the label's text style. Forwarded to the inner
997    /// [`StandardListItem`] — see its
998    /// [`label_style`](StandardListItem::label_style).
999    pub fn label_style(
1000        mut self,
1001        style: impl Into<teksilo_core::color_prop::TextStyleProp>,
1002    ) -> Self {
1003        self.inner = self.inner.label_style(style);
1004        self
1005    }
1006
1007    /// Override the subtitle's text style. Forwarded to the inner
1008    /// [`StandardListItem`] — see its
1009    /// [`subtitle_style`](StandardListItem::subtitle_style).
1010    pub fn subtitle_style(
1011        mut self,
1012        style: impl Into<teksilo_core::color_prop::TextStyleProp>,
1013    ) -> Self {
1014        self.inner = self.inner.subtitle_style(style);
1015        self
1016    }
1017
1018    /// Override the label's text color. Forwarded to the inner
1019    /// [`StandardListItem`] — see its `label_color(...)`.
1020    pub fn label_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
1021        self.inner = self.inner.label_color(color);
1022        self
1023    }
1024
1025    /// Override the subtitle's text color. Forwarded to the inner
1026    /// [`StandardListItem`] — see its `subtitle_color(...)`.
1027    pub fn subtitle_color(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
1028        self.inner = self.inner.subtitle_color(color);
1029        self
1030    }
1031
1032    /// Truncate the primary label instead of wrapping it. Forwarded to the
1033    /// inner [`StandardListItem`] — see its
1034    /// [`label_overflow`](StandardListItem::label_overflow).
1035    pub fn label_overflow(mut self, overflow: TextOverflow) -> Self {
1036        self.inner = self.inner.label_overflow(overflow);
1037        self
1038    }
1039
1040    /// Truncate the subtitle instead of wrapping it. Forwarded to the inner
1041    /// [`StandardListItem`] — see its
1042    /// [`subtitle_overflow`](StandardListItem::subtitle_overflow).
1043    pub fn subtitle_overflow(mut self, overflow: TextOverflow) -> Self {
1044        self.inner = self.inner.subtitle_overflow(overflow);
1045        self
1046    }
1047
1048    /// Per-call style override for the row chrome. Forwarded to the
1049    /// inner [`StandardListItem`] — see its `style(...)` for the
1050    /// precedence rules (per-call > theme.style_slots.standard_item >
1051    /// `RecipeStandardItemStyle`).
1052    pub fn style(mut self, style: impl teksilo_core::styles::StandardItemStyle) -> Self {
1053        self.inner = self.inner.style(style);
1054        self
1055    }
1056
1057    /// Attach a plain tooltip shown after the standard hover delay.
1058    /// Forwarded to the inner [`StandardListItem`] — see its
1059    /// [`tooltip`](StandardListItem::tooltip).
1060    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
1061        self.inner = self.inner.tooltip(text);
1062        self
1063    }
1064
1065    /// Attach a rich tooltip looked up from the global tooltip registry by key.
1066    /// Forwarded to the inner [`StandardListItem`] — see its
1067    /// [`rich_tooltip`](StandardListItem::rich_tooltip).
1068    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
1069        self.inner = self.inner.rich_tooltip(key);
1070        self
1071    }
1072
1073    /// Attach a rich tooltip from an inline
1074    /// [`TooltipContent`](crate::tooltip::TooltipContent) value.
1075    /// Forwarded to the inner [`StandardListItem`] — see its
1076    /// [`rich_tooltip_content`](StandardListItem::rich_tooltip_content).
1077    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
1078        self.inner = self.inner.rich_tooltip_content(content);
1079        self
1080    }
1081
1082    /// Attach a composite tooltip whose body is an arbitrary widget tree.
1083    /// Forwarded to the inner [`StandardListItem`] — see its
1084    /// [`composite_tooltip`](StandardListItem::composite_tooltip).
1085    pub fn composite_tooltip(mut self, content: impl Widget + 'static) -> Self {
1086        self.inner = self.inner.composite_tooltip(content);
1087        self
1088    }
1089
1090    // Tree-specific ---------------------------------------------------------
1091
1092    /// Set the indent depth (0 = root level). Each level adds one
1093    /// `STANDARD_ITEM_TREE_INDENT_STEP` of leading whitespace.
1094    pub fn depth(mut self, depth: usize) -> Self {
1095        self.depth = depth;
1096        self
1097    }
1098
1099    /// Declare whether the node has children, which determines whether the
1100    /// chevron column is interactive or decorative-only.
1101    pub fn has_children(mut self, has: bool) -> Self {
1102        self.has_children = has;
1103        self
1104    }
1105
1106    /// Set the expanded state, statically or reactively via a bound
1107    /// `Signal<bool>`.
1108    pub fn is_expanded(mut self, expanded: impl Into<Prop<bool>>) -> Self {
1109        self.is_expanded = expanded.into();
1110        self
1111    }
1112
1113    /// Convenience for the TreeView delegate path:
1114    /// `.from_entry(entry)` sets depth + has_children + is_expanded.
1115    pub fn from_entry(self, entry: &FlatEntry) -> Self {
1116        self.depth(entry.depth)
1117            .has_children(entry.has_children)
1118            .is_expanded(entry.is_expanded)
1119    }
1120
1121    /// Click handler for the chevron. Wired only when `has_children`
1122    /// is true. Typical use: `.on_toggle(ctx.toggle_callback())` from
1123    /// a `TreeRowContext` (see `TreeView::new_with_context`).
1124    ///
1125    /// The callback receives the firing [`EventContext`] so apps can
1126    /// dispatch an intent (e.g. lazy-load children on expand), open
1127    /// a dialog, or otherwise route the toggle through the framework
1128    /// before mutating model state.
1129    pub fn on_toggle(
1130        mut self,
1131        f: impl Fn(&mut teksilo_core::widget::EventContext) + 'static,
1132    ) -> Self {
1133        self.on_toggle = Some(Rc::new(f));
1134        self
1135    }
1136
1137    /// Variant accepting an already-`Rc`'d callback. Useful when the
1138    /// same callback is shared across multiple call sites without an
1139    /// extra clone — e.g. `TreeRowContext::toggle_callback()` returns
1140    /// this shape directly.
1141    pub fn on_toggle_rc(mut self, f: Rc<dyn Fn(&mut teksilo_core::widget::EventContext)>) -> Self {
1142        self.on_toggle = Some(f);
1143        self
1144    }
1145}
1146
1147impl std::fmt::Debug for StandardTreeItem {
1148    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1149        f.debug_struct("StandardTreeItem")
1150            .field("inner", &self.inner)
1151            .field("depth", &self.depth)
1152            .field("has_children", &self.has_children)
1153            .finish()
1154    }
1155}
1156
1157impl Widget for StandardTreeItem {
1158    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
1159        use crate::styles::recipe_standard_item_style as si;
1160
1161        // Bridge `self.inner.enabled` into the arena — same pattern
1162        // as StandardListItem. The chevron + indent siblings inherit
1163        // disabled via the ancestor walk.
1164        let self_id = ctx.self_id();
1165        ctx.enabled_when(self_id, self.inner.enabled.clone());
1166
1167        // 1. Build the StandardListItem's inner row (no bg yet).
1168        let roles = self.inner.resolve_roles(ctx);
1169        let inner_content_id = self.inner.build_content(ctx, &roles);
1170
1171        // 2. Indent column — empty FixedSize at `depth * step` width.
1172        let indent_width = self.depth as f32 * si::STANDARD_ITEM_TREE_INDENT_STEP;
1173        let indent_id = ctx.add(FixedSize::new().width(indent_width));
1174
1175        // 3. Chevron column — always reserved width so siblings at
1176        //    the same depth align. `TwistArrow` paints nothing for
1177        //    leaves. The click is wired via `TwistArrow::on_click`
1178        //    (which already installs a transparent hit-target rect
1179        //    + tap recognizer on its own node) — more direct than
1180        //    `FixedSize.on_tap`, which routes taps through the column
1181        //    wrapper and the composed parent chain.
1182        let chevron_size = si::STANDARD_ITEM_CHEVRON_COLUMN_WIDTH;
1183        // The chevron is a *foreground* of the row, so it takes the same
1184        // role the label does — otherwise a style whose selected row is a
1185        // solid accent capsule would flip the label to white and leave the
1186        // chevron a grey smudge on the accent, under WCAG 1.4.11's 3:1
1187        // floor. Under a wash-based style this resolves to `Secondary`
1188        // exactly as before.
1189        let chevron_role = self.inner.foreground_role(&roles, TextRole::Secondary);
1190        let mut chevron = TwistArrow::new(chevron_size, self.has_children, self.is_expanded.get())
1191            .color(chevron_role);
1192        if self.has_children
1193            && let Some(cb) = self.on_toggle.clone()
1194        {
1195            chevron = chevron.on_click(move |ctx| cb(ctx));
1196        }
1197        let chevron_column_id = ctx.add(FixedSize::new().width(chevron_size).child(chevron));
1198
1199        // 4. Outer HStack: indent | chevron column | inner row.
1200        let outer_row_id = ctx.add(
1201            HStack::new()
1202                .spacing(0.0)
1203                .alignment(VAlignment::Center)
1204                .add_child(indent_id)
1205                .add_child(chevron_column_id)
1206                .add_child(inner_content_id),
1207        );
1208
1209        // 5. Wrap with the rounded selection bg + interaction handler
1210        //    via the inner's helper.
1211        let root_id = self.inner.build_with_background(ctx, outer_row_id, &roles);
1212
1213        self.inner.root_child_id = Some(root_id);
1214
1215        // Attach tooltip — forwarded from the inner item's tooltip slots.
1216        // Tree rows stack vertically, so the tooltip opens to the trailing
1217        // `Side` — a `Below` tooltip would cover the next row down.
1218        let tip_placement = crate::tooltip::TooltipPlacement::Side;
1219        if let Some(content) = self.inner.composite_tooltip_content.take() {
1220            let delay = ctx.theme().motion.tooltip_delay_heavy;
1221            crate::tooltip::attach_composite_tooltip_boxed_with_placement(
1222                ctx,
1223                root_id,
1224                content,
1225                delay,
1226                tip_placement,
1227            );
1228        } else if let Some(source) = self.inner.rich_tooltip_source.clone() {
1229            let delay = ctx.theme().motion.tooltip_delay;
1230            crate::tooltip::attach_rich_tooltip_source_with_placement(
1231                ctx,
1232                root_id,
1233                source,
1234                delay,
1235                tip_placement,
1236            );
1237        } else if let Some(text) = self.inner.tooltip_text.clone() {
1238            let delay = ctx.theme().motion.tooltip_delay;
1239            crate::tooltip::attach_plain_tooltip_with_placement(
1240                ctx,
1241                root_id,
1242                text,
1243                delay,
1244                tip_placement,
1245            );
1246        }
1247
1248        vec![root_id]
1249    }
1250
1251    fn layout_response(&self, proposal: SizeProposal, ctx: &LayoutContext) -> LayoutResponse {
1252        self.inner.layout_response(proposal, ctx)
1253    }
1254
1255    fn place_children(
1256        &self,
1257        bounds: Rect,
1258        proposal: SizeProposal,
1259        children: &mut [WidgetPlacement],
1260        ctx: &LayoutContext,
1261    ) {
1262        self.inner.place_children(bounds, proposal, children, ctx);
1263    }
1264
1265    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1266        self.inner.accessibility(builder);
1267    }
1268
1269    fn children(&self) -> Vec<WidgetId> {
1270        self.inner.children()
1271    }
1272}
1273
1274// ---------------------------------------------------------------------------
1275// Tests
1276// ---------------------------------------------------------------------------
1277
1278#[cfg(test)]
1279mod tests {
1280    use super::*;
1281    use teksilo_canvas::SizeProposal;
1282    use teksilo_core::Theme;
1283    use teksilo_core::styles::StandardItemStyle;
1284    use teksilo_core::widget_tree::WidgetTree;
1285    use teksilo_i18n::lit;
1286
1287    fn theme() -> Theme {
1288        teksilo_core::presets::intui::light()
1289    }
1290
1291    /// A theme whose `text_on_accent` is distinguishable from
1292    /// `text_primary`.
1293    ///
1294    /// IntUI's are **both black** — it pairs black labels with its teal
1295    /// accent deliberately — so the stock preset cannot tell a flipped
1296    /// label from an unflipped one, and a test written against it would
1297    /// pass no matter what the hook did.
1298    fn discriminating_theme() -> Theme {
1299        let mut t = theme();
1300        t.colors.text_on_accent = teksilo_tokens::Color::WHITE;
1301        assert_ne!(t.colors.text_primary, t.colors.text_on_accent);
1302        t
1303    }
1304
1305    /// Every glyph colour a render pass emitted, quantized to 8-bit.
1306    fn glyph_colors(tree: &mut WidgetTree) -> Vec<[u8; 4]> {
1307        tree.render()
1308            .glyphs
1309            .iter()
1310            .map(|g| {
1311                let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1312                [q(g.color[0]), q(g.color[1]), q(g.color[2]), q(g.color[3])]
1313            })
1314            .collect()
1315    }
1316
1317    fn rgba8(c: teksilo_tokens::Color) -> [u8; 4] {
1318        let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1319        [q(c.r()), q(c.g()), q(c.b()), q(c.a())]
1320    }
1321
1322    /// A style that fills a selected row with a saturated colour has to be
1323    /// able to recolour the label on top of it, and it cannot do that from
1324    /// `make_body` — the label is built first. `selected_label_role` is
1325    /// the hook; this is both halves of it.
1326    #[derive(Debug, Default, Clone, Copy)]
1327    struct OnAccentSelectionStyle;
1328
1329    impl StandardItemStyle for OnAccentSelectionStyle {
1330        fn make_body(
1331            &self,
1332            cfg: &StandardItemStyleConfig,
1333            ctx: &mut teksilo_core::build_context::BuildContext,
1334        ) -> WidgetId {
1335            crate::styles::RecipeStandardItemStyle::default().make_body(cfg, ctx)
1336        }
1337
1338        fn selected_label_role(&self) -> Option<TextRole> {
1339            Some(TextRole::OnAccent)
1340        }
1341    }
1342
1343    /// The default is `None`, and a row under it keeps `TextRole::Primary`
1344    /// whether or not it is selected — the behaviour every existing style
1345    /// relies on.
1346    #[test]
1347    fn a_style_without_the_hook_leaves_the_selected_label_alone() {
1348        let t = discriminating_theme();
1349        let primary = rgba8(t.colors.text_primary);
1350        let on_accent = rgba8(t.colors.text_on_accent);
1351
1352        let mut tree = WidgetTree::new()
1353            .with_theme(t)
1354            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1355                teksilo_canvas::MockTextBackend::new(),
1356            )));
1357        tree.add(StandardListItem::new(lit!("Row")).selected(Signal::new(true)));
1358        tree.layout(SizeProposal::exact(300.0, 40.0));
1359        let colors = glyph_colors(&mut tree);
1360        assert!(colors.contains(&primary));
1361        assert!(!colors.contains(&on_accent));
1362    }
1363
1364    /// …and a style that declares the hook flips it, but only while the
1365    /// row is *emphasised*.
1366    #[test]
1367    fn the_hook_flips_the_label_of_an_emphasised_row() {
1368        let t = discriminating_theme();
1369        let on_accent = rgba8(t.colors.text_on_accent);
1370
1371        let mut tree = WidgetTree::new()
1372            .with_theme(t)
1373            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1374                teksilo_canvas::MockTextBackend::new(),
1375            )));
1376        tree.add(
1377            StandardListItem::new(lit!("Row"))
1378                .selected(Signal::new(true))
1379                .style(OnAccentSelectionStyle),
1380        );
1381        tree.layout(SizeProposal::exact(300.0, 40.0));
1382        assert!(glyph_colors(&mut tree).contains(&on_accent));
1383    }
1384
1385    /// An *unselected* row must keep its normal label even under a style
1386    /// that declares the hook — otherwise every row in the list would read
1387    /// as chosen.
1388    #[test]
1389    fn the_hook_does_not_touch_an_unselected_row() {
1390        let t = discriminating_theme();
1391        let primary = rgba8(t.colors.text_primary);
1392        let on_accent = rgba8(t.colors.text_on_accent);
1393
1394        let mut tree = WidgetTree::new()
1395            .with_theme(t)
1396            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1397                teksilo_canvas::MockTextBackend::new(),
1398            )));
1399        tree.add(StandardListItem::new(lit!("Row")).style(OnAccentSelectionStyle));
1400        tree.layout(SizeProposal::exact(300.0, 40.0));
1401        let colors = glyph_colors(&mut tree);
1402        assert!(colors.contains(&primary));
1403        assert!(!colors.contains(&on_accent));
1404    }
1405
1406    /// Every foreground on a tree row has to flip together.
1407    ///
1408    /// The chevron is painted by `TwistArrow`, which defaulted to a
1409    /// hardcoded `TextRole::Secondary`. Under a style whose selected row
1410    /// is a solid accent capsule, the label flipped to white and the
1411    /// chevron stayed a grey smudge on the accent — under WCAG 1.4.11's
1412    /// 3:1 floor, and visibly wrong beside the flipped label. The chevron
1413    /// paints a `Path`, not glyphs, so it shows up in `shapes`.
1414    #[test]
1415    fn the_hook_flips_a_tree_rows_chevron_with_its_label() {
1416        let t = discriminating_theme();
1417        let secondary = rgba8(t.colors.text_secondary);
1418        let on_accent = rgba8(t.colors.text_on_accent);
1419
1420        let mut tree = WidgetTree::new()
1421            .with_theme(t)
1422            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1423                teksilo_canvas::MockTextBackend::new(),
1424            )));
1425        tree.add(
1426            StandardTreeItem::new(lit!("Node"))
1427                .has_children(true)
1428                .selected(Signal::new(true))
1429                .style(OnAccentSelectionStyle),
1430        );
1431        tree.layout(SizeProposal::exact(300.0, 40.0));
1432
1433        let shapes: Vec<[u8; 4]> = tree
1434            .render()
1435            .shapes
1436            .iter()
1437            .map(|s| {
1438                let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1439                [q(s.color[0]), q(s.color[1]), q(s.color[2]), q(s.color[3])]
1440            })
1441            .collect();
1442        let paths: Vec<[u8; 4]> = tree
1443            .render()
1444            .paths
1445            .iter()
1446            .map(|p| {
1447                let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1448                [q(p.color[0]), q(p.color[1]), q(p.color[2]), q(p.color[3])]
1449            })
1450            .collect();
1451        let painted: Vec<[u8; 4]> = shapes.into_iter().chain(paths).collect();
1452
1453        assert!(
1454            painted.contains(&on_accent),
1455            "the chevron did not flip with the label; painted {painted:?}"
1456        );
1457        assert!(
1458            !painted.contains(&secondary),
1459            "the chevron is still painting the muted role on an accent capsule"
1460        );
1461    }
1462
1463    /// …and a tree row under a style *without* the hook keeps the muted
1464    /// chevron every other theme expects.
1465    #[test]
1466    fn a_tree_rows_chevron_is_muted_without_the_hook() {
1467        let t = discriminating_theme();
1468        let secondary = rgba8(t.colors.text_secondary);
1469
1470        let mut tree = WidgetTree::new()
1471            .with_theme(t)
1472            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1473                teksilo_canvas::MockTextBackend::new(),
1474            )));
1475        tree.add(
1476            StandardTreeItem::new(lit!("Node"))
1477                .has_children(true)
1478                .selected(Signal::new(true)),
1479        );
1480        tree.layout(SizeProposal::exact(300.0, 40.0));
1481        let paths: Vec<[u8; 4]> = tree
1482            .render()
1483            .paths
1484            .iter()
1485            .map(|p| {
1486                let q = |v: f32| (v.clamp(0.0, 1.0) * 255.0).round() as u8;
1487                [q(p.color[0]), q(p.color[1]), q(p.color[2]), q(p.color[3])]
1488            })
1489            .collect();
1490        assert!(paths.contains(&secondary), "painted {paths:?}");
1491    }
1492
1493    /// A row whose window has gone inactive falls back to the muted
1494    /// `SelectedInactive` capsule, so its label has to fall back too — an
1495    /// on-accent label on a neutral grey would be the worst of both.
1496    #[test]
1497    fn the_hook_reverts_when_the_row_stops_being_emphasised() {
1498        let t = discriminating_theme();
1499        let primary = rgba8(t.colors.text_primary);
1500        let on_accent = rgba8(t.colors.text_on_accent);
1501
1502        let mut tree = WidgetTree::new()
1503            .with_theme(t)
1504            .with_text_backend(std::rc::Rc::new(std::cell::RefCell::new(
1505                teksilo_canvas::MockTextBackend::new(),
1506            )));
1507        tree.add(
1508            StandardListItem::new(lit!("Row"))
1509                .selected(Signal::new(true))
1510                .style(OnAccentSelectionStyle),
1511        );
1512        tree.layout(SizeProposal::exact(300.0, 40.0));
1513        assert!(glyph_colors(&mut tree).contains(&on_accent));
1514
1515        tree.set_window_active(false);
1516        tree.layout(SizeProposal::exact(300.0, 40.0));
1517        let colors = glyph_colors(&mut tree);
1518        assert!(
1519            colors.contains(&primary),
1520            "an inactive window's selected row kept its on-accent label"
1521        );
1522        assert!(!colors.contains(&on_accent));
1523    }
1524
1525    #[test]
1526    fn list_item_layout_single_line() {
1527        let mut tree = WidgetTree::new().with_theme(theme());
1528        let id = tree.add(StandardListItem::new(lit!("Hello")));
1529        tree.layout(SizeProposal {
1530            width: Some(300.0),
1531            height: None,
1532        });
1533        let b = tree.bounds(id);
1534        use crate::styles::recipe_standard_item_style as si;
1535        assert!(b.height >= si::STANDARD_ITEM_MIN_HEIGHT_SINGLE_LINE - 0.5);
1536    }
1537
1538    /// A long subtitle in the default `Wrap` mode reports its full intrinsic
1539    /// width, over-constraining the primary `HStack` — the trailing slot is
1540    /// pushed past the row's right edge and out of the containing card. This
1541    /// pins the behaviour `subtitle_overflow` exists to escape.
1542    #[test]
1543    fn a_wrapping_subtitle_pushes_the_trailing_slot_out_of_the_row() {
1544        const ROW_W: f32 = 680.0;
1545        let mut tree = WidgetTree::new().with_theme(theme());
1546        let row = tree.add(
1547            StandardListItem::new(lit!("2026-07-14 10:05"))
1548                .subtitle(lit!(
1549                    "11 KB · /home/user/Nextcloud/Documents/Books/backups/novel-20260714-100528.skrib"
1550                ))
1551                .trailing_slot(crate::button::Button::new(lit!("Open"))),
1552        );
1553        tree.layout(SizeProposal::exact(ROW_W, 56.0));
1554
1555        let button = tree.find_by_label("Open").expect("trailing button");
1556        assert!(
1557            tree.bounds(button).right() > tree.bounds(row).right(),
1558            "a wrapping subtitle should overflow the row (got button right={}, row right={})",
1559            tree.bounds(button).right(),
1560            tree.bounds(row).right(),
1561        );
1562    }
1563
1564    /// With an ellipsis overflow the subtitle shrinks and truncates inside the
1565    /// row instead, so the trailing actions stay reachable within it.
1566    #[test]
1567    fn an_eliding_subtitle_keeps_the_trailing_slot_inside_the_row() {
1568        const ROW_W: f32 = 680.0;
1569        let mut tree = WidgetTree::new().with_theme(theme());
1570        let row = tree.add(
1571            StandardListItem::new(lit!("2026-07-14 10:05"))
1572                .subtitle(lit!(
1573                    "11 KB · /home/user/Nextcloud/Documents/Books/backups/novel-20260714-100528.skrib"
1574                ))
1575                .subtitle_overflow(TextOverflow::Ellipsis(teksilo_canvas::EllipsisMode::Middle))
1576                .trailing_slot(crate::button::Button::new(lit!("Open"))),
1577        );
1578        tree.layout(SizeProposal::exact(ROW_W, 56.0));
1579
1580        let button = tree.find_by_label("Open").expect("trailing button");
1581        assert!(
1582            tree.bounds(button).right() <= tree.bounds(row).right() + 0.5,
1583            "an elided subtitle must keep the trailing slot inside the row \
1584             (got button right={}, row right={})",
1585            tree.bounds(button).right(),
1586            tree.bounds(row).right(),
1587        );
1588    }
1589
1590    /// The same lever on the tree row, forwarded to the inner list item.
1591    #[test]
1592    fn tree_item_forwards_the_overflow_levers() {
1593        const ROW_W: f32 = 400.0;
1594        let mut tree = WidgetTree::new().with_theme(theme());
1595        let row = tree.add(
1596            StandardTreeItem::new(lit!(
1597                "A very long chapter title that cannot possibly fit this row"
1598            ))
1599            .label_overflow(TextOverflow::Ellipsis(
1600                teksilo_canvas::EllipsisMode::Trailing,
1601            ))
1602            .trailing_slot(crate::button::Button::new(lit!("Open"))),
1603        );
1604        tree.layout(SizeProposal::exact(ROW_W, 56.0));
1605
1606        let button = tree.find_by_label("Open").expect("trailing button");
1607        assert!(
1608            tree.bounds(button).right() <= tree.bounds(row).right() + 0.5,
1609            "an elided label must keep the tree row's trailing slot inside it \
1610             (got button right={}, row right={})",
1611            tree.bounds(button).right(),
1612            tree.bounds(row).right(),
1613        );
1614    }
1615
1616    #[test]
1617    fn selected_item_draws_focus_colour_boundary() {
1618        // WCAG 1.4.1 / 1.4.11 (audit G13): a selected item draws a
1619        // non-color-alone boundary in the focus/accent colour, so selection is
1620        // perceivable beyond the low-contrast surface_selected wash.
1621        let t = theme();
1622        let border = t.colors.border_focused.to_array();
1623        // The boundary is a stroked rounded-rect (a ShapeQuad with stroke_width
1624        // > 0 in the border colour), not a fill.
1625        let has_boundary = |frame: &teksilo_canvas::RenderFrame| {
1626            frame
1627                .shapes
1628                .iter()
1629                .any(|s| s.color == border && s.stroke_width > 0.0)
1630        };
1631
1632        let mut sel = WidgetTree::new().with_theme(t.clone());
1633        sel.add(StandardListItem::new(lit!("X")).selected(true));
1634        sel.layout(SizeProposal::exact(200.0, 40.0));
1635        assert!(
1636            has_boundary(&sel.render()),
1637            "selected item must draw a boundary in the focus/accent colour"
1638        );
1639
1640        let mut plain = WidgetTree::new().with_theme(t);
1641        plain.add(StandardListItem::new(lit!("X")).selected(false));
1642        plain.layout(SizeProposal::exact(200.0, 40.0));
1643        assert!(
1644            !has_boundary(&plain.render()),
1645            "an unselected item draws no such boundary"
1646        );
1647    }
1648
1649    #[test]
1650    fn list_item_layout_two_line() {
1651        let mut tree = WidgetTree::new().with_theme(theme());
1652        let id = tree.add(StandardListItem::new(lit!("Title")).subtitle(lit!("Subtitle text")));
1653        tree.layout(SizeProposal {
1654            width: Some(300.0),
1655            height: None,
1656        });
1657        let b = tree.bounds(id);
1658        use crate::styles::recipe_standard_item_style as si;
1659        assert!(
1660            b.height >= si::STANDARD_ITEM_MIN_HEIGHT_TWO_LINE - 0.5,
1661            "two-line height {} < expected {}",
1662            b.height,
1663            si::STANDARD_ITEM_MIN_HEIGHT_TWO_LINE
1664        );
1665    }
1666
1667    #[test]
1668    fn list_item_a11y_name_is_label_only() {
1669        // Subtitle goes to `description`, not concatenated into the
1670        // name. Lets screen readers present primary vs supplementary
1671        // info distinctly.
1672        let mut tree = WidgetTree::new().with_theme(theme());
1673        let id = tree.add(StandardListItem::new(lit!("Title")).subtitle(lit!("Subtitle")));
1674        tree.layout(SizeProposal::exact(300.0, 100.0));
1675        let info = tree.accessibility_node(id);
1676        assert_eq!(info.name(), Some("Title"));
1677    }
1678
1679    /// **A caller can see when the row is hovered**, which is what lets it reveal
1680    /// controls there. The row writes the signal; the caller only watches it.
1681    #[test]
1682    fn a_shared_interaction_signal_reports_the_rows_hover() {
1683        let state = Signal::new(InteractionState::Idle);
1684        let mut tree = WidgetTree::new().with_theme(theme());
1685        let id =
1686            tree.add(StandardListItem::new(lit!("A result")).interaction_signal(state.clone()));
1687        tree.layout(SizeProposal::exact(300.0, 40.0));
1688        let _ = tree.render();
1689        assert_eq!(state.get(), InteractionState::Idle);
1690
1691        let b = tree.bounds(id);
1692        tree.dispatch_event(teksilo_core::WidgetEvent::PointerMove {
1693            position: teksilo_canvas::Point::new(b.x + b.width / 2.0, b.y + b.height / 2.0),
1694        });
1695        tree.layout(SizeProposal::exact(300.0, 40.0));
1696        let _ = tree.render();
1697        assert_eq!(
1698            state.get(),
1699            InteractionState::Hovered,
1700            "the row shares its own hover state with whoever asked for it"
1701        );
1702    }
1703
1704    /// **A row that draws its own label still has a name.**
1705    ///
1706    /// `label_slot` replaces the drawing, not the label: a search result picking
1707    /// the matched run out of its excerpt is built from runs and cannot be a
1708    /// string, but a row a screen reader cannot name is not an acceptable price
1709    /// for that. The text passed to `new` stays the accessible name.
1710    #[test]
1711    fn a_row_that_draws_its_own_label_keeps_its_accessible_name() {
1712        let mut tree = WidgetTree::new().with_theme(theme());
1713        let id = tree.add(
1714            StandardListItem::new(lit!("she walked across the ice"))
1715                .label_slot(TextWidget::new(lit!("…across the ice"))),
1716        );
1717        tree.layout(SizeProposal::exact(300.0, 100.0));
1718        let info = tree.accessibility_node(id);
1719        assert_eq!(
1720            info.name(),
1721            Some("she walked across the ice"),
1722            "the name comes from the label, not from what was drawn instead of it"
1723        );
1724    }
1725
1726    #[test]
1727    fn list_item_a11y_name_no_subtitle() {
1728        let mut tree = WidgetTree::new().with_theme(theme());
1729        let id = tree.add(StandardListItem::new(lit!("Just a title")));
1730        tree.layout(SizeProposal::exact(300.0, 100.0));
1731        let info = tree.accessibility_node(id);
1732        assert_eq!(info.name(), Some("Just a title"));
1733    }
1734
1735    #[test]
1736    fn list_item_with_checkbox_two_state() {
1737        use teksilo_core::signal::Signal;
1738        let checked = Signal::new(false);
1739        let mut tree = WidgetTree::new().with_theme(theme());
1740        let _id =
1741            tree.add(StandardListItem::new(lit!("Item with checkbox")).checkbox(checked.clone()));
1742        tree.layout(SizeProposal::exact(300.0, 100.0));
1743        // Just verify the build succeeds with the checkbox attached.
1744        // Toggle behavior is exercised by Checkbox's own tests.
1745        assert!(!checked.get());
1746    }
1747
1748    #[test]
1749    fn list_item_with_tristate_checkbox() {
1750        use teksilo_core::signal::Signal;
1751        let state = Signal::new(CheckState::Indeterminate);
1752        let mut tree = WidgetTree::new().with_theme(theme());
1753        let id = tree.add(StandardListItem::new(lit!("Folder")).tristate_checkbox(state.clone()));
1754        tree.layout(SizeProposal::exact(300.0, 100.0));
1755        let b = tree.bounds(id);
1756        assert!(b.width > 0.0);
1757    }
1758
1759    #[test]
1760    fn tree_item_chevron_reserved_for_leaf() {
1761        // Leaf and branch at same depth should produce identical
1762        // outer widths (chevron column reserved).
1763        let mut tree = WidgetTree::new().with_theme(theme());
1764        let leaf = tree.add(
1765            StandardTreeItem::new(lit!("file"))
1766                .depth(1)
1767                .has_children(false),
1768        );
1769        let branch = tree.add(
1770            StandardTreeItem::new(lit!("folder"))
1771                .depth(1)
1772                .has_children(true),
1773        );
1774        tree.layout(SizeProposal::exact(400.0, 200.0));
1775        let bl = tree.bounds(leaf);
1776        let bb = tree.bounds(branch);
1777        assert!((bl.width - bb.width).abs() < 0.5);
1778    }
1779
1780    #[test]
1781    fn twist_arrow_on_click_baseline() {
1782        // Ensure TwistArrow's own on_click(Fn() + 'static) wiring
1783        // works in isolation. If this fires but the StandardTreeItem
1784        // chevron path doesn't, the bug is in the StandardTreeItem
1785        // composition, not in the underlying widgets.
1786        use std::cell::Cell;
1787        use std::rc::Rc;
1788        use teksilo_canvas::Point;
1789        let fired = Rc::new(Cell::new(0u32));
1790        let f = fired.clone();
1791        let mut tree = WidgetTree::new().with_theme(theme());
1792        let id =
1793            tree.add(TwistArrow::new(20.0, true, false).on_click(move |_ctx| f.set(f.get() + 1)));
1794        tree.layout(SizeProposal::exact(40.0, 40.0));
1795        let b = tree.bounds(id);
1796        dispatch_tap(
1797            &mut tree,
1798            Point::new(b.x + b.width * 0.5, b.y + b.height * 0.5),
1799        );
1800        assert_eq!(fired.get(), 1, "TwistArrow.on_click() must fire on tap");
1801    }
1802
1803    #[test]
1804    fn fixed_size_wrapping_twist_arrow_on_tap_baseline() {
1805        // If on_tap on a FixedSize that wraps a TwistArrow works
1806        // here, the issue with StandardTreeItem's chevron is
1807        // composition (parent siblings) — not the chevron-column
1808        // shape itself.
1809        use std::cell::Cell;
1810        use std::rc::Rc;
1811        use teksilo_canvas::Point;
1812        use teksilo_core::widget_builder::WidgetBuilder;
1813        let fired = Rc::new(Cell::new(0u32));
1814        let f = fired.clone();
1815        let mut tree = WidgetTree::new().with_theme(theme());
1816        let id = tree.add(
1817            FixedSize::new()
1818                .width(20.0_f32)
1819                .child(TwistArrow::new(20.0, true, false))
1820                .on_tap(move |_, _| f.set(f.get() + 1)),
1821        );
1822        tree.layout(SizeProposal::exact(40.0, 40.0));
1823        let b = tree.bounds(id);
1824        dispatch_tap(
1825            &mut tree,
1826            Point::new(b.x + b.width * 0.5, b.y + b.height * 0.5),
1827        );
1828        assert_eq!(fired.get(), 1);
1829    }
1830
1831    #[test]
1832    fn fixed_size_on_tap_baseline() {
1833        // Sanity check: confirm `FixedSize::new().on_tap(...)` even
1834        // fires when constructed via the WidgetBuilder chain. If this
1835        // breaks, the StandardTreeItem chevron-tap path is doomed.
1836        use std::cell::Cell;
1837        use std::rc::Rc;
1838        use teksilo_canvas::Point;
1839        use teksilo_core::widget_builder::WidgetBuilder;
1840        let fired = Rc::new(Cell::new(0u32));
1841        let f = fired.clone();
1842        let mut tree = WidgetTree::new().with_theme(theme());
1843        let id = tree.add(
1844            FixedSize::new()
1845                .width(40.0_f32)
1846                .height(40.0_f32)
1847                .child(TextWidget::new(lit!("x")))
1848                .on_tap(move |_, _| f.set(f.get() + 1)),
1849        );
1850        tree.layout(SizeProposal::exact(200.0, 200.0));
1851        let b = tree.bounds(id);
1852        dispatch_tap(&mut tree, Point::new(b.x + 20.0, b.y + 20.0));
1853        assert_eq!(fired.get(), 1);
1854    }
1855
1856    fn dispatch_tap(tree: &mut WidgetTree, position: teksilo_canvas::Point) {
1857        use teksilo_core::event::{Modifiers, PointerButton, WidgetEvent};
1858        tree.dispatch_event(WidgetEvent::PointerDown {
1859            position,
1860            button: PointerButton::Primary,
1861            modifiers: Modifiers::NONE,
1862        });
1863        tree.dispatch_event(WidgetEvent::PointerUp {
1864            position,
1865            button: PointerButton::Primary,
1866            modifiers: Modifiers::NONE,
1867        });
1868    }
1869
1870    #[test]
1871    fn list_item_checkbox_two_state_toggles_via_tap() {
1872        use teksilo_canvas::Point;
1873        let checked = Signal::new(false);
1874        let mut tree = WidgetTree::new().with_theme(theme());
1875        let id = tree.add(StandardListItem::new(lit!("Row")).checkbox(checked.clone()));
1876        tree.layout(SizeProposal::exact(400.0, 60.0));
1877        let bounds = tree.bounds(id);
1878        use crate::styles::recipe_standard_item_style as si;
1879        // Checkbox sits at the row's leading edge, just inside the
1880        // bg_horizontal_inset + padding_horizontal. Tap a few pixels
1881        // in from there so we land on the box visual.
1882        let cb_x = bounds.x
1883            + si::STANDARD_ITEM_BG_HORIZONTAL_INSET
1884            + si::STANDARD_ITEM_PADDING_HORIZONTAL
1885            + 4.0;
1886        let cb_y = bounds.y + bounds.height * 0.5;
1887        dispatch_tap(&mut tree, Point::new(cb_x, cb_y));
1888        assert!(
1889            checked.get(),
1890            "tap on checkbox should flip the bound signal"
1891        );
1892        dispatch_tap(&mut tree, Point::new(cb_x, cb_y));
1893        assert!(!checked.get(), "second tap should flip back");
1894    }
1895
1896    #[test]
1897    fn list_item_row_tap_outside_checkbox_does_not_toggle() {
1898        use teksilo_canvas::Point;
1899        let checked = Signal::new(false);
1900        let mut tree = WidgetTree::new().with_theme(theme());
1901        let id = tree.add(
1902            StandardListItem::new(lit!("A long-enough label so the tap target lands on text"))
1903                .checkbox(checked.clone()),
1904        );
1905        tree.layout(SizeProposal::exact(400.0, 60.0));
1906        let bounds = tree.bounds(id);
1907        // Tap far to the right of the checkbox (well past the
1908        // checkbox column) — should land on the label area.
1909        let label_x = bounds.x + bounds.width * 0.7;
1910        let label_y = bounds.y + bounds.height * 0.5;
1911        dispatch_tap(&mut tree, Point::new(label_x, label_y));
1912        assert!(
1913            !checked.get(),
1914            "tap on row body must not toggle the embedded checkbox"
1915        );
1916    }
1917
1918    #[test]
1919    fn tree_item_chevron_tap_fires_on_toggle() {
1920        use std::cell::Cell;
1921        use std::rc::Rc;
1922        use teksilo_canvas::Point;
1923        let fired = Rc::new(Cell::new(0u32));
1924        let fired_clone = fired.clone();
1925        let mut tree = WidgetTree::new().with_theme(theme());
1926        let id = tree.add(
1927            StandardTreeItem::new(lit!("Folder"))
1928                .depth(0)
1929                .has_children(true)
1930                .is_expanded(false)
1931                .on_toggle(move |_ctx| fired_clone.set(fired_clone.get() + 1)),
1932        );
1933        tree.layout(SizeProposal::exact(400.0, 60.0));
1934        let bounds = tree.bounds(id);
1935        use crate::styles::recipe_standard_item_style as si;
1936        // Inside the row's content padding the chevron column sits at
1937        // `padding_horizontal` (depth=0 → indent=0). Sample its
1938        // center.
1939        let cx = bounds.x
1940            + si::STANDARD_ITEM_PADDING_HORIZONTAL
1941            + si::STANDARD_ITEM_CHEVRON_COLUMN_WIDTH * 0.5;
1942        let cy = bounds.y + bounds.height * 0.5;
1943        dispatch_tap(&mut tree, Point::new(cx, cy));
1944        assert_eq!(
1945            fired.get(),
1946            1,
1947            "tap on chevron column should fire on_toggle exactly once"
1948        );
1949    }
1950
1951    #[test]
1952    fn tristate_checkbox_user_click_never_sets_indeterminate() {
1953        // The user can't set a checkbox to "half" by clicking. The
1954        // tristate cycle on user input is Unchecked ↔ Checked;
1955        // Indeterminate is reserved for model-driven aggregation.
1956        use teksilo_canvas::Point;
1957        let state = Signal::new(CheckState::Unchecked);
1958        let mut tree = WidgetTree::new().with_theme(theme());
1959        let id = tree.add(StandardListItem::new(lit!("Folder")).tristate_checkbox(state.clone()));
1960        tree.layout(SizeProposal::exact(400.0, 60.0));
1961        let bounds = tree.bounds(id);
1962        use crate::styles::recipe_standard_item_style as si;
1963        let cx = bounds.x + si::STANDARD_ITEM_PADDING_HORIZONTAL + 8.0;
1964        let cy = bounds.y + bounds.height * 0.5;
1965        // Click 1: Unchecked → Checked
1966        dispatch_tap(&mut tree, Point::new(cx, cy));
1967        assert_eq!(state.get(), CheckState::Checked);
1968        // Click 2: Checked → Unchecked (NOT Indeterminate)
1969        dispatch_tap(&mut tree, Point::new(cx, cy));
1970        assert_eq!(state.get(), CheckState::Unchecked);
1971        // Click 3: Unchecked → Checked again
1972        dispatch_tap(&mut tree, Point::new(cx, cy));
1973        assert_eq!(state.get(), CheckState::Checked);
1974    }
1975
1976    #[test]
1977    fn tristate_checkbox_user_click_from_indeterminate_goes_to_checked() {
1978        // Common in tree-folder selection: when the parent shows
1979        // partial state (some children checked) and the user clicks
1980        // it, the whole subtree should become checked.
1981        use teksilo_canvas::Point;
1982        let state = Signal::new(CheckState::Indeterminate);
1983        let mut tree = WidgetTree::new().with_theme(theme());
1984        let id = tree.add(StandardListItem::new(lit!("Folder")).tristate_checkbox(state.clone()));
1985        tree.layout(SizeProposal::exact(400.0, 60.0));
1986        let bounds = tree.bounds(id);
1987        use crate::styles::recipe_standard_item_style as si;
1988        let cx = bounds.x + si::STANDARD_ITEM_PADDING_HORIZONTAL + 8.0;
1989        let cy = bounds.y + bounds.height * 0.5;
1990        dispatch_tap(&mut tree, Point::new(cx, cy));
1991        assert_eq!(state.get(), CheckState::Checked);
1992    }
1993
1994    #[test]
1995    fn tree_item_no_toggle_when_no_children() {
1996        use std::cell::Cell;
1997        use std::rc::Rc;
1998        use teksilo_canvas::Point;
1999        let fired = Rc::new(Cell::new(0u32));
2000        let fired_clone = fired.clone();
2001        let mut tree = WidgetTree::new().with_theme(theme());
2002        let id = tree.add(
2003            StandardTreeItem::new(lit!("Leaf"))
2004                .depth(0)
2005                .has_children(false)
2006                .on_toggle(move |_ctx| fired_clone.set(fired_clone.get() + 1)),
2007        );
2008        tree.layout(SizeProposal::exact(400.0, 60.0));
2009        let bounds = tree.bounds(id);
2010        use crate::styles::recipe_standard_item_style as si;
2011        let cx = bounds.x
2012            + si::STANDARD_ITEM_PADDING_HORIZONTAL
2013            + si::STANDARD_ITEM_CHEVRON_COLUMN_WIDTH * 0.5;
2014        let cy = bounds.y + bounds.height * 0.5;
2015        dispatch_tap(&mut tree, Point::new(cx, cy));
2016        assert_eq!(
2017            fired.get(),
2018            0,
2019            "leaf rows must not wire on_toggle even if a callback was set"
2020        );
2021    }
2022
2023    #[test]
2024    fn tree_item_from_entry_sets_depth_and_state() {
2025        use teksilo_data::TreeModel;
2026        let m = TreeModel::<&str>::new();
2027        let root = m.insert_root(0, "r");
2028        let _child = m.insert_child(root, 0, "c");
2029
2030        let entry = FlatEntry {
2031            node_id: root,
2032            depth: 1,
2033            has_children: true,
2034            is_expanded: true,
2035        };
2036        let mut tree = WidgetTree::new().with_theme(theme());
2037        let id = tree.add(StandardTreeItem::new(lit!("x")).from_entry(&entry));
2038        tree.layout(SizeProposal::exact(400.0, 100.0));
2039        assert!(tree.bounds(id).width > 0.0);
2040    }
2041
2042    #[test]
2043    fn list_item_tooltip_appears_on_hover() {
2044        let mut tree = WidgetTree::new().with_theme(theme());
2045        let id = tree.add(StandardListItem::new(lit!("Row")).tooltip(lit!("Tip")));
2046        tree.layout(SizeProposal::exact(300.0, 200.0));
2047        tree.pointer_move(tree.bounds(id).center());
2048        tree.advance_time(std::time::Duration::from_secs(1));
2049        assert_eq!(
2050            tree.active_overlays().len(),
2051            1,
2052            "tooltip should appear on hover"
2053        );
2054        assert!(tree.find_by_label("Tip").is_some());
2055    }
2056
2057    #[test]
2058    fn tree_item_tooltip_appears_on_hover() {
2059        let mut tree = WidgetTree::new().with_theme(theme());
2060        let id = tree.add(StandardTreeItem::new(lit!("Node")).tooltip(lit!("TreeTip")));
2061        tree.layout(SizeProposal::exact(300.0, 200.0));
2062        tree.pointer_move(tree.bounds(id).center());
2063        tree.advance_time(std::time::Duration::from_secs(1));
2064        assert_eq!(
2065            tree.active_overlays().len(),
2066            1,
2067            "tooltip should appear on hover"
2068        );
2069        assert!(tree.find_by_label("TreeTip").is_some());
2070    }
2071}