This commit is contained in:
2026-10-30 13:50:00 +07:00
parent 51137b1467
commit f23c582c16
13 changed files with 607 additions and 22 deletions
Generated
+11
View File
@@ -1513,6 +1513,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"gpui", "gpui",
"gpui-native-core", "gpui-native-core",
"gpui-native-data",
"gpui-native-icons", "gpui-native-icons",
"gpui-native-theme", "gpui-native-theme",
"gpui_platform", "gpui_platform",
@@ -1705,6 +1706,15 @@ dependencies = [
"unicode-segmentation", "unicode-segmentation",
] ]
[[package]]
name = "gpui-native-data"
version = "0.1.0"
dependencies = [
"gpui",
"gpui-native-icons",
"gpui-native-theme",
]
[[package]] [[package]]
name = "gpui-native-icons" name = "gpui-native-icons"
version = "0.1.0" version = "0.1.0"
@@ -1718,6 +1728,7 @@ version = "0.1.0"
dependencies = [ dependencies = [
"gpui", "gpui",
"gpui-native-core", "gpui-native-core",
"gpui-native-data",
"gpui-native-icons", "gpui-native-icons",
"gpui-native-theme", "gpui-native-theme",
] ]
+5 -2
View File
@@ -1,5 +1,5 @@
[workspace] [workspace]
members = ["crates/theme", "crates/icons", "crates/core", "examples/gallery"] members = ["crates/theme", "crates/icons", "crates/core", "crates/data", "examples/gallery"]
[package] [package]
name = "gpui-native-kit" name = "gpui-native-kit"
@@ -8,12 +8,14 @@ edition = "2024"
description = "GPUI components that feel native to the operating system" description = "GPUI components that feel native to the operating system"
[features] [features]
default = ["core"] default = ["core", "data"]
core = ["dep:gpui-native-core"] core = ["dep:gpui-native-core"]
data = ["dep:gpui-native-data"]
[dependencies] [dependencies]
gpui = { workspace = true } gpui = { workspace = true }
gpui-native-core = { path = "crates/core", optional = 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-icons = { path = "crates/icons" }
gpui-native-theme = { path = "crates/theme" } gpui-native-theme = { path = "crates/theme" }
@@ -23,5 +25,6 @@ gpui_platform = { git = "https://github.com/zed-industries/zed", rev = "ec86047d
"font-kit", "font-kit",
] } ] }
gpui-native-core = { path = "crates/core" } gpui-native-core = { path = "crates/core" }
gpui-native-data = { path = "crates/data" }
gpui-native-icons = { path = "crates/icons" } gpui-native-icons = { path = "crates/icons" }
gpui-native-theme = { path = "crates/theme" } gpui-native-theme = { path = "crates/theme" }
-2
View File
@@ -31,7 +31,6 @@ full = [
"tooltip", "tooltip",
"menu", "menu",
"dialog", "dialog",
"sheet",
"toast", "toast",
] ]
button = [] button = []
@@ -49,5 +48,4 @@ scroll_area = []
tooltip = [] tooltip = []
menu = ["scroll_area"] menu = ["scroll_area"]
dialog = [] dialog = []
sheet = []
toast = [] toast = []
+1
View File
@@ -122,6 +122,7 @@ impl RenderOnce for ScrollArea {
pane.extend(self.children); pane.extend(self.children);
let mut wrapper = div().relative(); let mut wrapper = div().relative();
wrapper.style().refine(&self.style);
wrapper.extend(std::iter::once(pane.into_any_element())); wrapper.extend(std::iter::once(pane.into_any_element()));
if let Some(handle) = self.handle { if let Some(handle) = self.handle {
+54
View File
@@ -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. // 30 rows × 20px in a 200px viewport: the thumb has something to report.
assert!(handle.max_offset().y > px(0.)); 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<Self>) -> 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.));
}
+18
View File
@@ -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 = []
+5
View File
@@ -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;
+316
View File
@@ -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<dyn Fn(Option<usize>, &mut Window, &mut App)>;
type ItemsFn = Box<dyn Fn(&mut Window, &mut App) -> Vec<ListItem>>;
/// One row of a [`List`].
pub struct ListItem {
label: SharedString,
icon: Option<IconRole>,
detail: Option<SharedString>,
disabled: bool,
}
impl ListItem {
/// Creates a row with this label.
pub fn new<L>(label: L) -> Self
where
L: Into<SharedString>,
{
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<D>(mut self, detail: D) -> Self
where
D: Into<SharedString>,
{
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<usize>,
a11y_label: Option<SharedString>,
on_change: Option<ChangeHandler>,
items: Option<ItemsFn>,
}
impl List {
/// Creates a list with nothing selected.
pub fn new<I>(id: I) -> Self
where
I: Into<ElementId>,
{
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<usize>) -> Self {
self.selected = selected;
self
}
/// Reports the next selection, from clicks and keys alike.
pub fn on_change<F>(mut self, handler: F) -> Self
where
F: Fn(Option<usize>, &mut Window, &mut App) + 'static,
{
self.on_change = Some(Rc::new(handler));
self
}
/// Sets the rows, built fresh each render.
pub fn items<F>(mut self, items: F) -> Self
where
F: Fn(&mut Window, &mut App) -> Vec<ListItem> + 'static,
{
self.items = Some(Box::new(items));
self
}
/// Overrides the accessibility label, which defaults to the id.
pub fn accessibility_label<L>(mut self, label: L) -> Self
where
L: Into<SharedString>,
{
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<usize> = 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<AnyElement> = 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<ChangeHandler>,
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<usize>, forward: bool) -> Option<usize> {
if forward {
selectable
.iter()
.copied()
.find(|index| Some(*index) > selected)
} else {
selectable
.iter()
.copied()
.rev()
.find(|index| Some(*index) < selected)
}
}
+140
View File
@@ -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<usize>,
changes: Rc<RefCell<Vec<Option<usize>>>>,
}
impl Render for Harness {
fn render(&mut self, _window: &mut Window, cx: &mut Context<Self>) -> 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<RefCell<Vec<Option<usize>>>>;
type ListWindow = (
TestAppContext,
VisualTestContext,
WindowHandle<Harness>,
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<usize>) -> 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<gpui::Pixels>) {
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::<Option<usize>>::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)]);
}
+12 -10
View File
@@ -49,12 +49,13 @@ gpui-native-kit/ # repo root = workspace root
│ ├── icons/ # gpui-native-icons: system icons + bundled fallback │ ├── icons/ # gpui-native-icons: system icons + bundled fallback
│ ├── core/ # gpui-native-core: components every app needs │ ├── core/ # gpui-native-core: components every app needs
│ │ # (one cargo feature per component) │ │ # (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) │ └── editor/ # gpui-native-editor: created when wave 2 needs it (D12)
└── examples/ └── examples/
└── gallery/ # showcase; a page per component + theme/icon inspector └── 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 ```mermaid
graph TD graph TD
@@ -79,7 +80,7 @@ One-way, no cycles. Nothing depends on `gpui-component`, `gpui-kit`, `gpuikit`,
### 4.3 Component design ### 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. - **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| …)`. - 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). - 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. - 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<XxxState>` the application owns. - **Value-like controls** are `#[derive(IntoElement)]` + `RenderOnce`; stateful ones are `Entity<XxxState>` 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. - **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. - **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. - 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. - 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. - **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. | | 8 | core | Progress | Determinate + indeterminate. |
| 9 | core | Avatar | Initials; image slot later. | | 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. | | 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 | | 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 | 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 | | 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) | | 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<usize>` + `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. | | then | pickers or data | Calendar/DatePicker, TimeField, ColorPicker | Decide at unit time. |
| editor | editor (new) | Text/code editor, syntax highlighting (D12) | Same gate. | | 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). - All code in this repo is original: Apache-2.0 (D6).
- gpui: Apache-2.0 dependency, nothing copied. - 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. - 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. - 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. - 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 | | 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 | | D13 | Accent shades hybrid: stated platform ramps where available (WinUI, KDE), OKLCH derivation elsewhere |
| D14 | Custom-theme live reload via `notify` | | 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 | | 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 | | 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 | | 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 | | 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) | | 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. 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). 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. 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. 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. 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. Then — Phase G: pickers, polish, → 0.1; start `crates/editor` if wanted. 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. Batched polish candidates between units: scrollbar auto-hide + horizontal thumb, `Undo`/`Redo` menu events.
+1
View File
@@ -8,6 +8,7 @@ description = "Live preview of every component against the resolved system theme
gpui = { workspace = true } gpui = { workspace = true }
gpui_platform = { workspace = true } gpui_platform = { workspace = true }
gpui-native-core = { workspace = true } gpui-native-core = { workspace = true }
gpui-native-data = { workspace = true }
gpui-native-icons = { workspace = true } gpui-native-icons = { workspace = true }
gpui-native-theme = { workspace = true } gpui-native-theme = { workspace = true }
+39
View File
@@ -23,6 +23,7 @@ use gpui_native_core::switch::Switch;
use gpui_native_core::toast::{Toast, ToastSeverity, Toasts, ToastsView}; use gpui_native_core::toast::{Toast, ToastSeverity, Toasts, ToastsView};
use gpui_native_core::toggle::Toggle; use gpui_native_core::toggle::Toggle;
use gpui_native_core::tooltip::Tooltip; use gpui_native_core::tooltip::Tooltip;
use gpui_native_data::list::{List, ListItem};
use gpui_native_icons::{IconAssets, IconRole, icon}; use gpui_native_icons::{IconAssets, IconRole, icon};
use gpui_native_theme::{ActiveTheme as _, Theme}; use gpui_native_theme::{ActiveTheme as _, Theme};
use gpui_platform::application; use gpui_platform::application;
@@ -41,6 +42,7 @@ struct Gallery {
alert_open: bool, alert_open: bool,
toasts: Entity<Toasts>, toasts: Entity<Toasts>,
scroll: gpui::ScrollHandle, scroll: gpui::ScrollHandle,
list_selected: Option<usize>,
} }
impl Render for Gallery { 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( .child(
v_flex() v_flex()
.gap_2() .gap_2()
@@ -957,6 +995,7 @@ fn main() {
alert_open: false, alert_open: false,
toasts, toasts,
scroll: gpui::ScrollHandle::new(), scroll: gpui::ScrollHandle::new(),
list_selected: None,
} }
}) })
}, },
+5 -8
View File
@@ -1,24 +1,21 @@
//! GPUI components that feel native to the operating system. //! 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_icons as icons;
pub use gpui_native_theme as theme; pub use gpui_native_theme as theme;
#[cfg(feature = "core")] #[cfg(feature = "core")]
#[allow(unused_imports)] // empty until the first component lands #[allow(unused_imports)]
pub use gpui_native_core::*; pub use gpui_native_core::*;
#[cfg(feature = "data")]
pub use gpui_native_data as data;
use gpui::App; use gpui::App;
/// Initializes the framework: installs the theme global from the current /// Initializes the framework.
/// system appearance and, with the `core` feature, the component globals
/// and keybindings. Call once before opening windows.
pub fn init(cx: &mut App) { pub fn init(cx: &mut App) {
#[cfg(feature = "core")] #[cfg(feature = "core")]
gpui_native_core::init(cx); gpui_native_core::init(cx);
#[cfg(not(feature = "core"))] #[cfg(not(feature = "core"))]
theme::Theme::init(cx); theme::Theme::init(cx);
} }