add list
This commit is contained in:
Generated
+11
@@ -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
@@ -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" }
|
||||||
|
|||||||
@@ -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 = []
|
||||||
|
|||||||
@@ -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 {
|
||||||
|
|||||||
@@ -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.));
|
||||||
|
}
|
||||||
|
|||||||
@@ -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 = []
|
||||||
@@ -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;
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
@@ -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.
|
||||||
|
|||||||
@@ -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 }
|
||||||
|
|
||||||
|
|||||||
@@ -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
@@ -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);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user