diff --git a/crates/data/Cargo.toml b/crates/data/Cargo.toml index 0f4667f..593a13b 100644 --- a/crates/data/Cargo.toml +++ b/crates/data/Cargo.toml @@ -14,6 +14,7 @@ gpui = { workspace = true, features = ["test-support"] } [features] default = ["full"] -full = ["list", "tree"] +full = ["list", "tabs", "tree"] list = [] +tabs = [] tree = [] diff --git a/crates/data/src/lib.rs b/crates/data/src/lib.rs index e5f7b13..5f06ba9 100644 --- a/crates/data/src/lib.rs +++ b/crates/data/src/lib.rs @@ -4,5 +4,8 @@ #[cfg(feature = "list")] pub mod list; +#[cfg(feature = "tabs")] +pub mod tabs; + #[cfg(feature = "tree")] pub mod tree; diff --git a/crates/data/src/tabs.rs b/crates/data/src/tabs.rs new file mode 100644 index 0000000..cd1d78a --- /dev/null +++ b/crates/data/src/tabs.rs @@ -0,0 +1,299 @@ +use std::rc::Rc; + +use gpui::prelude::FluentBuilder as _; +use gpui::{ + AnyElement, App, ElementId, Hsla, InteractiveElement as _, IntoElement, KeyDownEvent, + ParentElement as _, Pixels, RenderOnce, Role, SharedString, StatefulInteractiveElement as _, + Styled as _, Window, div, +}; +use gpui_native_icons::{IconRole, icon}; +use gpui_native_theme::ActiveTheme as _; + +/// The change reporter, fired with the next selected tab by pointer and keys. +type ChangeHandler = Rc; +type TabsFn = Box Vec>; + +/// One tab of a [`Tabs`] strip. +pub struct Tab { + label: SharedString, + icon: Option, + disabled: bool, +} + +impl Tab { + /// Creates a tab with this label. + pub fn new(label: L) -> Self + where + L: Into, + { + Self { + label: label.into(), + icon: None, + disabled: false, + } + } + + /// Shows an icon leading the label. + pub fn icon(mut self, role: IconRole) -> Self { + self.icon = Some(role); + self + } + + /// Renders the tab dimmed; clicks and keyboard skip it. + pub fn disabled(mut self, disabled: bool) -> Self { + self.disabled = disabled; + self + } +} + +/// A tab strip. +#[derive(IntoElement)] +pub struct Tabs { + id: ElementId, + selected: usize, + a11y_label: Option, + on_change: Option, + tabs: Option, +} + +impl Tabs { + /// Creates a strip selecting the first tab. + pub fn new(id: I) -> Self + where + I: Into, + { + Self { + id: id.into(), + selected: 0, + a11y_label: None, + on_change: None, + tabs: None, + } + } + + /// Sets the current selection. + pub fn selected(mut self, selected: usize) -> Self { + self.selected = selected; + self + } + + /// Reports the next selected tab, from clicks and keys alike. + pub fn on_change(mut self, handler: F) -> Self + where + F: Fn(usize, &mut Window, &mut App) + 'static, + { + self.on_change = Some(Rc::new(handler)); + self + } + + /// Sets the tabs, built fresh each render. + pub fn tabs(mut self, tabs: F) -> Self + where + F: Fn(&mut Window, &mut App) -> Vec + 'static, + { + self.tabs = Some(Box::new(tabs)); + self + } + + /// Overrides the accessibility label, which defaults to the id. + pub fn accessibility_label(mut self, label: L) -> Self + where + L: Into, + { + self.a11y_label = Some(label.into()); + self + } +} + +struct TabsChrome { + foreground: Hsla, + muted: Hsla, + muted_foreground: Hsla, + primary: Hsla, + border: Hsla, + radius_sm: Pixels, + icon_size: Pixels, + text_size: Pixels, + /// The strip's selected index; re-selecting it reports nothing. + selected: usize, +} + +impl RenderOnce for Tabs { + fn render(self, window: &mut Window, cx: &mut App) -> impl IntoElement { + let chrome = { + let theme = cx.theme(); + TabsChrome { + foreground: theme.colors.foreground, + muted: theme.colors.muted, + muted_foreground: theme.colors.muted_foreground, + primary: theme.ramps.primary.base, + border: theme.colors.border, + radius_sm: theme.radius.sm, + icon_size: theme.sizes.medium.icon_size, + text_size: theme.sizes.medium.text_size, + selected: self.selected, + } + }; + + let tabs = match self.tabs { + Some(build) => build(window, cx), + None => Vec::new(), + }; + + let selectable: Vec = tabs + .iter() + .enumerate() + .filter(|(_, tab)| !tab.disabled) + .map(|(index, _)| index) + .collect(); + + // Keyboard focus rides the strip across frames. + let focus_handle = window.with_element_namespace(self.id.clone(), |window| { + window.use_keyed_state("tabs-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 strip: Vec = tabs + .iter() + .enumerate() + .map(|(index, tab)| { + render_tab( + tab, + index, + self.selected == index, + &chrome, + &self.on_change, + &focus, + ) + }) + .collect(); + + div() + .id(self.id) + .flex() + .gap_1() + .border_b_1() + .border_color(chrome.border) + .text_size(chrome.text_size) + .track_focus(&focus) + .focusable() + .tab_stop(true) + .role(Role::TabList) + .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() { + "right" => adjacent(&selectable, selected, true), + "left" => adjacent(&selectable, selected, false), + "home" => selectable.first().copied(), + "end" => selectable.last().copied(), + _ => return, + }; + if let Some(next) = target + && next != selected + && let Some(on_change) = &on_change + { + on_change(next, window, cx); + } + cx.stop_propagation(); + } + }) + .children(strip) + } +} + +fn render_tab( + tab: &Tab, + index: usize, + selected: bool, + chrome: &TabsChrome, + on_change: &Option, + focus: &gpui::FocusHandle, +) -> AnyElement { + let label_color = if tab.disabled { + chrome.muted_foreground + } else if selected { + chrome.primary + } else { + chrome.foreground + }; + + let mut strip_tab = div() + .id(("tab", index)) + .flex() + .items_center() + .gap_1p5() + .px_2() + .py_1() + .rounded_tl(chrome.radius_sm) + .rounded_tr(chrome.radius_sm) + .role(Role::Tab) + .aria_selected(selected) + .text_color(label_color) + .when(selected, |strip_tab| { + // The underline sits on the strip's hairline, marking the active + // tab the way a native tab bar does. + strip_tab.border_b_2().border_color(chrome.primary) + }) + .when(!tab.disabled && !selected, |strip_tab| { + strip_tab.hover(|strip_tab| strip_tab.bg(chrome.muted)) + }) + .child( + div() + .flex_none() + .pb_0p5() + .when_some(tab.icon, |strip_tab, role| { + strip_tab.child(icon(role).size(chrome.icon_size * 0.75).color(label_color)) + }), + ) + .child(strip_tab_label(tab.label.clone(), selected, chrome)); + + if !tab.disabled { + let selected_index = chrome.selected; + strip_tab = strip_tab.cursor_pointer().on_click({ + let on_change = on_change.clone(); + let focus = focus.clone(); + move |_, window, cx| { + window.focus(&focus, cx); + if index != selected_index + && let Some(on_change) = &on_change + { + on_change(index, window, cx); + } + } + }); + } + + strip_tab.into_any_element() +} + +fn strip_tab_label(label: SharedString, selected: bool, chrome: &TabsChrome) -> AnyElement { + div() + .text_color(if selected { + chrome.primary + } else { + chrome.foreground + }) + .child(label) + .into_any_element() +} + +/// The next selectable tab in the direction. +fn adjacent(selectable: &[usize], selected: usize, forward: bool) -> Option { + if forward { + selectable.iter().copied().find(|index| *index > selected) + } else { + selectable + .iter() + .copied() + .rev() + .find(|index| *index < selected) + } +} diff --git a/crates/data/tests/tabs.rs b/crates/data/tests/tabs.rs new file mode 100644 index 0000000..e76971c --- /dev/null +++ b/crates/data/tests/tabs.rs @@ -0,0 +1,112 @@ +use std::{cell::RefCell, rc::Rc}; + +use gpui::{ + Context, IntoElement, Modifiers, MouseButton, ParentElement as _, Render, Styled as _, + TestAppContext, VisualTestContext, Window, div, point, px, +}; +use gpui_native_data::tabs::{Tab, Tabs}; +use gpui_native_theme::Theme; + +type Changes = Rc>>; + +struct Harness { + selected: usize, + changes: Changes, +} + +impl Render for Harness { + fn render(&mut self, _window: &mut Window, cx: &mut Context) -> impl IntoElement { + let changes = self.changes.clone(); + let view = cx.entity(); + div().size_full().child( + Tabs::new("test-tabs") + .selected(self.selected) + .on_change(move |next, _, cx| { + changes.borrow_mut().push(next); + view.update(cx, |harness, cx| { + harness.selected = next; + cx.notify(); + }); + }) + .tabs(|_, _| { + vec![ + Tab::new("One"), + Tab::new("Two"), + Tab::new("Off").disabled(true), + Tab::new("End"), + ] + }), + ) + } +} + +/// Same-length labels keep the strip's geometry predictable: each tab is +/// roughly 40 px wide with a 4 px gap, so clicks at `(20/62/104/146, 12)` +/// land on One / Two / the disabled Off / End. +fn tabs_window() -> (TestAppContext, VisualTestContext, Changes) { + let mut cx = TestAppContext::single(); + cx.update(Theme::init); + let changes = Rc::new(RefCell::new(Vec::new())); + let harness_changes = changes.clone(); + let window = cx.add_window(move |_, _| Harness { + selected: 0, + changes: harness_changes, + }); + let mut visual = VisualTestContext::from_window(window.into(), &cx); + visual.update(|window, cx| { + window.draw(cx).clear(cx); + }); + (cx, visual, changes) +} + +fn click(visual: &mut VisualTestContext, position: gpui::Point) { + visual.simulate_mouse_down(position, MouseButton::Left, Modifiers::default()); + visual.simulate_mouse_up(position, MouseButton::Left, Modifiers::default()); +} + +#[test] +fn clicking_a_tab_selects_it_and_hands_the_strip_the_keyboard() { + let (_cx, mut visual, changes) = tabs_window(); + + // Tab 0 is already selected; selecting it again reports nothing. + click(&mut visual, point(px(20.), px(12.))); + assert!(changes.borrow().is_empty()); + + // The click focused the strip, so the arrows switch from here. + visual.simulate_keystrokes("right"); + assert_eq!(*changes.borrow(), vec![1]); +} + +#[test] +fn clicking_a_disabled_tab_reports_nothing() { + let (_cx, mut visual, changes) = tabs_window(); + + click(&mut visual, point(px(104.), px(12.))); + assert!(changes.borrow().is_empty()); +} + +#[test] +fn arrow_keys_skip_disabled_tabs_and_never_wrap() { + let (_cx, mut visual, changes) = tabs_window(); + + click(&mut visual, point(px(20.), px(12.))); // One: already selected, no report + visual.simulate_keystrokes("right"); // Two + visual.simulate_keystrokes("right"); // over the disabled tab → End + visual.simulate_keystrokes("right"); // last; no wrap + assert_eq!(*changes.borrow(), vec![1, 3]); + + visual.simulate_keystrokes("left"); // over the disabled tab → Two + visual.simulate_keystrokes("home"); // One + visual.simulate_keystrokes("end"); // End + assert_eq!(*changes.borrow(), vec![1, 3, 1, 0, 3]); +} + +#[test] +fn clicking_a_different_tab_reports_once() { + let (_cx, mut visual, changes) = tabs_window(); + + click(&mut visual, point(px(20.), px(12.))); // One: no change, just focus + click(&mut visual, point(px(62.), px(12.))); // Two + click(&mut visual, point(px(62.), px(12.))); // Two again: no change + assert_eq!(*changes.borrow(), vec![1]); +} diff --git a/docs/PLAN.md b/docs/PLAN.md index 343fc50..74f361c 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -217,7 +217,7 @@ Each row is one review unit: build it, show it in the gallery, review, then move | 11 | core | Popover | ✅ Landed (2026-10-29). First overlay. Controlled `open` + `on_change` (Rc-shared handler); `trigger`/`content` element closures; panel = surface + `Radius.overlay` + `Elevations.popover` + border, role Dialog with label; `anchored()` + `deferred()` (window-overflow flip for free); enter fade via gpui `with_animation` at `Motion.overlay` (respects reduce-motion); claims keyboard focus while open; escape and click-outside dismiss (outside clicks swallowed). No scrim — native macOS popover model; the overlay token waits for Dialog. Known v1 gaps: no exit animation, trigger aria-expanded left to the app | | 11+ | core | Tooltip ✅ (2026-10-29), Dialog + AlertDialog ✅ (2026-10-29), Toast ✅ (2026-10-29) | One unit each, same gate. Tooltip rides gpui's own `.tooltip()` lifecycle (hover delay, anchoring, dismissal — all framework-owned); ours is the styled view (`surface` + border + `Radius.sm`, small text) plus a `Tooltip::text` closure and a Button passthrough. Dialog + AlertDialog (one component, `alert(true)` switches semantics): scrim (`colors.overlay`, occluding) + centered panel (surface, `Radius.overlay`, `Elevations.dialog`), role Dialog/AlertDialog, focus claimed while open; modal kind closes on escape + scrim click, alert kind is inert to both (the user answers through the actions); actions footer is caller-built elements closing through owner state. Toast: `Toasts` store entity (push/dismiss/clear + auto-dismiss timers on the GPUI executor, `Toast::DURATION` 5 s, zero = sticky) + `ToastsView` deferred stack (top-right, `Radius.overlay` + popover elevation, severity icon from the new success/warning ramps, close button, `on_dismiss` windowless handler). Toast landed the deferred `success`/`warning` tokens (§5.2). Sheet dropped from the roadmap | | 14 | core + theme | Motion (per component) | ✅ Landed in five passes (2026-10-30), replacing the reverted same-day framework attempt — no token tower, no `motion` module, no ghost panels; just gpui's own primitives used inline, one separately reviewed pass per component: (a) theme — `reapply` forwards `reduce_motion` via `cx.set_reduce_motion`, so gpui snaps every animation under the OS setting; (b) Switch — the thumb absolute-positions in the track and slides on `with_spring` (inline `SpringConfig::new(500, 35, 1)`, `Pixels` target, mounts at its target so the initial render never animates; velocity survives mid-flight retargets); (c) Toast — each toast slides up 8 px into its stack slot on `with_animation` at `Motion.overlay` with `ease_out_quint` (margin-driven, so only the entering toast moves; keyed per entry so the clock survives unrelated stack re-renders); (d) Dialog — the scrim enters with the panel (same timing and easing, its own `dialog-scrim-enter` animation id so the clocks start in the same frame); (e) Progress — the indeterminate sweep caps at 30 fps via `with_max_fps`. Overlay enter fades already existed from units 11/12. Exits and hover washes stay deferred — they were the complexity sink of the reverted attempt (§ deferred list) | -| then | data (new) | List ✅ (2026-10-30), Tree ✅ (2026-10-30), then Tabs → Sidebar | Created on demand, same gate. Table dropped from the roadmap. List: controlled single-selection rows (`selected: Option` + `on_change`); click selects and focuses the list, arrows/home/end move selection skipping disabled rows and never wrapping (native list behavior), hover is paint-only, click focus rides a keyed `FocusHandle` namespaced by the list id; roles List/ListItem with `aria_selected` and position/size-of-set; internal scrolling left to the caller's ScrollArea; `sheet` feature dropped from core as dead config. Tree: the caller flattens its tree into depth-annotated `TreeItem`s (bezel's model — the component never walks a tree); the caller owns expansion and receives toggles via `on_disclosure(index)` (chevron click, or left/right on the selected row); same controlled selection and row paint as List; roles Tree/TreeItem with `aria_selected`/`aria_level`/`aria_expanded`/set-size; indent guides deferred | +| then | data (new) | List ✅ (2026-10-30), Tree ✅ (2026-10-30), Tabs ✅ (2026-10-30), then Sidebar | Created on demand, same gate. Table dropped from the roadmap. List: controlled single-selection rows (`selected: Option` + `on_change`); click selects and focuses the list, arrows/home/end move selection skipping disabled rows and never wrapping (native list behavior), hover is paint-only, click focus rides a keyed `FocusHandle` namespaced by the list id; roles List/ListItem with `aria_selected` and position/size-of-set; internal scrolling left to the caller's ScrollArea; `sheet` feature dropped from core as dead config. Tree: the caller flattens its tree into depth-annotated `TreeItem`s (bezel's model — the component never walks a tree); the caller owns expansion and receives toggles via `on_disclosure(index)` (chevron click, or left/right on the selected row); same controlled selection and row paint as List; roles Tree/TreeItem with `aria_selected`/`aria_level`/`aria_expanded`/set-size; indent guides deferred. Tabs: a controlled strip only — `selected: usize` + `on_change`; the panel is the app's to render; active tab marked by an underline on the strip's hairline (bezel's model); arrows/left-right/home/end switch tabs with automatic activation, skipping disabled and never wrapping; re-selecting the active tab reports nothing; roles TabList/Tab with `aria_selected`; no wrap, no segmented-pill variant yet | | then | pickers or data | Calendar/DatePicker, TimeField, ColorPicker | Decide at unit time. | | editor | editor (new) | Text/code editor, syntax highlighting (D12) | Same gate. | @@ -321,7 +321,7 @@ Dropped from rev 3: D2, D5, D9 (port-era decisions); the review gate returns in 4. ~~Phase D input~~ ✅ — single-line Input (unit 10) + the §6.2 completion pass (modifiers, undo/redo, read-only, actions + key context, caret blink, paint polish). NumberInput and multiline remain open as deferred units. 5. ~~Phase E overlays~~ ✅ — Popover (unit 11), ScrollArea (unit 11.5), Menu (unit 12), Tooltip + Dialog/AlertDialog + Toast (unit 11+ row; Sheet dropped). Phase gate held: escape/dismiss semantics and focus behavior per overlay. 6. ~~Unit 14 — motion, per component (§6 row 14, D21)~~ ✅ Landed in five passes (2026-10-30): (a) theme `reduce_motion` → `cx.set_reduce_motion` in `reapply`; (b) Switch thumb `with_spring`; (c) Toast 8 px slide-in; (d) Dialog scrim enters with the panel; (e) Progress 30 fps cap. Exits and hover washes stay deferred (§6 deferred list). -7. **Phase F, data first** — ~~`crates/data` List~~ ✅ landed (2026-10-30; opened the crate, dropped the dead `sheet` feature from core); ~~Tree~~ ✅ landed (2026-10-30). Next: **Tabs** → Sidebar, same gate. Windows/GNOME/KDE/Omarchy theme sources and the Segoe Fluent/freedesktop icon resolvers slot in as verification environments allow — a source may state `Motion.overlay` when it needs platform timing (§5.5) — with side-by-side gallery screenshots per platform. NumberInput/multiline return as deferred units when needed. +7. **Phase F, data first** — ~~`crates/data` List~~ ✅ landed (2026-10-30; opened the crate, dropped the dead `sheet` feature from core); ~~Tree~~ ✅ landed (2026-10-30); ~~Tabs~~ ✅ landed (2026-10-30). Next: **Sidebar**, same gate. Windows/GNOME/KDE/Omarchy theme sources and the Segoe Fluent/freedesktop icon resolvers slot in as verification environments allow — a source may state `Motion.overlay` when it needs platform timing (§5.5) — with side-by-side gallery screenshots per platform. NumberInput/multiline return as deferred units when needed. 8. Then — Phase G: pickers, polish, → 0.1; start `crates/editor` if wanted. Batched polish candidates between units: scrollbar auto-hide + horizontal thumb, `Undo`/`Redo` menu events. diff --git a/examples/gallery/src/main.rs b/examples/gallery/src/main.rs index da5fa85..912bf2d 100644 --- a/examples/gallery/src/main.rs +++ b/examples/gallery/src/main.rs @@ -24,6 +24,7 @@ use gpui_native_core::toast::{Toast, ToastSeverity, Toasts, ToastsView}; use gpui_native_core::toggle::Toggle; use gpui_native_core::tooltip::Tooltip; use gpui_native_data::list::{List, ListItem}; +use gpui_native_data::tabs::{Tab, Tabs}; use gpui_native_data::tree::{Tree, TreeItem}; use gpui_native_icons::{IconAssets, IconRole, icon}; use gpui_native_theme::{ActiveTheme as _, Theme}; @@ -46,6 +47,7 @@ struct Gallery { list_selected: Option, tree_nodes: Vec, tree_selected: Option, + tab_selected: usize, } /// A demo model for the tree section: the app owns the real tree and its @@ -472,6 +474,44 @@ impl Render for Gallery { .items(move |_, _| items.clone()) }), ) + .child( + v_flex() + .gap_2() + .child(section_label( + "tabs · native refs — NSTabView, WinUI TabView, Adwaita TabBar, Breeze", + colors, + )) + .child({ + let gallery = gallery.clone(); + Tabs::new("gallery-tabs") + .selected(self.tab_selected) + .on_change(move |selected, _, cx| { + gallery.update(cx, |gallery, cx| { + gallery.tab_selected = selected; + cx.notify(); + }); + }) + .tabs(|_, _| { + vec![ + Tab::new("Overview"), + Tab::new("Details"), + Tab::new("Settings"), + Tab::new("Locked").disabled(true), + ] + }) + }) + .child(match self.tab_selected { + 0 => div() + .child("The overview panel is the app's business — the strip only reports.") + .into_any_element(), + 1 => div() + .child("Details: rows, lists and trees all live under their own tabs.") + .into_any_element(), + _ => div() + .child("Settings: keyboard focus rides the strip; arrows switch tabs.") + .into_any_element(), + }), + ) .child( v_flex() .gap_2() @@ -1092,6 +1132,7 @@ fn main() { scroll: gpui::ScrollHandle::new(), list_selected: None, tree_selected: None, + tab_selected: 0, tree_nodes: vec![ GalleryNode { label: "Workspace".into(),