1use std::rc::Rc;
24
25use iced::Event as Input;
26use iced::advanced::layout::{self, Layout};
27use iced::advanced::widget::{Operation, Tree, Widget, tree};
28use iced::advanced::{Clipboard, Shell, overlay, renderer};
29use iced::keyboard::{self, key::Named};
30use iced::widget::{
31 Space, button, column, container, mouse_area, opaque, row, scrollable, stack, text,
32};
33use iced::{
34 Alignment, Background, Border, Color, Element, Length, Padding, Rectangle, Renderer, Shadow,
35 Size, Theme, Vector, mouse, touch,
36};
37
38use crate::icon::{Glyph, themed};
39use crate::keys::{self, Chord, Keymap};
40use crate::overlay::menu::{self, Parts, RowStatus};
41use crate::overlay::tooltip::{Position, tooltip};
42use crate::primitives::icon_button::{IconButton, icon_button};
43use crate::theme::{Tokens, fade, mix, radius, space, text_size};
44
45pub const WIDTH: f32 = 256.0;
47pub const RAIL_WIDTH: f32 = 48.0;
49pub const DRAWER_WIDTH: f32 = 288.0;
51pub const BREAKPOINT: f32 = 640.0;
53pub const BAR_HEIGHT: f32 = 48.0;
55
56const RAIL_BUTTON: f32 = 32.0;
58
59pub const TRIGGER_ID: iced::widget::Id = iced::widget::Id::new("iced-cube-sidebar-trigger");
61
62#[derive(Debug, Clone, PartialEq)]
65pub struct Item<Id> {
66 pub id: Id,
67 pub label: String,
68 pub icon: Option<Glyph>,
69 pub badge: Option<String>,
71 pub disabled: bool,
72 pub children: Vec<Item<Id>>,
73}
74
75pub fn item<Id>(id: Id, label: impl Into<String>) -> Item<Id> {
77 Item {
78 id,
79 label: label.into(),
80 icon: None,
81 badge: None,
82 disabled: false,
83 children: Vec::new(),
84 }
85}
86
87impl<Id> Item<Id> {
88 pub fn icon(mut self, glyph: Glyph) -> Self {
89 self.icon = Some(glyph);
90 self
91 }
92
93 pub fn badge(mut self, badge: impl Into<String>) -> Self {
94 self.badge = Some(badge.into());
95 self
96 }
97
98 pub fn disabled(mut self, disabled: bool) -> Self {
99 self.disabled = disabled;
100 self
101 }
102
103 pub fn children(mut self, children: impl IntoIterator<Item = Item<Id>>) -> Self {
105 self.children = children
106 .into_iter()
107 .map(|mut child| {
108 child.children.clear();
109 child
110 })
111 .collect();
112 self
113 }
114
115 pub fn has_children(&self) -> bool {
116 !self.children.is_empty()
117 }
118}
119
120#[derive(Debug, Clone, PartialEq)]
122pub struct Group<Id> {
123 pub label: String,
125 pub items: Vec<Item<Id>>,
126 pub collapsible: bool,
128}
129
130pub fn group<Id>(label: impl Into<String>, items: impl IntoIterator<Item = Item<Id>>) -> Group<Id> {
132 Group {
133 label: label.into(),
134 items: items.into_iter().collect(),
135 collapsible: false,
136 }
137}
138
139impl<Id> Group<Id> {
140 pub fn collapsible(mut self, collapsible: bool) -> Self {
141 self.collapsible = collapsible;
142 self
143 }
144}
145
146#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
149pub enum Row<Id> {
150 Group(usize),
151 Item(Id),
152}
153
154#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum Event<Id> {
157 Activate(Id),
160 Next,
162 Previous,
164 First,
165 Last,
166 ActivateHighlighted,
169 Expand,
171 Collapse,
173 ToggleGroup(usize),
174 ToggleItem(Id),
176 ToggleRail,
177 SetRail(bool),
178 OpenDrawer,
179 CloseDrawer,
180 ToggleDrawer,
181 Toggle,
184 Narrow(bool),
187 Focus(bool),
189}
190
191#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub enum Output<Id> {
194 Activated(Id),
196}
197
198#[derive(Debug, Clone, PartialEq)]
200pub struct State<Id> {
201 groups: Vec<Group<Id>>,
202 active: Option<Id>,
203 highlighted: Option<Row<Id>>,
204 closed: Vec<bool>,
205 expanded: Vec<Id>,
206 rail: bool,
207 drawer: bool,
208 narrow: bool,
209 focused: bool,
210 keyboard: bool,
212}
213
214impl<Id: Copy + PartialEq> State<Id> {
215 pub fn new(groups: impl IntoIterator<Item = Group<Id>>) -> Self {
217 let groups: Vec<Group<Id>> = groups.into_iter().collect();
218 Self {
219 closed: vec![false; groups.len()],
220 groups,
221 active: None,
222 highlighted: None,
223 expanded: Vec::new(),
224 rail: false,
225 drawer: false,
226 narrow: false,
227 focused: false,
228 keyboard: false,
229 }
230 }
231
232 pub fn with_active(mut self, id: Id) -> Self {
234 if self.item(id).is_some_and(|item| !item.disabled) {
235 self.active = Some(id);
236 if let Some(parent) = self.parent_of(id) {
237 self.open_item(parent);
238 }
239 }
240 self
241 }
242
243 pub fn with_rail(mut self, rail: bool) -> Self {
245 self.rail = rail;
246 self
247 }
248
249 pub fn groups(&self) -> &[Group<Id>] {
250 &self.groups
251 }
252
253 pub fn active(&self) -> Option<Id> {
254 self.active
255 }
256
257 pub fn highlighted(&self) -> Option<Row<Id>> {
258 self.highlighted
259 }
260
261 pub fn is_rail(&self) -> bool {
263 self.rail
264 }
265
266 pub fn is_compact(&self) -> bool {
269 self.rail && !self.narrow
270 }
271
272 pub fn is_narrow(&self) -> bool {
273 self.narrow
274 }
275
276 pub fn is_drawer_open(&self) -> bool {
277 self.narrow && self.drawer
278 }
279
280 pub fn is_focused(&self) -> bool {
281 self.focused
282 }
283
284 pub fn shows_highlight(&self) -> bool {
287 self.keyboard && self.has_keys()
288 }
289
290 pub fn has_keys(&self) -> bool {
293 self.focused || self.is_drawer_open()
294 }
295
296 pub fn is_group_open(&self, group: usize) -> bool {
297 !self.closed.get(group).copied().unwrap_or(true)
298 }
299
300 pub fn is_expanded(&self, id: Id) -> bool {
301 self.expanded.contains(&id)
302 }
303
304 pub fn item(&self, id: Id) -> Option<&Item<Id>> {
305 self.groups
306 .iter()
307 .flat_map(|group| &group.items)
308 .find_map(|item| {
309 if item.id == id {
310 return Some(item);
311 }
312 item.children.iter().find(|child| child.id == id)
313 })
314 }
315
316 pub fn parent_of(&self, id: Id) -> Option<Id> {
318 self.groups
319 .iter()
320 .flat_map(|group| &group.items)
321 .find(|item| item.children.iter().any(|child| child.id == id))
322 .map(|item| item.id)
323 }
324
325 fn group_of(&self, id: Id) -> Option<usize> {
326 self.groups.iter().position(|group| {
327 group
328 .items
329 .iter()
330 .any(|item| item.id == id || item.children.iter().any(|child| child.id == id))
331 })
332 }
333
334 pub fn is_active_branch(&self, item: &Item<Id>) -> bool {
337 Some(item.id) == self.active
338 || self
339 .active
340 .is_some_and(|active| item.children.iter().any(|child| child.id == active))
341 }
342
343 pub fn rows(&self) -> Vec<Row<Id>> {
346 let compact = self.is_compact();
347 let mut rows = Vec::new();
348 for (index, group) in self.groups.iter().enumerate() {
349 if group.collapsible && !compact {
350 rows.push(Row::Group(index));
351 }
352 if !compact && !self.is_group_open(index) {
353 continue;
354 }
355 for item in group.items.iter().filter(|item| !item.disabled) {
356 rows.push(Row::Item(item.id));
357 if compact || !self.is_expanded(item.id) {
358 continue;
359 }
360 rows.extend(
361 item.children
362 .iter()
363 .filter(|child| !child.disabled)
364 .map(|child| Row::Item(child.id)),
365 );
366 }
367 }
368 rows
369 }
370
371 pub fn update(&mut self, event: Event<Id>) -> Option<Output<Id>> {
373 match event {
374 Event::Next
375 | Event::Previous
376 | Event::First
377 | Event::Last
378 | Event::ActivateHighlighted
379 | Event::Expand
380 | Event::Collapse => self.keyboard = true,
381 Event::Activate(_)
382 | Event::ToggleGroup(_)
383 | Event::ToggleItem(_)
384 | Event::Focus(_)
385 | Event::OpenDrawer
386 | Event::ToggleDrawer
387 | Event::Toggle => self.keyboard = false,
388 _ => {}
389 }
390 match event {
391 Event::Activate(id) => return self.activate(id),
392 Event::Next => self.step(true),
393 Event::Previous => self.step(false),
394 Event::First => self.highlighted = self.rows().first().copied(),
395 Event::Last => self.highlighted = self.rows().last().copied(),
396 Event::ActivateHighlighted => match self.highlighted {
397 Some(Row::Group(index)) => self.toggle_group(index),
398 Some(Row::Item(id)) => return self.activate(id),
399 None => self.step(true),
400 },
401 Event::Expand => self.expand(),
402 Event::Collapse => self.collapse(),
403 Event::ToggleGroup(index) => self.toggle_group(index),
404 Event::ToggleItem(id) => self.toggle_item(id),
405 Event::ToggleRail => self.set_rail(!self.rail),
406 Event::SetRail(rail) => self.set_rail(rail),
407 Event::OpenDrawer => self.open_drawer(),
408 Event::CloseDrawer => self.drawer = false,
409 Event::ToggleDrawer if self.drawer => self.drawer = false,
410 Event::ToggleDrawer => self.open_drawer(),
411 Event::Toggle if self.narrow => {
412 return self.update(Event::ToggleDrawer);
413 }
414 Event::Toggle => self.set_rail(!self.rail),
415 Event::Narrow(narrow) => {
416 self.narrow = narrow;
417 if !narrow {
418 self.drawer = false;
419 }
420 self.settle_highlight();
421 }
422 Event::Focus(focused) => {
423 self.focused = focused;
424 if focused && self.highlighted.is_none() {
425 self.highlight_active();
426 }
427 }
428 }
429 None
430 }
431
432 pub fn shortcut(&self, keymap: &Keymap<Action>, key: &keys::Event) -> Option<Event<Id>> {
436 keymap
437 .resolve_event(key)
438 .filter(|action| *action == Action::ToggleRail)
439 .and_then(|action| action.event(self))
440 }
441
442 fn activate(&mut self, id: Id) -> Option<Output<Id>> {
443 let item = self.item(id).filter(|item| !item.disabled)?;
444 if item.has_children() {
445 if self.is_compact() {
446 self.rail = false;
447 self.open_item(id);
448 } else {
449 self.toggle_item(id);
450 }
451 self.highlighted = Some(Row::Item(id));
452 return None;
453 }
454 self.active = Some(id);
455 self.highlighted = Some(Row::Item(id));
456 self.drawer = false;
457 Some(Output::Activated(id))
458 }
459
460 fn step(&mut self, forward: bool) {
461 let rows = self.rows();
462 if rows.is_empty() {
463 return;
464 }
465 let current = self
466 .highlighted
467 .and_then(|row| rows.iter().position(|candidate| *candidate == row));
468 let len = rows.len();
469 let next = match (current, forward) {
470 (None, true) => 0,
471 (None, false) => len - 1,
472 (Some(index), true) => (index + 1) % len,
473 (Some(index), false) => (index + len - 1) % len,
474 };
475 self.highlighted = rows.get(next).copied();
476 }
477
478 fn expand(&mut self) {
479 match self.highlighted {
480 Some(Row::Group(index)) if !self.is_group_open(index) => self.toggle_group(index),
481 Some(Row::Group(index)) => {
482 self.highlighted = self
483 .groups
484 .get(index)
485 .and_then(|group| group.items.iter().find(|item| !item.disabled))
486 .map(|item| Row::Item(item.id));
487 }
488 Some(Row::Item(id)) => {
489 let Some(item) = self.item(id).filter(|item| item.has_children()) else {
490 return;
491 };
492 if self.is_compact() {
493 return;
494 }
495 if !self.is_expanded(id) {
496 self.open_item(id);
497 return;
498 }
499 if let Some(child) = item.children.iter().find(|child| !child.disabled) {
500 self.highlighted = Some(Row::Item(child.id));
501 }
502 }
503 None => {}
504 }
505 }
506
507 fn collapse(&mut self) {
508 match self.highlighted {
509 Some(Row::Group(index)) if self.is_group_open(index) => self.toggle_group(index),
510 Some(Row::Group(_)) | None => {}
511 Some(Row::Item(id)) => {
512 if self.is_expanded(id) {
513 self.toggle_item(id);
514 return;
515 }
516 if let Some(parent) = self.parent_of(id) {
517 self.highlighted = Some(Row::Item(parent));
518 return;
519 }
520 let group = self
521 .group_of(id)
522 .filter(|&index| self.groups[index].collapsible && !self.is_compact());
523 if let Some(index) = group {
524 self.highlighted = Some(Row::Group(index));
525 }
526 }
527 }
528 }
529
530 fn toggle_group(&mut self, index: usize) {
531 let Some(closed) = self.closed.get_mut(index) else {
532 return;
533 };
534 if !self.groups[index].collapsible {
535 return;
536 }
537 *closed = !*closed;
538 if *closed && self.highlighted.is_some() {
539 let inside = match self.highlighted {
540 Some(Row::Item(id)) => self.group_of(id) == Some(index),
541 _ => false,
542 };
543 if inside {
544 self.highlighted = Some(Row::Group(index));
545 }
546 }
547 }
548
549 fn open_item(&mut self, id: Id) {
550 if !self.expanded.contains(&id) {
551 self.expanded.push(id);
552 }
553 }
554
555 fn toggle_item(&mut self, id: Id) {
556 if !self.item(id).is_some_and(Item::has_children) {
557 return;
558 }
559 if !self.expanded.contains(&id) {
560 self.open_item(id);
561 return;
562 }
563 self.expanded.retain(|expanded| *expanded != id);
564 if let Some(Row::Item(highlighted)) = self.highlighted
565 && self.parent_of(highlighted) == Some(id)
566 {
567 self.highlighted = Some(Row::Item(id));
568 }
569 }
570
571 fn set_rail(&mut self, rail: bool) {
572 self.rail = rail;
573 self.settle_highlight();
574 }
575
576 fn open_drawer(&mut self) {
577 self.drawer = true;
578 self.highlight_active();
579 }
580
581 fn highlight_active(&mut self) {
583 let rows = self.rows();
584 let active = self
585 .active
586 .map(Row::Item)
587 .filter(|row| rows.contains(row))
588 .or_else(|| {
589 self.active
590 .and_then(|id| self.parent_of(id))
591 .map(Row::Item)
592 .filter(|row| rows.contains(row))
593 });
594 self.highlighted = active.or_else(|| rows.first().copied());
595 }
596
597 fn settle_highlight(&mut self) {
600 let Some(row) = self.highlighted else {
601 return;
602 };
603 let rows = self.rows();
604 if rows.contains(&row) {
605 return;
606 }
607 let fallback = match row {
608 Row::Item(id) => self
609 .parent_of(id)
610 .map(Row::Item)
611 .filter(|row| rows.contains(row)),
612 Row::Group(index) => self
613 .groups
614 .get(index)
615 .and_then(|group| group.items.iter().find(|item| !item.disabled))
616 .map(|item| Row::Item(item.id)),
617 };
618 self.highlighted = fallback;
619 }
620}
621
622#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
624pub enum Action {
625 Next,
626 Previous,
627 First,
628 Last,
629 Activate,
630 Expand,
631 Collapse,
632 ToggleRail,
635 Close,
637}
638
639impl Action {
640 pub fn event<Id: Copy + PartialEq>(self, state: &State<Id>) -> Option<Event<Id>> {
643 let event = match self {
644 Action::Next => Event::Next,
645 Action::Previous => Event::Previous,
646 Action::First => Event::First,
647 Action::Last => Event::Last,
648 Action::Activate => Event::ActivateHighlighted,
649 Action::Expand => Event::Expand,
650 Action::Collapse => Event::Collapse,
651 Action::ToggleRail => Event::Toggle,
652 Action::Close if state.is_drawer_open() => Event::CloseDrawer,
653 Action::Close if state.is_focused() => Event::Focus(false),
654 Action::Close => return None,
655 };
656 Some(event)
657 }
658}
659
660impl keys::Action for Action {
661 const ALL: &'static [Self] = &[
662 Action::Next,
663 Action::Previous,
664 Action::First,
665 Action::Last,
666 Action::Activate,
667 Action::Expand,
668 Action::Collapse,
669 Action::ToggleRail,
670 Action::Close,
671 ];
672
673 fn defaults() -> Keymap<Self> {
674 default_keymap()
675 }
676
677 fn name(self) -> &'static str {
678 match self {
679 Action::Next => "Next",
680 Action::Previous => "Previous",
681 Action::First => "First",
682 Action::Last => "Last",
683 Action::Activate => "Activate",
684 Action::Expand => "Expand",
685 Action::Collapse => "Collapse",
686 Action::ToggleRail => "ToggleRail",
687 Action::Close => "Close",
688 }
689 }
690
691 fn description(self) -> &'static str {
692 match self {
693 Action::Next => "Highlights the next row, wrapping.",
694 Action::Previous => "Highlights the previous row, wrapping.",
695 Action::First => "Highlights the first row.",
696 Action::Last => "Highlights the last row.",
697 Action::Activate => "Activates the highlighted item or opens and closes its group.",
698 Action::Expand => "Opens the highlighted group or parent item, or moves into it.",
699 Action::Collapse => "Closes the highlighted group or parent item, or moves out to it.",
700 Action::ToggleRail => {
701 "Collapses the sidebar to an icon rail and back, or toggles the drawer on a narrow window."
702 }
703 Action::Close => "Closes the drawer, or leaves the sidebar.",
704 }
705 }
706}
707
708pub fn default_keymap() -> Keymap<Action> {
726 Keymap::new()
727 .bind(Chord::named(Named::ArrowDown), Action::Next)
728 .bind(Chord::named(Named::ArrowUp), Action::Previous)
729 .bind(Chord::named(Named::Home), Action::First)
730 .bind(Chord::named(Named::End), Action::Last)
731 .bind(Chord::named(Named::Enter), Action::Activate)
732 .bind(Chord::named(Named::Space), Action::Activate)
733 .bind(Chord::named(Named::ArrowRight), Action::Expand)
734 .bind(Chord::named(Named::ArrowLeft), Action::Collapse)
735 .bind(Chord::character('b').command(), Action::ToggleRail)
736 .bind(Chord::named(Named::Escape), Action::Close)
737}
738
739pub fn surface_style(tokens: &Tokens) -> container::Style {
742 let background = if tokens.is_dark {
743 mix(tokens.background, tokens.foreground, 0.04)
744 } else {
745 mix(tokens.background, tokens.muted, 0.5)
746 };
747 container::Style {
748 background: Some(Background::Color(background)),
749 text_color: Some(tokens.foreground),
750 ..container::Style::default()
751 }
752}
753
754pub fn edge_style(tokens: &Tokens) -> container::Style {
756 container::Style {
757 background: Some(Background::Color(tokens.border)),
758 ..container::Style::default()
759 }
760}
761
762pub fn scrim_style(tokens: &Tokens) -> container::Style {
764 container::Style {
765 background: Some(Background::Color(fade(
766 Color::BLACK,
767 if tokens.is_dark { 0.7 } else { 0.5 },
768 ))),
769 ..container::Style::default()
770 }
771}
772
773pub fn item_style(
777 tokens: &Tokens,
778 active: bool,
779 keyboard: bool,
780 status: button::Status,
781) -> button::Style {
782 let row = match status {
783 button::Status::Disabled => RowStatus::Disabled,
784 _ if active => RowStatus::Highlighted,
785 button::Status::Hovered | button::Status::Pressed => RowStatus::Highlighted,
786 button::Status::Active => RowStatus::Idle,
787 };
788 let colours = menu::row_style(tokens, row, false);
789 button::Style {
790 background: colours.background.map(Background::Color),
791 text_color: colours.text,
792 border: Border {
793 color: if keyboard {
794 tokens.ring
795 } else {
796 Color::TRANSPARENT
797 },
798 width: if keyboard { 1.0 } else { 0.0 },
799 radius: radius::SM.into(),
800 },
801 shadow: Shadow::default(),
802 snap: true,
803 }
804}
805
806pub struct Sidebar<'a, Id, Message> {
808 state: &'a State<Id>,
809 header: Option<Element<'a, Message>>,
810 footer: Option<Element<'a, Message>>,
811 content: Option<Element<'a, Message>>,
812 width: Option<Length>,
813 breakpoint: f32,
814 trigger: bool,
815 keymap: Keymap<Action>,
816 on_event: Option<Box<dyn Fn(Event<Id>) -> Message + 'a>>,
817}
818
819impl<Id: std::fmt::Debug, Message> std::fmt::Debug for Sidebar<'_, Id, Message> {
820 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
821 f.debug_struct("Sidebar")
822 .field("state", self.state)
823 .field("width", &self.width)
824 .field("breakpoint", &self.breakpoint)
825 .field("trigger", &self.trigger)
826 .field("keymap", &self.keymap)
827 .finish_non_exhaustive()
828 }
829}
830
831pub fn sidebar<Id, Message>(state: &State<Id>) -> Sidebar<'_, Id, Message> {
834 Sidebar {
835 state,
836 header: None,
837 footer: None,
838 content: None,
839 width: None,
840 breakpoint: BREAKPOINT,
841 trigger: true,
842 keymap: default_keymap(),
843 on_event: None,
844 }
845}
846
847impl<'a, Id, Message> Sidebar<'a, Id, Message> {
848 pub fn header(mut self, header: impl Into<Element<'a, Message>>) -> Self {
851 self.header = Some(header.into());
852 self
853 }
854
855 pub fn footer(mut self, footer: impl Into<Element<'a, Message>>) -> Self {
857 self.footer = Some(footer.into());
858 self
859 }
860
861 pub fn content(mut self, content: impl Into<Element<'a, Message>>) -> Self {
864 self.content = Some(content.into());
865 self
866 }
867
868 pub fn width(mut self, width: impl Into<Length>) -> Self {
871 self.width = Some(width.into());
872 self
873 }
874
875 pub fn breakpoint(mut self, width: f32) -> Self {
877 self.breakpoint = width;
878 self
879 }
880
881 pub fn trigger(mut self, trigger: bool) -> Self {
884 self.trigger = trigger;
885 self
886 }
887
888 pub fn keymap(mut self, keymap: Keymap<Action>) -> Self {
891 self.keymap = keymap;
892 self
893 }
894
895 pub fn on_event(mut self, on_event: impl Fn(Event<Id>) -> Message + 'a) -> Self {
896 self.on_event = Some(Box::new(on_event));
897 self
898 }
899}
900
901type OnEvent<'a, Id, Message> = Rc<dyn Fn(Event<Id>) -> Message + 'a>;
902
903#[derive(Debug, Clone, Copy, PartialEq, Eq)]
905enum Mode {
906 Docked,
907 Rail,
908 Drawer,
909}
910
911impl<'a, Id, Message> From<Sidebar<'a, Id, Message>> for Element<'a, Message>
912where
913 Id: Copy + PartialEq + 'a,
914 Message: Clone + 'a,
915{
916 fn from(sidebar: Sidebar<'a, Id, Message>) -> Self {
917 let Sidebar {
918 state,
919 header,
920 footer,
921 content,
922 width,
923 breakpoint,
924 trigger: show_trigger,
925 keymap,
926 on_event,
927 } = sidebar;
928 let on_event: Option<OnEvent<'a, Id, Message>> = on_event.map(Rc::from);
929
930 let Some(content) = content else {
931 let mode = if state.rail { Mode::Rail } else { Mode::Docked };
932 let panel = panel(state, header, footer, mode, width, false, on_event.clone());
935 return watch(panel, state, Some(keymap), true, None, on_event);
936 };
937
938 if !state.narrow {
939 let mode = if state.rail { Mode::Rail } else { Mode::Docked };
940 let panel = panel(state, header, footer, mode, width, true, on_event.clone());
941 let panel = watch(panel, state, Some(keymap), true, None, on_event.clone());
942 let page = row![panel, container(content).width(Length::Fill)].height(Length::Fill);
943 return watch(page.into(), state, None, false, Some(breakpoint), on_event);
944 }
945
946 let mut page = column![];
947 if show_trigger {
948 let bar = container(
949 trigger(
950 on_event
951 .as_ref()
952 .map(|on_event| on_event(Event::ToggleDrawer)),
953 )
954 .tooltip(None)
955 .id(TRIGGER_ID),
956 )
957 .padding([0.0, space::SM])
958 .height(BAR_HEIGHT)
959 .width(Length::Fill)
960 .align_y(Alignment::Center);
961 page = page.push(bar).push(edge(Length::Fill, Length::Fixed(1.0)));
962 }
963 page = page.push(container(content).width(Length::Fill).height(Length::Fill));
964
965 let mut layers = stack![page].width(Length::Fill).height(Length::Fill);
966 if state.is_drawer_open() {
967 let scrim = container(Space::new())
968 .width(Length::Fill)
969 .height(Length::Fill)
970 .style(|theme| scrim_style(&Tokens::of(theme)));
971 let scrim: Element<'a, Message> = match &on_event {
972 Some(on_event) => opaque(mouse_area(scrim).on_press(on_event(Event::CloseDrawer))),
973 None => opaque(scrim),
974 };
975 let drawer = panel(
976 state,
977 header,
978 footer,
979 Mode::Drawer,
980 None,
981 true,
982 on_event.clone(),
983 );
984 let drawer = watch(drawer, state, Some(keymap), false, None, on_event.clone());
985 layers = layers.push(scrim).push(opaque(drawer));
986 }
987 watch(
988 layers.into(),
989 state,
990 None,
991 false,
992 Some(breakpoint),
993 on_event,
994 )
995 }
996}
997
998pub fn trigger<'a, Message>(on_press: Option<Message>) -> IconButton<'a, Message> {
1001 icon_button(crate::lucide!(PanelLeft))
1002 .label("Toggle sidebar")
1003 .on_press_maybe(on_press)
1004}
1005
1006fn edge<'a, Message: 'a>(width: Length, height: Length) -> Element<'a, Message> {
1007 container(Space::new())
1008 .width(width)
1009 .height(height)
1010 .style(|theme| edge_style(&Tokens::of(theme)))
1011 .into()
1012}
1013
1014fn panel<'a, Id, Message>(
1015 state: &'a State<Id>,
1016 header: Option<Element<'a, Message>>,
1017 footer: Option<Element<'a, Message>>,
1018 mode: Mode,
1019 width: Option<Length>,
1020 with_edge: bool,
1021 on_event: Option<OnEvent<'a, Id, Message>>,
1022) -> Element<'a, Message>
1023where
1024 Id: Copy + PartialEq + 'a,
1025 Message: Clone + 'a,
1026{
1027 let on_event = on_event.as_deref();
1028 let keyboard = state.shows_highlight();
1029 let mut body = column![].spacing(space::SM).width(Length::Fill);
1030 for (index, group) in state.groups.iter().enumerate() {
1031 body = body.push(group_block(state, index, group, mode, keyboard, on_event));
1032 }
1033
1034 let mut sections = column![].width(Length::Fill).height(Length::Fill);
1035 if let Some(header) = header {
1036 sections = sections.push(container(header).padding(space::SM).width(Length::Fill));
1037 }
1038 sections = sections.push(
1039 scrollable(body)
1040 .direction(scrollable::Direction::Vertical(
1041 scrollable::Scrollbar::hidden(),
1042 ))
1043 .height(Length::Fill),
1044 );
1045 if let Some(footer) = footer {
1046 sections = sections.push(container(footer).padding(space::SM).width(Length::Fill));
1047 }
1048
1049 let width = match mode {
1050 Mode::Rail => Length::Fixed(RAIL_WIDTH),
1051 Mode::Docked => width.unwrap_or(Length::Fixed(WIDTH)),
1052 Mode::Drawer => Length::Fixed(DRAWER_WIDTH),
1053 };
1054 let surface = container(sections)
1055 .width(Length::Fill)
1056 .height(Length::Fill)
1057 .style(|theme| surface_style(&Tokens::of(theme)));
1058 let mut panel = row![surface].width(width).height(Length::Fill);
1059 if with_edge {
1060 panel = panel.push(edge(Length::Fixed(1.0), Length::Fill));
1061 }
1062 if mode != Mode::Drawer {
1063 return panel.into();
1064 }
1065 container(panel)
1066 .max_width(DRAWER_WIDTH)
1067 .height(Length::Fill)
1068 .style(|theme| drawer_style(&Tokens::of(theme)))
1069 .into()
1070}
1071
1072pub fn drawer_style(tokens: &Tokens) -> container::Style {
1074 container::Style {
1075 shadow: Shadow {
1076 color: fade(Color::BLACK, if tokens.is_dark { 0.5 } else { 0.15 }),
1077 offset: Vector::new(4.0, 0.0),
1078 blur_radius: 16.0,
1079 },
1080 ..container::Style::default()
1081 }
1082}
1083
1084fn group_block<'a, Id, Message>(
1085 state: &'a State<Id>,
1086 index: usize,
1087 group: &'a Group<Id>,
1088 mode: Mode,
1089 keyboard: bool,
1090 on_event: Option<&(dyn Fn(Event<Id>) -> Message + 'a)>,
1091) -> Element<'a, Message>
1092where
1093 Id: Copy + PartialEq + 'a,
1094 Message: Clone + 'a,
1095{
1096 let mut block = column![].width(Length::Fill);
1097 if mode == Mode::Rail {
1098 for item in &group.items {
1099 block = block.push(rail_item(state, item, on_event));
1100 }
1101 return container(block.spacing(space::XS))
1102 .padding(space::SM)
1103 .width(Length::Fill)
1104 .into();
1105 }
1106
1107 if !group.label.is_empty() {
1108 let highlighted = keyboard && state.highlighted == Some(Row::Group(index));
1109 block = block.push(group_heading(state, index, group, highlighted, on_event));
1110 }
1111 if state.is_group_open(index) || !group.collapsible {
1112 for item in &group.items {
1113 block = block.push(item_rows(state, item, keyboard, on_event));
1114 }
1115 }
1116 container(block.spacing(2.0))
1117 .padding([space::XS, space::XS])
1118 .width(Length::Fill)
1119 .into()
1120}
1121
1122fn group_heading<'a, Id, Message>(
1123 state: &State<Id>,
1124 index: usize,
1125 group: &'a Group<Id>,
1126 highlighted: bool,
1127 on_event: Option<&(dyn Fn(Event<Id>) -> Message + 'a)>,
1128) -> Element<'a, Message>
1129where
1130 Id: Copy + PartialEq + 'a,
1131 Message: Clone + 'a,
1132{
1133 let label = text(group.label.as_str())
1134 .size(text_size::XS)
1135 .font(crate::theme::semibold())
1136 .wrapping(text::Wrapping::None)
1137 .width(Length::Fill)
1138 .style(|theme: &Theme| menu::label_style(&Tokens::of(theme)));
1139 if !group.collapsible {
1140 return menu::inset(
1141 container(label)
1142 .padding(menu::ROW_PADDING)
1143 .height(menu::ROW_PADDING.top + menu::LINE_HEIGHT + menu::ROW_PADDING.bottom)
1144 .align_y(Alignment::Center),
1145 );
1146 }
1147 let chevron = if state.is_group_open(index) {
1148 crate::lucide!(ChevronDown)
1149 } else {
1150 crate::lucide!(ChevronRight)
1151 };
1152 let heading = row![
1153 label,
1154 themed(chevron, menu::ICON_SIZE, 1.0, |theme| {
1155 Tokens::of(theme).muted_foreground
1156 })
1157 ]
1158 .align_y(Alignment::Center);
1159 menu::inset(
1160 button(heading)
1161 .padding(menu::ROW_PADDING)
1162 .width(Length::Fill)
1163 .on_press_maybe(on_event.map(|on_event| on_event(Event::ToggleGroup(index))))
1164 .style(move |theme, status| item_style(&Tokens::of(theme), false, highlighted, status)),
1165 )
1166}
1167
1168fn item_rows<'a, Id, Message>(
1169 state: &'a State<Id>,
1170 item: &'a Item<Id>,
1171 keyboard: bool,
1172 on_event: Option<&(dyn Fn(Event<Id>) -> Message + 'a)>,
1173) -> Element<'a, Message>
1174where
1175 Id: Copy + PartialEq + 'a,
1176 Message: Clone + 'a,
1177{
1178 let expanded = state.is_expanded(item.id);
1179 let active = if item.has_children() {
1180 !expanded && state.is_active_branch(item)
1181 } else {
1182 state.active == Some(item.id)
1183 };
1184 let row_element = item_row(state, item, active, keyboard, on_event);
1185 if !item.has_children() || !expanded {
1186 return row_element;
1187 }
1188
1189 let children = column(item.children.iter().map(|child| {
1190 let active = state.active == Some(child.id);
1191 item_row(state, child, active, keyboard, on_event)
1192 }))
1193 .spacing(2.0)
1194 .width(Length::Fill);
1195 let nested = row![
1196 edge(Length::Fixed(1.0), Length::Fill),
1197 container(children).padding(Padding::ZERO.left(space::SM)),
1198 ]
1199 .height(Length::Shrink);
1200 column![
1201 row_element,
1202 container(nested).padding(Padding {
1203 top: 2.0,
1204 bottom: 0.0,
1205 left: space::LG + space::XS,
1206 right: 0.0,
1207 })
1208 ]
1209 .spacing(0.0)
1210 .into()
1211}
1212
1213fn item_row<'a, Id, Message>(
1214 state: &State<Id>,
1215 item: &'a Item<Id>,
1216 active: bool,
1217 keyboard: bool,
1218 on_event: Option<&(dyn Fn(Event<Id>) -> Message + 'a)>,
1219) -> Element<'a, Message>
1220where
1221 Id: Copy + PartialEq + 'a,
1222 Message: Clone + 'a,
1223{
1224 let message = on_event
1225 .filter(|_| !item.disabled)
1226 .map(|on_event| on_event(Event::Activate(item.id)));
1227 let status = if message.is_some() {
1228 RowStatus::Idle
1229 } else {
1230 RowStatus::Disabled
1231 };
1232 let highlighted = keyboard && state.highlighted == Some(Row::Item(item.id));
1233
1234 let parts = Parts {
1235 icon: item.icon,
1236 hint: item.badge.as_deref(),
1237 ..Parts::label(&item.label)
1238 };
1239 let mut content = menu::content(parts, status, false);
1240 if item.has_children() {
1241 let chevron = if state.is_expanded(item.id) {
1242 crate::lucide!(ChevronDown)
1243 } else {
1244 crate::lucide!(ChevronRight)
1245 };
1246 let opacity = crate::icon::opacity(message.is_some());
1247 content = content.push(themed(chevron, menu::ICON_SIZE, opacity, |theme| {
1248 Tokens::of(theme).muted_foreground
1249 }));
1250 }
1251
1252 menu::inset(
1253 button(content)
1254 .padding(menu::ROW_PADDING)
1255 .width(Length::Fill)
1256 .on_press_maybe(message)
1257 .style(move |theme, status| {
1258 item_style(&Tokens::of(theme), active, highlighted, status)
1259 }),
1260 )
1261}
1262
1263fn rail_item<'a, Id, Message>(
1264 state: &State<Id>,
1265 item: &'a Item<Id>,
1266 on_event: Option<&(dyn Fn(Event<Id>) -> Message + 'a)>,
1267) -> Element<'a, Message>
1268where
1269 Id: Copy + PartialEq + 'a,
1270 Message: Clone + 'a,
1271{
1272 let message = on_event
1273 .filter(|_| !item.disabled)
1274 .map(|on_event| on_event(Event::Activate(item.id)));
1275 let enabled = message.is_some();
1276 let active = state.is_active_branch(item);
1277 let highlighted = state.shows_highlight() && state.highlighted == Some(Row::Item(item.id));
1278
1279 let face: Element<'a, Message> = match item.icon {
1280 Some(glyph) => themed(
1281 glyph,
1282 menu::ICON_SIZE,
1283 crate::icon::opacity(enabled),
1284 |theme| Tokens::of(theme).foreground,
1285 )
1286 .into(),
1287 None => text(
1288 item.label
1289 .chars()
1290 .next()
1291 .map(String::from)
1292 .unwrap_or_default(),
1293 )
1294 .size(text_size::SM)
1295 .font(crate::theme::semibold())
1296 .into(),
1297 };
1298 let square = button(container(face).center(RAIL_BUTTON))
1299 .width(RAIL_BUTTON)
1300 .height(RAIL_BUTTON)
1301 .padding(0)
1302 .on_press_maybe(message)
1303 .style(move |theme, status| item_style(&Tokens::of(theme), active, highlighted, status));
1304 tooltip(square, item.label.as_str())
1305 .position(Position::Right)
1306 .into()
1307}
1308
1309pub struct MenuButton<'a, Message> {
1313 title: text::Fragment<'a>,
1314 subtitle: Option<text::Fragment<'a>>,
1315 icon: Option<Glyph>,
1316 initials: Option<text::Fragment<'a>>,
1317 compact: bool,
1318 on_press: Option<Message>,
1319}
1320
1321impl<Message> std::fmt::Debug for MenuButton<'_, Message> {
1322 fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
1323 f.debug_struct("MenuButton")
1324 .field("title", &self.title)
1325 .field("subtitle", &self.subtitle)
1326 .field("compact", &self.compact)
1327 .finish_non_exhaustive()
1328 }
1329}
1330
1331pub fn menu_button<'a, Message>(title: impl text::IntoFragment<'a>) -> MenuButton<'a, Message> {
1333 MenuButton {
1334 title: title.into_fragment(),
1335 subtitle: None,
1336 icon: None,
1337 initials: None,
1338 compact: false,
1339 on_press: None,
1340 }
1341}
1342
1343impl<'a, Message> MenuButton<'a, Message> {
1344 pub fn subtitle(mut self, subtitle: impl text::IntoFragment<'a>) -> Self {
1345 self.subtitle = Some(subtitle.into_fragment());
1346 self
1347 }
1348
1349 pub fn icon(mut self, glyph: Glyph) -> Self {
1351 self.icon = Some(glyph);
1352 self
1353 }
1354
1355 pub fn initials(mut self, initials: impl text::IntoFragment<'a>) -> Self {
1357 self.initials = Some(initials.into_fragment());
1358 self
1359 }
1360
1361 pub fn compact(mut self, compact: bool) -> Self {
1364 self.compact = compact;
1365 self
1366 }
1367
1368 pub fn on_press(mut self, message: Message) -> Self {
1369 self.on_press = Some(message);
1370 self
1371 }
1372
1373 pub fn on_press_maybe(mut self, message: Option<Message>) -> Self {
1374 self.on_press = message;
1375 self
1376 }
1377}
1378
1379impl<'a, Message: Clone + 'a> From<MenuButton<'a, Message>> for Element<'a, Message> {
1380 fn from(menu_button: MenuButton<'a, Message>) -> Self {
1381 let MenuButton {
1382 title,
1383 subtitle,
1384 icon,
1385 initials,
1386 compact,
1387 on_press,
1388 } = menu_button;
1389 let logo = icon.is_some();
1390 let face: Element<'a, Message> = match (icon, initials) {
1391 (Some(glyph), _) => themed(glyph, menu::ICON_SIZE, 1.0, |theme| {
1392 Tokens::of(theme).primary_foreground
1393 })
1394 .into(),
1395 (None, Some(initials)) => text(initials)
1396 .size(text_size::XS)
1397 .font(crate::theme::semibold())
1398 .wrapping(text::Wrapping::None)
1399 .into(),
1400 (None, None) => themed(crate::lucide!(User), menu::ICON_SIZE, 1.0, |theme| {
1401 Tokens::of(theme).foreground
1402 })
1403 .into(),
1404 };
1405 let square = container(face)
1406 .center(RAIL_BUTTON)
1407 .style(move |theme| avatar_style(&Tokens::of(theme), logo));
1408
1409 let content: Element<'a, Message> = if compact {
1410 square.into()
1411 } else {
1412 let mut lines = column![
1413 text(title)
1414 .size(text_size::SM)
1415 .font(crate::theme::semibold())
1416 .wrapping(text::Wrapping::None)
1417 ];
1418 if let Some(subtitle) = subtitle {
1419 lines = lines.push(
1420 text(subtitle)
1421 .size(text_size::XS)
1422 .wrapping(text::Wrapping::None)
1423 .style(|theme: &Theme| text::Style {
1424 color: Some(Tokens::of(theme).muted_foreground),
1425 }),
1426 );
1427 }
1428 row![
1429 square,
1430 container(lines).width(Length::Fill).clip(true),
1431 themed(
1432 crate::lucide!(ChevronsUpDown),
1433 menu::ICON_SIZE,
1434 1.0,
1435 |theme| { Tokens::of(theme).muted_foreground }
1436 ),
1437 ]
1438 .spacing(space::SM)
1439 .align_y(Alignment::Center)
1440 .into()
1441 };
1442
1443 let width = if compact {
1444 Length::Fixed(RAIL_BUTTON)
1445 } else {
1446 Length::Fill
1447 };
1448 let padding = if compact { 0.0 } else { space::SM };
1449 button(content)
1450 .padding(padding)
1451 .width(width)
1452 .on_press_maybe(on_press)
1453 .style(|theme, status| {
1454 let status = match status {
1455 button::Status::Disabled => button::Status::Active,
1456 status => status,
1457 };
1458 item_style(&Tokens::of(theme), false, false, status)
1459 })
1460 .into()
1461 }
1462}
1463
1464pub fn avatar_style(tokens: &Tokens, logo: bool) -> container::Style {
1466 let (background, text_color) = if logo {
1467 (tokens.primary, tokens.primary_foreground)
1468 } else {
1469 (tokens.muted, tokens.foreground)
1470 };
1471 container::Style {
1472 background: Some(Background::Color(background)),
1473 text_color: Some(text_color),
1474 border: Border {
1475 radius: radius::MD.into(),
1476 ..Border::default()
1477 },
1478 ..container::Style::default()
1479 }
1480}
1481
1482fn watch<'a, Id, Message>(
1484 content: Element<'a, Message>,
1485 state: &'a State<Id>,
1486 keymap: Option<Keymap<Action>>,
1487 track_focus: bool,
1488 breakpoint: Option<f32>,
1489 on_event: Option<OnEvent<'a, Id, Message>>,
1490) -> Element<'a, Message>
1491where
1492 Id: Copy + PartialEq + 'a,
1493 Message: 'a,
1494{
1495 let Some(on_event) = on_event else {
1496 return content;
1497 };
1498 Element::new(Watch {
1499 content,
1500 state,
1501 keymap,
1502 track_focus,
1503 breakpoint,
1504 on_event,
1505 })
1506}
1507
1508struct Watch<'a, Id, Message> {
1512 content: Element<'a, Message>,
1513 state: &'a State<Id>,
1514 keymap: Option<Keymap<Action>>,
1515 track_focus: bool,
1516 breakpoint: Option<f32>,
1517 on_event: OnEvent<'a, Id, Message>,
1518}
1519
1520fn is_press(event: &Input) -> bool {
1521 matches!(
1522 event,
1523 Input::Mouse(mouse::Event::ButtonPressed(_))
1524 | Input::Touch(touch::Event::FingerPressed { .. })
1525 )
1526}
1527
1528fn press_position(event: &Input, cursor: mouse::Cursor) -> Option<iced::Point> {
1529 match event {
1530 Input::Touch(touch::Event::FingerPressed { position, .. }) => Some(*position),
1531 _ => cursor.position(),
1532 }
1533}
1534
1535impl<Id, Message> Widget<Message, Theme, Renderer> for Watch<'_, Id, Message>
1536where
1537 Id: Copy + PartialEq,
1538{
1539 fn size(&self) -> Size<Length> {
1540 self.content.as_widget().size()
1541 }
1542
1543 fn size_hint(&self) -> Size<Length> {
1544 self.content.as_widget().size_hint()
1545 }
1546
1547 fn tag(&self) -> tree::Tag {
1548 self.content.as_widget().tag()
1549 }
1550
1551 fn state(&self) -> tree::State {
1552 self.content.as_widget().state()
1553 }
1554
1555 fn children(&self) -> Vec<Tree> {
1556 self.content.as_widget().children()
1557 }
1558
1559 fn diff(&self, tree: &mut Tree) {
1560 self.content.as_widget().diff(tree);
1561 }
1562
1563 fn layout(
1564 &mut self,
1565 tree: &mut Tree,
1566 renderer: &Renderer,
1567 limits: &layout::Limits,
1568 ) -> layout::Node {
1569 self.content.as_widget_mut().layout(tree, renderer, limits)
1570 }
1571
1572 fn draw(
1573 &self,
1574 tree: &Tree,
1575 renderer: &mut Renderer,
1576 theme: &Theme,
1577 style: &renderer::Style,
1578 layout: Layout<'_>,
1579 cursor: mouse::Cursor,
1580 viewport: &Rectangle,
1581 ) {
1582 self.content
1583 .as_widget()
1584 .draw(tree, renderer, theme, style, layout, cursor, viewport);
1585 }
1586
1587 fn operate(
1588 &mut self,
1589 tree: &mut Tree,
1590 layout: Layout<'_>,
1591 renderer: &Renderer,
1592 operation: &mut dyn Operation,
1593 ) {
1594 self.content
1595 .as_widget_mut()
1596 .operate(tree, layout, renderer, operation);
1597 }
1598
1599 fn update(
1600 &mut self,
1601 tree: &mut Tree,
1602 event: &Input,
1603 layout: Layout<'_>,
1604 cursor: mouse::Cursor,
1605 renderer: &Renderer,
1606 clipboard: &mut dyn Clipboard,
1607 shell: &mut Shell<'_, Message>,
1608 viewport: &Rectangle,
1609 ) {
1610 if let Some(breakpoint) = self.breakpoint {
1611 let narrow = layout.bounds().width < breakpoint;
1612 if narrow != self.state.narrow {
1613 shell.publish((self.on_event)(Event::Narrow(narrow)));
1614 }
1615 }
1616
1617 if let (Some(keymap), Input::Keyboard(keyboard::Event::KeyPressed { key, modifiers, .. })) =
1618 (&self.keymap, event)
1619 && self.state.has_keys()
1620 && let Some(action) = keymap.resolve(key, *modifiers)
1621 {
1622 if let Some(event) = action.event(self.state) {
1623 shell.publish((self.on_event)(event));
1624 }
1625 shell.capture_event();
1626 return;
1627 }
1628
1629 if self.track_focus && is_press(event) {
1630 let inside =
1631 press_position(event, cursor).is_some_and(|point| layout.bounds().contains(point));
1632 if inside != self.state.focused {
1633 shell.publish((self.on_event)(Event::Focus(inside)));
1634 }
1635 }
1636
1637 self.content.as_widget_mut().update(
1638 tree, event, layout, cursor, renderer, clipboard, shell, viewport,
1639 );
1640 }
1641
1642 fn mouse_interaction(
1643 &self,
1644 tree: &Tree,
1645 layout: Layout<'_>,
1646 cursor: mouse::Cursor,
1647 viewport: &Rectangle,
1648 renderer: &Renderer,
1649 ) -> mouse::Interaction {
1650 self.content
1651 .as_widget()
1652 .mouse_interaction(tree, layout, cursor, viewport, renderer)
1653 }
1654
1655 fn overlay<'b>(
1656 &'b mut self,
1657 tree: &'b mut Tree,
1658 layout: Layout<'b>,
1659 renderer: &Renderer,
1660 viewport: &Rectangle,
1661 translation: Vector,
1662 ) -> Option<overlay::Element<'b, Message, Theme, Renderer>> {
1663 self.content
1664 .as_widget_mut()
1665 .overlay(tree, layout, renderer, viewport, translation)
1666 }
1667}
1668
1669#[cfg(test)]
1670mod tests {
1671 use super::*;
1672 use crate::theme::{dark, light};
1673
1674 #[derive(Debug, Clone, Copy, PartialEq, Eq)]
1675 enum Page {
1676 Home,
1677 Inbox,
1678 Projects,
1679 Alpha,
1680 Beta,
1681 Archived,
1682 Settings,
1683 Billing,
1684 }
1685
1686 use Page::*;
1687
1688 fn state() -> State<Page> {
1689 State::new([
1690 group(
1691 "Platform",
1692 [
1693 item(Home, "Home"),
1694 item(Inbox, "Inbox").badge("12"),
1695 item(Projects, "Projects").children([
1696 item(Alpha, "Alpha"),
1697 item(Beta, "Beta"),
1698 item(Archived, "Archived").disabled(true),
1699 ]),
1700 ],
1701 ),
1702 group(
1703 "Account",
1704 [item(Settings, "Settings"), item(Billing, "Billing")],
1705 )
1706 .collapsible(true),
1707 ])
1708 }
1709
1710 fn rows(state: &State<Page>) -> Vec<Row<Page>> {
1711 state.rows()
1712 }
1713
1714 #[test]
1715 fn builders_set_fields() {
1716 let entry = item(Home, "Home")
1717 .icon(crate::lucide!(House))
1718 .badge("3")
1719 .disabled(true);
1720 assert_eq!(entry.badge.as_deref(), Some("3"));
1721 assert!(entry.icon.is_some() && entry.disabled);
1722
1723 let nested = item(Projects, "Projects")
1724 .children([item(Alpha, "Alpha").children([item(Beta, "Beta")])]);
1725 assert!(nested.children[0].children.is_empty(), "one level deep");
1726
1727 let group = group("G", [item(Home, "Home")]).collapsible(true);
1728 assert!(group.collapsible);
1729
1730 let state = State::new([group]);
1731 assert!(!state.is_rail() && !state.is_narrow() && !state.is_focused());
1732 assert_eq!(state.active(), None);
1733 }
1734
1735 #[test]
1736 fn with_active_opens_the_parent_and_ignores_disabled() {
1737 let state = state().with_active(Beta);
1738 assert_eq!(state.active(), Some(Beta));
1739 assert!(state.is_expanded(Projects));
1740 assert_eq!(state.clone().with_active(Archived).active(), Some(Beta));
1741 }
1742
1743 #[test]
1744 fn rows_follow_what_is_showing() {
1745 let mut state = state();
1746 assert_eq!(
1747 rows(&state),
1748 [
1749 Row::Item(Home),
1750 Row::Item(Inbox),
1751 Row::Item(Projects),
1752 Row::Group(1),
1753 Row::Item(Settings),
1754 Row::Item(Billing)
1755 ]
1756 );
1757 let _ = state.update(Event::ToggleItem(Projects));
1758 assert!(rows(&state).contains(&Row::Item(Alpha)));
1759 assert!(!rows(&state).contains(&Row::Item(Archived)), "disabled");
1760 let _ = state.update(Event::ToggleGroup(1));
1761 assert!(!rows(&state).contains(&Row::Item(Settings)));
1762 assert!(rows(&state).contains(&Row::Group(1)));
1763 }
1764
1765 #[test]
1766 fn the_rail_shows_top_level_items_only() {
1767 let state = state().with_rail(true);
1768 assert!(state.is_compact());
1769 assert_eq!(
1770 rows(&state),
1771 [
1772 Row::Item(Home),
1773 Row::Item(Inbox),
1774 Row::Item(Projects),
1775 Row::Item(Settings),
1776 Row::Item(Billing)
1777 ]
1778 );
1779 let mut narrow = state.clone();
1780 let _ = narrow.update(Event::Narrow(true));
1781 assert!(
1782 narrow.is_rail() && !narrow.is_compact(),
1783 "the drawer shows labels"
1784 );
1785 }
1786
1787 #[test]
1788 fn activating_a_leaf_makes_it_active() {
1789 let mut state = state();
1790 assert_eq!(
1791 state.update(Event::Activate(Inbox)),
1792 Some(Output::Activated(Inbox))
1793 );
1794 assert_eq!(state.active(), Some(Inbox));
1795 assert_eq!(state.highlighted(), Some(Row::Item(Inbox)));
1796 assert_eq!(state.update(Event::Activate(Archived)), None);
1797 assert_eq!(state.active(), Some(Inbox));
1798 }
1799
1800 #[test]
1801 fn activating_a_parent_toggles_its_children() {
1802 let mut state = state();
1803 assert_eq!(state.update(Event::Activate(Projects)), None);
1804 assert!(state.is_expanded(Projects));
1805 let _ = state.update(Event::Activate(Projects));
1806 assert!(!state.is_expanded(Projects));
1807 }
1808
1809 #[test]
1810 fn a_parent_in_the_rail_expands_the_sidebar() {
1811 let mut state = state().with_rail(true);
1812 let _ = state.update(Event::Activate(Projects));
1813 assert!(!state.is_rail());
1814 assert!(state.is_expanded(Projects));
1815 }
1816
1817 #[test]
1818 fn up_and_down_wrap_through_rows() {
1819 let mut state = state();
1820 let _ = state.update(Event::Next);
1821 assert_eq!(state.highlighted(), Some(Row::Item(Home)));
1822 let _ = state.update(Event::Previous);
1823 assert_eq!(state.highlighted(), Some(Row::Item(Billing)));
1824 let _ = state.update(Event::Next);
1825 assert_eq!(state.highlighted(), Some(Row::Item(Home)));
1826 let _ = state.update(Event::Last);
1827 assert_eq!(state.highlighted(), Some(Row::Item(Billing)));
1828 let _ = state.update(Event::First);
1829 assert_eq!(state.highlighted(), Some(Row::Item(Home)));
1830 }
1831
1832 #[test]
1833 fn enter_activates_the_highlight() {
1834 let mut state = state();
1835 let _ = state.update(Event::Next);
1836 let _ = state.update(Event::Next);
1837 assert_eq!(
1838 state.update(Event::ActivateHighlighted),
1839 Some(Output::Activated(Inbox))
1840 );
1841 let _ = state.update(Event::Last);
1842 let _ = state.update(Event::Previous);
1843 let _ = state.update(Event::Previous);
1844 assert_eq!(state.highlighted(), Some(Row::Group(1)));
1845 let _ = state.update(Event::ActivateHighlighted);
1846 assert!(!state.is_group_open(1));
1847 }
1848
1849 #[test]
1850 fn right_and_left_open_close_and_move_through_parents() {
1851 let mut state = state();
1852 state.highlighted = Some(Row::Item(Projects));
1853 let _ = state.update(Event::Expand);
1854 assert!(state.is_expanded(Projects));
1855 let _ = state.update(Event::Expand);
1856 assert_eq!(state.highlighted(), Some(Row::Item(Alpha)));
1857 let _ = state.update(Event::Collapse);
1858 assert_eq!(state.highlighted(), Some(Row::Item(Projects)));
1859 let _ = state.update(Event::Collapse);
1860 assert!(!state.is_expanded(Projects));
1861 }
1862
1863 #[test]
1864 fn right_and_left_open_and_close_groups() {
1865 let mut state = state();
1866 state.highlighted = Some(Row::Item(Settings));
1867 let _ = state.update(Event::Collapse);
1868 assert_eq!(state.highlighted(), Some(Row::Group(1)));
1869 let _ = state.update(Event::Collapse);
1870 assert!(!state.is_group_open(1));
1871 let _ = state.update(Event::Expand);
1872 assert!(state.is_group_open(1));
1873 let _ = state.update(Event::Expand);
1874 assert_eq!(state.highlighted(), Some(Row::Item(Settings)));
1875 state.highlighted = Some(Row::Item(Home));
1876 let _ = state.update(Event::Collapse);
1877 assert_eq!(state.highlighted(), Some(Row::Item(Home)), "group is fixed");
1878 }
1879
1880 #[test]
1881 fn closing_moves_a_hidden_highlight_out() {
1882 let mut state = state().with_active(Alpha);
1883 state.highlighted = Some(Row::Item(Alpha));
1884 let _ = state.update(Event::ToggleItem(Projects));
1885 assert_eq!(state.highlighted(), Some(Row::Item(Projects)));
1886
1887 state.highlighted = Some(Row::Item(Billing));
1888 let _ = state.update(Event::ToggleGroup(1));
1889 assert_eq!(state.highlighted(), Some(Row::Group(1)));
1890 let _ = state.update(Event::ToggleGroup(0));
1891 assert!(state.is_group_open(0), "fixed groups stay open");
1892 }
1893
1894 #[test]
1895 fn toggling_the_rail_moves_the_highlight_to_a_showing_row() {
1896 let mut state = state().with_active(Alpha);
1897 state.highlighted = Some(Row::Item(Alpha));
1898 let _ = state.update(Event::ToggleRail);
1899 assert!(state.is_rail());
1900 assert_eq!(state.highlighted(), Some(Row::Item(Projects)));
1901 state.highlighted = Some(Row::Group(1));
1902 let _ = state.update(Event::SetRail(true));
1903 assert_eq!(state.highlighted(), Some(Row::Item(Settings)));
1904 }
1905
1906 #[test]
1907 fn the_trigger_toggles_the_rail_or_the_drawer() {
1908 let mut state = state().with_active(Inbox);
1909 let _ = state.update(Event::Toggle);
1910 assert!(state.is_rail());
1911 let _ = state.update(Event::Narrow(true));
1912 let _ = state.update(Event::Toggle);
1913 assert!(state.is_drawer_open());
1914 assert_eq!(state.highlighted(), Some(Row::Item(Inbox)));
1915 let _ = state.update(Event::Toggle);
1916 assert!(!state.is_drawer_open());
1917 }
1918
1919 #[test]
1920 fn the_drawer_closes_on_activation_and_when_the_window_widens() {
1921 let mut state = state();
1922 let _ = state.update(Event::Narrow(true));
1923 let _ = state.update(Event::OpenDrawer);
1924 assert!(state.has_keys());
1925 let _ = state.update(Event::Activate(Home));
1926 assert!(!state.is_drawer_open());
1927
1928 let _ = state.update(Event::OpenDrawer);
1929 let _ = state.update(Event::Narrow(false));
1930 assert!(!state.is_drawer_open());
1931 let _ = state.update(Event::Narrow(true));
1932 assert!(!state.is_drawer_open(), "stays closed");
1933 let _ = state.update(Event::ToggleDrawer);
1934 let _ = state.update(Event::CloseDrawer);
1935 assert!(!state.is_drawer_open());
1936 }
1937
1938 #[test]
1939 fn the_ring_shows_once_the_keyboard_moves_the_highlight() {
1940 let mut state = state().with_active(Home);
1941 let _ = state.update(Event::Focus(true));
1942 assert!(!state.shows_highlight(), "a press does not draw the ring");
1943 let _ = state.update(Event::Next);
1944 assert!(state.shows_highlight());
1945 let _ = state.update(Event::Activate(Inbox));
1946 assert!(!state.shows_highlight(), "a click hides it again");
1947 let _ = state.update(Event::Next);
1948 let _ = state.update(Event::Focus(false));
1949 assert!(!state.shows_highlight(), "only while the sidebar has keys");
1950 }
1951
1952 #[test]
1953 fn focus_highlights_the_active_item() {
1954 let mut state = state().with_active(Beta);
1955 let _ = state.update(Event::Focus(true));
1956 assert!(state.is_focused());
1957 assert_eq!(state.highlighted(), Some(Row::Item(Beta)));
1958 let _ = state.update(Event::Focus(false));
1959 assert!(!state.has_keys());
1960
1961 let mut closed = state.clone();
1962 closed.highlighted = None;
1963 let _ = closed.update(Event::ToggleItem(Projects));
1964 let _ = closed.update(Event::Focus(true));
1965 assert_eq!(closed.highlighted(), Some(Row::Item(Projects)));
1966 }
1967
1968 fn press(keymap: &Keymap<Action>, chord: &str) -> Option<Action> {
1969 let chord: Chord = chord.parse().unwrap();
1970 keymap.resolve(chord.key(), chord.modifiers())
1971 }
1972
1973 #[test]
1974 fn default_keymap_covers_every_action() {
1975 let keymap = default_keymap();
1976 assert_eq!(press(&keymap, "ArrowDown"), Some(Action::Next));
1977 assert_eq!(press(&keymap, "ArrowUp"), Some(Action::Previous));
1978 assert_eq!(press(&keymap, "Home"), Some(Action::First));
1979 assert_eq!(press(&keymap, "End"), Some(Action::Last));
1980 assert_eq!(press(&keymap, "Enter"), Some(Action::Activate));
1981 assert_eq!(press(&keymap, "Space"), Some(Action::Activate));
1982 assert_eq!(press(&keymap, "ArrowRight"), Some(Action::Expand));
1983 assert_eq!(press(&keymap, "ArrowLeft"), Some(Action::Collapse));
1984 assert_eq!(press(&keymap, "Mod+B"), Some(Action::ToggleRail));
1985 assert_eq!(press(&keymap, "Escape"), Some(Action::Close));
1986 assert_eq!(press(&keymap, "B"), None);
1987 for action in <Action as keys::Action>::ALL {
1988 assert!(!keymap.chords(action).is_empty(), "{action:?}");
1989 }
1990 assert_eq!(Keymap::<Action>::defaults(), keymap);
1991 }
1992
1993 #[test]
1994 fn keymap_overrides_and_unbinding() {
1995 let keymap = default_keymap()
1996 .unbind(&"Mod+B".parse().unwrap())
1997 .bind("Ctrl+Shift+S".parse().unwrap(), Action::ToggleRail);
1998 assert_eq!(press(&keymap, "Mod+B"), None);
1999 assert_eq!(press(&keymap, "Ctrl+Shift+S"), Some(Action::ToggleRail));
2000 }
2001
2002 #[test]
2003 fn actions_map_to_events() {
2004 let mut state = state();
2005 assert_eq!(Action::Next.event(&state), Some(Event::Next));
2006 assert_eq!(
2007 Action::Activate.event(&state),
2008 Some(Event::ActivateHighlighted)
2009 );
2010 assert_eq!(Action::ToggleRail.event(&state), Some(Event::Toggle));
2011 assert_eq!(Action::Close.event(&state), None);
2012 let _ = state.update(Event::Focus(true));
2013 assert_eq!(Action::Close.event(&state), Some(Event::Focus(false)));
2014 let _ = state.update(Event::Narrow(true));
2015 let _ = state.update(Event::OpenDrawer);
2016 assert_eq!(Action::Close.event(&state), Some(Event::CloseDrawer));
2017 }
2018
2019 #[test]
2020 fn shortcut_only_resolves_the_app_wide_action() {
2021 let state = state();
2022 let keymap = default_keymap();
2023 let key = |chord: &str| {
2024 let chord: Chord = chord.parse().unwrap();
2025 keys::Event {
2026 key: chord.key().clone(),
2027 modifiers: chord.modifiers(),
2028 }
2029 };
2030 assert_eq!(state.shortcut(&keymap, &key("Mod+B")), Some(Event::Toggle));
2031 assert_eq!(state.shortcut(&keymap, &key("ArrowDown")), None);
2032 }
2033
2034 #[test]
2035 fn rows_highlight_when_active_hovered_or_pressed() {
2036 for theme in [light(), dark()] {
2037 let tokens = Tokens::of(&theme);
2038 let idle = item_style(&tokens, false, false, button::Status::Active);
2039 assert_eq!(idle.background, None);
2040 assert_eq!(idle.text_color, tokens.foreground);
2041 for status in [button::Status::Hovered, button::Status::Pressed] {
2042 let style = item_style(&tokens, false, false, status);
2043 assert_eq!(style.background, Some(Background::Color(tokens.accent)));
2044 }
2045 let active = item_style(&tokens, true, false, button::Status::Active);
2046 assert_eq!(active.background, Some(Background::Color(tokens.accent)));
2047 let disabled = item_style(&tokens, true, false, button::Status::Disabled);
2048 assert_eq!(disabled.background, None);
2049 assert_eq!(disabled.text_color, tokens.muted_foreground);
2050 }
2051 }
2052
2053 #[test]
2054 fn the_keyboard_highlight_draws_a_ring() {
2055 let tokens = Tokens::of(&light());
2056 let ring = item_style(&tokens, false, true, button::Status::Active);
2057 assert_eq!(ring.border.color, tokens.ring);
2058 assert_eq!(ring.border.width, 1.0);
2059 let plain = item_style(&tokens, false, false, button::Status::Active);
2060 assert_eq!(plain.border.width, 0.0);
2061 }
2062
2063 #[test]
2064 fn surfaces_sit_between_page_and_card() {
2065 for theme in [light(), dark()] {
2066 let tokens = Tokens::of(&theme);
2067 let surface = surface_style(&tokens).background;
2068 assert!(surface.is_some());
2069 assert_ne!(surface, Some(Background::Color(tokens.background)));
2070 assert_eq!(
2071 edge_style(&tokens).background,
2072 Some(Background::Color(tokens.border))
2073 );
2074 assert!(scrim_style(&tokens).background.is_some());
2075 }
2076 let logo = avatar_style(&Tokens::of(&light()), true);
2077 assert_eq!(
2078 logo.text_color,
2079 Some(Tokens::of(&light()).primary_foreground)
2080 );
2081 }
2082
2083 #[test]
2084 fn builder_defaults() {
2085 let state = state();
2086 let sidebar: Sidebar<'_, Page, ()> = sidebar(&state);
2087 assert_eq!(sidebar.breakpoint, BREAKPOINT);
2088 assert!(sidebar.trigger);
2089 assert!(sidebar.content.is_none());
2090 assert!(sidebar.on_event.is_none());
2091 assert_eq!(sidebar.keymap, default_keymap());
2092 }
2093}