Merge remote-tracking branch 'origin/main' into feat/confirm-window-close

# Conflicts:
#	src/ui/settings.rs
This commit is contained in:
thomas
2026-07-27 10:41:06 +08:00
20 changed files with 1933 additions and 275 deletions
+33
View File
@@ -7,8 +7,41 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0
## [Unreleased]
### Added
- **The window's leading corner carries the app's mark off macOS** — macOS fills
the top-left with the traffic lights; on Windows and Linux that corner was
empty, with everything the caption row holds (the rail's "+" and collapse, the
corner chrome, the window controls) pushed to a right edge. The "duo" mark now
heads the tab rail on its content inset, the line the search box and every row
label below it start on, and follows the rail's controls into the title strip
when the sidebar is collapsed — so the corner never falls back to nothing.
Drawn, never clicked: it takes no hover capsule and no hit box, leaving the
strip grabbable through it.
### Fixed
- **The editor and diff overlays keep their header on the caption line when the
detail panel is open** — off macOS the title bar is hoisted above
`[terminal | panel]` so the ─ ▢ ✕ group can reach the window's corner, which
left both overlays — anchored to the terminal column — starting 40px down.
Their headers are drawn to *be* the title bar while they're up (its height,
its insets, a full chrome tile for their one control), and instead landed a
row low, level with the panel's tab row. They now hang on the row that owns
the bar, inset by the panel's width, so the corner chrome keeps its surface
and its clicks.
- **Those headers became real title bars** — dragging one now moves the window
and double-clicking zooms it. Both covered the caption row and neither did
either, with the panel open or closed, so opening a file turned the top of the
window into a 40px strip that looked exactly like a title bar and answered
nothing. Their controls (the ✕, the diff's back-to-all-files chip) are
`occlude()`d to keep taking clicks: a drag region on Windows is HTCAPTION, and
the OS claims the press before the app hit-tests.
- **The rail's top zone lines up with the title bar to the pixel** — the bar
reserves a hairline inside its own height that the rail's stand-in row didn't,
so everything in that row sat half a pixel low. Invisible on the line-art
tiles; not on the mark, which visibly hopped as collapsing the rail handed it
over to the bar.
- **CJK and emoji stop falling through to the OS on Windows and Linux** — the
default `font_fallbacks` named only faces that ship with macOS (Menlo, Apple
Color Emoji), so off macOS the entire chain matched nothing and every
+141
View File
@@ -0,0 +1,141 @@
//! Crash log — the panic message the OS crash reporter throws away.
//!
//! Most tty7 panics happen inside a gpui input callback, and those callbacks are
//! `extern "C"`: the panic can't unwind across them, so the runtime aborts. What
//! macOS then records is the *abort* — `panic_cannot_unwind` on top of
//! `handle_key_event` — with no message, no `file:line`, and the original frames
//! already unwound away. Reports like that are undiagnosable, and the GUI has no
//! logger and no terminal to print to.
//!
//! So we write the two lines that matter (message + location, plus a backtrace)
//! to `crash.log` in the config dir before the process goes down.
use std::fmt::Write as _;
use std::path::PathBuf;
/// Rewrite the log once it passes this, so a panic loop can't grow it forever.
const MAX_BYTES: u64 = 256 * 1024;
/// Install the panic hook for this process. `role` labels the records, since the
/// GUI and the daemon it spawns share one config dir. Chains to the previously
/// installed hook, so the usual stderr output still happens when there's a
/// terminal to see it.
pub fn install(role: &'static str) {
let previous = std::panic::take_hook();
std::panic::set_hook(Box::new(move |info| {
record(role, info);
previous(info);
}));
}
/// Append one record. Every step is best-effort: a panic handler that panics
/// (or fails loudly) is worse than one that loses a log line.
fn record(role: &str, info: &std::panic::PanicHookInfo<'_>) {
let Some(path) = log_path() else {
return;
};
let thread = std::thread::current();
let mut record = String::new();
// `info` renders as "panicked at <file:line:col>:\n<message>" — the exact
// pair the crash report is missing.
let _ = write!(
record,
"\n=== {} {} v{} pid {} thread {:?}\n{info}\n{}\n",
utc_timestamp(),
role,
env!("CARGO_PKG_VERSION"),
std::process::id(),
thread.name().unwrap_or("<unnamed>"),
std::backtrace::Backtrace::force_capture(),
);
append(&path, &record);
}
fn append(path: &PathBuf, record: &str) {
use std::io::Write as _;
let truncate = std::fs::metadata(path).is_ok_and(|m| m.len() > MAX_BYTES);
let mut file = match std::fs::OpenOptions::new()
.create(true)
.append(!truncate)
.write(true)
.truncate(truncate)
.open(path)
{
Ok(f) => f,
Err(_) => return,
};
let _ = file.write_all(record.as_bytes());
let _ = file.flush();
}
fn log_path() -> Option<PathBuf> {
crate::core::config::config_path("crash.log")
}
/// `YYYY-MM-DD HH:MM:SS UTC` from the epoch seconds, so a record can be lined up
/// against an OS crash report without pulling in a date crate.
fn utc_timestamp() -> String {
let secs = std::time::SystemTime::now()
.duration_since(std::time::UNIX_EPOCH)
.map(|d| d.as_secs())
.unwrap_or(0);
let (days, rem) = (secs / 86_400, secs % 86_400);
let (y, m, d) = civil_from_days(days as i64);
format!(
"{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02} UTC",
rem / 3600,
(rem % 3600) / 60,
rem % 60
)
}
/// Howard Hinnant's `civil_from_days`: days since the Unix epoch → (y, m, d).
fn civil_from_days(z: i64) -> (i64, u32, u32) {
let z = z + 719_468;
let era = z.div_euclid(146_097);
let doe = z.rem_euclid(146_097);
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
let y = yoe + era * 400;
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
let mp = (5 * doy + 2) / 153;
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
(if m <= 2 { y + 1 } else { y }, m, d)
}
#[cfg(test)]
mod tests {
use super::{civil_from_days, install, log_path};
/// The whole point of the hook: after a panic, the message and its location
/// are on disk. `catch_unwind` stands in for the abort — the hook runs
/// before either outcome.
#[test]
fn a_panic_lands_in_the_crash_log() {
// Same pinned temp dir the config tests use (set-once, first call wins).
let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id()));
std::fs::create_dir_all(&dir).ok();
crate::core::config::set_config_dir(dir);
let path = log_path().expect("a pinned config dir resolves a log path");
let _ = std::fs::remove_file(&path);
install("test");
let _ = std::panic::catch_unwind(|| panic!("crash-log probe"));
let body = std::fs::read_to_string(&path).expect("the hook wrote a record");
assert!(body.contains("crash-log probe"), "message: {body}");
// Bare file name: `panic!`'s location carries the platform's own
// separator (`src\core\crash.rs` on Windows).
assert!(body.contains("crash.rs:"), "location: {body}");
assert!(body.contains("test v"), "role + version: {body}");
}
#[test]
fn civil_from_days_matches_known_dates() {
assert_eq!(civil_from_days(0), (1970, 1, 1));
assert_eq!(civil_from_days(20_660), (2026, 7, 26));
// Leap day, and the day after it.
assert_eq!(civil_from_days(19_782), (2024, 2, 29));
assert_eq!(civil_from_days(19_783), (2024, 3, 1));
}
}
+1
View File
@@ -12,6 +12,7 @@ pub mod agent_hooks;
pub mod agent_prompt;
pub mod cli_agent;
pub mod config;
pub mod crash;
// SSH connection-manager data layer (WS1). Its public API is consumed by the
// daemon-session, auth, forwarding, and UI workstreams, which land separately —
// so parts of it read as dead code until those merge.
+11
View File
@@ -298,6 +298,17 @@ fn main() {
// under this dir too, so the order matters).
apply_config_dir_arg();
// Panics inside gpui's `extern "C"` input callbacks abort instead of
// unwinding, and the OS crash report then holds the abort rather than the
// panic — no message, no location. Record those to `crash.log` in the config
// dir. Installed here, right after the config dir resolves, so both the GUI
// and the daemon below are covered from their first line of real work.
crate::core::crash::install(if std::env::args().any(|a| a == "--daemon") {
"daemon"
} else {
"gui"
});
// Daemon mode: when launched with `--daemon` we run the headless persistent
// terminal server and never open a window. This is the backing process the GUI
// auto-spawns and reconnects to; it owns all PTYs + child shells and outlives
+65 -3
View File
@@ -236,6 +236,24 @@ impl RemoteTerminal {
let retry_shell = shell.clone();
match Self::spawn_once(size, cell_w, cell_h, cwd, shell) {
Ok(term) => Ok(term),
Err(first_err) if daemon_not_listening(&first_err) => {
// Nothing is on the socket: the daemon died (crash, OOM, a stray
// `kill`) since the last pane was opened. Every later spawn would
// fail the same way, so bring one back up and retry rather than
// leaving the window unable to open another terminal.
if let Err(start_err) = crate::daemon::spawn::ensure_running() {
return Err(anyhow::anyhow!(
"daemon not running ({first_err}); starting one failed: {start_err}"
));
}
Self::spawn_once(size, cell_w, cell_h, retry_cwd, retry_shell).map_err(
|second_err| {
anyhow::anyhow!(
"daemon not running ({first_err}); started one but Spawn still failed: {second_err}"
)
},
)
}
Err(first_err) if daemon_disconnected_before_spawn_reply(&first_err) => {
// A live-but-old daemon can accept the connection, panic while
// handling Spawn, and close before replying. Restart once so an
@@ -1430,6 +1448,21 @@ fn record_mark(term: &Term<EventProxy>, marks: &crate::terminal::marks::Marks, e
}
}
/// Whether the failure is "nothing is listening on the socket" — the daemon is
/// gone, as opposed to alive but unhappy. On Unix a dead daemon leaves the
/// socket file behind (`ConnectionRefused`) or removed it on the way out
/// (`NotFound`); on Windows the named pipe simply isn't there (`NotFound`).
fn daemon_not_listening(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
cause.downcast_ref::<std::io::Error>().is_some_and(|io| {
matches!(
io.kind(),
std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
)
})
})
}
fn daemon_disconnected_before_spawn_reply(err: &anyhow::Error) -> bool {
err.chain().any(|cause| {
cause.downcast_ref::<std::io::Error>().is_some_and(|io| {
@@ -1604,10 +1637,14 @@ fn parse_osc_notification(payload: &[u8]) -> Option<(Option<String>, String)> {
/// isolation (dev vs. real config dir), exactly like every other config-dir file.
fn connect() -> anyhow::Result<Stream> {
transport::connect().map_err(|e| {
anyhow::anyhow!(
"connect to daemon at {}: {e}",
// `context`, not a formatted `anyhow!`: callers classify the failure by
// downcasting to `io::Error` (see `daemon_not_listening`), and
// interpolating the cause into a string would leave the chain with
// nothing to find.
anyhow::Error::new(e).context(format!(
"connect to daemon at {}",
transport::endpoint_display()
)
))
})
}
@@ -1742,6 +1779,31 @@ mod tests {
assert!(!daemon_disconnected_before_spawn_reply(&refused));
}
/// A dead daemon is the one failure the client can fix by itself, and it
/// must be told apart from a live daemon saying no — restarting on *that*
/// would kill every running pane over a bad shell setting.
#[test]
fn only_a_dead_daemon_is_worth_starting_one_for() {
let connect_failed = |kind| -> anyhow::Error {
anyhow::Error::new(std::io::Error::new(kind, "no listener"))
.context("connect to daemon at /tmp/tty7.sock")
};
assert!(daemon_not_listening(&connect_failed(
std::io::ErrorKind::ConnectionRefused
)));
assert!(daemon_not_listening(&connect_failed(
std::io::ErrorKind::NotFound
)));
// A daemon that answered and refused, and one that hung up mid-Spawn:
// neither is "not running", and each has its own recovery.
let refused = anyhow::anyhow!("daemon refused Spawn: configured shell missing");
assert!(!daemon_not_listening(&refused));
let eof: anyhow::Error =
std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "closed").into();
assert!(!daemon_not_listening(&eof));
}
/// Without a real daemon, drive the reader path directly: a `UnixStream::pair`
/// stands in for the connection. We hand `RemoteTerminal` one half (as if it
/// were the attach'd socket) and push framed `DaemonMsg`s down the other, then
+24
View File
@@ -71,6 +71,16 @@ pub(super) fn grid_smart_range<T: EventListener>(
term: &Term<T>,
click: Point,
) -> Option<SmartRange> {
// 0) The click carries the geometry of the frame that dispatched it, and
// the grid can shrink out from under it (a split, a window drag, a
// replayed attach size landing on the reader thread). Both walks below
// index `grid[click.line]` straight away, and `Grid`'s `Index<Line>`
// only `debug_assert`s the bound — a release build walks off the
// storage. Same guard, same reason, as `TerminalView::grid_line`.
if click.line < term.topmost_line() || click.line > term.bottommost_line() {
return None;
}
// 1) An explicit OSC 8 hyperlink run wins outright — the program told us
// the exact extent, no guessing needed.
if let Some((start, end)) = hyperlink_run(term, click) {
@@ -805,6 +815,20 @@ mod tests {
assert!(grid_smart_range(&term, Point::new(Line(0), Column(99))).is_none());
}
/// A double-click dispatched with the previous frame's geometry can name a
/// row the grid has since dropped. Indexing it walks off the storage, and
/// the click arrives in a gpui `extern "C"` callback where that panic
/// aborts instead of unwinding — so the row has to be refused first.
#[test]
fn click_outside_the_grid_rows_yields_no_range() {
let term = term_with(10, 2, "hello");
// Below the last row of a shrunken grid...
assert!(grid_smart_range(&term, Point::new(Line(2), Column(0))).is_none());
assert!(grid_smart_range(&term, Point::new(Line(9_000), Column(0))).is_none());
// ...and above the top of a scrollback this short.
assert!(grid_smart_range(&term, Point::new(Line(-1), Column(0))).is_none());
}
fn selected(text: &str, click: usize) -> Option<String> {
let chars: Vec<char> = text.chars().collect();
range(text, click).map(|(s, e)| chars[s..=e].iter().collect())
+132 -17
View File
@@ -93,6 +93,18 @@ pub struct NativeSshParts {
persist: Box<crate::daemon::protocol::NativeSshSpec>,
}
/// An established shell daemon pane (fresh spawn or re-attach), ready to be
/// wrapped in a view: the output of the fallible
/// [`TerminalView::spawn_shell_terminal`], consumed by the infallible
/// [`TerminalView::from_shell_parts`].
pub struct ShellParts {
terminal: RemoteTerminal,
pane_id: u64,
/// The explicit shell pick this pane was spawned with, if any; `None` for a
/// re-attached pane (the pick isn't persisted).
shell_spec: Option<ShellSpec>,
}
/// See `TerminalView::drag_scroll`.
#[derive(Clone, Copy)]
struct DragScroll {
@@ -816,19 +828,25 @@ fn fallback_chain(family: &str, configured: &[String]) -> Vec<String> {
}
impl TerminalView {
pub fn new(
/// The fallible half of a shell-backed view: establish the daemon pane
/// *before* the view is built, so a refused spawn (daemon down, spawn
/// error) comes back as an `Err` the caller can report. Splitting it out
/// matters beyond tidiness: the view is constructed inside `cx.new`, deep
/// under gpui's `extern "C"` input callbacks, where a panic can't unwind
/// and aborts the process instead. Mirrors
/// [`Self::spawn_native_ssh_terminal`].
///
/// Provisional size; corrected on the first prepaint once we can measure.
/// The PTY lives in the daemon now. On session restore (`restore_pane`),
/// re-`attach` to the still-running pane so its process + scrollback come
/// back intact; otherwise `spawn` a fresh pane (with the caller's shell
/// pick, if any). The caller only passes a `restore_pane` it has already
/// confirmed alive, so we trust it here.
pub fn spawn_shell_terminal(
working_directory: Option<std::path::PathBuf>,
restore_pane: Option<u64>,
shell: Option<ShellSpec>,
window: &mut Window,
cx: &mut Context<Self>,
) -> anyhow::Result<Self> {
// Provisional size; corrected on the first prepaint once we can measure.
// The PTY lives in the daemon now. On session restore (`restore_pane`),
// re-`attach` to the still-running pane so its process + scrollback come
// back intact; otherwise `spawn` a fresh pane (with the caller's shell
// pick, if any). The caller only passes a `restore_pane` it has already
// confirmed alive, so we trust it here.
) -> anyhow::Result<ShellParts> {
let (terminal, pane_id, shell_spec) = match restore_pane {
Some(id) => (
RemoteTerminal::attach(TermSize::new(80, 24), 8, 17, id)?,
@@ -848,9 +866,23 @@ impl TerminalView {
(terminal, id, shell)
}
};
let mut view = Self::with_terminal(terminal, pane_id, window, cx);
view.shell_spec = shell_spec;
Ok(view)
Ok(ShellParts {
terminal,
pane_id,
shell_spec,
})
}
/// Wrap an established shell pane (from [`Self::spawn_shell_terminal`]) in
/// a view. Infallible by construction — see that function.
pub fn from_shell_parts(
parts: ShellParts,
window: &mut Window,
cx: &mut Context<Self>,
) -> Self {
let mut view = Self::with_terminal(parts.terminal, parts.pane_id, window, cx);
view.shell_spec = parts.shell_spec;
view
}
/// Spawn a native (russh) SSH pane for `spec` and build the view around it
@@ -1160,6 +1192,18 @@ impl TerminalView {
cell_width: Pixels,
line_height: Pixels,
) {
// A remembered hover cell describes the *old* geometry: after a resize
// its row may not exist any more, and the pointer sits over a different
// cell regardless. Forget it — the next mouse move records a fresh one.
// (`grid_line` also refuses a stale row, so this is about not underlining
// the wrong cell, not about safety.) The link that cell resolved to goes
// with it: it is held in grid coordinates the reflow just moved text
// under, so keeping it would underline whatever now sits there (and hold
// the pointing-hand cursor over it) until the pointer moves again.
if (cols, rows) != (self.terminal.size().cols, self.terminal.size().rows) {
self.last_hover_cell = None;
self.hovered_link = None;
}
self.cell_width = cell_width;
self.line_height = line_height;
self.terminal.resize(
@@ -4335,6 +4379,23 @@ impl TerminalView {
}
}
/// The grid line a screen `row` currently maps to, or `None` when that row
/// is outside the grid.
///
/// Mandatory before indexing: `Grid`'s `Index<Line>` only `debug_assert`s
/// the bound, so a release build walks off the storage and panics on the
/// slice check instead. A remembered cell goes stale whenever the grid
/// shrinks under it — split a pane, drag the window smaller — and the
/// callers here run inside gpui's `extern "C"` input callbacks, where that
/// panic can't unwind and aborts the process.
fn grid_line(
term: &alacritty_terminal::Term<crate::terminal::remote::EventProxy>,
row: usize,
) -> Option<Line> {
let line = Line(row as i32 - term.grid().display_offset() as i32);
(line >= term.topmost_line() && line <= term.bottommost_line()).then_some(line)
}
/// Open the link under the given cell, if any (OSC 8 hyperlink, plain URL or
/// existing file or directory path detected in the row text). Returns true if one opened.
pub fn open_link_at(&self, col: usize, row: usize, cx: &mut Context<Self>) -> bool {
@@ -4342,8 +4403,9 @@ impl TerminalView {
return false;
}
let term = self.terminal.term.lock();
let display_offset = term.grid().display_offset() as i32;
let line = Line(row as i32 - display_offset);
let Some(line) = Self::grid_line(&term, row) else {
return false;
};
let cols = term.columns();
if col >= cols {
return false;
@@ -4490,8 +4552,7 @@ impl TerminalView {
include_loopback: bool,
) -> Option<HoveredLink> {
let term = self.terminal.term.lock();
let display_offset = term.grid().display_offset() as i32;
let line = Line(row as i32 - display_offset);
let line = Self::grid_line(&term, row)?;
let cols = term.columns();
if col >= cols {
return None;
@@ -6790,6 +6851,60 @@ mod gpui_tests {
(window, daemon_side)
}
/// A hover cell remembered while the pane was tall names a row the grid no
/// longer has once the pane shrinks (a vertical split, a smaller window).
/// Resolving it must decline rather than index the grid — this path runs
/// from `ModifiersChanged` (the ⌘ of the very ⌘⇧D that split the pane), an
/// `extern "C"` callback where the panic can't unwind and aborts the app.
#[gpui::test]
fn a_stale_hover_row_does_not_index_the_shrunken_grid(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
// Hover the last row of the 24-row grid, then shrink to 8 rows.
view.hover_link_at(0, 23, true, cx);
view.terminal.resize(TermSize::new(80, 8), 8, 17);
// `set_grid_size` drops the stale cell in the real app; pin it
// here so the guard inside the lookup is what's under test.
view.last_hover_cell = Some((0, 23));
assert!(
!view.refresh_link_hover(true, cx),
"a row outside the grid can't hold a link"
);
})
.unwrap();
}
/// The other half of the fix: the pane that shrank forgets the hover it was
/// holding, rather than carrying a cell (and the underline it resolved) that
/// now names different text.
#[gpui::test]
fn a_resize_forgets_the_hovered_cell(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
window
.update(cx, |view, _, cx| {
// Pin a known geometry first — what the test window measured for
// itself is the element's business, and this is about the
// transition. Then hover the last row of those 24.
view.set_grid_size(80, 24, px(8.), px(17.));
view.hover_link_at(0, 23, true, cx);
assert_eq!(view.last_hover_cell, Some((0, 23)));
view.hovered_link = Some(HoveredLink {
line: 23,
start: 0,
end: 3,
});
// The same geometry again changes nothing...
view.set_grid_size(80, 24, px(8.), px(17.));
assert_eq!(view.last_hover_cell, Some((0, 23)));
// ...but a split (or a window drag) that shrinks the pane does.
view.set_grid_size(80, 8, px(8.), px(17.));
assert!(view.last_hover_cell.is_none(), "the cell is stale");
assert!(view.hovered_link.is_none(), "so is the link it resolved");
})
.unwrap();
}
#[gpui::test]
fn title_events_drive_the_tab_title(cx: &mut TestAppContext) {
let (window, _daemon) = harness(cx);
+209 -24
View File
@@ -9,7 +9,9 @@ use gpui_component::color_picker::{ColorPickerEvent, ColorPickerState};
use gpui_component::input::{InputEvent, InputState};
use gpui_component::select::{SearchableVec, SelectEvent, SelectState};
use gpui_component::slider::{SliderEvent, SliderState};
use gpui_component::{ActiveTheme as _, IndexPath, TitleBar, WindowExt as _};
use gpui_component::{
ActiveTheme as _, IndexPath, InteractiveElementExt as _, TitleBar, WindowExt as _,
};
use std::cell::{Cell, RefCell};
use std::collections::HashSet;
use std::rc::Rc;
@@ -190,6 +192,93 @@ pub(crate) fn title_bar_hug_offset() -> f32 {
}
}
/// Edge of the brand mark that anchors the window's leading corner off macOS
/// (see [`window_mark`]). Between a chrome tile's 32px hit box and its 13px
/// glyph: the mark paints no hover capsule, so what has to sit level with the
/// tiles beside it is its *ink* — and solid art reads heavier than line work at
/// equal size, hence short of the tile box rather than matching it.
pub(crate) const WINDOW_MARK_SIZE: f32 = 20.;
/// The "duo" mark — the same art the app icon and the About page carry — drawn
/// at the leading edge of the title-bar row, or `None` on macOS.
///
/// macOS owns that corner: the traffic lights sit there, and [`TITLE_BAR_LEAD`]
/// reserves them 80px. Everywhere else it is empty. The row's contents are the
/// rail's controls at its *right* end and the window chrome at the far side, so
/// the window's leading corner — the slot Windows reads as the app's identity,
/// filled by Explorer, VS Code and Zed alike — held nothing at all, which comes
/// across as unfinished rather than restrained.
///
/// Drawn, never clicked. It is not a menu button, so it stays out of the tile
/// rhythm (no hover capsule) and deliberately takes no `occlude()`: the row it
/// lives in is a `WindowControlArea::Drag`, and letting the mark fall through to
/// that keeps the strip grabbable instead of punching a dead 20px hole in it.
/// Make a row that stands in for the title bar behave like one: drag it to move
/// the window, double-click it to zoom.
///
/// Three rows do this. The rail's top zone sits level with the real bar but
/// outside it (the bar only spans the column beside the rail), and the code and
/// diff overlays each cover the bar with a header of their own drawn to its line.
/// Without this they are all dead strips: 40px across the top of the window that
/// look exactly like the caption and do nothing when you grab them.
///
/// Driven the way gpui-component's own `TitleBar` drives it — a press arms a
/// flag and the first *move* starts the window move — so a plain click, and a
/// double-click, still land intact. Note that on Windows the drag area maps to
/// HTCAPTION and the OS claims the press before gpui hit-tests, so every button
/// inside one of these rows needs an `occlude()` wrapper to get its clicks back.
pub(crate) fn title_bar_drag(row: gpui::Stateful<gpui::Div>) -> gpui::Stateful<gpui::Div> {
let should_move = Rc::new(Cell::new(false));
row.window_control_area(gpui::WindowControlArea::Drag)
.on_mouse_down(gpui::MouseButton::Left, {
let should_move = should_move.clone();
move |_, _, _| should_move.set(true)
})
.on_mouse_up(gpui::MouseButton::Left, {
let should_move = should_move.clone();
move |_, _, _| should_move.set(false)
})
.on_mouse_move(move |_, window, _| {
if should_move.replace(false) {
window.start_window_move();
}
})
.on_double_click(|_, window, _| {
// gpui only implements `titlebar_double_click` on macOS — the trait
// method is an empty default everywhere else, so on Linux this row
// swallowed the double-click and nothing zoomed. `zoom_window` is the
// maximise toggle there (x11 `_NET_WM_STATE_MAXIMIZED_*`, wayland
// `set_maximized`), and what gpui-component's own `TitleBar` calls on
// Linux for exactly this reason. Windows needs neither: the row is a
// drag area, which maps to HTCAPTION, and the OS has already restored
// or maximised the window before this could run.
if cfg!(target_os = "linux") {
window.zoom_window();
} else {
window.titlebar_double_click();
}
})
}
pub(crate) fn window_mark() -> Option<impl IntoElement> {
if cfg!(target_os = "macos") {
return None;
}
// Decoded once and shared: the title bar re-renders on every cursor blink,
// and building a fresh `Image` per frame would re-copy the PNG and miss
// gpui's image cache, which is keyed on the image's identity.
static LOGO: std::sync::OnceLock<Arc<gpui::Image>> = std::sync::OnceLock::new();
let logo = LOGO
.get_or_init(|| {
Arc::new(gpui::Image::from_bytes(
gpui::ImageFormat::Png,
include_bytes!("../../assets/logo@256.png").to_vec(),
))
})
.clone();
Some(img(logo).size(px(WINDOW_MARK_SIZE)).flex_shrink_0())
}
/// One tab: a split-pane tree plus an optional user-assigned name. Settings is
/// no longer a tab — it's a full-window overlay (`Tty7App::settings`), so every
/// tab is a real terminal tab.
@@ -843,10 +932,16 @@ impl Tty7App {
// First run (no session file): the very first terminal has no
// predecessor to inherit from, so start in the app's current
// directory (None → default behavior).
None => {
let first = new_terminal(font_size, None, None, None, window, cx);
(vec![Tab::new(Pane::leaf(first))], 0)
}
None => match new_terminal(font_size, None, None, None, window, cx) {
Ok(first) => (vec![Tab::new(Pane::leaf(first))], 0),
// The daemon we just tried to start isn't answering. A window
// with no tabs is a legal state (it shows the home page), and
// far better than taking the launch down over it.
Err(e) => {
log::error!("first terminal failed to start: {e}");
(Vec::new(), 0)
}
},
// A saved session (with tabs, or an empty home-page state): rebuild it
// the same way a daemon restart does.
some => tabs_from_session(some, font_size, window, cx),
@@ -1271,7 +1366,13 @@ impl Tty7App {
return;
};
let alive = alive_panes();
let pane = session_to_pane(&st.pane, &alive, self.font_size, window, cx);
let Some(pane) = session_to_pane(&st.pane, &alive, self.font_size, window, cx) else {
// Nothing came back (an unreachable daemon). Put the entry back so
// the tab is still reopenable once the daemon is up again.
window.push_notification("Could not reopen the tab: no terminal started", cx);
self.closed.push(st);
return;
};
// Leaving the current tab for the reopened one; snapshot its focused
// pane so switching back restores it (same as `activate`).
self.remember_active_pane(window, cx);
@@ -2725,7 +2826,14 @@ impl Tty7App {
.focused_or_first(window, cx)
.and_then(|leaf| leaf.read(cx).local_cwd())
});
let tab = new_terminal(self.font_size, cwd, None, shell, window, cx);
let tab = match new_terminal(self.font_size, cwd, None, shell, window, cx) {
Ok(view) => view,
Err(e) => {
log::error!("new tab spawn failed: {e}");
window.push_notification(format!("Could not open a terminal: {e}"), cx);
return;
}
};
// Leaving the current tab for the new one; snapshot its focused pane
// so switching back restores it (same as `activate`).
self.remember_active_pane(window, cx);
@@ -2839,7 +2947,14 @@ impl Tty7App {
}
} else {
let shell = target.read(cx).shell_spec();
new_terminal(self.font_size, cwd, None, shell, window, cx)
match new_terminal(self.font_size, cwd, None, shell, window, cx) {
Ok(view) => view,
Err(e) => {
log::error!("split spawn failed: {e}");
window.push_notification(format!("Could not split the pane: {e}"), cx);
return;
}
}
};
if let Some(tab) = self.tabs.get_mut(self.active) {
if tab.pane.split_leaf(&target, axis, new.clone()) {
@@ -3381,7 +3496,14 @@ impl Tty7App {
window: &mut Window,
cx: &mut Context<Self>,
) {
let view = new_terminal(self.font_size, Some(wt.path), None, None, window, cx);
let view = match new_terminal(self.font_size, Some(wt.path), None, None, window, cx) {
Ok(view) => view,
Err(e) => {
log::error!("worktree tab spawn failed: {e}");
window.push_notification(format!("Could not open a terminal: {e}"), cx);
return;
}
};
self.remember_active_pane(window, cx);
self.maximized = None;
let insert_at = self.new_tab_insert_at(cx);
@@ -5211,6 +5333,26 @@ impl Render for Tty7App {
} else {
(Some(title_bar), None)
};
// And where the overlays hang. Normally on the terminal column, which they
// fill: the bar is that column's first child, so an `inset_0` overlay
// covers it and the overlay's own header row lands *on* the caption line —
// which is what both headers are drawn for (title-bar height, the bar's
// insets, a full-size chrome tile for their one control).
//
// With the bar hoisted, a column-anchored overlay starts 40px down and its
// header sits one row too low: level with the panel's tab row instead of
// with the caption. So it hangs on the row that owns the bar instead,
// inset from the right by the panel's width — covering the bar's band over
// the terminal column (which carries nothing there but the drag region, or
// the rail's controls while it's collapsed: exactly what an overlay covers
// with the panel closed) and stopping short of the panel, so the ─ ▢ ✕
// group and the corner chrome keep their own surface and their clicks.
let (column_overlays, hoisted_overlays) = if panel_below_title_bar {
(Vec::new(), overlays)
} else {
(overlays, Vec::new())
};
let panel_px = self.right_panel_px(window, cx);
// The terminal column, and the anchor for both overlays: they fill it —
// and, since the panel is a sibling rather than a child, stop short of the
// panel for free. With the bar spanning above, they stop short of it too,
@@ -5224,7 +5366,7 @@ impl Render for Tty7App {
.relative()
.when_some(column_title_bar, |this, bar| this.child(bar))
.child(body_area)
.children(overlays);
.children(column_overlays);
let panel_row = div()
.flex_1()
.min_h_0()
@@ -5246,6 +5388,8 @@ impl Render for Tty7App {
.min_w_0()
.flex()
.flex_col()
// The containing block for the hoisted overlays below.
.relative()
.child(
// The bar's own band over the panel, painted in the panel's
// surface so the column still reads as one continuous
@@ -5278,6 +5422,19 @@ impl Render for Tty7App {
.child(bar),
)
.child(panel_row)
// Last child, so they paint over both the bar and the column.
// Each overlay is `absolute().inset_0()` against this wrapper,
// which is the only thing that has to know where the panel
// starts.
.children(hoisted_overlays.into_iter().map(|overlay| {
div()
.absolute()
.top_0()
.left_0()
.bottom_0()
.right(px(panel_px))
.child(overlay)
}))
.into_any_element(),
None => panel_row.into_any_element(),
})
@@ -5706,7 +5863,12 @@ fn tabs_from_session(
let alive = alive_panes();
let mut tabs: Vec<Tab> = Vec::with_capacity(session.tabs.len());
for st in &session.tabs {
let pane = session_to_pane(&st.pane, &alive, font_size, window, cx);
// A tab whose every leaf failed to come back has nothing to show; drop
// it rather than restore an empty frame (or, worse, abort the launch).
let Some(pane) = session_to_pane(&st.pane, &alive, font_size, window, cx) else {
log::error!("dropping a restored tab: no pane in it could be started");
continue;
};
tabs.push(Tab {
pane,
name: st.name.clone(),
@@ -5720,8 +5882,9 @@ fn tabs_from_session(
sidebar_group: std::cell::RefCell::new(st.sidebar_group.clone()),
});
}
// Clamp the saved active index into the rebuilt range.
let active = session.active.min(tabs.len() - 1);
// Clamp the saved active index into the rebuilt range (which can be empty
// when nothing restored).
let active = session.active.min(tabs.len().saturating_sub(1));
(tabs, active)
}
@@ -5729,13 +5892,17 @@ fn tabs_from_session(
/// `pane_id` is still alive in the daemon re-`attach`es (process + scrollback
/// intact); otherwise it spawns a fresh shell in the saved cwd. `alive` is the
/// daemon's current pane set, computed once by the caller.
///
/// `None` when nothing under this node could be started (an unreachable
/// daemon): restore drops what it can't rebuild instead of leaving `Empty`
/// nodes — which every tree operation ignores — in a live tab.
fn session_to_pane(
sp: &SessionPane,
alive: &std::collections::HashSet<u64>,
font_size: f32,
window: &mut Window,
cx: &mut Context<Tty7App>,
) -> Pane {
) -> Option<Pane> {
match sp {
SessionPane::Leaf {
cwd,
@@ -5756,7 +5923,7 @@ fn session_to_pane(
if let Some(spec) = ssh_spec.clone() {
let resolved = crate::ui::ssh_connect::resolve_persisted_ssh_spec(spec, cx);
match new_terminal_native(font_size, cwd.clone(), resolved, window, cx) {
Ok(view) => return Pane::leaf(view),
Ok(view) => return Some(Pane::leaf(view)),
// Keep restore alive: fall through to a local shell in
// this slot rather than aborting startup.
Err(e) => log::error!("restoring native SSH pane failed: {e}"),
@@ -5765,7 +5932,13 @@ fn session_to_pane(
}
// A shell pick isn't persisted in the session, so a stale pane that
// must respawn comes back on the default shell.
let view = new_terminal(font_size, cwd.clone(), restore, None, window, cx);
let view = match new_terminal(font_size, cwd.clone(), restore, None, window, cx) {
Ok(view) => view,
Err(e) => {
log::error!("restoring pane failed: {e}");
return None;
}
};
// A pane that could NOT re-attach lost its running agent with the
// daemon; when we captured that agent's native session id, hand
// the fresh shell its resume command so the conversation picks up
@@ -5779,20 +5952,32 @@ fn session_to_pane(
{
view.read(cx).run_command_line(&cmd);
}
Pane::leaf(view)
Some(Pane::leaf(view))
}
SessionPane::Split { axis, ratio, a, b } => {
let axis = match axis {
SessionAxis::Horizontal => Axis::Horizontal,
SessionAxis::Vertical => Axis::Vertical,
};
let a = session_to_pane(a, alive, font_size, window, cx);
let b = session_to_pane(b, alive, font_size, window, cx);
Pane::split_node(axis, *ratio, a, b)
// One side failing collapses the split onto the survivor, exactly
// as closing that pane by hand would.
match (
session_to_pane(a, alive, font_size, window, cx),
session_to_pane(b, alive, font_size, window, cx),
) {
(Some(a), Some(b)) => Some(Pane::split_node(axis, *ratio, a, b)),
(Some(only), None) | (None, Some(only)) => Some(only),
(None, None) => None,
}
}
}
}
/// Build a shell-backed terminal view, wiring the per-pane subscriptions every
/// pane needs. Fallible: the daemon can refuse the spawn (it died, it's
/// wedged, the shell doesn't exist), and every caller here runs inside a gpui
/// input callback, where a panic can't unwind and would abort the app instead
/// of surfacing the failure. Report it, don't `expect` it.
fn new_terminal(
font_size: f32,
working_directory: Option<std::path::PathBuf>,
@@ -5800,10 +5985,10 @@ fn new_terminal(
shell: Option<ShellSpec>,
window: &mut Window,
cx: &mut Context<Tty7App>,
) -> Entity<TerminalView> {
) -> anyhow::Result<Entity<TerminalView>> {
let parts = TerminalView::spawn_shell_terminal(working_directory, restore_pane, shell)?;
let view = cx.new(|cx| {
let mut view = TerminalView::new(working_directory, restore_pane, shell, window, cx)
.expect("failed to start terminal");
let mut view = TerminalView::from_shell_parts(parts, window, cx);
// Inherit the current global font size so new panes match existing ones.
view.font_size = px(font_size);
view
@@ -5829,7 +6014,7 @@ fn new_terminal(
)
.detach();
watch_pane_focus(&view, window, cx);
view
Ok(view)
}
/// Re-render the app whenever `view` takes focus. Nothing else does this: a
+26 -18
View File
@@ -801,7 +801,7 @@ impl Tty7App {
/// every buffer that was ever opened. Sits on the title bar's line and matches
/// its height, so the editor's top edge lines up with the panel's tab row and
/// the rail's controls across the window.
fn render_editor_header(&self, cx: &mut Context<Self>) -> gpui::Div {
fn render_editor_header(&self, cx: &mut Context<Self>) -> gpui::Stateful<gpui::Div> {
let active = self.tab_code().and_then(|c| c.active_file());
let name = active.map(|f| f.label());
let dirty = active.is_some_and(|f| f.dirty);
@@ -816,7 +816,10 @@ impl Tty7App {
} else {
crate::ui::app::TITLE_BAR_LEAD
};
h_flex()
// The overlay covers the real title bar, so this row inherits its drag and
// zoom gestures — otherwise opening a file turns the top of the window into
// a strip that looks like the caption and can't move it.
crate::ui::app::title_bar_drag(h_flex().id("editor-header"))
.flex_none()
.h(px(crate::ui::app::TITLE_BAR_HEIGHT))
.items_center()
@@ -847,22 +850,27 @@ impl Tty7App {
)
})
.child(
crate::ui::tab_strip::chrome_tile_sized(
// This header is the title bar's own height and sits flush
// with it, so its one control is a full chrome tile — not the
// half-size one it used to be, which read as a different
// class of button on the same line.
Button::new("editor-panel-close").icon(Icon::new(IconName::Close)),
crate::ui::app::TILE_SIZE,
crate::ui::app::TILE_GLYPH_LINE,
false,
cx,
)
.rounded_lg()
.tooltip("Back to Terminal (Esc)")
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_code_panel(window, cx);
})),
// `occlude()` for the same reason the title bar's own tiles carry
// it: this row is a `WindowControlArea::Drag`, which on Windows is
// HTCAPTION, and the OS takes the press before gpui hit-tests.
div().occlude().flex_shrink_0().child(
crate::ui::tab_strip::chrome_tile_sized(
// This header is the title bar's own height and sits flush
// with it, so its one control is a full chrome tile — not the
// half-size one it used to be, which read as a different
// class of button on the same line.
Button::new("editor-panel-close").icon(Icon::new(IconName::Close)),
crate::ui::app::TILE_SIZE,
crate::ui::app::TILE_GLYPH_LINE,
false,
cx,
)
.rounded_lg()
.tooltip("Back to Terminal (Esc)")
.on_click(cx.listener(|this, _, window, cx| {
this.toggle_code_panel(window, cx);
})),
),
)
}
+56 -47
View File
@@ -327,7 +327,10 @@ impl Tty7App {
} else {
crate::ui::app::TITLE_BAR_LEAD
};
h_flex()
// Standing in for the title bar means carrying its gestures too: the
// overlay covers the real bar, so without this the whole top of the window
// stops moving it while a diff is up.
crate::ui::app::title_bar_drag(h_flex().id("diff-overlay-header"))
.flex_shrink_0()
.h(px(crate::ui::app::TITLE_BAR_HEIGHT))
.pl(px(lead))
@@ -355,38 +358,42 @@ impl Tty7App {
// click target back to the whole tree — otherwise the only way out
// of a focused view would be to close and re-open the overlay.
.when_some(focused_name(overlay), |bar, name| {
// Wrapped like every other control on a drag row — see the header's
// own note: HTCAPTION would otherwise swallow the click on Windows.
bar.child(
h_flex()
.id("diff-overlay-unfocus")
.items_center()
.gap_1()
.px_1p5()
.py_0p5()
.rounded_md()
.cursor_pointer()
.hover(|s| s.bg(cx.theme().list_hover))
.on_click(cx.listener(|this, _, _window, cx| {
let active = this.active;
if let Some(overlay) = this
.tabs
.get_mut(active)
.and_then(|t| t.diff_overlay.as_mut())
{
overlay.focus = None;
cx.notify();
}
}))
.child(
Icon::new(IconName::ChevronLeft)
.small()
.text_color(cx.theme().muted_foreground),
)
.child(
div()
.text_xs()
.font_family(self.font_family.clone())
.child(name),
),
div().occlude().flex_shrink_0().child(
h_flex()
.id("diff-overlay-unfocus")
.items_center()
.gap_1()
.px_1p5()
.py_0p5()
.rounded_md()
.cursor_pointer()
.hover(|s| s.bg(cx.theme().list_hover))
.on_click(cx.listener(|this, _, _window, cx| {
let active = this.active;
if let Some(overlay) = this
.tabs
.get_mut(active)
.and_then(|t| t.diff_overlay.as_mut())
{
overlay.focus = None;
cx.notify();
}
}))
.child(
Icon::new(IconName::ChevronLeft)
.small()
.text_color(cx.theme().muted_foreground),
)
.child(
div()
.text_xs()
.font_family(self.font_family.clone())
.child(name),
),
),
)
})
.when(
@@ -438,21 +445,23 @@ impl Tty7App {
)
.child(div().flex_1())
.child(
crate::ui::tab_strip::chrome_tile_sized(
// Explicit tile, not `.small()`: this bar stands in for the
// title bar while the overlay is up, so its close control is
// the same tile the title bar's controls are.
Button::new("diff-overlay-close").icon(Icon::new(IconName::Close)),
crate::ui::app::TILE_SIZE,
crate::ui::app::TILE_GLYPH_LINE,
false,
cx,
)
.rounded_lg()
.tooltip("Close Diff (Esc)")
.on_click(cx.listener(|this, _, window, cx| {
this.close_diff_overlay(window, cx);
})),
div().occlude().flex_shrink_0().child(
crate::ui::tab_strip::chrome_tile_sized(
// Explicit tile, not `.small()`: this bar stands in for the
// title bar while the overlay is up, so its close control is
// the same tile the title bar's controls are.
Button::new("diff-overlay-close").icon(Icon::new(IconName::Close)),
crate::ui::app::TILE_SIZE,
crate::ui::app::TILE_GLYPH_LINE,
false,
cx,
)
.rounded_lg()
.tooltip("Close Diff (Esc)")
.on_click(cx.listener(|this, _, window, cx| {
this.close_diff_overlay(window, cx);
})),
),
)
}
+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();
+181 -92
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,
};
@@ -1152,7 +1150,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()
@@ -1178,13 +1176,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,
@@ -1193,19 +1208,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()
}
@@ -1215,7 +1289,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() {
@@ -1339,7 +1416,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();
@@ -1365,7 +1442,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();
@@ -1468,7 +1545,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)),
@@ -1728,6 +1805,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()
@@ -1795,12 +1874,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.
@@ -1823,7 +1908,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(
@@ -1969,13 +2068,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();
@@ -2644,7 +2743,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() {
@@ -2737,7 +2836,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() {
@@ -2753,7 +2852,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() {
@@ -2776,7 +2875,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() {
@@ -2983,11 +3082,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();
@@ -3010,17 +3109,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();
@@ -3148,30 +3247,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)),
@@ -3404,19 +3503,19 @@ 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 confirm_close_switch = Switch::new("wt-confirm-window-close")
let confirm_close_switch = crate::ui::theme::switch("wt-confirm-window-close", cx)
.checked(confirm_window_close)
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_confirm_window_close(*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();
@@ -3617,7 +3716,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)
@@ -3649,7 +3748,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>();
@@ -3988,20 +4087,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()
@@ -4021,12 +4130,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()
@@ -4039,22 +4143,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();
@@ -4364,7 +4453,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
+43 -40
View File
@@ -13,16 +13,13 @@
use gpui::{
Animation, AnimationExt as _, AnyElement, Axis, Bounds, Context, Div, FontWeight, MouseButton,
MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, SharedString, Stateful, Window,
WindowControlArea, canvas, deferred, div, ease_out_quint, linear_color_stop, linear_gradient,
prelude::*, px,
MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, SharedString, Stateful, Window, canvas,
deferred, div, ease_out_quint, linear_color_stop, linear_gradient, prelude::*, px,
};
use gpui_component::button::{Button, ButtonVariants as _};
use gpui_component::input::Input;
use gpui_component::menu::{ContextMenu, ContextMenuExt as _};
use gpui_component::{
ActiveTheme as _, Icon, IconName, InteractiveElementExt as _, Sizable as _, h_flex, v_flex,
};
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
use std::cell::{Cell, RefCell};
use std::rc::Rc;
@@ -77,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
@@ -409,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.
@@ -489,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.;
@@ -751,11 +751,35 @@ impl Tty7App {
let controls = h_flex()
.flex_shrink_0()
.h(px(TITLE_BAR_HEIGHT))
// Same box as the real title bar this row stands in for, hairline
// included: gpui-component's `TitleBar` draws a `border_b_1` inside its
// own `TITLE_BAR_HEIGHT` (tty7 paints it transparent, but it still takes
// its pixel), so the bar centres its contents on 19.5 while an
// unbordered 40px row centres them on 20. Half a pixel is invisible on
// the line-art tiles, and *not* on the solid brand mark: collapsing the
// rail hands the mark from this row to the bar, and it visibly hopped up
// as it went. Reserve the same pixel here and the handover is still.
.border_b_1()
.border_color(cx.theme().transparent)
.items_center()
.justify_end()
.gap(px(2.))
// Glyph's ink, not hit box, on the content edge — see `TILE_PAD`.
.pr(px(crate::ui::app::tile_trailing_inset()))
// The brand mark leads the row, on the rail's own content inset — the
// line the search magnifier and every row label below it start on, so
// it reads as the head of this column rather than a floating badge.
// The spacer is what keeps the controls pinned right once the row has
// a leading child (`justify_end` alone no longer does it).
.when_some(crate::ui::app::window_mark(), |row, mark| {
row.child(
div()
.flex_shrink_0()
.pl(px(crate::ui::app::CONTENT_INSET))
.child(mark),
)
.child(div().flex_1())
})
// Both tiles are wrapped in an `occlude()` div, exactly like the
// title-strip chrome. This row is a `WindowControlArea::Drag` (set
// below), which on Windows maps to HTCAPTION — the OS claims the click
@@ -934,34 +958,13 @@ impl Tty7App {
//
// The real `TitleBar` — which carries the window's drag region
// — only spans the *right* column in this layout, so this strip
// would be dead space you can't grab the window by. Make the
// controls' own row act like the title bar it sits level with:
// drag to move, double-click to zoom. Driven exactly like
// `TitleBar` does it (and the settings overlay's stand-in
// strip): a press arms a flag and the first *move* starts the
// window move, so a plain click — and a double-click — still
// lands intact, while the buttons on the right keep taking
// their own clicks.
.child({
let should_move = Rc::new(Cell::new(false));
controls
.id("sidebar-titlebar-drag")
.window_control_area(WindowControlArea::Drag)
.on_mouse_down(MouseButton::Left, {
let should_move = should_move.clone();
move |_, _, _| should_move.set(true)
})
.on_mouse_up(MouseButton::Left, {
let should_move = should_move.clone();
move |_, _, _| should_move.set(false)
})
.on_mouse_move(move |_, window, _| {
if should_move.replace(false) {
window.start_window_move();
}
})
.on_double_click(|_, window, _| window.titlebar_double_click())
})
// would be dead space you can't grab the window by. `title_bar_drag`
// makes the controls' row act like the bar it sits level with:
// drag to move, double-click to zoom, while the buttons on the
// right keep taking their own clicks (they're `occlude()`d).
.child(crate::ui::app::title_bar_drag(
controls.id("sidebar-titlebar-drag"),
))
.child(top_bar)
.child(crate::ui::scrollbar::with_vertical_scrollbar(
"tab-sidebar-scrollbar",
+19
View File
@@ -1518,6 +1518,25 @@ impl Tty7App {
// Negative off macOS only: the bar already inset us past the window
// controls, and there the reserve *is* the clearance.
.ml(px(crate::ui::app::title_bar_hug_offset()))
// The brand mark follows the rail's controls into the strip, so
// collapsing the sidebar doesn't strip the window's leading corner
// back to nothing (see `app::window_mark`). The group is anchored by
// its tiles' *hit boxes*, which start `tile_trailing_inset()` from
// the window edge; the mark has no box, so it adds the difference
// back to land its own ink on `CONTENT_INSET` like the rail's did.
.when_some(crate::ui::app::window_mark(), |group, mark| {
group.child(
div()
.flex_shrink_0()
.pl(px(crate::ui::app::CONTENT_INSET
- crate::ui::app::tile_trailing_inset()))
// The mark is solid where the tiles are line work, so it
// needs more air than the 2px that separates two tiles
// before the "+" beside it stops reading as part of it.
.pr(px(4.))
.child(mark),
)
})
.child(
div().occlude().flex_shrink_0().child(
self.attach_new_tab_menu(
+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.