1use std::{ops::Range, rc::Rc, time::Duration};
32
33use gpui_kit::{
34 AnyElement, App, AppContext as _, Bounds, ClickEvent, Context, Element, ElementId, Entity,
35 EventEmitter, FocusHandle, GlobalElementId, InspectorElementId, InteractiveElement as _,
36 IntoElement, LayoutId, MouseButton, ParentElement, Pixels, Render, RenderOnce, ScrollHandle,
37 SharedString, StatefulInteractiveElement as _, StyleRefinement, Styled, WeakFocusHandle,
38 Window,
39 base::{
40 Collapsible, Disableable, Placement, Selectable, Sheet, StyledExt as _,
41 TestSupportExt as _, Transition, h_flex, transition, v_flex,
42 },
43 canvas, div, point,
44 prelude::FluentBuilder as _,
45 px,
46};
47
48use crate::{ActiveTheme as _, Button, ButtonSize, Icon, ScrollArea, Theme};
49use gpui_kit::assets::IconName;
50
51#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
53pub enum SidebarSide {
54 #[default]
56 Left,
57 Right,
59}
60
61#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
63pub enum SidebarCollapsible {
64 Offcanvas,
66 #[default]
69 Icon,
70 None,
72}
73
74#[derive(Clone, Copy, Debug, PartialEq)]
76pub enum SidebarEvent {
77 Toggled(bool),
79 Resized(Pixels),
81}
82
83pub struct SidebarState {
89 open: bool,
90 width: Pixels,
91 width_range: Range<Pixels>,
92 icon_width: Pixels,
93 collapsible: SidebarCollapsible,
94 resizing: bool,
95 sheet: bool,
99 sheet_open: bool,
100}
101
102impl EventEmitter<SidebarEvent> for SidebarState {}
103
104impl SidebarState {
105 pub fn new(cx: &App) -> Self {
108 let metrics = &cx.theme().metrics;
109 Self {
110 open: true,
111 width: metrics.sidebar_width,
112 width_range: metrics.sidebar_width_range.clone(),
113 icon_width: metrics.icon_sidebar_width,
114 collapsible: SidebarCollapsible::Icon,
115 resizing: false,
116 sheet: false,
117 sheet_open: false,
118 }
119 }
120
121 pub fn closed(mut self) -> Self {
123 self.open = false;
124 self
125 }
126
127 pub fn with_width(mut self, width: Pixels) -> Self {
129 self.width = clamp(width, &self.width_range);
130 self
131 }
132
133 pub fn with_width_range(mut self, range: Range<Pixels>) -> Self {
135 self.width_range = range;
136 self.width = clamp(self.width, &self.width_range);
137 self
138 }
139
140 pub fn with_collapsible(mut self, collapsible: SidebarCollapsible) -> Self {
142 self.collapsible = collapsible;
143 self
144 }
145
146 pub fn with_icon_width(mut self, width: Pixels) -> Self {
150 self.icon_width = width;
151 self
152 }
153
154 pub fn icon_width(&self) -> Pixels {
156 self.icon_width
157 }
158
159 pub fn is_open(&self) -> bool {
163 if self.sheet {
164 self.sheet_open
165 } else {
166 self.open || self.collapsible == SidebarCollapsible::None
167 }
168 }
169
170 pub fn is_icon_only(&self) -> bool {
173 !self.sheet && !self.is_open() && self.collapsible == SidebarCollapsible::Icon
174 }
175
176 pub fn is_sheet(&self) -> bool {
180 self.sheet
181 }
182
183 pub fn width(&self) -> Pixels {
185 self.width
186 }
187
188 pub fn width_range(&self) -> &Range<Pixels> {
190 &self.width_range
191 }
192
193 pub fn collapsible(&self) -> SidebarCollapsible {
195 self.collapsible
196 }
197
198 pub fn is_resizing(&self) -> bool {
200 self.resizing
201 }
202
203 fn target_width(&self, sheet_width: Pixels) -> Pixels {
205 if self.sheet {
206 if self.sheet_open { sheet_width } else { px(0.) }
207 } else if self.is_open() {
208 self.width
209 } else if self.collapsible == SidebarCollapsible::Icon {
210 self.icon_width
211 } else {
212 px(0.)
213 }
214 }
215
216 pub fn set_open(&mut self, open: bool, cx: &mut Context<Self>) {
219 let flag = if self.sheet {
220 &mut self.sheet_open
221 } else {
222 &mut self.open
223 };
224 if *flag == open {
225 return;
226 }
227 *flag = open;
228 cx.emit(SidebarEvent::Toggled(open));
229 cx.notify();
230 }
231
232 pub fn toggle(&mut self, cx: &mut Context<Self>) {
234 let open = if self.sheet {
235 self.sheet_open
236 } else {
237 self.open
238 };
239 self.set_open(!open, cx);
240 }
241
242 fn set_sheet(&mut self, sheet: bool, cx: &mut Context<Self>) {
244 if self.sheet != sheet {
245 self.sheet = sheet;
246 cx.notify();
247 }
248 }
249
250 pub fn set_width(&mut self, width: Pixels, cx: &mut Context<Self>) {
252 let width = clamp(width, &self.width_range);
253 if self.width == width {
254 return;
255 }
256 self.width = width;
257 cx.emit(SidebarEvent::Resized(width));
258 cx.notify();
259 }
260
261 pub fn set_icon_width(&mut self, width: Pixels, cx: &mut Context<Self>) {
263 if self.icon_width != width {
264 self.icon_width = width;
265 cx.notify();
266 }
267 }
268
269 pub fn set_collapsible(&mut self, collapsible: SidebarCollapsible, cx: &mut Context<Self>) {
271 if self.collapsible != collapsible {
272 self.collapsible = collapsible;
273 cx.notify();
274 }
275 }
276
277 fn set_resizing(&mut self, resizing: bool, cx: &mut Context<Self>) {
278 if self.resizing != resizing {
279 self.resizing = resizing;
280 cx.notify();
281 }
282 }
283}
284
285fn clamp(width: Pixels, range: &Range<Pixels>) -> Pixels {
286 width.max(range.start).min(range.end)
287}
288
289struct SidebarResize(gpui_kit::EntityId);
293
294struct NoDragPreview;
296
297impl Render for NoDragPreview {
298 fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
299 div()
300 }
301}
302
303#[derive(IntoElement)]
313pub struct SidebarLayout {
314 state: Entity<SidebarState>,
315 side: SidebarSide,
316 sidebar: Option<Sidebar>,
317 children: Vec<AnyElement>,
318 style: StyleRefinement,
319}
320
321impl SidebarLayout {
322 pub fn new(state: &Entity<SidebarState>) -> Self {
324 Self {
325 state: state.clone(),
326 side: SidebarSide::Left,
327 sidebar: None,
328 children: Vec::new(),
329 style: StyleRefinement::default(),
330 }
331 }
332
333 pub fn sidebar(mut self, sidebar: Sidebar) -> Self {
335 self.sidebar = Some(sidebar);
336 self
337 }
338
339 pub fn side(mut self, side: SidebarSide) -> Self {
341 self.side = side;
342 self
343 }
344}
345
346impl Styled for SidebarLayout {
347 fn style(&mut self) -> &mut StyleRefinement {
348 &mut self.style
349 }
350}
351
352impl ParentElement for SidebarLayout {
353 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
354 self.children.extend(elements);
355 }
356}
357
358impl RenderOnce for SidebarLayout {
359 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
360 let (handle_width, breakpoint, sheet_width) = {
361 let metrics = &cx.theme().metrics;
362 (
363 metrics.resize_handle,
364 metrics.sidebar_sheet_breakpoint,
365 metrics.sidebar_sheet_width,
366 )
367 };
368 let sheet = window.viewport_size().width < breakpoint;
371 self.state
372 .update(cx, |state, cx| state.set_sheet(sheet, cx));
373 let (open, icon_only, width, icon_width, target, resizing) = {
374 let state = self.state.read(cx);
375 (
376 state.is_open(),
377 state.is_icon_only(),
378 state.width,
379 state.icon_width,
380 state.target_width(sheet_width),
381 state.resizing,
382 )
383 };
384 let id = self.state.entity_id().as_u64();
385 let side = self.side;
386 let policy = if resizing {
391 Transition::new(Duration::ZERO)
392 } else {
393 Theme::global(cx).motion.fold_transition()
394 };
395 let shown = px(transition(
396 ElementId::NamedInteger("sidebar-width".into(), id),
397 f32::from(target),
398 policy,
399 window,
400 cx,
401 ));
402 let visible = shown > px(0.);
403 let icon_mode = icon_only && shown <= icon_width;
410 let inner_width = if sheet {
411 sheet_width
412 } else if icon_mode {
413 icon_width
414 } else {
415 width
416 };
417
418 let state = self.state.clone();
419 let state_for_down = self.state.clone();
420 let state_for_up = self.state.clone();
421 let state_for_up_out = self.state.clone();
422 let panel = div()
423 .id("sidebar-panel")
424 .test_support()
425 .flex_shrink_0()
426 .h_full()
427 .overflow_hidden()
428 .w(shown)
429 .when(side == SidebarSide::Right, |this| this.flex().justify_end())
430 .when(visible, |this| {
431 this.child(
432 div().h_full().w(inner_width).flex_shrink_0().children(
433 self.sidebar
434 .map(|sidebar| sidebar.side(side).icon_mode(icon_mode)),
435 ),
436 )
437 });
438 let handle = (open && !sheet).then(|| {
439 div()
440 .id("sidebar-resize-handle")
441 .test_support()
442 .absolute()
443 .top_0()
444 .bottom_0()
445 .map(|this| match side {
446 SidebarSide::Left => this.left(shown - handle_width / 2.),
447 SidebarSide::Right => this.right(shown - handle_width / 2.),
448 })
449 .w(handle_width)
450 .cursor_col_resize()
451 .on_mouse_down(MouseButton::Left, move |_, _, cx| {
452 state_for_down.update(cx, |state, cx| state.set_resizing(true, cx));
453 })
454 .on_drag(SidebarResize(self.state.entity_id()), |_, _, _, cx| {
455 cx.new(|_| NoDragPreview)
456 })
457 });
458 let inset = div()
459 .id("sidebar-inset")
460 .test_support()
461 .flex()
462 .flex_col()
463 .flex_1()
464 .min_w_0()
465 .h_full()
466 .overflow_hidden()
467 .children(self.children);
468
469 let bounds_state = window.use_keyed_state(
470 ElementId::NamedInteger("sidebar-layout-bounds".into(), id),
471 cx,
472 |_, _| None::<Bounds<Pixels>>,
473 );
474 let layout_bounds = *bounds_state.read(cx);
475 let probe = {
476 let keyed = bounds_state;
477 canvas(
478 move |bounds, window, cx| {
479 if *keyed.read(cx) != Some(bounds) {
480 keyed.update(cx, |held, _| *held = Some(bounds));
481 window.refresh();
482 }
483 },
484 |_, _, _, _| {},
485 )
486 .absolute()
487 .top_0()
488 .left_0()
489 .size_full()
490 };
491 div()
492 .id(ElementId::NamedInteger("sidebar-layout".into(), id))
493 .flex()
494 .items_stretch()
495 .size_full()
496 .relative()
497 .overflow_hidden()
498 .refine_style(&self.style)
499 .child(probe)
500 .on_drag_move::<SidebarResize>(move |event, _, cx| {
501 if event.drag(cx).0 != state.entity_id() {
502 return;
503 }
504 let width = match side {
505 SidebarSide::Left => event.event.position.x - event.bounds.origin.x,
506 SidebarSide::Right => event.bounds.right() - event.event.position.x,
507 };
508 state.update(cx, |state, cx| state.set_width(width, cx));
509 })
510 .on_mouse_up(MouseButton::Left, move |_, _, cx| {
511 state_for_up.update(cx, |state, cx| state.set_resizing(false, cx));
512 })
513 .on_mouse_up_out(MouseButton::Left, move |_, _, cx| {
514 state_for_up_out.update(cx, |state, cx| state.set_resizing(false, cx));
515 })
516 .map(|this| {
517 if sheet {
518 let progress = f32::from(shown) / f32::from(sheet_width);
519 let scrim = cx.theme().scrim.opacity(progress);
520 let close_state = self.state.clone();
521 let focus = sheet_focus(&self.state, open, window, cx);
522 let viewport = window.viewport_size();
523 let area = layout_bounds.unwrap_or(Bounds {
524 origin: point(px(0.), px(0.)),
525 size: viewport,
526 });
527 this.child(inset).when(visible, |this| {
528 this.child(
529 Sheet::new(cx)
530 .focus_handle(focus)
531 .dismiss_before_y(area.origin.y)
532 .request_close(move |_, cx| {
533 close_state.update(cx, |state, cx| state.set_open(false, cx));
534 })
535 .overlay(
536 div()
537 .id("sidebar-scrim")
538 .test_support()
539 .absolute()
540 .left(area.origin.x)
541 .top(area.origin.y)
542 .w(area.size.width)
543 .h(area.size.height)
544 .bg(scrim),
545 )
546 .surface(
547 div()
548 .absolute()
549 .top(area.origin.y)
550 .h(area.size.height)
551 .occlude()
552 .map(|this| match side {
553 SidebarSide::Left => this.left(area.origin.x),
554 SidebarSide::Right => {
555 this.right(viewport.width - area.right())
556 }
557 })
558 .child(panel),
559 ),
560 )
561 })
562 } else {
563 match side {
564 SidebarSide::Left => this.child(panel).children(handle).child(inset),
565 SidebarSide::Right => this.child(inset).children(handle).child(panel),
566 }
567 }
568 })
569 }
570}
571
572struct SheetFocus {
575 handle: FocusHandle,
576 previous: Option<WeakFocusHandle>,
577 open: bool,
578}
579
580fn sheet_focus(
583 state: &Entity<SidebarState>,
584 open: bool,
585 window: &mut Window,
586 cx: &mut App,
587) -> FocusHandle {
588 let keyed = window.use_keyed_state(
589 ElementId::NamedInteger("sidebar-sheet-focus".into(), state.entity_id().as_u64()),
590 cx,
591 |_, cx| SheetFocus {
592 handle: cx.focus_handle(),
593 previous: None,
594 open: false,
595 },
596 );
597 let handle = keyed.read(cx).handle.clone();
598 let (was_open, previous) = {
599 let focus = keyed.read(cx);
600 (focus.open, focus.previous.clone())
601 };
602 if open && !was_open {
603 let before = window.focused(cx).map(|focused| focused.downgrade());
604 keyed.update(cx, |focus, _| {
605 focus.previous = before;
606 focus.open = true;
607 });
608 let target = handle.clone();
609 window.defer(cx, move |window, cx| target.focus(window, cx));
610 } else if !open && was_open {
611 keyed.update(cx, |focus, _| {
612 focus.previous = None;
613 focus.open = false;
614 });
615 if let Some(before) = previous.and_then(|weak| weak.upgrade()) {
616 window.defer(cx, move |window, cx| before.focus(window, cx));
617 }
618 }
619 handle
620}
621
622#[derive(IntoElement)]
631pub struct Sidebar {
632 header: Vec<AnyElement>,
633 children: Vec<AnyElement>,
634 footer: Vec<AnyElement>,
635 style: StyleRefinement,
636 side: SidebarSide,
637 icon_mode: bool,
638}
639
640impl Default for Sidebar {
641 fn default() -> Self {
642 Self::new()
643 }
644}
645
646impl Sidebar {
647 pub fn new() -> Self {
649 Self {
650 header: Vec::new(),
651 children: Vec::new(),
652 footer: Vec::new(),
653 style: StyleRefinement::default(),
654 side: SidebarSide::Left,
655 icon_mode: false,
656 }
657 }
658
659 pub fn header(mut self, header: impl IntoElement) -> Self {
662 self.header.push(header.into_any_element());
663 self
664 }
665
666 pub fn footer(mut self, footer: impl IntoElement) -> Self {
668 self.footer.push(footer.into_any_element());
669 self
670 }
671
672 fn side(mut self, side: SidebarSide) -> Self {
673 self.side = side;
674 self
675 }
676
677 fn icon_mode(mut self, icon_mode: bool) -> Self {
678 self.icon_mode = icon_mode;
679 self
680 }
681}
682
683impl Styled for Sidebar {
684 fn style(&mut self) -> &mut StyleRefinement {
685 &mut self.style
686 }
687}
688
689impl ParentElement for Sidebar {
690 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
691 self.children.extend(elements);
692 }
693}
694
695impl RenderOnce for Sidebar {
696 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
697 let scroll = window
698 .use_keyed_state("sidebar-scroll", cx, |_, _| ScrollHandle::new())
699 .read(cx)
700 .clone();
701 let scrolled = scroll.offset().y < px(-0.5);
702 let edge = transition(
703 "sidebar-header-edge",
704 if scrolled { 1. } else { 0. },
705 Theme::global(cx).motion.fast_transition(),
706 window,
707 cx,
708 );
709 let theme = cx.theme();
710 let has_footer = !self.footer.is_empty();
711 SidebarScoped::new(
712 self.icon_mode,
713 v_flex()
714 .size_full()
715 .bg(theme.sidebar)
716 .text_color(theme.foreground())
717 .map(|this| match self.side {
718 SidebarSide::Left => this.border_r_1(),
719 SidebarSide::Right => this.border_l_1(),
720 })
721 .border_color(theme.sidebar_border)
722 .refine_style(&self.style)
723 .child(
724 v_flex()
725 .id("sidebar-header")
726 .relative()
727 .flex_shrink_0()
728 .children(self.header)
729 .when(edge > 0.001, |this| {
730 this.child(
731 div()
732 .id("sidebar-header-edge")
733 .test_support()
734 .absolute()
735 .bottom_0()
736 .left_0()
737 .right_0()
738 .h_px()
739 .bg(theme.sidebar_border)
740 .opacity(edge),
741 )
742 }),
743 )
744 .child(
745 ScrollArea::new("sidebar-content")
746 .track(&scroll)
747 .flex()
748 .flex_col()
749 .flex_1()
750 .min_h_0()
751 .px_2()
752 .pb_2()
753 .children(self.children),
754 )
755 .when(has_footer, |this| {
756 this.child(v_flex().flex_shrink_0().px_2().pb_2().children(self.footer))
757 }),
758 )
759 }
760}
761
762type ToggleHandler = Rc<dyn Fn(&bool, &mut Window, &mut App)>;
763
764#[derive(IntoElement)]
772pub struct SidebarGroup {
773 id: Option<ElementId>,
774 label: Option<SharedString>,
775 action: Option<AnyElement>,
776 open: Option<bool>,
777 on_toggle: Option<ToggleHandler>,
778 children: Vec<AnyElement>,
779 style: StyleRefinement,
780}
781
782impl Default for SidebarGroup {
783 fn default() -> Self {
784 Self::new()
785 }
786}
787
788impl SidebarGroup {
789 pub fn new() -> Self {
791 Self {
792 id: None,
793 label: None,
794 action: None,
795 open: None,
796 on_toggle: None,
797 children: Vec::new(),
798 style: StyleRefinement::default(),
799 }
800 }
801
802 pub fn id(mut self, id: impl Into<ElementId>) -> Self {
805 self.id = Some(id.into());
806 self
807 }
808
809 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
811 self.label = Some(label.into());
812 self
813 }
814
815 pub fn action(mut self, action: impl IntoElement) -> Self {
818 self.action = Some(action.into_any_element());
819 self
820 }
821
822 pub fn collapsible(mut self, open: bool) -> Self {
824 self.open = Some(open);
825 self
826 }
827
828 pub fn on_toggle(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
830 self.on_toggle = Some(Rc::new(handler));
831 self
832 }
833}
834
835impl Styled for SidebarGroup {
836 fn style(&mut self) -> &mut StyleRefinement {
837 &mut self.style
838 }
839}
840
841impl ParentElement for SidebarGroup {
842 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
843 self.children.extend(elements);
844 }
845}
846
847impl RenderOnce for SidebarGroup {
848 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
849 let (muted, row_height, text_size) = {
853 let theme = cx.theme();
854 (
855 theme.sidebar_muted_foreground,
856 theme.metrics.row_sm,
857 theme.text_control.size,
858 )
859 };
860 let icon_mode = sidebar_scope(cx).is_some_and(|scope| scope.icon_mode);
861 let open = self.open.unwrap_or(true);
862 let group_id = self.id.or_else(|| self.label.clone().map(ElementId::from));
863 let toggle_id = group_id
864 .clone()
865 .map(|id| ElementId::NamedChild(id.into(), "toggle".into()));
866 let reveal_id = ElementId::NamedChild(
867 group_id.unwrap_or_else(|| "sidebar-group".into()).into(),
868 "reveal".into(),
869 );
870 let progress = fold_progress(&reveal_id, open || icon_mode, window, cx);
871 let on_toggle = self.on_toggle;
872 v_flex()
873 .pt_2()
874 .refine_style(&self.style)
875 .when(!icon_mode, |this| {
876 this.when_some(self.label, |this, label| {
877 let row = h_flex()
878 .h(row_height)
879 .text_size(text_size)
880 .text_color(muted);
881 let text = div()
882 .flex_1()
883 .min_w_0()
884 .whitespace_nowrap()
885 .overflow_hidden()
886 .text_ellipsis()
887 .child(label.clone());
888 this.child(match (self.open, toggle_id) {
889 (Some(open), Some(id)) => row
890 .child(
891 Button::new(id)
892 .ghost()
893 .flex_1()
894 .min_w_0()
895 .justify_start()
896 .h(row_height)
897 .px_2()
898 .font_normal()
899 .text_color(muted)
900 .toggled(open)
901 .accessibility_label(label)
902 .child(text)
903 .trailing_icon(fold_chevron(progress))
904 .on_click(move |_, window, cx| {
905 if let Some(on_toggle) = &on_toggle {
906 on_toggle(&!open, window, cx);
907 }
908 }),
909 )
910 .children(self.action.map(|action| div().pl_1().child(action))),
911 _ => row
912 .px_2()
913 .child(text)
914 .children(self.action.map(|action| div().pl_1().child(action))),
915 })
916 })
917 })
918 .child(
919 folding(reveal_id, open || icon_mode, progress)
927 .content(v_flex().w_full().gap_0p5().children(self.children)),
928 )
929 }
930}
931
932fn fold_progress(id: &ElementId, open: bool, window: &mut Window, cx: &mut App) -> f32 {
936 transition(
937 id.clone(),
938 if open { 1. } else { 0. },
939 Theme::global(cx).motion.fold_transition(),
940 window,
941 cx,
942 )
943}
944
945fn fold_chevron(progress: f32) -> Icon {
948 Icon::from(IconName::ChevronRight)
949 .rotate(gpui_kit::radians(progress * std::f32::consts::FRAC_PI_2))
950}
951
952fn folding(reveal_id: ElementId, open: bool, progress: f32) -> Collapsible {
956 if !open && progress <= 0. {
962 Collapsible::new().open(false)
963 } else {
964 Collapsible::new().reveal(reveal_id, progress)
965 }
966}
967
968#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
970pub enum SidebarMenuSize {
971 Sm,
973 #[default]
975 Default,
976 Lg,
978}
979
980type ClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
981
982#[derive(IntoElement)]
995pub struct SidebarMenuButton {
996 id: ElementId,
997 button: Button,
998 label: Option<SharedString>,
999 badge: Option<SharedString>,
1000 action: Option<AnyElement>,
1001 show_action: bool,
1002 sub: Option<SidebarMenuSub>,
1003 open: Option<bool>,
1004 on_toggle: Option<ToggleHandler>,
1005 on_click: Option<ClickHandler>,
1006 size: SidebarMenuSize,
1007 disabled: bool,
1008 has_tooltip: bool,
1009 has_icon: bool,
1010}
1011
1012impl SidebarMenuButton {
1013 pub fn new(id: impl Into<ElementId>) -> Self {
1015 let id = id.into();
1016 Self {
1017 button: Button::new(id.clone()).ghost(),
1018 id,
1019 label: None,
1020 badge: None,
1021 action: None,
1022 show_action: false,
1023 sub: None,
1024 open: None,
1025 on_toggle: None,
1026 on_click: None,
1027 size: SidebarMenuSize::Default,
1028 disabled: false,
1029 has_tooltip: false,
1030 has_icon: false,
1031 }
1032 }
1033
1034 pub fn submenu(mut self, sub: SidebarMenuSub) -> Self {
1037 self.sub = Some(sub);
1038 self
1039 }
1040
1041 pub fn collapsible(mut self, open: bool) -> Self {
1044 self.open = Some(open);
1045 self
1046 }
1047
1048 pub fn on_toggle(mut self, handler: impl Fn(&bool, &mut Window, &mut App) + 'static) -> Self {
1051 self.on_toggle = Some(Rc::new(handler));
1052 self
1053 }
1054
1055 pub fn label(mut self, label: impl Into<SharedString>) -> Self {
1057 self.label = Some(label.into());
1058 self
1059 }
1060
1061 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
1063 self.button = self.button.icon(icon);
1064 self.has_icon = true;
1065 self
1066 }
1067
1068 pub fn trailing_icon(mut self, icon: impl Into<Icon>) -> Self {
1070 self.button = self.button.trailing_icon(icon);
1071 self
1072 }
1073
1074 pub fn badge(mut self, badge: impl Into<SharedString>) -> Self {
1076 self.badge = Some(badge.into());
1077 self
1078 }
1079
1080 pub fn action(mut self, action: impl IntoElement) -> Self {
1084 self.action = Some(action.into_any_element());
1085 self
1086 }
1087
1088 pub fn show_action(mut self, show: bool) -> Self {
1091 self.show_action = show;
1092 self
1093 }
1094
1095 pub fn size(mut self, size: SidebarMenuSize) -> Self {
1097 self.size = size;
1098 self
1099 }
1100
1101 pub fn tooltip(mut self, text: impl Into<SharedString>) -> Self {
1103 self.button = self.button.tooltip(text);
1104 self.has_tooltip = true;
1105 self
1106 }
1107
1108 pub fn tooltip_at(mut self, placement: Placement, text: impl Into<SharedString>) -> Self {
1110 self.button = self.button.tooltip_at(placement, text);
1111 self.has_tooltip = true;
1112 self
1113 }
1114
1115 pub fn on_click(
1117 mut self,
1118 handler: impl Fn(&ClickEvent, &mut Window, &mut App) + 'static,
1119 ) -> Self {
1120 self.on_click = Some(Rc::new(handler));
1121 self
1122 }
1123}
1124
1125impl Selectable for SidebarMenuButton {
1126 fn selected(mut self, selected: bool) -> Self {
1127 self.button = self.button.selected(selected);
1128 self
1129 }
1130
1131 fn is_selected(&self) -> bool {
1132 self.button.is_selected()
1133 }
1134
1135 fn open(mut self, open: bool) -> Self {
1138 self.button = self.button.open(open);
1139 self
1140 }
1141
1142 fn is_open(&self) -> bool {
1143 self.button.is_open()
1144 }
1145}
1146
1147impl Disableable for SidebarMenuButton {
1148 fn disabled(mut self, disabled: bool) -> Self {
1149 self.disabled = disabled;
1150 self.button = self.button.disabled(disabled);
1151 self
1152 }
1153}
1154
1155impl Styled for SidebarMenuButton {
1156 fn style(&mut self) -> &mut StyleRefinement {
1157 self.button.style()
1158 }
1159}
1160
1161impl ParentElement for SidebarMenuButton {
1162 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
1163 self.button.extend(elements);
1164 }
1165}
1166
1167impl RenderOnce for SidebarMenuButton {
1168 fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
1169 let theme = cx.theme();
1170 let foreground = theme.foreground();
1171 let muted = theme.sidebar_muted_foreground;
1172 let touch = theme.touch;
1173 let caption = theme.base.typography.xs.size;
1174 let action_room = theme.metrics.control_sm + theme.base.spacing.xs * 2.;
1177 let icon_mode = sidebar_scope(cx).is_some_and(|scope| scope.icon_mode);
1178 let (height, text) = match self.size {
1179 SidebarMenuSize::Sm => (theme.metrics.row_sm, caption),
1180 SidebarMenuSize::Default => (theme.metrics.row, theme.text_control.size),
1181 SidebarMenuSize::Lg => (theme.metrics.row_lg, theme.text_control.size),
1182 };
1183 let row_id = ElementId::NamedChild(self.id.clone().into(), "row".into());
1184 let fold_id = ElementId::NamedChild(self.id.clone().into(), "reveal".into());
1185 let group_name: SharedString = format!("sidebar-row-{:?}", self.id).into();
1186 let open = self.open;
1187 let on_toggle = self.on_toggle;
1188 let on_click = self.on_click;
1189 let has_action = self.action.is_some();
1193 let mut button = self
1194 .button
1195 .w_full()
1196 .h(height)
1197 .px_2()
1198 .when(has_action && !icon_mode, |this| this.pr(action_room))
1201 .font_normal()
1202 .text_size(text)
1203 .when(!self.disabled, |this| this.text_color(foreground))
1204 .when(on_toggle.is_some() || on_click.is_some(), |this| {
1205 this.on_click(move |event, window, cx| {
1206 if let (Some(on_toggle), Some(open)) = (&on_toggle, open) {
1207 on_toggle(&!open, window, cx);
1208 }
1209 if let Some(on_click) = &on_click {
1210 on_click(event, window, cx);
1211 }
1212 })
1213 });
1214 if icon_mode {
1215 button = button.justify_center();
1218 if let Some(label) = &self.label {
1219 button = button.accessibility_label(label.clone());
1220 if !self.has_tooltip {
1221 button = button.tooltip_at(Placement::Right, label.clone());
1222 }
1223 if !self.has_icon {
1224 let initial: SharedString = label
1225 .chars()
1226 .next()
1227 .map(|c| c.to_uppercase().to_string())
1228 .unwrap_or_default()
1229 .into();
1230 button = button.child(div().text_size(text).child(initial));
1231 }
1232 }
1233 return v_flex().child(div().id(row_id).child(button));
1234 }
1235 button = button.justify_start();
1236 if let Some(label) = self.label {
1237 button = button.label(label);
1238 }
1239 if let Some(open) = open {
1240 button = button.trailing_icon(fold_chevron(fold_progress(&fold_id, open, window, cx)));
1241 }
1242 if let Some(badge) = self.badge {
1243 button = button.child(
1244 div()
1245 .flex_1()
1246 .flex()
1247 .justify_end()
1248 .pl_2()
1249 .text_size(caption)
1250 .text_color(muted)
1251 .child(badge),
1252 );
1253 }
1254 let row = div()
1255 .id(row_id)
1256 .relative()
1257 .group(group_name.clone())
1258 .child(button)
1259 .when_some(self.action, |this, action| {
1260 this.child(
1261 div()
1262 .absolute()
1263 .top_0()
1264 .bottom_0()
1265 .right_1()
1266 .flex()
1267 .items_center()
1268 .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
1274 .when(!touch && !self.show_action, |this| {
1277 this.opacity(0.)
1278 .group_hover(group_name, |this| this.opacity(1.))
1279 })
1280 .child(action),
1281 )
1282 });
1283 v_flex()
1284 .gap_0p5()
1285 .child(row)
1286 .when_some(self.sub, |this, sub| {
1287 let open = open.unwrap_or(true);
1288 let progress = fold_progress(&fold_id, open, window, cx);
1289 this.child(folding(fold_id, open, progress).content(sub))
1290 })
1291 }
1292}
1293
1294#[derive(IntoElement)]
1297pub struct SidebarMenuSub {
1298 children: Vec<AnyElement>,
1299 style: StyleRefinement,
1300 guide: bool,
1301}
1302
1303impl Default for SidebarMenuSub {
1304 fn default() -> Self {
1305 Self::new()
1306 }
1307}
1308
1309impl SidebarMenuSub {
1310 pub fn new() -> Self {
1312 Self {
1313 children: Vec::new(),
1314 style: StyleRefinement::default(),
1315 guide: true,
1316 }
1317 }
1318
1319 pub fn guide(mut self, guide: bool) -> Self {
1322 self.guide = guide;
1323 self
1324 }
1325}
1326
1327impl Styled for SidebarMenuSub {
1328 fn style(&mut self) -> &mut StyleRefinement {
1329 &mut self.style
1330 }
1331}
1332
1333impl ParentElement for SidebarMenuSub {
1334 fn extend(&mut self, elements: impl IntoIterator<Item = AnyElement>) {
1335 self.children.extend(elements);
1336 }
1337}
1338
1339impl RenderOnce for SidebarMenuSub {
1340 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
1341 let border = cx.theme().sidebar_border;
1342 let icon_mode = sidebar_scope(cx).is_some_and(|scope| scope.icon_mode);
1343 div().w_full().pl_4().child(
1348 v_flex()
1349 .w_full()
1350 .pl_1p5()
1351 .when(self.guide, |this| this.border_l_1().border_color(border))
1352 .refine_style(&self.style)
1353 .when(!icon_mode, |this| this.children(self.children)),
1354 )
1355 }
1356}
1357
1358#[derive(IntoElement)]
1360pub struct SidebarSeparator {
1361 style: StyleRefinement,
1362}
1363
1364impl Default for SidebarSeparator {
1365 fn default() -> Self {
1366 Self::new()
1367 }
1368}
1369
1370impl SidebarSeparator {
1371 pub fn new() -> Self {
1373 Self {
1374 style: StyleRefinement::default(),
1375 }
1376 }
1377}
1378
1379impl Styled for SidebarSeparator {
1380 fn style(&mut self) -> &mut StyleRefinement {
1381 &mut self.style
1382 }
1383}
1384
1385impl RenderOnce for SidebarSeparator {
1386 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
1387 div()
1388 .mx_2()
1389 .my_2()
1390 .h(px(1.))
1391 .bg(cx.theme().sidebar_border)
1392 .refine_style(&self.style)
1393 }
1394}
1395
1396#[derive(IntoElement)]
1400pub struct SidebarMenuSkeleton {
1401 id: ElementId,
1402 show_icon: bool,
1403}
1404
1405impl SidebarMenuSkeleton {
1406 pub fn new(id: impl Into<ElementId>) -> Self {
1409 Self {
1410 id: id.into(),
1411 show_icon: true,
1412 }
1413 }
1414
1415 pub fn show_icon(mut self, show_icon: bool) -> Self {
1417 self.show_icon = show_icon;
1418 self
1419 }
1420}
1421
1422impl RenderOnce for SidebarMenuSkeleton {
1423 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
1424 let (fill, row_height, bar_height) = {
1425 let theme = cx.theme();
1426 (
1427 theme.sidebar_accent,
1428 theme.metrics.row,
1429 theme.base.typography.xs.size,
1430 )
1431 };
1432 let icon_mode = sidebar_scope(cx).is_some_and(|scope| scope.icon_mode);
1433 let seed = format!("{:?}", self.id).bytes().fold(7u32, |acc, byte| {
1435 acc.wrapping_mul(31).wrapping_add(byte as u32)
1436 });
1437 let width = 0.5 + (seed % 41) as f32 / 100.;
1438 let icon_id = ElementId::NamedChild(self.id.clone().into(), "icon".into());
1439 let bar_id = ElementId::NamedChild(self.id.clone().into(), "bar".into());
1440 div()
1441 .id(self.id)
1442 .flex()
1443 .items_center()
1444 .gap_1p5()
1445 .h(row_height)
1446 .px_2()
1447 .when(icon_mode, |this| this.justify_center())
1448 .when(self.show_icon, |this| {
1449 this.child(
1450 crate::Skeleton::new(icon_id)
1451 .size_4()
1452 .flex_shrink_0()
1453 .rounded_sm()
1454 .bg(fill),
1455 )
1456 })
1457 .when(!icon_mode, |this| {
1458 this.child(
1459 crate::Skeleton::new(bar_id)
1460 .h(bar_height)
1461 .w(gpui_kit::relative(width))
1462 .rounded_sm()
1463 .bg(fill),
1464 )
1465 })
1466 }
1467}
1468
1469#[derive(IntoElement)]
1475pub struct SidebarTrigger {
1476 id: ElementId,
1477 state: Entity<SidebarState>,
1478 tooltip: bool,
1479 size: ButtonSize,
1480 icon: Icon,
1481}
1482
1483impl SidebarTrigger {
1484 pub fn new(id: impl Into<ElementId>, state: &Entity<SidebarState>) -> Self {
1486 Self {
1487 id: id.into(),
1488 state: state.clone(),
1489 tooltip: true,
1490 size: ButtonSize::Default,
1491 icon: IconName::PanelLeft.into(),
1492 }
1493 }
1494
1495 pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
1498 self.icon = icon.into();
1499 self
1500 }
1501
1502 pub fn tooltip(mut self, tooltip: bool) -> Self {
1504 self.tooltip = tooltip;
1505 self
1506 }
1507
1508 pub fn size(mut self, size: ButtonSize) -> Self {
1510 self.size = size;
1511 self
1512 }
1513}
1514
1515impl RenderOnce for SidebarTrigger {
1516 fn render(self, _: &mut Window, cx: &mut App) -> impl IntoElement {
1517 let (open, fixed) = {
1518 let state = self.state.read(cx);
1519 (
1520 state.is_open(),
1521 state.collapsible == SidebarCollapsible::None,
1522 )
1523 };
1524 let name = if open { "Hide sidebar" } else { "Show sidebar" };
1525 let state = self.state;
1526 Button::new(self.id)
1527 .ghost()
1528 .size(self.size)
1529 .icon(self.icon)
1530 .accessibility_label(name)
1531 .when(self.tooltip, |this| this.tooltip(name))
1532 .disabled(fixed)
1533 .on_click(move |_, _, cx| state.update(cx, |state, cx| state.toggle(cx)))
1534 }
1535}
1536
1537#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1539pub(crate) struct SidebarScopeInfo {
1540 pub icon_mode: bool,
1542}
1543
1544#[derive(Default)]
1548struct SidebarScope {
1549 stack: Vec<SidebarScopeInfo>,
1550}
1551
1552impl gpui_kit::Global for SidebarScope {}
1553
1554pub(crate) fn sidebar_scope(cx: &App) -> Option<SidebarScopeInfo> {
1556 cx.try_global::<SidebarScope>()?.stack.last().copied()
1557}
1558
1559pub(crate) fn in_sidebar(cx: &App) -> bool {
1561 sidebar_scope(cx).is_some()
1562}
1563
1564fn with_sidebar_scope<R>(info: SidebarScopeInfo, cx: &mut App, f: impl FnOnce(&mut App) -> R) -> R {
1565 cx.default_global::<SidebarScope>().stack.push(info);
1566 let result = f(cx);
1567 cx.default_global::<SidebarScope>().stack.pop();
1568 result
1569}
1570
1571struct SidebarScoped {
1576 info: SidebarScopeInfo,
1577 child: AnyElement,
1578}
1579
1580impl SidebarScoped {
1581 fn new(icon_mode: bool, child: impl IntoElement) -> Self {
1582 Self {
1583 info: SidebarScopeInfo { icon_mode },
1584 child: child.into_any_element(),
1585 }
1586 }
1587}
1588
1589impl IntoElement for SidebarScoped {
1590 type Element = Self;
1591
1592 fn into_element(self) -> Self::Element {
1593 self
1594 }
1595}
1596
1597impl Element for SidebarScoped {
1598 type RequestLayoutState = ();
1599 type PrepaintState = ();
1600
1601 fn id(&self) -> Option<ElementId> {
1602 None
1603 }
1604
1605 fn source_location(&self) -> Option<&'static std::panic::Location<'static>> {
1606 None
1607 }
1608
1609 fn request_layout(
1610 &mut self,
1611 _: Option<&GlobalElementId>,
1612 _: Option<&InspectorElementId>,
1613 window: &mut Window,
1614 cx: &mut App,
1615 ) -> (LayoutId, Self::RequestLayoutState) {
1616 let layout_id =
1617 with_sidebar_scope(self.info, cx, |cx| self.child.request_layout(window, cx));
1618 (layout_id, ())
1619 }
1620
1621 fn prepaint(
1622 &mut self,
1623 _: Option<&GlobalElementId>,
1624 _: Option<&InspectorElementId>,
1625 _: gpui_kit::Bounds<Pixels>,
1626 _: &mut Self::RequestLayoutState,
1627 window: &mut Window,
1628 cx: &mut App,
1629 ) -> Self::PrepaintState {
1630 with_sidebar_scope(self.info, cx, |cx| {
1631 self.child.prepaint(window, cx);
1632 });
1633 }
1634
1635 fn paint(
1636 &mut self,
1637 _: Option<&GlobalElementId>,
1638 _: Option<&InspectorElementId>,
1639 _: gpui_kit::Bounds<Pixels>,
1640 _: &mut Self::RequestLayoutState,
1641 _: &mut Self::PrepaintState,
1642 window: &mut Window,
1643 cx: &mut App,
1644 ) {
1645 with_sidebar_scope(self.info, cx, |cx| self.child.paint(window, cx));
1646 }
1647}
1648
1649#[cfg(test)]
1650mod tests {
1651 use super::*;
1652 use gpui_kit::TestAppContext;
1653
1654 fn init(cx: &mut TestAppContext) {
1655 cx.update(|cx| {
1656 gpui_kit::init(cx);
1657 crate::init(cx);
1658 });
1659 }
1660
1661 #[gpui_kit::test]
1662 fn state_clamps_width_and_reports_changes(cx: &mut TestAppContext) {
1663 init(cx);
1664 let state = cx.new(|cx| SidebarState::new(cx).with_width_range(px(200.)..px(400.)));
1665 let events = Rc::new(std::cell::RefCell::new(Vec::new()));
1666 cx.update({
1667 let events = events.clone();
1668 let state = state.clone();
1669 move |cx| {
1670 cx.subscribe(&state, move |_, event: &SidebarEvent, _| {
1671 events.borrow_mut().push(*event);
1672 })
1673 .detach();
1674 }
1675 });
1676 state.update(cx, |state, cx| {
1677 assert_eq!(state.width(), px(300.));
1678 state.set_width(px(50.), cx);
1679 assert_eq!(state.width(), px(200.));
1680 state.set_width(px(900.), cx);
1681 assert_eq!(state.width(), px(400.));
1682 state.set_width(px(400.), cx);
1683 state.toggle(cx);
1684 assert!(!state.is_open());
1685 state.set_open(false, cx);
1686 state.set_open(true, cx);
1687 });
1688 assert_eq!(
1689 &*events.borrow(),
1690 &[
1691 SidebarEvent::Resized(px(200.)),
1692 SidebarEvent::Resized(px(400.)),
1693 SidebarEvent::Toggled(false),
1694 SidebarEvent::Toggled(true),
1695 ]
1696 );
1697 }
1698
1699 #[gpui_kit::test]
1700 fn collapse_modes_decide_the_closed_width(cx: &mut TestAppContext) {
1701 init(cx);
1702 let state = cx.new(|cx| SidebarState::new(cx).closed());
1703 state.update(cx, |state, cx| {
1704 let sheet = px(288.);
1705 assert_eq!(state.target_width(sheet), px(48.), "a rail by default");
1706 assert!(state.is_icon_only());
1707 state.set_collapsible(SidebarCollapsible::Offcanvas, cx);
1708 assert_eq!(state.target_width(sheet), px(0.));
1709 assert!(!state.is_icon_only());
1710 state.set_collapsible(SidebarCollapsible::Icon, cx);
1711 assert_eq!(state.target_width(sheet), px(48.));
1712 state.set_collapsible(SidebarCollapsible::None, cx);
1713 assert!(state.is_open(), "a fixed sidebar is always open");
1714 assert_eq!(state.target_width(sheet), px(300.));
1715 assert!(!state.is_icon_only());
1716 });
1717 }
1718
1719 #[gpui_kit::test]
1720 fn a_sheet_has_its_own_open_flag(cx: &mut TestAppContext) {
1721 init(cx);
1722 let state = cx.new(|cx| SidebarState::new(cx));
1723 state.update(cx, |state, cx| {
1724 let sheet = px(288.);
1725 state.set_sheet(true, cx);
1726 assert!(state.is_sheet());
1727 assert!(!state.is_open(), "a sheet starts closed");
1728 assert!(!state.is_icon_only(), "a sheet never shows a rail");
1729 assert_eq!(state.target_width(sheet), px(0.));
1730 state.toggle(cx);
1731 assert!(state.is_open());
1732 assert_eq!(state.target_width(sheet), sheet);
1733 state.set_collapsible(SidebarCollapsible::None, cx);
1734 state.set_open(false, cx);
1735 assert!(!state.is_open(), "a fixed sidebar still closes as a sheet");
1736 state.set_sheet(false, cx);
1737 assert!(state.is_open(), "the wide layout kept its own open state");
1738 assert_eq!(state.target_width(sheet), px(300.));
1739 });
1740 }
1741
1742 #[gpui_kit::test]
1743 fn the_scope_nests_and_reports_the_innermost(cx: &mut TestAppContext) {
1744 cx.update(|cx| {
1745 assert!(!in_sidebar(cx));
1746 let rail = SidebarScopeInfo { icon_mode: true };
1747 let full = SidebarScopeInfo { icon_mode: false };
1748 with_sidebar_scope(full, cx, |cx| {
1749 assert_eq!(sidebar_scope(cx), Some(full));
1750 with_sidebar_scope(rail, cx, |cx| assert_eq!(sidebar_scope(cx), Some(rail)));
1751 assert_eq!(sidebar_scope(cx), Some(full));
1752 });
1753 assert!(!in_sidebar(cx));
1754 });
1755 }
1756}