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,
disabled: bool,
tab_stop: bool,
debug_selector: Option<Rc<dyn Fn() -> String>>,
a11y_label: Option<SharedString>,
tooltip: Option<TooltipBuilder>,
on_click: Option<ClickHandler>,
@@ -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<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.
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))
+5 -1
View File
@@ -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 = []
+37 -59
View File
@@ -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<CloseHandler>,
) -> 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()
}
+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 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<DockState>, pane: &PaneView) -> AnyElement {
fn render_drop_zones(state: &Entity<DockState>, pane: &PaneView) -> Vec<AnyElement> {
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.
+3
View File
@@ -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),
+5
View File
@@ -22,6 +22,7 @@ pub struct ThemeDefinition {
pub secondary: Option<String>,
pub secondary_foreground: Option<String>,
pub muted: Option<String>,
pub muted_hover: Option<String>,
pub muted_foreground: Option<String>,
pub destructive: Option<String>,
pub destructive_foreground: Option<String>,
@@ -88,6 +89,10 @@ impl ThemeDefinition {
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> {
parse_hex(self.muted_foreground.as_deref()?)
}
+21
View File
@@ -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));
}
}
+1
View File
@@ -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,
+3 -1
View File
@@ -50,9 +50,11 @@ pub struct PartialPalette {
pub secondary: Option<Hsla>,
pub secondary_foreground: 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 destructive: Option<Hsla>,
/// No platform states severity colors; presets override them.
/// No platform states severity colors, presets override them.
pub success: Option<Hsla>,
pub warning: 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).
- ~~**`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.