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