From 8be5ac91ec2bbcefdd9652353dbc05b25c16026c Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 30 Oct 2026 14:12:09 +0700 Subject: [PATCH] add tree --- crates/data/Cargo.toml | 3 +- crates/data/src/lib.rs | 3 + crates/data/src/tree.rs | 439 +++++++++++++++++++++++++++++++++++ crates/data/tests/tree.rs | 191 +++++++++++++++ docs/PLAN.md | 4 +- examples/gallery/src/main.rs | 153 ++++++++++++ 6 files changed, 790 insertions(+), 3 deletions(-) create mode 100644 crates/data/src/tree.rs create mode 100644 crates/data/tests/tree.rs diff --git a/crates/data/Cargo.toml b/crates/data/Cargo.toml index dfd166a..0f4667f 100644 --- a/crates/data/Cargo.toml +++ b/crates/data/Cargo.toml @@ -14,5 +14,6 @@ gpui = { workspace = true, features = ["test-support"] } [features] default = ["full"] -full = ["list"] +full = ["list", "tree"] list = [] +tree = [] diff --git a/crates/data/src/lib.rs b/crates/data/src/lib.rs index 9f4b81b..e5f7b13 100644 --- a/crates/data/src/lib.rs +++ b/crates/data/src/lib.rs @@ -3,3 +3,6 @@ #[cfg(feature = "list")] pub mod list; + +#[cfg(feature = "tree")] +pub mod tree; diff --git a/crates/data/src/tree.rs b/crates/data/src/tree.rs new file mode 100644 index 0000000..e248022 --- /dev/null +++ b/crates/data/src/tree.rs @@ -0,0 +1,439 @@ +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 selection by pointer and keys. +type ChangeHandler = Rc, &mut Window, &mut App)>; +/// The disclosure reporter, fired with the toggled row's index. +type DisclosureHandler = Rc; +type ItemsFn = Box Vec>; + +/// Tree item. +#[derive(Clone)] +pub struct TreeItem { + label: SharedString, + icon: Option, + depth: usize, + expandable: bool, + expanded: bool, + disabled: bool, +} + +impl TreeItem { + /// Creates a leaf row with this label at the root depth. + pub fn new(label: L) -> Self + where + L: Into, + { + Self { + label: label.into(), + icon: None, + depth: 0, + expandable: false, + expanded: false, + disabled: false, + } + } + + /// Shows an icon leading the label. + pub fn icon(mut self, role: IconRole) -> Self { + self.icon = Some(role); + self + } + + /// Nests the row one level per count. + pub fn depth(mut self, depth: usize) -> Self { + self.depth = depth; + self + } + + /// Marks the row as having children. + pub fn expandable(mut self, expandable: bool) -> Self { + self.expandable = expandable; + self + } + + /// Sets the row's disclosure state. + pub fn expanded(mut self, expanded: bool) -> Self { + self.expanded = expanded; + self + } + + /// Renders the row dimmed; clicks and keyboard skip it. + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } +} + +/// A tree. +#[derive(IntoElement)] +pub struct Tree { + id: ElementId, + selected: Option, + a11y_label: Option, + on_change: Option, + on_disclosure: Option, + items: Option, +} + +impl Tree { + /// Creates a tree with nothing selected. + pub fn new(id: I) -> Self + where + I: Into, + { + Self { + id: id.into(), + selected: None, + a11y_label: None, + on_change: None, + on_disclosure: None, + items: None, + } + } + + /// Sets the current selection. + pub fn selected(mut self, selected: Option) -> Self { + self.selected = selected; + self + } + + /// Reports the next selection, 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 + } + + /// Reports the row whose disclosure the user toggled. + pub fn on_disclosure(mut self, handler: F) -> Self + where + F: Fn(usize, &mut Window, &mut App) + 'static, + { + self.on_disclosure = Some(Rc::new(handler)); + self + } + + /// Sets the visible rows, built fresh each render. + pub fn items(mut self, items: F) -> Self + where + F: Fn(&mut Window, &mut App) -> Vec + 'static, + { + self.items = Some(Box::new(items)); + 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 TreeChrome { + foreground: Hsla, + muted: Hsla, + muted_foreground: Hsla, + primary: Hsla, + primary_foreground: Hsla, + radius_sm: Pixels, + icon_size: Pixels, + padding_x: Pixels, + text_size: Pixels, + /// The row count, for the rows' set-size a11y attributes. + count: usize, +} + +impl RenderOnce for Tree { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let chrome = { + let theme = cx.theme(); + TreeChrome { + 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, + padding_x: theme.sizes.medium.padding_x, + text_size: theme.sizes.medium.text_size, + count: 0, + } + }; + + let items = match self.items { + Some(build) => build(window, cx), + None => Vec::new(), + }; + + let selectable: Vec = items + .iter() + .enumerate() + .filter(|(_, item)| !item.disabled) + .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("tree-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 = TreeChrome { + count: items.len(), + ..chrome + }; + + let rows: Vec = items + .iter() + .enumerate() + .map(|(index, item)| { + render_row( + item, + index, + self.selected == Some(index), + &chrome, + &self.on_change, + &self.on_disclosure, + &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::Tree) + .aria_label(label) + .on_key_down({ + let selected = self.selected; + let on_change = self.on_change.clone(); + let on_disclosure = self.on_disclosure.clone(); + move |event: &KeyDownEvent, window, cx| { + match event.keystroke.key.as_str() { + "down" => { + if let Some(next) = adjacent(&selectable, selected, true) + && Some(next) != selected + && let Some(on_change) = &on_change + { + on_change(Some(next), window, cx); + } + } + "up" => { + if let Some(next) = adjacent(&selectable, selected, false) + && Some(next) != selected + && let Some(on_change) = &on_change + { + on_change(Some(next), window, cx); + } + } + "home" => { + if let Some(first) = selectable.first() + && selected != Some(*first) + && let Some(on_change) = &on_change + { + on_change(Some(*first), window, cx); + } + } + "end" => { + if let Some(last) = selectable.last() + && selected != Some(*last) + && let Some(on_change) = &on_change + { + on_change(Some(*last), window, cx); + } + } + "left" | "right" => { + let expanding = event.keystroke.key == "right"; + if let Some(index) = selected + && let Some(item) = items.get(index) + && item.expandable + && !item.disabled + && item.expanded != expanding + && let Some(on_disclosure) = &on_disclosure + { + on_disclosure(index, window, cx); + } + } + _ => return, + } + cx.stop_propagation(); + } + }) + .children(rows) + } +} + +fn render_row( + item: &TreeItem, + index: usize, + selected: bool, + chrome: &TreeChrome, + on_change: &Option, + on_disclosure: &Option, + focus: &FocusHandle, +) -> AnyElement { + let label_color = row_color(item, selected, chrome); + let disabled = item.disabled; + + // One indent step per depth level, on the theme's horizontal padding. + let indent = chrome.padding_x * (item.depth as f32 + 1.0); + + div() + .id(("tree-item", index)) + .flex() + .items_center() + .gap_2() + .pl(indent) + .pr_2() + .py_1() + .rounded(chrome.radius_sm) + .role(Role::TreeItem) + .aria_selected(selected) + .aria_level(item.depth + 1) + .aria_size_of_set(chrome.count) + .when(item.expandable, |row| row.aria_expanded(item.expanded)) + .map(|row| { + if disabled { + row + } else if selected { + row.bg(chrome.primary) + } else { + row.hover(|row| row.bg(chrome.muted)) + } + }) + .when(!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); + } + } + }) + }) + .child(render_disclosure( + item, + index, + disabled, + selected, + chrome, + on_disclosure, + )) + .when_some(item.icon, |row, role| { + row.child(icon(role).size(chrome.icon_size).color(label_color)) + }) + .child( + div() + .flex_1() + .text_color(label_color) + .child(item.label.clone()), + ) + .into_any_element() +} + +/// The disclosure slot. +fn render_disclosure( + item: &TreeItem, + index: usize, + disabled: bool, + selected: bool, + chrome: &TreeChrome, + on_disclosure: &Option, +) -> AnyElement { + let glyph_color = if disabled { + chrome.muted_foreground + } else if selected { + chrome.primary_foreground + } else { + chrome.muted_foreground + }; + + let cell = div() + .flex_none() + .size(chrome.icon_size) + .flex() + .items_center() + .justify_center() + .child( + icon(if item.expanded { + IconRole::ChevronDown + } else { + IconRole::ChevronRight + }) + .size(chrome.icon_size * 0.75) + .color(glyph_color), + ); + + if item.expandable && !disabled { + cell.id(("tree-disclosure", index)) + .cursor_pointer() + .on_click({ + let on_disclosure = on_disclosure.clone(); + move |_, window, cx| { + if let Some(on_disclosure) = &on_disclosure { + on_disclosure(index, window, cx); + } + // The toggle is not a selection: keep it off the row. + cx.stop_propagation(); + } + }) + .into_any_element() + } else { + cell.into_any_element() + } +} + +/// The row's label/icon color. +fn row_color(item: &TreeItem, selected: bool, chrome: &TreeChrome) -> Hsla { + if item.disabled { + chrome.muted_foreground + } else if selected { + chrome.primary_foreground + } else { + chrome.foreground + } +} + +/// 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/tree.rs b/crates/data/tests/tree.rs new file mode 100644 index 0000000..463b612 --- /dev/null +++ b/crates/data/tests/tree.rs @@ -0,0 +1,191 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + Context, IntoElement, Modifiers, MouseButton, ParentElement as _, Render, Styled as _, + TestAppContext, VisualTestContext, Window, WindowHandle, div, point, px, +}; +use gpui_native_data::tree::{Tree, TreeItem}; +use gpui_native_theme::Theme; + +type Changes = Rc>>>; +type Disclosures = Rc>>; + +struct Harness { + selected: Option, + changes: Changes, + disclosures: Disclosures, +} + +impl Render for Harness { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let changes = self.changes.clone(); + let disclosures = self.disclosures.clone(); + let view = cx.entity(); + let change_view = view.clone(); + div().size_full().child( + Tree::new("test-tree") + .selected(self.selected) + .on_change(move |next, _, cx| { + changes.borrow_mut().push(next); + change_view.update(cx, |harness, cx| { + harness.selected = next; + cx.notify(); + }); + }) + .on_disclosure(move |index, _, cx| { + disclosures.borrow_mut().push(index); + view.update(cx, |_, cx| cx.notify()); + }) + .items(|_, _| { + vec![ + TreeItem::new("Root") + .depth(0) + .expandable(true) + .expanded(true), + TreeItem::new("Child").depth(1), + TreeItem::new("Collapsed").depth(0).expandable(true), + TreeItem::new("Untouchable").depth(1).disabled(true), + TreeItem::new("Last").depth(0), + ] + }), + ) + } +} + +/// Rows paint at the top-left, one per ~34 px band (same rhythm as the +/// list), so clicks at `(50, 16/48/80/112/144)` land on Root / Child / +/// Collapsed / the disabled Untouchable / Last. Each expandable row leads +/// with a 16 px disclosure slot at its indent — a click at `(24, y)` hits +/// row 0's chevron. +fn tree_window() -> ( + TestAppContext, + VisualTestContext, + WindowHandle, + Changes, + Disclosures, +) { + let mut cx = TestAppContext::single(); + cx.update(Theme::init); + let changes = Rc::new(RefCell::new(Vec::new())); + let disclosures = Rc::new(RefCell::new(Vec::new())); + let harness_changes = changes.clone(); + let harness_disclosures = disclosures.clone(); + let window = cx.add_window(move |_, _| Harness { + selected: None, + changes: harness_changes, + disclosures: harness_disclosures, + }); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + (cx, visual, window, changes, disclosures) +} + +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_tree_the_keyboard() { + let (_cx, mut visual, _window, changes, disclosures) = tree_window(); + + click(&mut visual, point(px(50.), px(16.))); + assert_eq!(*changes.borrow(), vec![Some(0)]); + assert!(disclosures.borrow().is_empty()); + + // The click focused the tree; arrows move within the visible rows. + visual.simulate_keystrokes("down"); + draw(&mut visual); + assert_eq!(*changes.borrow(), vec![Some(0), Some(1)]); +} + +#[test] +fn clicking_the_disclosure_toggles_without_selecting() { + let (_cx, mut visual, _window, changes, disclosures) = tree_window(); + + // Row 0's chevron, not its label. + click(&mut visual, point(px(24.), px(16.))); + draw(&mut visual); + assert_eq!(*disclosures.borrow(), vec![0]); + assert!( + changes.borrow().is_empty(), + "a disclosure is not a selection" + ); +} + +#[test] +fn arrow_right_expands_and_left_collapses_the_selected_row() { + let (_cx, mut visual, _window, changes, disclosures) = tree_window(); + + click(&mut visual, point(px(50.), px(16.))); // Root: expanded expandable + visual.simulate_keystrokes("left"); // collapse → reports + visual.simulate_keystrokes("right"); // still collapsed per the caller → nothing yet + visual.simulate_keystrokes("down"); // Child: a leaf → arrows are inert + visual.simulate_keystrokes("right"); + visual.simulate_keystrokes("left"); + draw(&mut visual); + assert_eq!(*disclosures.borrow(), vec![0]); + assert_eq!( + *changes.borrow(), + vec![Some(0), Some(1)], + "disclosure keys never change the selection" + ); + + // Select the collapsed expandable row and expand it. + click(&mut visual, point(px(50.), px(80.))); + visual.simulate_keystrokes("right"); + draw(&mut visual); + assert_eq!(*disclosures.borrow(), vec![0, 2]); +} + +#[test] +fn keyboard_navigation_skips_disabled_rows_and_never_wraps() { + let (_cx, mut visual, _window, changes, _disclosures) = tree_window(); + + click(&mut visual, point(px(50.), px(16.))); + visual.simulate_keystrokes("down"); // Two + visual.simulate_keystrokes("down"); // Collapsed — selectable, not disabled + visual.simulate_keystrokes("down"); // over the disabled row → Last + visual.simulate_keystrokes("down"); // last selectable; no wrap + draw(&mut visual); + assert_eq!(*changes.borrow(), vec![Some(0), Some(1), Some(2), Some(4)]); + + visual.simulate_keystrokes("home"); + visual.simulate_keystrokes("end"); + visual.simulate_keystrokes("up"); // over the disabled row + draw(&mut visual); + assert_eq!( + *changes.borrow(), + vec![ + Some(0), + Some(1), + Some(2), + Some(4), + Some(0), + Some(4), + Some(2) + ] + ); +} + +#[test] +fn disclosure_keys_skip_disabled_and_unselected_rows() { + let (_cx, mut visual, _window, changes, disclosures) = tree_window(); + + // Focus through the first row, then park the selection on the leaf. + click(&mut visual, point(px(50.), px(16.))); + visual.simulate_keystrokes("down"); // Child (leaf), focused tree + visual.simulate_keystrokes("left"); // leaf: no disclosure + visual.simulate_keystrokes("right"); // leaf: no disclosure + draw(&mut visual); + assert!(disclosures.borrow().is_empty()); + assert_eq!(*changes.borrow(), vec![Some(0), Some(1)]); +} diff --git a/docs/PLAN.md b/docs/PLAN.md index 1d92b20..343fc50 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), then Tree → Tabs → 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 | +| then | data (new) | List ✅ (2026-10-30), Tree ✅ (2026-10-30), then Tabs → 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 | | 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. **Next: Phase F, data first** — ~~`crates/data` List~~ ✅ landed (2026-10-30; opened the crate, dropped the dead `sheet` feature from core). Then Tree → Tabs → 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** — ~~`crates/data` List~~ ✅ landed (2026-10-30; opened the crate, dropped the dead `sheet` feature from core); ~~Tree~~ ✅ landed (2026-10-30). Next: **Tabs** → 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. 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 65b0717..da5fa85 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::tree::{Tree, TreeItem}; use gpui_native_icons::{IconAssets, IconRole, icon}; use gpui_native_theme::{ActiveTheme as _, Theme}; use gpui_platform::application; @@ -43,6 +44,66 @@ struct Gallery { toasts: Entity, scroll: gpui::ScrollHandle, list_selected: Option, + tree_nodes: Vec, + tree_selected: Option, +} + +/// A demo model for the tree section: the app owns the real tree and its +/// expansion set, and flattens the visible nodes for the component. +#[derive(Clone)] +struct GalleryNode { + label: SharedString, + icon: Option, + expanded: bool, + children: Vec, +} + +fn flatten_tree( + nodes: &[GalleryNode], + depth: usize, + prefix: &[usize], + items: &mut Vec, + paths: &mut Vec>, +) { + for (index, node) in nodes.iter().enumerate() { + let mut path = prefix.to_vec(); + path.push(index); + + let mut item = TreeItem::new(node.label.clone()) + .depth(depth) + .expandable(!node.children.is_empty()) + .expanded(node.expanded); + if let Some(role) = node.icon { + item = item.icon(role); + } + items.push(item); + paths.push(path.clone()); + + if node.expanded { + flatten_tree(&node.children, depth + 1, &path, items, paths); + } + } +} + +fn toggle_tree_path(nodes: &mut [GalleryNode], path: &[usize]) { + let Some(index) = path.first() else { return }; + let Some(node) = nodes.get_mut(*index) else { + return; + }; + if path.len() == 1 { + node.expanded = !node.expanded; + } else { + toggle_tree_path(&mut node.children, &path[1..]); + } +} + +/// The visible-row paths of the current tree state, matching the flat index +/// the tree component reports. +fn tree_paths(nodes: &[GalleryNode]) -> Vec> { + let mut items = Vec::new(); + let mut paths = Vec::new(); + flatten_tree(nodes, 0, &[], &mut items, &mut paths); + paths } impl Render for Gallery { @@ -377,6 +438,40 @@ impl Render for Gallery { .child(format!("selected: {:?}", self.list_selected)), ), ) + .child( + v_flex() + .gap_2() + .child(section_label( + "tree · native refs — NSOutlineView, WinUI TreeView, Adwaita expander rows, Breeze", + colors, + )) + .child({ + let gallery = gallery.clone(); + let disclosure_gallery = gallery.clone(); + let mut items = Vec::new(); + let mut paths = Vec::new(); + flatten_tree(&self.tree_nodes, 0, &[], &mut items, &mut paths); + + Tree::new("gallery-tree") + .selected(self.tree_selected) + .on_change(move |selected, _, cx| { + gallery.update(cx, |gallery, cx| { + gallery.tree_selected = selected; + cx.notify(); + }); + }) + .on_disclosure(move |index, _, cx| { + disclosure_gallery.update(cx, |gallery, cx| { + let paths = tree_paths(&gallery.tree_nodes); + if let Some(path) = paths.get(index) { + toggle_tree_path(&mut gallery.tree_nodes, path); + } + cx.notify(); + }); + }) + .items(move |_, _| items.clone()) + }), + ) .child( v_flex() .gap_2() @@ -996,6 +1091,64 @@ fn main() { toasts, scroll: gpui::ScrollHandle::new(), list_selected: None, + tree_selected: None, + tree_nodes: vec![ + GalleryNode { + label: "Workspace".into(), + icon: Some(IconRole::Folder), + expanded: true, + children: vec![GalleryNode { + label: "gpui-native-kit".into(), + icon: Some(IconRole::Folder), + expanded: true, + children: vec![ + GalleryNode { + label: "crates".into(), + icon: Some(IconRole::Folder), + expanded: true, + children: vec![ + GalleryNode { + label: "theme".into(), + icon: None, + expanded: false, + children: Vec::new(), + }, + GalleryNode { + label: "core".into(), + icon: None, + expanded: false, + children: Vec::new(), + }, + GalleryNode { + label: "data".into(), + icon: None, + expanded: false, + children: Vec::new(), + }, + ], + }, + GalleryNode { + label: "docs".into(), + icon: Some(IconRole::Folder), + expanded: false, + children: Vec::new(), + }, + GalleryNode { + label: "Cargo.toml".into(), + icon: None, + expanded: false, + children: Vec::new(), + }, + ], + }], + }, + GalleryNode { + label: "Archive.zip".into(), + icon: None, + expanded: false, + children: Vec::new(), + }, + ], } }) },