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