1use 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
74pub const GAP: f32 = gap::XS;
76pub const PADDING: f32 = gap::XS;
78pub const SEPARATOR_WIDTH: f32 = 1.0 + 2.0 * gap::XS;
80pub const SEPARATOR_HEIGHT: f32 = 20.0;
82pub const RING_WIDTH: f32 = 2.0;
84
85#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
87pub enum Kind {
88 Action,
90 Toggle(bool),
92 Choice(bool),
95}
96
97#[derive(Debug, Clone, PartialEq)]
99pub struct Item<Id> {
100 pub id: Id,
101 pub icon: Glyph,
102 pub label: String,
104 pub shortcut: Option<String>,
107 pub disabled: bool,
108 pub kind: Kind,
109}
110
111impl<Id> Item<Id> {
112 pub fn is_on(&self) -> bool {
114 matches!(self.kind, Kind::Toggle(true) | Kind::Choice(true))
115 }
116
117 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#[derive(Debug, Clone, PartialEq)]
128pub enum Entry<Id> {
129 Item(Item<Id>),
130 Separator,
132 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
147pub fn button<Id>(id: Id, icon: Glyph, label: impl Into<String>) -> Entry<Id> {
149 new_item(id, icon, label, Kind::Action)
150}
151
152pub 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
157pub 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 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 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
216pub 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
242pub 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
282pub enum Highlight<Id> {
283 Item(Id),
284 More,
286}
287
288#[derive(Debug, Clone, Copy, PartialEq, Eq)]
289enum Stop {
290 Entry(usize),
291 More,
292}
293
294#[derive(Debug, Clone, Copy, PartialEq)]
296pub enum Event<Id> {
297 Resized(f32),
299 Press(Id),
301 Overflow(usize, dropdown_menu::Event<Id>),
303 Focus,
306 Next,
308 Previous,
310 First,
311 Last,
312 ActivateHighlighted,
315 Close,
317}
318
319#[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 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 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 pub fn is_on(&self, id: Id) -> bool {
369 self.item(id).is_some_and(Item::is_on)
370 }
371
372 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 pub fn overflow(&self) -> &dropdown_menu::State<Id> {
382 &self.overflow
383 }
384
385 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 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 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 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 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 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 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 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(¤t) {
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 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#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
616pub enum Action {
617 Focus,
619 Next,
620 Previous,
621 First,
622 Last,
623 Activate,
624 Close,
625}
626
627impl Action {
628 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
686pub 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
715pub 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
727pub 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
738pub 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
758pub 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 pub fn tooltip(mut self, position: Option<Position>) -> Self {
774 self.tooltip = position;
775 self
776 }
777
778 pub fn more_label(mut self, label: impl Into<String>) -> Self {
780 self.more = label.into();
781 self
782 }
783
784 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
836fn 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
945fn 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 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 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}