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