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