add muted_hover token and rework tab strip visuals

This commit is contained in:
2026-11-02 17:08:51 +07:00
parent 545d0986ed
commit f295b25366
10 changed files with 130 additions and 111 deletions
+15 -1
View File
@@ -41,6 +41,7 @@ pub struct Button {
size: Size, size: Size,
disabled: bool, disabled: bool,
tab_stop: bool, tab_stop: bool,
debug_selector: Option<Rc<dyn Fn() -> String>>,
a11y_label: Option<SharedString>, a11y_label: Option<SharedString>,
tooltip: Option<TooltipBuilder>, tooltip: Option<TooltipBuilder>,
on_click: Option<ClickHandler>, on_click: Option<ClickHandler>,
@@ -60,6 +61,7 @@ impl Button {
size: Size::default(), size: Size::default(),
disabled: false, disabled: false,
tab_stop: true, tab_stop: true,
debug_selector: None,
a11y_label: None, a11y_label: None,
tooltip: None, tooltip: None,
on_click: None, on_click: None,
@@ -102,6 +104,15 @@ impl Button {
self self
} }
/// Sets the debug-bounds selector, for headless tests.
pub fn debug_selector<F>(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. /// Renders the button inert, unfocusable, and dimmed.
pub fn disabled(mut self, disabled: bool) -> Self { pub fn disabled(mut self, disabled: bool) -> Self {
self.disabled = disabled; self.disabled = disabled;
@@ -160,7 +171,7 @@ impl RenderOnce for Button {
ButtonVariant::Ghost => ( ButtonVariant::Ghost => (
None, None,
colors.foreground, colors.foreground,
colors.muted, colors.muted_hover,
ramps.secondary.pressed, ramps.secondary.pressed,
), ),
ButtonVariant::Destructive => ( ButtonVariant::Destructive => (
@@ -173,6 +184,9 @@ impl RenderOnce for Button {
h_flex() h_flex()
.id(self.id) .id(self.id)
.when_some(self.debug_selector, |button, selector| {
button.debug_selector(move || selector())
})
.role(Role::Button) .role(Role::Button)
.justify_center() .justify_center()
.when(icon_only, |button| button.size(metrics.control_height)) .when(icon_only, |button| button.size(metrics.control_height))
+5 -1
View File
@@ -19,5 +19,9 @@ default = ["full"]
full = ["list", "sidebar", "tabs", "tree"] full = ["list", "sidebar", "tabs", "tree"]
list = [] list = []
sidebar = [] sidebar = []
tabs = ["dep:gpui-native-core", "gpui-native-core/button"] tabs = [
"dep:gpui-native-core",
"gpui-native-core/button",
"gpui-native-core/tooltip",
]
tree = [] tree = []
+37 -59
View File
@@ -8,6 +8,7 @@ use gpui::{
}; };
use gpui_native_core::button::{Button, ButtonVariant}; use gpui_native_core::button::{Button, ButtonVariant};
use gpui_native_core::common::Sizable as _; use gpui_native_core::common::Sizable as _;
use gpui_native_core::tooltip::Tooltip;
use gpui_native_icons::{IconRole, icon}; use gpui_native_icons::{IconRole, icon};
use gpui_native_theme::{ActiveTheme as _, Size}; use gpui_native_theme::{ActiveTheme as _, Size};
use gpui_native_utils::adjacent_selectable; use gpui_native_utils::adjacent_selectable;
@@ -277,6 +278,7 @@ fn render_tab(
let theme = cx.theme(); let theme = cx.theme();
let metrics = theme.sizes.resolve(Size::default()); let metrics = theme.sizes.resolve(Size::default());
let small = theme.sizes.small;
let label_color = match variant { let label_color = match variant {
TabsVariant::Underline => { TabsVariant::Underline => {
@@ -319,36 +321,41 @@ fn render_tab(
.py_1() .py_1()
.rounded_tl(theme.radius.sm) .rounded_tl(theme.radius.sm)
.rounded_tr(theme.radius.sm) .rounded_tr(theme.radius.sm)
.border_b_2()
.border_color(gpui::transparent_black())
.when(selected, |strip_tab| { .when(selected, |strip_tab| {
strip_tab strip_tab.border_color(theme.ramps.primary.base)
.border_b_2()
.border_color(theme.ramps.primary.base)
}), }),
TabsVariant::Pill => strip_tab TabsVariant::Pill => strip_tab
.h(metrics.control_height) .h(small.control_height + metrics.plate_inset * 2.0)
.px(metrics.padding_x) .px(metrics.dense_padding_x)
.pr(metrics.plate_inset)
.rounded(theme.radius.control) .rounded(theme.radius.control)
.when(selected, |strip_tab| { .when(selected, |strip_tab| {
strip_tab strip_tab
.bg(theme.colors.muted) .bg(theme.colors.muted)
.font_weight(gpui::FontWeight::MEDIUM) .hover(|strip_tab| strip_tab.bg(theme.colors.muted_hover))
}), }),
}; };
let lead = tab.lead.take(); let lead = tab.lead.take();
strip_tab = match lead { strip_tab = match lead {
Some(lead) => strip_tab.child(lead), Some(lead) => strip_tab.child(lead),
None => strip_tab.child( None => match variant {
div() TabsVariant::Underline => strip_tab.child(div().flex_none().pb_0p5().when_some(
.flex_none() tab.icon,
.when(variant == TabsVariant::Underline, |slot| slot.pb_0p5()) |slot, role| {
.when_some(tab.icon, |slot, role| {
slot.child(icon(role).size(metrics.dense_icon_size).color(label_color)) 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 { if variant == TabsVariant::Pill {
strip_tab = strip_tab.child(div().flex_none().w(metrics.padding_x * 1.5)); strip_tab = strip_tab.child(div().flex_none().w(metrics.padding_x * 1.5));
@@ -389,55 +396,26 @@ fn render_tab_close(
index: usize, index: usize,
on_close: &Option<CloseHandler>, on_close: &Option<CloseHandler>,
) -> AnyElement { ) -> AnyElement {
// The wrapper carries the test selector; the button paints itself. Button::new(("tab-close", index))
div() .icon(IconRole::Close)
.id(("tab-close", index)) .variant(ButtonVariant::Ghost)
.small()
.tab_stop(false)
.debug_selector({ .debug_selector({
let id = format!("{tabs_id}-close-{}", tab.label); let id = format!("{tabs_id}-close-{}", tab.label);
move || id move || id.clone()
}) })
.flex_none() .tooltip(Tooltip::text("Close"))
.child( .accessibility_label(SharedString::from(format!("Close {}", tab.label)))
Button::new(("tab-close-button", index)) .on_click({
.icon(IconRole::Close) let on_close = on_close.clone();
.variant(ButtonVariant::Ghost) move |_, window, cx| {
.small() cx.stop_propagation();
.tab_stop(false) if let Some(on_close) = &on_close {
.accessibility_label(SharedString::from(format!("Close {}", tab.label))) on_close(index, window, cx);
.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
} }
} })
TabsVariant::Pill => {
if tab.disabled {
theme.colors.muted_foreground
} else {
theme.colors.foreground
}
}
};
div()
.text_color(color)
.child(tab.label.clone())
.into_any_element() .into_any_element()
} }
+39 -49
View File
@@ -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 snapshot(&self) -> TreeView {
fn snap(node: &Node) -> TreeView { fn snap(node: &Node) -> TreeView {
match node { match node {
@@ -843,7 +843,7 @@ fn render_pane(
content.child(view.cached(StyleRefinement::default().absolute().size_full())) content.child(view.cached(StyleRefinement::default().absolute().size_full()))
}) })
.when(cx.has_active_drag(), |content| { .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!( let menu = ContextMenu::new(SharedString::from(format!(
@@ -854,7 +854,6 @@ fn render_pane(
let dock_id = dock_id.clone(); let dock_id = dock_id.clone();
let state = state.clone(); let state = state.clone();
let pane = pane.clone(); let pane = pane.clone();
let strip_height = metrics.strip_height;
let padding_x = metrics.dense_padding_x; let padding_x = metrics.dense_padding_x;
move |_, _| { move |_, _| {
let selected = pane 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() h_flex()
.flex_none() .flex_none()
.h(strip_height)
.w_full() .w_full()
.items_center() .items_center()
.pr(padding_x) .pr(padding_x)
.child(tabs) .child(tabs)
.child(div().flex_1())
.into_any_element() .into_any_element()
} }
}) })
@@ -982,56 +979,49 @@ fn render_pane(
/// ///
/// - Four edge bands that split /// - Four edge bands that split
/// - The center that moves the tab in. /// - The center that moves the tab in.
fn render_drop_zones(state: &Entity<DockState>, pane: &PaneView) -> AnyElement { fn render_drop_zones(state: &Entity<DockState>, pane: &PaneView) -> Vec<AnyElement> {
let Some(anchor) = pane let Some(anchor) = pane
.active .active
.clone() .clone()
.or_else(|| pane.tabs.first().map(|tab| tab.panel.clone())) .or_else(|| pane.tabs.first().map(|tab| tab.panel.clone()))
else { else {
return div().into_any_element(); return Vec::new();
}; };
div() vec![
.absolute() drop_zone(state, &anchor, Some(SplitDirection::Left))
.inset_0() .absolute()
.child( .top_0()
drop_zone(state, &anchor, Some(SplitDirection::Left)) .bottom_0()
.absolute() .left_0()
.top_0() .w(relative(0.25))
.bottom_0() .into_any_element(),
.left_0() drop_zone(state, &anchor, Some(SplitDirection::Right))
.w(relative(0.25)), .absolute()
) .top_0()
.child( .bottom_0()
drop_zone(state, &anchor, Some(SplitDirection::Right)) .right_0()
.absolute() .w(relative(0.25))
.top_0() .into_any_element(),
.bottom_0() drop_zone(state, &anchor, Some(SplitDirection::Top))
.right_0() .absolute()
.w(relative(0.25)), .top_0()
) .left(relative(0.25))
.child( .right(relative(0.25))
drop_zone(state, &anchor, Some(SplitDirection::Top)) .h(relative(0.25))
.absolute() .into_any_element(),
.top_0() drop_zone(state, &anchor, Some(SplitDirection::Bottom))
.left(relative(0.25)) .absolute()
.right(relative(0.25)) .bottom_0()
.h(relative(0.25)), .left(relative(0.25))
) .right(relative(0.25))
.child( .h(relative(0.25))
drop_zone(state, &anchor, Some(SplitDirection::Bottom)) .into_any_element(),
.absolute() drop_zone(state, &anchor, None)
.bottom_0() .absolute()
.left(relative(0.25)) .inset(relative(0.25))
.right(relative(0.25)) .into_any_element(),
.h(relative(0.25)), ]
)
.child(
drop_zone(state, &anchor, None)
.absolute()
.inset(relative(0.25)),
)
.into_any_element()
} }
/// Renders a drop zone for panel dragging. /// Renders a drop zone for panel dragging.
+3
View File
@@ -24,6 +24,7 @@ pub struct ColorTokens {
pub secondary: Hsla, pub secondary: Hsla,
pub secondary_foreground: Hsla, pub secondary_foreground: Hsla,
pub muted: Hsla, pub muted: Hsla,
pub muted_hover: Hsla,
pub muted_foreground: Hsla, pub muted_foreground: Hsla,
pub accent: Hsla, pub accent: Hsla,
pub accent_foreground: Hsla, pub accent_foreground: Hsla,
@@ -58,6 +59,7 @@ impl ColorTokens {
secondary: hex(0xf4f4f5), secondary: hex(0xf4f4f5),
secondary_foreground: hex(0x18181b), secondary_foreground: hex(0x18181b),
muted: hex(0xf4f4f5), muted: hex(0xf4f4f5),
muted_hover: hex(0xe4e4e7),
muted_foreground: hex(0x71717a), muted_foreground: hex(0x71717a),
accent: hex(0x2563eb), accent: hex(0x2563eb),
accent_foreground: hex(0xffffff), accent_foreground: hex(0xffffff),
@@ -91,6 +93,7 @@ impl ColorTokens {
secondary: hex(0x27272a), secondary: hex(0x27272a),
secondary_foreground: hex(0xfafafa), secondary_foreground: hex(0xfafafa),
muted: hex(0x27272a), muted: hex(0x27272a),
muted_hover: hex(0x3f3f46),
muted_foreground: hex(0xa1a1aa), muted_foreground: hex(0xa1a1aa),
accent: hex(0x3b82f6), accent: hex(0x3b82f6),
accent_foreground: hex(0xffffff), accent_foreground: hex(0xffffff),
+5
View File
@@ -22,6 +22,7 @@ pub struct ThemeDefinition {
pub secondary: Option<String>, pub secondary: Option<String>,
pub secondary_foreground: Option<String>, pub secondary_foreground: Option<String>,
pub muted: Option<String>, pub muted: Option<String>,
pub muted_hover: Option<String>,
pub muted_foreground: Option<String>, pub muted_foreground: Option<String>,
pub destructive: Option<String>, pub destructive: Option<String>,
pub destructive_foreground: Option<String>, pub destructive_foreground: Option<String>,
@@ -88,6 +89,10 @@ impl ThemeDefinition {
parse_hex(self.muted.as_deref()?) parse_hex(self.muted.as_deref()?)
} }
pub fn muted_hover(&self) -> Option<Hsla> {
parse_hex(self.muted_hover.as_deref()?)
}
pub fn muted_foreground(&self) -> Option<Hsla> { pub fn muted_foreground(&self) -> Option<Hsla> {
parse_hex(self.muted_foreground.as_deref()?) parse_hex(self.muted_foreground.as_deref()?)
} }
+21
View File
@@ -111,6 +111,11 @@ pub(crate) fn resolve(
base.secondary_foreground, base.secondary_foreground,
), ),
muted: pick(def_color(|d| d.muted()), system_palette.muted, base.muted), 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( muted_foreground: pick(
def_color(|d| d.muted_foreground()), def_color(|d| d.muted_foreground()),
system_palette.muted_foreground, system_palette.muted_foreground,
@@ -601,4 +606,20 @@ mod tests {
let preset_stated = resolve(&system(ThemeVariant::Dark, palette, None), Some(&def), None); let preset_stated = resolve(&system(ThemeVariant::Dark, palette, None), Some(&def), None);
assert_eq!(preset_stated.colors.info, hsla(0x0ea5e9)); 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));
}
} }
+1
View File
@@ -79,6 +79,7 @@ impl MacosSource {
secondary: None, secondary: None,
secondary_foreground: Self::color_to_hsla(&NSColor::secondaryLabelColor()), secondary_foreground: Self::color_to_hsla(&NSColor::secondaryLabelColor()),
muted: None, muted: None,
muted_hover: None,
muted_foreground: Self::color_to_hsla(&NSColor::secondaryLabelColor()), muted_foreground: Self::color_to_hsla(&NSColor::secondaryLabelColor()),
destructive: Self::color_to_hsla(&NSColor::systemRedColor()), destructive: Self::color_to_hsla(&NSColor::systemRedColor()),
success: None, success: None,
+3 -1
View File
@@ -50,9 +50,11 @@ pub struct PartialPalette {
pub secondary: Option<Hsla>, pub secondary: Option<Hsla>,
pub secondary_foreground: Option<Hsla>, pub secondary_foreground: Option<Hsla>,
pub muted: Option<Hsla>, pub muted: Option<Hsla>,
/// No platform states a hover wash, presets override it.
pub muted_hover: Option<Hsla>,
pub muted_foreground: Option<Hsla>, pub muted_foreground: Option<Hsla>,
pub destructive: Option<Hsla>, pub destructive: Option<Hsla>,
/// No platform states severity colors; presets override them. /// No platform states severity colors, presets override them.
pub success: Option<Hsla>, pub success: Option<Hsla>,
pub warning: Option<Hsla>, pub warning: Option<Hsla>,
pub info: Option<Hsla>, pub info: Option<Hsla>,
+1
View File
@@ -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). - ~~**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). - ~~**`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. - **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. - ~~**`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. Controls through unit 10 paint from existing roles and ramps.