From dcb8ba0c77562c28118cc04fbe1426aa553a3328 Mon Sep 17 00:00:00 2001 From: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com> Date: Mon, 27 Jul 2026 09:07:29 +0800 Subject: [PATCH 1/3] feat(theme): derive interaction state and status colors from the theme MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Selected options were indistinguishable from their neighbours on every bundled theme, worst on Dracula where a segmented control's active segment measured 1.03:1 against its siblings (issue #197). The cause was not Dracula: the app had no state model. Interaction state was a set of fixed `mix(bg, fg, t)` ratios scattered across `neutrals()` and `.opacity()` multipliers at call sites, and the fields nobody had noticed — `input`, `ring`, `switch`, `switch_thumb`, `danger`, `warning`, `success`, `link` — silently kept gpui-component's stock values, greys and Tailwind hues with no relationship to the active theme. Introduce the state model the app was missing. `Surface` is the interaction-state ladder for one painting surface, and every rung is derived to hit a *contrast ratio* against that surface rather than a fixed blend ratio. A fixed blend makes the perceived step depend on the seed: selected-vs-resting ranged from 1.20:1 (Catppuccin Latte) to 1.47:1 (Dracula). Every theme now lands on 1.70-1.72:1. The target is 1.70 because that is where the already-signed-off Dracula highlight sits, so the change is a no-op on the theme the look was tuned against and a lift for the rest. Ladders are per surface. Menu rows paint on `popover`, not the window background, which is why the context-menu highlight measured 1.20:1 against the panel it actually sat on while nominally being the fill that reads fine on the terminal ground. A fill alone never communicates selection, so `Surface` carries the label colours too: take a surface, take both channels. The app had already learned this three times locally — the tab chip, the chrome tiles and the sidebar each grew a hand-written fill-plus-text pair — while every site that had not been hand-fixed shipped a fill and nothing else. `ink_on` keeps a selected label readable on its own fill; without it, raising a fill toward the foreground drags the label under the resting ones around it (4.14:1 on Latte). `Semantics` derives danger/warning/success/info/link from each theme's own ANSI-16 instead of stock Tailwind, so a danger marker and an error line of shell output are finally the same red. Conditioning drives a seed toward black or white rather than toward the foreground: routing through Rosé Pine Dawn's purple-grey foreground collapsed its red and yellow into two indistinguishable mauves. Switch toggles read inverted on every dark theme because the knob fell back through two unset fields to a stock near-black. The knob now takes the light end of the theme's axis, the unchecked track comes off the ladder, and the checked track takes the accent — hue is the only axis left once the knob has claimed lightness, which is why every system switch is coloured. `segmented` is hand-rolled. The stock `ButtonGroup::outline()` path gave the selected segment the same border and the same label colour as its siblings, leaving one fill as the entire signal, and exposes no way to vary weight. Nine tests pin the properties rather than the current taste, so retuning the four contrast constants does not force a test edit but abandoning the model does. --- src/ui/file_tree.rs | 15 +- src/ui/forwards.rs | 3 +- src/ui/home.rs | 6 +- src/ui/presets.rs | 706 +++++++++++++++++++++++++++++++++++++++++- src/ui/right_panel.rs | 15 +- src/ui/settings.rs | 254 +++++++++------ src/ui/sftp.rs | 6 +- src/ui/tab_sidebar.rs | 15 +- src/ui/theme.rs | 214 ++++++++++++- 9 files changed, 1104 insertions(+), 130 deletions(-) diff --git a/src/ui/file_tree.rs b/src/ui/file_tree.rs index e9a4358f..f5a29b3d 100644 --- a/src/ui/file_tree.rs +++ b/src/ui/file_tree.rs @@ -1156,6 +1156,7 @@ impl Tty7App { let is_dir = row.entry.is_dir; let selected = self.tab_code().and_then(|c| c.selected.as_deref()) == Some(&*path); let muted = cx.theme().muted_foreground; + let sf = cx.global::().popover; // Unsaved edits used to be visible on the editor's file tabs; with those // gone the tree is the only place an open buffer is represented, so it has // to carry the dirty marker or unsaved work becomes invisible. @@ -1207,11 +1208,15 @@ impl Tty7App { .py_1() .rounded(cx.theme().radius) .cursor_pointer() - // Soft inset-pill highlight on the content surface. - .when(selected, |d| d.bg(cx.theme().accent)) - .when(!selected, |d| { - d.hover(|s| s.bg(cx.theme().accent.opacity(0.5))) - }) + // Soft inset-pill highlight on the content surface. The tree paints on + // `popover` (see the container below), so this is that surface's + // ladder — read explicitly rather than through `Theme::accent`, which + // is gpui-component's name for a row highlight and says nothing about + // which surface it was anchored to. Hover was `accent.opacity(0.5)`; a + // ladder rung is a real colour, so it doesn't change meaning depending + // on what it lands on. + .when(selected, |d| d.bg(gpui::rgb(sf.selected))) + .when(!selected, |d| d.hover(|s| s.bg(gpui::rgb(sf.hover)))) // Folders take the full foreground, files the muted tone — a neutral // weight difference, no hue, so the tree keeps the terminal's calm. .child(Icon::new(icon).xsmall().text_color(if is_dir { diff --git a/src/ui/forwards.rs b/src/ui/forwards.rs index 34df3278..c656f96e 100644 --- a/src/ui/forwards.rs +++ b/src/ui/forwards.rs @@ -240,6 +240,7 @@ impl Tty7App { ) -> Stateful
{ let theme = cx.theme(); let muted = theme.muted_foreground; + let sf = cx.global::().sidebar; let letter = match forward.kind { SshForwardKind::Local => "L", SshForwardKind::Remote => "R", @@ -277,7 +278,7 @@ impl Tty7App { .py(px(3.)) .rounded(px(5.)) .cursor_pointer() - .hover(|s| s.bg(theme.sidebar_accent.opacity(0.55))) + .hover(|s| s.bg(gpui::rgb(sf.hover))) .on_click(cx.listener(move |this, _, window, cx| { this.edit_managed_forward(forward_for_edit.clone(), window, cx) })) diff --git a/src/ui/home.rs b/src/ui/home.rs index 1de76fa9..020a324a 100644 --- a/src/ui/home.rs +++ b/src/ui/home.rs @@ -297,8 +297,10 @@ impl Tty7App { ) }; // The established popup language: a solid 10px-radius panel with inset - // soft-grey pill highlights — no translucency, no saturated accent. - let hover_fill = cx.theme().accent.opacity(0.6); + // soft-grey pill highlights — no translucency, no saturated accent. The + // panel is a popover, so its rows read that ladder's hover rung; the 0.6 + // alpha this replaces made the fill depend on whatever showed through. + let hover_fill = gpui::rgb(cx.global::().popover.hover); let mut panel = v_flex() .w(px(360.)) diff --git a/src/ui/presets.rs b/src/ui/presets.rs index e119a24d..35843cc2 100644 --- a/src/ui/presets.rs +++ b/src/ui/presets.rs @@ -1,7 +1,7 @@ //! The theme system: the serializable [`Theme`] seed model, the derived -//! shell-chrome [`Neutrals`], the [`Themes`] registry, and the loaders that turn -//! built-in tables, user YAML files, and imported iTerm2 schemes into concrete -//! themes. +//! shell-chrome [`Neutrals`], the interaction-state [`Surface`] ladders, the +//! [`Themes`] registry, and the loaders that turn built-in tables, user YAML +//! files, and imported iTerm2 schemes into concrete themes. //! //! A theme is a **minimal seed** — a background (solid or gradient), a //! foreground, one accent, an optional cursor/selection, and the ANSI-16 @@ -15,6 +15,14 @@ //! in an iTerm2 `*.itermcolors` scheme, which the loader imports on the fly. A //! theme's light/dark brightness is *inferred* from its background luminance — //! there is no `dark` field to set. +//! +//! # Interaction state +//! +//! Resting / hover / selected / pressed are a **first-class part of the theme**, +//! not something each widget re-derives. [`Theme::surface`] returns the state +//! ladder for whatever surface a widget paints on, and every rung is derived to +//! hit a *contrast ratio* against that surface rather than a fixed blend ratio — +//! see [`state`] for why that distinction is the whole point. use std::path::PathBuf; @@ -87,6 +95,10 @@ pub struct Theme { /// The shell-chrome palette derived from a theme's seed. Consumed by /// `apply_theme` to paint gpui-component's `Theme`. +/// +/// These are the theme's *static* colors — the ones that mean the same thing +/// wherever they appear. Anything that varies with interaction state lives in a +/// [`Surface`] instead. #[derive(Debug, Clone)] pub struct Neutrals { pub background: u32, @@ -99,12 +111,148 @@ pub struct Neutrals { pub caret: u32, pub selection: u32, pub sidebar: u32, - pub sidebar_sel: u32, pub sidebar_fg: u32, - pub list_active: u32, - pub list_hover: u32, + /// The seed accent, nudged until it can carry ink — see [`legible_accent`]. + pub accent: u32, } +/// One semantic colour in the three shapes the UI actually needs it in. +/// +/// Splitting them is not ceremony: a red that is legible as *text* on the +/// background is a different red from one that works as a filled button, and the +/// text on that button is a third. Collapsing them is how a danger button ends up +/// with unreadable text, or a warning label ends up below AA. +#[derive(Debug, Clone, Copy)] +pub struct Semantic { + /// Text (or a small solid mark) on the window background. WCAG AA, 4.5:1. + pub ink: u32, + /// A filled chip or button. The non-text floor, 3:1. + pub fill: u32, + /// Text on top of `fill`. + pub on_fill: u32, +} + +/// The status palette, derived from the theme's **own ANSI-16** rather than from +/// a fixed set of brand colours. +/// +/// Every theme already ships a red, green, yellow and cyan — they are what the +/// terminal in the same window paints with. Until this existed, gpui-component's +/// stock Tailwind values (`red-400`, `yellow-400`, `green-400`) were used +/// instead, which meant two unrelated reds on screen at once — `#ff5555` in the +/// terminal and `#f87171` on the delete button, on Dracula — and, on the light +/// themes, a danger colour at 2.45:1 that cleared no contrast floor at all. +/// +/// Each is conditioned by [`legible_ink`], so a seed too pale or too dark for its +/// role is deepened along its own hue rather than swapped for something foreign. +#[derive(Debug, Clone)] +pub struct Semantics { + pub danger: Semantic, + pub warning: Semantic, + pub success: Semantic, + pub info: Semantic, + /// Links. Distinct from `info` only in intent, but it is the field + /// gpui-component's markdown renderer reads, and left unset it resolves to + /// the body text colour — a link that looks exactly like prose. + pub link: Semantic, +} + +/// The contrast targets that define how loud each interaction state reads. +/// +/// **These four numbers are the app's only knobs for state prominence.** They +/// exist because the alternative — a fixed `mix(bg, fg, t)` per state, which is +/// what this file used to do — makes the *perceived* step depend on the theme. +/// The old ladder (`hover` 0.09, `sidebar_sel` 0.12, `list_active` 0.17) put +/// selected-vs-resting anywhere from 1.20:1 (Catppuccin Latte) to 1.47:1 +/// (Dracula), and the segmented control — which read gpui-component's stock +/// `input` grey instead of the ladder at all — landed at **1.03:1 on Dracula**, +/// i.e. invisible. See issue #197. +/// +/// A ratio target removes the theme from the equation: every theme lands on the +/// same perceived step, so tuning taste here retunes the whole app at once and +/// no theme can be an outlier. +/// +/// `SELECTED` is 1.70 because that is where the already-signed-off Dracula +/// highlight sits (`mix(bg, fg, 0.17)` ≈ `#4b4d56`, 1.72:1) — the value the +/// palette and menu look was tuned against. Anchoring *to* it keeps that look +/// and pulls the light themes, which were as low as 1.20:1, up to match. +pub mod state { + /// Pointer feedback. Deliberately a whisper: it answers the mouse without + /// competing with the selection it may be sitting next to. + pub const HOVER: f32 = 1.18; + /// The resting selection. Never the *only* signal — see [`super::Surface`]. + pub const SELECTED: f32 = 1.70; + /// Held down. One step past selected so pressing a selected item still reads. + pub const PRESSED: f32 = 2.10; + /// Resting label text. 4.6:1 keeps a de-emphasised label at WCAG AA on every + /// theme; the fixed `mix(fg, bg, 0.42)` it replaces drifted with the seed. + pub const TEXT_RESTING: f32 = 4.6; +} + +/// The interaction-state ladder for one painting surface: the fills for each +/// state plus the paired label colors. +/// +/// # Both channels, always +/// +/// A fill alone does not communicate selection. The app learned this the hard +/// way in three separate places — `tab_strip`'s active chip, `tab_strip`'s +/// chrome tiles and the settings sidebar each grew a hand-written +/// fill-plus-text-color pair, while every site that *hadn't* been hand-fixed +/// (segmented controls, the SSH profile list) shipped a fill and nothing else +/// and could not be read. So the text colors ride along in this struct: take a +/// `Surface`, take both channels. +/// +/// * **Fill** answers *which one* — it locates the selection in the row. +/// * **Text** (`text_selected` + `FontWeight::MEDIUM` vs `text_resting`) +/// answers *that this is it* — it survives a low-contrast fill, an oddly +/// seeded imported theme, and a color-blind reader. +/// +/// A keyboard-driven single cursor (the command palette, a context menu) can get +/// away with the fill alone because the eye tracks the one thing that moves. +/// A *static* choice among visible siblings cannot. +#[derive(Debug, Clone, Copy)] +pub struct Surface { + /// The surface itself — what a resting item paints on (i.e. no fill). + pub base: u32, + pub hover: u32, + pub selected: u32, + pub pressed: u32, + /// Label color for a resting/unselected item on this surface. + pub text_resting: u32, + /// Label color for the selected item. Pair it with `FontWeight::MEDIUM`. + pub text_selected: u32, +} + +/// Every surface the shell actually paints interactive rows on, published as a +/// GPUI global by `apply_theme` so a render pass can read the ladder without +/// re-resolving (and cloning) the theme registry every frame. +/// +/// Which surface a widget picks matters: a menu row sits on `popover`, not on +/// the window background, and a ladder anchored to the wrong surface is exactly +/// how the context-menu highlight ended up at 1.20:1 while claiming to be the +/// same 0.17 mix that reads fine on the window. +#[derive(Debug, Clone)] +pub struct Surfaces { + /// The window background — settings sheets, panels, the terminal ground. + pub window: Surface, + /// The sunk sidebar rail. + pub sidebar: Surface, + /// Elevated surfaces: menus, dropdowns, the command palette. + pub popover: Surface, +} + +impl Global for Surfaces {} + +/// The active theme's contrast-conditioned accent (see [`legible_accent`]), +/// published so a render pass can reach it without cloning the theme registry. +/// +/// Deliberately its own global rather than a field on [`Surfaces`]: the accent is +/// not a surface, and it has exactly one job — ink that must be *noticed* (the +/// caret, the focus ring, a switch's checked track). Every neutral fill in the +/// app comes from a `Surface`; this is the one thing that doesn't. +pub struct ActiveAccent(pub u32); + +impl Global for ActiveAccent {} + impl Theme { /// The representative solid background color. pub fn background_color(&self) -> u32 { @@ -125,15 +273,87 @@ impl Theme { border: mix(bg, fg, 0.16), secondary: mix(bg, fg, 0.09), muted: mix(bg, fg, 0.06), - muted_foreground: mix(fg, bg, 0.42), + muted_foreground: dim(fg, bg, state::TEXT_RESTING), popover: mix(bg, fg, 0.05), caret: self.caret.unwrap_or(self.accent), selection: self.selection.unwrap_or_else(|| mix(bg, fg, 0.20)), sidebar: mix(bg, fg, 0.03), - sidebar_sel: mix(bg, fg, 0.12), sidebar_fg: mix(fg, bg, 0.28), - list_active: mix(bg, fg, 0.17), - list_hover: mix(bg, fg, 0.09), + accent: legible_accent(bg, self.accent), + } + } + + /// The interaction-state ladder for content painted on `base`. + /// + /// Every rung blends `base` toward the (legibility-guaranteed) foreground + /// until it clears its [`state`] contrast target *against `base`* — so the + /// direction is "toward the text" by construction on light and dark themes + /// alike. The old fixed-mix ladder had no such guarantee: because the + /// segmented control's fill came from a stock grey rather than the theme, + /// selecting a segment made it *darker* than its siblings on light themes + /// and *lighter* on dark ones, by accident of where `#2f2f2f` happened to + /// fall. + pub fn surface(&self, base: u32) -> Surface { + let bg = self.background_color(); + let fg = legible_foreground(bg, self.foreground); + let selected = raise(base, fg, state::SELECTED); + Surface { + base, + hover: raise(base, fg, state::HOVER), + selected, + pressed: raise(base, fg, state::PRESSED), + // Dimmed from the foreground until it is merely AA-readable on this + // surface, rather than a fixed blend — a resting label must stay + // legible on an imported theme nobody vetted, too. + text_resting: dim(fg, base, state::TEXT_RESTING), + // Measured against the *selected fill*, not the surface: that fill is + // the ground this particular label actually sits on. See `ink_on`. + text_selected: ink_on(selected, fg, state::TEXT_RESTING), + } + } + + /// The status palette, built from this theme's own ANSI red/green/yellow/cyan. + /// + /// The normal (not bright) ANSI slots are the seeds: they are what the + /// terminal in the same window paints, so a danger marker and an error line + /// of shell output finally wear the same red. Where a slot is too pale or too + /// dark for a role, [`legible_ink`] deepens it along its own hue rather than + /// reaching for a colour the theme never declared. + pub fn semantics(&self) -> Semantics { + let bg = self.background_color(); + let fg = legible_foreground(bg, self.foreground); + let ansi = |i: usize| -> u32 { + let (r, g, b) = self.ansi16[i]; + (r as u32) << 16 | (g as u32) << 8 | b as u32 + }; + let build = |seed: u32| { + let fill = legible_ink(bg, seed, ACCENT_FLOOR); + Semantic { + ink: legible_ink(bg, seed, TEXT_FLOOR), + fill, + on_fill: ink_on(fill, fg, TEXT_FLOOR), + } + }; + Semantics { + danger: build(ansi(1)), + success: build(ansi(2)), + warning: build(ansi(3)), + info: build(ansi(6)), + link: build(ansi(6)), + } + } + + /// The ladders for all three surfaces the shell paints rows on. + pub fn surfaces(&self) -> Surfaces { + let m = self.neutrals(); + let mut sidebar = self.surface(m.sidebar); + // The rail's resting label is a tuned value (a lighter 0.28 dim, so rows + // in a sunk column don't read as disabled), not the generic AA floor. + sidebar.text_resting = m.sidebar_fg; + Surfaces { + window: self.surface(m.background), + sidebar, + popover: self.surface(m.popover), } } @@ -179,6 +399,109 @@ impl Theme { } } +/// The shared bisection behind [`raise`] and [`dim`]: find the blend of `from` +/// toward `toward` whose contrast against `from`-or-`toward` (whichever is the +/// surface, passed as `against`) sits at `target`. +/// +/// `against` must **not** sit strictly between the endpoints in luminance, or +/// the ratio along the blend is V-shaped rather than monotone and a bisection +/// would return an arbitrary one of the two answers. Every caller satisfies +/// that: [`raise`] and [`dim`] pass one of the endpoints itself, and +/// [`legible_accent`] passes the background, which an accent only reaches this +/// code by being *close* to — while `fg` is guaranteed 4.5:1 away from it. +fn bisect_contrast(from: u32, toward: u32, against: u32, target: f32) -> u32 { + // 12 halvings resolve t to ~0.0002 — far finer than an 8-bit channel step, + // so the result is exact in the only units that reach the screen. + const STEPS: u32 = 12; + let rising = contrast(toward, against) > contrast(from, against); + // Unreachable target (e.g. a 4.6:1 label floor on a surface whose own + // foreground only manages 4.5): clamp to the most extreme blend rather than + // returning something arbitrary from the middle of the range. + if rising && contrast(toward, against) <= target { + return toward; + } + if !rising && contrast(toward, against) >= target { + return toward; + } + let (mut lo, mut hi) = (0.0f32, 1.0f32); + for _ in 0..STEPS { + let m = 0.5 * (lo + hi); + let reached = if rising { + contrast(mix(from, toward, m), against) >= target + } else { + contrast(mix(from, toward, m), against) <= target + }; + if reached { hi = m } else { lo = m } + } + mix(from, toward, hi) +} + +/// Lift a fill off `base` toward `toward` (always the foreground) until it +/// clears `target` contrast against `base`. +/// +/// This is the primitive that replaced fixed `mix(bg, fg, t)` state colors: the +/// caller names the perceived step it wants and gets it on every theme, instead +/// of naming a blend and getting whatever step that theme's seed implies. +fn raise(base: u32, toward: u32, target: f32) -> u32 { + bisect_contrast(base, toward, base, target) +} + +/// Dim `ink` toward `surface` until it sits *at* `target` contrast against +/// `surface` — a de-emphasised label that is still guaranteed readable, rather +/// than a fixed blend whose ratio drifts with the seed. +fn dim(ink: u32, surface: u32, target: f32) -> u32 { + bisect_contrast(ink, surface, surface, target) +} + +/// The label color for text sitting on `fill`: the theme's foreground when it +/// still clears `target` there, otherwise that foreground pushed *past* itself +/// (toward white on a dark fill, black on a light one) until it does. +/// +/// This exists because the fill ladder and the text channel pull against each +/// other. Raising a fill toward the foreground necessarily moves the ground +/// closer to the label it carries, and on a theme whose foreground isn't an +/// extreme — Catppuccin Latte's `#4c4f69` is only 7.4:1 on its own background — +/// a 1.70:1 selected fill drags the selected label down to 4.14:1, *below* the +/// resting labels around it. A selection whose text is harder to read than its +/// neighbours' is not a selection. +/// +/// Pushing along the fg→extreme axis rather than snapping to pure black/white +/// keeps the theme's ink hue; Latte's selected label becomes a deeper version of +/// the same blue-grey, not a foreign pure black. +/// +/// Three tiers, in order of how much of the theme they preserve: the foreground +/// itself, then the foreground deepened along its own side, then — only when that +/// side simply cannot reach the target — the opposite extreme. That last tier is +/// not hypothetical: the Light theme's danger fill is `#d1242f`, a mid-dark red +/// against which even *pure black* tops out at 3.96:1. White text on a dark red +/// button is the right answer there, and it is only reachable by looking the +/// other way. +fn ink_on(fill: u32, fg: u32, target: f32) -> u32 { + if contrast(fg, fill) >= target { + return fg; + } + // Push *away* from the fill along the axis the foreground already sits on — + // darker ink gets darker, lighter ink lighter. Choosing the extreme by the + // fill's own brightness instead is wrong at the midpoint: Latte's `#b8bac6` + // fill reads as "dark" to a `< 0.5` luminance test, which sends its already + // dark ink toward white and *lowers* the contrast it was called to raise. + let near = if relative_luminance(fg) < relative_luminance(fill) { + 0x000000 + } else { + 0xffffff + }; + let deepened = bisect_contrast(fg, near, fill, target); + if contrast(deepened, fill) >= target { + return deepened; + } + // The foreground's own side is exhausted. Take whichever extreme reads best. + if contrast(fill, 0xffffff) >= contrast(fill, 0x000000) { + 0xffffff + } else { + 0x000000 + } +} + /// Blend `a` toward `b` by `t` (0.0 = all `a`, 1.0 = all `b`), per channel. pub(crate) fn mix(a: u32, b: u32, t: f32) -> u32 { let (ar, ag, ab) = (a >> 16 & 0xff, a >> 8 & 0xff, a & 0xff); @@ -211,6 +534,15 @@ fn relative_luminance(c: u32) -> f32 { 0.2126 * chan(c >> 16 & 0xff) + 0.7152 * chan(c >> 8 & 0xff) + 0.0722 * chan(c & 0xff) } +/// The largest per-channel difference between two colors (0…255). A crude but +/// hue-aware "are these the same colour" check — contrast alone can't tell red +/// from green, since they can share a luminance. +#[cfg(test)] +fn channel_distance(a: u32, b: u32) -> u32 { + let d = |sh: u32| (a >> sh & 0xff).abs_diff(b >> sh & 0xff); + d(16).max(d(8)).max(d(0)) +} + /// WCAG contrast ratio between two colors (1.0 … 21.0). fn contrast(a: u32, b: u32) -> f32 { let (l1, l2) = (relative_luminance(a), relative_luminance(b)); @@ -223,6 +555,51 @@ fn is_dark(bg: u32) -> bool { relative_luminance(bg) < 0.5 } +/// Whether `a` is the lighter of two colors. Lets callers pick "the light end of +/// this theme's axis" without caring which of background/foreground that is — +/// e.g. a switch knob, which is near-white in both macOS appearances. +pub(crate) fn is_lighter(a: u32, b: u32) -> bool { + relative_luminance(a) > relative_luminance(b) +} + +/// The minimum contrast an accent must clear against the background before it is +/// allowed to carry ink (a caret, a link, a focus ring). 3:1 is the WCAG +/// large-text / non-text floor. +const ACCENT_FLOOR: f32 = 3.0; + +/// The WCAG AA text floor. What a coloured *label* must clear on its ground. +const TEXT_FLOOR: f32 = 4.5; + +/// Make a hued seed usable at `floor` against `bg`: keep it when it already +/// clears, otherwise drive it *away from the background* — toward white on a dark +/// theme, black on a light one — until it does. +/// +/// This is why a seed colour can never be used raw. The bundled Light theme's +/// accent `#00c2ff` manages 2.07:1 on white and the built-ins' accents span +/// 2.07:1 to 8.43:1; the ANSI reds behind [`Semantics`] are just as uneven. Any +/// unconditional use of one is a coin flip on some theme. +/// +/// It drives toward black/white rather than toward the theme's foreground because +/// the foreground is usually *tinted*, and blending into a tint destroys hue at +/// exactly the moment hue matters most — when a seed is far from the floor and so +/// has to travel far. On Rosé Pine Dawn, whose foreground is the purple-grey +/// `#575279`, routing through it turned the ANSI red into `#9a5e7a` and the ANSI +/// yellow into `#876a62`: two muddy mauves a user could not tell apart, which is +/// no use at all for "did that fail or is it just a warning". Black and white are +/// neutral, so the hue survives the trip. +fn legible_ink(bg: u32, seed: u32, floor: f32) -> u32 { + if contrast(seed, bg) >= floor { + return seed; + } + let away = if is_dark(bg) { 0xffffff } else { 0x000000 }; + bisect_contrast(seed, away, bg, floor) +} + +/// The accent conditioned for ink (caret, focus ring, a switch's checked track). +fn legible_accent(bg: u32, accent: u32) -> u32 { + legible_ink(bg, accent, ACCENT_FLOOR) +} + /// Guarantee a legible default text color: keep the authored `fg` if it clears /// the WCAG AA text threshold (4.5) against `bg`, otherwise fall back to pure /// black or white — whichever contrasts more. Protects hand-authored and @@ -980,6 +1357,315 @@ mod tests { } } + /// Every surface's state ladder must be *strictly ordered and separable* on + /// every built-in — this is the regression guard for issue #197, where the + /// segmented control's selected fill sat 1.03:1 from its unselected siblings + /// on Dracula (and no better than 1.20:1 on any other bundled theme). + /// + /// The assertions are deliberately below the [`state`] targets: they pin the + /// *property* (a selection is distinguishable from resting and from hover on + /// every surface of every theme), not the current taste, so retuning the + /// constants doesn't force a test edit but abandoning the ladder does. + #[test] + fn state_ladder_is_separable_on_every_surface() { + for t in builtins() { + let s = t.surfaces(); + for (name, sf) in [ + ("window", s.window), + ("sidebar", s.sidebar), + ("popover", s.popover), + ] { + let sel_base = contrast(sf.selected, sf.base); + let sel_hover = contrast(sf.selected, sf.hover); + let hover_base = contrast(sf.hover, sf.base); + assert!( + sel_base >= 1.6, + "{}/{name}: selected is only {sel_base:.2}:1 from the surface", + t.id + ); + assert!( + sel_hover >= 1.3, + "{}/{name}: selected is only {sel_hover:.2}:1 from hover", + t.id + ); + assert!( + hover_base >= 1.1, + "{}/{name}: hover is only {hover_base:.2}:1 from the surface", + t.id + ); + assert!( + contrast(sf.pressed, sf.base) > sel_base, + "{}/{name}: pressed must read past selected", + t.id + ); + } + } + } + + /// The whole point of a ratio target over a blend ratio: the *perceived* step + /// is the same on every theme. A fixed `mix` put selected-vs-resting between + /// 1.20:1 and 1.47:1 depending on the seed; these must all agree. + #[test] + fn state_ladder_is_theme_independent() { + let ratios: Vec = builtins() + .iter() + .map(|t| { + let w = t.surfaces().window; + contrast(w.selected, w.base) + }) + .collect(); + let (lo, hi) = ratios + .iter() + .fold((f32::MAX, 0.0f32), |(l, h), r| (l.min(*r), h.max(*r))); + assert!( + hi - lo < 0.05, + "selected step drifts across themes: {lo:.2}:1 … {hi:.2}:1" + ); + assert!( + (lo - state::SELECTED).abs() < 0.05, + "selected step {lo:.2}:1 missed its {:.2}:1 target", + state::SELECTED + ); + } + + /// Anchoring `SELECTED` to 1.70 must leave the signed-off Dracula highlight + /// where it was — the value the palette/menu look was tuned against. This is + /// what makes the fix a no-op on the theme it was designed on and a lift for + /// everything else; if a retune moves Dracula, that was a taste decision and + /// wants to be a deliberate one. + #[test] + fn dracula_selection_matches_the_signed_off_grey() { + let dracula = builtins().into_iter().find(|t| t.id == "dracula").unwrap(); + let bg = dracula.background_color(); + let legacy = mix(bg, dracula.foreground, 0.17); // the old `list_active` + let now = dracula.surfaces().window.selected; + assert!( + contrast(now, legacy) < 1.05, + "Dracula's selection moved: {now:#08x} vs the tuned {legacy:#08x}" + ); + } + + /// A resting label must clear WCAG AA on the surface it sits on, for every + /// theme *and* every surface — a menu row's label sits on `popover`, not on + /// the window background, and the fixed dim it replaced was anchored to the + /// latter wherever it was used. + #[test] + fn resting_labels_stay_readable() { + for t in builtins() { + let s = t.surfaces(); + for (name, sf) in [("window", s.window), ("popover", s.popover)] { + let ratio = contrast(sf.text_resting, sf.base); + assert!( + ratio >= 4.5, + "{}/{name}: resting label only {ratio:.2}:1", + t.id + ); + } + } + } + + /// The text channel's two invariants, on every surface of every theme. + /// + /// 1. A selected label is readable **on its own fill** — never merely on the + /// surface it would have sat on unselected. Getting this wrong is subtle: + /// raising the fill toward the foreground eats the label's contrast, and + /// Catppuccin Latte's selected label landed at 4.14:1 (below the 4.57:1 of + /// the *resting* labels beside it) before `ink_on` existed. + /// 2. The two label colors differ enough to read as a step, so the channel + /// still says something when the fill is washed out — a translucent + /// window, a blurred background, an imported seed nobody vetted. + #[test] + fn label_channel_is_readable_and_stepped() { + for t in builtins() { + let s = t.surfaces(); + for (name, sf) in [ + ("window", s.window), + ("sidebar", s.sidebar), + ("popover", s.popover), + ] { + let on_fill = contrast(sf.text_selected, sf.selected); + assert!( + on_fill >= 4.5, + "{}/{name}: selected label only {on_fill:.2}:1 on its own fill", + t.id + ); + let step = contrast(sf.text_selected, sf.text_resting); + assert!( + step >= 1.35, + "{}/{name}: label step is only {step:.2}:1 — the channel says nothing", + t.id + ); + } + } + } + + /// A switch's two tracks must both be distinguishable *from each other* and + /// from the surface, and the knob — one colour serving both states — has to + /// stay visible on each. + /// + /// The toggles shipped inverted on every dark theme (stock near-black knob on + /// a near-white checked track, and invisible on the unchecked one) because + /// `switch`, `switch_thumb` and `tokens.background` were all unset. This pins + /// the arrangement that replaced it: knob at the light end of the theme's + /// axis, unchecked track on the ladder, checked track on the accent. + /// + /// The knob-on-checked-track floor is 1.25, not 3 — a white knob on a + /// coloured track is separated by the component's `shadow_md`, exactly as it + /// is in macOS, and demanding raw contrast there would force every accent to + /// go dark. + #[test] + fn switch_tracks_and_knob_stay_legible() { + for t in builtins() { + let m = t.neutrals(); + let unchecked = t.surfaces().window.selected; + let checked = m.accent; + let knob = if is_lighter(m.background, m.foreground) { + m.background + } else { + m.foreground + }; + assert!( + contrast(knob, unchecked) >= 1.25, + "{}: knob {knob:#08x} lost on the unchecked track {unchecked:#08x}", + t.id + ); + assert!( + contrast(knob, checked) >= 1.25, + "{}: knob {knob:#08x} lost on the checked track {checked:#08x}", + t.id + ); + // The two states must not be near-identical greys, or the switch says + // nothing but the knob's position. + assert!( + contrast(checked, unchecked) >= 1.3, + "{}: checked and unchecked tracks are {:.2}:1 apart", + t.id, + contrast(checked, unchecked) + ); + } + } + + /// Status colours must clear their floors on every theme, and — the point of + /// deriving them from the theme's own ANSI-16 — must stay *recognisable* as + /// red / green / yellow rather than converging on the foreground. + /// + /// Before this, `danger` was gpui-component's stock `#f87171` on every theme: + /// 2.45:1 on Catppuccin Latte (under even the 3:1 non-text floor) and a + /// different red from the `#ff5555` the terminal beside it paints. + #[test] + fn semantic_colors_clear_their_floors() { + for t in builtins() { + let bg = t.background_color(); + let s = t.semantics(); + for (name, c) in [ + ("danger", s.danger), + ("warning", s.warning), + ("success", s.success), + ("info", s.info), + ("link", s.link), + ] { + assert!( + contrast(c.ink, bg) >= TEXT_FLOOR - 0.01, + "{}/{name}: ink {:#08x} only {:.2}:1 on the background", + t.id, + c.ink, + contrast(c.ink, bg) + ); + assert!( + contrast(c.fill, bg) >= ACCENT_FLOOR - 0.01, + "{}/{name}: fill {:#08x} only {:.2}:1 on the background", + t.id, + c.fill, + contrast(c.fill, bg) + ); + assert!( + contrast(c.on_fill, c.fill) >= TEXT_FLOOR - 0.01, + "{}/{name}: text on its own fill is only {:.2}:1", + t.id, + contrast(c.on_fill, c.fill) + ); + } + // Conditioning must not wash the hues into each other: a user has to + // be able to tell an error from a success without reading the label. + for (a, b, pair) in [ + (s.danger.ink, s.success.ink, "danger/success"), + (s.danger.ink, s.warning.ink, "danger/warning"), + (s.success.ink, s.warning.ink, "success/warning"), + ] { + assert!( + channel_distance(a, b) >= 40, + "{}: {pair} collapsed to nearly the same colour ({a:#08x} vs {b:#08x})", + t.id + ); + } + } + } + + /// Each status colour must stay recognisably its own theme's hue — that is + /// the whole reason for sourcing them from ANSI-16 rather than a brand set. + /// Where a seed already clears its floor it must pass through untouched. + #[test] + fn semantic_colors_keep_the_theme_hue() { + let dracula = builtins().into_iter().find(|t| t.id == "dracula").unwrap(); + let ansi_red = { + let (r, g, b) = dracula.ansi16[1]; + (r as u32) << 16 | (g as u32) << 8 | b as u32 + }; + assert_eq!(ansi_red, 0xff5555, "Dracula's ANSI red moved"); + // 4.53:1 on Dracula's background — already over AA, so it is used as-is + // and the danger dot matches the terminal's own error output exactly. + assert_eq!(dracula.semantics().danger.ink, ansi_red); + } + + /// Every theme's accent must be able to carry ink (caret, link, focus ring). + /// The bundled Light theme's raw `#00c2ff` manages 2.07:1 on white, which is + /// why this conditioning exists rather than using the seed directly. + #[test] + fn accents_are_conditioned_to_carry_ink() { + for t in builtins() { + let bg = t.background_color(); + let a = t.neutrals().accent; + let ratio = contrast(a, bg); + assert!( + ratio >= ACCENT_FLOOR - 0.01, + "{}: accent {a:#08x} only {ratio:.2}:1 on the background", + t.id + ); + } + // ...and a seed that already clears the floor is passed through untouched, + // so conditioning never dulls a theme that didn't need it. + let rose = builtins() + .into_iter() + .find(|t| t.id == "rose_pine") + .unwrap(); + assert_eq!(rose.neutrals().accent, rose.accent); + } + + /// `raise`/`dim` must land *just* past their targets from either direction, + /// and clamp rather than return a mid-range guess when one is unreachable. + /// + /// "Just past" is one 8-bit channel step, not zero: the tightest grey clearing + /// 2.0:1 on black is `#404040` at 2.025:1, because a channel step near there + /// moves the ratio by ~0.03. Anything tighter would be asserting sub-pixel + /// precision the framebuffer can't hold. + #[test] + fn contrast_bisection_hits_its_target() { + const SLACK: f32 = 0.05; + // Reachable, rising: a fill lifted off black. + let f = raise(0x000000, 0xffffff, 2.0); + assert!((2.0..2.0 + SLACK).contains(&contrast(f, 0x000000))); + // Reachable, rising: lifted off white — the direction flips, the API + // doesn't (this is what the old fixed-mix ladder got wrong per theme). + let f = raise(0xffffff, 0x000000, 2.0); + assert!((2.0..2.0 + SLACK).contains(&contrast(f, 0xffffff))); + // Reachable, falling: white ink dimmed to just above AA on black. + let d = dim(0xffffff, 0x000000, 4.5); + assert!((contrast(d, 0x000000) - 4.5).abs() < SLACK); + // Unreachable: nothing between these two clears 21:1, so clamp to the + // far endpoint instead of bisecting to something arbitrary. + assert_eq!(raise(0x000000, 0x808080, 21.0), 0x808080); + } + /// A bad foreground is swapped for a legible black/white; a good one is kept. #[test] fn legible_foreground_rescues_unreadable_text() { diff --git a/src/ui/right_panel.rs b/src/ui/right_panel.rs index fea8c475..f551c8d3 100644 --- a/src/ui/right_panel.rs +++ b/src/ui/right_panel.rs @@ -962,6 +962,9 @@ impl Tty7App { /// Newest first because that's the end you came from: you scrolled past the /// thing you want, and the list should start where your attention is. fn render_panel_outline(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + // This panel is a sunk rail (see the `sidebar` fill on its container), so + // its rows read the sidebar ladder. + let sf = cx.global::().sidebar; let Some(leaf) = self .tabs .get(self.active) @@ -1027,7 +1030,7 @@ impl Tty7App { .py(px(3.)) .rounded(px(5.)) .cursor_pointer() - .hover(|s| s.bg(cx.theme().sidebar_accent.opacity(0.55))) + .hover(|s| s.bg(gpui::rgb(sf.hover))) .on_click(cx.listener(move |_this, _, _window, cx| { leaf.update(cx, |view, cx| { view.scroll_to_mark(row, cx); @@ -1074,6 +1077,7 @@ impl Tty7App { /// diff overlay's hunk cards, which need far more than 260px to be readable. /// Clicking a row opens the full overlay on that repo. fn render_panel_changes(&mut self, window: &mut Window, cx: &mut Context) -> AnyElement { + let sf = cx.global::().sidebar; let cwd = self .tabs .get(self.active) @@ -1160,8 +1164,13 @@ impl Tty7App { .py(px(3.)) .rounded(px(5.)) .cursor_pointer() - .hover(|s| s.bg(cx.theme().sidebar_accent.opacity(0.55))) - .when(selected, |s| s.bg(cx.theme().sidebar_accent)) + // The rail's own ladder. Hover used to be this fill at + // 55% alpha, which on a light theme is a tint nobody + // can see — the same mistake `chrome_tile_variant_for` + // already documents having fixed in the title bar, made + // again here because there was nothing to reuse. + .hover(|s| s.bg(gpui::rgb(sf.hover))) + .when(selected, |s| s.bg(gpui::rgb(sf.selected))) .on_click({ let cwd = cwd.clone(); let path = path.clone(); diff --git a/src/ui/settings.rs b/src/ui/settings.rs index 76e3aad5..ecb54bf5 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -11,8 +11,7 @@ use gpui::{ prelude::*, px, relative, rgb, }; use gpui_component::InteractiveElementExt as _; -use gpui_component::Selectable as _; -use gpui_component::button::{Button, ButtonGroup, ButtonVariants as _}; +use gpui_component::button::{Button, ButtonVariants as _}; use gpui_component::color_picker::{ColorPicker, ColorPickerState}; use gpui_component::input::{Input, InputEvent, InputState}; use gpui_component::link::Link; @@ -20,7 +19,6 @@ use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, Po use gpui_component::select::{SearchableVec, Select, SelectState}; use gpui_component::sidebar::{Sidebar, SidebarCollapsible, SidebarMenu, SidebarMenuItem}; use gpui_component::slider::{Slider, SliderState}; -use gpui_component::switch::Switch; use gpui_component::{ ActiveTheme as _, Icon, IconName, Sizable as _, WindowExt as _, h_flex, v_flex, }; @@ -1147,7 +1145,7 @@ impl Tty7App { .px_2p5() .mx_neg_2p5() .rounded_lg() - .hover(|h| h.bg(theme.secondary.opacity(0.2))) + .hover(|h| h.bg(gpui::rgb(cx.global::().window.hover))) .child( v_flex() .gap_0p5() @@ -1173,13 +1171,30 @@ impl Tty7App { .child(h_flex().flex_shrink_0().child(control)) } - /// A segmented control (gpui-component's `ButtonGroup`, outline) for a small - /// set of mutually-exclusive options — the refined stand-in for a raw row of - /// radio circles, which read as an unstyled form beside the sheet's tuned - /// steppers and chips. Joined outline segments with a soft-filled active one - /// speak the same segmented language as the −│value│+ stepper; `small` pins - /// every option control to the same 24px height as the selects beside them. - /// `selected` is the active index; `on_pick` fires with the newly chosen one. + /// A segmented control for a small set of mutually-exclusive options — the + /// refined stand-in for a raw row of radio circles, which read as an unstyled + /// form beside the sheet's tuned steppers and chips. Joined segments in a + /// single outlined track, one of them filled, speak the same segmented + /// language as the −│value│+ stepper right beside them; the 24px height + /// matches the selects in the same rows. `selected` is the active index; + /// `on_pick` fires with the newly chosen one. + /// + /// # Why this is hand-rolled + /// + /// It used to be gpui-component's `ButtonGroup::outline()` with + /// `Button::selected`, and that is what issue #197 was reported against. That + /// path derives the selected segment's fill from `Theme::input` and gives it + /// the *same* border and the *same* label color as its unselected siblings — + /// so the entire selection signal was one fill, and that fill came from a + /// grey unrelated to the active theme. On Dracula it measured **1.03:1**. + /// + /// `Theme::input` is now themed (see `ui::theme::apply_theme`), which fixes + /// the stock control for inputs and selects. But a segmented control is the + /// one place in the app where several options sit visibly side by side with a + /// *static* selection, so it is precisely where a fill alone is not enough + /// (see `presets::Surface`) — and the stock button exposes no way to vary the + /// label's weight. Owning the 30 lines is cheaper than a fork patch, and it + /// puts the control on the same ladder every hand-rolled surface reads. pub(crate) fn segmented( &self, id: &'static str, @@ -1188,19 +1203,61 @@ impl Tty7App { cx: &mut Context, on_pick: impl Fn(&mut Self, usize, &mut Window, &mut Context) + 'static, ) -> AnyElement { - ButtonGroup::new(id) - .outline() - .small() + let sf = cx.global::().window; + let border = cx.theme().border; + let on_pick = std::rc::Rc::new(on_pick); + h_flex() + .id(id) + .h(px(24.)) + .rounded_lg() + .border_1() + .border_color(border) + // The track paints its own ground rather than letting the sheet show + // through. Every rung of the ladder was derived against this colour, + // so painting it is what makes those ratios true — a control that + // leaves its ground to whatever it happens to be composited over is + // the shape of the bug this whole change is about. + .bg(gpui::rgb(sf.base)) + // One track, clipped so the end segments' fills follow its rounding + // instead of squaring off the corners they sit in. + .overflow_hidden() .children(options.iter().enumerate().map(|(i, label)| { - // `(id, i)` keeps each segment's element id unique across the - // several segmented controls on the page. - Button::new((id, i)).label(*label).selected(i == selected) - })) - .on_click(cx.listener(move |this, clicks: &Vec, window, cx| { - // Single-select: `clicks` carries just the newly chosen index. - if let Some(&ix) = clicks.first() { - on_pick(this, ix, window, cx); - } + let active = i == selected; + let on_pick = on_pick.clone(); + h_flex() + // `(id, i)` keeps each segment's element id unique across the + // several segmented controls on the page. + .id((id, i)) + .items_center() + .justify_center() + .h_full() + .px_2p5() + .text_sm() + .cursor_pointer() + // Hairlines *between* segments only — the track already owns + // its outer edge, and a border on the first segment would + // double it. + .when(i > 0, |s| s.border_l_1().border_color(border)) + // Both channels, every time. The fill locates the selection in + // the row; the label color and weight say it is the one — and + // keep saying it on a translucent window, where the fill is + // washing over whatever is behind the sheet. + .when(active, |s| { + s.bg(gpui::rgb(sf.selected)) + .text_color(gpui::rgb(sf.text_selected)) + .font_weight(FontWeight::MEDIUM) + }) + .when(!active, |s| { + s.text_color(gpui::rgb(sf.text_resting)) + .hover(|h| h.bg(gpui::rgb(sf.hover))) + }) + // Pressed reads past selected, so pushing the segment that is + // already chosen still acknowledges the click. + .active(|s| s.bg(gpui::rgb(sf.pressed))) + .child(*label) + .on_click(cx.listener(move |this, _, window, cx| { + on_pick(this, i, window, cx); + })) })) .into_any_element() } @@ -1210,7 +1267,10 @@ impl Tty7App { let theme = cx.theme(); let foreground = theme.foreground; let border = theme.border; - let hover_bg = theme.secondary.opacity(0.6); + // Hover comes off the ladder; `stepper_bg` stays a soft resting tint — + // it decorates a container rather than signalling a state, which is the + // one job an alpha-multiplied grey is still fine for. + let hover_bg = gpui::rgb(cx.global::().window.hover); let stepper_bg = theme.secondary.opacity(0.35); let font_size = self.font_size; let (font_select, font_bold_select, font_italic_select) = match self.active_settings() { @@ -1334,7 +1394,7 @@ impl Tty7App { let font_family_control = font_dropdown(&font_select); let font_bold_control = font_dropdown(&font_bold_select); let font_italic_control = font_dropdown(&font_italic_select); - let ligature_switch = Switch::new("font-ligatures") + let ligature_switch = crate::ui::theme::switch("font-ligatures", cx) .checked(font_ligatures) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_font_ligatures(*on, cx))) .into_any_element(); @@ -1360,7 +1420,7 @@ impl Tty7App { ); // Blink lives here beside the shape — one Cursor home, not "shape is // appearance, blink is behavior" split across two pages. - let blink_switch = Switch::new("cursor-blink") + let blink_switch = crate::ui::theme::switch("cursor-blink", cx) .checked(cursor_blink) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_cursor_blink(*on, cx))) .into_any_element(); @@ -1463,7 +1523,7 @@ impl Tty7App { .child(format!("{:.0}%", opacity * 100.)), ) .into_any_element(); - let blur_switch = Switch::new("window-blur") + let blur_switch = crate::ui::theme::switch("window-blur", cx) .checked(blur) .on_click( cx.listener(|this, on: &bool, window, cx| this.set_window_blur(*on, window, cx)), @@ -1723,6 +1783,8 @@ impl Tty7App { /// saved-profile list (each row selects into the detail pane). fn render_ssh_master(&self, cx: &mut Context) -> AnyElement { let muted = cx.theme().muted_foreground; + // Rows in this list paint on the settings sheet, i.e. the window surface. + let sf = cx.global::().window; let profiles = cx.global::().ssh_profiles.clone(); let detail = self .active_settings() @@ -1790,12 +1852,18 @@ impl Tty7App { .py_2() .px_2() .rounded_md() - .when(selected, |r| r.bg(cx.theme().secondary.opacity(0.4))) + // The window ladder, both channels. This row used to tint with + // `secondary.opacity(0.4)` — a 9% grey at 40% alpha, i.e. an + // effective 3.6% tint, which put the selected row 1.05–1.12:1 + // from a resting one and 1.02–1.06:1 from a hovered one. It was + // the second site named in issue #197. Multiplying a soft grey + // by alpha is how a fill silently disappears: the result depends + // on whatever it happens to be composited over, which is + // exactly the unknown a per-surface ladder removes. + .when(selected, |r| r.bg(gpui::rgb(sf.selected))) // A subtle hover fill so the whole row reads as the (clickable) // select affordance; the selected row keeps its own highlight. - .when(!selected, |r| { - r.hover(|s| s.bg(cx.theme().secondary.opacity(0.2))) - }) + .when(!selected, |r| r.hover(|s| s.bg(gpui::rgb(sf.hover)))) // Left-click anywhere on the row selects it — its edit form // opens in the detail pane. Clicks on the trailing ⋯ are // swallowed by its wrapper, so they don't also start an edit. @@ -1818,7 +1886,21 @@ impl Tty7App { v_flex() .min_w_0() .gap_0p5() - .child(div().text_sm().truncate().child(title)) + .child( + div() + .text_sm() + .truncate() + // The label channel: the selected row's title + // steps up in colour and weight, so which + // profile is loaded in the detail pane reads + // from the type and not from the fill alone. + .when(selected, |d| { + d.text_color(gpui::rgb(sf.text_selected)) + .font_weight(FontWeight::MEDIUM) + }) + .when(!selected, |d| d.text_color(gpui::rgb(sf.text_resting))) + .child(title), + ) .child(div().text_xs().text_color(muted).truncate().child(subtitle)), ) .child( @@ -1964,13 +2046,13 @@ impl Tty7App { /// under the form or the empty-state hint. fn render_ssh_security_block(&self, cx: &mut Context) -> AnyElement { let verify = cx.global::().verify_host_keys; - let verify_switch = Switch::new("ssh-verify-host-keys") + let verify_switch = crate::ui::theme::switch("ssh-verify-host-keys", cx) .checked(verify) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_verify_host_keys(*on, cx))) .into_any_element(); let warn_on_close = cx.global::().ssh_warn_on_close; - let warn_switch = Switch::new("ssh-warn-on-close") + let warn_switch = crate::ui::theme::switch("ssh-warn-on-close", cx) .checked(warn_on_close) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_ssh_warn_on_close(*on, cx))) .into_any_element(); @@ -2639,7 +2721,7 @@ impl Tty7App { self.settings_row( "Agent forwarding", "Forward the local ssh-agent to the session.", - Switch::new("ssh-form-agent") + crate::ui::theme::switch("ssh-form-agent", cx) .checked(form.agent_forward) .on_click(cx.listener(|this, on: &bool, _w, cx| { if let Some(f) = this.ssh_form_mut() { @@ -2732,7 +2814,7 @@ impl Tty7App { self.settings_row( "X11 forwarding", "Request X11 forwarding (needs XQuartz on macOS).", - Switch::new("ssh-form-x11") + crate::ui::theme::switch("ssh-form-x11", cx) .checked(form.x11) .on_click(cx.listener(|this, on: &bool, _w, cx| { if let Some(f) = this.ssh_form_mut() { @@ -2748,7 +2830,7 @@ impl Tty7App { self.settings_row( "Shell integration", "Let the remote shell report prompts, exit codes and directory.", - Switch::new("ssh-form-shell-integration") + crate::ui::theme::switch("ssh-form-shell-integration", cx) .checked(form.shell_integration) .on_click(cx.listener(|this, on: &bool, _w, cx| { if let Some(f) = this.ssh_form_mut() { @@ -2771,7 +2853,7 @@ impl Tty7App { self.settings_row( "Skip banner", "Suppress the server login banner.", - Switch::new("ssh-form-banner") + crate::ui::theme::switch("ssh-form-banner", cx) .checked(form.skip_banner) .on_click(cx.listener(|this, on: &bool, _w, cx| { if let Some(f) = this.ssh_form_mut() { @@ -2978,11 +3060,11 @@ impl Tty7App { None => return div().into_any_element(), }; - let link_switch = Switch::new("term-link-url") + let link_switch = crate::ui::theme::switch("term-link-url", cx) .checked(link_url) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_link_url(*on, cx))) .into_any_element(); - let ssh_loopback_switch = Switch::new("term-ssh-loopback-forward") + let ssh_loopback_switch = crate::ui::theme::switch("term-ssh-loopback-forward", cx) .checked(ssh_loopback_forward) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_ssh_loopback_forward(*on, cx))) .into_any_element(); @@ -3005,17 +3087,17 @@ impl Tty7App { }, ); - let focus_switch = Switch::new("term-focus-follows") + let focus_switch = crate::ui::theme::switch("term-focus-follows", cx) .checked(focus_follows) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_focus_follows_mouse(*on, cx))) .into_any_element(); - let mouse_hide_switch = Switch::new("term-mouse-hide") + let mouse_hide_switch = crate::ui::theme::switch("term-mouse-hide", cx) .checked(mouse_hide) .on_click( cx.listener(|this, on: &bool, _w, cx| this.set_mouse_hide_while_typing(*on, cx)), ) .into_any_element(); - let mouse_report_switch = Switch::new("term-mouse-report") + let mouse_report_switch = crate::ui::theme::switch("term-mouse-report", cx) .checked(mouse_reporting) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_mouse_reporting(*on, cx))) .into_any_element(); @@ -3143,30 +3225,30 @@ impl Tty7App { let copy_on_select = cfg.copy_on_select; let clip_trim = cfg.clipboard_trim_trailing_spaces; - let tab_completion_switch = Switch::new("term-tab-completion") + let tab_completion_switch = crate::ui::theme::switch("term-tab-completion", cx) .checked(tab_completion) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_tab_completion(*on, cx))) .into_any_element(); - let history_search_switch = Switch::new("term-history-search") + let history_search_switch = crate::ui::theme::switch("term-history-search", cx) .checked(history_search) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_history_search(*on, cx))) .into_any_element(); - let smart_select_switch = Switch::new("term-smart-select") + let smart_select_switch = crate::ui::theme::switch("term-smart-select", cx) .checked(smart_select) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_smart_select(*on, cx))) .into_any_element(); - let copy_on_select_switch = Switch::new("term-copy-on-select") + let copy_on_select_switch = crate::ui::theme::switch("term-copy-on-select", cx) .checked(copy_on_select) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_copy_on_select(*on, cx))) .into_any_element(); - let trim_switch = Switch::new("term-clip-trim") + let trim_switch = crate::ui::theme::switch("term-clip-trim", cx) .checked(clip_trim) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_clipboard_trim(*on, cx))) .into_any_element(); // macOS only: the Option/special-character split this toggle resolves // doesn't exist on other platforms, where Alt always carries Meta. let option_alt_row = cfg!(target_os = "macos").then(|| { - let switch = Switch::new("term-option-as-alt") + let switch = crate::ui::theme::switch("term-option-as-alt", cx) .checked(option_as_alt) .on_click( cx.listener(|this, on: &bool, _w, cx| this.set_macos_option_as_alt(*on, cx)), @@ -3398,15 +3480,15 @@ impl Tty7App { }, ); - let restore_switch = Switch::new("wt-restore-session") + let restore_switch = crate::ui::theme::switch("wt-restore-session", cx) .checked(restore_session) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_restore_session(*on, cx))) .into_any_element(); - let remember_window_switch = Switch::new("wt-remember-window") + let remember_window_switch = crate::ui::theme::switch("wt-remember-window", cx) .checked(remember_window_size) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_remember_window_size(*on, cx))) .into_any_element(); - let tray_switch = Switch::new("wt-tray-icon") + let tray_switch = crate::ui::theme::switch("wt-tray-icon", cx) .checked(show_tray_icon) .on_click(cx.listener(|this, on: &bool, _w, cx| this.set_show_tray_icon(*on, cx))) .into_any_element(); @@ -3597,7 +3679,7 @@ impl Tty7App { /// the OS — one card per light/dark slot. fn render_theme_selection(&self, cx: &mut Context) -> AnyElement { let follow = cx.global::().theme_follow_system; - let follow_switch = Switch::new("theme-follow-system") + let follow_switch = crate::ui::theme::switch("theme-follow-system", cx) .checked(follow) .on_click(cx.listener(|this, on: &bool, window, cx| { this.set_theme_follow_system(*on, window, cx) @@ -3629,7 +3711,7 @@ impl Tty7App { let border = theme.border; let foreground = theme.foreground; let muted_fg = theme.muted_foreground; - let hover_bg = theme.secondary.opacity(0.5); + let hover_bg = gpui::rgb(cx.global::().window.hover); let surface = theme.secondary.opacity(0.28); let config = cx.global::(); @@ -3968,20 +4050,30 @@ impl Tty7App { .child(tok) }; - // A preset toggle button, highlighted when active. - let preset_button = - |id: &'static str, label: &'static str, value: &'static str, on: bool| { - Button::new(id).label(label).small().selected(on).on_click( - cx.listener(move |this, _, _w, cx| this.set_keybinding_preset(value, cx)), - ) - }; - // A prefix choice button (tmux preset only). - let prefix_button = - |id: &'static str, label: &'static str, value: &'static str, on: bool| { - Button::new(id).label(label).small().selected(on).on_click( - cx.listener(move |this, _, _w, cx| this.set_keybinding_prefix(value, cx)), - ) - }; + // Preset and prefix are each a one-of-two choice among visible siblings — + // a segmented control, and now built as one. They used to be loose + // `Button::selected` pairs, which put them on gpui-component's + // `tokens.button_active`: another field nothing set, so the "on" button + // wore a stock grey with no relation to the theme (issue #197's failure + // mode, in a different corner of the same page). + let preset_control = self.segmented( + "kb-preset", + &["Default", "tmux"], + usize::from(tmux), + cx, + |this, ix, _w, cx| { + this.set_keybinding_preset(if ix == 0 { "default" } else { "tmux" }, cx) + }, + ); + let prefix_control = self.segmented( + "kb-prefix", + &["Ctrl-B", "Ctrl-A"], + usize::from(prefix == "ctrl-a"), + cx, + |this, ix, _w, cx| { + this.set_keybinding_prefix(if ix == 0 { "ctrl-b" } else { "ctrl-a" }, cx) + }, + ); let preset_row = h_flex() .items_center() @@ -4001,12 +4093,7 @@ impl Tty7App { "tmux remaps pane/tab actions onto prefix sequences (e.g. Ctrl-B then C).", )), ) - .child( - h_flex() - .gap_1() - .child(preset_button("preset-default", "Default", "default", !tmux)) - .child(preset_button("preset-tmux", "tmux", "tmux", tmux)), - ); + .child(h_flex().flex_shrink_0().child(preset_control)); let prefix_row = h_flex() .items_center() @@ -4019,22 +4106,7 @@ impl Tty7App { .text_color(foreground) .child("Prefix"), ) - .child( - h_flex() - .gap_1() - .child(prefix_button( - "prefix-ctrl-b", - "Ctrl-B", - "ctrl-b", - prefix == "ctrl-b", - )) - .child(prefix_button( - "prefix-ctrl-a", - "Ctrl-A", - "ctrl-a", - prefix == "ctrl-a", - )), - ); + .child(h_flex().flex_shrink_0().child(prefix_control)); let count = effective.len(); let mut list = v_flex().mt_2(); @@ -4344,7 +4416,7 @@ impl Tty7App { .gap_2() .items_center() .child( - Switch::new("check-updates") + crate::ui::theme::switch("check-updates", cx) .checked(check_for_updates) .on_click(cx.listener(|this, on: &bool, _w, cx| { this.set_check_for_updates(*on, cx) diff --git a/src/ui/sftp.rs b/src/ui/sftp.rs index 650e9f0e..e931c660 100644 --- a/src/ui/sftp.rs +++ b/src/ui/sftp.rs @@ -1262,6 +1262,8 @@ impl Tty7App { /// "the parent folder" and matches the rows below rather than a toolbar action. fn render_sftp_go_up_row(&self, cx: &mut Context) -> AnyElement { let foreground = cx.theme().foreground; + // Matches the directory rows below it, which paint on the popover surface. + let sf = cx.global::().popover; h_flex() .id("sftp-go-up") .items_center() @@ -1271,7 +1273,7 @@ impl Tty7App { .py_1() .rounded(cx.theme().radius) .cursor_pointer() - .hover(|s| s.bg(cx.theme().accent.opacity(0.5))) + .hover(|s| s.bg(gpui::rgb(sf.hover))) .child( Icon::new(IconName::FolderOpen) .xsmall() @@ -1481,7 +1483,7 @@ impl Tty7App { let accent = cx.theme().accent; let border = cx.theme().border; let sidebar = cx.theme().sidebar; - let hover = cx.theme().sidebar_accent.opacity(0.4); + let hover = gpui::rgb(cx.global::().sidebar.hover); let expanded = self.sftp_panel.tray_expanded || history; // The summary line: how many are moving and how far along the run is, as diff --git a/src/ui/tab_sidebar.rs b/src/ui/tab_sidebar.rs index bc4e7768..f09cf0c9 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -77,6 +77,8 @@ impl Tty7App { cx: &mut Context, ) -> impl IntoElement + use<> { let active = self.active; + // The rail is a sunk column, so its rows read the sidebar ladder. + let sf = cx.global::().sidebar; // While the bare ⌘/Ctrl hold is armed (see `ui::hints`), each of the // first nine rows swaps its close affordance for a ⌘N badge — same slot // and footprint as the chips, so the vertical list gets the identical @@ -409,7 +411,7 @@ impl Tty7App { }) .when(!is_active, |s| { s.text_color(cx.theme().sidebar_foreground) - .hover(|s| s.bg(cx.theme().sidebar_accent.opacity(0.5))) + .hover(|s| s.bg(gpui::rgb(sf.hover))) }) // Held: a light dimming so the row under your cursor reads // as picked up. Not a lift — it stays in the rail's plane. @@ -489,12 +491,13 @@ impl Tty7App { // carries alpha; the inactive hover is a half-strength // wash), so flatten them against `sidebar` to get the // opaque colour the float must match. - let backing = if is_active { - cx.theme().sidebar.blend(cx.theme().sidebar_accent) + // Both rungs are opaque ladder colours, so the float's + // backing is just the rung itself — no flattening needed, + // and no alpha to drift out of step with the row it copies. + let backing: gpui::Hsla = if is_active { + gpui::rgb(sf.selected).into() } else { - cx.theme() - .sidebar - .blend(cx.theme().sidebar_accent.opacity(0.5)) + gpui::rgb(sf.hover).into() }; let mut fade_from = backing; fade_from.a = 0.; diff --git a/src/ui/theme.rs b/src/ui/theme.rs index c1ef0903..4f0b85ea 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -374,6 +374,8 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { sync_native_appearance(Some(theme.dark)); } let m = theme.neutrals(); + let surfaces = theme.surfaces(); + let sem = theme.semantics(); let active = theme.active_palette(); // Read before `Theme::global_mut` borrows `cx`. macOS reports the overlay / // legacy scroller preference here; Windows reports the accessibility @@ -410,6 +412,13 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { opacity, image: theme.image.clone(), }); + // Publish the interaction-state ladders. Every hand-rolled control in the + // shell reads its resting/hover/selected fills and label colors from here + // rather than picking a `Theme` colour field that looks about right — which + // is how the same state ended up wearing four different greys (and one + // invisible one) across the app. See `presets::Surface`. + cx.set_global(surfaces.clone()); + cx.set_global(presets::ActiveAccent(m.accent)); let t = Theme::global_mut(cx); // The window base carries the theme's opacity so a translucent/blurred theme @@ -436,15 +445,23 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { // Context menus and dropdowns highlight the hovered/selected row from // `tokens.accent` (fill) + `accent_foreground` (text) — see gpui-component's // `MenuItemElement`. Left unset, that highlight falls back to the stock - // saturated accent, which snaps hard against this app's soft mix-based - // palette (the "生硬" hover). Point it at the same soft fill the command - // palette uses for its selected row (`list_active`, mix 0.17) so context + // saturated accent, which snaps hard against this app's soft palette (the + // "生硬" hover). Point it at the popover ladder's selected rung so context // menu, dropdown and palette share one hover language; keep the text at // `foreground` so it stays legible on the low-contrast fill instead of the // stock inverted accent text. The plain `accent`/`accent_foreground` fields // feed the same highlight in the input completion / code-action popovers, so // mirror both the fields and the tokens to keep every menu surface in step. - let accent_fill = rgb(m.list_active); + // + // NOTE the surface: menu rows paint on `popover`, not on the window + // background. This used to read the window ladder, which is why the menu + // highlight measured as little as 1.20:1 against the panel it actually sat on + // while nominally being the same fill that reads fine on the terminal ground. + // + // This does *not* mean "accent" — the field is gpui-component's name for a + // row highlight, and pointing the theme's real accent at it is what would + // give the saturated snap. `surfaces.popover.selected` says what it is. + let accent_fill = rgb(surfaces.popover.selected); let accent_text: Hsla = rgb(m.foreground).into(); t.accent = accent_fill.into(); t.accent_foreground = accent_text; @@ -473,6 +490,135 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { t.tokens.button_primary_hover = primary_hover.into(); t.tokens.button_primary_active = primary_active.into(); + // Status colours. The last family that was still gpui-component's stock + // Tailwind (`red-400`, `yellow-400`, `green-400`) — a palette with no + // relationship to the active theme, used at 33 sites. On Dracula that put a + // `#f87171` delete button beside `#ff5555` terminal output: two reds, one + // window. On the light themes it was worse than inconsistent, at 2.45:1 — + // under even the non-text floor. + // + // They now come from each theme's *own* ANSI-16 (see `Theme::semantics`), so + // a danger marker and an error line of shell output are the same red. + // + // Each family gets three roles because the plain field and the tokens are + // read for different jobs: tty7's own sites use `Theme::danger` as a text / + // small-mark colour (a 7px status dot in the run list, a label), while + // gpui-component's buttons fill from `tokens.button_danger` and put + // `*_foreground` on top of that fill. One value cannot serve both. + // `ink` and `fill` each step one notch either side of themselves for + // hover/active — the same shape the primary family above uses. + let steps = |c: u32| { + ( + Hsla::from(rgb(c)), + Hsla::from(rgb(presets::mix(c, m.background, 0.15))), + Hsla::from(rgb(presets::mix(c, m.foreground, 0.15))), + ) + }; + + // ── Danger ── + let (ink, ink_hover, ink_active) = steps(sem.danger.ink); + let (fill, fill_hover, fill_active) = steps(sem.danger.fill); + let on_fill = Hsla::from(rgb(sem.danger.on_fill)); + t.danger = ink; + t.danger_hover = ink_hover; + t.danger_active = ink_active; + t.danger_foreground = on_fill; + t.tokens.danger = fill.into(); + t.tokens.danger_hover = fill_hover.into(); + t.tokens.danger_active = fill_active.into(); + t.tokens.danger_foreground = on_fill.into(); + t.tokens.button_danger = fill.into(); + t.tokens.button_danger_hover = fill_hover.into(); + t.tokens.button_danger_active = fill_active.into(); + t.tokens.button_danger_foreground = on_fill.into(); + + // ── Warning ── + let (ink, ink_hover, ink_active) = steps(sem.warning.ink); + let (fill, fill_hover, fill_active) = steps(sem.warning.fill); + let on_fill = Hsla::from(rgb(sem.warning.on_fill)); + t.warning = ink; + t.warning_hover = ink_hover; + t.warning_active = ink_active; + t.warning_foreground = on_fill; + t.tokens.warning = fill.into(); + t.tokens.warning_hover = fill_hover.into(); + t.tokens.warning_active = fill_active.into(); + t.tokens.warning_foreground = on_fill.into(); + t.tokens.button_warning = fill.into(); + t.tokens.button_warning_hover = fill_hover.into(); + t.tokens.button_warning_active = fill_active.into(); + t.tokens.button_warning_foreground = on_fill.into(); + + // ── Success ── + let (ink, ink_hover, ink_active) = steps(sem.success.ink); + let (fill, fill_hover, fill_active) = steps(sem.success.fill); + let on_fill = Hsla::from(rgb(sem.success.on_fill)); + t.success = ink; + t.success_hover = ink_hover; + t.success_active = ink_active; + t.success_foreground = on_fill; + t.tokens.success = fill.into(); + t.tokens.success_hover = fill_hover.into(); + t.tokens.success_active = fill_active.into(); + t.tokens.success_foreground = on_fill.into(); + t.tokens.button_success = fill.into(); + t.tokens.button_success_hover = fill_hover.into(); + t.tokens.button_success_active = fill_active.into(); + t.tokens.button_success_foreground = on_fill.into(); + + // ── Info ── + let (ink, ink_hover, ink_active) = steps(sem.info.ink); + let (fill, fill_hover, fill_active) = steps(sem.info.fill); + let on_fill = Hsla::from(rgb(sem.info.on_fill)); + t.info = ink; + t.info_hover = ink_hover; + t.info_active = ink_active; + t.info_foreground = on_fill; + t.tokens.info = fill.into(); + t.tokens.info_hover = fill_hover.into(); + t.tokens.info_active = fill_active.into(); + t.tokens.info_foreground = on_fill.into(); + t.tokens.button_info = fill.into(); + t.tokens.button_info_hover = fill_hover.into(); + t.tokens.button_info_active = fill_active.into(); + t.tokens.button_info_foreground = on_fill.into(); + + // Links. Unset, these resolve to near-white on dark themes and near-black on + // light ones — i.e. the body text colour, so a link in the Markdown preview + // (`ui::code_editor`, which renders through gpui-component's `TextView`) + // looked exactly like prose. The theme's own cyan is what a terminal user + // already reads as "this is a link". + t.link = rgb(sem.link.ink).into(); + t.link_hover = rgb(presets::mix(sem.link.ink, m.foreground, 0.25)).into(); + t.link_active = rgb(presets::mix(sem.link.ink, m.background, 0.20)).into(); + t.tokens.link = Hsla::from(rgb(sem.link.ink)).into(); + t.tokens.link_hover = Hsla::from(rgb(presets::mix(sem.link.ink, m.foreground, 0.25))).into(); + t.tokens.link_active = Hsla::from(rgb(presets::mix(sem.link.ink, m.background, 0.20))).into(); + + // Switches. Three more fields nobody had set, and the reason the toggles read + // inverted on every dark theme: + // + // * `switch_thumb` falls back to `tokens.background` — also unset — so the + // knob was gpui-component's stock near-black. On the (near-white) checked + // track that is a dark knob on a light track, the opposite of every system + // switch; on the dark unchecked track it disappeared entirely. + // * `switch` (the unchecked track) was the stock `#404040`, unrelated to the + // theme. + // + // The knob takes the light end of the theme's own axis — it is a raised + // physical object, and both macOS modes render it near-white — and the + // component already draws it with `shadow_md`, which is what separates it from + // a light track rather than raw contrast. The unchecked track is the window + // ladder's `selected` rung: the same "this is filled" grey as everything else. + let knob = if presets::is_lighter(m.background, m.foreground) { + m.background + } else { + m.foreground + }; + t.tokens.background = Hsla::from(rgb(m.background)).into(); + t.tokens.switch_thumb = Hsla::from(rgb(knob)).into(); + t.tokens.switch = Hsla::from(rgb(surfaces.window.selected)).into(); + t.caret = rgb(m.caret).into(); t.selection = rgb(m.selection).into(); // text selection highlight @@ -523,24 +669,51 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { // the `sidebar*` color fields — so those must be set on `tokens` or the // override is a no-op and the column falls back to the stock surface. let sidebar_bg = rgb(m.sidebar); - let sidebar_sel = rgb(m.sidebar_sel); + let sidebar_sel = rgb(surfaces.sidebar.selected); t.sidebar = sidebar_bg.into(); t.tokens.sidebar = Hsla::from(sidebar_bg).into(); t.sidebar_border = rgb(m.border).into(); - t.sidebar_foreground = rgb(m.sidebar_fg).into(); + t.sidebar_foreground = rgb(surfaces.sidebar.text_resting).into(); t.sidebar_accent = sidebar_sel.into(); t.tokens.sidebar_accent = Hsla::from(sidebar_sel).into(); - t.sidebar_accent_foreground = rgb(m.foreground).into(); + t.sidebar_accent_foreground = rgb(surfaces.sidebar.text_selected).into(); // Flatten gpui-component's list selection highlight (used by the command // palette) into a single soft fill — no blue ring, no accent tint — so it // matches this app's minimal aesthetic instead of the stock look. Keep // `active_highlight` on (the alternative path tints with the shared // `accent`), but make the ring colour equal the fill so the box disappears. + // + // The palette and its list paint on an elevated panel, so this is the popover + // ladder — same reasoning as `accent` above. t.list.active_highlight = true; - t.list_active = rgb(m.list_active).into(); - t.list_active_border = rgb(m.list_active).into(); - t.list_hover = rgb(m.list_hover).into(); + t.list_active = rgb(surfaces.popover.selected).into(); + t.list_active_border = rgb(surfaces.popover.selected).into(); + t.list_hover = rgb(surfaces.popover.hover).into(); + + // ── Stock widgets that were still wearing gpui-component's defaults ────── + // + // Everything above overrides a field because someone noticed it looking + // off-theme. The fields *nobody noticed* are the actual hazard: they silently + // keep the stock value, which is a fixed grey with no relationship to the + // active theme, so whether a control reads is down to where that theme's + // background happens to land relative to a hardcoded `#2f2f2f`. + // + // `input` is how issue #197 happened. Outline buttons (and inputs, selects, + // switches) derive their resting *and* selected fills from it, so an unset + // `input` put the selected segment of every segmented control at 1.03:1 + // against its neighbours on Dracula — and inverted the direction of the + // change between light and dark themes. Pointing it at the window ladder ties + // it to the theme; `Tty7App::segmented` no longer depends on this path at all + // (it paints the ladder itself), but every other stock control still does. + t.input = rgb(surfaces.window.selected).into(); + t.tokens.input = Hsla::from(rgb(surfaces.window.selected)).into(); + + // Focus rings: the one place the theme's *real* accent belongs. A ring is ink + // on the background at 1–2px, which is exactly the job `legible_accent` + // conditions the seed for; the stock `neutral-300` was both off-theme and + // indistinguishable from a border. + t.ring = rgb(m.accent).into(); // `sync_native_appearance` above may have flipped the macOS app appearance, // which resets the traffic-light buttons to their default (higher) position. @@ -554,6 +727,27 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { } } +/// A `Switch` wearing the theme's accent on its checked track. +/// +/// Every switch in the app goes through here rather than through `Switch::new` +/// directly. gpui-component defaults the checked track to `tokens.primary`, which +/// tty7 tunes for *primary buttons* — a near-white on dark themes, deliberately, +/// because a primary button is a light slab with dark text. As a switch track +/// that same colour swallows the (near-white) knob, so the two uses genuinely +/// need two colours and the component only offers a per-instance override. +/// +/// The accent is the right one, and this is the one control in the app that gets +/// it. On/off differ by *hue* here because they cannot differ by lightness: the +/// knob has already claimed the light end of the axis, so a checked track that +/// reads as "brighter" is a track the knob vanishes into. It is the same reason +/// every system switch is coloured. `Neutrals::accent` is contrast-conditioned +/// (see `presets::legible_accent`), so a seed as pale as the Light theme's +/// `#00c2ff` still lands on a track a white knob can sit on. +pub(crate) fn switch(id: impl Into, cx: &App) -> gpui_component::switch::Switch { + let accent = cx.global::().0; + gpui_component::switch::Switch::new(id).color(Hsla::from(rgb(accent))) +} + /// Apply `Config::mouse_hide_while_typing` to GPUI's cursor-hide policy: hide the /// pointer while typing when on, never when off. Called at startup and whenever /// the config changes (setter + hot-reload) so the switch takes effect live. From 4107829bf8e1d8a8a7e101ca03d07d141cc5d2f4 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 27 Jul 2026 09:39:26 +0800 Subject: [PATCH 2/3] =?UTF-8?q?fix(theme):=20finish=20the=20sweep=20?= =?UTF-8?q?=E2=80=94=20plain-button=20states=20and=20midtone=20grounds?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two gaps in the interaction-state change. `Theme::input` reaches the *outline* button path, which reads the field live, but not the plain one: gpui-component derives `tokens.button_hover` and `tokens.button_active` from `input` once, inside the `apply_config` that `Theme::change` runs — from the stock `#2f2f2f`, before any of `apply_theme`'s overrides exist. A snapshot never sees the fix, so a plain `Button` still hovered and pressed in that grey and `Button::selected` (the terminal search bar's `Aa` / `.*` toggles, the last two left in the app) filled from `tokens.secondary_active` the same way — ~1.03:1 on Dracula, i.e. issue #197 again, one snapshot removed from the field that fixed it. Point those state rungs at the window ladder. `tokens.button`, the resting fill, is deliberately left alone. `legible_ink` picked its extreme with `is_dark`, whose 0.5 luminance threshold is the wrong question: on a midtone ground (luminance 0.18…0.5) it says "dark" while black outreaches white, so a status seed was driven toward white and clamped there *below* the floor with its hue gone — precisely when a user most needs to tell an error from a warning. Take the reachable extreme instead, as `legible_foreground` already does. Every built-in sits far enough from the midpoint that this picks what `is_dark` did, so the derived palettes are unchanged; only an imported scheme could reach the divergent range. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD --- src/ui/presets.rs | 35 ++++++++++++++++++++++++++++++++++- src/ui/theme.rs | 22 ++++++++++++++++++++++ 2 files changed, 56 insertions(+), 1 deletion(-) diff --git a/src/ui/presets.rs b/src/ui/presets.rs index 35843cc2..3f951cf2 100644 --- a/src/ui/presets.rs +++ b/src/ui/presets.rs @@ -591,7 +591,19 @@ fn legible_ink(bg: u32, seed: u32, floor: f32) -> u32 { if contrast(seed, bg) >= floor { return seed; } - let away = if is_dark(bg) { 0xffffff } else { 0x000000 }; + // Whichever extreme the background is *further* from, exactly as + // [`legible_foreground`] picks it — not `is_dark`, whose 0.5 luminance + // threshold is the wrong question here. The two answers only diverge on a + // midtone background (luminance 0.18…0.5), where `is_dark` still says "dark" + // but black outreaches white: an imported scheme on a mid-grey ground would + // have been driven to pure white and clamped there *below* the floor, losing + // the hue and failing the job in one go. Every built-in is far enough from + // the midpoint that this picks what `is_dark` did. + let away = if contrast(0xffffff, bg) >= contrast(0x000000, bg) { + 0xffffff + } else { + 0x000000 + }; bisect_contrast(seed, away, bg, floor) } @@ -1666,6 +1678,27 @@ mod tests { assert_eq!(raise(0x000000, 0x808080, 21.0), 0x808080); } + /// Conditioning has to take the extreme it can actually *reach*, which on a + /// midtone ground is not the one `is_dark`'s 0.5 luminance threshold names. + /// A mid-grey background is "dark" by that test, yet white tops out at + /// 3.95:1 on it while black manages 5.32:1 — so driving toward white would + /// clamp at pure white, below the floor and with the hue thrown away, in the + /// one case where a status colour most needs both. Reachable only for an + /// imported scheme; every built-in sits far enough from the midpoint that + /// this picks the same extreme `is_dark` did. + #[test] + fn semantic_conditioning_survives_a_midtone_background() { + let bg = 0x808080; + for seed in [0xff5555u32, 0x50fa7b, 0xf1fa8c, 0x8be9fd] { + let ink = legible_ink(bg, seed, TEXT_FLOOR); + assert!( + contrast(ink, bg) >= TEXT_FLOOR - 0.01, + "{seed:#08x} conditioned to {ink:#08x}, only {:.2}:1 on a midtone ground", + contrast(ink, bg) + ); + } + } + /// A bad foreground is swapped for a legible black/white; a good one is kept. #[test] fn legible_foreground_rescues_unreadable_text() { diff --git a/src/ui/theme.rs b/src/ui/theme.rs index 4f0b85ea..0669d2df 100644 --- a/src/ui/theme.rs +++ b/src/ui/theme.rs @@ -709,6 +709,28 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) { t.input = rgb(surfaces.window.selected).into(); t.tokens.input = Hsla::from(rgb(surfaces.window.selected)).into(); + // …but `input` only reaches the *outline* path, which reads the field live. + // The plain (non-outline) button family is derived from `input` **once**, + // inside the `apply_config` that `Theme::change` ran above — i.e. from the + // stock `#2f2f2f`, before any of this function's overrides exist — and a + // snapshot never sees the fix. So a plain `Button` still hovered and pressed + // in that grey, and `Button::selected` (the terminal search bar's `Aa` / `.*` + // toggles, the last two in the app) filled from `tokens.secondary_active` the + // same way: on Dracula, ~1.03:1 against the surface behind it. That is issue + // #197 again, one snapshot removed from the field that fixed it. + // + // Only the *state* rungs move — `tokens.button` (the resting fill) is left + // alone, so a plain button keeps the flat look it has today and only its + // hover/pressed/selected join the ladder. + let button_hover: Hsla = rgb(surfaces.window.hover).into(); + let button_active: Hsla = rgb(surfaces.window.selected).into(); + t.tokens.button_hover = button_hover.into(); + t.tokens.button_active = button_active.into(); + t.tokens.secondary_hover = button_hover.into(); + t.tokens.secondary_active = button_active.into(); + t.tokens.button_secondary_hover = button_hover.into(); + t.tokens.button_secondary_active = button_active.into(); + // Focus rings: the one place the theme's *real* accent belongs. A ring is ink // on the background at 1–2px, which is exactly the job `legible_accent` // conditions the seed for; the stock `neutral-300` was both off-theme and From dddd07a3602ae51d1f4456b23b6eed4bce348df5 Mon Sep 17 00:00:00 2001 From: thomas Date: Mon, 27 Jul 2026 09:42:02 +0800 Subject: [PATCH 3/3] fix(ui): give the segmented control the surface it actually paints on MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit `segmented` paints its own opaque track — deliberately, since every rung above it was derived against that ground. But it hardcoded the window surface, and one of its sixteen call sites is the managed-forward form in the right panel, which is a sunk `sidebar` rail: the track landed as a faintly darker box cut out of the column around it, and its hover / selected / label rungs were measured against a ground that was not there. Split off `segmented_on`, which takes the `Surface`; `segmented` keeps the window default the settings sheet wants. Co-Authored-By: Claude Opus 5 (1M context) Claude-Session: https://claude.ai/code/session_01WCb8ZDmvdA5xbVtvs647tD --- src/ui/forwards.rs | 6 +++++- src/ui/settings.rs | 17 +++++++++++++++++ 2 files changed, 22 insertions(+), 1 deletion(-) diff --git a/src/ui/forwards.rs b/src/ui/forwards.rs index c656f96e..804be22c 100644 --- a/src/ui/forwards.rs +++ b/src/ui/forwards.rs @@ -358,6 +358,9 @@ impl Tty7App { fn forward_form(&self, pane_id: u64, cx: &mut Context) -> Div { let theme = cx.theme(); let muted = theme.muted_foreground; + // The form is inside the right panel, i.e. on the sunk rail — not on the + // settings sheet the segmented control otherwise assumes. + let sf = cx.global::().sidebar; let kind = self.loopback_panel.mf_kind; let editing = self.loopback_panel.mf_editing.is_some(); let selected = match kind { @@ -394,7 +397,8 @@ impl Tty7App { .pt(px(6.)) .pb(px(2.)) .gap(px(5.)) - .child(self.segmented( + .child(self.segmented_on( + sf, "ssh-managed-forward-kind", &["Local", "Remote", "Dynamic"], selected, diff --git a/src/ui/settings.rs b/src/ui/settings.rs index ecb54bf5..26aa1c96 100644 --- a/src/ui/settings.rs +++ b/src/ui/settings.rs @@ -1204,6 +1204,23 @@ impl Tty7App { on_pick: impl Fn(&mut Self, usize, &mut Window, &mut Context) + 'static, ) -> AnyElement { let sf = cx.global::().window; + self.segmented_on(sf, id, options, selected, cx, on_pick) + } + + /// [`Self::segmented`] for a control that does *not* sit on the settings + /// sheet. The track paints its own opaque ground, so it has to be told which + /// one: dropped on the right panel's sunk rail, a window-surface track reads + /// as a faintly darker box cut out of the column it sits in — and every rung + /// above it was derived against the wrong ground. + pub(crate) fn segmented_on( + &self, + sf: presets::Surface, + id: &'static str, + options: &'static [&'static str], + selected: usize, + cx: &mut Context, + on_pick: impl Fn(&mut Self, usize, &mut Window, &mut Context) + 'static, + ) -> AnyElement { let border = cx.theme().border; let on_pick = std::rc::Rc::new(on_pick); h_flex()