mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 08:02:24 +00:00
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:
+10
-5
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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
@@ -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.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<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
@@ -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
|
||||
|
||||
@@ -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
@@ -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<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.
|
||||
|
||||
Reference in New Issue
Block a user