Skip to main content

gpui_kit_material/components/
navigation.rs

1//! M3 Expressive navigation bar and navigation rail.
2//!
3//! Both are lists of [`NavItem`] destinations painted by one item renderer,
4//! [`NavigationItem`], in three layouts:
5//!
6//! - [`NavItemLayout::Vertical`]: the flexible navigation bar's compact item
7//!   and the collapsed rail's item, a 56×32dp active-indicator pill over a
8//!   label-medium label.
9//! - [`NavItemLayout::Horizontal`]: the flexible navigation bar's medium
10//!   item, icon and label side by side inside a 40dp pill.
11//! - [`NavItemLayout::Expanded`]: the expanded rail's item, a 56dp pill that
12//!   hugs the icon and label-large label.
13//!
14//! Selecting an item grows its secondary-container pill from its center on
15//! the fast spatial spring while icon and label colors cross-fade on the fast
16//! effects spring; the icon switches to its filled form (Material Symbols
17//! FILL 1). The hover/press state layer covers the pill wherever the pointer
18//! is on the item, and a ripple spreads inside the pill.
19//!
20//! Behavior is GPUI Base's: each item is a Base [`gpui_base::Tab`] (tab role,
21//! selection, position in set, pointer and Enter/Space activation) inside a
22//! Base [`gpui_base::Tabs`] tab list, wrapped in a navigation landmark. Arrow
23//! keys rove focus between items through the shared [`super::roving`]
24//! helper; Enter or Space activates.
25use super::button::{ButtonFocusRing, ButtonRipple};
26use super::roving::{self, Change, Orientation, Roving};
27use super::{InteractionPreview, large_badge, small_badge};
28use crate::color::ColorRole;
29use crate::icons::{Icon, Style, Symbol};
30use crate::motion::{self, MotionToken};
31use crate::theme::{blend, color};
32use crate::tokens::{
33    elevation,
34    shape::RADIUS_LARGE,
35    state,
36    typography::{TypeRole, rem},
37};
38use gpui::{prelude::FluentBuilder as _, *};
39use std::{collections::BTreeMap, rc::Rc};
40
41/// The hover group every item's state layer follows. Items are siblings, so
42/// one static name resolves to the enclosing item (GPUI keeps a stack per
43/// group name).
44pub(crate) const ITEM_GROUP: &str = "material-navigation-item";
45
46/// An item's badge, drawn on its icon's top-trailing corner.
47#[derive(Clone, Debug, Default, PartialEq, Eq)]
48pub enum NavBadge {
49    #[default]
50    None,
51    /// The 6dp error dot.
52    Small,
53    /// A count or short label on the large badge, e.g. "3" or "999+".
54    Large(SharedString),
55}
56
57/// A destination icon in its unselected (outlined) and selected (filled)
58/// forms. Build it once and keep it: resolving symbol paths allocates.
59#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct NavIcon {
61    unselected: SharedString,
62    selected: SharedString,
63}
64
65impl NavIcon {
66    /// Two asset paths, e.g. two separate symbols.
67    pub fn new(unselected: impl Into<SharedString>, selected: impl Into<SharedString>) -> Self {
68        Self {
69            unselected: unselected.into(),
70            selected: selected.into(),
71        }
72    }
73
74    /// One Material Symbol, rounded: FILL 0 at rest and FILL 1 selected, the
75    /// M3 navigation icon change.
76    pub fn symbol(symbol: Symbol) -> Self {
77        let icon = symbol.icon().style(Style::Rounded);
78        Self::new(icon.asset_path(), icon.filled(true).asset_path())
79    }
80
81    pub(crate) fn path(&self, selected: bool) -> SharedString {
82        if selected {
83            self.selected.clone()
84        } else {
85            self.unselected.clone()
86        }
87    }
88}
89
90/// One destination: a unique value, the caller's label and icon, and an
91/// optional badge. Cheap to clone; keep a list in view state.
92#[derive(Clone, Debug)]
93pub struct NavItem {
94    value: SharedString,
95    label: SharedString,
96    icon: NavIcon,
97    badge: NavBadge,
98    disabled: bool,
99    preview: Option<InteractionPreview>,
100}
101
102impl NavItem {
103    pub fn new(
104        value: impl Into<SharedString>,
105        label: impl Into<SharedString>,
106        icon: NavIcon,
107    ) -> Self {
108        Self {
109            value: value.into(),
110            label: label.into(),
111            icon,
112            badge: NavBadge::None,
113            disabled: false,
114            preview: None,
115        }
116    }
117    pub fn badge(mut self, badge: NavBadge) -> Self {
118        self.badge = badge;
119        self
120    }
121    pub fn disabled(mut self, disabled: bool) -> Self {
122        self.disabled = disabled;
123        self
124    }
125    /// Render this item as a static specimen in one forced interaction state
126    /// (state layer, focus ring or disabled colors). A bar or rail holding a
127    /// previewed item is a specimen and leaves the tab order. See
128    /// [`InteractionPreview`].
129    pub fn preview_state(mut self, state: InteractionPreview) -> Self {
130        self.preview = Some(state);
131        self
132    }
133    pub fn value(&self) -> &SharedString {
134        &self.value
135    }
136    pub fn label(&self) -> &SharedString {
137        &self.label
138    }
139    fn is_disabled(&self) -> bool {
140        self.disabled || self.preview.is_some_and(InteractionPreview::is_disabled)
141    }
142    pub(crate) fn is_disabled_value(&self) -> bool {
143        self.is_disabled()
144    }
145    pub(crate) fn preview_state_value(&self) -> Option<InteractionPreview> {
146        self.preview
147    }
148    pub(crate) fn badge_value(&self) -> &NavBadge {
149        &self.badge
150    }
151    pub(crate) fn icon_value(&self) -> &NavIcon {
152        &self.icon
153    }
154}
155
156/// Which M3 item an item renders as.
157#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
158pub enum NavItemLayout {
159    /// Icon over label: the navigation bar's compact item and the collapsed
160    /// rail's item.
161    #[default]
162    Vertical,
163    /// Icon beside label in a 40dp pill: the medium navigation bar's item.
164    Horizontal,
165    /// Icon beside label in a 56dp pill: the expanded rail's item.
166    Expanded,
167}
168
169/// Item geometry in design px.
170#[derive(Clone, Copy, Debug, PartialEq)]
171pub(crate) struct Geometry {
172    pub vertical: bool,
173    pub item_height: f32,
174    pub pill_height: f32,
175    pub pill_min_width: f32,
176    pub pill_padding: f32,
177    /// Between icon and label (inside the pill when horizontal).
178    pub gap: f32,
179    /// Above the pill of a vertical item.
180    pub top: f32,
181    /// Leading inset of an expanded rail item.
182    pub inset: f32,
183    pub label: TypeRole,
184}
185
186impl NavItemLayout {
187    pub(crate) const fn geometry(self) -> Geometry {
188        match self {
189            Self::Vertical => Geometry {
190                vertical: true,
191                item_height: 64.,
192                pill_height: 32.,
193                pill_min_width: 56.,
194                pill_padding: 0.,
195                gap: 4.,
196                top: 6.,
197                inset: 0.,
198                label: TypeRole::LabelMedium,
199            },
200            Self::Horizontal => Geometry {
201                vertical: false,
202                item_height: 64.,
203                pill_height: 40.,
204                pill_min_width: 0.,
205                pill_padding: 16.,
206                gap: 4.,
207                top: 0.,
208                inset: 0.,
209                label: TypeRole::LabelMedium,
210            },
211            Self::Expanded => Geometry {
212                vertical: false,
213                item_height: 56.,
214                pill_height: 56.,
215                pill_min_width: 0.,
216                pill_padding: 16.,
217                gap: 8.,
218                top: 0.,
219                inset: 20.,
220                label: TypeRole::LabelLarge,
221            },
222        }
223    }
224}
225
226/// The resolved paint of one item for the current motion frame.
227#[derive(Clone, Copy, Debug, PartialEq)]
228pub(crate) struct ItemPaint {
229    pub icon: Rgba,
230    pub label: Rgba,
231    pub indicator: Rgba,
232    /// The pill's horizontal scale, 0 hidden to 1 full (may overshoot).
233    pub indicator_scale: f32,
234    pub hover: Rgba,
235    pub press: Rgba,
236}
237
238/// `selection` is the fast spatial amount (indicator size) and `fade` the
239/// fast effects amount (colors), each 0 unselected and 1 selected.
240pub(crate) fn item_paint(selected: bool, selection: f32, fade: f32, disabled: bool) -> ItemPaint {
241    let fade = fade.clamp(0., 1.);
242    let rest = color(ColorRole::ON_SURFACE_VARIANT);
243    if disabled {
244        let ink = state::disabled(color(ColorRole::ON_SURFACE));
245        return ItemPaint {
246            icon: ink,
247            label: ink,
248            indicator: color(ColorRole::ON_SURFACE).opacity(state::DISABLED_CONTAINER * fade),
249            indicator_scale: selection.max(0.),
250            hover: Rgba::default(),
251            press: Rgba::default(),
252        };
253    }
254    let layer = if selected {
255        color(ColorRole::ON_SECONDARY_CONTAINER)
256    } else {
257        rest
258    };
259    ItemPaint {
260        icon: blend(rest, color(ColorRole::ON_SECONDARY_CONTAINER), fade),
261        label: blend(rest, color(ColorRole::SECONDARY), fade),
262        indicator: color(ColorRole::SECONDARY_CONTAINER).opacity(fade),
263        indicator_scale: selection.max(0.),
264        hover: layer.opacity(state::HOVER),
265        press: layer.opacity(state::PRESS),
266    }
267}
268
269pub(crate) fn type_role<E: Styled>(element: E, role: TypeRole) -> E {
270    let scale = role.scale();
271    element
272        .font_family(scale.font_family)
273        .text_size(scale.font_size)
274        .line_height(scale.line_height)
275        .font_weight(FontWeight(scale.font_weight as f32))
276}
277
278/// Uniform ripple clip corners.
279pub(crate) fn ripple_corners(radius: Pixels) -> Corners<AbsoluteLength> {
280    let radius = AbsoluteLength::from(radius);
281    Corners {
282        top_left: radius,
283        top_right: radius,
284        bottom_right: radius,
285        bottom_left: radius,
286    }
287}
288
289/// The large badge an item draws after its label instead of on its icon.
290///
291/// Items with the icon beside the label (the horizontal bar's and the
292/// expanded rail's) put a large badge after the label, as the kit's nav
293/// item building block does ("Badge label text" trailing the label): on the
294/// icon it would cover the label's first letters. Small badges stay on the
295/// icon, as do badges of items without a visible label.
296pub(crate) fn trailing_badge(
297    badge: &NavBadge,
298    layout: NavItemLayout,
299    show_label: bool,
300) -> Option<SharedString> {
301    match (badge, layout) {
302        (NavBadge::Large(text), NavItemLayout::Expanded) => Some(text.clone()),
303        (NavBadge::Large(text), NavItemLayout::Horizontal) if show_label => Some(text.clone()),
304        _ => None,
305    }
306}
307
308/// A 24dp icon with the item's badge on its top-trailing corner.
309pub(crate) fn badged_icon(icon: Icon, badge: &NavBadge) -> Div {
310    let slot = div()
311        .relative()
312        .flex_shrink_0()
313        .size(rem(24.))
314        .child(icon.size(rem(24.)));
315    match badge {
316        NavBadge::None => slot,
317        NavBadge::Small => slot.child(small_badge().absolute().top_0().right_0()),
318        NavBadge::Large(text) => slot.child(
319            large_badge("badge", text.clone())
320                .absolute()
321                .top(rem(-2.))
322                .left(rem(12.)),
323        ),
324    }
325}
326
327#[cfg(test)]
328thread_local! {
329    pub(crate) static PAINTED: std::cell::RefCell<BTreeMap<String, ItemPaint>> =
330        const { std::cell::RefCell::new(BTreeMap::new()) };
331}
332
333pub(crate) type ClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
334
335/// One navigation destination as an M3 item: active-indicator pill, filled
336/// icon when selected, badge and label. Bars and rails compose these; a
337/// gallery may show one alone as a building block.
338#[derive(IntoElement)]
339pub struct NavigationItem {
340    id: ElementId,
341    item: NavItem,
342    layout: NavItemLayout,
343    selected: bool,
344    show_label: bool,
345    focus: Option<FocusHandle>,
346    tab_stop: bool,
347    position: Option<(usize, usize)>,
348    fill: bool,
349    ripple: bool,
350    on_click: Option<ClickHandler>,
351}
352
353impl NavigationItem {
354    pub fn new(id: impl Into<ElementId>, item: NavItem) -> Self {
355        Self {
356            id: id.into(),
357            item,
358            layout: NavItemLayout::Vertical,
359            selected: false,
360            show_label: true,
361            focus: None,
362            tab_stop: true,
363            position: None,
364            fill: false,
365            ripple: true,
366            on_click: None,
367        }
368    }
369    /// Whether presses spread a ripple (on by default); the state layer
370    /// shows either way.
371    pub fn ripple(mut self, ripple: bool) -> Self {
372        self.ripple = ripple;
373        self
374    }
375    pub fn layout(mut self, layout: NavItemLayout) -> Self {
376        self.layout = layout;
377        self
378    }
379    pub fn selected(mut self, selected: bool) -> Self {
380        self.selected = selected;
381        self
382    }
383    /// Hide the label, leaving the icon (its label stays the accessible name).
384    pub fn show_label(mut self, show: bool) -> Self {
385        self.show_label = show;
386        self
387    }
388    pub fn on_click(
389        mut self,
390        handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
391    ) -> Self {
392        self.on_click = Some(Rc::new(handler));
393        self
394    }
395    fn roving(mut self, focus: FocusHandle, tab_stop: bool, position: (usize, usize)) -> Self {
396        self.focus = Some(focus);
397        self.tab_stop = tab_stop;
398        self.position = Some(position);
399        self
400    }
401    fn fill(mut self) -> Self {
402        self.fill = true;
403        self
404    }
405}
406
407impl RenderOnce for NavigationItem {
408    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
409        let geometry = self.layout.geometry();
410        let item = self.item;
411        let preview = item.preview;
412        let disabled = item.is_disabled();
413        let enabled = !disabled;
414        let selected = self.selected;
415        let target = if selected { 1. } else { 0. };
416        let selection =
417            motion::selection_value((self.id.clone(), "nav-indicator"), target, window, cx);
418        let fade = motion::token_value(
419            (self.id.clone(), "nav-fade"),
420            target,
421            MotionToken::FastEffects,
422            window,
423            cx,
424        );
425        let paint = item_paint(selected, selection, fade, disabled);
426        #[cfg(test)]
427        PAINTED.with(|painted| painted.borrow_mut().insert(format!("{:?}", self.id), paint));
428        let focus = self.focus.unwrap_or_else(|| {
429            window
430                .use_keyed_state((self.id.clone(), "nav-focus"), cx, |_, cx| {
431                    cx.focus_handle()
432                })
433                .read(cx)
434                .clone()
435        });
436        let focus = focus.tab_stop(self.tab_stop && enabled && preview.is_none());
437
438        let label = |id: &'static str| {
439            div()
440                .id(id)
441                .flex_shrink_0()
442                .whitespace_nowrap()
443                .text_color(paint.label)
444                .map(|label| type_role(label, geometry.label))
445                .child(item.label.clone())
446        };
447        let scale = paint.indicator_scale;
448        let inset = relative((1. - scale) / 2.);
449        let indicator = (scale > 0.001 && paint.indicator.a > 0.001).then(|| {
450            div()
451                .absolute()
452                .top_0()
453                .bottom_0()
454                .left(inset)
455                .right(inset)
456                .rounded_full()
457                .bg(paint.indicator)
458        });
459        let layer = div()
460            .id("nav-state-layer")
461            .absolute()
462            .inset_0()
463            .rounded_full()
464            .map(|layer| match preview {
465                _ if disabled => layer,
466                None => layer
467                    .group_hover(ITEM_GROUP, move |style| style.bg(paint.hover))
468                    .group_active(ITEM_GROUP, move |style| style.bg(paint.press)),
469                Some(InteractionPreview::Hovered) => layer.bg(paint.hover),
470                Some(InteractionPreview::Pressed) => layer.bg(paint.press),
471                Some(_) => layer,
472            });
473        let trailing = trailing_badge(&item.badge, self.layout, self.show_label);
474        let icon = badged_icon(
475            Icon::new(item.icon.path(selected)).color(paint.icon),
476            if trailing.is_some() {
477                &NavBadge::None
478            } else {
479                &item.badge
480            },
481        );
482        let pill_height = rem(geometry.pill_height);
483        let pill = div()
484            .relative()
485            .flex()
486            .flex_shrink_0()
487            .items_center()
488            .justify_center()
489            .h(pill_height)
490            .min_w(rem(geometry.pill_min_width))
491            .px(rem(geometry.pill_padding))
492            .gap(rem(geometry.gap))
493            .rounded_full()
494            .when(cfg!(test), |pill| {
495                let value = item.value.clone();
496                pill.debug_selector(move || format!("nav-pill-{value}"))
497            })
498            .children(indicator)
499            .child(layer)
500            .child(icon)
501            .when(!geometry.vertical && self.show_label, |pill| {
502                pill.child(label("nav-label"))
503            })
504            .when_some(trailing, |pill, text| {
505                pill.child(large_badge("badge", text))
506            });
507        let radius = pill_height.to_pixels(window.rem_size()) / 2.;
508        let ink = if selected {
509            color(ColorRole::ON_SECONDARY_CONTAINER)
510        } else {
511            color(ColorRole::ON_SURFACE_VARIANT)
512        };
513        let ripple = ButtonRipple::new(
514            pill,
515            (self.id.clone(), "nav-ripple").into(),
516            preview.is_none() && self.ripple,
517            enabled,
518            ripple_corners(radius),
519            Edges::all(px(0.).into()),
520            ink,
521            window,
522            cx,
523        );
524        let ring =
525            ButtonFocusRing::with_corners(ripple, focus.clone(), enabled, Corners::all(radius))
526                .forced(preview == Some(InteractionPreview::Focused));
527
528        let click_focus = focus.clone();
529        let on_click = self.on_click.filter(|_| enabled && preview.is_none());
530        let vertical_top = if self.show_label {
531            geometry.top
532        } else {
533            (geometry.item_height - geometry.pill_height) / 2.
534        };
535        gpui_base::Tab::new(self.id)
536            .group(ITEM_GROUP)
537            .selected(selected)
538            .disabled(disabled)
539            .accessibility_label(item.label.clone())
540            .when_some(self.position, |tab, (index, count)| {
541                tab.set_position(index + 1, count)
542            })
543            .track_focus(&focus)
544            .flex_shrink_0()
545            .h(rem(geometry.item_height))
546            .when(self.fill, |tab| tab.flex_1().min_w_0())
547            .map(|tab| match self.layout {
548                NavItemLayout::Vertical => tab
549                    .flex_col()
550                    .justify_start()
551                    .items_center()
552                    .pt(rem(vertical_top))
553                    .gap(rem(geometry.gap)),
554                NavItemLayout::Horizontal => tab.flex_row().items_center().justify_center(),
555                NavItemLayout::Expanded => tab
556                    .w_full()
557                    .flex_row()
558                    .items_center()
559                    .justify_start()
560                    .px(rem(geometry.inset)),
561            })
562            .when(on_click.is_some(), |tab| tab.cursor_pointer())
563            .child(ring)
564            .when(geometry.vertical && self.show_label, |tab| {
565                tab.child(label("nav-label"))
566            })
567            .when_some(on_click, |tab, handler| {
568                tab.on_click(move |event, window, cx| {
569                    click_focus.focus(window, cx);
570                    handler(event, window, cx)
571                })
572            })
573    }
574}
575
576/// Shared list behavior of bars and rails.
577pub(crate) struct Destinations {
578    pub handles: Rc<[FocusHandle]>,
579    pub selected: Option<usize>,
580    pub entry: Option<usize>,
581    pub roving: Roving,
582}
583
584pub(crate) fn destinations(
585    id: &ElementId,
586    items: &[NavItem],
587    selected: Option<&SharedString>,
588    orientation: Orientation,
589    window: &mut Window,
590    cx: &mut App,
591) -> Destinations {
592    let state = window.use_keyed_state((id.clone(), "nav-focus-handles"), cx, |_, _| {
593        BTreeMap::<SharedString, FocusHandle>::new()
594    });
595    let handles: Rc<[FocusHandle]> = state
596        .update(cx, |handles, cx| {
597            roving::sync_handles(handles, items, |item| &item.value, || cx.focus_handle())
598        })
599        .into();
600    let selected = items.iter().position(|item| Some(&item.value) == selected);
601    let specimen = items.iter().any(|item| item.preview.is_some());
602    let skip: Rc<[bool]> = items.iter().map(NavItem::is_disabled).collect();
603    // Arrows only move focus; Enter or Space activates.
604    let roving = Roving::new(handles.clone(), orientation)
605        .skip(skip)
606        .selected(selected);
607    let entry = if specimen {
608        None
609    } else {
610        roving.tab_stop(window)
611    };
612    Destinations {
613        handles,
614        selected,
615        entry,
616        roving,
617    }
618}
619
620pub(crate) fn item_click(
621    on_change: Option<Change>,
622    value: SharedString,
623    selected: bool,
624) -> ClickHandler {
625    Rc::new(move |_, window, cx| {
626        if !selected && let Some(on_change) = &on_change {
627            on_change(&value, window, cx);
628        }
629    })
630}
631
632/// M3 Expressive flexible navigation bar: 3–5 primary destinations along
633/// the bottom of a compact or medium window, on surface-container.
634#[derive(IntoElement)]
635pub struct NavigationBar {
636    id: ElementId,
637    label: SharedString,
638    items: Vec<NavItem>,
639    selected: Option<SharedString>,
640    layout: NavItemLayout,
641    adaptive: bool,
642    show_labels: bool,
643    ripple: bool,
644    on_change: Option<Change>,
645}
646
647impl NavigationBar {
648    /// `label` is the navigation landmark's accessible name.
649    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
650        Self {
651            id: id.into(),
652            label: label.into(),
653            items: Vec::new(),
654            selected: None,
655            layout: NavItemLayout::Vertical,
656            adaptive: false,
657            show_labels: true,
658            ripple: true,
659            on_change: None,
660        }
661    }
662    /// The destinations. Values must be unique.
663    pub fn items(mut self, items: impl IntoIterator<Item = NavItem>) -> Self {
664        self.items = items.into_iter().collect();
665        self
666    }
667    pub fn selected(mut self, value: impl Into<SharedString>) -> Self {
668        self.selected = Some(value.into());
669        self
670    }
671    /// Choose the item layout from the bar's own width, M3's window size
672    /// classes: vertical items while compact (under 600dp), horizontal
673    /// items from medium widths up. Overrides [`Self::layout`].
674    pub fn adaptive(mut self, adaptive: bool) -> Self {
675        self.adaptive = adaptive;
676        self
677    }
678    /// Whether presses spread a ripple (on by default).
679    pub fn ripple(mut self, ripple: bool) -> Self {
680        self.ripple = ripple;
681        self
682    }
683    /// Horizontal items (icon beside label) for medium windows; vertical
684    /// (the default) for compact ones. `Expanded` is a rail layout and
685    /// renders as horizontal here.
686    pub fn layout(mut self, layout: NavItemLayout) -> Self {
687        self.layout = match layout {
688            NavItemLayout::Expanded => NavItemLayout::Horizontal,
689            other => other,
690        };
691        self
692    }
693    pub fn show_labels(mut self, show: bool) -> Self {
694        self.show_labels = show;
695        self
696    }
697    pub fn on_change(
698        mut self,
699        handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
700    ) -> Self {
701        self.on_change = Some(Rc::new(handler));
702        self
703    }
704}
705
706/// The widest share a horizontal bar item takes, so a few items gather in
707/// the middle of a wide bar instead of spreading to its edges.
708const HORIZONTAL_ITEM_MAX: f32 = 128.;
709/// The M3 medium window class starts at 600dp.
710pub const MEDIUM_WIDTH: f32 = 600.;
711
712/// The layout an adaptive bar uses at `width` design px.
713pub(crate) fn adaptive_layout(width: f32) -> NavItemLayout {
714    if width >= MEDIUM_WIDTH {
715        NavItemLayout::Horizontal
716    } else {
717        NavItemLayout::Vertical
718    }
719}
720
721impl RenderOnce for NavigationBar {
722    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
723        let list = destinations(
724            &self.id,
725            &self.items,
726            self.selected.as_ref(),
727            Orientation::Row,
728            window,
729            cx,
730        );
731        let count = self.items.len();
732        // An adaptive bar measures its width after layout and settles on the
733        // matching layout on the next frame (only when the class changes).
734        let measured = self.adaptive.then(|| {
735            window.use_keyed_state((self.id.clone(), "nav-bar-width"), cx, |_, _| None::<f32>)
736        });
737        let layout = match measured.as_ref().and_then(|width| *width.read(cx)) {
738            Some(width) => adaptive_layout(width),
739            None => self.layout,
740        };
741        let geometry = layout.geometry();
742        let horizontal = layout == NavItemLayout::Horizontal;
743        let ripple = self.ripple;
744        let lane = gpui_base::Tabs::new((self.id.clone(), "nav-items"))
745            .aria_label(self.label.clone())
746            .flex()
747            .flex_row()
748            .w_full()
749            .h(rem(geometry.item_height))
750            .when(horizontal, |lane| {
751                lane.max_w(rem(HORIZONTAL_ITEM_MAX * count as f32))
752            })
753            .children(self.items.into_iter().enumerate().map(|(index, item)| {
754                let selected = list.selected == Some(index);
755                let click = item_click(self.on_change.clone(), item.value.clone(), selected);
756                NavigationItem::new(
757                    ElementId::NamedChild(self.id.clone().into(), item.value.clone()),
758                    item,
759                )
760                .layout(layout)
761                .selected(selected)
762                .show_label(self.show_labels)
763                .ripple(ripple)
764                .fill()
765                .roving(
766                    list.handles[index].clone(),
767                    list.entry == Some(index),
768                    (index, count),
769                )
770                .on_click(move |event, window, cx| click(event, window, cx))
771            }));
772        let lane = list.roving.install(lane);
773        div()
774            .id(self.id)
775            .role(Role::Navigation)
776            .aria_label(self.label)
777            .w_full()
778            .flex()
779            .justify_center()
780            .bg(color(ColorRole::SURFACE_CONTAINER))
781            .when_some(measured, |bar, width| {
782                bar.relative().child(
783                    canvas(
784                        move |bounds, window, cx| {
785                            let design = f32::from(bounds.size.width)
786                                / f32::from(window.rem_size())
787                                * crate::tokens::typography::BASE_PX;
788                            let changed = width.update(cx, |width, _| {
789                                let class = width.map(adaptive_layout);
790                                *width = Some(design);
791                                class != Some(adaptive_layout(design))
792                            });
793                            if changed {
794                                window.defer(cx, |window, _| window.refresh());
795                            }
796                        },
797                        |_, _, _, _| {},
798                    )
799                    .absolute()
800                    .inset_0(),
801                )
802            })
803            .child(lane)
804    }
805}
806
807/// Where a rail's destinations sit in its height.
808#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
809pub enum RailAlignment {
810    #[default]
811    Top,
812    Middle,
813}
814
815/// The collapsed rail's width.
816pub const RAIL_COLLAPSED_WIDTH: f32 = 96.;
817/// The expanded rail's default width (M3: 220–360dp).
818pub const RAIL_EXPANDED_WIDTH: f32 = 220.;
819
820/// M3 Expressive navigation rail: a 96dp column of vertical items that
821/// expands to a 220–360dp column of horizontal items, with an optional menu
822/// button and FAB above the destinations and, when expanded, section
823/// headers. The width moves on the default spatial spring.
824///
825/// A modal expanded rail floats on surface-container at level 2 with large
826/// trailing corners over a scrim ([`navigation_scrim`]); Escape dismisses it.
827#[derive(IntoElement)]
828pub struct NavigationRail {
829    id: ElementId,
830    label: SharedString,
831    items: Vec<NavItem>,
832    sections: Vec<(usize, SharedString)>,
833    selected: Option<SharedString>,
834    expanded: bool,
835    modal: bool,
836    alignment: RailAlignment,
837    expanded_width: f32,
838    menu: Option<AnyElement>,
839    fab: Option<AnyElement>,
840    hide_collapsed: bool,
841    ripple: bool,
842    on_change: Option<Change>,
843    on_dismiss: Option<ClickHandler>,
844}
845
846impl NavigationRail {
847    /// `label` is the navigation landmark's accessible name.
848    pub fn new(id: impl Into<ElementId>, label: impl Into<SharedString>) -> Self {
849        Self {
850            id: id.into(),
851            label: label.into(),
852            items: Vec::new(),
853            sections: Vec::new(),
854            selected: None,
855            expanded: false,
856            modal: false,
857            alignment: RailAlignment::Top,
858            expanded_width: RAIL_EXPANDED_WIDTH,
859            menu: None,
860            fab: None,
861            hide_collapsed: false,
862            ripple: true,
863            on_change: None,
864            on_dismiss: None,
865        }
866    }
867    /// The destinations. Values must be unique.
868    pub fn items(mut self, items: impl IntoIterator<Item = NavItem>) -> Self {
869        self.items = items.into_iter().collect();
870        self
871    }
872    /// A section header shown above item `before` while expanded.
873    pub fn section(mut self, before: usize, header: impl Into<SharedString>) -> Self {
874        self.sections.push((before, header.into()));
875        self
876    }
877    pub fn selected(mut self, value: impl Into<SharedString>) -> Self {
878        self.selected = Some(value.into());
879        self
880    }
881    pub fn expanded(mut self, expanded: bool) -> Self {
882        self.expanded = expanded;
883        self
884    }
885    /// Paint the floating modal container (meaningful while expanded).
886    pub fn modal(mut self, modal: bool) -> Self {
887        self.modal = modal;
888        self
889    }
890    pub fn alignment(mut self, alignment: RailAlignment) -> Self {
891        self.alignment = alignment;
892        self
893    }
894    /// Hide the rail while collapsed: it springs from zero width to the
895    /// expanded width and leaves the tab order once fully collapsed.
896    pub fn hide_collapsed(mut self, hide: bool) -> Self {
897        self.hide_collapsed = hide;
898        self
899    }
900    /// Whether presses spread a ripple (on by default).
901    pub fn ripple(mut self, ripple: bool) -> Self {
902        self.ripple = ripple;
903        self
904    }
905    /// The expanded width in design px, clamped to M3's 220–360dp.
906    pub fn expanded_width(mut self, width: f32) -> Self {
907        self.expanded_width = width.clamp(220., 360.);
908        self
909    }
910    /// The menu button at the top (the caller's icon button that toggles
911    /// `expanded`).
912    pub fn menu(mut self, menu: impl IntoElement) -> Self {
913        self.menu = Some(menu.into_any_element());
914        self
915    }
916    /// The FAB under the menu: a FAB when collapsed, an extended FAB when
917    /// expanded (the caller supplies whichever matches `expanded`).
918    pub fn fab(mut self, fab: impl IntoElement) -> Self {
919        self.fab = Some(fab.into_any_element());
920        self
921    }
922    pub fn on_change(
923        mut self,
924        handler: impl Fn(&SharedString, &mut Window, &mut App) + 'static,
925    ) -> Self {
926        self.on_change = Some(Rc::new(handler));
927        self
928    }
929    /// Escape inside a modal rail calls this (the scrim's click is the
930    /// caller's, see [`navigation_scrim`]).
931    pub fn on_dismiss(mut self, handler: impl Fn(&mut Window, &mut App) + 'static) -> Self {
932        self.on_dismiss = Some(Rc::new(move |_, window, cx| handler(window, cx)));
933        self
934    }
935}
936
937/// Top padding above a rail's menu button.
938const RAIL_TOP: f32 = 44.;
939/// Space between the menu/FAB block and the destinations.
940const RAIL_HEADER_GAP: f32 = 40.;
941const RAIL_ITEM_GAP: f32 = 4.;
942const SECTION_HEIGHT: f32 = 36.;
943
944impl RenderOnce for NavigationRail {
945    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
946        let expanded = self.expanded;
947        let width = motion::token_value(
948            (self.id.clone(), "rail-width"),
949            if expanded {
950                self.expanded_width
951            } else if self.hide_collapsed {
952                0.
953            } else {
954                RAIL_COLLAPSED_WIDTH
955            },
956            MotionToken::DefaultSpatial,
957            window,
958            cx,
959        )
960        .max(0.);
961        // A hidden rail keeps nothing focusable once it has closed.
962        let hidden = self.hide_collapsed && !expanded && width < 1.;
963        let ripple = self.ripple;
964        let list = destinations(
965            &self.id,
966            &self.items,
967            self.selected.as_ref(),
968            Orientation::Column,
969            window,
970            cx,
971        );
972        let layout = if expanded {
973            NavItemLayout::Expanded
974        } else {
975            NavItemLayout::Vertical
976        };
977        let count = self.items.len();
978        let modal = self.modal && expanded;
979        let mut sections = self.sections;
980        sections.sort_by_key(|(before, _)| *before);
981        let mut sections = sections.into_iter().peekable();
982        let mut lane = gpui_base::Tabs::new((self.id.clone(), "rail-items"))
983            .aria_label(self.label.clone())
984            .aria_orientation(accesskit::Orientation::Vertical)
985            .w_full()
986            .flex()
987            .flex_col()
988            .when(!expanded, |lane| {
989                lane.items_center().gap(rem(RAIL_ITEM_GAP))
990            });
991        let items = if hidden { Vec::new() } else { self.items };
992        for (index, item) in items.into_iter().enumerate() {
993            while let Some((_, header)) = sections.next_if(|(before, _)| *before <= index) {
994                if expanded {
995                    lane = lane.child(section_header(index, header));
996                }
997            }
998            let selected = list.selected == Some(index);
999            let click = item_click(self.on_change.clone(), item.value.clone(), selected);
1000            lane = lane.child(
1001                NavigationItem::new(
1002                    ElementId::NamedChild(self.id.clone().into(), item.value.clone()),
1003                    item,
1004                )
1005                .layout(layout)
1006                .selected(selected)
1007                .ripple(ripple)
1008                .roving(
1009                    list.handles[index].clone(),
1010                    list.entry == Some(index),
1011                    (index, count),
1012                )
1013                .on_click(move |event, window, cx| click(event, window, cx)),
1014            );
1015        }
1016        if expanded {
1017            for (index, header) in sections {
1018                lane = lane.child(section_header(index, header));
1019            }
1020        }
1021        let lane = list.roving.install(lane);
1022        // The entering layout fades in while the width moves.
1023        let lane = motion::enter(
1024            (
1025                self.id.clone(),
1026                if expanded {
1027                    "rail-expanded"
1028                } else {
1029                    "rail-collapsed"
1030                },
1031            ),
1032            lane,
1033        );
1034        let (menu, fab) = if hidden {
1035            (None, None)
1036        } else {
1037            (self.menu, self.fab)
1038        };
1039        let has_header = menu.is_some() || fab.is_some();
1040        let header = div()
1041            .flex()
1042            .flex_col()
1043            .flex_shrink_0()
1044            .gap(rem(RAIL_ITEM_GAP))
1045            .map(|header| {
1046                if expanded {
1047                    header.items_start().pl(rem(20.))
1048                } else {
1049                    header.w_full().items_center()
1050                }
1051            })
1052            .children(menu)
1053            .children(fab);
1054        let on_dismiss = self.on_dismiss.filter(|_| modal);
1055        div()
1056            .id(self.id)
1057            .role(Role::Navigation)
1058            .aria_label(self.label)
1059            .relative()
1060            .h_full()
1061            .w(rem(width))
1062            .flex_shrink_0()
1063            .flex()
1064            .flex_col()
1065            .overflow_hidden()
1066            .pt(rem(RAIL_TOP))
1067            .pb(rem(20.))
1068            .map(|rail| {
1069                if modal {
1070                    rail.bg(color(ColorRole::SURFACE_CONTAINER))
1071                        .rounded_r(RADIUS_LARGE)
1072                        .shadow(elevation::level(2))
1073                } else {
1074                    rail.bg(color(ColorRole::SURFACE))
1075                }
1076            })
1077            .when(has_header, |rail| {
1078                rail.child(header)
1079                    .child(div().h(rem(RAIL_HEADER_GAP)).flex_shrink_0())
1080            })
1081            .child(
1082                div()
1083                    .flex_1()
1084                    .min_h_0()
1085                    .w_full()
1086                    .flex()
1087                    .flex_col()
1088                    .when(self.alignment == RailAlignment::Middle, |column| {
1089                        column.justify_center()
1090                    })
1091                    .child(lane),
1092            )
1093            .when_some(on_dismiss, |rail, dismiss| {
1094                rail.on_key_down(move |event: &KeyDownEvent, window, cx| {
1095                    if event.keystroke.key == "escape" {
1096                        cx.stop_propagation();
1097                        dismiss(&ClickEvent::default(), window, cx);
1098                    }
1099                })
1100            })
1101    }
1102}
1103
1104fn section_header(index: usize, header: SharedString) -> impl IntoElement {
1105    div()
1106        .id(("rail-section", index))
1107        .role(Role::Heading)
1108        .aria_label(header.clone())
1109        .flex_shrink_0()
1110        .h(rem(SECTION_HEIGHT))
1111        .flex()
1112        .items_center()
1113        .pl(rem(36.))
1114        .pr(rem(20.))
1115        .whitespace_nowrap()
1116        .text_color(color(ColorRole::ON_SURFACE_VARIANT))
1117        .map(|text| type_role(text, TypeRole::TitleSmall))
1118        .child(header)
1119}
1120
1121/// The scrim behind a modal expanded rail: scrim at 32%, fading in. Its
1122/// click dismisses the rail.
1123pub fn navigation_scrim(
1124    id: impl Into<ElementId>,
1125    on_dismiss: impl Fn(&mut Window, &mut App) + 'static,
1126) -> impl IntoElement {
1127    motion::enter(
1128        id,
1129        div()
1130            .id("navigation-scrim")
1131            .absolute()
1132            .inset_0()
1133            .bg(color(ColorRole::SCRIM).opacity(0.32))
1134            .on_click(move |_, window, cx| on_dismiss(window, cx)),
1135    )
1136}
1137
1138#[cfg(test)]
1139#[path = "navigation_tests.rs"]
1140mod tests;