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