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