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