Skip to main content

teksilo_widgets/
toolbar.rs

1// SPDX-License-Identifier: MPL-2.0
2// SPDX-FileCopyrightText: 2026 FernTech
3
4//! `Toolbar` — a command bar with automatic **overflow**.
5//!
6//! Excess actions collapse into a trailing chevron (`⌄`) that opens a popover
7//! menu, mirroring Qt's `QToolBar` extension button, macOS `NSToolbar`'s
8//! overflow menu, and WinUI `CommandBar`. Synthesized API:
9//!
10//! - **Actions** ([`ToolbarAction`]) — a command with a **label + icon** (both
11//!   required), an optional tooltip, enabled state, optional toggle (checkable)
12//!   or dropdown [`menu`](ToolbarAction::menu), an **overflow priority**
13//!   (NSToolbar: lowest priority collapses first), and an **`always_overflow`**
14//!   flag (WinUI secondary commands). Each action has a toolbar form (an
15//!   [`IconButton`], or a [`PopoverIconButton`] when it carries a menu) and a
16//!   menu form (a `MenuItem`, or a submenu), so it renders correctly whether
17//!   inline or in the overflow menu.
18//! - **Pinned widgets** ([`ToolbarItem::custom`]) — arbitrary widgets (a search
19//!   field, a `SegmentedControl`) that never collapse.
20//! - **Collapsible widgets** — an arbitrary widget that *does* overflow, by
21//!   supplying an overflow representation (NSToolbar `menuFormRepresentation` /
22//!   Qt `QWidgetAction`): a **menu row** ([`ToolbarAction`]) via
23//!   [`ToolbarItem::custom(w).overflow_as(action)`](ToolbarItem::overflow_as)
24//!   (or [`ToolbarOverflow`] + [`ToolbarItem::collapsible`]; an icon-only
25//!   control reuses its icon as the menu glyph), or a **live embedded widget**
26//!   via [`ToolbarItem::custom(w).overflow_widget(f)`](ToolbarItem::overflow_widget)
27//!   (the factory rebuilds the control — e.g. a `ComboBox` bound to the same
28//!   signal — inside the menu so it stays usable while collapsed). When the bar
29//!   is tight the inline widget is hidden and its overflow form appears in the
30//!   menu.
31//! - **Separators** and **flexible space** (NSToolbar `flexibleSpace`).
32//! - Toolbar-wide **[`button_size`](Toolbar::button_size)** (default
33//!   [`Compact`](IconButtonSize::Compact)), **[`button_style`](Toolbar::button_style)**
34//!   (a shared [`IconButtonStyle`] for every action), and **orientation**.
35//!
36//! Overflow is computed every layout pass from each item's intrinsic size
37//! (measured even while collapsed, via
38//! [`LayoutContext::measure_intrinsic`](teksilo_core::widget::LayoutContext::measure_intrinsic)),
39//! so items reappear correctly as the bar widens — no stale-width glitches.
40//!
41//! The chevron's drop-down is a real [`MenuList`] whose rows are gated by
42//! [`MenuList::item_when`],
43//! so it sizes compactly to the currently-collapsed rows, carries standard
44//! menu chrome, takes focus when opened, and supports arrow / `Home` / `End` /
45//! `Enter` keyboard navigation (skipping the hidden rows).
46//!
47//! **Accessibility (ARIA toolbar pattern).** The bar emits `Role::Toolbar`
48//! with its orientation and name. It is a single Tab stop with **roving
49//! tab-index**: arrow keys move focus among the visible controls (and the
50//! chevron), `Home`/`End` jump to the ends. The chevron announces
51//! `HasPopup::Menu` and its expanded state; overflowed actions are dormant
52//! (absent from the AT tree), represented instead by their menu items — so no
53//! action is announced twice. Toggle actions carry `Toggled`.
54//!
55//! ```ignore
56//! // on_activate requires an EventContext — use ignore.
57//! use teksilo_widgets::toolbar::{Toolbar, ToolbarAction, ToolbarItem};
58//! use teksilo_i18n::lit;
59//! let _bar = Toolbar::new()
60//!     .action(ToolbarAction::new(lit!("Save"), save_icon).on_activate(|ctx| { /* ... */ }))
61//!     .action(ToolbarAction::new(lit!("Undo"), undo_icon).priority(-1))
62//!     .item(ToolbarItem::flexible_space());
63//! ```
64//!
65//! ## Touch and pen
66//!
67//! Nothing to do here, and it is worth recording why: every command on the bar
68//! is an [`IconButton`] or a [`PopoverIconButton`], so each one inherits the
69//! framework press, release activation and slide-off abort from the button
70//! family, and each is already 24 dp at Compact
71//! (`IconButtonSize::Compact`/`Default`). The bar itself carries only roving
72//! keyboard navigation and takes no press of its own. The overflow chevron is
73//! gated on the layout-derived `is_overflowing` signal, not on hover, so it is
74//! reachable by a finger without any reveal policy.
75
76use std::cell::RefCell;
77use std::rc::Rc;
78
79use teksilo_canvas::{Point, Rect, Size, SizeProposal};
80use teksilo_core::accessibility::AccessNodeBuilder;
81use teksilo_core::accesskit::HasPopup;
82use teksilo_core::build_context::BuildContext;
83use teksilo_core::event::{EventResponse, Key, WidgetEvent};
84use teksilo_core::overlay::OverlayPlacement;
85use teksilo_core::signal::{Prop, Signal};
86use teksilo_core::styles::{IconButtonSize, IconButtonStyle, SharedIconButtonStyle};
87use teksilo_core::widget::{
88    EventContext, LayoutContext, LayoutResponse, PendingChild, Widget, WidgetPlacement,
89};
90use teksilo_core::widget_builder::{HandlerSet, WidgetBuilder};
91use teksilo_core::widget_id::WidgetId;
92use teksilo_i18n::LocalizedString;
93
94use crate::Panel;
95use crate::icon_button::IconButton;
96use crate::menu_item::MenuItem;
97use crate::menu_list::MenuList;
98use crate::popover_widget::PopoverIconButton;
99use crate::primitives::icon_widget::IconWidget;
100use crate::primitives::{Divider, HStack, Spacer, VStack};
101use teksilo_core::styles::density::{dp, spacing};
102use teksilo_tokens::{InputTokens, TargetRole};
103
104/// Toolbar design tokens.
105pub const TOOLBAR_HEIGHT_DEFAULT: f32 = 40.0;
106
107/// [`TOOLBAR_HEIGHT_DEFAULT`] raised to the density's `target_size`
108/// (24 / 32 / 44 dp). The identity at Compact.
109pub fn toolbar_height_default(tokens: &InputTokens) -> f32 {
110    dp(TOOLBAR_HEIGHT_DEFAULT, TargetRole::Target, tokens)
111}
112pub const TOOLBAR_SPACING: f32 = 4.0;
113
114/// [`TOOLBAR_SPACING`] scaled by the density's `spacing_factor`
115/// (1.00 / 1.15 / 1.30).
116pub fn toolbar_spacing(tokens: &InputTokens) -> f32 {
117    spacing(TOOLBAR_SPACING, tokens)
118}
119/// Width/height reserved for the overflow chevron when it is shown.
120const CHEVRON_EXTENT: f32 = 30.0;
121
122/// [`CHEVRON_EXTENT`] raised to the density's `target_size`
123/// (24 / 32 / 44 dp). The identity at Compact.
124fn chevron_extent(tokens: &InputTokens) -> f32 {
125    dp(CHEVRON_EXTENT, TargetRole::Target, tokens)
126}
127const ICON_SIZE: f32 = 16.0;
128
129/// Layout axis of the toolbar.
130#[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
131pub enum ToolbarOrientation {
132    /// Items flow left-to-right (default).
133    #[default]
134    Horizontal,
135    /// Items flow top-to-bottom.
136    Vertical,
137}
138
139type IconFactory = Rc<dyn Fn() -> IconWidget>;
140
141/// A toolbar command: a **label + an icon** (both required), plus optional
142/// tooltip/toggle, an activation handler, an overflow priority, and an
143/// `always_overflow` flag. Renders as an icon-only [`IconButton`] inline (the
144/// label is its tooltip + accessible name) and as a labelled `MenuItem` in the
145/// overflow menu.
146#[derive(Clone)]
147pub struct ToolbarAction {
148    label: LocalizedString,
149    icon: IconFactory,
150    /// Plain-text tooltip shown after a hover delay.
151    /// Mutually exclusive with `rich_tooltip_source` — every tooltip
152    /// setter clears the other so last-call wins.
153    tooltip: Option<LocalizedString>,
154    /// Optional rich tooltip source (registry key or inline content).
155    /// Mutually exclusive with `tooltip` — every tooltip setter clears
156    /// the other so last-call wins. Boxed because the inline
157    /// `TooltipContent` payload is large and rarely set, and
158    /// `ToolbarAction` is boxed in `ToolbarItemKind` /
159    /// `OverflowMenuForm` (keeps those enums compact).
160    rich_tooltip_source: Option<Box<crate::tooltip::RichTooltipSource>>,
161    /// Optional composite tooltip body, stored as a factory because
162    /// `ToolbarAction` is `Clone` and `Box<dyn Widget>` is not — the
163    /// factory (`Rc`, which is `Clone`) is invoked once per `make_button`
164    /// to produce a fresh body. Mutually exclusive with the other two.
165    composite_tooltip_factory: Option<Rc<dyn Fn() -> Box<dyn Widget>>>,
166    /// Enabled state, static or reactive; forwarded to the inline
167    /// `IconButton` / `PopoverIconButton` and the overflow `MenuItem`.
168    enabled: Prop<bool>,
169    on_activate: Rc<dyn Fn(&mut EventContext)>,
170    toggle: Option<Signal<bool>>,
171    /// Optional dropdown menu. When set, the inline control is a
172    /// [`PopoverIconButton`] that opens this [`MenuList`] instead of a plain
173    /// [`IconButton`] that runs `on_activate`; in the overflow the action
174    /// becomes a **submenu**. `MenuList` isn't `Clone`, so it is a factory.
175    menu: Option<Rc<dyn Fn() -> MenuList>>,
176    priority: i32,
177    always_overflow: bool,
178}
179
180impl ToolbarAction {
181    /// A new action with the given (translatable) `label` and `icon` factory,
182    /// and a no-op handler. The label is the inline button's tooltip +
183    /// accessible name (the button is icon-only); the icon factory builds the
184    /// glyph for both the inline [`IconButton`] and the overflow menu row
185    /// (`IconWidget` isn't `Clone`, so it is a factory).
186    pub fn new(label: impl Into<LocalizedString>, icon: impl Fn() -> IconWidget + 'static) -> Self {
187        Self {
188            label: label.into(),
189            icon: Rc::new(icon),
190            tooltip: None,
191            rich_tooltip_source: None,
192            composite_tooltip_factory: None,
193            enabled: Prop::Static(true),
194            on_activate: Rc::new(|_| {}),
195            toggle: None,
196            menu: None,
197            priority: 0,
198            always_overflow: false,
199        }
200    }
201
202    /// Turn this action into a **dropdown**: its inline control becomes a
203    /// [`PopoverIconButton`] that opens the [`MenuList`] built by `factory`
204    /// (instead of a plain button that runs `on_activate`), and in the overflow
205    /// it becomes a submenu. `MenuList` isn't `Clone`, so pass a factory that
206    /// builds a fresh one. Mutually exclusive with `on_activate` / `toggle`
207    /// (the menu owns the interaction).
208    pub fn menu(mut self, factory: impl Fn() -> MenuList + 'static) -> Self {
209        self.menu = Some(Rc::new(factory));
210        self
211    }
212
213    /// Plain-text tooltip shown after a hover delay (the inline button is
214    /// icon-only; with none set, the label is used). Overrides any previously
215    /// set rich tooltip — every setter clears the other so last-call wins.
216    pub fn tooltip(mut self, text: impl Into<LocalizedString>) -> Self {
217        self.tooltip = Some(text.into());
218        self.rich_tooltip_source = None;
219        self.composite_tooltip_factory = None;
220        self
221    }
222
223    /// Attach a rich tooltip resolved from the app-wide tooltip registry.
224    /// The `key` is looked up via
225    /// [`TooltipRegistry`](crate::tooltip::TooltipRegistry) at build
226    /// time; the resolved body text supports inline markup
227    /// (`[label](url)`, `*italic*`, `**bold**`) and the entry's
228    /// shortcut / "more" fields are rendered automatically.
229    ///
230    /// Overrides any previously set plain `.tooltip(...)` — every setter
231    /// clears the other so last-call wins.
232    pub fn rich_tooltip(mut self, key: impl Into<String>) -> Self {
233        self.rich_tooltip_source =
234            Some(Box::new(crate::tooltip::RichTooltipSource::Key(key.into())));
235        self.tooltip = None;
236        self.composite_tooltip_factory = None;
237        self
238    }
239
240    /// Attach a rich tooltip driven by inline
241    /// [`TooltipContent`](crate::tooltip::TooltipContent) — for
242    /// one-off tooltips that aren't worth registering in the central
243    /// catalog. Overrides any previously set plain `.tooltip(...)`.
244    pub fn rich_tooltip_content(mut self, content: crate::tooltip::TooltipContent) -> Self {
245        self.rich_tooltip_source = Some(Box::new(crate::tooltip::RichTooltipSource::Content(
246            content,
247        )));
248        self.tooltip = None;
249        self.composite_tooltip_factory = None;
250        self
251    }
252
253    /// Attach a composite tooltip whose body is built by `factory` — an
254    /// arbitrary widget tree (tabbed sections, charts, conditional rows).
255    /// Because `ToolbarAction` is `Clone`, the body is supplied as a
256    /// factory closure (not a `Box<dyn Widget>` instance, which is not
257    /// `Clone`); the closure is invoked to produce a fresh body for the
258    /// inline button. Overrides any previously set tooltip — every setter
259    /// clears the others so last-call wins.
260    pub fn composite_tooltip(mut self, factory: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
261        self.composite_tooltip_factory = Some(Rc::new(factory));
262        self.tooltip = None;
263        self.rich_tooltip_source = None;
264        self
265    }
266
267    /// Enabled state, static or reactive.
268    pub fn enabled(mut self, enabled: impl Into<Prop<bool>>) -> Self {
269        self.enabled = enabled.into();
270        self
271    }
272
273    /// Activation handler (tap / Enter / Space / AT click / menu activate).
274    pub fn on_activate(mut self, f: impl Fn(&mut EventContext) + 'static) -> Self {
275        self.on_activate = Rc::new(f);
276        self
277    }
278
279    /// Make this a checkable (toggle) action bound to `state`. Inline it reads
280    /// as a pressed toggle button; in overflow as a checkmark menu item.
281    pub fn toggle(mut self, state: Signal<bool>) -> Self {
282        self.toggle = Some(state);
283        self
284    }
285
286    /// Overflow priority — actions with the **lowest** priority collapse into
287    /// the menu first (NSToolbar semantics). Default `0`.
288    pub fn priority(mut self, priority: i32) -> Self {
289        self.priority = priority;
290        self
291    }
292
293    /// Always live in the overflow menu, never inline (WinUI secondary command).
294    pub fn always_overflow(mut self) -> Self {
295        self.always_overflow = true;
296        self
297    }
298
299    /// Build this action's inline button — an icon-only [`IconButton`] at the
300    /// toolbar-wide `size` and optional `style`. The label is applied as the
301    /// accessible name (icon-only buttons have no visible text); the tooltip is
302    /// the explicit tooltip, or the label when none was set.
303    fn make_button(
304        &self,
305        size: IconButtonSize,
306        style: Option<&SharedIconButtonStyle>,
307    ) -> Box<dyn Widget> {
308        let mut btn = IconButton::new((self.icon)())
309            .size(size)
310            .enabled(self.enabled.clone());
311        if let Some(style) = style {
312            btn = btn.style_shared(style.clone());
313        }
314        // Forward tooltip (mutually-exclusive setters; at most one branch runs).
315        // Plain tooltip defaults to the label so the hover text is never empty.
316        if let Some(ref factory) = self.composite_tooltip_factory {
317            btn = btn.composite_tooltip_boxed(factory());
318        } else if let Some(ref source) = self.rich_tooltip_source {
319            match (**source).clone() {
320                crate::tooltip::RichTooltipSource::Key(key) => {
321                    btn = btn.rich_tooltip(key);
322                }
323                crate::tooltip::RichTooltipSource::Content(content) => {
324                    btn = btn.rich_tooltip_content(content);
325                }
326            }
327        } else {
328            btn = btn.tooltip(self.tooltip.clone().unwrap_or_else(|| self.label.clone()));
329        }
330        // Dropdown action: the icon opens a `MenuList` popover. The inner
331        // IconButton keeps its tooltip / size / style; the popover owns the
332        // activation, so `on_activate` / `toggle` don't apply.
333        if let Some(ref menu) = self.menu {
334            let btn = btn.has_popup(HasPopup::Menu);
335            let pop = PopoverIconButton::new(btn)
336                .bare()
337                .content(menu())
338                .placement(OverlayPlacement::BelowPreferred);
339            return Box::new(pop.access_label(self.label.clone()));
340        }
341        // Command action: click runs `on_activate`. `IconButton::toggle`
342        // auto-flips `state` on click, then `on_activate` fires (post-flip) — so
343        // no manual flip here (unlike the old `Button`).
344        if let Some(ref toggle) = self.toggle {
345            btn = btn.toggle(toggle.clone());
346        }
347        let act = self.on_activate.clone();
348        btn = btn.on_activate_fn(move |ctx| act(ctx));
349        // The accessible name is always the label (a rich / composite tooltip
350        // would otherwise leave an icon-only button unnamed).
351        Box::new(btn.access_label(self.label.clone()))
352    }
353
354    /// Build this action's overflow row — a `MenuItem` that runs the action
355    /// and closes the popover. Checkable actions show a check mark; a dropdown
356    /// action ([`menu`](Self::menu)) becomes a submenu.
357    fn make_menu_item(&self) -> MenuItem {
358        // Dropdown action → a submenu in the overflow (the same `MenuList`).
359        if let Some(ref menu) = self.menu {
360            let m = menu.clone();
361            return MenuItem::submenu(self.label.clone(), move || Box::new(m()) as Box<dyn Widget>)
362                .enabled(self.enabled.clone())
363                .icon((self.icon)());
364        }
365        let mut mi = MenuItem::new(self.label.clone())
366            .enabled(self.enabled.clone())
367            .icon((self.icon)());
368        let act = self.on_activate.clone();
369        if let Some(ref toggle) = self.toggle {
370            mi = mi.checked(toggle.clone());
371            let toggle = toggle.clone();
372            mi = mi.on_activate_fn(move |ctx| {
373                toggle.set(!toggle.get());
374                act(ctx);
375                ctx.dismiss_self_overlay_chain();
376            });
377        } else {
378            mi = mi.on_activate_fn(move |ctx| {
379                act(ctx);
380                ctx.dismiss_self_overlay_chain();
381            });
382        }
383        mi
384    }
385}
386
387/// A widget that knows how to represent itself in a `Toolbar`'s overflow menu
388/// when it is collapsed (NSToolbar `menuFormRepresentation` / Qt
389/// `QWidgetAction`). Implement this on a widget and add it with
390/// [`ToolbarItem::collapsible`] to make it overflow into the chevron menu as
391/// the returned [`ToolbarAction`] (a menu row), instead of staying pinned.
392///
393/// For widgets that are best represented in the menu *as themselves* (a
394/// `ComboBox`, a slider) rather than as a one-shot menu row, use
395/// [`ToolbarItem::overflow_widget`] instead — it embeds a live widget in the
396/// menu.
397pub trait ToolbarOverflow {
398    /// The menu-form representation shown when this widget overflows.
399    fn toolbar_menu_form(&self) -> ToolbarAction;
400}
401
402/// Factory that rebuilds a widget for the overflow menu (widgets aren't
403/// `Clone`, and the menu builds its rows lazily).
404type MenuWidgetFactory = Rc<dyn Fn() -> Box<dyn Widget>>;
405
406/// What a collapsible toolbar item shows when it collapses into the overflow
407/// (chevron) menu.
408enum OverflowMenuForm {
409    /// A standard menu row built from a [`ToolbarAction`] — label, optional
410    /// icon (an icon-only inline control reuses its icon here as the menu
411    /// item's leading glyph), and the action's activation. Used by actions
412    /// and by custom widgets that opt in via
413    /// [`ToolbarItem::overflow_as`] / [`ToolbarItem::collapsible`].
414    Action(Box<ToolbarAction>),
415    /// A live widget embedded directly in the menu — e.g. the same
416    /// `ComboBox` (bound to the same signal) the inline slot shows, so the
417    /// control stays fully usable while collapsed. Built by the factory when
418    /// the menu is constructed. Opt in via [`ToolbarItem::overflow_widget`].
419    Widget(MenuWidgetFactory),
420}
421
422/// Per-collapsible-item overflow metadata: the menu-form to show plus the
423/// priority / `always_overflow` flags that drive [`compute_overflow`].
424struct CollapsibleMeta {
425    priority: i32,
426    always_overflow: bool,
427    form: OverflowMenuForm,
428}
429
430enum ToolbarItemKind {
431    Action(Box<ToolbarAction>),
432    /// Arbitrary widget. `menu_form == None` → pinned (never collapses);
433    /// `Some(form)` → collapsible, shown as that menu row (or embedded widget)
434    /// when overflowed.
435    Custom {
436        pending: PendingChild,
437        menu_form: Option<OverflowMenuForm>,
438    },
439    Separator,
440    FlexibleSpace,
441}
442
443/// One slot in a [`Toolbar`].
444pub struct ToolbarItem {
445    kind: ToolbarItemKind,
446}
447
448impl ToolbarItem {
449    /// A collapsible command.
450    pub fn action(action: ToolbarAction) -> Self {
451        Self {
452            kind: ToolbarItemKind::Action(Box::new(action)),
453        }
454    }
455
456    /// A pinned arbitrary widget (never collapses) — e.g. a search field. Make
457    /// it collapsible with [`overflow_as`](Self::overflow_as).
458    pub fn custom(widget: impl Widget + 'static) -> Self {
459        Self {
460            kind: ToolbarItemKind::Custom {
461                pending: teksilo_core::IntoTeksiChild::into_pending(widget),
462                menu_form: None,
463            },
464        }
465    }
466
467    /// A pinned arbitrary widget by pre-registered id.
468    pub fn custom_id(id: WidgetId) -> Self {
469        Self {
470            kind: ToolbarItemKind::Custom {
471                pending: PendingChild::Id(id),
472                menu_form: None,
473            },
474        }
475    }
476
477    /// A collapsible widget that supplies its own menu form via
478    /// [`ToolbarOverflow`]. When the bar is too narrow, the widget is hidden
479    /// and its `toolbar_menu_form()` appears in the overflow menu.
480    pub fn collapsible(widget: impl Widget + ToolbarOverflow + 'static) -> Self {
481        let menu_form = widget.toolbar_menu_form();
482        Self {
483            kind: ToolbarItemKind::Custom {
484                pending: teksilo_core::IntoTeksiChild::into_pending(widget),
485                menu_form: Some(OverflowMenuForm::Action(Box::new(menu_form))),
486            },
487        }
488    }
489
490    /// Make a [`custom`](Self::custom) widget collapsible with an explicit menu
491    /// **row** — the [`ToolbarAction`] shown when it overflows (NSToolbar
492    /// `menuFormRepresentation`). Best for controls whose menu form is a
493    /// single command; an icon-only inline control reuses its icon here as the
494    /// menu item's leading glyph (pass it to [`ToolbarAction::new`]).
495    pub fn overflow_as(mut self, menu_form: ToolbarAction) -> Self {
496        if let ToolbarItemKind::Custom { menu_form: mf, .. } = &mut self.kind {
497            *mf = Some(OverflowMenuForm::Action(Box::new(menu_form)));
498        }
499        self
500    }
501
502    /// Make a [`custom`](Self::custom) widget collapsible by embedding a **live
503    /// widget** in the overflow menu — the factory rebuilds the control (e.g.
504    /// a `ComboBox` bound to the same signal) so it stays fully interactive
505    /// while collapsed, instead of degrading to a one-shot menu row. Best for
506    /// stateful inputs (combo boxes, sliders) that have no meaningful single
507    /// "command" representation.
508    pub fn overflow_widget(mut self, factory: impl Fn() -> Box<dyn Widget> + 'static) -> Self {
509        if let ToolbarItemKind::Custom { menu_form: mf, .. } = &mut self.kind {
510            *mf = Some(OverflowMenuForm::Widget(Rc::new(factory)));
511        }
512        self
513    }
514
515    /// A separator line between groups.
516    pub fn separator() -> Self {
517        Self {
518            kind: ToolbarItemKind::Separator,
519        }
520    }
521
522    /// Flexible space that pushes the following items to the trailing edge
523    /// (NSToolbar `flexibleSpace`). Collapses to nothing when over-constrained.
524    pub fn flexible_space() -> Self {
525        Self {
526            kind: ToolbarItemKind::FlexibleSpace,
527        }
528    }
529}
530
531/// A command bar with automatic overflow. See the [module docs](self).
532pub struct Toolbar {
533    items: Vec<ToolbarItem>,
534    orientation: ToolbarOrientation,
535    /// Gap between items. `None` follows the active density
536    /// ([`toolbar_spacing`]); `Some` is an explicit app override that density
537    /// leaves alone.
538    spacing: Option<f32>,
539    label: Option<LocalizedString>,
540    /// Size variant applied to every action's inline [`IconButton`] (and the
541    /// overflow chevron). Default [`IconButtonSize::Compact`].
542    button_size: IconButtonSize,
543    /// Optional toolbar-wide [`IconButtonStyle`] applied to every action button
544    /// (and the chevron). `None` → each button uses the theme's default
545    /// (flat / ghost) icon-button style.
546    button_style: Option<SharedIconButtonStyle>,
547    /// Compact (shrink-to-fit) sizing: report the *natural content* extent as
548    /// the wanted size and shrink toward the collapsed minimum, instead of
549    /// greedily filling the offered main extent. See [`Toolbar::compact`].
550    compact: bool,
551
552    // Reactive state (created in `new`, shared with build).
553    /// Per-action collapsed flag (index = action declaration order).
554    overflowed: Signal<Vec<bool>>,
555    /// Whether any action is collapsed (drives chevron visibility).
556    is_overflowing: Signal<bool>,
557    /// Roving tab-index: the action index (or `action_count` for the chevron)
558    /// that is currently the toolbar's single Tab stop.
559    roving: Signal<usize>,
560
561    // Build state.
562    /// Overflow metadata per collapsible item (declaration order): the
563    /// menu-form to show in the chevron menu plus each item's overflow
564    /// priority / `always_overflow`. Drives both the overflow menu rows
565    /// (gated by [`overflowed`](Self::overflowed)) and [`compute_overflow`].
566    menu_forms: Rc<Vec<CollapsibleMeta>>,
567    /// Inline widget id per collapsible item (an `IconButton` for actions, the
568    /// widget itself for collapsible customs), aligned with `menu_forms`.
569    collapsible_ids: Vec<WidgetId>,
570    /// Pinned-item ids (non-collapsible customs / separators / flexible-space)
571    /// for measurement.
572    pinned_ids: Vec<WidgetId>,
573    chevron_id: Option<WidgetId>,
574    root_child_id: Option<WidgetId>,
575    /// Cached overflow flags to avoid redundant signal writes.
576    last_flags: RefCell<Vec<bool>>,
577}
578
579impl Toolbar {
580    /// Create an empty toolbar with the default orientation (horizontal) and
581    /// `Compact`, ghost icon buttons. Add commands with [`action`](Self::action)
582    /// or layout items with [`item`](Self::item).
583    pub fn new() -> Self {
584        Self {
585            items: Vec::new(),
586            orientation: ToolbarOrientation::Horizontal,
587            button_size: IconButtonSize::Compact,
588            button_style: None,
589            spacing: None,
590            label: None,
591            compact: false,
592            overflowed: Signal::new(Vec::new()),
593            is_overflowing: Signal::new(false),
594            roving: Signal::new(0),
595            menu_forms: Rc::new(Vec::new()),
596            collapsible_ids: Vec::new(),
597            pinned_ids: Vec::new(),
598            chevron_id: None,
599            root_child_id: None,
600            last_flags: RefCell::new(Vec::new()),
601        }
602    }
603
604    /// Add an item (action, pinned widget, separator, flexible space).
605    pub fn item(mut self, item: ToolbarItem) -> Self {
606        self.items.push(item);
607        self
608    }
609
610    /// Add several items from an iterator, in order.
611    ///
612    /// The loop form of [`item`](Self::item): reach for it when the command set
613    /// is data-driven rather than written out call by call.
614    pub fn items(self, items: impl IntoIterator<Item = ToolbarItem>) -> Self {
615        items.into_iter().fold(self, Self::item)
616    }
617
618    /// Sugar for `.item(ToolbarItem::action(a))`.
619    pub fn action(self, action: ToolbarAction) -> Self {
620        self.item(ToolbarItem::action(action))
621    }
622
623    /// Add several collapsible commands from an iterator.
624    ///
625    /// The loop form of [`action`](Self::action), for a command set built from
626    /// data rather than spelled out one call at a time.
627    pub fn actions(self, actions: impl IntoIterator<Item = ToolbarAction>) -> Self {
628        actions.into_iter().fold(self, Self::action)
629    }
630
631    /// Add a pinned inline child widget (sugar for
632    /// `.item(ToolbarItem::custom(widget))`). Pinned widgets never collapse
633    /// into the overflow menu — use [`action`](Self::action) for collapsible
634    /// commands.
635    pub fn child(self, widget: impl teksilo_core::IntoTeksiChild) -> Self {
636        match teksilo_core::IntoTeksiChild::into_pending(widget) {
637            teksilo_core::PendingChild::Id(id) => self.item(ToolbarItem::custom_id(id)),
638            teksilo_core::PendingChild::Deferred(w) => self.item(ToolbarItem::custom(w)),
639        }
640    }
641
642    /// Add several pinned inline children from an iterator.
643    ///
644    /// The loop form of [`child`](Self::child). Like `child`, every widget added
645    /// this way is pinned and never collapses into the overflow menu.
646    pub fn children(
647        self,
648        iter: impl IntoIterator<Item = impl teksilo_core::IntoTeksiChild>,
649    ) -> Self {
650        iter.into_iter().fold(self, Self::child)
651    }
652    /// Attach `widget` when it is `Some`, and do nothing when it is `None`.
653    ///
654    /// The conditional-child form. `teksu!`'s `if` without an `else` lowers to
655    /// this, and it is what `cond.then(|| w)` is for in a builder chain. `None`
656    /// adds no arena node, so nothing is laid out, painted, or published to the
657    /// accessibility tree, and a stack applies no spacing around it.
658    pub fn child_opt(self, widget: Option<impl teksilo_core::IntoTeksiChild>) -> Self {
659        match widget {
660            Some(w) => self.child(w),
661            None => self,
662        }
663    }
664
665    /// Set the layout axis (default [`ToolbarOrientation::Horizontal`]).
666    pub fn orientation(mut self, orientation: ToolbarOrientation) -> Self {
667        self.orientation = orientation;
668        self
669    }
670
671    /// Size variant applied to every action's inline [`IconButton`] and the
672    /// overflow chevron (default [`IconButtonSize::Compact`]).
673    pub fn button_size(mut self, size: IconButtonSize) -> Self {
674        self.button_size = size;
675        self
676    }
677
678    /// A toolbar-wide [`IconButtonStyle`] applied to every action button and the
679    /// overflow chevron — one shared style for the whole bar (the icon-button
680    /// analogue of `theme.style_slots`). Default: the theme's flat / ghost
681    /// icon-button style.
682    pub fn button_style(mut self, style: impl IconButtonStyle) -> Self {
683        self.button_style = Some(Rc::new(style));
684        self
685    }
686
687    /// The gap actually used: the app's override if it set one, else the
688    /// density's [`toolbar_spacing`].
689    fn resolved_spacing(&self, tokens: &InputTokens) -> f32 {
690        self.spacing.unwrap_or_else(|| toolbar_spacing(tokens))
691    }
692
693    /// Gap between consecutive toolbar items in logical pixels. Overrides the
694    /// density's [`toolbar_spacing`] ([`TOOLBAR_SPACING`] at Compact).
695    pub fn spacing(mut self, spacing: f32) -> Self {
696        self.spacing = Some(spacing);
697        self
698    }
699
700    /// Override the accessible name (default: the localized "Toolbar").
701    pub fn label(mut self, label: impl Into<LocalizedString>) -> Self {
702        self.label = Some(label.into());
703        self
704    }
705
706    /// **Compact** (shrink-to-fit) sizing. By default a toolbar *fills* the main
707    /// extent it is offered (it is meant to span a full command bar). In compact
708    /// mode it instead reports its **natural content** extent as the wanted size
709    /// and is *shrinkable* down to its collapsed minimum (the pinned items plus
710    /// the overflow chevron) — so it sits as a tight cluster when there is room,
711    /// composes next to other widgets (e.g. a title and a `Spacer`) without
712    /// claiming their space, and still collapses excess actions into the `⌄`
713    /// menu when the slot is genuinely too narrow. Use it to embed a toolbar in a
714    /// constrained header rather than a full-width bar.
715    pub fn compact(mut self, compact: bool) -> Self {
716        self.compact = compact;
717        self
718    }
719
720    /// Reactive signal that is `true` whenever any action is collapsed into the
721    /// overflow menu (WinUI `IsOverflowOpen`-adjacent introspection).
722    pub fn is_overflowing(&self) -> Signal<bool> {
723        self.is_overflowing.clone()
724    }
725
726    fn horizontal(&self) -> bool {
727        self.orientation == ToolbarOrientation::Horizontal
728    }
729}
730
731impl Default for Toolbar {
732    fn default() -> Self {
733        Self::new()
734    }
735}
736
737impl std::fmt::Debug for Toolbar {
738    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
739        f.debug_struct("Toolbar")
740            .field("items", &self.items.len())
741            .field("orientation", &self.orientation)
742            .finish()
743    }
744}
745
746impl Widget for Toolbar {
747    fn build(&mut self, ctx: &mut BuildContext) -> Vec<WidgetId> {
748        let _ = ctx.theme_signal();
749        let horizontal = self.horizontal();
750
751        // Build the row in declaration order. Collapsible items (actions +
752        // collapsible customs) get an index `i` into `collapsible_ids` /
753        // `menu_forms`, are gated by `visible_when(!overflowed[i])`, and become
754        // the roving tab-stop when `roving == i`. Pinned items always show.
755        let take_items = std::mem::take(&mut self.items);
756        self.collapsible_ids = Vec::new();
757        self.pinned_ids = Vec::new();
758        let mut menu_forms: Vec<CollapsibleMeta> = Vec::new();
759        let mut child_ids: Vec<WidgetId> = Vec::new();
760
761        for item in take_items {
762            // Resolve the item to an inline widget id + an optional menu form.
763            let (inline_id, menu_form): (WidgetId, Option<OverflowMenuForm>) = match item.kind {
764                ToolbarItemKind::Action(action) => {
765                    let id = ctx.add_boxed(
766                        action.make_button(self.button_size, self.button_style.as_ref()),
767                    );
768                    (id, Some(OverflowMenuForm::Action(action)))
769                }
770                ToolbarItemKind::Custom { pending, menu_form } => {
771                    let id = match pending {
772                        PendingChild::Id(id) => id,
773                        PendingChild::Deferred(w) => ctx.add_boxed(w),
774                    };
775                    (id, menu_form)
776                }
777                ToolbarItemKind::Separator => {
778                    let id = ctx.add(if horizontal {
779                        Divider::vertical()
780                    } else {
781                        Divider::horizontal()
782                    });
783                    self.pinned_ids.push(id);
784                    child_ids.push(id);
785                    continue;
786                }
787                ToolbarItemKind::FlexibleSpace => {
788                    let id = ctx.add(Spacer::new());
789                    self.pinned_ids.push(id);
790                    child_ids.push(id);
791                    continue;
792                }
793            };
794            child_ids.push(inline_id);
795            match menu_form {
796                Some(form) => {
797                    // Collapsible: gate visibility + roving tab-stop on its index.
798                    let i = self.collapsible_ids.len();
799                    let of = self.overflowed.clone();
800                    ctx.visible_when(
801                        inline_id,
802                        of.map(move |flags| flags.get(i).copied() != Some(true)),
803                    );
804                    let rov = self.roving.clone();
805                    ctx.set_tab_stop(inline_id, rov.map(move |r| *r == i));
806                    self.collapsible_ids.push(inline_id);
807                    // Priority / always-overflow come from the action form;
808                    // an embedded-widget form defaults to ordinary priority.
809                    let (priority, always_overflow) = match &form {
810                        OverflowMenuForm::Action(a) => (a.priority, a.always_overflow),
811                        OverflowMenuForm::Widget(_) => (0, false),
812                    };
813                    menu_forms.push(CollapsibleMeta {
814                        priority,
815                        always_overflow,
816                        form,
817                    });
818                }
819                None => self.pinned_ids.push(inline_id),
820            }
821        }
822
823        let action_count = self.collapsible_ids.len();
824        self.menu_forms = Rc::new(menu_forms);
825        // Seed the overflowed flags (none collapsed initially → all visible).
826        self.overflowed.set(vec![false; action_count]);
827        *self.last_flags.borrow_mut() = vec![false; action_count];
828
829        // Overflow chevron: a PopoverIconButton (HasPopup::Menu) whose content
830        // is a real `MenuList` with ONE row per collapsible item, each gated
831        // via `MenuList::item_when(overflowed[i])`. Only the currently
832        // collapsed rows are shown — hidden rows collapse to zero height (no
833        // gaps) and are skipped by keyboard navigation — so the menu
834        // reconciles reactively as the bar resizes, with no rebuild of the
835        // (dormant) popover subtree. Using `MenuList` (rather than a bare
836        // column) gives the menu its compact size-to-content sizing, the
837        // standard popover chrome, focus-on-open (it is a focusable Tab stop,
838        // and `PopoverWidget` moves focus into it), and arrow/Home/End/Enter
839        // keyboard navigation. A row may be an ordinary menu item OR a live
840        // embedded widget (e.g. a `ComboBox` bound to the same signal as its
841        // inline twin).
842        if action_count > 0 {
843            let menu_forms = self.menu_forms.clone();
844            let mut menu = MenuList::new();
845            for (i, meta) in menu_forms.iter().enumerate() {
846                let row: Box<dyn Widget> = match &meta.form {
847                    OverflowMenuForm::Action(a) => Box::new(a.make_menu_item()),
848                    OverflowMenuForm::Widget(factory) => factory(),
849                };
850                // Show this row only while its inline twin is collapsed.
851                let of = self.overflowed.clone();
852                let visible = of.map(move |flags| flags.get(i).copied() == Some(true));
853                menu = menu.item_boxed_when(row, visible);
854            }
855            // The chevron matches the toolbar-wide button size + style so it
856            // sits flush with the action buttons.
857            let mut chevron_btn = IconButton::new(IconWidget::chevron_down(ICON_SIZE))
858                .size(self.button_size)
859                .tooltip(teksilo_i18n::tr_widget!(toolbar_more()));
860            if let Some(ref style) = self.button_style {
861                chevron_btn = chevron_btn.style_shared(style.clone());
862            }
863            let chevron = PopoverIconButton::new(chevron_btn)
864                .content(menu)
865                // `MenuList` already routes through the Menu `PopoverStyle`
866                // for its own surface — don't double-chrome it.
867                .bare()
868                .placement(OverlayPlacement::BelowPreferred)
869                .has_popup_kind(HasPopup::Menu);
870            let chevron_id = ctx.add(chevron);
871            let is_of = self.is_overflowing.clone();
872            ctx.visible_when(chevron_id, is_of);
873            // Chevron is the roving stop when roving == action_count.
874            let rov = self.roving.clone();
875            ctx.set_tab_stop(chevron_id, rov.map(move |r| *r == action_count));
876            self.chevron_id = Some(chevron_id);
877            child_ids.push(chevron_id);
878        }
879
880        // ARIA toolbar keyboard pattern: the bar is a single Tab stop with
881        // roving focus. Intercept arrow / Home / End on the preview pass (so it
882        // works while a child button is focused) and move focus + the roving
883        // tab-stop among the visible controls (+ chevron).
884        if action_count > 0 {
885            let abids = self.collapsible_ids.clone();
886            let chevron_id = self.chevron_id;
887            let overflowed = self.overflowed.clone();
888            let roving = self.roving.clone();
889            let handlers = HandlerSet::new().on_key_preview(
890                move |event: &WidgetEvent, ctx: &mut EventContext| -> EventResponse {
891                    let WidgetEvent::KeyDown { key, .. } = event else {
892                        return EventResponse::Ignored;
893                    };
894                    // Roving directions follow the *visual* axis, resolved at
895                    // event time so a locale change flips them live. On a
896                    // horizontal bar under RTL the layout mirrors (logical-first
897                    // sits at the right edge), so ArrowRight steps to the
898                    // previous control and ArrowLeft to the next — per the
899                    // WAI-ARIA toolbar pattern. Vertical bars are direction-
900                    // independent.
901                    let (prev, next) = if horizontal {
902                        if ctx.is_rtl() {
903                            (Key::ArrowRight, Key::ArrowLeft)
904                        } else {
905                            (Key::ArrowLeft, Key::ArrowRight)
906                        }
907                    } else {
908                        (Key::ArrowUp, Key::ArrowDown)
909                    };
910                    let is_nav =
911                        *key == prev || *key == next || *key == Key::Home || *key == Key::End;
912                    if !is_nav {
913                        return EventResponse::Ignored;
914                    }
915                    // Focusable roving indices: visible actions then chevron.
916                    let flags = overflowed.get();
917                    let mut seq: Vec<usize> = (0..abids.len())
918                        .filter(|i| flags.get(*i).copied() != Some(true))
919                        .collect();
920                    if chevron_id.is_some() && flags.iter().any(|&f| f) {
921                        seq.push(abids.len());
922                    }
923                    if seq.is_empty() {
924                        return EventResponse::Ignored;
925                    }
926                    let cur = roving.get();
927                    let pos = seq.iter().position(|&x| x == cur).unwrap_or(0);
928                    let new_pos = if *key == Key::Home {
929                        0
930                    } else if *key == Key::End {
931                        seq.len() - 1
932                    } else if *key == next {
933                        (pos + 1).min(seq.len() - 1)
934                    } else {
935                        pos.saturating_sub(1)
936                    };
937                    let target = seq[new_pos];
938                    roving.set(target);
939                    let id = if target < abids.len() {
940                        Some(abids[target])
941                    } else {
942                        chevron_id
943                    };
944                    if let Some(id) = id {
945                        ctx.request_focus(id);
946                    }
947                    EventResponse::Handled
948                },
949            );
950            ctx.apply_self_handlers(handlers);
951        }
952
953        let row: WidgetId = if horizontal {
954            let mut r = HStack::new().spacing(self.resolved_spacing(&ctx.theme().input));
955            for id in &child_ids {
956                r = r.child(*id);
957            }
958            ctx.add(r)
959        } else {
960            let mut r = VStack::new().spacing(self.resolved_spacing(&ctx.theme().input));
961            for id in &child_ids {
962                r = r.child(*id);
963            }
964            ctx.add(r)
965        };
966
967        // A *transparent, padding-free, borderless* presentational wrapper. The
968        // toolbar sits directly on its host's surface (a dock header, a form
969        // row): a themed `Panel` background/border would draw a spurious box, and
970        // the default padding would inflate the bar by the theme inset on every
971        // side (a compact 24 dp button reading as ~48 dp) and spill a tight slot.
972        // The toolbar owns only its `spacing`.
973        let root = ctx.add(
974            Panel::new()
975                .a11y_presentational()
976                .background(teksilo_tokens::SurfaceRole::Transparent)
977                .border_width(0.0)
978                .padding(0.0)
979                .child(row),
980        );
981        self.root_child_id = Some(root);
982        vec![root]
983    }
984
985    fn layout_response(
986        &self,
987        proposal: SizeProposal,
988        ctx: &LayoutContext,
989    ) -> teksilo_core::widget::LayoutResponse {
990        let Some(root) = self.root_child_id else {
991            return proposal.resolve(0.0, 0.0).into();
992        };
993        let horizontal = self.horizontal();
994
995        // Compact mode: report the natural content extent (everything inline) as
996        // the wanted size, but be *shrinkable* to the collapsed minimum (pinned
997        // items + the overflow chevron). The parent sizes us to content when
998        // there is room (no spreading / claiming a sibling's space) and shrinks
999        // us when over-constrained, at which point `place_children` collapses
1000        // the excess actions into the `⌄` menu against the smaller bounds.
1001        if self.compact {
1002            let probe = SizeProposal::unspecified();
1003            let main = |s: Size| if horizontal { s.width } else { s.height };
1004            let cross = |s: Size| if horizontal { s.height } else { s.width };
1005
1006            // Natural content = every collapsible + pinned item laid out INLINE.
1007            // Measured via `measure_intrinsic` (NOT `child_size`) so an item that
1008            // has collapsed into the overflow — and is therefore dormant — still
1009            // counts; otherwise the measured content would shrink as items
1010            // collapse and the bar could never re-expand once it overflowed. The
1011            // overflow chevron is excluded here: it appears only *while*
1012            // overflowing, so counting it would reserve a permanent trailing gap.
1013            let mut content_main = 0.0_f32;
1014            let mut content_cross = 0.0_f32;
1015            let mut count = 0usize;
1016            for &id in self.collapsible_ids.iter().chain(self.pinned_ids.iter()) {
1017                if let Some(s) = ctx.measure_intrinsic(id, probe) {
1018                    content_main += main(s);
1019                    content_cross = content_cross.max(cross(s));
1020                    count += 1;
1021                }
1022            }
1023            content_main +=
1024                self.resolved_spacing(&ctx.theme.input) * count.saturating_sub(1) as f32;
1025            // Cross axis: clamp to the offered size when the parent bounds it (a
1026            // tight slot can't be exceeded), else use the measured content cross.
1027            let offered_cross = if horizontal {
1028                proposal.height
1029            } else {
1030                proposal.width
1031            };
1032            let content_cross = offered_cross.map_or(content_cross, |c| c.min(content_cross));
1033
1034            // Collapsed minimum = pinned items + the overflow chevron.
1035            let mut min_main = 0.0_f32;
1036            let mut min_count = 0usize;
1037            for &id in &self.pinned_ids {
1038                if let Some(s) = ctx.measure_intrinsic(id, probe) {
1039                    min_main += main(s);
1040                    min_count += 1;
1041                }
1042            }
1043            if let Some(cid) = self.chevron_id
1044                && let Some(s) = ctx.measure_intrinsic(cid, probe)
1045            {
1046                min_main += main(s);
1047                min_count += 1;
1048            }
1049            min_main +=
1050                self.resolved_spacing(&ctx.theme.input) * min_count.saturating_sub(1) as f32;
1051            let min_main = min_main.min(content_main);
1052
1053            let (size, min) = if horizontal {
1054                (
1055                    Size::new(content_main, content_cross),
1056                    Size::new(min_main, content_cross),
1057                )
1058            } else {
1059                (
1060                    Size::new(content_cross, content_main),
1061                    Size::new(content_cross, min_main),
1062                )
1063            };
1064            return LayoutResponse::shrinkable(size, min, 1.0);
1065        }
1066
1067        // Default: FILL the offered main extent and handle overflow internally
1068        // (collapsing actions into the chevron menu in `place_children`). If it
1069        // reported its natural content width instead, its parent would size it to
1070        // that width and it would spill outside the container. Take the content
1071        // size only on an axis the parent left open.
1072        let content = ctx
1073            .child_size(root, proposal)
1074            .unwrap_or_else(|| proposal.resolve(0.0, 0.0));
1075        let (width, height) = if horizontal {
1076            (proposal.width.unwrap_or(content.width), content.height)
1077        } else {
1078            (content.width, proposal.height.unwrap_or(content.height))
1079        };
1080        Size::new(width, height).into()
1081    }
1082
1083    fn place_children(
1084        &self,
1085        bounds: Rect,
1086        _proposal: SizeProposal,
1087        children: &mut [WidgetPlacement],
1088        ctx: &LayoutContext,
1089    ) {
1090        // Position the single root child to fill our bounds.
1091        for child in children.iter_mut() {
1092            child.origin = Point::new(bounds.x, bounds.y);
1093            child.size = Size::new(bounds.width, bounds.height);
1094        }
1095
1096        // Compute the overflow set from intrinsic sizes (measured even while
1097        // collapsed) along the main axis.
1098        let horizontal = self.horizontal();
1099        let avail = if horizontal {
1100            bounds.width
1101        } else {
1102            bounds.height
1103        };
1104        let main = |s: Size| if horizontal { s.width } else { s.height };
1105
1106        let probe = SizeProposal::unspecified();
1107        let mut pinned_total = 0.0_f32;
1108        for &id in &self.pinned_ids {
1109            if let Some(s) = ctx.measure_intrinsic(id, probe) {
1110                pinned_total += main(s);
1111            }
1112        }
1113        let n = self.collapsible_ids.len();
1114        let mut action_w = vec![0.0_f32; n];
1115        for (i, &id) in self.collapsible_ids.iter().enumerate() {
1116            action_w[i] = ctx.measure_intrinsic(id, probe).map(main).unwrap_or(0.0);
1117        }
1118
1119        let priorities: Vec<i32> = self.menu_forms.iter().map(|a| a.priority).collect();
1120        let always: Vec<bool> = self.menu_forms.iter().map(|a| a.always_overflow).collect();
1121        let total_slots = self.pinned_ids.len() + n; // for spacing estimate
1122
1123        let flags = compute_overflow(
1124            avail,
1125            pinned_total,
1126            &action_w,
1127            &priorities,
1128            &always,
1129            self.resolved_spacing(&ctx.theme.input),
1130            chevron_extent(&ctx.theme.input),
1131            total_slots,
1132        );
1133
1134        // Publish flags (guarded) + the is_overflowing signal. The overflow
1135        // menu's rows are gated directly off `overflowed` via `visible_when`,
1136        // so flipping the signal reconciles the popover with no extra model.
1137        if *self.last_flags.borrow() != flags {
1138            *self.last_flags.borrow_mut() = flags.clone();
1139            let any = flags.iter().any(|&f| f);
1140
1141            self.overflowed.set(flags.clone());
1142            if self.is_overflowing.get() != any {
1143                self.is_overflowing.set(any);
1144            }
1145            // Keep the roving tab-stop on a visible target.
1146            self.clamp_roving(&flags);
1147        }
1148    }
1149
1150    fn accessibility(&self, builder: &mut AccessNodeBuilder) {
1151        builder.set_role(teksilo_core::accesskit::Role::Toolbar);
1152        builder.set_orientation(if self.horizontal() {
1153            teksilo_core::accesskit::Orientation::Horizontal
1154        } else {
1155            teksilo_core::accesskit::Orientation::Vertical
1156        });
1157        let name = self
1158            .label
1159            .as_ref()
1160            .map(|l| l.resolve_now())
1161            .unwrap_or_else(|| teksilo_i18n::tr_widget!(a11y_toolbar_name()).resolve_now());
1162        builder.set_name(name);
1163    }
1164
1165    fn children(&self) -> Vec<WidgetId> {
1166        self.root_child_id.into_iter().collect()
1167    }
1168}
1169
1170impl Toolbar {
1171    /// The ordered list of currently-focusable controls: visible action
1172    /// buttons (declaration order) followed by the chevron when overflowing.
1173    /// Returned as roving indices (`0..n` for actions, `n` for the chevron).
1174    fn focusable_indices(&self, flags: &[bool]) -> Vec<usize> {
1175        let mut seq: Vec<usize> = Vec::new();
1176        for i in 0..self.collapsible_ids.len() {
1177            if flags.get(i).copied() != Some(true) {
1178                seq.push(i);
1179            }
1180        }
1181        if flags.iter().any(|&f| f) {
1182            seq.push(self.collapsible_ids.len()); // chevron slot
1183        }
1184        seq
1185    }
1186
1187    /// Resolve a roving index to the widget id it controls.
1188    fn id_for_roving(&self, r: usize) -> Option<WidgetId> {
1189        if r < self.collapsible_ids.len() {
1190            Some(self.collapsible_ids[r])
1191        } else {
1192            self.chevron_id
1193        }
1194    }
1195
1196    /// Ensure `roving` points at a focusable (visible) control.
1197    fn clamp_roving(&self, flags: &[bool]) {
1198        let seq = self.focusable_indices(flags);
1199        if seq.is_empty() {
1200            return;
1201        }
1202        let cur = self.roving.get();
1203        if !seq.contains(&cur) {
1204            self.roving.set(seq[0]);
1205        }
1206    }
1207}
1208
1209/// Greedy priority overflow: keep actions inline in declaration order, but drop
1210/// the **lowest-priority** ones (ties: last declared) into the menu until the
1211/// rest fit, reserving the chevron once anything overflows. `always_overflow`
1212/// actions start collapsed. Returns a per-action collapsed flag.
1213fn compute_overflow(
1214    avail: f32,
1215    pinned_total: f32,
1216    action_w: &[f32],
1217    priority: &[i32],
1218    always: &[bool],
1219    spacing: f32,
1220    chevron_extent: f32,
1221    total_slots: usize,
1222) -> Vec<bool> {
1223    let n = action_w.len();
1224    let mut collapsed = always.to_vec();
1225    collapsed.resize(n, false);
1226
1227    // Width of everything that is currently inline.
1228    let inline_width = |collapsed: &[bool], with_chevron: bool| -> f32 {
1229        let mut visible_slots = total_slots;
1230        let mut w = pinned_total;
1231        for i in 0..n {
1232            if collapsed[i] {
1233                visible_slots -= 1;
1234            } else {
1235                w += action_w[i];
1236            }
1237        }
1238        if with_chevron {
1239            w += chevron_extent;
1240            visible_slots += 1;
1241        }
1242        w + spacing * (visible_slots.saturating_sub(1)) as f32
1243    };
1244
1245    let any_collapsed = |c: &[bool]| c.iter().any(|&x| x);
1246
1247    // If nothing is forced into overflow and it all fits, done.
1248    if !any_collapsed(&collapsed) && inline_width(&collapsed, false) <= avail + 0.5 {
1249        return collapsed;
1250    }
1251
1252    // Otherwise the chevron is present; drop lowest-priority inline actions
1253    // until the rest fit (or none remain inline).
1254    loop {
1255        if inline_width(&collapsed, true) <= avail + 0.5 {
1256            break;
1257        }
1258        // Pick the lowest-priority still-inline action (ties: highest index).
1259        let mut victim: Option<usize> = None;
1260        for i in 0..n {
1261            if collapsed[i] {
1262                continue;
1263            }
1264            match victim {
1265                None => victim = Some(i),
1266                Some(v) => {
1267                    if priority[i] < priority[v] || (priority[i] == priority[v] && i > v) {
1268                        victim = Some(i);
1269                    }
1270                }
1271            }
1272        }
1273        match victim {
1274            Some(v) => collapsed[v] = true,
1275            None => break, // nothing left inline; residual overflow of the bar
1276        }
1277    }
1278    collapsed
1279}
1280
1281#[cfg(test)]
1282mod tests;