add muted_hover token and rework tab strip visuals
This commit is contained in:
@@ -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))
|
||||
|
||||
@@ -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
@@ -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
@@ -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.
|
||||
|
||||
@@ -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),
|
||||
|
||||
@@ -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()?)
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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>,
|
||||
|
||||
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user