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..804be22c 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) })) @@ -357,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 { @@ -393,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/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..3f951cf2 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,63 @@ 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; + } + // 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) +} + +/// 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 +1369,336 @@ 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); + } + + /// 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/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..26aa1c96 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,78 @@ 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; + 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() + .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 +1284,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 +1411,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 +1437,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 +1540,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 +1800,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 +1869,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 +1903,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 +2063,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 +2738,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 +2831,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 +2847,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 +2870,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 +3077,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 +3104,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 +3242,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 +3497,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 +3696,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 +3728,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 +4067,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 +4110,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 +4123,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 +4433,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 66ad5db1..8fa2c6b2 100644 --- a/src/ui/tab_sidebar.rs +++ b/src/ui/tab_sidebar.rs @@ -74,6 +74,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 @@ -406,7 +408,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. @@ -486,12 +488,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..0669d2df 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,73 @@ 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(); + + // …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 + // 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 +749,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.