Merge pull request #205 from l0ng-ai/feat/theme-interaction-state

feat(theme): derive interaction state and status colors from the theme
This commit is contained in:
l0ng-ai
2026-07-27 09:53:11 +08:00
committed by GitHub
9 changed files with 1181 additions and 131 deletions
+10 -5
View File
@@ -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::<crate::ui::presets::Surfaces>().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 {
+7 -2
View File
@@ -240,6 +240,7 @@ impl Tty7App {
) -> Stateful<Div> {
let theme = cx.theme();
let muted = theme.muted_foreground;
let sf = cx.global::<crate::ui::presets::Surfaces>().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<Self>) -> 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::<crate::ui::presets::Surfaces>().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,
+4 -2
View File
@@ -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::<crate::ui::presets::Surfaces>().popover.hover);
let mut panel = v_flex()
.w(px(360.))
+729 -10
View File
@@ -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<f32> = 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() {
+12 -3
View File
@@ -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<Self>) -> 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::<crate::ui::presets::Surfaces>().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<Self>) -> AnyElement {
let sf = cx.global::<crate::ui::presets::Surfaces>().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();
+180 -91
View File
@@ -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::<presets::Surfaces>().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<Self>,
on_pick: impl Fn(&mut Self, usize, &mut Window, &mut Context<Self>) + 'static,
) -> AnyElement {
ButtonGroup::new(id)
.outline()
.small()
let sf = cx.global::<presets::Surfaces>().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<Self>,
on_pick: impl Fn(&mut Self, usize, &mut Window, &mut Context<Self>) + '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<usize>, 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::<presets::Surfaces>().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<Self>) -> 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::<presets::Surfaces>().window;
let profiles = cx.global::<Config>().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.051.12:1
// from a resting one and 1.021.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<Self>) -> AnyElement {
let verify = cx.global::<Config>().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::<Config>().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<Self>) -> AnyElement {
let follow = cx.global::<Config>().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::<presets::Surfaces>().window.hover);
let surface = theme.secondary.opacity(0.28);
let config = cx.global::<Config>();
@@ -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)
+4 -2
View File
@@ -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<Self>) -> AnyElement {
let foreground = cx.theme().foreground;
// Matches the directory rows below it, which paint on the popover surface.
let sf = cx.global::<crate::ui::presets::Surfaces>().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::<crate::ui::presets::Surfaces>().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
+9 -6
View File
@@ -74,6 +74,8 @@ impl Tty7App {
cx: &mut Context<Self>,
) -> impl IntoElement + use<> {
let active = self.active;
// The rail is a sunk column, so its rows read the sidebar ladder.
let sf = cx.global::<crate::ui::presets::Surfaces>().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.;
+226 -10
View File
@@ -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 12px, 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<gpui::ElementId>, cx: &App) -> gpui_component::switch::Switch {
let accent = cx.global::<presets::ActiveAccent>().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.