Skip to main content

gpui_kit/navigation/
sidebar.rs

1//! A navigation rail of sectioned places, expanded or collapsed to icons.
2//!
3//! Where the typist is, is caller-owned. The sidebar reports the place that was
4//! picked and marks whatever the caller says is current, so a host that refuses
5//! a move keeps the place that still holds highlighted.
6//!
7//! Collapsing is a change of drawing, never of substance. A collapsed rail
8//! shows glyphs and reaches the label through a [`Tooltip`](crate::overlay::Tooltip), and every item
9//! still publishes its full name, so nothing becomes unaddressable by being
10//! made narrow.
11
12use std::rc::Rc;
13
14use gpui::{
15    AnyElement, App, InteractiveElement, IntoElement, ParentElement, RenderOnce, SharedString,
16    StatefulInteractiveElement, Styled, Window, div, prelude::FluentBuilder, px,
17};
18use gpui_kit_assets::{Icon, icon};
19use gpui_kit_semantics::{NodeSpec, Role, Semantic};
20use gpui_kit_theme::{ActiveTheme, ControlSize, Radius, Space, TextTone, Theme, TypeScale};
21
22use crate::display::badge::Badge;
23use crate::foundation::{
24    Disableable, FocusRing, Ident, Pressable, Sizable, StyledExt, text as foundation_text,
25};
26use crate::motion::{Flipping, flip};
27use crate::overlay::Tooltipped;
28
29/// How wide the rail is expanded, and how wide it is collapsed to glyphs.
30/// Neither value repeats anywhere else.
31const EXPANDED_WIDTH: f32 = 240.0;
32const COLLAPSED_WIDTH: f32 = 52.0;
33
34/// How far a nested item is indented from its parent in the expanded rail.
35const NESTING_INDENT: f32 = 16.0;
36
37type SelectHandler = Rc<dyn Fn(SharedString, &mut Window, &mut App)>;
38
39/// One place in the rail.
40#[derive(Debug, Clone, PartialEq, Eq)]
41pub struct SidebarItem {
42    id: SharedString,
43    label: SharedString,
44    icon: Option<Icon>,
45    badge: Option<SharedString>,
46    disabled: bool,
47    children: Vec<SidebarItem>,
48}
49
50impl SidebarItem {
51    pub fn new(id: impl Into<SharedString>, label: impl Into<SharedString>) -> Self {
52        Self {
53            id: id.into(),
54            label: label.into(),
55            icon: None,
56            badge: None,
57            disabled: false,
58            children: Vec::new(),
59        }
60    }
61
62    pub fn icon(mut self, glyph: Icon) -> Self {
63        self.icon = Some(glyph);
64        self
65    }
66
67    /// A count or a state shown next to the label, such as how many runs a
68    /// place holds.
69    pub fn badge(mut self, badge: impl Into<SharedString>) -> Self {
70        self.badge = Some(badge.into());
71        self
72    }
73
74    pub fn disabled(mut self, disabled: bool) -> Self {
75        self.disabled = disabled;
76        self
77    }
78
79    /// Nested places, one level deep.
80    ///
81    /// A rail deeper than that stops being a rail, so anything nested inside a
82    /// child is dropped here rather than drawn at a depth the sidebar cannot
83    /// lay out or publish.
84    pub fn children(mut self, children: impl IntoIterator<Item = SidebarItem>) -> Self {
85        self.children = children
86            .into_iter()
87            .map(|child| SidebarItem {
88                children: Vec::new(),
89                ..child
90            })
91            .collect();
92        self
93    }
94
95    pub fn id(&self) -> &SharedString {
96        &self.id
97    }
98
99    pub fn label(&self) -> &SharedString {
100        &self.label
101    }
102}
103
104/// A titled run of places.
105#[derive(Debug, Clone, PartialEq, Eq)]
106pub struct SidebarSection {
107    id: SharedString,
108    title: Option<SharedString>,
109    items: Vec<SidebarItem>,
110}
111
112impl SidebarSection {
113    pub fn new(id: impl Into<SharedString>) -> Self {
114        Self {
115            id: id.into(),
116            title: None,
117            items: Vec::new(),
118        }
119    }
120
121    pub fn title(mut self, title: impl Into<SharedString>) -> Self {
122        self.title = Some(title.into());
123        self
124    }
125
126    pub fn item(mut self, item: SidebarItem) -> Self {
127        self.items.push(item);
128        self
129    }
130
131    pub fn items(mut self, items: impl IntoIterator<Item = SidebarItem>) -> Self {
132        self.items.extend(items);
133        self
134    }
135}
136
137/// A collapsible navigation rail.
138#[derive(IntoElement)]
139pub struct Sidebar {
140    ident: Ident,
141    sections: Vec<SidebarSection>,
142    active: Option<SharedString>,
143    collapsed: bool,
144    header: Option<AnyElement>,
145    footer: Option<AnyElement>,
146    disabled: bool,
147    size: ControlSize,
148    on_select: Option<SelectHandler>,
149}
150
151impl std::fmt::Debug for Sidebar {
152    fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
153        formatter
154            .debug_struct("Sidebar")
155            .field("ident", &self.ident)
156            .field("sections", &self.sections.len())
157            .field("active", &self.active)
158            .field("collapsed", &self.collapsed)
159            .field("disabled", &self.disabled)
160            .field("has_handler", &self.on_select.is_some())
161            .finish()
162    }
163}
164
165impl Sidebar {
166    pub fn new(ident: impl Into<Ident>) -> Self {
167        Self {
168            ident: ident.into(),
169            sections: Vec::new(),
170            active: None,
171            collapsed: false,
172            header: None,
173            footer: None,
174            disabled: false,
175            size: ControlSize::Md,
176            on_select: None,
177        }
178    }
179
180    pub fn section(mut self, section: SidebarSection) -> Self {
181        self.sections.push(section);
182        self
183    }
184
185    pub fn sections(mut self, sections: impl IntoIterator<Item = SidebarSection>) -> Self {
186        self.sections.extend(sections);
187        self
188    }
189
190    /// The place the caller says is current. The sidebar marks it and never
191    /// moves it.
192    pub fn active(mut self, id: impl Into<SharedString>) -> Self {
193        self.active = Some(id.into());
194        self
195    }
196
197    /// Draws glyphs only, with each label reachable as hover help.
198    pub fn collapsed(mut self, collapsed: bool) -> Self {
199        self.collapsed = collapsed;
200        self
201    }
202
203    pub fn header(mut self, header: impl IntoElement) -> Self {
204        self.header = Some(header.into_any_element());
205        self
206    }
207
208    /// The slot at the bottom of the rail, kept in both widths.
209    pub fn footer(mut self, footer: impl IntoElement) -> Self {
210        self.footer = Some(footer.into_any_element());
211        self
212    }
213
214    pub fn on_select(
215        mut self,
216        handler: impl Fn(SharedString, &mut Window, &mut App) + 'static,
217    ) -> Self {
218        self.on_select = Some(Rc::new(handler));
219        self
220    }
221
222    fn item_element(
223        &self,
224        item: &SidebarItem,
225        level: u32,
226        theme: &Theme,
227        window: &mut Window,
228        cx: &mut App,
229    ) -> AnyElement {
230        let metrics = theme.control.get(self.size);
231        let ident = self.ident.child(item.id.as_ref());
232        let active = self.active.as_ref() == Some(&item.id);
233        let disabled = self.disabled || item.disabled;
234        let actionable = !disabled && self.on_select.is_some();
235        let color = if disabled {
236            theme.colors.text_faint
237        } else if active {
238            theme.colors.text
239        } else {
240            theme.colors.text_muted
241        };
242        let indent = if self.collapsed || level == 1 {
243            0.0
244        } else {
245            NESTING_INDENT
246        };
247        let glyph_slot = flip(ident.child("glyph").semantic_id(), cx);
248
249        let mut row = div()
250            .id(ident.element_id())
251            .flex()
252            .flex_row()
253            .items_center()
254            .h(px(metrics.height))
255            .w_full()
256            .gap(px(theme.space(Space::Sm)))
257            .pl(px(theme.space(Space::Sm) + indent))
258            .pr(px(theme.space(Space::Sm)))
259            .radius(theme, Radius::Control)
260            .when(self.collapsed, |element| element.justify_center())
261            .when(active, |element| element.bg(theme.colors.selected))
262            .when(disabled, |element| element.opacity(theme.opacity.disabled))
263            .child(
264                // The glyph is the one thing that survives collapsing, so it
265                // travels to its narrow position rather than being redrawn
266                // there.
267                div()
268                    .flex()
269                    .flex_none()
270                    .w(px(metrics.icon_size))
271                    .justify_center()
272                    .children(
273                        item.icon
274                            .map(|glyph| icon(glyph).size(px(metrics.icon_size)).text_color(color)),
275                    )
276                    .flip(&glyph_slot, window, cx),
277            )
278            .when(!self.collapsed, |element| {
279                element
280                    .child(
281                        div().flex_1().overflow_hidden().child(
282                            foundation_text(theme, TypeScale::Label, item.label.clone())
283                                .text_size(px(metrics.font_size))
284                                .text_color(color),
285                        ),
286                    )
287                    .children(item.badge.clone().map(|badge| Badge::new(badge).neutral()))
288            })
289            .when(actionable, |element| {
290                element
291                    .cursor_pointer()
292                    .tab_index(0)
293                    .pressable(cx)
294                    .hover(|style| style.bg(theme.colors.hover))
295                    .focus_ring(theme)
296            });
297
298        // A narrow rail hides the wording, so the wording has to reach the
299        // typist some other way.
300        if self.collapsed {
301            row = row.tip(ident.clone(), item.label.clone());
302        }
303
304        if let (true, Some(handler)) = (actionable, self.on_select.clone()) {
305            let id = item.id.clone();
306            let click = Rc::clone(&handler);
307            let clicked = id.clone();
308            row = row
309                .on_click(move |_, window, cx| click(clicked.clone(), window, cx))
310                .on_key_down(move |event, window, cx| {
311                    if matches!(event.keystroke.key.as_str(), "enter" | "space") {
312                        handler(id.clone(), window, cx);
313                        cx.stop_propagation();
314                    }
315                });
316        }
317
318        let mut spec = NodeSpec::new(ident.semantic_id(), Role::Link)
319            .parent(self.ident.semantic_id())
320            .selected(active)
321            .disabled(disabled)
322            .level(level)
323            // A collapsed rail draws a glyph and still says what it is.
324            .text(item.label.clone());
325        if let Some(badge) = item.badge.clone() {
326            spec = spec.value(badge);
327        }
328
329        row.semantic_in(cx, spec).into_any_element()
330    }
331}
332
333impl Disableable for Sidebar {
334    /// Freezes the whole rail. A frozen rail installs no handler at all.
335    fn disabled(mut self, disabled: bool) -> Self {
336        self.disabled = disabled;
337        self
338    }
339}
340
341impl Sizable for Sidebar {
342    fn control_size(mut self, size: ControlSize) -> Self {
343        self.size = size;
344        self
345    }
346}
347
348impl RenderOnce for Sidebar {
349    fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement {
350        let theme = cx.theme().clone();
351        let mut body = div()
352            .flex()
353            .flex_col()
354            .flex_1()
355            .gap(px(theme.space(Space::Md)))
356            .overflow_hidden();
357
358        for section in &self.sections {
359            let section_ident = self.ident.child(section.id.as_ref());
360            let mut rows: Vec<AnyElement> = Vec::new();
361            for item in &section.items {
362                rows.push(self.item_element(item, 1, &theme, window, cx));
363                for child in &item.children {
364                    rows.push(self.item_element(child, 2, &theme, window, cx));
365                }
366            }
367
368            // A collapsed rail has no room for a caption, so the run is
369            // separated by a rule instead of titled.
370            let heading = section
371                .title
372                .clone()
373                .filter(|_| !self.collapsed)
374                .map(|title| {
375                    foundation_text(&theme, TypeScale::Caption, title.clone())
376                        .px(px(theme.space(Space::Sm)))
377                        .text_tone(&theme, TextTone::Faint)
378                        .semantic_in(
379                            cx,
380                            NodeSpec::new(section_ident.semantic_id(), Role::Heading)
381                                .parent(self.ident.semantic_id())
382                                .level(1)
383                                .text(title),
384                        )
385                });
386
387            body = body.child(
388                div()
389                    .flex()
390                    .flex_col()
391                    .gap(px(theme.space(Space::Xs)))
392                    .children(heading)
393                    .children(rows),
394            );
395        }
396
397        div()
398            .id(self.ident.element_id())
399            .flex()
400            .flex_col()
401            .h_full()
402            .w(px(if self.collapsed {
403                COLLAPSED_WIDTH
404            } else {
405                EXPANDED_WIDTH
406            }))
407            .gap(px(theme.space(Space::Md)))
408            .p(px(theme.space(Space::Sm)))
409            .bg(theme.colors.panel)
410            .children(self.header)
411            .child(body)
412            .children(self.footer)
413            .semantic_in(
414                cx,
415                NodeSpec::new(self.ident.semantic_id(), Role::List).expanded(!self.collapsed),
416            )
417    }
418}