Skip to main content

iced_cube/navigation/
sidebar.rs

1//! Persistent app navigation down one side of the window.
2//!
3//! [`State`] owns the groups and items, which item is active, the keyboard
4//! highlight, which groups and parent items are open, and whether the
5//! sidebar is collapsed to an icon rail. Its [`update`](State::update)
6//! handles clicks and keyboard navigation and returns an [`Output`] when an
7//! item is activated.
8//!
9//! [`sidebar`] draws the panel, and with [`content`](Sidebar::content)
10//! the page beside it. On a window narrower than the
11//! [breakpoint](Sidebar::breakpoint) the panel becomes an off-canvas
12//! drawer instead: a trigger button opens it over a scrim, and a tap on
13//! the scrim, Escape or choosing an item closes it.
14//!
15//! # Keyboard
16//!
17//! A press inside the sidebar gives it keyboard focus, and an open drawer
18//! always has it. While focused the sidebar resolves its own [`Keymap`] of
19//! [`Action`]s and captures the keys it uses; see [`default_keymap`]. Only
20//! [`Action::ToggleRail`] is meant to work app-wide: route key presses from
21//! [`keys::subscription`] through [`State::shortcut`] for that.
22
23use 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
45/// Width of the expanded sidebar.
46pub const WIDTH: f32 = 256.0;
47/// Width of the icon rail.
48pub const RAIL_WIDTH: f32 = 48.0;
49/// Width of the drawer on narrow windows.
50pub const DRAWER_WIDTH: f32 = 288.0;
51/// Below this window width the sidebar becomes a drawer.
52pub const BREAKPOINT: f32 = 640.0;
53/// Height of the bar holding the trigger on narrow windows.
54pub const BAR_HEIGHT: f32 = 48.0;
55
56/// Side of the square buttons in the rail.
57const RAIL_BUTTON: f32 = 32.0;
58
59/// The widget id of the trigger in the narrow window bar, for tests.
60pub const TRIGGER_ID: iced::widget::Id = iced::widget::Id::new("iced-cube-sidebar-trigger");
61
62/// One navigation item. Items nest one level: children of children are
63/// ignored.
64#[derive(Debug, Clone, PartialEq)]
65pub struct Item<Id> {
66    pub id: Id,
67    pub label: String,
68    pub icon: Option<Glyph>,
69    /// A short count or status on the right, such as `12`.
70    pub badge: Option<String>,
71    pub disabled: bool,
72    pub children: Vec<Item<Id>>,
73}
74
75/// Creates an enabled item.
76pub 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    /// Nested items, shown below this one while it is expanded.
104    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/// A labelled group of items.
121#[derive(Debug, Clone, PartialEq)]
122pub struct Group<Id> {
123    /// The heading. An empty label draws no heading.
124    pub label: String,
125    pub items: Vec<Item<Id>>,
126    /// Whether the heading opens and closes the group.
127    pub collapsible: bool,
128}
129
130/// Creates a group that is always open.
131pub 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/// A row the keyboard can highlight: a collapsible group's heading or an
147/// item.
148#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)]
149pub enum Row<Id> {
150    Group(usize),
151    Item(Id),
152}
153
154/// Changes to a sidebar.
155#[derive(Debug, Clone, Copy, PartialEq, Eq)]
156pub enum Event<Id> {
157    /// Chooses an item, as a click does. A parent item opens or closes
158    /// its children instead.
159    Activate(Id),
160    /// Highlights the next row, wrapping.
161    Next,
162    /// Highlights the previous row, wrapping.
163    Previous,
164    First,
165    Last,
166    /// Activates the highlighted item or opens and closes the highlighted
167    /// group.
168    ActivateHighlighted,
169    /// Opens the highlighted group or parent, or moves into it when open.
170    Expand,
171    /// Closes the highlighted group or parent, or moves out to it.
172    Collapse,
173    ToggleGroup(usize),
174    /// Opens or closes a parent item's children.
175    ToggleItem(Id),
176    ToggleRail,
177    SetRail(bool),
178    OpenDrawer,
179    CloseDrawer,
180    ToggleDrawer,
181    /// What the trigger does: toggles the drawer on a narrow window and
182    /// the rail otherwise.
183    Toggle,
184    /// Whether the window is narrower than the breakpoint. The sidebar
185    /// sends this itself when the width crosses the breakpoint.
186    Narrow(bool),
187    /// The sidebar gained or lost keyboard focus.
188    Focus(bool),
189}
190
191/// What an event did that the app should act on.
192#[derive(Debug, Clone, Copy, PartialEq, Eq)]
193pub enum Output<Id> {
194    /// An item was activated: show its page.
195    Activated(Id),
196}
197
198/// The navigation, the active item and how the sidebar is shown.
199#[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    /// Whether the keyboard moved the highlight last, so it is drawn.
211    keyboard: bool,
212}
213
214impl<Id: Copy + PartialEq> State<Id> {
215    /// An expanded sidebar with every group open and nothing active.
216    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    /// Starts with `id` active, opening its parent when it is a child.
233    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    /// Starts collapsed to the icon rail.
244    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    /// Whether the sidebar is set to collapse to a rail on wide windows.
262    pub fn is_rail(&self) -> bool {
263        self.rail
264    }
265
266    /// Whether the sidebar is drawn as a rail right now: collapsed on a
267    /// wide window. The drawer always shows labels.
268    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    /// Whether the highlighted row gets a ring: while the sidebar has keys
285    /// and the keyboard moved the highlight last.
286    pub fn shows_highlight(&self) -> bool {
287        self.keyboard && self.has_keys()
288    }
289
290    /// Whether the sidebar resolves its keymap: while focused or while
291    /// the drawer is open.
292    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    /// The parent of a child item.
317    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    /// Whether an item or one of its children is the active one, so a
335    /// closed parent still shows where the page is.
336    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    /// The rows the keyboard moves through, in order: headings of
344    /// collapsible groups and the enabled items that are showing.
345    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    /// Applies an event and returns the activated item, if any.
372    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    /// Turns a key press from [`keys::subscription`] into an event for the
433    /// app-wide action, [`Action::ToggleRail`]. The sidebar resolves every
434    /// other action itself while it has focus.
435    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    /// Highlights the active item, or the first row, if it is showing.
582    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    /// Moves a highlight that is no longer showing to its parent or group,
598    /// or drops it.
599    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/// What a sidebar keyboard shortcut does.
623#[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    /// Collapses to the rail and back, or opens and closes the drawer on
633    /// a narrow window.
634    ToggleRail,
635    /// Closes the drawer, or gives up keyboard focus.
636    Close,
637}
638
639impl Action {
640    /// The [`Event`] this action sends to `state`, or `None` when
641    /// [`Action::Close`] has nothing to close.
642    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
708/// The default sidebar shortcuts:
709///
710/// | Keys | Action |
711/// | --- | --- |
712/// | `ArrowDown` | [`Action::Next`] |
713/// | `ArrowUp` | [`Action::Previous`] |
714/// | `Home` | [`Action::First`] |
715/// | `End` | [`Action::Last`] |
716/// | `Enter`, `Space` | [`Action::Activate`] |
717/// | `ArrowRight` | [`Action::Expand`] |
718/// | `ArrowLeft` | [`Action::Collapse`] |
719/// | `Ctrl+B` (`Cmd+B` on macOS) | [`Action::ToggleRail`] |
720/// | `Escape` | [`Action::Close`] |
721///
722/// The sidebar resolves these itself while it has focus or its drawer is
723/// open. Route [`keys::subscription`] through [`State::shortcut`] so
724/// `Ctrl+B` works everywhere else too.
725pub 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
739/// The sidebar surface: a shade off the page in light themes, and lifted
740/// like a card in dark ones.
741pub 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
754/// The one pixel edge between the sidebar and the page.
755pub fn edge_style(tokens: &Tokens) -> container::Style {
756    container::Style {
757        background: Some(Background::Color(tokens.border)),
758        ..container::Style::default()
759    }
760}
761
762/// The scrim behind the open drawer.
763pub 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
773/// How an item row is drawn: in the shared menu row colours, highlighted
774/// while active or under the pointer, with a ring while it has the
775/// keyboard highlight.
776pub 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
806/// A sidebar builder. Convert it into an [`Element`] to render.
807pub 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
831/// Renders the navigation of `state`. Without
832/// [`on_event`](Sidebar::on_event) every item renders disabled.
833pub 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    /// Content above the groups, such as the app name and a switcher
849    /// built with [`menu_button`].
850    pub fn header(mut self, header: impl Into<Element<'a, Message>>) -> Self {
851        self.header = Some(header.into());
852        self
853    }
854
855    /// Content below the groups, such as the signed in user.
856    pub fn footer(mut self, footer: impl Into<Element<'a, Message>>) -> Self {
857        self.footer = Some(footer.into());
858        self
859    }
860
861    /// The page beside the sidebar. With content, the sidebar turns into a
862    /// drawer on narrow windows; without it, only the panel is drawn.
863    pub fn content(mut self, content: impl Into<Element<'a, Message>>) -> Self {
864        self.content = Some(content.into());
865        self
866    }
867
868    /// The expanded width. Defaults to [`WIDTH`]; use [`Length::Fill`]
869    /// inside a resizable panel.
870    pub fn width(mut self, width: impl Into<Length>) -> Self {
871        self.width = Some(width.into());
872        self
873    }
874
875    /// The window width below which the sidebar becomes a drawer.
876    pub fn breakpoint(mut self, width: f32) -> Self {
877        self.breakpoint = width;
878        self
879    }
880
881    /// Whether narrow windows get a bar with the trigger above the
882    /// content. Turn it off to place [`trigger`] in your own header.
883    pub fn trigger(mut self, trigger: bool) -> Self {
884        self.trigger = trigger;
885        self
886    }
887
888    /// Replaces the [`default_keymap`] the focused sidebar resolves keys
889    /// with.
890    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/// How the panel is drawn.
904#[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            // Whatever sits beside a lone panel, such as a handle, draws
933            // the edge.
934            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
998/// The icon button that opens the drawer or collapses the sidebar. Send
999/// [`Event::Toggle`] from it to do whichever fits the window.
1000pub 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
1072/// The shadow the drawer casts over the page.
1073pub 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
1309/// A header or footer button: a logo or avatar, a title and a subtitle,
1310/// with an up and down chevron to show it opens a menu. Wrap it in a
1311/// dropdown menu for an app switcher or a user menu.
1312pub 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
1331/// Creates a header or footer button titled `title`.
1332pub 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    /// A logo icon on a primary square, as for an app or team.
1350    pub fn icon(mut self, glyph: Glyph) -> Self {
1351        self.icon = Some(glyph);
1352        self
1353    }
1354
1355    /// Initials on a muted square, as for a person.
1356    pub fn initials(mut self, initials: impl text::IntoFragment<'a>) -> Self {
1357        self.initials = Some(initials.into_fragment());
1358        self
1359    }
1360
1361    /// Shows only the square, for the rail. Pass
1362    /// [`State::is_compact`].
1363    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
1464/// The square behind a menu button's logo or initials.
1465pub 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
1482/// Wraps part of the sidebar to watch for presses, keys and width.
1483fn 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
1508/// Gives the sidebar keyboard focus on a press inside it and takes it
1509/// away on a press outside, resolves its keymap while it has keys, and
1510/// reports when the width crosses the breakpoint.
1511struct 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}