Skip to main content

teksilo_widgets/
split_button.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! SplitButton — a button split into two regions sharing a single frame.
5//!
6//! The left region is the **default action**: it shows the label of the
7//! currently-selected item and, on click, fires that item's command
8//! (behaving like a regular [`Button`](crate::button::Button)). The right
9//! region is a narrow chevron zone that, on click, opens a
10//! [`MenuList`] of related actions. Picking an
11//! action from the dropdown fires it and promotes its index to become the
12//! new default for the session (IntelliJ's "remember last used"
13//! convention).
14//!
15//! SplitButton reuses [`MenuItem`] verbatim
16//! for the dropdown rows — the caller passes real `MenuItem` values via
17//! `.item(...)`, so icons, shortcut labels, enabled flags, and separators
18//! all come for free.
19//!
20//! ```rust
21//! # use teksilo_widgets::{SplitButton, MenuItem, ButtonVariant};
22//! # use teksilo_i18n::lit;
23//! # use teksilo_core::Intent;
24//! let _w = SplitButton::new()
25//!     .item(MenuItem::new(lit!("Run")).on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.run"))))
26//!     .item(MenuItem::new(lit!("Run Tests")).on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.run-tests"))))
27//!     .separator()
28//!     .item(MenuItem::new(lit!("Debug")).on_activate_fn(|ctx| ctx.send_intent(Intent::new("app.debug"))))
29//!     .variant(ButtonVariant::Plain);
30//! ```
31
32use std::rc::Rc;
33use teksilo_i18n::lit;
34
35use teksilo_canvas::{Rect, SizeProposal};
36use teksilo_core::accessibility::AccessNodeBuilder;
37use teksilo_core::binding::BindingLevel;
38use teksilo_core::build_context::BuildContext;
39use teksilo_core::event::{EventResponse, Key, WidgetEvent};
40use teksilo_core::overlay::{DismissBehavior, OverlayLayer, OverlayPlacement, OverlayRequest};
41use teksilo_core::signal::{Prop, Signal};
42use teksilo_core::styles::{SharedSplitButtonStyle, SplitButtonStyle, SplitButtonStyleConfig};
43use teksilo_core::widget::{CursorIcon, EventContext, LayoutContext, Widget, WidgetPlacement};
44use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
45use teksilo_core::widget_id::WidgetId;
46use teksilo_tokens::TextRole;
47
48use crate::button::{ButtonVariant, InteractionState};
49use crate::menu_item::MenuItem;
50use crate::menu_list::MenuList;
51use crate::primitives::{
52    Center, FixedSize, HStack, IconWidget, MinSize, Padding, RectWidget, TextWidget, ZStack,
53};
54use teksilo_i18n::LocalizedString;
55
56/// One row of the SplitButton's dropdown: either a real MenuItem or a
57/// separator. Stored unbuilt until `build()` hands the items to a MenuList.
58/// MenuItem is boxed because it is substantially larger than `Separator`,
59/// which would otherwise bloat every `Row::Separator` slot.
60enum Row {
61    Item(Box<MenuItem>),
62    Separator,
63}
64
65/// SplitButton design tokens.
66pub const SPLIT_BUTTON_HEIGHT: f32 = 24.0;
67pub const SPLIT_BUTTON_MIN_WIDTH: f32 = 72.0;
68pub const SPLIT_BUTTON_PADDING_HORIZONTAL: f32 = 14.0;
69pub const SPLIT_BUTTON_PADDING_VERTICAL: f32 = 0.0;
70pub const SPLIT_BUTTON_CORNER_RADIUS: f32 = 4.0;
71pub const SPLIT_BUTTON_BORDER_WIDTH: f32 = 1.0;
72pub const SPLIT_BUTTON_CHEVRON_WIDTH: f32 = 22.0;
73pub const SPLIT_BUTTON_DIVIDER_WIDTH: f32 = 1.0;
74pub const SPLIT_BUTTON_CHEVRON_ICON_SIZE: f32 = 12.0;
75/// Gap between an optional main-region leading icon and the label.
76pub const SPLIT_BUTTON_ICON_LABEL_GAP: f32 = 6.0;
77
78/// A button split into a default-action region and a chevron dropdown region.
79///
80/// See the [module-level documentation](self) for a usage overview.
81pub struct SplitButton {
82    rows: Vec<Row>,
83    variant: ButtonVariant,
84    /// Per-call Tier-3 chrome override. `None` ⇒ theme slot ⇒ the built-in
85    /// `RecipeSplitButtonStyle`.
86    style_override: Option<SharedSplitButtonStyle>,
87    /// Per-call override for the main-region label text style (font, size,
88    /// weight). `None` ⇒ the inner `TextWidget` default.
89    label_style: Option<teksilo_core::color_prop::TextStyleProp>,
90    /// Per-call override for the main-region label text color. `None` ⇒ the
91    /// variant/interaction-derived cascade; setting this replaces it.
92    text_role_override: Option<teksilo_core::color_prop::ColorProp>,
93    /// Optional leading icon for the main (default-action) region, rendered
94    /// before the label (mirrors `Button`'s `IconLocation::Leading`). The
95    /// dropdown rows carry their own `MenuItem::icon`s independently.
96    icon: Option<IconWidget>,
97    /// Enabled state, static or reactive; forwarded to the arena at build
98    /// time.
99    enabled: Prop<bool>,
100    initial_selected: usize,
101    /// Whether picking an item from the dropdown promotes it to the new
102    /// session default (IntelliJ's "remember last used"). `true` for
103    /// [`SplitButton::new`], `false` for [`SplitButton::new_static`].
104    promote_on_select: bool,
105    /// Tooltip shown on hover over the main (default-action) region.
106    tooltip_text: Option<LocalizedString>,
107    /// Rich tooltip source for the main region (registry key or inline
108    /// content). Mutually exclusive with `tooltip_text` and
109    /// `composite_tooltip_content`.
110    rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
111    /// Composite tooltip body for the main region (CK3-style widget
112    /// tree). Mutually exclusive with the other two main slots.
113    composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
114    /// Tooltip shown on hover over the trailing chevron region. Falls
115    /// back to a generic "Show dropdown menu" label when not explicitly
116    /// set, since the chevron region has no label of its own.
117    chevron_tooltip_text: Option<LocalizedString>,
118    /// Rich tooltip source for the chevron region.
119    chevron_rich_tooltip_source: Option<crate::tooltip::RichTooltipSource>,
120    /// Composite tooltip body for the chevron region.
121    chevron_composite_tooltip_content: Option<Box<dyn teksilo_core::widget::Widget>>,
122    // Build state
123    interaction: Signal<InteractionState>,
124    selected: Signal<usize>,
125    /// Unresolved labels mirrored from the menu items, kept as
126    /// `LocalizedString` (not snapshots) so the main-region label and AT
127    /// name follow a live locale switch — `build` re-resolves them through
128    /// a locale-zipped signal and `accessibility` re-resolves on each walk.
129    labels: Rc<Vec<LocalizedString>>,
130    /// Tracks whether the dropdown overlay is currently visible.
131    /// Drives the accessibility `set_expanded()` state so AT announces
132    /// "collapsed" / "expanded" as the menu opens and closes.
133    menu_open: Signal<bool>,
134    menu_content_id: Option<WidgetId>,
135    root_child_id: Option<WidgetId>,
136}
137
138impl SplitButton {
139    /// Standard SplitButton: picking an item from the dropdown both
140    /// **fires** the item's action and **promotes** it to become the new
141    /// default for the session. The main region's label and click action
142    /// update to match the most recently picked item.
143    pub fn new() -> Self {
144        Self {
145            rows: Vec::new(),
146            variant: ButtonVariant::Plain,
147            style_override: None,
148            label_style: None,
149            text_role_override: None,
150            icon: None,
151            enabled: Prop::Static(true),
152            initial_selected: 0,
153            promote_on_select: true,
154            tooltip_text: None,
155            rich_tooltip_source: None,
156            composite_tooltip_content: None,
157            chevron_tooltip_text: None,
158            chevron_rich_tooltip_source: None,
159            chevron_composite_tooltip_content: None,
160            interaction: Signal::new(InteractionState::Idle),
161            selected: Signal::new(0),
162            labels: Rc::new(Vec::new()),
163            menu_open: Signal::new(false),
164            menu_content_id: None,
165            root_child_id: None,
166        }
167    }
168
169    /// Static-default SplitButton: the main region is pinned to
170    /// `initial_selected` (default 0) and **never** changes after the
171    /// user picks something from the dropdown. Picking an item still
172    /// fires that item's action — only the promotion is skipped.
173    ///
174    /// Use this when the main region represents a semantically fixed
175    /// primary action (e.g. "Commit") and the dropdown offers related
176    /// variants ("Commit and Push", "Commit and Push to…") that should
177    /// not displace the primary.
178    pub fn new_static() -> Self {
179        Self {
180            promote_on_select: false,
181            ..Self::new()
182        }
183    }
184
185    /// Add a menu item. The item is reused verbatim as a row of the
186    /// dropdown, and its label + action are also used to drive the main
187    /// region (when its index is the current default).
188    pub fn item(mut self, item: MenuItem) -> Self {
189        self.rows.push(Row::Item(Box::new(item)));
190        self
191    }
192
193    /// Add a separator row in the dropdown. Separators are skipped when
194    /// computing item indices for `initial_selected`.
195    pub fn separator(mut self) -> Self {
196        self.rows.push(Row::Separator);
197        self
198    }
199
200    /// Set the visual style variant (filled, plain, ghost, …) for the entire
201    /// button frame. Mirrors the same variants as
202    /// [`Button::variant`](crate::button::Button::variant).
203    pub fn variant(mut self, variant: ButtonVariant) -> Self {
204        self.variant = variant;
205        self
206    }
207
208    /// Set a leading icon for the main (default-action) region, rendered before
209    /// the label (mirrors [`Button::icon`](crate::button::Button::icon) with
210    /// `IconLocation::Leading`). Unlike the per-row `MenuItem::icon`s, this glyph
211    /// is fixed regardless of which item is the current default — use it for a
212    /// stable action affordance (e.g. a "+" add glyph).
213    ///
214    /// The icon's tint follows the main-region label (the variant/interaction
215    /// cascade, or [`text_role`](Self::text_role) when overridden), so any
216    /// colour set on the passed `IconWidget` is replaced — same contract as
217    /// `Button`. Its size is left alone, so `.icon_size(..)` on the caller's
218    /// widget is honoured.
219    pub fn icon(mut self, icon: IconWidget) -> Self {
220        self.icon = Some(icon);
221        self
222    }
223
224    /// Override the Tier-3 frame chrome for this instance. Takes precedence
225    /// over `theme.style_slots.split_button` and the built-in
226    /// `RecipeSplitButtonStyle`.
227    pub fn style(mut self, style: impl SplitButtonStyle) -> Self {
228        self.style_override = Some(Rc::new(style));
229        self
230    }
231
232    /// Override the main-region label text style (font, size, weight).
233    /// Accepts a `TextStyleRole`, a `TextStyle`, or a `Signal` of either.
234    /// Default (unset) is the inner `TextWidget` default — e.g. pass
235    /// `TextStyleRole::BodyBold` for a bold default action.
236    pub fn text_style(mut self, style: impl Into<teksilo_core::color_prop::TextStyleProp>) -> Self {
237        self.label_style = Some(style.into());
238        self
239    }
240
241    /// Override the control's text colour — the main-region label, its
242    /// leading [`icon`](Self::icon), and the chevron, which the
243    /// variant/interaction cascade tints together. Accepts `Color`, a role,
244    /// or a `Signal` of either. Default (unset) is that cascade; setting this
245    /// replaces it wholesale (loses hover/disabled tint).
246    pub fn text_role(mut self, color: impl Into<teksilo_core::color_prop::ColorProp>) -> Self {
247        self.text_role_override = Some(color.into());
248        self
249    }
250
251    /// Set the enabled state, statically or reactively. Forwarded to the
252    /// arena at build time.
253    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
254        self.enabled = enabled.into();
255        self
256    }
257
258    /// Which item index (counting only items, not separators) should be
259    /// the initial default. Defaults to 0.
260    pub fn initial_selected(mut self, index: usize) -> Self {
261        self.initial_selected = index;
262        self
263    }
264
265    /// Attach a tooltip to the main (default-action) region. Same hover
266    /// delay as [`Button::tooltip`](crate::button::Button::tooltip).
267    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
268        self.tooltip_text = Some(text.into());
269        self.rich_tooltip_source = None;
270        self.composite_tooltip_content = None;
271        self
272    }
273
274    /// Attach a rich tooltip to the main region.
275    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
276        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
277        self.tooltip_text = None;
278        self.composite_tooltip_content = None;
279        self
280    }
281
282    /// Attach a rich tooltip to the main region driven by inline `TooltipContent`.
283    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
284        self.rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Content(content));
285        self.tooltip_text = None;
286        self.composite_tooltip_content = None;
287        self
288    }
289
290    /// Attach a composite tooltip to the main region.
291    pub fn composite_tooltip(
292        mut self,
293        content: impl teksilo_core::widget::Widget + 'static,
294    ) -> Self {
295        self.composite_tooltip_content = Some(Box::new(content));
296        self.tooltip_text = None;
297        self.rich_tooltip_source = None;
298        self
299    }
300
301    /// Override the tooltip shown on hover over the trailing chevron
302    /// region. When unset, the chevron gets a default "Show dropdown
303    /// menu" tooltip so its affordance isn't silent.
304    pub fn chevron_tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
305        self.chevron_tooltip_text = Some(text.into());
306        self.chevron_rich_tooltip_source = None;
307        self.chevron_composite_tooltip_content = None;
308        self
309    }
310
311    /// Attach a rich tooltip to the chevron region.
312    pub fn chevron_rich_tooltip(mut self, key: impl Into<String>) -> Self {
313        self.chevron_rich_tooltip_source = Some(crate::tooltip::RichTooltipSource::Key(key.into()));
314        self.chevron_tooltip_text = None;
315        self.chevron_composite_tooltip_content = None;
316        self
317    }
318
319    /// Attach a rich tooltip to the chevron region driven by inline `TooltipContent`.
320    pub fn chevron_rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
321        self.chevron_rich_tooltip_source =
322            Some(crate::tooltip::RichTooltipSource::Content(content));
323        self.chevron_tooltip_text = None;
324        self.chevron_composite_tooltip_content = None;
325        self
326    }
327
328    /// Attach a composite tooltip to the chevron region.
329    pub fn chevron_composite_tooltip(
330        mut self,
331        content: impl teksilo_core::widget::Widget + 'static,
332    ) -> Self {
333        self.chevron_composite_tooltip_content = Some(Box::new(content));
334        self.chevron_tooltip_text = None;
335        self.chevron_rich_tooltip_source = None;
336        self
337    }
338}
339
340impl Default for SplitButton {
341    fn default() -> Self {
342        Self::new()
343    }
344}
345
346impl std::fmt::Debug for SplitButton {
347    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
348        f.debug_struct("SplitButton")
349            .field("rows", &self.rows.len())
350            .field("style", &self.variant)
351            .field("enabled", &self.enabled.get())
352            .finish()
353    }
354}
355
356// --- Text-color resolution (variant × state) ---
357//
358// Only the default-action label and chevron icon colour are resolved here;
359// the frame background / border moved to `RecipeSplitButtonStyle`. Mirrors
360// `Button::resolve_text_role` so a Button and a SplitButton with the same
361// variant read identically — keep them in lockstep if Button's text table
362// changes.
363
364// SplitButton normalises the 7-value `ButtonVariant` down to the three
365// buckets it knows how to paint: `Filled` family (Filled / Destructive),
366// `Plain` family (Plain / Tinted / Outlined), `Ghost` family (Ghost / Link).
367// `classify` is shared with the Tier-3 `RecipeSplitButtonStyle` (frame
368// background / border) so the frame and the widget-owned text colour stay in
369// lockstep; the widget keeps `resolve_text_role` (mirrors how `Button` keeps
370// its own text-role resolution while delegating chrome to `ButtonStyle`).
371#[derive(Copy, Clone, Eq, PartialEq)]
372#[allow(clippy::enum_variant_names)]
373pub(crate) enum SplitButtonFamily {
374    FilledLike,
375    PlainLike,
376    GhostLike,
377}
378
379pub(crate) fn classify(variant: ButtonVariant) -> SplitButtonFamily {
380    match variant {
381        ButtonVariant::Filled | ButtonVariant::Destructive => SplitButtonFamily::FilledLike,
382        ButtonVariant::Plain | ButtonVariant::Tinted | ButtonVariant::Outlined => {
383            SplitButtonFamily::PlainLike
384        }
385        ButtonVariant::Ghost | ButtonVariant::Link => SplitButtonFamily::GhostLike,
386    }
387}
388
389fn resolve_text_role(variant: ButtonVariant, state: InteractionState) -> TextRole {
390    if state == InteractionState::Disabled {
391        return TextRole::Disabled;
392    }
393    match classify(variant) {
394        SplitButtonFamily::FilledLike => TextRole::OnAccent,
395        SplitButtonFamily::PlainLike | SplitButtonFamily::GhostLike => TextRole::Primary,
396    }
397}
398
399impl Widget for SplitButton {
400    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
401        let variant = self.variant;
402        let self_id = ctx.self_id();
403        // Forward the enabled state into the arena; see IconButton.
404        ctx.enabled_when(self_id, self.enabled.clone());
405        // Drives the style's reactive `is_disabled` (custom chrome may dim
406        // the frame). The recipe default leaves the frame undimmed and the
407        // leaves substitute disabled colours in their own paint.
408        let effective_enabled = ctx.effective_enabled_signal(self_id);
409
410        // Resolve the active frame chrome: per-call override > theme slot >
411        // built-in `RecipeSplitButtonStyle`.
412        let split_style: SharedSplitButtonStyle = self
413            .style_override
414            .clone()
415            .or_else(|| ctx.theme().style_slots.split_button.clone())
416            .unwrap_or_else(|| Rc::new(crate::styles::RecipeSplitButtonStyle));
417
418        // ---- Extract label / action for each MenuItem and wrap each item's
419        // activation so selecting it from the menu also promotes its index
420        // to the current default. ----
421
422        let mut labels_vec: Vec<LocalizedString> = Vec::new();
423        let mut actions_vec: Vec<Option<Rc<dyn Fn(&mut EventContext)>>> = Vec::new();
424        // Split button menus always open Below the trigger (the chevron
425        // half lives at the bottom-right of the button), so the menu's
426        // top edge is attached to the trigger.
427        let mut menu = MenuList::new().attached_side(crate::shadow::AttachedSide::Top);
428
429        // Create the `selected` signal early so the wrap closures can
430        // capture it.
431        let initial = self.initial_selected;
432        let selected: Signal<usize> = ctx.signal(initial);
433        let promote_on_select = self.promote_on_select;
434
435        for row in self.rows.drain(..) {
436            match row {
437                Row::Item(boxed_item) => {
438                    let mut item = *boxed_item;
439                    let label = item.label_localized();
440                    let action = item.action();
441                    let my_index = labels_vec.len();
442                    labels_vec.push(label);
443                    actions_vec.push(action.clone());
444
445                    // Only wrap the item's activation when we need to
446                    // promote the selected index. In static mode we hand
447                    // the MenuItem through untouched so its original
448                    // action runs as-is — no redirection, no extra Rc
449                    // churn, and the MenuItem's existing tests still
450                    // hold for the inner behavior.
451                    if promote_on_select {
452                        let prev_action = action.clone();
453                        let promote = selected.clone();
454                        item = item.on_activate_fn(move |ctx: &mut EventContext| {
455                            if let Some(ref a) = prev_action {
456                                a(ctx);
457                            }
458                            promote.set(my_index);
459                        });
460                    }
461                    menu = menu.item(item);
462                }
463                Row::Separator => {
464                    menu = menu.separator();
465                }
466            }
467        }
468
469        // Clamp initial_selected to a valid range now that we know the count.
470        let item_count = labels_vec.len();
471        if item_count == 0 || selected.get() >= item_count {
472            selected.set(0);
473        }
474
475        let labels_rc = Rc::new(labels_vec);
476        let actions_rc: Rc<Vec<Option<Rc<dyn Fn(&mut EventContext)>>>> = Rc::new(actions_vec);
477
478        self.labels = labels_rc.clone();
479        self.selected = selected.clone();
480
481        // ---- Menu-open tracker (drives accessibility set_expanded). ----
482        // `selected` also feeds the a11y name, so bind it AccessibilityOnly
483        // so AT updates when the promoted item changes without relayout.
484        let menu_open = self.menu_open.clone();
485        let self_id_for_bindings = ctx.self_id();
486        menu_open.bind_to(
487            self_id_for_bindings,
488            ctx.binding_registry(),
489            BindingLevel::AccessibilityOnly,
490        );
491        selected.bind_to(
492            self_id_for_bindings,
493            ctx.binding_registry(),
494            BindingLevel::AccessibilityOnly,
495        );
496
497        // ---- Interaction state signal ----
498        // Seeded to Idle; never carries Disabled. The framework gates
499        // event dispatch on `arena.is_enabled(self_id)`, so disabled
500        // SplitButtons simply don't receive events. Style chrome
501        // reads `is_disabled` from `effective_enabled` if needed.
502        let interaction = ctx.signal(InteractionState::Idle);
503        self.interaction = interaction.clone();
504
505        // Subtree hover signal — the framework writes `true` whenever the
506        // pointer is over a strict descendant of the row container (main
507        // region, divider, or chevron region). Replaces per-region
508        // `on_hover` handlers with a single `hover_within` binding on the
509        // row HStack below.
510        let hovered_signal = ctx.signal(false);
511        ctx.effect(&hovered_signal, {
512            let interaction = interaction.clone();
513            move |entered| {
514                // Pressed / Focused are owned by on_tap, on_key,
515                // and on_focus; only flip the ambient Idle <-> Hovered pair.
516                match interaction.get() {
517                    InteractionState::Pressed
518                    | InteractionState::Focused
519                    | InteractionState::Disabled => {}
520                    _ => {
521                        interaction.set(if *entered {
522                            InteractionState::Hovered
523                        } else {
524                            InteractionState::Idle
525                        });
526                    }
527                }
528            }
529        });
530
531        // ---- Derived reactive text role (frame bg/border live in the style) ----
532        // Text colour stays a widget concern (mirrors Button's
533        // `resolve_text_role`); it tints the default-action label, its leading
534        // icon, and the chevron — all three go through `label_color` below, so
535        // a `text_role(..)` override replaces the cascade for the whole
536        // control. The frame background / border is resolved inside the active
537        // `SplitButtonStyle` from the interaction bools built below.
538        let text_role = interaction.map(move |s| resolve_text_role(variant, *s));
539        // The divider is a RectWidget used as a 1-dp vertical rule; role-based
540        // so it follows theme changes without an intermediate signal.
541
542        // ---- Main-region label bound to `selected` ----
543        let main_label_text = {
544            let labels = labels_rc.clone();
545            // Zip the locale signal so the displayed default-action label
546            // re-resolves on a locale switch, not only on selection change.
547            selected.zip(&ctx.locale_signal()).map(move |(i, _)| {
548                if labels.is_empty() {
549                    String::new()
550                } else {
551                    labels[(*i).min(labels.len() - 1)].resolve_now()
552                }
553            })
554        };
555
556        // ---- Pre-register the menu overlay (dormant until opened) ----
557        // Built the first time the popup is opened, not on every rebuild of the
558        // field. See `teksilo_core::deferred_subtree::DeferredSubtree`.
559        let menu_id = ctx.add_deferred(self.menu_open.clone(), menu);
560        ctx.set_dormant(menu_id);
561        self.menu_content_id = Some(menu_id);
562
563        let self_id = ctx.self_id();
564
565        // ---- Main region subtree ----
566        let label_color: teksilo_core::color_prop::ColorProp = self
567            .text_role_override
568            .clone()
569            .unwrap_or_else(|| text_role.clone().into());
570        let mut label_widget = TextWidget::new(lit!(""))
571            .text(main_label_text)
572            .color(label_color.clone())
573            .single_line()
574            .a11y_hidden();
575        if let Some(style) = &self.label_style {
576            label_widget = label_widget.style(style.clone());
577        }
578        let label_id = ctx.add(label_widget);
579
580        // Optional leading icon in the main region: `[icon, gap, label]` inside
581        // the padding (mirrors Button's `IconLocation::Leading`). When no icon is
582        // set, the label goes straight into the padding — node count unchanged.
583        //
584        // The glyph is tinted with the *label's* colour, exactly as
585        // `Button::make_icon` does — an untinted icon keeps `IconWidget`'s
586        // default `TextRole::Primary`, which silently matches on a light theme
587        // (`text_primary` and `text_on_accent` are both black) and then paints
588        // near-white on the accent fill in dark mode.
589        let main_inner_id = if let Some(icon) = self.icon.take() {
590            let icon_id = ctx.add(icon.color(label_color.clone()));
591            ctx.add(
592                HStack::new()
593                    .spacing(SPLIT_BUTTON_ICON_LABEL_GAP)
594                    .add_child(icon_id)
595                    .add_child(label_id),
596            )
597        } else {
598            label_id
599        };
600
601        let main_padding_id = ctx.add(
602            Padding::symmetric(
603                SPLIT_BUTTON_PADDING_VERTICAL,
604                SPLIT_BUTTON_PADDING_HORIZONTAL,
605            )
606            .child_id(main_inner_id),
607        );
608        // ZStack (default CENTER alignment) centers the padded label within
609        // the MinSize bounds when the region is wider than the text — same
610        // pattern Button uses. Without this, MinSize stretches Padding to
611        // fill and the label pins to the top-left inset corner.
612        let main_content_id = ctx.add(ZStack::new().add_child(main_padding_id));
613
614        let main_region = {
615            let actions_for_tap = actions_rc.clone();
616            let selected_for_tap = selected.clone();
617            MinSize::new(SPLIT_BUTTON_MIN_WIDTH, SPLIT_BUTTON_HEIGHT)
618                .child_id(main_content_id)
619                .on_tap(move |_pos, ctx: &mut EventContext| {
620                    let idx = selected_for_tap.get();
621                    if let Some(Some(action)) = actions_for_tap.get(idx) {
622                        action(ctx);
623                    }
624                })
625                .cursor(CursorIcon::Pointer)
626        };
627        let main_region_id = ctx.add(main_region);
628
629        // Attach the main-region tooltip if configured. Three
630        // mutually-exclusive setters; setters clear the others.
631        if let Some(content) = self.composite_tooltip_content.take() {
632            let delay = ctx.theme().motion.tooltip_delay_heavy;
633            crate::tooltip::attach_composite_tooltip_boxed(ctx, main_region_id, content, delay);
634        } else if let Some(source) = self.rich_tooltip_source.take() {
635            let delay = ctx.theme().motion.tooltip_delay;
636            crate::tooltip::attach_rich_tooltip_source(ctx, main_region_id, source, delay);
637        } else if let Some(text) = self.tooltip_text.clone() {
638            let delay = ctx.theme().motion.tooltip_delay;
639            crate::tooltip::attach_plain_tooltip(ctx, main_region_id, text, delay);
640        }
641
642        // ---- Divider between main and chevron regions ----
643        let divider_fill_id =
644            ctx.add(RectWidget::new().background(teksilo_tokens::BorderRole::Default));
645        let divider_id = ctx.add(
646            FixedSize::new()
647                .width(SPLIT_BUTTON_DIVIDER_WIDTH)
648                .height(SPLIT_BUTTON_HEIGHT)
649                .child_id(divider_fill_id),
650        );
651
652        // ---- Chevron region ----
653        // Tinted with `label_color`, not the raw `text_role` cascade: the
654        // cascade is control-wide (one `interaction` signal fed by
655        // `hover_within` across main region + divider + chevron), so a
656        // `text_role(..)` override that reached only the main region would
657        // split a previously-unified tint — a `.text_role(Error)` Filled
658        // button would paint a red label beside a black chevron.
659        let chevron_icon_id = ctx.add(
660            IconWidget::chevron_down(SPLIT_BUTTON_CHEVRON_ICON_SIZE).color(label_color.clone()),
661        );
662        let chevron_centered_id = ctx.add(Center::new().child_id(chevron_icon_id));
663
664        let chevron_region = {
665            let int_for_tap = interaction.clone();
666            FixedSize::new()
667                .width(SPLIT_BUTTON_CHEVRON_WIDTH)
668                .height(SPLIT_BUTTON_HEIGHT)
669                .child_id(chevron_centered_id)
670                .on_tap({
671                    let menu_open = self.menu_open.clone();
672                    move |_pos, ctx: &mut EventContext| {
673                        int_for_tap.set(InteractionState::Pressed);
674                        // Build the popup if this is its first open, before the overlay
675                        // below is measured against it and focus moves into it.
676                        ctx.materialize_now(menu_id);
677                        ctx.activate(menu_id);
678                        menu_open.set(true);
679                        let on_dismiss_open = menu_open.clone();
680                        ctx.show_overlay(OverlayRequest {
681                            content_id: menu_id,
682                            anchor: self_id,
683                            placement: OverlayPlacement::BelowPreferred,
684                            dismiss: DismissBehavior::EscapeOrClickOutside,
685                            layer: OverlayLayer::InTree,
686                            parent_overlay: None,
687                            on_dismiss: Some(Rc::new(move || on_dismiss_open.set(false))),
688                            fade_duration: None,
689                        });
690                        // The MenuList owns the keyboard-navigation handler
691                        // (ArrowUp/ArrowDown/Enter/Escape) and that handler
692                        // only fires when the MenuList is focused. Hand focus
693                        // over so the user can immediately keyboard-walk the
694                        // items they just opened.
695                        ctx.request_focus(menu_id);
696                    }
697                })
698                .cursor(CursorIcon::Pointer)
699        };
700        let chevron_region_id = ctx.add(chevron_region);
701
702        // Attach the chevron tooltip. Defaults to "Show dropdown menu"
703        // so the bare ▾ affordance is never silent — the caller can
704        // override via `.chevron_tooltip(...)` (plain),
705        // `.chevron_rich_tooltip(...)`, or `.chevron_composite_tooltip(...)`.
706        if let Some(content) = self.chevron_composite_tooltip_content.take() {
707            let delay = ctx.theme().motion.tooltip_delay_heavy;
708            crate::tooltip::attach_composite_tooltip_boxed(ctx, chevron_region_id, content, delay);
709        } else if let Some(source) = self.chevron_rich_tooltip_source.take() {
710            let delay = ctx.theme().motion.tooltip_delay;
711            crate::tooltip::attach_rich_tooltip_source(ctx, chevron_region_id, source, delay);
712        } else {
713            let chevron_text = self
714                .chevron_tooltip_text
715                .clone()
716                .unwrap_or_else(|| lit!("Show dropdown menu"));
717            let delay = ctx.theme().motion.tooltip_delay;
718            crate::tooltip::attach_plain_tooltip(ctx, chevron_region_id, chevron_text, delay);
719        }
720
721        // ---- Row: main | divider | chevron ----
722        // `hover_within` writes `hovered_signal` whenever the pointer is
723        // over a strict descendant of this HStack — i.e. main_region,
724        // divider, or chevron_region — driving the unified Hovered halo.
725        // This assembled row is the interactive `content` the style frames.
726        let content_id = ctx.add(
727            HStack::new()
728                .spacing(0.0)
729                .add_child(main_region_id)
730                .add_child(divider_id)
731                .add_child(chevron_region_id)
732                .hover_within(hovered_signal),
733        );
734
735        // ---- Delegate the shared frame chrome to the Tier-3 style ----
736        // The style owns the background fill, border, corner radius, and
737        // overall min size; we hand it the interactive row plus the live
738        // interaction bools (derived from the single `interaction` enum,
739        // which carries exactly one transient state at a time).
740        let cfg = SplitButtonStyleConfig {
741            content: content_id,
742            is_pressed: interaction.map(|s| *s == InteractionState::Pressed),
743            is_hovered: interaction.map(|s| *s == InteractionState::Hovered),
744            // `:focus-visible`: keyboard-only focus ring (gate raw focus on
745            // the input-modality signal).
746            is_focused: interaction
747                .map(|s| *s == InteractionState::Focused)
748                .and(&ctx.focus_visible()),
749            is_disabled: effective_enabled.map(|on| !*on),
750            variant,
751        };
752        let root_id = split_style.make_body(&cfg, ctx);
753        self.root_child_id = Some(root_id);
754
755        // ---- Self handlers: the SplitButton is the single focus stop.
756        // Space/Enter fires the current default; ArrowDown opens the menu.
757        let actions_for_key = actions_rc.clone();
758        let selected_for_key = selected.clone();
759        let int_for_key = interaction.clone();
760        let int_for_focus = interaction.clone();
761        let menu_open_for_key = self.menu_open.clone();
762
763        let handler_set = HandlerSet::new()
764            .on_key(
765                move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
766                    match event {
767                        WidgetEvent::KeyDown {
768                            key: Key::Space | Key::Enter,
769                            ..
770                        } => {
771                            int_for_key.set(InteractionState::Pressed);
772                            EventResponse::Handled
773                        }
774                        WidgetEvent::KeyUp {
775                            key: Key::Space | Key::Enter,
776                            ..
777                        } => {
778                            // Lone-KeyUp guard: only fire if we saw the
779                            // matching KeyDown (state is Pressed). A lone
780                            // KeyUp means the KeyDown was consumed
781                            // elsewhere (shortcut, focus transfer) and
782                            // this widget is not the activation target.
783                            // Mirrors `build_interaction_handlers`.
784                            if int_for_key.get() != InteractionState::Pressed {
785                                return EventResponse::Ignored;
786                            }
787                            let idx = selected_for_key.get();
788                            if let Some(Some(action)) = actions_for_key.get(idx) {
789                                action(ctx);
790                            }
791                            int_for_key.set(InteractionState::Focused);
792                            EventResponse::Handled
793                        }
794                        // ArrowDown alone, or Alt+ArrowDown (the native
795                        // "open dropdown" shortcut) both open the menu.
796                        WidgetEvent::KeyDown {
797                            key: Key::ArrowDown,
798                            ..
799                        } => {
800                            // Build the popup if this is its first open, before the overlay
801                            // below is measured against it and focus moves into it.
802                            ctx.materialize_now(menu_id);
803                            ctx.activate(menu_id);
804                            menu_open_for_key.set(true);
805                            let on_dismiss_key = menu_open_for_key.clone();
806                            ctx.show_overlay(OverlayRequest {
807                                content_id: menu_id,
808                                anchor: self_id,
809                                placement: OverlayPlacement::BelowPreferred,
810                                dismiss: DismissBehavior::EscapeOrClickOutside,
811                                layer: OverlayLayer::InTree,
812                                parent_overlay: None,
813                                on_dismiss: Some(Rc::new(move || on_dismiss_key.set(false))),
814                                fade_duration: None,
815                            });
816                            ctx.request_focus(menu_id);
817                            EventResponse::Handled
818                        }
819                        _ => EventResponse::Ignored,
820                    }
821                },
822            )
823            .on_focus(move |gained: bool, _ctx: &mut EventContext| {
824                if gained {
825                    if int_for_focus.get() == InteractionState::Idle {
826                        int_for_focus.set(InteractionState::Focused);
827                    }
828                } else {
829                    int_for_focus.set(InteractionState::Idle);
830                }
831            })
832            // `accessibility` exposes ONE node (this one) advertising
833            // `Action::Click`, but the pointer handlers live on the
834            // descendant main / chevron regions — an AT click dispatched
835            // to this node never reaches them (preview walks strict
836            // ancestors, bubble walks target → root; neither descends).
837            // Fire the current default action, mirroring Enter/Space.
838            .on_access_action({
839                let actions = actions_rc.clone();
840                let selected = selected.clone();
841                move |action, ctx: &mut EventContext| {
842                    if action == teksilo_core::accesskit::Action::Click {
843                        if let Some(Some(default_action)) = actions.get(selected.get()) {
844                            default_action(ctx);
845                        }
846                        EventResponse::Handled
847                    } else {
848                        EventResponse::Ignored
849                    }
850                }
851            })
852            .focusable(true);
853
854        ctx.apply_self_handlers(handler_set);
855
856        // Return BOTH the visible root AND the dormant menu content so
857        // the framework links `menu_id` under this SplitButton in the
858        // arena. Without this the menu stays an orphan root: it leaks on
859        // `destroy_subtree` (never reached from this widget's child list)
860        // and `arena.hit_test_at` walks its subtree on every click even
861        // while dormant. Mirrors `PopoverButton::build`. The layout pass
862        // skips dormant children automatically; `place_children` zeroes
863        // the slot if it ever surfaces active.
864        vec![root_id, menu_id]
865    }
866
867    fn layout_response(
868        &self,
869        proposal: SizeProposal,
870        ctx: &LayoutContext,
871    ) -> teksilo_core::widget::LayoutResponse {
872        match self.root_child_id {
873            Some(id) => ctx
874                .child_size(id, proposal)
875                .unwrap_or_else(|| proposal.resolve(0.0, 0.0)),
876            None => proposal.resolve(0.0, 0.0),
877        }
878        .into()
879    }
880
881    fn place_children(
882        &self,
883        bounds: Rect,
884        _proposal: SizeProposal,
885        children: &mut [WidgetPlacement],
886        _ctx: &LayoutContext,
887    ) {
888        // The visible row fills our bounds; the menu content is owned by
889        // the overlay manager when shown and stays zero-sized otherwise.
890        // Dormant children are filtered out before placements reach here;
891        // zero the menu slot defensively if it ever surfaces active so we
892        // don't clobber the overlay's own positioning.
893        for child in children.iter_mut() {
894            if Some(child.id) == self.menu_content_id {
895                child.size = teksilo_canvas::Size::ZERO;
896                continue;
897            }
898            child.origin = bounds.origin();
899            child.size = bounds.size();
900        }
901    }
902
903    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
904        builder.set_role(teksilo_core::accesskit::Role::Button);
905        if !self.labels.is_empty() {
906            let idx = self.selected.get().min(self.labels.len() - 1);
907            builder.set_name(self.labels[idx].resolve_now());
908        }
909        // Framework a11y walker sets `set_disabled` from arena state.
910        builder.set_has_popup(teksilo_core::accesskit::HasPopup::Menu);
911        builder.set_expanded(self.menu_open.get());
912        builder.add_action(teksilo_core::accesskit::Action::Click);
913        builder.add_action(teksilo_core::accesskit::Action::Focus);
914    }
915
916    fn children(&self) -> Vec<WidgetId> {
917        // Include the dormant menu content alongside the visible root so
918        // `set_dormant` cascades correctly and `arena.hit_test_at` can
919        // prune the menu subtree when it isn't visible.
920        let mut out = Vec::new();
921        if let Some(id) = self.root_child_id {
922            out.push(id);
923        }
924        if let Some(id) = self.menu_content_id {
925            out.push(id);
926        }
927        out
928    }
929}
930
931#[cfg(test)]
932mod tests {
933    use super::*;
934    use std::cell::Cell as StdCell;
935    use std::rc::Rc as StdRc;
936    use teksilo_core::event::Modifiers;
937    use teksilo_core::widget_tree::WidgetTree;
938
939    fn themed_tree() -> WidgetTree {
940        WidgetTree::new().with_theme(teksilo_core::presets::intui::light())
941    }
942
943    /// Regression: the dropdown menu must be linked as a child of the
944    /// SplitButton, not left as an orphan arena root. An orphan root
945    /// leaks on `destroy_subtree` (never reached from the widget's child
946    /// list) and is walked by `hit_test_at` on every click. Mirrors the
947    /// content-linking contract `PopoverButton` documents.
948    #[test]
949    fn menu_content_is_linked_as_child_not_orphan_root() {
950        let mut tree = themed_tree();
951        let split = tree.add(
952            SplitButton::new()
953                .item(MenuItem::new(lit!("Save")))
954                .item(MenuItem::new(lit!("Save As"))),
955        );
956        tree.layout(SizeProposal::exact(300.0, 60.0));
957
958        let children = tree.children(split);
959        assert_eq!(
960            children.len(),
961            2,
962            "SplitButton must expose both the visible root and the dormant menu"
963        );
964        let menu_id = children[1];
965        assert_eq!(
966            tree.parent(menu_id),
967            Some(split),
968            "menu must be parented under the SplitButton, not left an orphan root"
969        );
970    }
971
972    /// Enter activates the *currently-selected* item's action — the core
973    /// SplitButton contract (the primary region fires the default).
974    #[test]
975    fn enter_fires_current_default_action() {
976        let fired: StdRc<StdCell<Option<usize>>> = StdRc::new(StdCell::new(None));
977        let (f0, f1) = (fired.clone(), fired.clone());
978        let mut tree = themed_tree();
979        let split = tree.add(
980            SplitButton::new()
981                .initial_selected(1)
982                .item(MenuItem::new(lit!("A")).on_activate_fn(move |_| f0.set(Some(0))))
983                .item(MenuItem::new(lit!("B")).on_activate_fn(move |_| f1.set(Some(1)))),
984        );
985        tree.layout(SizeProposal::exact(300.0, 60.0));
986        tree.focus(split);
987        tree.press_key(Key::Enter, Modifiers::NONE);
988        assert_eq!(
989            fired.get(),
990            Some(1),
991            "Enter must fire the currently-selected item's action"
992        );
993    }
994
995    /// The SplitButton exposes ONE a11y node advertising `Action::Click`,
996    /// but the pointer handlers live on descendant regions the dispatch
997    /// never reaches. An AT / automation click must therefore fire the
998    /// current default action, exactly like Enter.
999    #[test]
1000    fn access_click_fires_current_default_action() {
1001        let fired: StdRc<StdCell<Option<usize>>> = StdRc::new(StdCell::new(None));
1002        let (f0, f1) = (fired.clone(), fired.clone());
1003        let mut tree = themed_tree();
1004        let split = tree.add(
1005            SplitButton::new()
1006                .initial_selected(1)
1007                .item(MenuItem::new(lit!("A")).on_activate_fn(move |_| f0.set(Some(0))))
1008                .item(MenuItem::new(lit!("B")).on_activate_fn(move |_| f1.set(Some(1)))),
1009        );
1010        tree.layout(SizeProposal::exact(300.0, 60.0));
1011        tree.dispatch_event(teksilo_core::event::WidgetEvent::AccessAction {
1012            action: teksilo_core::accesskit::Action::Click,
1013            target: Some(split),
1014            target_node: teksilo_core::accessibility::root_node_id(),
1015            data: None,
1016        });
1017        assert_eq!(
1018            fired.get(),
1019            Some(1),
1020            "AT click must fire the currently-selected item's action"
1021        );
1022    }
1023
1024    /// A lone Space/Enter KeyUp (no preceding KeyDown) must NOT fire the
1025    /// default action — the lone-KeyUp guard, matching the rest of the
1026    /// button family.
1027    #[test]
1028    fn lone_keyup_does_not_fire_default_action() {
1029        let fired: StdRc<StdCell<u32>> = StdRc::new(StdCell::new(0));
1030        let f = fired.clone();
1031        let mut tree = themed_tree();
1032        let split = tree.add(
1033            SplitButton::new()
1034                .item(MenuItem::new(lit!("A")).on_activate_fn(move |_| f.set(f.get() + 1))),
1035        );
1036        tree.layout(SizeProposal::exact(300.0, 60.0));
1037        tree.focus(split);
1038
1039        // Lone KeyUp — must be a no-op.
1040        tree.dispatch_event(teksilo_core::event::WidgetEvent::KeyUp {
1041            key: Key::Enter,
1042            modifiers: Modifiers::NONE,
1043        });
1044        assert_eq!(
1045            fired.get(),
1046            0,
1047            "lone KeyUp must not fire the default action"
1048        );
1049
1050        // Sanity: a full KeyDown+KeyUp DOES fire.
1051        tree.press_key(Key::Enter, Modifiers::NONE);
1052        assert_eq!(
1053            fired.get(),
1054            1,
1055            "full KeyDown+KeyUp fires the default action"
1056        );
1057    }
1058
1059    /// A per-call `.style(...)` override is consulted: the custom
1060    /// `SplitButtonStyle::make_body` runs and frames the interactive content.
1061    #[test]
1062    fn custom_style_make_body_is_invoked() {
1063        struct MarkerStyle(StdRc<StdCell<bool>>);
1064        impl SplitButtonStyle for MarkerStyle {
1065            fn make_body(&self, cfg: &SplitButtonStyleConfig, _ctx: &mut BuildContext) -> WidgetId {
1066                self.0.set(true);
1067                // Frame the pre-built interactive row verbatim.
1068                cfg.content
1069            }
1070        }
1071
1072        let fired = StdRc::new(StdCell::new(false));
1073        let mut tree = themed_tree();
1074        tree.add(
1075            SplitButton::new()
1076                .item(MenuItem::new(lit!("A")))
1077                .style(MarkerStyle(fired.clone())),
1078        );
1079        tree.layout(SizeProposal::exact(300.0, 60.0));
1080        assert!(
1081            fired.get(),
1082            "a per-call SplitButtonStyle override must drive the frame chrome"
1083        );
1084    }
1085
1086    /// ArrowDown opens the dropdown menu overlay.
1087    #[test]
1088    fn arrow_down_opens_the_menu() {
1089        let mut tree = themed_tree();
1090        let split = tree.add(
1091            SplitButton::new()
1092                .item(MenuItem::new(lit!("A")))
1093                .item(MenuItem::new(lit!("B"))),
1094        );
1095        tree.layout(SizeProposal::exact(300.0, 60.0));
1096        tree.focus(split);
1097        assert!(tree.active_overlays().is_empty());
1098        tree.press_key(Key::ArrowDown, Modifiers::NONE);
1099        assert_eq!(
1100            tree.active_overlays().len(),
1101            1,
1102            "ArrowDown must open the dropdown menu overlay"
1103        );
1104    }
1105
1106    /// How many path leaves in the rendered frame paint at `expected`. The
1107    /// leading icon and the chevron are the glyphs a SplitButton draws; the
1108    /// label is a text run, so it never shows up here.
1109    fn paths_colored(frame: &teksilo_canvas::RenderFrame, expected: [f32; 4]) -> usize {
1110        frame.paths.iter().filter(|p| p.color == expected).count()
1111    }
1112
1113    /// Regression: the main region's leading icon must be tinted with the
1114    /// label's colour, not left on `IconWidget`'s default `TextRole::Primary`.
1115    ///
1116    /// This only shows up on a dark theme. In `intui::light` `text_primary`
1117    /// and `text_on_accent` are *both* `#000000`, so an untinted glyph looks
1118    /// correct by coincidence; in `intui::dark` `text_primary` is `#DFE1E5`
1119    /// against a black `text_on_accent`, so the untinted "+" painted white on
1120    /// the accent fill while the label beside it stayed black.
1121    #[test]
1122    fn filled_leading_icon_is_tinted_like_the_label_in_dark_mode() {
1123        let theme = teksilo_core::presets::intui::dark();
1124        let mut tree = WidgetTree::new().with_theme(theme.clone());
1125        tree.add(
1126            SplitButton::new_static()
1127                .variant(ButtonVariant::Filled)
1128                .icon(IconWidget::checkmark(14.0))
1129                .item(MenuItem::new(lit!("Scene"))),
1130        );
1131        tree.layout(SizeProposal::exact(300.0, 60.0));
1132        let frame = tree.render();
1133
1134        assert_eq!(
1135            paths_colored(&frame, theme.colors.text_on_accent.to_array()),
1136            2,
1137            "both the leading icon and the chevron must paint at text_on_accent"
1138        );
1139        assert_eq!(
1140            paths_colored(&frame, theme.colors.text_primary.to_array()),
1141            0,
1142            "no glyph may fall back to IconWidget's default text_primary on an accent fill"
1143        );
1144    }
1145
1146    /// The tint follows `text_role(..)` when the caller overrides it — the
1147    /// icon and the label stay in lockstep rather than the icon falling back
1148    /// to the variant cascade.
1149    #[test]
1150    fn leading_icon_follows_the_text_role_override() {
1151        let theme = teksilo_core::presets::intui::dark();
1152        let mut tree = WidgetTree::new().with_theme(theme.clone());
1153        tree.add(
1154            SplitButton::new_static()
1155                .variant(ButtonVariant::Filled)
1156                .text_role(teksilo_tokens::TextRole::Error)
1157                .icon(IconWidget::checkmark(14.0))
1158                .item(MenuItem::new(lit!("Delete"))),
1159        );
1160        tree.layout(SizeProposal::exact(300.0, 60.0));
1161
1162        assert_eq!(
1163            paths_colored(&tree.render(), theme.colors.text_error.to_array()),
1164            2,
1165            "text_role(..) must retint the leading icon, not just the label"
1166        );
1167    }
1168}