From f23c582c166d03b99d7acf7ba6ce415a72d35f9f Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Fri, 30 Oct 2026 13:50:00 +0700 Subject: [PATCH] add list --- Cargo.lock | 11 ++ Cargo.toml | 7 +- crates/core/Cargo.toml | 2 - crates/core/src/scroll_area.rs | 1 + crates/core/tests/scroll_area.rs | 54 ++++++ crates/data/Cargo.toml | 18 ++ crates/data/src/lib.rs | 5 + crates/data/src/list.rs | 316 +++++++++++++++++++++++++++++++ crates/data/tests/list.rs | 140 ++++++++++++++ docs/PLAN.md | 22 ++- examples/gallery/Cargo.toml | 1 + examples/gallery/src/main.rs | 39 ++++ src/lib.rs | 13 +- 13 files changed, 607 insertions(+), 22 deletions(-) create mode 100644 crates/data/Cargo.toml create mode 100644 crates/data/src/lib.rs create mode 100644 crates/data/src/list.rs create mode 100644 crates/data/tests/list.rs diff --git a/Cargo.lock b/Cargo.lock index 883a8cb..a663520 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -1513,6 +1513,7 @@ version = "0.1.0" dependencies = [ "gpui", "gpui-native-core", + "gpui-native-data", "gpui-native-icons", "gpui-native-theme", "gpui_platform", @@ -1705,6 +1706,15 @@ dependencies = [ "unicode-segmentation", ] +[[package]] +name = "gpui-native-data" +version = "0.1.0" +dependencies = [ + "gpui", + "gpui-native-icons", + "gpui-native-theme", +] + [[package]] name = "gpui-native-icons" version = "0.1.0" @@ -1718,6 +1728,7 @@ version = "0.1.0" dependencies = [ "gpui", "gpui-native-core", + "gpui-native-data", "gpui-native-icons", "gpui-native-theme", ] diff --git a/Cargo.toml b/Cargo.toml index d638a07..f207cc7 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -1,5 +1,5 @@ [workspace] -members = ["crates/theme", "crates/icons", "crates/core", "examples/gallery"] +members = ["crates/theme", "crates/icons", "crates/core", "crates/data", "examples/gallery"] [package] name = "gpui-native-kit" @@ -8,12 +8,14 @@ edition = "2024" description = "GPUI components that feel native to the operating system" [features] -default = ["core"] +default = ["core", "data"] core = ["dep:gpui-native-core"] +data = ["dep:gpui-native-data"] [dependencies] gpui = { workspace = true } gpui-native-core = { path = "crates/core", optional = true } +gpui-native-data = { path = "crates/data", optional = true } gpui-native-icons = { path = "crates/icons" } gpui-native-theme = { path = "crates/theme" } @@ -23,5 +25,6 @@ gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "ec86047d "font-kit", ] } gpui-native-core = { path = "crates/core" } +gpui-native-data = { path = "crates/data" } gpui-native-icons = { path = "crates/icons" } gpui-native-theme = { path = "crates/theme" } diff --git a/crates/core/Cargo.toml b/crates/core/Cargo.toml index 397243d..da5c06e 100644 --- a/crates/core/Cargo.toml +++ b/crates/core/Cargo.toml @@ -31,7 +31,6 @@ full = [ "tooltip", "menu", "dialog", - "sheet", "toast", ] button = [] @@ -49,5 +48,4 @@ scroll_area = [] tooltip = [] menu = ["scroll_area"] dialog = [] -sheet = [] toast = [] diff --git a/crates/core/src/scroll_area.rs b/crates/core/src/scroll_area.rs index 2d7894f..2d227bd 100644 --- a/crates/core/src/scroll_area.rs +++ b/crates/core/src/scroll_area.rs @@ -122,6 +122,7 @@ impl RenderOnce for ScrollArea { pane.extend(self.children); let mut wrapper = div().relative(); + wrapper.style().refine(&self.style); wrapper.extend(std::iter::once(pane.into_any_element())); if let Some(handle) = self.handle { diff --git a/crates/core/tests/scroll_area.rs b/crates/core/tests/scroll_area.rs index 6eb8d3b..af5476b 100644 --- a/crates/core/tests/scroll_area.rs +++ b/crates/core/tests/scroll_area.rs @@ -94,3 +94,57 @@ fn the_thumb_paints_when_the_content_overflows() { // 30 rows × 20px in a 200px viewport: the thumb has something to report. assert!(handle.max_offset().y > px(0.)); } + +/// The gallery's exact shell: a window-filling root, a window-filling scroll +/// area over padded flex-column content that towers past the viewport. +struct GalleryShell { + scroll: ScrollHandle, +} + +impl Render for GalleryShell { + fn render(&mut self, _window: &mut Window, _cx: &mut Context) -> impl IntoElement { + div().size_full().child( + ScrollArea::new("gallery-scroll") + .handle(self.scroll.clone()) + .size_full() + .child( + div() + .p_6() + .flex() + .flex_col() + .gap_5() + .children((0..12).map(|section| { + div() + .h(px(220.)) + .child(SharedString::from(format!("section {section}"))) + })), + ), + ) + } +} + +#[test] +fn a_gallery_style_window_fill_scroll_area_scrolls_tall_flex_content() { + let mut cx = TestAppContext::single(); + cx.update(Theme::init); + let handle = ScrollHandle::new(); + let handle_for_view = handle.clone(); + let window = cx.add_window(move |_, _| GalleryShell { + scroll: handle_for_view, + }); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + + // 12 sections × 220px + padding towers past the window; a wheel event + // over the middle of the viewport must scroll the root area. + visual.simulate_event(ScrollEvent { + position: point(px(300.), px(300.)), + delta: ScrollDelta::Pixels(point(px(0.), px(-60.))), + modifiers: Modifiers::default(), + ..Default::default() + }); + assert_eq!(handle.offset().y, px(-60.)); + assert!(handle.max_offset().y > px(1000.)); +} diff --git a/crates/data/Cargo.toml b/crates/data/Cargo.toml new file mode 100644 index 0000000..dfd166a --- /dev/null +++ b/crates/data/Cargo.toml @@ -0,0 +1,18 @@ +[package] +name = "gpui-native-data" +version = "0.1.0" +edition = "2024" +description = "Data-heavy GPUI components: lists, trees, tabs, sidebars — one cargo feature each" + +[dependencies] +gpui = { workspace = true } +gpui-native-icons = { workspace = true } +gpui-native-theme = { workspace = true } + +[dev-dependencies] +gpui = { workspace = true, features = ["test-support"] } + +[features] +default = ["full"] +full = ["list"] +list = [] diff --git a/crates/data/src/lib.rs b/crates/data/src/lib.rs new file mode 100644 index 0000000..9f4b81b --- /dev/null +++ b/crates/data/src/lib.rs @@ -0,0 +1,5 @@ +//! Data-heavy components: lists, trees, tabs, sidebars — one cargo feature +//! each, painted from the theme global like `gpui-native-core`. + +#[cfg(feature = "list")] +pub mod list; diff --git a/crates/data/src/list.rs b/crates/data/src/list.rs new file mode 100644 index 0000000..0ebc5e4 --- /dev/null +++ b/crates/data/src/list.rs @@ -0,0 +1,316 @@ +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)>; +type ItemsFn = Box Vec>; + +/// One row of a [`List`]. +pub struct ListItem { + label: SharedString, + icon: Option, + detail: Option, + disabled: bool, +} + +impl ListItem { + /// Creates a row with this label. + pub fn new(label: L) -> Self + where + L: Into, + { + Self { + label: label.into(), + icon: None, + detail: None, + disabled: false, + } + } + + /// Shows an icon leading the label. + pub fn icon(mut self, role: IconRole) -> Self { + self.icon = Some(role); + self + } + + /// Shows muted trailing text. + pub fn detail(mut self, detail: D) -> Self + where + D: Into, + { + self.detail = Some(detail.into()); + self + } + + /// Renders the row dimmed; clicks and keyboard skip it. + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } +} + +/// A single-selection list. +#[derive(IntoElement)] +pub struct List { + id: ElementId, + selected: Option, + a11y_label: Option, + on_change: Option, + items: Option, +} + +impl List { + /// Creates a list with nothing selected. + pub fn new(id: I) -> Self + where + I: Into, + { + Self { + id: id.into(), + selected: None, + a11y_label: None, + on_change: 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 + } + + /// Sets the 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 ListChrome { + foreground: Hsla, + muted: Hsla, + muted_foreground: Hsla, + primary: Hsla, + primary_foreground: Hsla, + radius_sm: Pixels, + icon_size: Pixels, + text_size: Pixels, + /// The row count, for the rows' set-size a11y attributes. + count: usize, +} + +impl RenderOnce for List { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + 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(); + + let focus_handle = window.with_element_namespace(self.id.clone(), |window| { + window.use_keyed_state("list-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 = { + let theme = cx.theme(); + ListChrome { + 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: items.len(), + } + }; + + let rows: Vec = items + .iter() + .enumerate() + .map(|(index, item)| { + render_row( + index, + item, + 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(rows) + } +} + +fn render_row( + index: usize, + item: &ListItem, + selected: bool, + chrome: &ListChrome, + on_change: &Option, + focus: &FocusHandle, +) -> AnyElement { + let label_color = row_color(item, selected, chrome); + let disabled = item.disabled; + + div() + .id(("list-item", 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 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); + } + } + }) + }) + .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()), + ) + .when_some(item.detail.clone(), |row, detail| { + row.child( + div() + .flex_none() + .text_sm() + .text_color(if selected { + chrome.primary_foreground + } else { + chrome.muted_foreground + }) + .child(detail), + ) + }) + .into_any_element() +} + +/// The row's label/icon color. +fn row_color(item: &ListItem, selected: bool, chrome: &ListChrome) -> 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/list.rs b/crates/data/tests/list.rs new file mode 100644 index 0000000..701cde4 --- /dev/null +++ b/crates/data/tests/list.rs @@ -0,0 +1,140 @@ +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::list::{List, ListItem}; +use gpui_native_theme::Theme; + +struct Harness { + selected: Option, + changes: Rc>>>, +} + +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( + List::new("test-list") + .selected(self.selected) + .on_change(move |next, _, cx| { + changes.borrow_mut().push(next); + view.update(cx, |harness, cx| { + harness.selected = next; + cx.notify(); + }); + }) + .items(|_, _| { + vec![ + ListItem::new("One"), + ListItem::new("Two"), + ListItem::new("Unavailable").disabled(true), + ListItem::new("Three"), + ] + }), + ) + } +} + +type Changes = Rc>>>; + +type ListWindow = ( + TestAppContext, + VisualTestContext, + WindowHandle, + Changes, +); + +/// Rows paint at the top-left with no outer padding: one row per ~34 px band +/// (16 px text line + 2×4 px padding + 2 px gap), so clicks at `(50, 16/48/80/112)` +/// land on One / Two / the disabled Unavailable / Three. +fn list_window(selected: Option) -> ListWindow { + 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, + changes: harness_changes, + }); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + (cx, visual, window, 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_list_the_keyboard() { + let (_cx, mut visual, _window, changes) = list_window(None); + + click(&mut visual, point(px(50.), px(16.))); + assert_eq!(*changes.borrow(), vec![Some(0)]); + + // The click focused the list, so the arrows navigate from here. + visual.simulate_keystrokes("down"); + draw(&mut visual); + assert_eq!(*changes.borrow(), vec![Some(0), Some(1)]); +} + +#[test] +fn clicking_a_disabled_row_reports_nothing() { + let (_cx, mut visual, _window, changes) = list_window(None); + + click(&mut visual, point(px(50.), px(80.))); + draw(&mut visual); + assert_eq!(*changes.borrow(), Vec::>::new()); +} + +#[test] +fn keyboard_navigation_skips_disabled_rows_and_never_wraps() { + let (_cx, mut visual, _window, changes) = list_window(None); + + click(&mut visual, point(px(50.), px(16.))); + visual.simulate_keystrokes("down"); // over the disabled row → Two + visual.simulate_keystrokes("down"); // over the disabled row → Three + visual.simulate_keystrokes("down"); // last selectable; no wrap + draw(&mut visual); + assert_eq!(*changes.borrow(), vec![Some(0), Some(1), Some(3)]); + + visual.simulate_keystrokes("home"); + visual.simulate_keystrokes("end"); + visual.simulate_keystrokes("up"); // over the disabled row → Two + draw(&mut visual); + assert_eq!( + *changes.borrow(), + vec![Some(0), Some(1), Some(3), Some(0), Some(3), Some(1)] + ); +} + +#[test] +fn arrow_down_with_no_selection_picks_the_first_selectable_row() { + let (mut cx, mut visual, window, changes) = list_window(Some(1)); + + // Focus the list, then clear the selection app-side. + click(&mut visual, point(px(50.), px(48.))); + window + .update(&mut cx, |harness, _, cx| { + harness.selected = None; + cx.notify(); + }) + .unwrap(); + draw(&mut visual); + + visual.simulate_keystrokes("down"); + draw(&mut visual); + assert_eq!(*changes.borrow(), vec![Some(1), Some(0)]); +} diff --git a/docs/PLAN.md b/docs/PLAN.md index 42a2752..1d92b20 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -49,12 +49,13 @@ gpui-native-kit/ # repo root = workspace root │ ├── icons/ # gpui-native-icons: system icons + bundled fallback │ ├── core/ # gpui-native-core: components every app needs │ │ # (one cargo feature per component) +│ ├── data/ # gpui-native-data: List, Tree, Tabs, Sidebar (one cargo feature each) │ └── editor/ # gpui-native-editor: created when wave 2 needs it (D12) └── examples/ └── gallery/ # showcase; a page per component + theme/icon inspector ``` -Advanced crates (`data` for Table/Tree/List, `charts`, `pickers`) are created on demand when their wave starts — not speculative scaffolding. +Advanced crates (`data` for Tree/List/Tabs/Sidebar, `charts`, `pickers`) are created on demand when their wave starts — not speculative scaffolding. ```mermaid graph TD @@ -79,7 +80,7 @@ One-way, no cycles. Nothing depends on `gpui-component`, `gpui-kit`, `gpuikit`, ### 4.3 Component design - **One element at a time, review-gated (D20).** Each component is one unit: API sketch → implementation → tests → gallery page → review. The next unit starts only after the previous one is approved. No batch waves. -- **API shape follows gpui-kit `base` conventions** (adapted, simplified where we can): +- **API shape follows bezel and gpuikit conventions** where they fit — both are read as references only, never ported (D15) — always simplified: - Controlled components: the owner passes the current value, the callback reports the **next** value, the owner updates state and calls `cx.notify()`. `Checkbox::new(id).checked(v).on_change(move |next, window, cx| …)`. - One activation path: pointer, Enter, and Space all fire the same `on_click` (gpui synthesizes keyboard clicks for tracked focus elements). - Semantic states as builders — `disabled(bool)`, `selected(bool)`, `checked(bool)`, `pressed(bool)` — resolved **after** the builder chain and painted from theme state colors; disabled resolves last and always wins. @@ -88,7 +89,7 @@ One-way, no cycles. Nothing depends on `gpui-component`, `gpui-kit`, `gpuikit`, - **Value-like controls** are `#[derive(IntoElement)]` + `RenderOnce`; stateful ones are `Entity` the application owns. - **No style parameters** (bezel's model): a component reads the theme global at paint time and takes zero color/size arguments. Overrides are gpui style modifiers chained by the caller. - **Behavior is inline.** Activation, focus, keyboard, dragging, and a11y are written per component with gpui primitives (`Interactivity`, `FocusHandle` + `tab_stop(true)`, `actions!`, a11y builders). A helper is promoted to shared `common` only when a third component needs it — no pre-built behavior layer. -- **Deliberate divergence from base:** our `Link` opens `href` through the platform by default (`cx.open_url`) — native feel is the product — with `on_click` as an override for apps that route internally. Base never opens URLs; we do unless told otherwise. +- **Deliberate divergence from the references:** our `Link` opens `href` through the platform by default (`cx.open_url`) — native feel is the product — with `on_click` as an override for apps that route internally. Neither bezel nor gpuikit opens URLs; we do unless told otherwise. - Each component has a named native reference per platform (NSButton / WinUI Button / Adwaita / Breeze) recorded on its gallery page; "native feel" is reviewed against those references. - Naming seams per AGENTS.md: `is_`/`has_` readers, `with_` builders, `set_` mutation, `on_` callbacks, `State`/`Delegate` suffixes, stable domain-derived `ElementId`s. - **Motion is inline and per component (D21).** Each component animates with gpui's own primitives (`with_animation`, `with_spring`, the gpui easings, `with_max_fps`) directly in its own file, a few lines at most, using theme durations where they're shared (`Motion.overlay`). No shared motion module, no ghost panels, no hover-wash plumbing; overlay exit animations and hover washes are dropped until a genuinely simple approach exists. @@ -211,12 +212,12 @@ Each row is one review unit: build it, show it in the gallery, review, then move | 8 | core | Progress | Determinate + indeterminate. | | 9 | core | Avatar | Initials; image slot later. | | 10 | core | Input (single-line) then multiline, NumberInput | String buffer + `unicode-segmentation` word movement; the field owns its own selection. May seed `crates/editor`. Single-line finishing pass: §6.2. | -| 11.5 | core | ScrollArea + Scrollbar | ✅ Landed (2026-10-29). Inserted before the remaining overlays — Menus, Lists and Tables all scroll. gpui owns the mechanics (`overflow_*_scroll` + caller-held `ScrollHandle`); the component paints an overlay thumb (`scrollbar_thumb` token, `Scrollbar` metrics) with drag and geometry unit-tested. Wheel input is gpui's; the area stops wheel propagation so nested areas don't co-scroll. Horizontal thumb deferred; auto-hide on idle deferred | +| 11.5 | core | ScrollArea + Scrollbar | ✅ Landed (2026-10-29). Inserted before the remaining overlays — Menus, Lists and Tables all scroll. gpui owns the mechanics (`overflow_*_scroll` + caller-held `ScrollHandle`); the component paints an overlay thumb (`scrollbar_thumb` token, `Scrollbar` metrics) with drag and geometry unit-tested. Wheel input is gpui's; the area stops wheel propagation so nested areas don't co-scroll. Horizontal thumb deferred; auto-hide on idle deferred. ✅ Fixed (2026-10-30): the caller's size refinement now lands on the outer wrapper too — previously it applied to the pane alone, so a percentage-sized pane resolved against an auto-height wrapper and a window-filling scroll area (the gallery's root shell) never actually scrolled; a headless repro of the gallery shell locks the behavior | | 12 | core | Menu | ✅ Landed (2026-10-29). Controlled `open`/`on_change` + `trigger`/`items` (MenuItem: label, icon, shortcut hint, disabled, destructive, checked, separator). Popover shell patterns duplicated per D16 (promote at Dialog); panel = surface + overlay radius + popover elevation, role Menu; claims focus while open; keyboard nav — arrows/home/end over selectable items (wrapping, skipping separators + disabled), enter/space activates (closes menu before the action), escape dismisses; hover selects, click activates; trigger clicks toggle (popover-owned); selection resets on each open; long menus scroll via the ScrollArea once a height is styled. Deferred: submenus, type-ahead, scroll-selected-item-into-view, real shortcut handling | | 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, Table, Tree, Tabs, Sidebar | Created on demand, same gate. | +| 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 | pickers or data | Calendar/DatePicker, TimeField, ColorPicker | Decide at unit time. | | editor | editor (new) | Text/code editor, syntax highlighting (D12) | Same gate. | @@ -270,7 +271,7 @@ Pure tests for resolve/overlay chain, payload parsing (fixtures), icon resolutio - All code in this repo is original: Apache-2.0 (D6). - gpui: Apache-2.0 dependency, nothing copied. -- gpui-kit `base`: API-design reference (D20); where a small utility is adapted rather than reinvented, keep Apache-2.0 compliance — note the origin in a file-header comment or NOTICE. +- bezel and gpuikit: API-design references (D20); inspiration only, no code copied. gpui-kit is no longer consulted (D20). If that ever changes, respect their licenses. - Lucide icons: ISC — bundled subset with attribution in NOTICE. - SF Symbols and Segoe Fluent Icons: used through the operating system at runtime only; never extracted or redistributed. - native-theme, gpui-omarchy, gpuikit, bezel: inspiration only; no code copied. If that ever changes, respect their licenses. @@ -302,12 +303,12 @@ Pure tests for resolve/overlay chain, payload parsing (fixtures), icon resolutio | D12 | Editor deferred; `crates/editor` is created when wave 2's multiline editing or highlighting demands it | | D13 | Accent shades hybrid: stated platform ramps where available (WinUI, KDE), OKLCH derivation elsewhere | | D14 | Custom-theme live reload via `notify` | -| D15 | No wholesale porting, no UI-toolkit dependencies. Component APIs follow gpui-kit `base` conventions where they fit (D20), always simplified — never mirrored 1:1; small utilities may be adapted with attribution | +| D15 | No wholesale porting, no UI-toolkit dependencies. Component APIs follow bezel and gpuikit conventions where they fit (D20), always simplified — never mirrored 1:1; small utilities may be adapted with attribution | | D16 | No separate behavior crate; behavior lives in each component; shared helpers promoted at the third use | | D17 | `UPSTREAM.md` removed; provenance = §3 pins + git history | | D18 | Crate split for consumption: `core` (every-app components) → `editor`, `data`, … (advanced) → thin `gpui-native-kit` facade; one cargo feature per component, additive, module-gated | | D19 | System icons via semantic `IconRole`s with per-platform resolvers (SF Symbols / Segoe Fluent Icons / freedesktop) and a bundled Lucide fallback; platform glyph sets are used at runtime only, never bundled | -| D20 | Components land one element at a time behind a review gate: API sketch → implementation → tests → gallery page → review → next. gpui-kit `base` is the API-design reference for these units; its conventions (controlled state, one activation path, semantic states, a11y labels) and selected utilities are adopted, simplified | +| D20 | Components land one element at a time behind a review gate: API sketch → implementation → tests → gallery page → review → next. bezel and gpuikit are the API-design references for these units; conventions like controlled state, one activation path, semantic states, and a11y labels are adopted, simplified. gpui-kit is no longer consulted — judged over-engineered for our needs | | D21 | Motion is per component and inline: each component uses gpui's own animation/spring primitives (`with_animation`, `with_spring`, the gpui easings, `with_max_fps`) directly in its file; the theme owns only the durations components actually share (today `Motion.overlay`), growing per the role-minimal rule. No shared motion module, no exit ghosts, no hover-wash machinery — exits and washes stay out until a genuinely simple approach exists. Reduce motion is gpui-side: forward the OS flag once in `reapply` and every primitive snaps. Supersedes the reverted 2026-10-30 framework attempt (which proved the substrate but over-built the plumbing) | Dropped from rev 3: D2, D5, D9 (port-era decisions); the review gate returns in a lighter form as D20. D10 (no icon bundling) is superseded by D19. @@ -319,7 +320,8 @@ Dropped from rev 3: D2, D5, D9 (port-era decisions); the review gate returns in 3. ~~Phase C controls~~ ✅ — units 1–9 (Button → Avatar), each through the review gate; sizing API (§6.1). 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). **Next: Phase F** — `crates/data` (List, Table, Tree, Tabs, Sidebar) + Windows/GNOME/KDE/Omarchy theme sources (a source may state `Motion.overlay` when it needs platform timing, §5.5) + Segoe Fluent/freedesktop icon resolvers, with side-by-side gallery screenshots per platform. NumberInput/multiline return as deferred units when needed. -7. Then — Phase G: pickers, polish, → 0.1; start `crates/editor` if wanted. +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. +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/Cargo.toml b/examples/gallery/Cargo.toml index fd4a5e8..7645085 100644 --- a/examples/gallery/Cargo.toml +++ b/examples/gallery/Cargo.toml @@ -8,6 +8,7 @@ description = "Live preview of every component against the resolved system theme gpui = { workspace = true } gpui_platform = { workspace = true } gpui-native-core = { workspace = true } +gpui-native-data = { workspace = true } gpui-native-icons = { workspace = true } gpui-native-theme = { workspace = true } diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index 4fa7bfa..65b0717 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -23,6 +23,7 @@ use gpui_native_core::switch::Switch; 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_icons::{IconAssets, IconRole, icon}; use gpui_native_theme::{ActiveTheme as _, Theme}; use gpui_platform::application; @@ -41,6 +42,7 @@ struct Gallery { alert_open: bool, toasts: Entity, scroll: gpui::ScrollHandle, + list_selected: Option, } impl Render for Gallery { @@ -339,6 +341,42 @@ impl Render for Gallery { }) }), ) + .child( + v_flex() + .gap_2() + .child(section_label( + "list · native refs — NSTableView (list), WinUI ListView, Adwaita ListBox, Breeze", + colors, + )) + .child({ + let gallery = gallery.clone(); + List::new("gallery-list") + .selected(self.list_selected) + .on_change(move |selected, _, cx| { + gallery.update(cx, |gallery, cx| { + gallery.list_selected = selected; + cx.notify(); + }); + }) + .items(|_, _| { + vec![ + ListItem::new("Documents").icon(IconRole::Folder), + ListItem::new("Pictures") + .icon(IconRole::Folder) + .detail("24 items"), + ListItem::new("Music").icon(IconRole::Folder), + ListItem::new("Archive").disabled(true), + ListItem::new("Trash").detail("emptied weekly"), + ] + }) + }) + .child( + div() + .text_xs() + .text_color(colors.muted_foreground) + .child(format!("selected: {:?}", self.list_selected)), + ), + ) .child( v_flex() .gap_2() @@ -957,6 +995,7 @@ fn main() { alert_open: false, toasts, scroll: gpui::ScrollHandle::new(), + list_selected: None, } }) }, diff --git a/src/lib.rs b/src/lib.rs index f43ce60..b809b25 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -1,24 +1,21 @@ //! GPUI components that feel native to the operating system. //! -//! A thin facade: depend on `gpui-native-core` (and later siblings) directly -//! for a lean build, or on this crate for everything behind features. - pub use gpui_native_icons as icons; pub use gpui_native_theme as theme; #[cfg(feature = "core")] -#[allow(unused_imports)] // empty until the first component lands +#[allow(unused_imports)] pub use gpui_native_core::*; +#[cfg(feature = "data")] +pub use gpui_native_data as data; + use gpui::App; -/// Initializes the framework: installs the theme global from the current -/// system appearance and, with the `core` feature, the component globals -/// and keybindings. Call once before opening windows. +/// Initializes the framework. pub fn init(cx: &mut App) { #[cfg(feature = "core")] gpui_native_core::init(cx); - #[cfg(not(feature = "core"))] theme::Theme::init(cx); }