//! The sidebar: a controlled list with section headers — the Finder //! sidebar's model of rows grouped under non-selectable labels. use std::rc::Rc; use gpui::prelude::FluentBuilder as _; use gpui::{ AnyElement, App, ElementId, FocusHandle, InteractiveElement as _, IntoElement, KeyDownEvent, ParentElement as _, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, Styled as _, Window, div, }; use gpui_native_icons::{IconRole, icon}; use gpui_native_theme::ActiveTheme as _; use gpui_native_utils::{nav_key, paint_selectable_row, selectable_label_color}; /// The change reporter, fired with the next selected row by pointer and keys. type ChangeHandler = Rc, &mut Window, &mut App)>; type EntriesFn = Box Vec>; /// One entry of a [`Sidebar`], a section header or a selectable row. #[derive(Clone)] pub enum SidebarEntry { /// A non-selectable section label. Header { label: SharedString }, /// A selectable row. Row { label: SharedString, icon: Option, detail: Option, disabled: bool, }, } impl SidebarEntry { /// Creates a section header. pub fn header(label: L) -> Self where L: Into, { Self::Header { label: label.into(), } } /// Creates a selectable row. pub fn row(label: L) -> Self where L: Into, { Self::Row { label: label.into(), icon: None, detail: None, disabled: false, } } /// Shows an icon leading the label. pub fn icon(mut self, role: IconRole) -> Self { if let Self::Row { icon, .. } = &mut self { *icon = Some(role); } self } /// Shows muted trailing text. pub fn detail(mut self, detail: D) -> Self where D: Into, { if let Self::Row { detail: row_detail, .. } = &mut self { *row_detail = Some(detail.into()); } self } /// Renders the row dimmed; clicks and keyboard skip it. pub fn disabled(mut self, disabled: bool) -> Self { if let Self::Row { disabled: row_disabled, .. } = &mut self { *row_disabled = disabled; } self } } /// A sidebar. #[derive(IntoElement)] pub struct Sidebar { id: ElementId, selected: Option, a11y_label: Option, on_change: Option, entries: Option, } impl Sidebar { /// Creates a sidebar with nothing selected. pub fn new(id: I) -> Self where I: Into, { Self { id: id.into(), selected: None, a11y_label: None, on_change: None, entries: None, } } /// Sets the current selection. pub fn selected(mut self, selected: Option) -> Self { self.selected = selected; self } /// Reports the next selected row, from clicks and keys alike. pub fn on_change(mut self, handler: F) -> Self where F: Fn(Option, &mut Window, &mut App) + 'static, { self.on_change = Some(Rc::new(handler)); self } /// Sets the entries, built fresh each render. pub fn entries(mut self, entries: F) -> Self where F: Fn(&mut Window, &mut App) -> Vec + 'static, { self.entries = Some(Box::new(entries)); self } /// Overrides the accessibility label, which defaults to the id. pub fn accessibility_label(mut self, label: L) -> Self where L: Into, { self.a11y_label = Some(label.into()); self } } impl RenderOnce for Sidebar { fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { let entries = match self.entries { Some(build) => build(window, cx), None => Vec::new(), }; // Headers are never selectable; only enabled rows are. let selectable: Vec = entries .iter() .enumerate() .filter(|(_, entry)| match entry { SidebarEntry::Row { disabled, .. } => !disabled, SidebarEntry::Header { .. } => false, }) .map(|(index, _)| index) .collect(); let focus = gpui_native_utils::focus_handle(&self.id, window, cx); let theme = cx.theme(); let label = self .a11y_label .clone() .unwrap_or_else(|| SharedString::from(self.id.to_string())); // Entry paint reads the theme through this borrow. let children: Vec = entries .iter() .enumerate() .map(|(index, entry)| { render_entry( cx, entry, index, entries.len(), self.selected == Some(index), &self.on_change, &focus, ) }) .collect(); div() .id(self.id) .flex() .flex_col() .gap_0p5() .text_size(theme.sizes.medium.text_size) .track_focus(&focus) .focusable() .tab_stop(true) .role(Role::List) .aria_label(label) .on_key_down({ let selected = self.selected; let on_change = self.on_change.clone(); move |event: &KeyDownEvent, window, cx| { if nav_key(&selectable, selected, &event.keystroke.key, |next| { if let Some(on_change) = &on_change { on_change(next, window, cx); } }) { cx.stop_propagation(); } } }) .children(children) } } fn render_entry( cx: &App, entry: &SidebarEntry, index: usize, count: usize, selected: bool, on_change: &Option, focus: &FocusHandle, ) -> AnyElement { let theme = cx.theme(); match entry { SidebarEntry::Header { label } => div() .px_2() .pt_2() .pb_1() .text_sm() .font_weight(gpui::FontWeight::MEDIUM) .text_color(theme.colors.muted_foreground) .child(label.clone()) .into_any_element(), SidebarEntry::Row { label, icon: row_icon, detail, disabled: row_disabled, } => { let label_color = selectable_label_color(cx, selected, *row_disabled); let mut row = div() .id(("sidebar-row", index)) .flex() .items_center() .gap_2() .px_2() .py_1() .rounded(theme.radius.sm) .role(Role::ListItem) .aria_selected(selected) .aria_position_in_set(index + 1) .aria_size_of_set(count) .map(|row| paint_selectable_row(cx, row, selected, *row_disabled)); if !*row_disabled { row = row.cursor_pointer().on_click({ let on_change = on_change.clone(); let focus = focus.clone(); move |_, window, cx| { window.focus(&focus, cx); if let Some(on_change) = &on_change { on_change(Some(index), window, cx); } } }); } row = row.when_some(*row_icon, |row, role| { row.child( icon(role) .size(theme.sizes.medium.icon_size) .color(label_color), ) }); row = row.child(div().flex_1().text_color(label_color).child(label.clone())); if let Some(detail) = detail { row = row.child( div() .flex_none() .text_sm() .text_color(if selected { theme.colors.primary_foreground } else { theme.colors.muted_foreground }) .child(detail.clone()), ); } row.into_any_element() } } }