Skip to main content

gpui_cn/
sidebar.rs

1//! A sidebar beside the main content: resizable by dragging its edge,
2//! collapsible with a trigger, animated between the two.
3//!
4//! The parts follow shadcn's sidebar. [`SidebarState`] owns whether the
5//! sidebar is open, how wide it is, and how it collapses. [`SidebarLayout`]
6//! places a [`Sidebar`] beside the content and owns the resize handle and
7//! the collapse motion. Inside are [`SidebarGroup`]s of
8//! [`SidebarMenuButton`] rows, with [`SidebarMenuSub`] for nested rows,
9//! [`SidebarSeparator`] between sections, and [`SidebarMenuSkeleton`]
10//! while rows load. [`SidebarTrigger`] opens and closes it.
11//!
12//! ```
13//! use gpui_cn::{Sidebar, SidebarGroup, SidebarLayout, SidebarMenuButton, SidebarState, SidebarTrigger};
14//! use gpui_kit::{Entity, IntoElement, ParentElement as _, base::Selectable as _, div};
15//!
16//! fn shell(state: &Entity<SidebarState>) -> impl IntoElement {
17//!     SidebarLayout::new(state)
18//!         .sidebar(
19//!             Sidebar::new()
20//!                 .header(SidebarTrigger::new("trigger", state))
21//!                 .child(
22//!                     SidebarGroup::new()
23//!                         .label("Projects")
24//!                         .child(SidebarMenuButton::new("inbox").label("Inbox").selected(true)),
25//!                 ),
26//!         )
27//!         .child(div().child("Content"))
28//! }
29//! ```
30
31use 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/// Which edge of the content the sidebar is on.
52#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
53pub enum SidebarSide {
54    /// The left edge, the usual place for navigation.
55    #[default]
56    Left,
57    /// The right edge, for an inspector or a secondary panel.
58    Right,
59}
60
61/// What closing the sidebar does.
62#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
63pub enum SidebarCollapsible {
64    /// Slides out completely and gives its width to the content.
65    Offcanvas,
66    /// Narrows to a rail of icons. Rows drop their labels and show them as
67    /// tooltips instead. The default.
68    #[default]
69    Icon,
70    /// Cannot close. The trigger is disabled.
71    None,
72}
73
74/// Emitted by [`SidebarState`] after it changed.
75#[derive(Clone, Copy, Debug, PartialEq)]
76pub enum SidebarEvent {
77    /// The sidebar was opened or closed. The payload is the new state.
78    Toggled(bool),
79    /// The sidebar width changed by a drag or a call, in pixels.
80    Resized(Pixels),
81}
82
83/// Whether the sidebar is open, how wide it is, and how it collapses.
84///
85/// Shared by every page that shows the sidebar, so the width and the
86/// open state survive navigation. Edit it through its methods so the
87/// layout and the trigger observe the change.
88pub struct SidebarState {
89    open: bool,
90    width: Pixels,
91    width_range: Range<Pixels>,
92    icon_width: Pixels,
93    collapsible: SidebarCollapsible,
94    resizing: bool,
95    /// The layout sets this while the window is narrower than the sheet
96    /// breakpoint. The sheet has its own open flag, so closing it does not
97    /// close the sidebar the wide layout shows.
98    sheet: bool,
99    sheet_open: bool,
100}
101
102impl EventEmitter<SidebarEvent> for SidebarState {}
103
104impl SidebarState {
105    /// An open sidebar at the theme's sidebar width that narrows to a rail
106    /// of icons when closed.
107    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    /// Starts closed.
122    pub fn closed(mut self) -> Self {
123        self.open = false;
124        self
125    }
126
127    /// Starts at `width`, clamped to the range.
128    pub fn with_width(mut self, width: Pixels) -> Self {
129        self.width = clamp(width, &self.width_range);
130        self
131    }
132
133    /// The widths a drag can reach. The current width is clamped into it.
134    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    /// How the sidebar closes.
141    pub fn with_collapsible(mut self, collapsible: SidebarCollapsible) -> Self {
142        self.collapsible = collapsible;
143        self
144    }
145
146    /// The width of the rail when collapsed to icons. The theme's
147    /// `icon_sidebar_width` unless set. A window whose rail sits under the
148    /// macOS window controls gives it the theme's `window_controls_inset`.
149    pub fn with_icon_width(mut self, width: Pixels) -> Self {
150        self.icon_width = width;
151        self
152    }
153
154    /// The width of the rail when collapsed to icons.
155    pub fn icon_width(&self) -> Pixels {
156        self.icon_width
157    }
158
159    /// Whether the sidebar is open. A sidebar that cannot collapse is
160    /// always open beside the content; as a sheet it still opens and
161    /// closes.
162    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    /// Whether the sidebar is showing only icons: closed beside the
171    /// content, and collapsing to icons. A sheet never shows icons.
172    pub fn is_icon_only(&self) -> bool {
173        !self.sheet && !self.is_open() && self.collapsible == SidebarCollapsible::Icon
174    }
175
176    /// Whether the sidebar floats over the content as a sheet, which the
177    /// layout decides from the window width. A sheet starts closed, opens
178    /// from the trigger, and closes from the trigger or a tap outside it.
179    pub fn is_sheet(&self) -> bool {
180        self.sheet
181    }
182
183    /// The width the sidebar has when open.
184    pub fn width(&self) -> Pixels {
185        self.width
186    }
187
188    /// The widths a drag can reach.
189    pub fn width_range(&self) -> &Range<Pixels> {
190        &self.width_range
191    }
192
193    /// How the sidebar closes.
194    pub fn collapsible(&self) -> SidebarCollapsible {
195        self.collapsible
196    }
197
198    /// Whether a drag on the handle is in progress.
199    pub fn is_resizing(&self) -> bool {
200        self.resizing
201    }
202
203    /// The width the layout gives the sidebar right now, before motion.
204    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    /// Opens or closes the sidebar: the sheet while the sidebar is one,
217    /// the sidebar beside the content otherwise.
218    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    /// Closes an open sidebar and opens a closed one.
233    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    /// The layout calls this with the window width on every render.
243    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    /// Sets the width, clamped to the range.
251    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    /// Changes the width of the rail when collapsed to icons.
262    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    /// Changes how the sidebar closes.
270    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
289/// The value carried by a drag of the resize handle: which sidebar's
290/// state the drag resizes. Every layout listens for the drag, so each one
291/// checks that it is the owner before it moves.
292struct SidebarResize(gpui_kit::EntityId);
293
294/// What GPUI renders under the pointer during a resize: nothing.
295struct NoDragPreview;
296
297impl Render for NoDragPreview {
298    fn render(&mut self, _: &mut Window, _: &mut Context<Self>) -> impl IntoElement {
299        div()
300    }
301}
302
303/// A [`Sidebar`] beside the main content.
304///
305/// The sidebar keeps the width in its [`SidebarState`] and slides to its
306/// closed width when the state closes, over the theme's fold transition
307/// (or at once under reduced motion). A handle on its inner edge resizes
308/// it within the state's range while it is open. The remaining width is
309/// the content's.
310///
311/// The layout fills its parent, so give it a sized parent.
312#[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    /// A layout driven by `state`, with the sidebar on the left.
323    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    /// The sidebar.
334    pub fn sidebar(mut self, sidebar: Sidebar) -> Self {
335        self.sidebar = Some(sidebar);
336        self
337    }
338
339    /// Which edge the sidebar is on.
340    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        // A narrow window turns the sidebar into a sheet. The state learns
369        // it here, so the trigger and the pages read the same answer.
370        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        // The shown width is the animated value; the target is the state's.
387        // A drag moves the target every frame, so it must not animate. The
388        // slide eases in and out: the enter curve does most of its travel
389        // in the first 70ms, which reads as a snap.
390        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        // Closing, the rows keep their open form and the panel clips them
404        // as it narrows, so the labels slide out of view under the edge;
405        // they take the rail form once the panel has reached the rail,
406        // where an icon sits at the same place in both forms. Opening,
407        // they take the open form at once, so the labels come into view
408        // as the panel grows instead of appearing at the end.
409        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
572/// What the sheet remembers while it is open: its focus handle, and the
573/// element that had focus before it opened.
574struct SheetFocus {
575    handle: FocusHandle,
576    previous: Option<WeakFocusHandle>,
577    open: bool,
578}
579
580/// The sheet's focus handle. Focus moves into the sheet when it opens and
581/// goes back to the element that had it when the sheet closes.
582fn 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/// The sidebar surface: a header that stays put, content that scrolls, and
623/// a footer that stays put.
624///
625/// It paints the theme's sidebar tokens and puts its children under the
626/// sidebar scope, so a [`Button`] inside it hovers and selects with the
627/// sidebar's own surfaces instead of the window's, and rows know when the
628/// sidebar is a rail of icons. Fill it with [`SidebarGroup`] and
629/// [`SidebarMenuButton`].
630#[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    /// An empty sidebar.
648    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    /// Something above the scrolling content: a title bar, a workspace
660    /// switcher, a search field.
661    pub fn header(mut self, header: impl IntoElement) -> Self {
662        self.header.push(header.into_any_element());
663        self
664    }
665
666    /// Something below the scrolling content: an account row, settings.
667    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/// A run of rows under an optional label, with an optional action beside
765/// the label, and optionally collapsible.
766///
767/// A collapsible group is stateless: the owner keeps whether it is open
768/// and flips it in [`on_toggle`](Self::on_toggle), as shadcn's
769/// `Collapsible` does. In a sidebar collapsed to icons the label and the
770/// action are hidden and the rows always show.
771#[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    /// An unlabeled group.
790    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    /// A stable id for a collapsible group's toggle. Without one the label
803    /// is the id.
804    pub fn id(mut self, id: impl Into<ElementId>) -> Self {
805        self.id = Some(id.into());
806        self
807    }
808
809    /// The quiet label above the rows.
810    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
811        self.label = Some(label.into());
812        self
813    }
814
815    /// A small control at the far end of the label row: an icon-only
816    /// [`Button`] in the `Xs` or `Sm` size, such as "add".
817    pub fn action(mut self, action: impl IntoElement) -> Self {
818        self.action = Some(action.into_any_element());
819        self
820    }
821
822    /// Makes the label a toggle that shows the rows when `open`.
823    pub fn collapsible(mut self, open: bool) -> Self {
824        self.open = Some(open);
825        self
826    }
827
828    /// Called with the new open state when the label is activated.
829    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        // The label row is a small row, and the group sits a small step
850        // below what is above it, so a label reads as a heading for its
851        // rows rather than as a row of its own.
852        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                // Rows sit a hair apart so a hovered row beside the
920                // selected one reads as two rows, not one taller one. The
921                // rows fold with the theme's motion instead of vanishing.
922                // The reveal lays its content out as a root with the
923                // sidebar's width available, so the content asks for all
924                // of it; a root takes its content's width otherwise, and
925                // rows would shrink to the longest label.
926                folding(reveal_id, open || icon_mode, progress)
927                    .content(v_flex().w_full().gap_0p5().children(self.children)),
928            )
929    }
930}
931
932/// How far a folding region is open, 0 to 1, moving with the theme's fold
933/// transition (or at once under reduced motion). The first render adopts
934/// the target, so a region does not animate open when it first appears.
935fn 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
945/// The chevron beside a folding region: it points right while folded and
946/// turns down as the region opens, with the fold.
947fn fold_chevron(progress: f32) -> Icon {
948    Icon::from(IconName::ChevronRight)
949        .rotate(gpui_kit::radians(progress * std::f32::consts::FRAC_PI_2))
950}
951
952/// A collapsible region mid-fold: clipped to `progress` while it moves,
953/// fully open or unmounted once it rests. Folded rows leave the tree, so
954/// they take no focus and no tab stop.
955fn folding(reveal_id: ElementId, open: bool, progress: f32) -> Collapsible {
956    // The reveal measures its content on one frame and clips to that
957    // height on the next, so it stays in place while the region is open
958    // at rest: then the height is known the frame a close starts, and
959    // the rows do not vanish for a frame before they fold. A region that
960    // is closed and has finished closing leaves the tree instead.
961    if !open && progress <= 0. {
962        Collapsible::new().open(false)
963    } else {
964        Collapsible::new().reveal(reveal_id, progress)
965    }
966}
967
968/// The size tier of a [`SidebarMenuButton`].
969#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
970pub enum SidebarMenuSize {
971    /// 28px, 12px text.
972    Sm,
973    /// 30px, 13px text: the row height of the reference app.
974    #[default]
975    Default,
976    /// 48px, for a row with an avatar or two lines.
977    Lg,
978}
979
980type ClickHandler = Rc<dyn Fn(&ClickEvent, &mut Window, &mut App)>;
981
982/// A row in the sidebar: an icon, a label, a badge, an action that shows
983/// on hover, a selected state, and optionally nested rows it folds.
984///
985/// A [`Button`] in the ghost variant, full width, left aligned, at the row
986/// height, with the foreground as its text. Everything the button offers
987/// (tooltips, disabled, focus) applies. In a sidebar collapsed to icons
988/// the row shows its icon alone and its label as a tooltip.
989///
990/// A row with a [`submenu`](Self::submenu) that is [`collapsible`](Self::collapsible)
991/// carries a chevron and folds the nested rows. The owner keeps the open
992/// state and flips it in [`on_toggle`](Self::on_toggle), as shadcn's
993/// collapsible menu does.
994#[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    /// A row with a stable id.
1014    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    /// Rows nested under this one. Always shown unless the row is
1035    /// [`collapsible`](Self::collapsible).
1036    pub fn submenu(mut self, sub: SidebarMenuSub) -> Self {
1037        self.sub = Some(sub);
1038        self
1039    }
1040
1041    /// Makes the row a toggle for its nested rows, shown when `open`, with
1042    /// a chevron at its far edge.
1043    pub fn collapsible(mut self, open: bool) -> Self {
1044        self.open = Some(open);
1045        self
1046    }
1047
1048    /// Called with the new open state when a collapsible row is activated,
1049    /// before [`on_click`](Self::on_click).
1050    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    /// The visible text.
1056    pub fn label(mut self, label: impl Into<SharedString>) -> Self {
1057        self.label = Some(label.into());
1058        self
1059    }
1060
1061    /// An icon before the label.
1062    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    /// An icon after the label, pushed to the far edge.
1069    pub fn trailing_icon(mut self, icon: impl Into<Icon>) -> Self {
1070        self.button = self.button.trailing_icon(icon);
1071        self
1072    }
1073
1074    /// Quiet text at the far edge: a count, a shortcut.
1075    pub fn badge(mut self, badge: impl Into<SharedString>) -> Self {
1076        self.badge = Some(badge.into());
1077        self
1078    }
1079
1080    /// A control at the far edge that shows while the row is hovered: an
1081    /// icon-only [`Button`] in the `Sm` size, such as "more". It covers
1082    /// the badge while shown.
1083    pub fn action(mut self, action: impl IntoElement) -> Self {
1084        self.action = Some(action.into_any_element());
1085        self
1086    }
1087
1088    /// Keeps the action shown without a hover, such as while the menu it
1089    /// opens is open, so the pointer can leave the row for the menu.
1090    pub fn show_action(mut self, show: bool) -> Self {
1091        self.show_action = show;
1092        self
1093    }
1094
1095    /// The size tier.
1096    pub fn size(mut self, size: SidebarMenuSize) -> Self {
1097        self.size = size;
1098        self
1099    }
1100
1101    /// A text tooltip.
1102    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    /// A text tooltip that prefers a side.
1109    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    /// The activation handler for pointer, Enter, and Space.
1116    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    /// Kept apart from `selected`: a row is selected while it is the
1136    /// current view, and open only while its popover or menu shows.
1137    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        // A row action is a small icon button at the right edge, `right_1`
1175        // in from it; the row keeps that width plus a gap clear.
1176        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        // The ghost variant rests with muted text; a row is a destination,
1190        // so it reads in the foreground. A disabled row keeps the ghost's
1191        // disabled text.
1192        let has_action = self.action.is_some();
1193        let mut button = self
1194            .button
1195            .w_full()
1196            .h(height)
1197            .px_2()
1198            // The action floats at the row's right edge; the row keeps
1199            // that much clear so a badge or a chevron never sits under it.
1200            .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            // An icon alone, centered, named by its label. A row without
1216            // an icon shows the label's first letter, so it is not blank.
1217            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                        // A press on the action is the action's, not the
1269                        // row's: it must not select or fold the row. It
1270                        // stops the press rather than blocking the mouse,
1271                        // which would end the row's hover and hide the
1272                        // action under the pointer.
1273                        .on_mouse_down(MouseButton::Left, |_, _, cx| cx.stop_propagation())
1274                        // A finger cannot hover to reveal it, so on touch
1275                        // the action stays visible.
1276                        .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/// Rows nested under a row: indented, with a hairline down their left.
1295/// Hidden in a sidebar collapsed to icons.
1296#[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    /// An empty run of nested rows, with the guide line.
1311    pub fn new() -> Self {
1312        Self {
1313            children: Vec::new(),
1314            style: StyleRefinement::default(),
1315            guide: true,
1316        }
1317    }
1318
1319    /// Whether a hairline runs down the left of the nested rows, as
1320    /// shadcn draws it. On by default. Off keeps the indent alone.
1321    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        // The indent is padding on an outer box, not a margin on the
1344        // rows' box: while the rows fold, base lays the region out as a
1345        // root, and a root's margin is dropped, so a margin would put the
1346        // rows at the left edge until the fold ends.
1347        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/// A hairline between sections of the sidebar.
1359#[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    /// A separator with the row inset.
1372    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/// A placeholder row while rows load: a [`Skeleton`](crate::Skeleton)
1397/// where the icon goes and one of a stable random width where the label
1398/// goes, both pulsing in the sidebar's hover fill.
1399#[derive(IntoElement)]
1400pub struct SidebarMenuSkeleton {
1401    id: ElementId,
1402    show_icon: bool,
1403}
1404
1405impl SidebarMenuSkeleton {
1406    /// A skeleton row. The id decides the bar's width, so a list of
1407    /// skeletons looks like a list of different labels.
1408    pub fn new(id: impl Into<ElementId>) -> Self {
1409        Self {
1410            id: id.into(),
1411            show_icon: true,
1412        }
1413    }
1414
1415    /// Whether the row leaves room for an icon. On by default.
1416    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        // A stable pseudo-random width between 50% and 90%, from the id.
1434        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/// The button that opens and closes a sidebar.
1470///
1471/// An icon-only ghost [`Button`] with the panel glyph. It reads as "Hide
1472/// sidebar" while open and "Show sidebar" while closed, and is disabled
1473/// for a sidebar that cannot collapse.
1474#[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    /// A trigger for `state`.
1485    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    /// The icon, in place of Lucide's `panel-left`, such as an
1496    /// application's own SVG through [`Icon::from_bytes`].
1497    pub fn icon(mut self, icon: impl Into<Icon>) -> Self {
1498        self.icon = icon.into();
1499        self
1500    }
1501
1502    /// Whether the trigger shows its name as a tooltip. On by default.
1503    pub fn tooltip(mut self, tooltip: bool) -> Self {
1504        self.tooltip = tooltip;
1505        self
1506    }
1507
1508    /// The button size tier.
1509    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/// What a render inside a [`Sidebar`] knows about it.
1538#[derive(Clone, Copy, Debug, PartialEq, Eq)]
1539pub(crate) struct SidebarScopeInfo {
1540    /// The sidebar is a rail of icons.
1541    pub icon_mode: bool,
1542}
1543
1544/// The sidebars the render is inside, innermost last. Components read it
1545/// to pick the sidebar's surfaces, as shadcn remaps its CSS variables
1546/// inside `Sidebar`.
1547#[derive(Default)]
1548struct SidebarScope {
1549    stack: Vec<SidebarScopeInfo>,
1550}
1551
1552impl gpui_kit::Global for SidebarScope {}
1553
1554/// The innermost sidebar the element being rendered is inside, if any.
1555pub(crate) fn sidebar_scope(cx: &App) -> Option<SidebarScopeInfo> {
1556    cx.try_global::<SidebarScope>()?.stack.last().copied()
1557}
1558
1559/// Whether the element being rendered is inside a [`Sidebar`].
1560pub(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
1571/// An element that renders its child inside the sidebar scope.
1572///
1573/// A deferred child (any `RenderOnce`) renders during layout, so wrapping
1574/// the three phases is enough for every descendant to see the scope.
1575struct 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}