From f295b25366c1d5c3dd9dae8d1fde88ce7f98583d Mon Sep 17 00:00:00 2001 From: Ren Amamiya Date: Mon, 2 Nov 2026 17:08:51 +0700 Subject: [PATCH] add muted_hover token and rework tab strip visuals --- crates/core/src/button.rs | 16 +++++- crates/data/Cargo.toml | 6 +- crates/data/src/tabs.rs | 96 ++++++++++++-------------------- crates/layout/src/dock.rs | 88 +++++++++++++---------------- crates/theme/src/colors.rs | 3 + crates/theme/src/definition.rs | 5 ++ crates/theme/src/resolve.rs | 21 +++++++ crates/theme/src/source/macos.rs | 1 + crates/theme/src/system.rs | 4 +- docs/PLAN.md | 1 + 10 files changed, 130 insertions(+), 111 deletions(-) diff --git a/crates/core/src/button.rs b/crates/core/src/button.rs index 2dad715..9af3f81 100644 --- a/crates/core/src/button.rs +++ b/crates/core/src/button.rs @@ -41,6 +41,7 @@ pub struct Button { size: Size, disabled: bool, tab_stop: bool, + debug_selector: Option String>>, a11y_label: Option, tooltip: Option, on_click: Option, @@ -60,6 +61,7 @@ impl Button { size: Size::default(), disabled: false, tab_stop: true, + debug_selector: None, a11y_label: None, tooltip: None, on_click: None, @@ -102,6 +104,15 @@ impl Button { self } + /// Sets the debug-bounds selector, for headless tests. + pub fn debug_selector(mut self, selector: F) -> Self + where + F: Fn() -> String + 'static, + { + self.debug_selector = Some(Rc::new(selector)); + self + } + /// Renders the button inert, unfocusable, and dimmed. pub fn disabled(mut self, disabled: bool) -> Self { self.disabled = disabled; @@ -160,7 +171,7 @@ impl RenderOnce for Button { ButtonVariant::Ghost => ( None, colors.foreground, - colors.muted, + colors.muted_hover, ramps.secondary.pressed, ), ButtonVariant::Destructive => ( @@ -173,6 +184,9 @@ impl RenderOnce for Button { h_flex() .id(self.id) + .when_some(self.debug_selector, |button, selector| { + button.debug_selector(move || selector()) + }) .role(Role::Button) .justify_center() .when(icon_only, |button| button.size(metrics.control_height)) diff --git a/crates/data/Cargo.toml b/crates/data/Cargo.toml index 944d3f8..6359e65 100644 --- a/crates/data/Cargo.toml +++ b/crates/data/Cargo.toml @@ -19,5 +19,9 @@ default = ["full"] full = ["list", "sidebar", "tabs", "tree"] list = [] sidebar = [] -tabs = ["dep:gpui-native-core", "gpui-native-core/button"] +tabs = [ + "dep:gpui-native-core", + "gpui-native-core/button", + "gpui-native-core/tooltip", +] tree = [] diff --git a/crates/data/src/tabs.rs b/crates/data/src/tabs.rs index f96fb29..8c4199d 100644 --- a/crates/data/src/tabs.rs +++ b/crates/data/src/tabs.rs @@ -8,6 +8,7 @@ use gpui::{ }; use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::common::Sizable as _; +use gpui_native_core::tooltip::Tooltip; use gpui_native_icons::{IconRole, icon}; use gpui_native_theme::{ActiveTheme as _, Size}; use gpui_native_utils::adjacent_selectable; @@ -277,6 +278,7 @@ fn render_tab( let theme = cx.theme(); let metrics = theme.sizes.resolve(Size::default()); + let small = theme.sizes.small; let label_color = match variant { TabsVariant::Underline => { @@ -319,36 +321,41 @@ fn render_tab( .py_1() .rounded_tl(theme.radius.sm) .rounded_tr(theme.radius.sm) + .border_b_2() + .border_color(gpui::transparent_black()) .when(selected, |strip_tab| { - strip_tab - .border_b_2() - .border_color(theme.ramps.primary.base) + strip_tab.border_color(theme.ramps.primary.base) }), TabsVariant::Pill => strip_tab - .h(metrics.control_height) - .px(metrics.padding_x) + .h(small.control_height + metrics.plate_inset * 2.0) + .px(metrics.dense_padding_x) + .pr(metrics.plate_inset) .rounded(theme.radius.control) .when(selected, |strip_tab| { strip_tab .bg(theme.colors.muted) - .font_weight(gpui::FontWeight::MEDIUM) + .hover(|strip_tab| strip_tab.bg(theme.colors.muted_hover)) }), }; let lead = tab.lead.take(); + strip_tab = match lead { Some(lead) => strip_tab.child(lead), - None => strip_tab.child( - div() - .flex_none() - .when(variant == TabsVariant::Underline, |slot| slot.pb_0p5()) - .when_some(tab.icon, |slot, role| { + None => match variant { + TabsVariant::Underline => strip_tab.child(div().flex_none().pb_0p5().when_some( + tab.icon, + |slot, role| { slot.child(icon(role).size(metrics.dense_icon_size).color(label_color)) - }), - ), + }, + )), + TabsVariant::Pill => strip_tab.when_some(tab.icon, |strip_tab, role| { + strip_tab.child(icon(role).size(metrics.dense_icon_size).color(label_color)) + }), + }, }; - strip_tab = strip_tab.child(strip_tab_label(&tab, selected, variant, cx)); + strip_tab = strip_tab.child(tab.label.clone()); if variant == TabsVariant::Pill { strip_tab = strip_tab.child(div().flex_none().w(metrics.padding_x * 1.5)); @@ -389,55 +396,26 @@ fn render_tab_close( index: usize, on_close: &Option, ) -> AnyElement { - // The wrapper carries the test selector; the button paints itself. - div() - .id(("tab-close", index)) + Button::new(("tab-close", index)) + .icon(IconRole::Close) + .variant(ButtonVariant::Ghost) + .small() + .tab_stop(false) .debug_selector({ let id = format!("{tabs_id}-close-{}", tab.label); - move || id + move || id.clone() }) - .flex_none() - .child( - Button::new(("tab-close-button", index)) - .icon(IconRole::Close) - .variant(ButtonVariant::Ghost) - .small() - .tab_stop(false) - .accessibility_label(SharedString::from(format!("Close {}", tab.label))) - .on_click({ - let on_close = on_close.clone(); - move |_, window, cx| { - cx.stop_propagation(); - if let Some(on_close) = &on_close { - on_close(index, window, cx); - } - } - }), - ) - .into_any_element() -} - -fn strip_tab_label(tab: &Tab, selected: bool, variant: TabsVariant, cx: &App) -> AnyElement { - let theme = cx.theme(); - let color = match variant { - TabsVariant::Underline => { - if selected { - theme.ramps.primary.base - } else { - theme.colors.foreground + .tooltip(Tooltip::text("Close")) + .accessibility_label(SharedString::from(format!("Close {}", tab.label))) + .on_click({ + let on_close = on_close.clone(); + move |_, window, cx| { + cx.stop_propagation(); + if let Some(on_close) = &on_close { + on_close(index, window, cx); + } } - } - TabsVariant::Pill => { - if tab.disabled { - theme.colors.muted_foreground - } else { - theme.colors.foreground - } - } - }; - div() - .text_color(color) - .child(tab.label.clone()) + }) .into_any_element() } diff --git a/crates/layout/src/dock.rs b/crates/layout/src/dock.rs index a1ed83d..15bf27b 100644 --- a/crates/layout/src/dock.rs +++ b/crates/layout/src/dock.rs @@ -587,7 +587,7 @@ impl DockState { } } - /// The tree the dock renders from, one frame at a time. + /// The tree the dock renders from. fn snapshot(&self) -> TreeView { fn snap(node: &Node) -> TreeView { match node { @@ -843,7 +843,7 @@ fn render_pane( content.child(view.cached(StyleRefinement::default().absolute().size_full())) }) .when(cx.has_active_drag(), |content| { - content.child(render_drop_zones(state, pane)) + content.children(render_drop_zones(state, pane)) }); let menu = ContextMenu::new(SharedString::from(format!( @@ -854,7 +854,6 @@ fn render_pane( let dock_id = dock_id.clone(); let state = state.clone(); let pane = pane.clone(); - let strip_height = metrics.strip_height; let padding_x = metrics.dense_padding_x; move |_, _| { let selected = pane @@ -922,15 +921,13 @@ fn render_pane( } }); - // Chrome-less like the future titlebar: the pills carry the look. + // Chrome-less like the future titlebar: the bar wraps the pills. h_flex() .flex_none() - .h(strip_height) .w_full() .items_center() .pr(padding_x) .child(tabs) - .child(div().flex_1()) .into_any_element() } }) @@ -982,56 +979,49 @@ fn render_pane( /// /// - Four edge bands that split /// - The center that moves the tab in. -fn render_drop_zones(state: &Entity, pane: &PaneView) -> AnyElement { +fn render_drop_zones(state: &Entity, pane: &PaneView) -> Vec { let Some(anchor) = pane .active .clone() .or_else(|| pane.tabs.first().map(|tab| tab.panel.clone())) else { - return div().into_any_element(); + return Vec::new(); }; - div() - .absolute() - .inset_0() - .child( - drop_zone(state, &anchor, Some(SplitDirection::Left)) - .absolute() - .top_0() - .bottom_0() - .left_0() - .w(relative(0.25)), - ) - .child( - drop_zone(state, &anchor, Some(SplitDirection::Right)) - .absolute() - .top_0() - .bottom_0() - .right_0() - .w(relative(0.25)), - ) - .child( - drop_zone(state, &anchor, Some(SplitDirection::Top)) - .absolute() - .top_0() - .left(relative(0.25)) - .right(relative(0.25)) - .h(relative(0.25)), - ) - .child( - drop_zone(state, &anchor, Some(SplitDirection::Bottom)) - .absolute() - .bottom_0() - .left(relative(0.25)) - .right(relative(0.25)) - .h(relative(0.25)), - ) - .child( - drop_zone(state, &anchor, None) - .absolute() - .inset(relative(0.25)), - ) - .into_any_element() + vec![ + drop_zone(state, &anchor, Some(SplitDirection::Left)) + .absolute() + .top_0() + .bottom_0() + .left_0() + .w(relative(0.25)) + .into_any_element(), + drop_zone(state, &anchor, Some(SplitDirection::Right)) + .absolute() + .top_0() + .bottom_0() + .right_0() + .w(relative(0.25)) + .into_any_element(), + drop_zone(state, &anchor, Some(SplitDirection::Top)) + .absolute() + .top_0() + .left(relative(0.25)) + .right(relative(0.25)) + .h(relative(0.25)) + .into_any_element(), + drop_zone(state, &anchor, Some(SplitDirection::Bottom)) + .absolute() + .bottom_0() + .left(relative(0.25)) + .right(relative(0.25)) + .h(relative(0.25)) + .into_any_element(), + drop_zone(state, &anchor, None) + .absolute() + .inset(relative(0.25)) + .into_any_element(), + ] } /// Renders a drop zone for panel dragging. diff --git a/crates/theme/src/colors.rs b/crates/theme/src/colors.rs index 9fbd26f..ed2c51f 100644 --- a/crates/theme/src/colors.rs +++ b/crates/theme/src/colors.rs @@ -24,6 +24,7 @@ pub struct ColorTokens { pub secondary: Hsla, pub secondary_foreground: Hsla, pub muted: Hsla, + pub muted_hover: Hsla, pub muted_foreground: Hsla, pub accent: Hsla, pub accent_foreground: Hsla, @@ -58,6 +59,7 @@ impl ColorTokens { secondary: hex(0xf4f4f5), secondary_foreground: hex(0x18181b), muted: hex(0xf4f4f5), + muted_hover: hex(0xe4e4e7), muted_foreground: hex(0x71717a), accent: hex(0x2563eb), accent_foreground: hex(0xffffff), @@ -91,6 +93,7 @@ impl ColorTokens { secondary: hex(0x27272a), secondary_foreground: hex(0xfafafa), muted: hex(0x27272a), + muted_hover: hex(0x3f3f46), muted_foreground: hex(0xa1a1aa), accent: hex(0x3b82f6), accent_foreground: hex(0xffffff), diff --git a/crates/theme/src/definition.rs b/crates/theme/src/definition.rs index 95c8911..8bb8f4e 100644 --- a/crates/theme/src/definition.rs +++ b/crates/theme/src/definition.rs @@ -22,6 +22,7 @@ pub struct ThemeDefinition { pub secondary: Option, pub secondary_foreground: Option, pub muted: Option, + pub muted_hover: Option, pub muted_foreground: Option, pub destructive: Option, pub destructive_foreground: Option, @@ -88,6 +89,10 @@ impl ThemeDefinition { parse_hex(self.muted.as_deref()?) } + pub fn muted_hover(&self) -> Option { + parse_hex(self.muted_hover.as_deref()?) + } + pub fn muted_foreground(&self) -> Option { parse_hex(self.muted_foreground.as_deref()?) } diff --git a/crates/theme/src/resolve.rs b/crates/theme/src/resolve.rs index 1ee77b1..74afaea 100644 --- a/crates/theme/src/resolve.rs +++ b/crates/theme/src/resolve.rs @@ -111,6 +111,11 @@ pub(crate) fn resolve( base.secondary_foreground, ), muted: pick(def_color(|d| d.muted()), system_palette.muted, base.muted), + muted_hover: pick( + def_color(|d| d.muted_hover()), + system_palette.muted_hover, + base.muted_hover, + ), muted_foreground: pick( def_color(|d| d.muted_foreground()), system_palette.muted_foreground, @@ -601,4 +606,20 @@ mod tests { let preset_stated = resolve(&system(ThemeVariant::Dark, palette, None), Some(&def), None); assert_eq!(preset_stated.colors.info, hsla(0x0ea5e9)); } + + #[test] + fn muted_hover_follows_the_overlay_chain() { + // No platform states a hover wash; a preset or user file is the only + // override above the neutral base. + let palette = PartialPalette { + muted_hover: Some(hsla(0x155e75)), + ..PartialPalette::default() + }; + let stated = resolve(&system(ThemeVariant::Dark, palette, None), None, None); + assert_eq!(stated.colors.muted_hover, hsla(0x155e75)); + + let def = ThemeDefinition::parse_toml("muted_hover = \"#0ea5e9\"\n").unwrap(); + let preset_stated = resolve(&system(ThemeVariant::Dark, palette, None), Some(&def), None); + assert_eq!(preset_stated.colors.muted_hover, hsla(0x0ea5e9)); + } } diff --git a/crates/theme/src/source/macos.rs b/crates/theme/src/source/macos.rs index ac109f7..290a9ba 100644 --- a/crates/theme/src/source/macos.rs +++ b/crates/theme/src/source/macos.rs @@ -79,6 +79,7 @@ impl MacosSource { secondary: None, secondary_foreground: Self::color_to_hsla(&NSColor::secondaryLabelColor()), muted: None, + muted_hover: None, muted_foreground: Self::color_to_hsla(&NSColor::secondaryLabelColor()), destructive: Self::color_to_hsla(&NSColor::systemRedColor()), success: None, diff --git a/crates/theme/src/system.rs b/crates/theme/src/system.rs index 4b144e9..2c6a23b 100644 --- a/crates/theme/src/system.rs +++ b/crates/theme/src/system.rs @@ -50,9 +50,11 @@ pub struct PartialPalette { pub secondary: Option, pub secondary_foreground: Option, pub muted: Option, + /// No platform states a hover wash, presets override it. + pub muted_hover: Option, pub muted_foreground: Option, pub destructive: Option, - /// No platform states severity colors; presets override them. + /// No platform states severity colors, presets override them. pub success: Option, pub warning: Option, pub info: Option, diff --git a/docs/PLAN.md b/docs/PLAN.md index de43483..15a4a4a 100644 --- a/docs/PLAN.md +++ b/docs/PLAN.md @@ -159,6 +159,7 @@ The set stays role-minimal and grows one concept at a time, driven by the unit t - ~~**Scrollbar thumb token + metrics**~~ — ✅ landed with the ScrollArea unit (2026-10-29): `colors.scrollbar_thumb` (translucent over content: black in light, light gray in dark; TOML-overridable) and `Theme.scrollbar` (`width` 8 pt, `inset` 2 pt, `min_thumb` 24 pt). - ~~**`success` / `warning` roles**~~ — ✅ landed with Toast (2026-10-29): `colors.success`/`warning` (+ foregrounds; light and dark variants; TOML-overridable; no platform states them) and derived `Ramps.success`/`warning` via the OKLCH engine, matching the destructive treatment. **`info` joined them (2026-10-31)** — the fourth severity, same treatment (`colors.info` + foreground + `Ramps.info`, neutral sky blue, no platform states it, TOML `info =`). Trigger: the Alert's Info severity was painting from the primary ramp, so a warm platform accent (GNOME red, Omarchy red themes) made Info read as Danger; the token keeps Info calm however the accent turns out. Severity tokens now use the same picked value for the ramp and the token (the success/warning tokens used to re-pick with the system slot dropped, diverging from their own ramps if a platform ever stated one). - **Motion tokens** — after the reverted framework attempt (2026-10-30), motion stays minimal: `Motion.overlay` (200 ms) is the only token. Components reach for gpui's own easings and `SpringConfig` inline (D21); a token is added only when a third component shares the same value (D16). TOML `[motion]` overrides and per-platform motion presets wait for a real need. ~~The reduce-motion forwarding fix~~ ✅ landed (pass a, 2026-10-30): `reapply` calls `cx.set_reduce_motion(resolved.accessibility.reduce_motion)`, so gpui's animation elements snap under the OS setting. +- ~~**Ghost hover token**~~ — ✅ landed with the dock tabs (2026-11-02): `colors.muted_hover` (light zinc-200 / dark zinc-700, one step past `muted`; TOML `muted_hover =`; no platform states a hover wash). Trigger: the ghost Button's hover painted `muted` — the same token the selected pill fills with — so hovering a close control on a selected tab was invisible. `Button::Ghost` and the selected pill hover from `muted_hover` now. - ~~**`knob` / control-fill role**~~ — resolved without a token: Slider (unit 7) shipped on the Switch's borrowed roles (`primary_foreground` fill, `border` off-track); a dedicated role waits for a third consumer. Controls through unit 10 paint from existing roles and ramps.