From b4a996e44c77315ad11e7902c1ee384ee3275e19 Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 30 Oct 2026 14:45:55 +0700 Subject: [PATCH] add sidebar --- crates/data/Cargo.toml | 3 +- crates/data/src/lib.rs | 3 + crates/data/src/sidebar.rs | 369 +++++++++++++++++++++++++++++++++++ crates/data/tests/sidebar.rs | 119 +++++++++++ docs/PLAN.md | 4 +- examples/gallery/src/main.rs | 35 ++++ 6 files changed, 530 insertions(+), 3 deletions(-) create mode 100644 crates/data/src/sidebar.rs create mode 100644 crates/data/tests/sidebar.rs diff --git a/crates/data/Cargo.toml b/crates/data/Cargo.toml index 593a13b..2e5aa7c 100644 --- a/crates/data/Cargo.toml +++ b/crates/data/Cargo.toml @@ -14,7 +14,8 @@ gpui = { workspace = true, features = ["test-support"] } [features] default = ["full"] -full = ["list", "tabs", "tree"] +full = ["list", "sidebar", "tabs", "tree"] list = [] +sidebar = [] tabs = [] tree = [] diff --git a/crates/data/src/lib.rs b/crates/data/src/lib.rs index 5f06ba9..3fa6278 100644 --- a/crates/data/src/lib.rs +++ b/crates/data/src/lib.rs @@ -4,6 +4,9 @@ #[cfg(feature = "list")] pub mod list; +#[cfg(feature = "sidebar")] +pub mod sidebar; + #[cfg(feature = "tabs")] pub mod tabs; diff --git a/crates/data/src/sidebar.rs b/crates/data/src/sidebar.rs new file mode 100644 index 0000000..0aa5779 --- /dev/null +++ b/crates/data/src/sidebar.rs @@ -0,0 +1,369 @@ +//! 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, Hsla, InteractiveElement as _, IntoElement, + KeyDownEvent, ParentElement as _, Pixels, RenderOnce, Role, SharedString, + StatefulInteractiveElement as _, Styled as _, Window, div, +}; +use gpui_native_icons::{IconRole, icon}; +use gpui_native_theme::ActiveTheme as _; + +/// 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 + } +} + +struct SidebarChrome { + foreground: Hsla, + muted: Hsla, + muted_foreground: Hsla, + primary: Hsla, + primary_foreground: Hsla, + radius_sm: Pixels, + icon_size: Pixels, + text_size: Pixels, + /// The entry count, for the rows' set-size a11y attributes. + count: usize, +} + +impl RenderOnce for Sidebar { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let chrome = { + let theme = cx.theme(); + SidebarChrome { + foreground: theme.colors.foreground, + muted: theme.colors.muted, + muted_foreground: theme.colors.muted_foreground, + primary: theme.ramps.primary.base, + primary_foreground: theme.colors.primary_foreground, + radius_sm: theme.radius.sm, + icon_size: theme.sizes.medium.icon_size, + text_size: theme.sizes.medium.text_size, + count: 0, + } + }; + + 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(); + + // Keyboard focus rides the container across frames. + let focus_handle = window.with_element_namespace(self.id.clone(), |window| { + window.use_keyed_state("sidebar-focus", cx, |_, cx| cx.focus_handle()) + }); + let focus = focus_handle.read(cx).clone(); + + let label = self + .a11y_label + .clone() + .unwrap_or_else(|| SharedString::from(self.id.to_string())); + + let chrome = SidebarChrome { + count: entries.len(), + ..chrome + }; + + let children: Vec = entries + .iter() + .enumerate() + .map(|(index, entry)| { + render_entry( + entry, + index, + self.selected == Some(index), + &chrome, + &self.on_change, + &focus, + ) + }) + .collect(); + + div() + .id(self.id) + .flex() + .flex_col() + .gap_0p5() + .text_size(chrome.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| { + let target = match event.keystroke.key.as_str() { + "down" => adjacent(&selectable, selected, true), + "up" => adjacent(&selectable, selected, false), + "home" => selectable.first().copied(), + "end" => selectable.last().copied(), + _ => return, + }; + if let Some(next) = target + && Some(next) != selected + && let Some(on_change) = &on_change + { + on_change(Some(next), window, cx); + } + cx.stop_propagation(); + } + }) + .children(children) + } +} + +fn render_entry( + entry: &SidebarEntry, + index: usize, + selected: bool, + chrome: &SidebarChrome, + on_change: &Option, + focus: &FocusHandle, +) -> AnyElement { + match entry { + SidebarEntry::Header { label } => div() + .px_2() + .pt_2() + .pb_1() + .text_sm() + .font_weight(gpui::FontWeight::MEDIUM) + .text_color(chrome.muted_foreground) + .child(label.clone()) + .into_any_element(), + SidebarEntry::Row { + label, + icon: row_icon, + detail, + disabled: row_disabled, + } => { + let label_color = if *row_disabled { + chrome.muted_foreground + } else if selected { + chrome.primary_foreground + } else { + chrome.foreground + }; + + let mut row = div() + .id(("sidebar-row", index)) + .flex() + .items_center() + .gap_2() + .px_2() + .py_1() + .rounded(chrome.radius_sm) + .role(Role::ListItem) + .aria_selected(selected) + .aria_position_in_set(index + 1) + .aria_size_of_set(chrome.count) + .map(|row| { + if *row_disabled { + row + } else if selected { + row.bg(chrome.primary) + } else { + row.hover(|row| row.bg(chrome.muted)) + } + }); + + 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(chrome.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 { + chrome.primary_foreground + } else { + chrome.muted_foreground + }) + .child(detail.clone()), + ); + } + + row.into_any_element() + } + } +} + +/// The next selectable row in the direction. +fn adjacent(selectable: &[usize], selected: Option, forward: bool) -> Option { + if forward { + selectable + .iter() + .copied() + .find(|index| Some(*index) > selected) + } else { + selectable + .iter() + .copied() + .rev() + .find(|index| Some(*index) < selected) + } +} diff --git a/crates/data/tests/sidebar.rs b/crates/data/tests/sidebar.rs new file mode 100644 index 0000000..1bf64a7 --- /dev/null +++ b/crates/data/tests/sidebar.rs @@ -0,0 +1,119 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + Context, IntoElement, Modifiers, MouseButton, ParentElement as _, Render, Styled as _, + TestAppContext, VisualTestContext, Window, div, point, px, +}; +use gpui_native_data::sidebar::{Sidebar, SidebarEntry}; +use gpui_native_theme::Theme; + +type Changes = Rc>>>; + +struct Harness { + selected: Option, + changes: Changes, +} + +impl Render for Harness { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let changes = self.changes.clone(); + let view = cx.entity(); + div().size_full().child( + Sidebar::new("test-sidebar") + .selected(self.selected) + .on_change(move |next, _, cx| { + changes.borrow_mut().push(next); + view.update(cx, |harness, cx| { + harness.selected = next; + cx.notify(); + }); + }) + .entries(|_, _| { + vec![ + SidebarEntry::header("Favorites"), // 0: never selectable + SidebarEntry::row("Documents"), // 1 + SidebarEntry::row("Locked").disabled(true), // 2 + SidebarEntry::header("Locations"), // 3: never selectable + SidebarEntry::row("Trash"), // 4 + ] + }), + ) + } +} + +/// Entries stack from the top-left: the header band (~32 px) then ~34 px row +/// bands, so clicks at `(50, 16)` hit the header, `(50, 48)` Documents, +/// `(50, 82)` Locked, `(50, 116)` the second header, `(50, 150)` Trash. +fn sidebar_window() -> (TestAppContext, VisualTestContext, Changes) { + let mut cx = TestAppContext::single(); + cx.update(Theme::init); + let changes = Rc::new(RefCell::new(Vec::new())); + let harness_changes = changes.clone(); + let window = cx.add_window(move |_, _| Harness { + selected: None, + changes: harness_changes, + }); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + (cx, visual, changes) +} + +fn draw(visual: &mut VisualTestContext) { + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); +} + +fn click(visual: &mut VisualTestContext, position: gpui::Point) { + visual.simulate_mouse_down(position, MouseButton::Left, Modifiers::default()); + visual.simulate_mouse_up(position, MouseButton::Left, Modifiers::default()); +} + +#[test] +fn clicking_a_row_selects_it_and_hands_the_sidebar_the_keyboard() { + let (_cx, mut visual, changes) = sidebar_window(); + + // The header band is not a row: clicking it reports nothing. + click(&mut visual, point(px(50.), px(16.))); + draw(&mut visual); + assert!(changes.borrow().is_empty()); + + click(&mut visual, point(px(50.), px(48.))); + assert_eq!(*changes.borrow(), vec![Some(1)]); + + // The click focused the sidebar; the arrows move from here. + visual.simulate_keystrokes("down"); + draw(&mut visual); + assert_eq!(*changes.borrow(), vec![Some(1), Some(4)]); +} + +#[test] +fn clicking_a_disabled_row_reports_nothing() { + let (_cx, mut visual, changes) = sidebar_window(); + + click(&mut visual, point(px(50.), px(82.))); + draw(&mut visual); + assert!(changes.borrow().is_empty()); +} + +#[test] +fn keyboard_navigation_skips_headers_disabled_rows_and_never_wraps() { + let (_cx, mut visual, changes) = sidebar_window(); + + click(&mut visual, point(px(50.), px(48.))); // Documents, focuses + visual.simulate_keystrokes("down"); // over Locked → Trash + visual.simulate_keystrokes("down"); // over the second header; no wrap + draw(&mut visual); + assert_eq!(*changes.borrow(), vec![Some(1), Some(4)]); + + visual.simulate_keystrokes("up"); // over Locked → Documents + visual.simulate_keystrokes("end"); // last selectable row + visual.simulate_keystrokes("home"); // first selectable row + draw(&mut visual); + assert_eq!( + *changes.borrow(), + vec![Some(1), Some(4), Some(1), Some(4), Some(1)] + ); +} diff --git a/docs/PLAN.md b/docs/PLAN.md index 74f361c..b0010ef 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -217,7 +217,7 @@ Each row is one review unit: build it, show it in the gallery, review, then move | 11 | core | Popover | ✅ Landed (2026-10-29). First overlay. Controlled `open` + `on_change` (Rc-shared handler); `trigger`/`content` element closures; panel = surface + `Radius.overlay` + `Elevations.popover` + border, role Dialog with label; `anchored()` + `deferred()` (window-overflow flip for free); enter fade via gpui `with_animation` at `Motion.overlay` (respects reduce-motion); claims keyboard focus while open; escape and click-outside dismiss (outside clicks swallowed). No scrim — native macOS popover model; the overlay token waits for Dialog. Known v1 gaps: no exit animation, trigger aria-expanded left to the app | | 11+ | core | Tooltip ✅ (2026-10-29), Dialog + AlertDialog ✅ (2026-10-29), Toast ✅ (2026-10-29) | One unit each, same gate. Tooltip rides gpui's own `.tooltip()` lifecycle (hover delay, anchoring, dismissal — all framework-owned); ours is the styled view (`surface` + border + `Radius.sm`, small text) plus a `Tooltip::text` closure and a Button passthrough. Dialog + AlertDialog (one component, `alert(true)` switches semantics): scrim (`colors.overlay`, occluding) + centered panel (surface, `Radius.overlay`, `Elevations.dialog`), role Dialog/AlertDialog, focus claimed while open; modal kind closes on escape + scrim click, alert kind is inert to both (the user answers through the actions); actions footer is caller-built elements closing through owner state. Toast: `Toasts` store entity (push/dismiss/clear + auto-dismiss timers on the GPUI executor, `Toast::DURATION` 5 s, zero = sticky) + `ToastsView` deferred stack (top-right, `Radius.overlay` + popover elevation, severity icon from the new success/warning ramps, close button, `on_dismiss` windowless handler). Toast landed the deferred `success`/`warning` tokens (§5.2). Sheet dropped from the roadmap | | 14 | core + theme | Motion (per component) | ✅ Landed in five passes (2026-10-30), replacing the reverted same-day framework attempt — no token tower, no `motion` module, no ghost panels; just gpui's own primitives used inline, one separately reviewed pass per component: (a) theme — `reapply` forwards `reduce_motion` via `cx.set_reduce_motion`, so gpui snaps every animation under the OS setting; (b) Switch — the thumb absolute-positions in the track and slides on `with_spring` (inline `SpringConfig::new(500, 35, 1)`, `Pixels` target, mounts at its target so the initial render never animates; velocity survives mid-flight retargets); (c) Toast — each toast slides up 8 px into its stack slot on `with_animation` at `Motion.overlay` with `ease_out_quint` (margin-driven, so only the entering toast moves; keyed per entry so the clock survives unrelated stack re-renders); (d) Dialog — the scrim enters with the panel (same timing and easing, its own `dialog-scrim-enter` animation id so the clocks start in the same frame); (e) Progress — the indeterminate sweep caps at 30 fps via `with_max_fps`. Overlay enter fades already existed from units 11/12. Exits and hover washes stay deferred — they were the complexity sink of the reverted attempt (§ deferred list) | -| then | data (new) | List ✅ (2026-10-30), Tree ✅ (2026-10-30), Tabs ✅ (2026-10-30), then Sidebar | Created on demand, same gate. Table dropped from the roadmap. List: controlled single-selection rows (`selected: Option` + `on_change`); click selects and focuses the list, arrows/home/end move selection skipping disabled rows and never wrapping (native list behavior), hover is paint-only, click focus rides a keyed `FocusHandle` namespaced by the list id; roles List/ListItem with `aria_selected` and position/size-of-set; internal scrolling left to the caller's ScrollArea; `sheet` feature dropped from core as dead config. Tree: the caller flattens its tree into depth-annotated `TreeItem`s (bezel's model — the component never walks a tree); the caller owns expansion and receives toggles via `on_disclosure(index)` (chevron click, or left/right on the selected row); same controlled selection and row paint as List; roles Tree/TreeItem with `aria_selected`/`aria_level`/`aria_expanded`/set-size; indent guides deferred. Tabs: a controlled strip only — `selected: usize` + `on_change`; the panel is the app's to render; active tab marked by an underline on the strip's hairline (bezel's model); arrows/left-right/home/end switch tabs with automatic activation, skipping disabled and never wrapping; re-selecting the active tab reports nothing; roles TabList/Tab with `aria_selected`; no wrap, no segmented-pill variant yet | +| then | data (new) | List ✅ (2026-10-30), Tree ✅ (2026-10-30), Tabs ✅ (2026-10-30), Sidebar ✅ (2026-10-30) | Created on demand, same gate. Table dropped from the roadmap. List: controlled single-selection rows (`selected: Option` + `on_change`); click selects and focuses the list, arrows/home/end move selection skipping disabled rows and never wrapping (native list behavior), hover is paint-only, click focus rides a keyed `FocusHandle` namespaced by the list id; roles List/ListItem with `aria_selected` and position/size-of-set; internal scrolling left to the caller's ScrollArea; `sheet` feature dropped from core as dead config. Tree: the caller flattens its tree into depth-annotated `TreeItem`s (bezel's model — the component never walks a tree); the caller owns expansion and receives toggles via `on_disclosure(index)` (chevron click, or left/right on the selected row); same controlled selection and row paint as List; roles Tree/TreeItem with `aria_selected`/`aria_level`/`aria_expanded`/set-size; indent guides deferred. Tabs: a controlled strip only — `selected: usize` + `on_change`; the panel is the app's to render; active tab marked by an underline on the strip's hairline (bezel's model); arrows/left-right/home/end switch tabs with automatic activation, skipping disabled and never wrapping; re-selecting the active tab reports nothing; roles TabList/Tab with `aria_selected`; no wrap, no segmented-pill variant yet. Sidebar: List with section headers — `SidebarEntry::header(label)` / `row(label).icon().detail().disabled()`; headers are never selectable, selection indices address the flat entry list; same selection/keyboard/paint rules as List; roles List/ListItem (a sidebar is a list to accesskit, like macOS AXList); selection stays filled when unfocused (native) | | then | pickers or data | Calendar/DatePicker, TimeField, ColorPicker | Decide at unit time. | | editor | editor (new) | Text/code editor, syntax highlighting (D12) | Same gate. | @@ -321,7 +321,7 @@ Dropped from rev 3: D2, D5, D9 (port-era decisions); the review gate returns in 4. ~~Phase D input~~ ✅ — single-line Input (unit 10) + the §6.2 completion pass (modifiers, undo/redo, read-only, actions + key context, caret blink, paint polish). NumberInput and multiline remain open as deferred units. 5. ~~Phase E overlays~~ ✅ — Popover (unit 11), ScrollArea (unit 11.5), Menu (unit 12), Tooltip + Dialog/AlertDialog + Toast (unit 11+ row; Sheet dropped). Phase gate held: escape/dismiss semantics and focus behavior per overlay. 6. ~~Unit 14 — motion, per component (§6 row 14, D21)~~ ✅ Landed in five passes (2026-10-30): (a) theme `reduce_motion` → `cx.set_reduce_motion` in `reapply`; (b) Switch thumb `with_spring`; (c) Toast 8 px slide-in; (d) Dialog scrim enters with the panel; (e) Progress 30 fps cap. Exits and hover washes stay deferred (§6 deferred list). -7. **Phase F, data first** — ~~`crates/data` List~~ ✅ landed (2026-10-30; opened the crate, dropped the dead `sheet` feature from core); ~~Tree~~ ✅ landed (2026-10-30); ~~Tabs~~ ✅ landed (2026-10-30). Next: **Sidebar**, same gate. Windows/GNOME/KDE/Omarchy theme sources and the Segoe Fluent/freedesktop icon resolvers slot in as verification environments allow — a source may state `Motion.overlay` when it needs platform timing (§5.5) — with side-by-side gallery screenshots per platform. NumberInput/multiline return as deferred units when needed. +7. ~~Phase F, data first~~ ✅ data complete (2026-10-30) — `crates/data` opened with List, then Tree, Tabs, Sidebar (the dead `sheet` feature dropped from core along the way; Table dropped from the roadmap). Remaining Phase F: **Windows/GNOME/KDE/Omarchy theme sources** and the **Segoe Fluent/freedesktop icon resolvers**, slotted in as verification environments allow — a source may state `Motion.overlay` when it needs platform timing (§5.5) — with side-by-side gallery screenshots per platform. NumberInput/multiline return as deferred units when needed. 8. Then — Phase G: pickers, polish, → 0.1; start `crates/editor` if wanted. Batched polish candidates between units: scrollbar auto-hide + horizontal thumb, `Undo`/`Redo` menu events. diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 912bf2d..5e5ff91 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -24,6 +24,7 @@ use gpui_native_core::toast::{Toast, ToastSeverity, Toasts, ToastsView}; use gpui_native_core::toggle::Toggle; use gpui_native_core::tooltip::Tooltip; use gpui_native_data::list::{List, ListItem}; +use gpui_native_data::sidebar::{Sidebar, SidebarEntry}; use gpui_native_data::tabs::{Tab, Tabs}; use gpui_native_data::tree::{Tree, TreeItem}; use gpui_native_icons::{IconAssets, IconRole, icon}; @@ -48,6 +49,7 @@ struct Gallery { tree_nodes: Vec, tree_selected: Option, tab_selected: usize, + sidebar_selected: Option, } /// A demo model for the tree section: the app owns the real tree and its @@ -474,6 +476,38 @@ impl Render for Gallery { .items(move |_, _| items.clone()) }), ) + .child( + v_flex() + .gap_2() + .child(section_label( + "sidebar · native refs — NSOutlineView (source list), WinUI NavigationView, Adwaita Sidebar", + colors, + )) + .child(div().w(px(280.)).child({ + let gallery = gallery.clone(); + Sidebar::new("gallery-sidebar") + .selected(self.sidebar_selected) + .on_change(move |selected, _, cx| { + gallery.update(cx, |gallery, cx| { + gallery.sidebar_selected = selected; + cx.notify(); + }); + }) + .entries(|_, _| { + vec![ + SidebarEntry::header("Favorites"), + SidebarEntry::row("Documents").icon(IconRole::Folder), + SidebarEntry::row("Pictures") + .icon(IconRole::Folder) + .detail("24"), + SidebarEntry::row("Archive").disabled(true), + SidebarEntry::header("Locations"), + SidebarEntry::row("iCloud").icon(IconRole::Folder), + SidebarEntry::row("Trash"), + ] + }) + })), + ) .child( v_flex() .gap_2() @@ -1133,6 +1167,7 @@ fn main() { list_selected: None, tree_selected: None, tab_selected: 0, + sidebar_selected: None, tree_nodes: vec![ GalleryNode { label: "Workspace".into(),