Skip to main content

iced_cube/application/
toolbar.rs

1//! A compact strip of icon buttons: actions, toggles that stay pressed,
2//! single-choice groups, separators and a spacer.
3//!
4//! [`State`] owns the entries and whether each toggle or choice is on.
5//! The toolbar measures itself with iced's `responsive` widget, and the
6//! entries that do not fit move, in order from the end, into a "More"
7//! [dropdown menu](mod@crate::overlay::dropdown_menu) at the end of the strip,
8//! where toggles become checkbox items and choices radio items. So a
9//! toolbar built for a desktop window still works on a phone.
10//!
11//! The keyboard moves a highlight along the strip. The app routes key
12//! presses from [`keys::subscription`] through [`State::key_event`]:
13//! Ctrl+F10 focuses the toolbar, Left and Right move between the buttons
14//! (and the More button), Enter or Space presses one and Escape leaves.
15//! iced gives an app-drawn strip no focus of its own, so the toolbar
16//! tracks its focus in [`State`] and draws the highlight as a ring.
17//!
18//! ```no_run
19//! use iced::Element;
20//! use iced_cube::lucide;
21//! use iced_cube::toolbar::{self, Output, separator, toggle, toolbar};
22//!
23//! #[derive(Debug, Clone)]
24//! enum Message {
25//!     Toolbar(toolbar::Event<&'static str>),
26//! }
27//!
28//! struct App {
29//!     toolbar: toolbar::State<&'static str>,
30//! }
31//!
32//! impl App {
33//!     fn new() -> Self {
34//!         let toolbar = toolbar::State::new([
35//!             toggle("bold", lucide!(Bold), "Bold", false).shortcut("Ctrl+B"),
36//!             toggle("italic", lucide!(Italic), "Italic", false),
37//!             separator(),
38//!             toolbar::button("undo", lucide!(Undo2), "Undo"),
39//!         ]);
40//!         Self { toolbar }
41//!     }
42//!
43//!     fn update(&mut self, message: Message) {
44//!         let Message::Toolbar(event) = message;
45//!         if let Some(Output::Toggled(id, on)) = self.toolbar.update(event) {
46//!             println!("{id} is now {on}");
47//!         }
48//!     }
49//!
50//!     fn view(&self) -> Element<'_, Message> {
51//!         toolbar(&self.toolbar).on_event(Message::Toolbar).into()
52//!     }
53//! }
54//! ```
55
56use std::rc::Rc;
57
58use iced::keyboard::key::Named;
59use iced::widget::{self, column, container, responsive, row, sensor, space, stack};
60use iced::{Alignment, Background, Border, Element, Length};
61
62use crate::icon::Glyph;
63use crate::keys::{self, Chord, Keymap};
64use crate::overlay::anchored::Align;
65use crate::overlay::dropdown_menu::{self, dropdown_menu};
66use crate::overlay::tooltip::Position;
67use crate::primitives::button::Size;
68use crate::primitives::icon_button::icon_button;
69use crate::primitives::separator::vertical_separator;
70use crate::theme::{Tokens, radius, space as gap};
71
72pub use crate::overlay::dropdown_menu::Output;
73
74/// Space between neighbouring entries.
75pub const GAP: f32 = gap::XS;
76/// Space between the strip's edge and its entries.
77pub const PADDING: f32 = gap::XS;
78/// Width a separator takes: the line and a margin on each side.
79pub const SEPARATOR_WIDTH: f32 = 1.0 + 2.0 * gap::XS;
80/// Height of a separator's line.
81pub const SEPARATOR_HEIGHT: f32 = 20.0;
82/// Width of the highlight ring around the focused button.
83pub const RING_WIDTH: f32 = 2.0;
84
85/// What a button does when pressed.
86#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87pub enum Kind {
88    /// A plain action, such as undo.
89    Action,
90    /// Stays pressed while on, such as bold.
91    Toggle(bool),
92    /// One choice among the choice buttons next to it, such as an
93    /// alignment. A run of adjacent choices forms one group.
94    Choice(bool),
95}
96
97/// One icon button.
98#[derive(Debug, Clone, PartialEq)]
99pub struct Item<Id> {
100    pub id: Id,
101    pub icon: Glyph,
102    /// Shown as the tooltip and as the row label in the overflow menu.
103    pub label: String,
104    /// A hint such as `Ctrl+B`, added to the tooltip and shown in the
105    /// overflow menu. It does not bind the keys.
106    pub shortcut: Option<String>,
107    pub disabled: bool,
108    pub kind: Kind,
109}
110
111impl<Id> Item<Id> {
112    /// Whether a toggle or choice is on.
113    pub fn is_on(&self) -> bool {
114        matches!(self.kind, Kind::Toggle(true) | Kind::Choice(true))
115    }
116
117    /// The tooltip: the label, with the shortcut in brackets.
118    pub fn tooltip(&self) -> String {
119        match &self.shortcut {
120            Some(shortcut) => format!("{} ({shortcut})", self.label),
121            None => self.label.clone(),
122        }
123    }
124}
125
126/// One slot of a toolbar.
127#[derive(Debug, Clone, PartialEq)]
128pub enum Entry<Id> {
129    Item(Item<Id>),
130    /// A short vertical line between groups.
131    Separator,
132    /// Pushes the entries after it to the far end while everything fits.
133    Spacer,
134}
135
136fn new_item<Id>(id: Id, icon: Glyph, label: impl Into<String>, kind: Kind) -> Entry<Id> {
137    Entry::Item(Item {
138        id,
139        icon,
140        label: label.into(),
141        shortcut: None,
142        disabled: false,
143        kind,
144    })
145}
146
147/// An action button.
148pub fn button<Id>(id: Id, icon: Glyph, label: impl Into<String>) -> Entry<Id> {
149    new_item(id, icon, label, Kind::Action)
150}
151
152/// A button that stays pressed while on.
153pub fn toggle<Id>(id: Id, icon: Glyph, label: impl Into<String>, on: bool) -> Entry<Id> {
154    new_item(id, icon, label, Kind::Toggle(on))
155}
156
157/// A button that turns itself on and the choices next to it off.
158pub fn choice<Id>(id: Id, icon: Glyph, label: impl Into<String>, selected: bool) -> Entry<Id> {
159    new_item(id, icon, label, Kind::Choice(selected))
160}
161
162pub fn separator<Id>() -> Entry<Id> {
163    Entry::Separator
164}
165
166pub fn spacer<Id>() -> Entry<Id> {
167    Entry::Spacer
168}
169
170impl<Id> Entry<Id> {
171    /// Adds a shortcut hint. Ignored on separators and spacers, as is
172    /// [`disabled`](Self::disabled).
173    pub fn shortcut(self, hint: impl Into<String>) -> Self {
174        let hint = hint.into();
175        self.with_item(|item| item.shortcut = Some(hint))
176    }
177
178    pub fn disabled(self, disabled: bool) -> Self {
179        self.with_item(|item| item.disabled = disabled)
180    }
181
182    pub fn as_item(&self) -> Option<&Item<Id>> {
183        match self {
184            Entry::Item(item) => Some(item),
185            _ => None,
186        }
187    }
188
189    fn with_item(mut self, change: impl FnOnce(&mut Item<Id>)) -> Self {
190        if let Entry::Item(item) = &mut self {
191            change(item);
192        }
193        self
194    }
195
196    fn is_choice(&self) -> bool {
197        matches!(
198            self,
199            Entry::Item(Item {
200                kind: Kind::Choice(_),
201                ..
202            })
203        )
204    }
205
206    /// Width in the strip for buttons `side` pixels square.
207    fn width(&self, side: f32) -> f32 {
208        match self {
209            Entry::Item(_) => side,
210            Entry::Separator => SEPARATOR_WIDTH,
211            Entry::Spacer => 0.0,
212        }
213    }
214}
215
216/// How many entries fit in `width` before the rest move into the
217/// overflow menu. Every entry fits when they all do; otherwise room is
218/// kept for the More button and trailing separators and spacers go too.
219pub fn fit<Id>(entries: &[Entry<Id>], width: f32, size: Size) -> usize {
220    let side = size.metrics().height;
221    let available = width - 2.0 * PADDING;
222    let span = |entries: &[Entry<Id>]| -> f32 {
223        let widths: f32 = entries.iter().map(|entry| entry.width(side)).sum();
224        widths + GAP * entries.len().saturating_sub(1) as f32
225    };
226    if span(entries) <= available {
227        return entries.len();
228    }
229    let mut shown = (0..entries.len())
230        .rev()
231        .find(|&count| {
232            let before = entries.get(..count).unwrap_or_default();
233            span(before) + if count > 0 { GAP } else { 0.0 } + side <= available
234        })
235        .unwrap_or(0);
236    while shown > 0 && entries.get(shown - 1).and_then(Entry::as_item).is_none() {
237        shown -= 1;
238    }
239    shown
240}
241
242/// The overflow menu's entries for everything from `start` on. Toggles
243/// become checkbox items and choices radio items; spacers are dropped and
244/// separators kept only between items.
245pub fn overflow_entries<Id: Copy>(
246    entries: &[Entry<Id>],
247    start: usize,
248) -> Vec<dropdown_menu::Entry<Id>> {
249    let mut menu: Vec<dropdown_menu::Entry<Id>> = Vec::new();
250    for entry in entries.get(start..).unwrap_or_default() {
251        match entry {
252            Entry::Item(item) => menu.push(overflow_item(item)),
253            Entry::Separator => {
254                if menu.last().is_some_and(|last| last.as_item().is_some()) {
255                    menu.push(dropdown_menu::separator());
256                }
257            }
258            Entry::Spacer => {}
259        }
260    }
261    if menu.last().is_some_and(|last| last.as_item().is_none()) {
262        menu.pop();
263    }
264    menu
265}
266
267fn overflow_item<Id: Copy>(item: &Item<Id>) -> dropdown_menu::Entry<Id> {
268    let entry = match item.kind {
269        Kind::Action => dropdown_menu::item(item.id, item.label.clone()),
270        Kind::Toggle(on) => dropdown_menu::checkbox_item(item.id, item.label.clone(), on),
271        Kind::Choice(on) => dropdown_menu::radio_item(item.id, item.label.clone(), on),
272    };
273    let entry = entry.icon(item.icon).disabled(item.disabled);
274    match &item.shortcut {
275        Some(shortcut) => entry.shortcut(shortcut.clone()),
276        None => entry,
277    }
278}
279
280/// Where the keyboard highlight is.
281#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
282pub enum Highlight<Id> {
283    Item(Id),
284    /// The More button that opens the overflow menu.
285    More,
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289enum Stop {
290    Entry(usize),
291    More,
292}
293
294/// Changes to a toolbar.
295#[derive(Debug, Clone, Copy, PartialEq)]
296pub enum Event<Id> {
297    /// The strip was measured at this width. The widget sends this itself.
298    Resized(f32),
299    /// A button in the strip was pressed.
300    Press(Id),
301    /// An event for the overflow menu, holding everything from this entry on.
302    Overflow(usize, dropdown_menu::Event<Id>),
303    /// Highlights the first enabled button, or leaves the toolbar when it
304    /// has focus.
305    Focus,
306    /// Highlights the next button, wrapping.
307    Next,
308    /// Highlights the previous button, wrapping.
309    Previous,
310    First,
311    Last,
312    /// Presses the highlighted button, or opens the overflow menu on its
313    /// first item.
314    ActivateHighlighted,
315    /// Closes the overflow menu, or leaves the toolbar.
316    Close,
317}
318
319/// The entries, their on and off states, the overflow menu and the
320/// keyboard highlight.
321#[derive(Debug, Clone, PartialEq)]
322pub struct State<Id> {
323    entries: Vec<Entry<Id>>,
324    size: Size,
325    width: Option<f32>,
326    overflow: dropdown_menu::State<Id>,
327    split: usize,
328    highlighted: Option<Stop>,
329}
330
331impl<Id: Copy + PartialEq> State<Id> {
332    /// A toolbar of small buttons. Item ids must be unique.
333    pub fn new(entries: impl IntoIterator<Item = Entry<Id>>) -> Self {
334        let entries: Vec<Entry<Id>> = entries.into_iter().collect();
335        let split = entries.len();
336        Self {
337            overflow: dropdown_menu::State::new(overflow_entries(&entries, split)),
338            entries,
339            size: Size::Sm,
340            width: None,
341            split,
342            highlighted: None,
343        }
344    }
345
346    /// Sets the button size. Toolbars default to [`Size::Sm`].
347    pub fn with_size(mut self, size: Size) -> Self {
348        self.size = size;
349        self
350    }
351
352    pub fn entries(&self) -> &[Entry<Id>] {
353        &self.entries
354    }
355
356    pub fn size(&self) -> Size {
357        self.size
358    }
359
360    pub fn item(&self, id: Id) -> Option<&Item<Id>> {
361        self.entries
362            .iter()
363            .filter_map(Entry::as_item)
364            .find(|item| item.id == id)
365    }
366
367    /// Whether a toggle or choice is on.
368    pub fn is_on(&self, id: Id) -> bool {
369        self.item(id).is_some_and(Item::is_on)
370    }
371
372    /// How many entries show in the strip at the last measured width. The
373    /// rest are in the overflow menu. All of them until the strip is measured.
374    pub fn visible(&self) -> usize {
375        self.width.map_or(self.entries.len(), |width| {
376            fit(&self.entries, width, self.size)
377        })
378    }
379
380    /// The overflow menu, open or closed.
381    pub fn overflow(&self) -> &dropdown_menu::State<Id> {
382        &self.overflow
383    }
384
385    /// Where the keyboard highlight is, while the toolbar has focus.
386    pub fn highlighted(&self) -> Option<Highlight<Id>> {
387        match self.highlighted? {
388            Stop::More => Some(Highlight::More),
389            Stop::Entry(index) => self
390                .entries
391                .get(index)
392                .and_then(Entry::as_item)
393                .map(|item| Highlight::Item(item.id)),
394        }
395    }
396
397    pub fn is_focused(&self) -> bool {
398        self.highlighted.is_some()
399    }
400
401    /// Turns a toggle or choice on or off. Turning a choice on turns the
402    /// other choices in its group off.
403    pub fn set_on(&mut self, id: Id, on: bool) {
404        let Some(index) = self.position(id) else {
405            return;
406        };
407        let group = self.choice_group(index);
408        let Some(Entry::Item(item)) = self.entries.get_mut(index) else {
409            return;
410        };
411        match &mut item.kind {
412            Kind::Toggle(value) => *value = on,
413            Kind::Choice(_) if on => {
414                for sibling in group {
415                    if let Some(Entry::Item(item)) = self.entries.get_mut(sibling) {
416                        item.kind = Kind::Choice(sibling == index);
417                    }
418                }
419            }
420            Kind::Choice(value) => *value = false,
421            Kind::Action => return,
422        }
423        self.rebuild(self.split);
424    }
425
426    /// Enables or disables a button. A disabled button loses the highlight.
427    pub fn set_disabled(&mut self, id: Id, disabled: bool) {
428        let Some(index) = self.position(id) else {
429            return;
430        };
431        if let Some(Entry::Item(item)) = self.entries.get_mut(index) {
432            item.disabled = disabled;
433        }
434        if disabled && self.highlighted == Some(Stop::Entry(index)) {
435            self.highlighted = self.stops().first().copied();
436        }
437        self.rebuild(self.split);
438    }
439
440    /// Applies an event and returns what a press did, if anything.
441    pub fn update(&mut self, event: Event<Id>) -> Option<Output<Id>> {
442        match event {
443            Event::Resized(width) => {
444                self.width = Some(width);
445                self.keep_highlight_in_view();
446            }
447            Event::Press(id) => {
448                self.highlighted = None;
449                return self.press(id);
450            }
451            Event::Overflow(split, event) => return self.overflow_event(split, event),
452            Event::Focus if self.is_focused() => self.leave(),
453            Event::Focus => self.highlighted = self.stops().first().copied(),
454            Event::Next => self.step(true),
455            Event::Previous => self.step(false),
456            Event::First if self.is_focused() => self.highlighted = self.stops().first().copied(),
457            Event::Last if self.is_focused() => self.highlighted = self.stops().last().copied(),
458            Event::First | Event::Last => {}
459            Event::ActivateHighlighted => return self.activate_highlighted(),
460            Event::Close if self.overflow.is_open() => {
461                let _ = self.overflow.update(dropdown_menu::Event::Close);
462            }
463            Event::Close => self.highlighted = None,
464        }
465        None
466    }
467
468    fn leave(&mut self) {
469        self.highlighted = None;
470        let _ = self.overflow.update(dropdown_menu::Event::Close);
471    }
472
473    fn position(&self, id: Id) -> Option<usize> {
474        self.entries
475            .iter()
476            .position(|entry| entry.as_item().is_some_and(|item| item.id == id))
477    }
478
479    /// The indices of the run of choices around `index`.
480    fn choice_group(&self, index: usize) -> std::ops::Range<usize> {
481        let before = self.entries.get(..index).unwrap_or_default();
482        let start = before
483            .iter()
484            .rposition(|entry| !entry.is_choice())
485            .map_or(0, |position| position + 1);
486        let after = self.entries.get(index..).unwrap_or_default();
487        let end = after
488            .iter()
489            .position(|entry| !entry.is_choice())
490            .map_or(self.entries.len(), |position| index + position);
491        start..end
492    }
493
494    /// Applies a press on a button, wherever it is shown.
495    fn press(&mut self, id: Id) -> Option<Output<Id>> {
496        let item = self.item(id).filter(|item| !item.disabled)?;
497        let output = match item.kind {
498            Kind::Action => Output::Activated(id),
499            Kind::Toggle(on) => Output::Toggled(id, !on),
500            Kind::Choice(_) => Output::Selected(id),
501        };
502        match output {
503            Output::Toggled(_, on) => self.set_on(id, on),
504            Output::Selected(_) => self.set_on(id, true),
505            Output::Activated(_) => {}
506        }
507        Some(output)
508    }
509
510    fn overflow_event(
511        &mut self,
512        split: usize,
513        event: dropdown_menu::Event<Id>,
514    ) -> Option<Output<Id>> {
515        if split != self.split {
516            self.rebuild(split);
517        }
518        let output = self.overflow.update(event)?;
519        let id = match output {
520            Output::Activated(id) | Output::Toggled(id, _) | Output::Selected(id) => id,
521        };
522        match output {
523            Output::Toggled(_, on) => self.set_on(id, on),
524            Output::Selected(_) => self.set_on(id, true),
525            Output::Activated(_) => {}
526        }
527        Some(output)
528    }
529
530    /// Rebuilds the overflow menu for the entries from `split` on,
531    /// keeping it open if it was.
532    fn rebuild(&mut self, split: usize) {
533        let open = self.overflow.is_open() && split == self.split;
534        self.split = split;
535        self.overflow = dropdown_menu::State::new(overflow_entries(&self.entries, split));
536        if open {
537            let _ = self.overflow.update(dropdown_menu::Event::Open);
538        }
539    }
540
541    /// The highlight's stops: every enabled button in view, then More if
542    /// anything overflows.
543    fn stops(&self) -> Vec<Stop> {
544        let visible = self.visible();
545        let mut stops: Vec<Stop> = self
546            .entries
547            .iter()
548            .take(visible)
549            .enumerate()
550            .filter(|(_, entry)| entry.as_item().is_some_and(|item| !item.disabled))
551            .map(|(index, _)| Stop::Entry(index))
552            .collect();
553        if visible < self.entries.len() {
554            stops.push(Stop::More);
555        }
556        stops
557    }
558
559    fn step(&mut self, forward: bool) {
560        let Some(current) = self.highlighted else {
561            return;
562        };
563        let stops = self.stops();
564        let Some(position) = stops.iter().position(|stop| *stop == current) else {
565            self.highlighted = stops.first().copied();
566            return;
567        };
568        let len = stops.len();
569        let target = if forward {
570            (position + 1) % len
571        } else {
572            (position + len - 1) % len
573        };
574        self.highlighted = stops.get(target).copied();
575    }
576
577    fn keep_highlight_in_view(&mut self) {
578        let Some(current) = self.highlighted else {
579            return;
580        };
581        let stops = self.stops();
582        if !stops.contains(&current) {
583            self.highlighted = stops.last().copied();
584        }
585    }
586
587    fn activate_highlighted(&mut self) -> Option<Output<Id>> {
588        match self.highlighted? {
589            Stop::More => {
590                let visible = self.visible();
591                if visible != self.split {
592                    self.rebuild(visible);
593                }
594                let _ = self.overflow.update(dropdown_menu::Event::First);
595                None
596            }
597            Stop::Entry(index) => {
598                let id = self.entries.get(index)?.as_item()?.id;
599                self.press(id)
600            }
601        }
602    }
603
604    /// Turns a key press into an event: [`Action::Focus`] at any time, the
605    /// other actions only while the toolbar has focus. The open overflow
606    /// menu resolves its own keys before any of these.
607    pub fn key_event(&self, keymap: &Keymap<Action>, key: &keys::Event) -> Option<Event<Id>> {
608        keymap
609            .resolve_event(key)
610            .and_then(|action| action.event(self))
611    }
612}
613
614/// What a toolbar keyboard shortcut does.
615#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
616pub enum Action {
617    /// Focuses the toolbar, or leaves it.
618    Focus,
619    Next,
620    Previous,
621    First,
622    Last,
623    Activate,
624    Close,
625}
626
627impl Action {
628    /// The [`Event`] this action sends to `state`, or `None` when it does
629    /// nothing: only [`Action::Focus`] works while the toolbar has no focus.
630    pub fn event<Id: Copy + PartialEq>(self, state: &State<Id>) -> Option<Event<Id>> {
631        if !state.is_focused() {
632            return (self == Action::Focus).then_some(Event::Focus);
633        }
634        Some(match self {
635            Action::Focus => Event::Focus,
636            Action::Next => Event::Next,
637            Action::Previous => Event::Previous,
638            Action::First => Event::First,
639            Action::Last => Event::Last,
640            Action::Activate => Event::ActivateHighlighted,
641            Action::Close => Event::Close,
642        })
643    }
644}
645
646impl keys::Action for Action {
647    const ALL: &'static [Self] = &[
648        Action::Focus,
649        Action::Next,
650        Action::Previous,
651        Action::First,
652        Action::Last,
653        Action::Activate,
654        Action::Close,
655    ];
656
657    fn defaults() -> Keymap<Self> {
658        default_keymap()
659    }
660
661    fn name(self) -> &'static str {
662        match self {
663            Action::Focus => "Focus",
664            Action::Next => "Next",
665            Action::Previous => "Previous",
666            Action::First => "First",
667            Action::Last => "Last",
668            Action::Activate => "Activate",
669            Action::Close => "Close",
670        }
671    }
672
673    fn description(self) -> &'static str {
674        match self {
675            Action::Focus => "Highlights the first button, or leaves the toolbar.",
676            Action::Next => "Highlights the next button, wrapping, with More last.",
677            Action::Previous => "Highlights the previous button, wrapping.",
678            Action::First => "Highlights the first button.",
679            Action::Last => "Highlights the last button, or More when anything overflows.",
680            Action::Activate => "Presses the highlighted button, or opens the overflow menu.",
681            Action::Close => "Leaves the toolbar.",
682        }
683    }
684}
685
686/// The default toolbar shortcuts:
687///
688/// | Keys | Action |
689/// | --- | --- |
690/// | `Ctrl+F10` | [`Action::Focus`] |
691/// | `ArrowRight` | [`Action::Next`] |
692/// | `ArrowLeft` | [`Action::Previous`] |
693/// | `Home` | [`Action::First`] |
694/// | `End` | [`Action::Last`] |
695/// | `Enter`, `Space` | [`Action::Activate`] |
696/// | `Escape` | [`Action::Close`] |
697///
698/// Route presses from [`keys::subscription`] through [`State::key_event`].
699/// Only Focus claims a key until the toolbar has focus, so the arrows stay
700/// free for the rest of the app; route the toolbar after anything that
701/// should see the arrows first while it is focused. The open overflow
702/// menu handles its own keys with the dropdown menu keymap.
703pub fn default_keymap() -> Keymap<Action> {
704    Keymap::new()
705        .bind(Chord::named(Named::F10).ctrl(), Action::Focus)
706        .bind(Chord::named(Named::ArrowRight), Action::Next)
707        .bind(Chord::named(Named::ArrowLeft), Action::Previous)
708        .bind(Chord::named(Named::Home), Action::First)
709        .bind(Chord::named(Named::End), Action::Last)
710        .bind(Chord::named(Named::Enter), Action::Activate)
711        .bind(Chord::named(Named::Space), Action::Activate)
712        .bind(Chord::named(Named::Escape), Action::Close)
713}
714
715/// The ring drawn around the highlighted button.
716pub fn ring_style(tokens: &Tokens) -> container::Style {
717    container::Style {
718        border: Border {
719            color: tokens.ring,
720            width: RING_WIDTH,
721            radius: radius::MD.into(),
722        },
723        ..container::Style::default()
724    }
725}
726
727/// The style of the strip behind the buttons.
728pub fn bar_style(tokens: &Tokens) -> container::Style {
729    container::Style {
730        background: Some(Background::Color(tokens.background)),
731        text_color: Some(tokens.foreground),
732        ..container::Style::default()
733    }
734}
735
736type OnEvent<'a, Id, Message> = Rc<dyn Fn(Event<Id>) -> Message + 'a>;
737
738/// A toolbar builder. Convert it into an [`Element`] to render.
739pub struct Toolbar<'a, Id, Message> {
740    state: &'a State<Id>,
741    tooltip: Option<Position>,
742    more: String,
743    menu_keymap: Keymap<dropdown_menu::Action>,
744    on_event: Option<Box<dyn Fn(Event<Id>) -> Message + 'a>>,
745}
746
747impl<Id: std::fmt::Debug, Message> std::fmt::Debug for Toolbar<'_, Id, Message> {
748    fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
749        f.debug_struct("Toolbar")
750            .field("state", self.state)
751            .field("tooltip", &self.tooltip)
752            .field("more", &self.more)
753            .field("menu_keymap", &self.menu_keymap)
754            .finish_non_exhaustive()
755    }
756}
757
758/// Renders the entries of `state` that fit, and a More menu with the rest.
759/// Without [`on_event`](Toolbar::on_event) every button renders disabled.
760pub fn toolbar<'a, Id, Message>(state: &'a State<Id>) -> Toolbar<'a, Id, Message> {
761    Toolbar {
762        state,
763        tooltip: Some(Position::Bottom),
764        more: String::from("More"),
765        menu_keymap: dropdown_menu::default_keymap(),
766        on_event: None,
767    }
768}
769
770impl<'a, Id, Message> Toolbar<'a, Id, Message> {
771    /// Where the button tooltips appear, or `None` for none. Defaults to
772    /// below, since toolbars usually sit at the top of a window.
773    pub fn tooltip(mut self, position: Option<Position>) -> Self {
774        self.tooltip = position;
775        self
776    }
777
778    /// The More button's label, shown as its tooltip.
779    pub fn more_label(mut self, label: impl Into<String>) -> Self {
780        self.more = label.into();
781        self
782    }
783
784    /// Replaces the [dropdown menu keys](dropdown_menu::default_keymap)
785    /// the open overflow menu resolves.
786    pub fn menu_keymap(mut self, keymap: Keymap<dropdown_menu::Action>) -> Self {
787        self.menu_keymap = keymap;
788        self
789    }
790
791    pub fn on_event(mut self, on_event: impl Fn(Event<Id>) -> Message + 'a) -> Self {
792        self.on_event = Some(Box::new(on_event));
793        self
794    }
795}
796
797impl<'a, Id, Message> From<Toolbar<'a, Id, Message>> for Element<'a, Message>
798where
799    Id: Copy + PartialEq + 'a,
800    Message: Clone + 'a,
801{
802    fn from(toolbar: Toolbar<'a, Id, Message>) -> Self {
803        let Toolbar {
804            state,
805            tooltip,
806            more,
807            menu_keymap,
808            on_event,
809        } = toolbar;
810        let on_event: Option<OnEvent<'a, Id, Message>> = on_event.map(Rc::from);
811        let on_resize = on_event.clone();
812        let strip = responsive(move |size| {
813            strip(
814                state,
815                size.width,
816                tooltip,
817                &more,
818                &menu_keymap,
819                on_event.clone(),
820            )
821        })
822        .height(Length::Shrink);
823
824        let measured: Element<'a, Message> = match on_resize {
825            Some(on_event) => sensor(strip)
826                .on_resize(move |size| on_event(Event::Resized(size.width)))
827                .into(),
828            None => strip.into(),
829        };
830        column![measured, crate::application::edge()]
831            .width(Length::Fill)
832            .into()
833    }
834}
835
836/// The entries that fit in `width`, then the More button.
837fn strip<'a, Id, Message>(
838    state: &'a State<Id>,
839    width: f32,
840    tooltip: Option<Position>,
841    more: &str,
842    menu_keymap: &Keymap<dropdown_menu::Action>,
843    on_event: Option<OnEvent<'a, Id, Message>>,
844) -> Element<'a, Message>
845where
846    Id: Copy + PartialEq + 'a,
847    Message: Clone + 'a,
848{
849    let shown = fit(&state.entries, width, state.size);
850    let mut children: Vec<Element<'a, Message>> = state
851        .entries
852        .iter()
853        .take(shown)
854        .enumerate()
855        .map(|(index, entry)| match entry {
856            Entry::Item(item) => {
857                let highlighted = state.highlighted == Some(Stop::Entry(index));
858                item_button(item, state.size, tooltip, highlighted, on_event.as_ref())
859            }
860            Entry::Separator => container(vertical_separator())
861                .height(SEPARATOR_HEIGHT)
862                .padding([0.0, gap::XS])
863                .into(),
864            Entry::Spacer => space::horizontal().into(),
865        })
866        .collect();
867    if shown < state.entries.len() {
868        children.push(more_button(
869            state,
870            shown,
871            tooltip,
872            more,
873            menu_keymap,
874            on_event.as_ref(),
875        ));
876    }
877
878    container(
879        row(children)
880            .spacing(GAP)
881            .align_y(Alignment::Center)
882            .width(Length::Fill),
883    )
884    .padding(PADDING)
885    .width(Length::Fill)
886    .style(|theme| bar_style(&Tokens::of(theme)))
887    .into()
888}
889
890fn item_button<'a, Id, Message>(
891    item: &Item<Id>,
892    size: Size,
893    tooltip: Option<Position>,
894    highlighted: bool,
895    on_event: Option<&OnEvent<'a, Id, Message>>,
896) -> Element<'a, Message>
897where
898    Id: Copy + 'a,
899    Message: Clone + 'a,
900{
901    let on_press = on_event
902        .filter(|_| !item.disabled)
903        .map(|on_event| on_event(Event::Press(item.id)));
904    let button = icon_button(item.icon)
905        .label(item.tooltip())
906        .tooltip(tooltip)
907        .size(size)
908        .pressed(item.is_on())
909        .on_press_maybe(on_press);
910    ringed(button, highlighted)
911}
912
913fn more_button<'a, Id, Message>(
914    state: &'a State<Id>,
915    split: usize,
916    tooltip: Option<Position>,
917    more: &str,
918    menu_keymap: &Keymap<dropdown_menu::Action>,
919    on_event: Option<&OnEvent<'a, Id, Message>>,
920) -> Element<'a, Message>
921where
922    Id: Copy + PartialEq + 'a,
923    Message: Clone + 'a,
924{
925    let open = state.overflow.is_open() && state.split == split;
926    let trigger = icon_button(crate::lucide!(Ellipsis))
927        .label(more.to_owned())
928        .tooltip(tooltip.filter(|_| !open))
929        .size(state.size)
930        .pressed(open)
931        .on_press_maybe(
932            on_event.map(|on_event| on_event(Event::Overflow(split, dropdown_menu::Event::Toggle))),
933        );
934    let trigger = ringed(trigger, state.highlighted == Some(Stop::More));
935    let Some(on_event) = on_event.filter(|_| open).cloned() else {
936        return trigger;
937    };
938    dropdown_menu(&state.overflow, trigger)
939        .align(Align::End)
940        .keymap(menu_keymap.clone())
941        .on_event(move |event| on_event(Event::Overflow(split, event)))
942        .into()
943}
944
945/// Draws the highlight ring over a button while it has the keyboard focus.
946fn ringed<'a, Message: Clone + 'a>(
947    button: impl Into<Element<'a, Message>>,
948    highlighted: bool,
949) -> Element<'a, Message> {
950    let button = button.into();
951    if !highlighted {
952        return button;
953    }
954    stack![
955        button,
956        container(widget::space())
957            .width(Length::Fill)
958            .height(Length::Fill)
959            .style(|theme| ring_style(&Tokens::of(theme))),
960    ]
961    .into()
962}
963
964#[cfg(test)]
965mod tests {
966    use super::*;
967    use crate::theme::{dark, light};
968    use iced::keyboard::{Key, Modifiers};
969
970    const BOLD: u8 = 1;
971    const ITALIC: u8 = 2;
972    const LEFT: u8 = 3;
973    const RIGHT: u8 = 4;
974    const UNDO: u8 = 5;
975    const REDO: u8 = 6;
976
977    fn entries() -> Vec<Entry<u8>> {
978        vec![
979            toggle(BOLD, crate::lucide!(Bold), "Bold", true).shortcut("Ctrl+B"),
980            toggle(ITALIC, crate::lucide!(Italic), "Italic", false),
981            choice(LEFT, crate::lucide!(TextAlignStart), "Left", true),
982            separator(),
983            choice(RIGHT, crate::lucide!(TextAlignEnd), "Right", false),
984            button(UNDO, crate::lucide!(Undo2), "Undo"),
985        ]
986    }
987
988    fn state() -> State<u8> {
989        State::new(entries())
990    }
991
992    /// Measured at 160 pixels, where three buttons fit before More.
993    fn narrow() -> State<u8> {
994        let mut state = state();
995        let _ = state.update(Event::Resized(160.0));
996        state
997    }
998
999    #[test]
1000    fn builders_set_item_fields_and_ignore_separators() {
1001        let entry: Entry<u8> = button(UNDO, crate::lucide!(Undo2), "Undo")
1002            .shortcut("Ctrl+Z")
1003            .disabled(true);
1004        let item = entry.as_item().unwrap();
1005        assert_eq!(item.label, "Undo");
1006        assert_eq!(item.icon, crate::lucide!(Undo2));
1007        assert_eq!(item.shortcut.as_deref(), Some("Ctrl+Z"));
1008        assert!(item.disabled);
1009        assert_eq!(item.kind, Kind::Action);
1010        assert_eq!(item.tooltip(), "Undo (Ctrl+Z)");
1011        assert_eq!(separator::<u8>().disabled(true), Entry::Separator);
1012        assert_eq!(spacer::<u8>().shortcut("X"), Entry::Spacer);
1013        assert!(separator::<u8>().as_item().is_none());
1014    }
1015
1016    #[test]
1017    fn a_new_toolbar_is_small_unmeasured_and_unfocused() {
1018        let state = state();
1019        assert_eq!(state.size(), Size::Sm);
1020        assert_eq!(state.visible(), state.entries().len());
1021        assert!(!state.is_focused());
1022        assert_eq!(state.highlighted(), None);
1023        assert!(!state.overflow().is_open());
1024        assert!(state.is_on(BOLD) && state.is_on(LEFT));
1025        assert!(!state.is_on(ITALIC) && !state.is_on(UNDO));
1026        assert_eq!(state.with_size(Size::Lg).size(), Size::Lg);
1027    }
1028
1029    #[test]
1030    fn everything_fits_when_there_is_room() {
1031        // Five 32 pixel buttons, a 9 pixel separator, five gaps and padding.
1032        let exact = 5.0 * 32.0 + SEPARATOR_WIDTH + 5.0 * GAP + 2.0 * PADDING;
1033        assert_eq!(fit(&entries(), exact, Size::Sm), 6);
1034        assert_eq!(fit(&entries(), exact + 100.0, Size::Sm), 6);
1035        assert_eq!(fit::<u8>(&[], 0.0, Size::Sm), 0);
1036    }
1037
1038    #[test]
1039    fn overflow_keeps_room_for_more_and_drops_trailing_separators() {
1040        let exact = 5.0 * 32.0 + SEPARATOR_WIDTH + 5.0 * GAP + 2.0 * PADDING;
1041        assert_eq!(
1042            fit(&entries(), exact - 1.0, Size::Sm),
1043            3,
1044            "the separator before the cut goes too"
1045        );
1046        assert_eq!(fit(&entries(), 160.0, Size::Sm), 3);
1047        assert_eq!(fit(&entries(), 100.0, Size::Sm), 1);
1048        assert_eq!(fit(&entries(), 10.0, Size::Sm), 0);
1049        assert!(
1050            fit(&entries(), 160.0, Size::Lg) < 3,
1051            "bigger buttons fit fewer"
1052        );
1053    }
1054
1055    #[test]
1056    fn spacers_take_no_room() {
1057        let entries = vec![
1058            button(UNDO, crate::lucide!(Undo2), "Undo"),
1059            spacer(),
1060            button(REDO, crate::lucide!(Redo2), "Redo"),
1061        ];
1062        let exact = 2.0 * 32.0 + 2.0 * GAP + 2.0 * PADDING;
1063        assert_eq!(fit(&entries, exact, Size::Sm), 3);
1064        assert_eq!(fit(&entries, exact - 1.0, Size::Sm), 1);
1065    }
1066
1067    #[test]
1068    fn overflow_entries_map_kinds_and_tidy_separators() {
1069        let menu = overflow_entries(&entries(), 0);
1070        let kinds: Vec<_> = menu
1071            .iter()
1072            .map(|entry| entry.as_item().map(|item| item.kind.clone()))
1073            .collect();
1074        assert_eq!(
1075            kinds,
1076            [
1077                Some(dropdown_menu::Kind::Checkbox(true)),
1078                Some(dropdown_menu::Kind::Checkbox(false)),
1079                Some(dropdown_menu::Kind::Radio(true)),
1080                None,
1081                Some(dropdown_menu::Kind::Radio(false)),
1082                Some(dropdown_menu::Kind::Action),
1083            ]
1084        );
1085        let bold = menu[0].as_item().unwrap();
1086        assert_eq!(bold.shortcut.as_deref(), Some("Ctrl+B"));
1087        assert_eq!(bold.icon, Some(crate::lucide!(Bold)));
1088
1089        let from_separator = overflow_entries(&entries(), 3);
1090        assert!(
1091            from_separator[0].as_item().is_some(),
1092            "no leading separator"
1093        );
1094        assert_eq!(from_separator.len(), 2);
1095
1096        let tail = vec![
1097            button(UNDO, crate::lucide!(Undo2), "Undo"),
1098            separator(),
1099            spacer(),
1100        ];
1101        assert_eq!(overflow_entries(&tail, 0).len(), 1, "no trailing separator");
1102        assert!(overflow_entries(&tail, 9).is_empty());
1103    }
1104
1105    #[test]
1106    fn pressing_toggles_selects_and_activates() {
1107        let mut state = state();
1108        assert_eq!(
1109            state.update(Event::Press(BOLD)),
1110            Some(Output::Toggled(BOLD, false))
1111        );
1112        assert!(!state.is_on(BOLD));
1113        assert_eq!(
1114            state.update(Event::Press(RIGHT)),
1115            Some(Output::Selected(RIGHT))
1116        );
1117        assert!(state.is_on(RIGHT));
1118        assert!(state.is_on(LEFT), "a separator ends the choice group");
1119        assert_eq!(
1120            state.update(Event::Press(UNDO)),
1121            Some(Output::Activated(UNDO))
1122        );
1123        assert_eq!(state.update(Event::Press(99)), None);
1124    }
1125
1126    #[test]
1127    fn choices_in_one_run_exclude_each_other() {
1128        let mut state = State::new([
1129            choice(LEFT, crate::lucide!(TextAlignStart), "Left", true),
1130            choice(RIGHT, crate::lucide!(TextAlignEnd), "Right", false),
1131        ]);
1132        let _ = state.update(Event::Press(RIGHT));
1133        assert!(state.is_on(RIGHT) && !state.is_on(LEFT));
1134        state.set_on(RIGHT, false);
1135        assert!(!state.is_on(RIGHT) && !state.is_on(LEFT));
1136        state.set_on(LEFT, true);
1137        assert!(state.is_on(LEFT));
1138    }
1139
1140    #[test]
1141    fn disabled_buttons_do_nothing_and_lose_the_highlight() {
1142        let mut state = state();
1143        state.set_disabled(UNDO, true);
1144        assert_eq!(state.update(Event::Press(UNDO)), None);
1145        state.set_on(UNDO, true);
1146        assert!(!state.is_on(UNDO), "actions have no on state");
1147
1148        let _ = state.update(Event::Focus);
1149        assert_eq!(state.highlighted(), Some(Highlight::Item(BOLD)));
1150        state.set_disabled(BOLD, true);
1151        assert_eq!(state.highlighted(), Some(Highlight::Item(ITALIC)));
1152    }
1153
1154    #[test]
1155    fn the_overflow_menu_holds_what_does_not_fit_and_syncs_back() {
1156        let mut state = narrow();
1157        assert_eq!(state.visible(), 3);
1158        let _ = state.update(Event::Overflow(3, dropdown_menu::Event::Toggle));
1159        assert!(state.overflow().is_open());
1160        assert!(state.overflow().item(RIGHT).is_some());
1161        assert!(state.overflow().item(BOLD).is_none());
1162
1163        assert_eq!(
1164            state.update(Event::Overflow(3, dropdown_menu::Event::Activate(RIGHT))),
1165            Some(Output::Selected(RIGHT))
1166        );
1167        assert!(state.is_on(RIGHT));
1168        assert!(!state.overflow().is_open());
1169
1170        let _ = state.update(Event::Overflow(1, dropdown_menu::Event::Toggle));
1171        assert!(
1172            state.overflow().item(ITALIC).is_some(),
1173            "rebuilt for a new split"
1174        );
1175        assert_eq!(
1176            state.update(Event::Overflow(1, dropdown_menu::Event::Activate(ITALIC))),
1177            Some(Output::Toggled(ITALIC, true))
1178        );
1179        assert!(state.is_on(ITALIC));
1180    }
1181
1182    #[test]
1183    fn changing_a_toggle_keeps_an_open_overflow_menu_open() {
1184        let mut state = narrow();
1185        let _ = state.update(Event::Overflow(3, dropdown_menu::Event::Toggle));
1186        state.set_on(RIGHT, true);
1187        assert!(state.overflow().is_open());
1188        assert!(state.overflow().is_checked(RIGHT));
1189    }
1190
1191    #[test]
1192    fn focus_moves_through_the_visible_buttons_then_more() {
1193        let mut state = narrow();
1194        let _ = state.update(Event::Next);
1195        assert!(!state.is_focused(), "an unfocused toolbar does not move");
1196        let _ = state.update(Event::Focus);
1197        let mut seen = vec![state.highlighted().unwrap()];
1198        for _ in 0..4 {
1199            let _ = state.update(Event::Next);
1200            seen.push(state.highlighted().unwrap());
1201        }
1202        assert_eq!(
1203            seen,
1204            [
1205                Highlight::Item(BOLD),
1206                Highlight::Item(ITALIC),
1207                Highlight::Item(LEFT),
1208                Highlight::More,
1209                Highlight::Item(BOLD),
1210            ]
1211        );
1212        let _ = state.update(Event::Previous);
1213        assert_eq!(state.highlighted(), Some(Highlight::More));
1214        let _ = state.update(Event::First);
1215        assert_eq!(state.highlighted(), Some(Highlight::Item(BOLD)));
1216        let _ = state.update(Event::Last);
1217        assert_eq!(state.highlighted(), Some(Highlight::More));
1218        let _ = state.update(Event::Focus);
1219        assert!(!state.is_focused(), "focus again leaves");
1220    }
1221
1222    #[test]
1223    fn first_and_last_need_focus() {
1224        let mut state = state();
1225        let _ = state.update(Event::First);
1226        let _ = state.update(Event::Last);
1227        assert!(!state.is_focused());
1228    }
1229
1230    #[test]
1231    fn activating_the_highlight_presses_or_opens_more() {
1232        let mut state = narrow();
1233        let _ = state.update(Event::Focus);
1234        assert_eq!(
1235            state.update(Event::ActivateHighlighted),
1236            Some(Output::Toggled(BOLD, false))
1237        );
1238        assert!(state.is_focused(), "the keyboard keeps the focus");
1239
1240        let _ = state.update(Event::Last);
1241        assert_eq!(state.update(Event::ActivateHighlighted), None);
1242        assert!(state.overflow().is_open());
1243        assert_eq!(state.overflow().highlighted(), Some(RIGHT));
1244
1245        let _ = state.update(Event::Close);
1246        assert!(!state.overflow().is_open());
1247        assert_eq!(state.highlighted(), Some(Highlight::More));
1248        let _ = state.update(Event::Close);
1249        assert!(!state.is_focused());
1250        assert_eq!(state.update(Event::ActivateHighlighted), None);
1251    }
1252
1253    #[test]
1254    fn a_pointer_press_ends_keyboard_focus() {
1255        let mut state = state();
1256        let _ = state.update(Event::Focus);
1257        let _ = state.update(Event::Press(ITALIC));
1258        assert!(!state.is_focused());
1259    }
1260
1261    #[test]
1262    fn narrowing_moves_a_hidden_highlight_to_more() {
1263        let mut state = state();
1264        let _ = state.update(Event::Focus);
1265        let _ = state.update(Event::Last);
1266        assert_eq!(state.highlighted(), Some(Highlight::Item(UNDO)));
1267        let _ = state.update(Event::Resized(160.0));
1268        assert_eq!(state.highlighted(), Some(Highlight::More));
1269    }
1270
1271    fn key(key: Key, modifiers: Modifiers) -> keys::Event {
1272        keys::Event { key, modifiers }
1273    }
1274
1275    #[test]
1276    fn an_unfocused_toolbar_claims_only_focus() {
1277        let state = state();
1278        let keymap = default_keymap();
1279        let right = key(Key::Named(Named::ArrowRight), Modifiers::empty());
1280        let focus = key(Key::Named(Named::F10), Modifiers::CTRL);
1281        assert_eq!(state.key_event(&keymap, &right), None);
1282        assert_eq!(state.key_event(&keymap, &focus), Some(Event::Focus));
1283        for &action in <Action as keys::Action>::ALL {
1284            let expected = (action == Action::Focus).then_some(Event::Focus);
1285            assert_eq!(action.event(&state), expected, "{action:?}");
1286        }
1287    }
1288
1289    #[test]
1290    fn a_focused_toolbar_maps_every_action() {
1291        let mut state = state();
1292        let _ = state.update(Event::Focus);
1293        assert_eq!(Action::Focus.event(&state), Some(Event::Focus));
1294        assert_eq!(Action::Next.event(&state), Some(Event::Next));
1295        assert_eq!(Action::Previous.event(&state), Some(Event::Previous));
1296        assert_eq!(Action::First.event(&state), Some(Event::First));
1297        assert_eq!(Action::Last.event(&state), Some(Event::Last));
1298        assert_eq!(
1299            Action::Activate.event(&state),
1300            Some(Event::ActivateHighlighted)
1301        );
1302        assert_eq!(Action::Close.event(&state), Some(Event::Close));
1303    }
1304
1305    fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
1306        let chord: Chord = chord.parse().unwrap();
1307        keymap.resolve(chord.key(), chord.modifiers())
1308    }
1309
1310    #[test]
1311    fn default_keymap_covers_every_action() {
1312        let keymap = default_keymap();
1313        assert_eq!(press(&keymap, "Ctrl+F10"), Some(Action::Focus));
1314        assert_eq!(press(&keymap, "F10"), None, "plain F10 is the menubar's");
1315        assert_eq!(press(&keymap, "ArrowRight"), Some(Action::Next));
1316        assert_eq!(press(&keymap, "ArrowLeft"), Some(Action::Previous));
1317        assert_eq!(press(&keymap, "Home"), Some(Action::First));
1318        assert_eq!(press(&keymap, "End"), Some(Action::Last));
1319        assert_eq!(press(&keymap, "Enter"), Some(Action::Activate));
1320        assert_eq!(press(&keymap, "Space"), Some(Action::Activate));
1321        assert_eq!(press(&keymap, "Escape"), Some(Action::Close));
1322        for action in <Action as keys::Action>::ALL {
1323            assert!(!keymap.chords(action).is_empty(), "{action:?}");
1324        }
1325        assert_eq!(Keymap::<Action>::defaults(), keymap);
1326    }
1327
1328    #[test]
1329    fn keymap_overrides_and_unbinding() {
1330        let keymap = default_keymap()
1331            .unbind(&"Space".parse().unwrap())
1332            .bind("Alt+T".parse().unwrap(), Action::Focus);
1333        assert_eq!(press(&keymap, "Space"), None);
1334        assert_eq!(press(&keymap, "Alt+T"), Some(Action::Focus));
1335        assert_eq!(press(&keymap, "Ctrl+F10"), Some(Action::Focus));
1336    }
1337
1338    #[test]
1339    fn every_action_has_a_name_and_a_sentence() {
1340        for &action in <Action as keys::Action>::ALL {
1341            assert!(!keys::Action::name(action).is_empty());
1342            assert!(keys::Action::description(action).ends_with('.'));
1343        }
1344    }
1345
1346    #[test]
1347    fn builder_defaults_and_overrides() {
1348        let state = state();
1349        let bar: Toolbar<'_, u8, ()> = toolbar(&state);
1350        assert_eq!(bar.tooltip, Some(Position::Bottom));
1351        assert_eq!(bar.more, "More");
1352        assert_eq!(bar.menu_keymap, dropdown_menu::default_keymap());
1353        assert!(bar.on_event.is_none());
1354
1355        let bar: Toolbar<'_, u8, ()> = toolbar(&state)
1356            .tooltip(None)
1357            .more_label("Overflow")
1358            .menu_keymap(Keymap::new())
1359            .on_event(|_| ());
1360        assert_eq!(bar.tooltip, None);
1361        assert_eq!(bar.more, "Overflow");
1362        assert!(bar.menu_keymap.is_empty());
1363        assert!(bar.on_event.is_some());
1364    }
1365
1366    #[test]
1367    fn ring_and_strip_styles_come_from_tokens() {
1368        for theme in [light(), dark()] {
1369            let tokens = Tokens::of(&theme);
1370            let ring = ring_style(&tokens);
1371            assert_eq!(ring.border.color, tokens.ring);
1372            assert_eq!(ring.border.width, RING_WIDTH);
1373            assert_eq!(ring.border.radius, radius::MD.into());
1374            assert_eq!(ring.background, None);
1375            let bar = bar_style(&tokens);
1376            assert_eq!(bar.background, Some(Background::Color(tokens.background)));
1377            assert_eq!(bar.text_color, Some(tokens.foreground));
1378        }
1379    }
1380}