1use 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
41pub(crate) const ITEM_GROUP: &str = "material-navigation-item";
45
46#[derive(Clone, Debug, Default, PartialEq, Eq)]
48pub enum NavBadge {
49 #[default]
50 None,
51 Small,
53 Large(SharedString),
55}
56
57#[derive(Clone, Debug, PartialEq, Eq)]
60pub struct NavIcon {
61 unselected: SharedString,
62 selected: SharedString,
63}
64
65impl NavIcon {
66 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 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#[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 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
158pub enum NavItemLayout {
159 #[default]
162 Vertical,
163 Horizontal,
165 Expanded,
167}
168
169#[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 pub gap: f32,
179 pub top: f32,
181 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#[derive(Clone, Copy, Debug, PartialEq)]
228pub(crate) struct ItemPaint {
229 pub icon: Rgba,
230 pub label: Rgba,
231 pub indicator: Rgba,
232 pub indicator_scale: f32,
234 pub hover: Rgba,
235 pub press: Rgba,
236}
237
238pub(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
278pub(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
289pub(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
308pub(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#[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 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 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
576pub(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 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#[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 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 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 pub fn adaptive(mut self, adaptive: bool) -> Self {
675 self.adaptive = adaptive;
676 self
677 }
678 pub fn ripple(mut self, ripple: bool) -> Self {
680 self.ripple = ripple;
681 self
682 }
683 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
706const HORIZONTAL_ITEM_MAX: f32 = 128.;
709pub const MEDIUM_WIDTH: f32 = 600.;
711
712pub(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 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#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
809pub enum RailAlignment {
810 #[default]
811 Top,
812 Middle,
813}
814
815pub const RAIL_COLLAPSED_WIDTH: f32 = 96.;
817pub const RAIL_EXPANDED_WIDTH: f32 = 220.;
819
820#[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 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 pub fn items(mut self, items: impl IntoIterator<Item = NavItem>) -> Self {
869 self.items = items.into_iter().collect();
870 self
871 }
872 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 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 pub fn hide_collapsed(mut self, hide: bool) -> Self {
897 self.hide_collapsed = hide;
898 self
899 }
900 pub fn ripple(mut self, ripple: bool) -> Self {
902 self.ripple = ripple;
903 self
904 }
905 pub fn expanded_width(mut self, width: f32) -> Self {
907 self.expanded_width = width.clamp(220., 360.);
908 self
909 }
910 pub fn menu(mut self, menu: impl IntoElement) -> Self {
913 self.menu = Some(menu.into_any_element());
914 self
915 }
916 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 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
937const RAIL_TOP: f32 = 44.;
939const 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 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 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
1121pub 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;