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;