mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-22 00:02:23 +00:00
Merge remote-tracking branch 'origin/main' into fix/pane-cwd-from-process-table
# Conflicts: # CHANGELOG.md
This commit is contained in:
@@ -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
|
||||
|
||||
@@ -0,0 +1,141 @@
|
||||
//! Crash log — the panic message the OS crash reporter throws away.
|
||||
//!
|
||||
//! Most tty7 panics happen inside a gpui input callback, and those callbacks are
|
||||
//! `extern "C"`: the panic can't unwind across them, so the runtime aborts. What
|
||||
//! macOS then records is the *abort* — `panic_cannot_unwind` on top of
|
||||
//! `handle_key_event` — with no message, no `file:line`, and the original frames
|
||||
//! already unwound away. Reports like that are undiagnosable, and the GUI has no
|
||||
//! logger and no terminal to print to.
|
||||
//!
|
||||
//! So we write the two lines that matter (message + location, plus a backtrace)
|
||||
//! to `crash.log` in the config dir before the process goes down.
|
||||
|
||||
use std::fmt::Write as _;
|
||||
use std::path::PathBuf;
|
||||
|
||||
/// Rewrite the log once it passes this, so a panic loop can't grow it forever.
|
||||
const MAX_BYTES: u64 = 256 * 1024;
|
||||
|
||||
/// Install the panic hook for this process. `role` labels the records, since the
|
||||
/// GUI and the daemon it spawns share one config dir. Chains to the previously
|
||||
/// installed hook, so the usual stderr output still happens when there's a
|
||||
/// terminal to see it.
|
||||
pub fn install(role: &'static str) {
|
||||
let previous = std::panic::take_hook();
|
||||
std::panic::set_hook(Box::new(move |info| {
|
||||
record(role, info);
|
||||
previous(info);
|
||||
}));
|
||||
}
|
||||
|
||||
/// Append one record. Every step is best-effort: a panic handler that panics
|
||||
/// (or fails loudly) is worse than one that loses a log line.
|
||||
fn record(role: &str, info: &std::panic::PanicHookInfo<'_>) {
|
||||
let Some(path) = log_path() else {
|
||||
return;
|
||||
};
|
||||
let thread = std::thread::current();
|
||||
let mut record = String::new();
|
||||
// `info` renders as "panicked at <file:line:col>:\n<message>" — the exact
|
||||
// pair the crash report is missing.
|
||||
let _ = write!(
|
||||
record,
|
||||
"\n=== {} {} v{} pid {} thread {:?}\n{info}\n{}\n",
|
||||
utc_timestamp(),
|
||||
role,
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
std::process::id(),
|
||||
thread.name().unwrap_or("<unnamed>"),
|
||||
std::backtrace::Backtrace::force_capture(),
|
||||
);
|
||||
append(&path, &record);
|
||||
}
|
||||
|
||||
fn append(path: &PathBuf, record: &str) {
|
||||
use std::io::Write as _;
|
||||
let truncate = std::fs::metadata(path).is_ok_and(|m| m.len() > MAX_BYTES);
|
||||
let mut file = match std::fs::OpenOptions::new()
|
||||
.create(true)
|
||||
.append(!truncate)
|
||||
.write(true)
|
||||
.truncate(truncate)
|
||||
.open(path)
|
||||
{
|
||||
Ok(f) => f,
|
||||
Err(_) => return,
|
||||
};
|
||||
let _ = file.write_all(record.as_bytes());
|
||||
let _ = file.flush();
|
||||
}
|
||||
|
||||
fn log_path() -> Option<PathBuf> {
|
||||
crate::core::config::config_path("crash.log")
|
||||
}
|
||||
|
||||
/// `YYYY-MM-DD HH:MM:SS UTC` from the epoch seconds, so a record can be lined up
|
||||
/// against an OS crash report without pulling in a date crate.
|
||||
fn utc_timestamp() -> String {
|
||||
let secs = std::time::SystemTime::now()
|
||||
.duration_since(std::time::UNIX_EPOCH)
|
||||
.map(|d| d.as_secs())
|
||||
.unwrap_or(0);
|
||||
let (days, rem) = (secs / 86_400, secs % 86_400);
|
||||
let (y, m, d) = civil_from_days(days as i64);
|
||||
format!(
|
||||
"{y:04}-{m:02}-{d:02} {:02}:{:02}:{:02} UTC",
|
||||
rem / 3600,
|
||||
(rem % 3600) / 60,
|
||||
rem % 60
|
||||
)
|
||||
}
|
||||
|
||||
/// Howard Hinnant's `civil_from_days`: days since the Unix epoch → (y, m, d).
|
||||
fn civil_from_days(z: i64) -> (i64, u32, u32) {
|
||||
let z = z + 719_468;
|
||||
let era = z.div_euclid(146_097);
|
||||
let doe = z.rem_euclid(146_097);
|
||||
let yoe = (doe - doe / 1460 + doe / 36_524 - doe / 146_096) / 365;
|
||||
let y = yoe + era * 400;
|
||||
let doy = doe - (365 * yoe + yoe / 4 - yoe / 100);
|
||||
let mp = (5 * doy + 2) / 153;
|
||||
let d = (doy - (153 * mp + 2) / 5 + 1) as u32;
|
||||
let m = if mp < 10 { mp + 3 } else { mp - 9 } as u32;
|
||||
(if m <= 2 { y + 1 } else { y }, m, d)
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::{civil_from_days, install, log_path};
|
||||
|
||||
/// The whole point of the hook: after a panic, the message and its location
|
||||
/// are on disk. `catch_unwind` stands in for the abort — the hook runs
|
||||
/// before either outcome.
|
||||
#[test]
|
||||
fn a_panic_lands_in_the_crash_log() {
|
||||
// Same pinned temp dir the config tests use (set-once, first call wins).
|
||||
let dir = std::env::temp_dir().join(format!("tty7-covtest-{}", std::process::id()));
|
||||
std::fs::create_dir_all(&dir).ok();
|
||||
crate::core::config::set_config_dir(dir);
|
||||
let path = log_path().expect("a pinned config dir resolves a log path");
|
||||
let _ = std::fs::remove_file(&path);
|
||||
|
||||
install("test");
|
||||
let _ = std::panic::catch_unwind(|| panic!("crash-log probe"));
|
||||
|
||||
let body = std::fs::read_to_string(&path).expect("the hook wrote a record");
|
||||
assert!(body.contains("crash-log probe"), "message: {body}");
|
||||
// Bare file name: `panic!`'s location carries the platform's own
|
||||
// separator (`src\core\crash.rs` on Windows).
|
||||
assert!(body.contains("crash.rs:"), "location: {body}");
|
||||
assert!(body.contains("test v"), "role + version: {body}");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn civil_from_days_matches_known_dates() {
|
||||
assert_eq!(civil_from_days(0), (1970, 1, 1));
|
||||
assert_eq!(civil_from_days(20_660), (2026, 7, 26));
|
||||
// Leap day, and the day after it.
|
||||
assert_eq!(civil_from_days(19_782), (2024, 2, 29));
|
||||
assert_eq!(civil_from_days(19_783), (2024, 3, 1));
|
||||
}
|
||||
}
|
||||
@@ -12,6 +12,7 @@ pub mod agent_hooks;
|
||||
pub mod agent_prompt;
|
||||
pub mod cli_agent;
|
||||
pub mod config;
|
||||
pub mod crash;
|
||||
// SSH connection-manager data layer (WS1). Its public API is consumed by the
|
||||
// daemon-session, auth, forwarding, and UI workstreams, which land separately —
|
||||
// so parts of it read as dead code until those merge.
|
||||
|
||||
+11
@@ -298,6 +298,17 @@ fn main() {
|
||||
// under this dir too, so the order matters).
|
||||
apply_config_dir_arg();
|
||||
|
||||
// Panics inside gpui's `extern "C"` input callbacks abort instead of
|
||||
// unwinding, and the OS crash report then holds the abort rather than the
|
||||
// panic — no message, no location. Record those to `crash.log` in the config
|
||||
// dir. Installed here, right after the config dir resolves, so both the GUI
|
||||
// and the daemon below are covered from their first line of real work.
|
||||
crate::core::crash::install(if std::env::args().any(|a| a == "--daemon") {
|
||||
"daemon"
|
||||
} else {
|
||||
"gui"
|
||||
});
|
||||
|
||||
// Daemon mode: when launched with `--daemon` we run the headless persistent
|
||||
// terminal server and never open a window. This is the backing process the GUI
|
||||
// auto-spawns and reconnects to; it owns all PTYs + child shells and outlives
|
||||
|
||||
+65
-3
@@ -236,6 +236,24 @@ impl RemoteTerminal {
|
||||
let retry_shell = shell.clone();
|
||||
match Self::spawn_once(size, cell_w, cell_h, cwd, shell) {
|
||||
Ok(term) => Ok(term),
|
||||
Err(first_err) if daemon_not_listening(&first_err) => {
|
||||
// Nothing is on the socket: the daemon died (crash, OOM, a stray
|
||||
// `kill`) since the last pane was opened. Every later spawn would
|
||||
// fail the same way, so bring one back up and retry rather than
|
||||
// leaving the window unable to open another terminal.
|
||||
if let Err(start_err) = crate::daemon::spawn::ensure_running() {
|
||||
return Err(anyhow::anyhow!(
|
||||
"daemon not running ({first_err}); starting one failed: {start_err}"
|
||||
));
|
||||
}
|
||||
Self::spawn_once(size, cell_w, cell_h, retry_cwd, retry_shell).map_err(
|
||||
|second_err| {
|
||||
anyhow::anyhow!(
|
||||
"daemon not running ({first_err}); started one but Spawn still failed: {second_err}"
|
||||
)
|
||||
},
|
||||
)
|
||||
}
|
||||
Err(first_err) if daemon_disconnected_before_spawn_reply(&first_err) => {
|
||||
// A live-but-old daemon can accept the connection, panic while
|
||||
// handling Spawn, and close before replying. Restart once so an
|
||||
@@ -1430,6 +1448,21 @@ fn record_mark(term: &Term<EventProxy>, marks: &crate::terminal::marks::Marks, e
|
||||
}
|
||||
}
|
||||
|
||||
/// Whether the failure is "nothing is listening on the socket" — the daemon is
|
||||
/// gone, as opposed to alive but unhappy. On Unix a dead daemon leaves the
|
||||
/// socket file behind (`ConnectionRefused`) or removed it on the way out
|
||||
/// (`NotFound`); on Windows the named pipe simply isn't there (`NotFound`).
|
||||
fn daemon_not_listening(err: &anyhow::Error) -> bool {
|
||||
err.chain().any(|cause| {
|
||||
cause.downcast_ref::<std::io::Error>().is_some_and(|io| {
|
||||
matches!(
|
||||
io.kind(),
|
||||
std::io::ErrorKind::ConnectionRefused | std::io::ErrorKind::NotFound
|
||||
)
|
||||
})
|
||||
})
|
||||
}
|
||||
|
||||
fn daemon_disconnected_before_spawn_reply(err: &anyhow::Error) -> bool {
|
||||
err.chain().any(|cause| {
|
||||
cause.downcast_ref::<std::io::Error>().is_some_and(|io| {
|
||||
@@ -1604,10 +1637,14 @@ fn parse_osc_notification(payload: &[u8]) -> Option<(Option<String>, String)> {
|
||||
/// isolation (dev vs. real config dir), exactly like every other config-dir file.
|
||||
fn connect() -> anyhow::Result<Stream> {
|
||||
transport::connect().map_err(|e| {
|
||||
anyhow::anyhow!(
|
||||
"connect to daemon at {}: {e}",
|
||||
// `context`, not a formatted `anyhow!`: callers classify the failure by
|
||||
// downcasting to `io::Error` (see `daemon_not_listening`), and
|
||||
// interpolating the cause into a string would leave the chain with
|
||||
// nothing to find.
|
||||
anyhow::Error::new(e).context(format!(
|
||||
"connect to daemon at {}",
|
||||
transport::endpoint_display()
|
||||
)
|
||||
))
|
||||
})
|
||||
}
|
||||
|
||||
@@ -1742,6 +1779,31 @@ mod tests {
|
||||
assert!(!daemon_disconnected_before_spawn_reply(&refused));
|
||||
}
|
||||
|
||||
/// A dead daemon is the one failure the client can fix by itself, and it
|
||||
/// must be told apart from a live daemon saying no — restarting on *that*
|
||||
/// would kill every running pane over a bad shell setting.
|
||||
#[test]
|
||||
fn only_a_dead_daemon_is_worth_starting_one_for() {
|
||||
let connect_failed = |kind| -> anyhow::Error {
|
||||
anyhow::Error::new(std::io::Error::new(kind, "no listener"))
|
||||
.context("connect to daemon at /tmp/tty7.sock")
|
||||
};
|
||||
assert!(daemon_not_listening(&connect_failed(
|
||||
std::io::ErrorKind::ConnectionRefused
|
||||
)));
|
||||
assert!(daemon_not_listening(&connect_failed(
|
||||
std::io::ErrorKind::NotFound
|
||||
)));
|
||||
|
||||
// A daemon that answered and refused, and one that hung up mid-Spawn:
|
||||
// neither is "not running", and each has its own recovery.
|
||||
let refused = anyhow::anyhow!("daemon refused Spawn: configured shell missing");
|
||||
assert!(!daemon_not_listening(&refused));
|
||||
let eof: anyhow::Error =
|
||||
std::io::Error::new(std::io::ErrorKind::UnexpectedEof, "closed").into();
|
||||
assert!(!daemon_not_listening(&eof));
|
||||
}
|
||||
|
||||
/// Without a real daemon, drive the reader path directly: a `UnixStream::pair`
|
||||
/// stands in for the connection. We hand `RemoteTerminal` one half (as if it
|
||||
/// were the attach'd socket) and push framed `DaemonMsg`s down the other, then
|
||||
|
||||
@@ -71,6 +71,16 @@ pub(super) fn grid_smart_range<T: EventListener>(
|
||||
term: &Term<T>,
|
||||
click: Point,
|
||||
) -> Option<SmartRange> {
|
||||
// 0) The click carries the geometry of the frame that dispatched it, and
|
||||
// the grid can shrink out from under it (a split, a window drag, a
|
||||
// replayed attach size landing on the reader thread). Both walks below
|
||||
// index `grid[click.line]` straight away, and `Grid`'s `Index<Line>`
|
||||
// only `debug_assert`s the bound — a release build walks off the
|
||||
// storage. Same guard, same reason, as `TerminalView::grid_line`.
|
||||
if click.line < term.topmost_line() || click.line > term.bottommost_line() {
|
||||
return None;
|
||||
}
|
||||
|
||||
// 1) An explicit OSC 8 hyperlink run wins outright — the program told us
|
||||
// the exact extent, no guessing needed.
|
||||
if let Some((start, end)) = hyperlink_run(term, click) {
|
||||
@@ -805,6 +815,20 @@ mod tests {
|
||||
assert!(grid_smart_range(&term, Point::new(Line(0), Column(99))).is_none());
|
||||
}
|
||||
|
||||
/// A double-click dispatched with the previous frame's geometry can name a
|
||||
/// row the grid has since dropped. Indexing it walks off the storage, and
|
||||
/// the click arrives in a gpui `extern "C"` callback where that panic
|
||||
/// aborts instead of unwinding — so the row has to be refused first.
|
||||
#[test]
|
||||
fn click_outside_the_grid_rows_yields_no_range() {
|
||||
let term = term_with(10, 2, "hello");
|
||||
// Below the last row of a shrunken grid...
|
||||
assert!(grid_smart_range(&term, Point::new(Line(2), Column(0))).is_none());
|
||||
assert!(grid_smart_range(&term, Point::new(Line(9_000), Column(0))).is_none());
|
||||
// ...and above the top of a scrollback this short.
|
||||
assert!(grid_smart_range(&term, Point::new(Line(-1), Column(0))).is_none());
|
||||
}
|
||||
|
||||
fn selected(text: &str, click: usize) -> Option<String> {
|
||||
let chars: Vec<char> = text.chars().collect();
|
||||
range(text, click).map(|(s, e)| chars[s..=e].iter().collect())
|
||||
|
||||
+132
-17
@@ -93,6 +93,18 @@ pub struct NativeSshParts {
|
||||
persist: Box<crate::daemon::protocol::NativeSshSpec>,
|
||||
}
|
||||
|
||||
/// An established shell daemon pane (fresh spawn or re-attach), ready to be
|
||||
/// wrapped in a view: the output of the fallible
|
||||
/// [`TerminalView::spawn_shell_terminal`], consumed by the infallible
|
||||
/// [`TerminalView::from_shell_parts`].
|
||||
pub struct ShellParts {
|
||||
terminal: RemoteTerminal,
|
||||
pane_id: u64,
|
||||
/// The explicit shell pick this pane was spawned with, if any; `None` for a
|
||||
/// re-attached pane (the pick isn't persisted).
|
||||
shell_spec: Option<ShellSpec>,
|
||||
}
|
||||
|
||||
/// See `TerminalView::drag_scroll`.
|
||||
#[derive(Clone, Copy)]
|
||||
struct DragScroll {
|
||||
@@ -816,19 +828,25 @@ fn fallback_chain(family: &str, configured: &[String]) -> Vec<String> {
|
||||
}
|
||||
|
||||
impl TerminalView {
|
||||
pub fn new(
|
||||
/// The fallible half of a shell-backed view: establish the daemon pane
|
||||
/// *before* the view is built, so a refused spawn (daemon down, spawn
|
||||
/// error) comes back as an `Err` the caller can report. Splitting it out
|
||||
/// matters beyond tidiness: the view is constructed inside `cx.new`, deep
|
||||
/// under gpui's `extern "C"` input callbacks, where a panic can't unwind
|
||||
/// and aborts the process instead. Mirrors
|
||||
/// [`Self::spawn_native_ssh_terminal`].
|
||||
///
|
||||
/// Provisional size; corrected on the first prepaint once we can measure.
|
||||
/// The PTY lives in the daemon now. On session restore (`restore_pane`),
|
||||
/// re-`attach` to the still-running pane so its process + scrollback come
|
||||
/// back intact; otherwise `spawn` a fresh pane (with the caller's shell
|
||||
/// pick, if any). The caller only passes a `restore_pane` it has already
|
||||
/// confirmed alive, so we trust it here.
|
||||
pub fn spawn_shell_terminal(
|
||||
working_directory: Option<std::path::PathBuf>,
|
||||
restore_pane: Option<u64>,
|
||||
shell: Option<ShellSpec>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> anyhow::Result<Self> {
|
||||
// Provisional size; corrected on the first prepaint once we can measure.
|
||||
// The PTY lives in the daemon now. On session restore (`restore_pane`),
|
||||
// re-`attach` to the still-running pane so its process + scrollback come
|
||||
// back intact; otherwise `spawn` a fresh pane (with the caller's shell
|
||||
// pick, if any). The caller only passes a `restore_pane` it has already
|
||||
// confirmed alive, so we trust it here.
|
||||
) -> anyhow::Result<ShellParts> {
|
||||
let (terminal, pane_id, shell_spec) = match restore_pane {
|
||||
Some(id) => (
|
||||
RemoteTerminal::attach(TermSize::new(80, 24), 8, 17, id)?,
|
||||
@@ -848,9 +866,23 @@ impl TerminalView {
|
||||
(terminal, id, shell)
|
||||
}
|
||||
};
|
||||
let mut view = Self::with_terminal(terminal, pane_id, window, cx);
|
||||
view.shell_spec = shell_spec;
|
||||
Ok(view)
|
||||
Ok(ShellParts {
|
||||
terminal,
|
||||
pane_id,
|
||||
shell_spec,
|
||||
})
|
||||
}
|
||||
|
||||
/// Wrap an established shell pane (from [`Self::spawn_shell_terminal`]) in
|
||||
/// a view. Infallible by construction — see that function.
|
||||
pub fn from_shell_parts(
|
||||
parts: ShellParts,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Self>,
|
||||
) -> Self {
|
||||
let mut view = Self::with_terminal(parts.terminal, parts.pane_id, window, cx);
|
||||
view.shell_spec = parts.shell_spec;
|
||||
view
|
||||
}
|
||||
|
||||
/// Spawn a native (russh) SSH pane for `spec` and build the view around it
|
||||
@@ -1160,6 +1192,18 @@ impl TerminalView {
|
||||
cell_width: Pixels,
|
||||
line_height: Pixels,
|
||||
) {
|
||||
// A remembered hover cell describes the *old* geometry: after a resize
|
||||
// its row may not exist any more, and the pointer sits over a different
|
||||
// cell regardless. Forget it — the next mouse move records a fresh one.
|
||||
// (`grid_line` also refuses a stale row, so this is about not underlining
|
||||
// the wrong cell, not about safety.) The link that cell resolved to goes
|
||||
// with it: it is held in grid coordinates the reflow just moved text
|
||||
// under, so keeping it would underline whatever now sits there (and hold
|
||||
// the pointing-hand cursor over it) until the pointer moves again.
|
||||
if (cols, rows) != (self.terminal.size().cols, self.terminal.size().rows) {
|
||||
self.last_hover_cell = None;
|
||||
self.hovered_link = None;
|
||||
}
|
||||
self.cell_width = cell_width;
|
||||
self.line_height = line_height;
|
||||
self.terminal.resize(
|
||||
@@ -4335,6 +4379,23 @@ impl TerminalView {
|
||||
}
|
||||
}
|
||||
|
||||
/// The grid line a screen `row` currently maps to, or `None` when that row
|
||||
/// is outside the grid.
|
||||
///
|
||||
/// Mandatory before indexing: `Grid`'s `Index<Line>` only `debug_assert`s
|
||||
/// the bound, so a release build walks off the storage and panics on the
|
||||
/// slice check instead. A remembered cell goes stale whenever the grid
|
||||
/// shrinks under it — split a pane, drag the window smaller — and the
|
||||
/// callers here run inside gpui's `extern "C"` input callbacks, where that
|
||||
/// panic can't unwind and aborts the process.
|
||||
fn grid_line(
|
||||
term: &alacritty_terminal::Term<crate::terminal::remote::EventProxy>,
|
||||
row: usize,
|
||||
) -> Option<Line> {
|
||||
let line = Line(row as i32 - term.grid().display_offset() as i32);
|
||||
(line >= term.topmost_line() && line <= term.bottommost_line()).then_some(line)
|
||||
}
|
||||
|
||||
/// Open the link under the given cell, if any (OSC 8 hyperlink, plain URL or
|
||||
/// existing file or directory path detected in the row text). Returns true if one opened.
|
||||
pub fn open_link_at(&self, col: usize, row: usize, cx: &mut Context<Self>) -> bool {
|
||||
@@ -4342,8 +4403,9 @@ impl TerminalView {
|
||||
return false;
|
||||
}
|
||||
let term = self.terminal.term.lock();
|
||||
let display_offset = term.grid().display_offset() as i32;
|
||||
let line = Line(row as i32 - display_offset);
|
||||
let Some(line) = Self::grid_line(&term, row) else {
|
||||
return false;
|
||||
};
|
||||
let cols = term.columns();
|
||||
if col >= cols {
|
||||
return false;
|
||||
@@ -4490,8 +4552,7 @@ impl TerminalView {
|
||||
include_loopback: bool,
|
||||
) -> Option<HoveredLink> {
|
||||
let term = self.terminal.term.lock();
|
||||
let display_offset = term.grid().display_offset() as i32;
|
||||
let line = Line(row as i32 - display_offset);
|
||||
let line = Self::grid_line(&term, row)?;
|
||||
let cols = term.columns();
|
||||
if col >= cols {
|
||||
return None;
|
||||
@@ -6790,6 +6851,60 @@ mod gpui_tests {
|
||||
(window, daemon_side)
|
||||
}
|
||||
|
||||
/// A hover cell remembered while the pane was tall names a row the grid no
|
||||
/// longer has once the pane shrinks (a vertical split, a smaller window).
|
||||
/// Resolving it must decline rather than index the grid — this path runs
|
||||
/// from `ModifiersChanged` (the ⌘ of the very ⌘⇧D that split the pane), an
|
||||
/// `extern "C"` callback where the panic can't unwind and aborts the app.
|
||||
#[gpui::test]
|
||||
fn a_stale_hover_row_does_not_index_the_shrunken_grid(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
// Hover the last row of the 24-row grid, then shrink to 8 rows.
|
||||
view.hover_link_at(0, 23, true, cx);
|
||||
view.terminal.resize(TermSize::new(80, 8), 8, 17);
|
||||
// `set_grid_size` drops the stale cell in the real app; pin it
|
||||
// here so the guard inside the lookup is what's under test.
|
||||
view.last_hover_cell = Some((0, 23));
|
||||
assert!(
|
||||
!view.refresh_link_hover(true, cx),
|
||||
"a row outside the grid can't hold a link"
|
||||
);
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
/// The other half of the fix: the pane that shrank forgets the hover it was
|
||||
/// holding, rather than carrying a cell (and the underline it resolved) that
|
||||
/// now names different text.
|
||||
#[gpui::test]
|
||||
fn a_resize_forgets_the_hovered_cell(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
window
|
||||
.update(cx, |view, _, cx| {
|
||||
// Pin a known geometry first — what the test window measured for
|
||||
// itself is the element's business, and this is about the
|
||||
// transition. Then hover the last row of those 24.
|
||||
view.set_grid_size(80, 24, px(8.), px(17.));
|
||||
view.hover_link_at(0, 23, true, cx);
|
||||
assert_eq!(view.last_hover_cell, Some((0, 23)));
|
||||
view.hovered_link = Some(HoveredLink {
|
||||
line: 23,
|
||||
start: 0,
|
||||
end: 3,
|
||||
});
|
||||
// The same geometry again changes nothing...
|
||||
view.set_grid_size(80, 24, px(8.), px(17.));
|
||||
assert_eq!(view.last_hover_cell, Some((0, 23)));
|
||||
// ...but a split (or a window drag) that shrinks the pane does.
|
||||
view.set_grid_size(80, 8, px(8.), px(17.));
|
||||
assert!(view.last_hover_cell.is_none(), "the cell is stale");
|
||||
assert!(view.hovered_link.is_none(), "so is the link it resolved");
|
||||
})
|
||||
.unwrap();
|
||||
}
|
||||
|
||||
#[gpui::test]
|
||||
fn title_events_drive_the_tab_title(cx: &mut TestAppContext) {
|
||||
let (window, _daemon) = harness(cx);
|
||||
|
||||
+209
-24
@@ -9,7 +9,9 @@ use gpui_component::color_picker::{ColorPickerEvent, ColorPickerState};
|
||||
use gpui_component::input::{InputEvent, InputState};
|
||||
use gpui_component::select::{SearchableVec, SelectEvent, SelectState};
|
||||
use gpui_component::slider::{SliderEvent, SliderState};
|
||||
use gpui_component::{ActiveTheme as _, IndexPath, TitleBar, WindowExt as _};
|
||||
use gpui_component::{
|
||||
ActiveTheme as _, IndexPath, InteractiveElementExt as _, TitleBar, WindowExt as _,
|
||||
};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::collections::HashSet;
|
||||
use std::rc::Rc;
|
||||
@@ -190,6 +192,93 @@ pub(crate) fn title_bar_hug_offset() -> f32 {
|
||||
}
|
||||
}
|
||||
|
||||
/// Edge of the brand mark that anchors the window's leading corner off macOS
|
||||
/// (see [`window_mark`]). Between a chrome tile's 32px hit box and its 13px
|
||||
/// glyph: the mark paints no hover capsule, so what has to sit level with the
|
||||
/// tiles beside it is its *ink* — and solid art reads heavier than line work at
|
||||
/// equal size, hence short of the tile box rather than matching it.
|
||||
pub(crate) const WINDOW_MARK_SIZE: f32 = 20.;
|
||||
|
||||
/// The "duo" mark — the same art the app icon and the About page carry — drawn
|
||||
/// at the leading edge of the title-bar row, or `None` on macOS.
|
||||
///
|
||||
/// macOS owns that corner: the traffic lights sit there, and [`TITLE_BAR_LEAD`]
|
||||
/// reserves them 80px. Everywhere else it is empty. The row's contents are the
|
||||
/// rail's controls at its *right* end and the window chrome at the far side, so
|
||||
/// the window's leading corner — the slot Windows reads as the app's identity,
|
||||
/// filled by Explorer, VS Code and Zed alike — held nothing at all, which comes
|
||||
/// across as unfinished rather than restrained.
|
||||
///
|
||||
/// Drawn, never clicked. It is not a menu button, so it stays out of the tile
|
||||
/// rhythm (no hover capsule) and deliberately takes no `occlude()`: the row it
|
||||
/// lives in is a `WindowControlArea::Drag`, and letting the mark fall through to
|
||||
/// that keeps the strip grabbable instead of punching a dead 20px hole in it.
|
||||
/// Make a row that stands in for the title bar behave like one: drag it to move
|
||||
/// the window, double-click it to zoom.
|
||||
///
|
||||
/// Three rows do this. The rail's top zone sits level with the real bar but
|
||||
/// outside it (the bar only spans the column beside the rail), and the code and
|
||||
/// diff overlays each cover the bar with a header of their own drawn to its line.
|
||||
/// Without this they are all dead strips: 40px across the top of the window that
|
||||
/// look exactly like the caption and do nothing when you grab them.
|
||||
///
|
||||
/// Driven the way gpui-component's own `TitleBar` drives it — a press arms a
|
||||
/// flag and the first *move* starts the window move — so a plain click, and a
|
||||
/// double-click, still land intact. Note that on Windows the drag area maps to
|
||||
/// HTCAPTION and the OS claims the press before gpui hit-tests, so every button
|
||||
/// inside one of these rows needs an `occlude()` wrapper to get its clicks back.
|
||||
pub(crate) fn title_bar_drag(row: gpui::Stateful<gpui::Div>) -> gpui::Stateful<gpui::Div> {
|
||||
let should_move = Rc::new(Cell::new(false));
|
||||
row.window_control_area(gpui::WindowControlArea::Drag)
|
||||
.on_mouse_down(gpui::MouseButton::Left, {
|
||||
let should_move = should_move.clone();
|
||||
move |_, _, _| should_move.set(true)
|
||||
})
|
||||
.on_mouse_up(gpui::MouseButton::Left, {
|
||||
let should_move = should_move.clone();
|
||||
move |_, _, _| should_move.set(false)
|
||||
})
|
||||
.on_mouse_move(move |_, window, _| {
|
||||
if should_move.replace(false) {
|
||||
window.start_window_move();
|
||||
}
|
||||
})
|
||||
.on_double_click(|_, window, _| {
|
||||
// gpui only implements `titlebar_double_click` on macOS — the trait
|
||||
// method is an empty default everywhere else, so on Linux this row
|
||||
// swallowed the double-click and nothing zoomed. `zoom_window` is the
|
||||
// maximise toggle there (x11 `_NET_WM_STATE_MAXIMIZED_*`, wayland
|
||||
// `set_maximized`), and what gpui-component's own `TitleBar` calls on
|
||||
// Linux for exactly this reason. Windows needs neither: the row is a
|
||||
// drag area, which maps to HTCAPTION, and the OS has already restored
|
||||
// or maximised the window before this could run.
|
||||
if cfg!(target_os = "linux") {
|
||||
window.zoom_window();
|
||||
} else {
|
||||
window.titlebar_double_click();
|
||||
}
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn window_mark() -> Option<impl IntoElement> {
|
||||
if cfg!(target_os = "macos") {
|
||||
return None;
|
||||
}
|
||||
// Decoded once and shared: the title bar re-renders on every cursor blink,
|
||||
// and building a fresh `Image` per frame would re-copy the PNG and miss
|
||||
// gpui's image cache, which is keyed on the image's identity.
|
||||
static LOGO: std::sync::OnceLock<Arc<gpui::Image>> = std::sync::OnceLock::new();
|
||||
let logo = LOGO
|
||||
.get_or_init(|| {
|
||||
Arc::new(gpui::Image::from_bytes(
|
||||
gpui::ImageFormat::Png,
|
||||
include_bytes!("../../assets/logo@256.png").to_vec(),
|
||||
))
|
||||
})
|
||||
.clone();
|
||||
Some(img(logo).size(px(WINDOW_MARK_SIZE)).flex_shrink_0())
|
||||
}
|
||||
|
||||
/// One tab: a split-pane tree plus an optional user-assigned name. Settings is
|
||||
/// no longer a tab — it's a full-window overlay (`Tty7App::settings`), so every
|
||||
/// tab is a real terminal tab.
|
||||
@@ -843,10 +932,16 @@ impl Tty7App {
|
||||
// First run (no session file): the very first terminal has no
|
||||
// predecessor to inherit from, so start in the app's current
|
||||
// directory (None → default behavior).
|
||||
None => {
|
||||
let first = new_terminal(font_size, None, None, None, window, cx);
|
||||
(vec![Tab::new(Pane::leaf(first))], 0)
|
||||
}
|
||||
None => match new_terminal(font_size, None, None, None, window, cx) {
|
||||
Ok(first) => (vec![Tab::new(Pane::leaf(first))], 0),
|
||||
// The daemon we just tried to start isn't answering. A window
|
||||
// with no tabs is a legal state (it shows the home page), and
|
||||
// far better than taking the launch down over it.
|
||||
Err(e) => {
|
||||
log::error!("first terminal failed to start: {e}");
|
||||
(Vec::new(), 0)
|
||||
}
|
||||
},
|
||||
// A saved session (with tabs, or an empty home-page state): rebuild it
|
||||
// the same way a daemon restart does.
|
||||
some => tabs_from_session(some, font_size, window, cx),
|
||||
@@ -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<Self>,
|
||||
) {
|
||||
let view = new_terminal(self.font_size, Some(wt.path), None, None, window, cx);
|
||||
let view = match new_terminal(self.font_size, Some(wt.path), None, None, window, cx) {
|
||||
Ok(view) => view,
|
||||
Err(e) => {
|
||||
log::error!("worktree tab spawn failed: {e}");
|
||||
window.push_notification(format!("Could not open a terminal: {e}"), cx);
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.remember_active_pane(window, cx);
|
||||
self.maximized = None;
|
||||
let insert_at = self.new_tab_insert_at(cx);
|
||||
@@ -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<Tab> = Vec::with_capacity(session.tabs.len());
|
||||
for st in &session.tabs {
|
||||
let pane = session_to_pane(&st.pane, &alive, font_size, window, cx);
|
||||
// A tab whose every leaf failed to come back has nothing to show; drop
|
||||
// it rather than restore an empty frame (or, worse, abort the launch).
|
||||
let Some(pane) = session_to_pane(&st.pane, &alive, font_size, window, cx) else {
|
||||
log::error!("dropping a restored tab: no pane in it could be started");
|
||||
continue;
|
||||
};
|
||||
tabs.push(Tab {
|
||||
pane,
|
||||
name: st.name.clone(),
|
||||
@@ -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<u64>,
|
||||
font_size: f32,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Tty7App>,
|
||||
) -> Pane {
|
||||
) -> Option<Pane> {
|
||||
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<std::path::PathBuf>,
|
||||
@@ -5787,10 +5972,10 @@ fn new_terminal(
|
||||
shell: Option<ShellSpec>,
|
||||
window: &mut Window,
|
||||
cx: &mut Context<Tty7App>,
|
||||
) -> Entity<TerminalView> {
|
||||
) -> anyhow::Result<Entity<TerminalView>> {
|
||||
let parts = TerminalView::spawn_shell_terminal(working_directory, restore_pane, shell)?;
|
||||
let view = cx.new(|cx| {
|
||||
let mut view = TerminalView::new(working_directory, restore_pane, shell, window, cx)
|
||||
.expect("failed to start terminal");
|
||||
let mut view = TerminalView::from_shell_parts(parts, window, cx);
|
||||
// Inherit the current global font size so new panes match existing ones.
|
||||
view.font_size = px(font_size);
|
||||
view
|
||||
@@ -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
|
||||
|
||||
+26
-18
@@ -801,7 +801,7 @@ impl Tty7App {
|
||||
/// every buffer that was ever opened. Sits on the title bar's line and matches
|
||||
/// its height, so the editor's top edge lines up with the panel's tab row and
|
||||
/// the rail's controls across the window.
|
||||
fn render_editor_header(&self, cx: &mut Context<Self>) -> gpui::Div {
|
||||
fn render_editor_header(&self, cx: &mut Context<Self>) -> gpui::Stateful<gpui::Div> {
|
||||
let active = self.tab_code().and_then(|c| c.active_file());
|
||||
let name = active.map(|f| f.label());
|
||||
let dirty = active.is_some_and(|f| f.dirty);
|
||||
@@ -816,7 +816,10 @@ impl Tty7App {
|
||||
} else {
|
||||
crate::ui::app::TITLE_BAR_LEAD
|
||||
};
|
||||
h_flex()
|
||||
// The overlay covers the real title bar, so this row inherits its drag and
|
||||
// zoom gestures — otherwise opening a file turns the top of the window into
|
||||
// a strip that looks like the caption and can't move it.
|
||||
crate::ui::app::title_bar_drag(h_flex().id("editor-header"))
|
||||
.flex_none()
|
||||
.h(px(crate::ui::app::TITLE_BAR_HEIGHT))
|
||||
.items_center()
|
||||
@@ -847,22 +850,27 @@ impl Tty7App {
|
||||
)
|
||||
})
|
||||
.child(
|
||||
crate::ui::tab_strip::chrome_tile_sized(
|
||||
// This header is the title bar's own height and sits flush
|
||||
// with it, so its one control is a full chrome tile — not the
|
||||
// half-size one it used to be, which read as a different
|
||||
// class of button on the same line.
|
||||
Button::new("editor-panel-close").icon(Icon::new(IconName::Close)),
|
||||
crate::ui::app::TILE_SIZE,
|
||||
crate::ui::app::TILE_GLYPH_LINE,
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.rounded_lg()
|
||||
.tooltip("Back to Terminal (Esc)")
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.toggle_code_panel(window, cx);
|
||||
})),
|
||||
// `occlude()` for the same reason the title bar's own tiles carry
|
||||
// it: this row is a `WindowControlArea::Drag`, which on Windows is
|
||||
// HTCAPTION, and the OS takes the press before gpui hit-tests.
|
||||
div().occlude().flex_shrink_0().child(
|
||||
crate::ui::tab_strip::chrome_tile_sized(
|
||||
// This header is the title bar's own height and sits flush
|
||||
// with it, so its one control is a full chrome tile — not the
|
||||
// half-size one it used to be, which read as a different
|
||||
// class of button on the same line.
|
||||
Button::new("editor-panel-close").icon(Icon::new(IconName::Close)),
|
||||
crate::ui::app::TILE_SIZE,
|
||||
crate::ui::app::TILE_GLYPH_LINE,
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.rounded_lg()
|
||||
.tooltip("Back to Terminal (Esc)")
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.toggle_code_panel(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+56
-47
@@ -327,7 +327,10 @@ impl Tty7App {
|
||||
} else {
|
||||
crate::ui::app::TITLE_BAR_LEAD
|
||||
};
|
||||
h_flex()
|
||||
// Standing in for the title bar means carrying its gestures too: the
|
||||
// overlay covers the real bar, so without this the whole top of the window
|
||||
// stops moving it while a diff is up.
|
||||
crate::ui::app::title_bar_drag(h_flex().id("diff-overlay-header"))
|
||||
.flex_shrink_0()
|
||||
.h(px(crate::ui::app::TITLE_BAR_HEIGHT))
|
||||
.pl(px(lead))
|
||||
@@ -355,38 +358,42 @@ impl Tty7App {
|
||||
// click target back to the whole tree — otherwise the only way out
|
||||
// of a focused view would be to close and re-open the overlay.
|
||||
.when_some(focused_name(overlay), |bar, name| {
|
||||
// Wrapped like every other control on a drag row — see the header's
|
||||
// own note: HTCAPTION would otherwise swallow the click on Windows.
|
||||
bar.child(
|
||||
h_flex()
|
||||
.id("diff-overlay-unfocus")
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.px_1p5()
|
||||
.py_0p5()
|
||||
.rounded_md()
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.bg(cx.theme().list_hover))
|
||||
.on_click(cx.listener(|this, _, _window, cx| {
|
||||
let active = this.active;
|
||||
if let Some(overlay) = this
|
||||
.tabs
|
||||
.get_mut(active)
|
||||
.and_then(|t| t.diff_overlay.as_mut())
|
||||
{
|
||||
overlay.focus = None;
|
||||
cx.notify();
|
||||
}
|
||||
}))
|
||||
.child(
|
||||
Icon::new(IconName::ChevronLeft)
|
||||
.small()
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_family(self.font_family.clone())
|
||||
.child(name),
|
||||
),
|
||||
div().occlude().flex_shrink_0().child(
|
||||
h_flex()
|
||||
.id("diff-overlay-unfocus")
|
||||
.items_center()
|
||||
.gap_1()
|
||||
.px_1p5()
|
||||
.py_0p5()
|
||||
.rounded_md()
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.bg(cx.theme().list_hover))
|
||||
.on_click(cx.listener(|this, _, _window, cx| {
|
||||
let active = this.active;
|
||||
if let Some(overlay) = this
|
||||
.tabs
|
||||
.get_mut(active)
|
||||
.and_then(|t| t.diff_overlay.as_mut())
|
||||
{
|
||||
overlay.focus = None;
|
||||
cx.notify();
|
||||
}
|
||||
}))
|
||||
.child(
|
||||
Icon::new(IconName::ChevronLeft)
|
||||
.small()
|
||||
.text_color(cx.theme().muted_foreground),
|
||||
)
|
||||
.child(
|
||||
div()
|
||||
.text_xs()
|
||||
.font_family(self.font_family.clone())
|
||||
.child(name),
|
||||
),
|
||||
),
|
||||
)
|
||||
})
|
||||
.when(
|
||||
@@ -438,21 +445,23 @@ impl Tty7App {
|
||||
)
|
||||
.child(div().flex_1())
|
||||
.child(
|
||||
crate::ui::tab_strip::chrome_tile_sized(
|
||||
// Explicit tile, not `.small()`: this bar stands in for the
|
||||
// title bar while the overlay is up, so its close control is
|
||||
// the same tile the title bar's controls are.
|
||||
Button::new("diff-overlay-close").icon(Icon::new(IconName::Close)),
|
||||
crate::ui::app::TILE_SIZE,
|
||||
crate::ui::app::TILE_GLYPH_LINE,
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.rounded_lg()
|
||||
.tooltip("Close Diff (Esc)")
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.close_diff_overlay(window, cx);
|
||||
})),
|
||||
div().occlude().flex_shrink_0().child(
|
||||
crate::ui::tab_strip::chrome_tile_sized(
|
||||
// Explicit tile, not `.small()`: this bar stands in for the
|
||||
// title bar while the overlay is up, so its close control is
|
||||
// the same tile the title bar's controls are.
|
||||
Button::new("diff-overlay-close").icon(Icon::new(IconName::Close)),
|
||||
crate::ui::app::TILE_SIZE,
|
||||
crate::ui::app::TILE_GLYPH_LINE,
|
||||
false,
|
||||
cx,
|
||||
)
|
||||
.rounded_lg()
|
||||
.tooltip("Close Diff (Esc)")
|
||||
.on_click(cx.listener(|this, _, window, cx| {
|
||||
this.close_diff_overlay(window, cx);
|
||||
})),
|
||||
),
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
+10
-5
@@ -1156,6 +1156,7 @@ impl Tty7App {
|
||||
let is_dir = row.entry.is_dir;
|
||||
let selected = self.tab_code().and_then(|c| c.selected.as_deref()) == Some(&*path);
|
||||
let muted = cx.theme().muted_foreground;
|
||||
let sf = cx.global::<crate::ui::presets::Surfaces>().popover;
|
||||
// Unsaved edits used to be visible on the editor's file tabs; with those
|
||||
// gone the tree is the only place an open buffer is represented, so it has
|
||||
// to carry the dirty marker or unsaved work becomes invisible.
|
||||
@@ -1207,11 +1208,15 @@ impl Tty7App {
|
||||
.py_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.cursor_pointer()
|
||||
// Soft inset-pill highlight on the content surface.
|
||||
.when(selected, |d| d.bg(cx.theme().accent))
|
||||
.when(!selected, |d| {
|
||||
d.hover(|s| s.bg(cx.theme().accent.opacity(0.5)))
|
||||
})
|
||||
// Soft inset-pill highlight on the content surface. The tree paints on
|
||||
// `popover` (see the container below), so this is that surface's
|
||||
// ladder — read explicitly rather than through `Theme::accent`, which
|
||||
// is gpui-component's name for a row highlight and says nothing about
|
||||
// which surface it was anchored to. Hover was `accent.opacity(0.5)`; a
|
||||
// ladder rung is a real colour, so it doesn't change meaning depending
|
||||
// on what it lands on.
|
||||
.when(selected, |d| d.bg(gpui::rgb(sf.selected)))
|
||||
.when(!selected, |d| d.hover(|s| s.bg(gpui::rgb(sf.hover))))
|
||||
// Folders take the full foreground, files the muted tone — a neutral
|
||||
// weight difference, no hue, so the tree keeps the terminal's calm.
|
||||
.child(Icon::new(icon).xsmall().text_color(if is_dir {
|
||||
|
||||
+7
-2
@@ -240,6 +240,7 @@ impl Tty7App {
|
||||
) -> Stateful<Div> {
|
||||
let theme = cx.theme();
|
||||
let muted = theme.muted_foreground;
|
||||
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
|
||||
let letter = match forward.kind {
|
||||
SshForwardKind::Local => "L",
|
||||
SshForwardKind::Remote => "R",
|
||||
@@ -277,7 +278,7 @@ impl Tty7App {
|
||||
.py(px(3.))
|
||||
.rounded(px(5.))
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.bg(theme.sidebar_accent.opacity(0.55)))
|
||||
.hover(|s| s.bg(gpui::rgb(sf.hover)))
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
this.edit_managed_forward(forward_for_edit.clone(), window, cx)
|
||||
}))
|
||||
@@ -357,6 +358,9 @@ impl Tty7App {
|
||||
fn forward_form(&self, pane_id: u64, cx: &mut Context<Self>) -> Div {
|
||||
let theme = cx.theme();
|
||||
let muted = theme.muted_foreground;
|
||||
// The form is inside the right panel, i.e. on the sunk rail — not on the
|
||||
// settings sheet the segmented control otherwise assumes.
|
||||
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
|
||||
let kind = self.loopback_panel.mf_kind;
|
||||
let editing = self.loopback_panel.mf_editing.is_some();
|
||||
let selected = match kind {
|
||||
@@ -393,7 +397,8 @@ impl Tty7App {
|
||||
.pt(px(6.))
|
||||
.pb(px(2.))
|
||||
.gap(px(5.))
|
||||
.child(self.segmented(
|
||||
.child(self.segmented_on(
|
||||
sf,
|
||||
"ssh-managed-forward-kind",
|
||||
&["Local", "Remote", "Dynamic"],
|
||||
selected,
|
||||
|
||||
+4
-2
@@ -297,8 +297,10 @@ impl Tty7App {
|
||||
)
|
||||
};
|
||||
// The established popup language: a solid 10px-radius panel with inset
|
||||
// soft-grey pill highlights — no translucency, no saturated accent.
|
||||
let hover_fill = cx.theme().accent.opacity(0.6);
|
||||
// soft-grey pill highlights — no translucency, no saturated accent. The
|
||||
// panel is a popover, so its rows read that ladder's hover rung; the 0.6
|
||||
// alpha this replaces made the fill depend on whatever showed through.
|
||||
let hover_fill = gpui::rgb(cx.global::<crate::ui::presets::Surfaces>().popover.hover);
|
||||
|
||||
let mut panel = v_flex()
|
||||
.w(px(360.))
|
||||
|
||||
+729
-10
@@ -1,7 +1,7 @@
|
||||
//! The theme system: the serializable [`Theme`] seed model, the derived
|
||||
//! shell-chrome [`Neutrals`], the [`Themes`] registry, and the loaders that turn
|
||||
//! built-in tables, user YAML files, and imported iTerm2 schemes into concrete
|
||||
//! themes.
|
||||
//! shell-chrome [`Neutrals`], the interaction-state [`Surface`] ladders, the
|
||||
//! [`Themes`] registry, and the loaders that turn built-in tables, user YAML
|
||||
//! files, and imported iTerm2 schemes into concrete themes.
|
||||
//!
|
||||
//! A theme is a **minimal seed** — a background (solid or gradient), a
|
||||
//! foreground, one accent, an optional cursor/selection, and the ANSI-16
|
||||
@@ -15,6 +15,14 @@
|
||||
//! in an iTerm2 `*.itermcolors` scheme, which the loader imports on the fly. A
|
||||
//! theme's light/dark brightness is *inferred* from its background luminance —
|
||||
//! there is no `dark` field to set.
|
||||
//!
|
||||
//! # Interaction state
|
||||
//!
|
||||
//! Resting / hover / selected / pressed are a **first-class part of the theme**,
|
||||
//! not something each widget re-derives. [`Theme::surface`] returns the state
|
||||
//! ladder for whatever surface a widget paints on, and every rung is derived to
|
||||
//! hit a *contrast ratio* against that surface rather than a fixed blend ratio —
|
||||
//! see [`state`] for why that distinction is the whole point.
|
||||
|
||||
use std::path::PathBuf;
|
||||
|
||||
@@ -87,6 +95,10 @@ pub struct Theme {
|
||||
|
||||
/// The shell-chrome palette derived from a theme's seed. Consumed by
|
||||
/// `apply_theme` to paint gpui-component's `Theme`.
|
||||
///
|
||||
/// These are the theme's *static* colors — the ones that mean the same thing
|
||||
/// wherever they appear. Anything that varies with interaction state lives in a
|
||||
/// [`Surface`] instead.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Neutrals {
|
||||
pub background: u32,
|
||||
@@ -99,12 +111,148 @@ pub struct Neutrals {
|
||||
pub caret: u32,
|
||||
pub selection: u32,
|
||||
pub sidebar: u32,
|
||||
pub sidebar_sel: u32,
|
||||
pub sidebar_fg: u32,
|
||||
pub list_active: u32,
|
||||
pub list_hover: u32,
|
||||
/// The seed accent, nudged until it can carry ink — see [`legible_accent`].
|
||||
pub accent: u32,
|
||||
}
|
||||
|
||||
/// One semantic colour in the three shapes the UI actually needs it in.
|
||||
///
|
||||
/// Splitting them is not ceremony: a red that is legible as *text* on the
|
||||
/// background is a different red from one that works as a filled button, and the
|
||||
/// text on that button is a third. Collapsing them is how a danger button ends up
|
||||
/// with unreadable text, or a warning label ends up below AA.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Semantic {
|
||||
/// Text (or a small solid mark) on the window background. WCAG AA, 4.5:1.
|
||||
pub ink: u32,
|
||||
/// A filled chip or button. The non-text floor, 3:1.
|
||||
pub fill: u32,
|
||||
/// Text on top of `fill`.
|
||||
pub on_fill: u32,
|
||||
}
|
||||
|
||||
/// The status palette, derived from the theme's **own ANSI-16** rather than from
|
||||
/// a fixed set of brand colours.
|
||||
///
|
||||
/// Every theme already ships a red, green, yellow and cyan — they are what the
|
||||
/// terminal in the same window paints with. Until this existed, gpui-component's
|
||||
/// stock Tailwind values (`red-400`, `yellow-400`, `green-400`) were used
|
||||
/// instead, which meant two unrelated reds on screen at once — `#ff5555` in the
|
||||
/// terminal and `#f87171` on the delete button, on Dracula — and, on the light
|
||||
/// themes, a danger colour at 2.45:1 that cleared no contrast floor at all.
|
||||
///
|
||||
/// Each is conditioned by [`legible_ink`], so a seed too pale or too dark for its
|
||||
/// role is deepened along its own hue rather than swapped for something foreign.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Semantics {
|
||||
pub danger: Semantic,
|
||||
pub warning: Semantic,
|
||||
pub success: Semantic,
|
||||
pub info: Semantic,
|
||||
/// Links. Distinct from `info` only in intent, but it is the field
|
||||
/// gpui-component's markdown renderer reads, and left unset it resolves to
|
||||
/// the body text colour — a link that looks exactly like prose.
|
||||
pub link: Semantic,
|
||||
}
|
||||
|
||||
/// The contrast targets that define how loud each interaction state reads.
|
||||
///
|
||||
/// **These four numbers are the app's only knobs for state prominence.** They
|
||||
/// exist because the alternative — a fixed `mix(bg, fg, t)` per state, which is
|
||||
/// what this file used to do — makes the *perceived* step depend on the theme.
|
||||
/// The old ladder (`hover` 0.09, `sidebar_sel` 0.12, `list_active` 0.17) put
|
||||
/// selected-vs-resting anywhere from 1.20:1 (Catppuccin Latte) to 1.47:1
|
||||
/// (Dracula), and the segmented control — which read gpui-component's stock
|
||||
/// `input` grey instead of the ladder at all — landed at **1.03:1 on Dracula**,
|
||||
/// i.e. invisible. See issue #197.
|
||||
///
|
||||
/// A ratio target removes the theme from the equation: every theme lands on the
|
||||
/// same perceived step, so tuning taste here retunes the whole app at once and
|
||||
/// no theme can be an outlier.
|
||||
///
|
||||
/// `SELECTED` is 1.70 because that is where the already-signed-off Dracula
|
||||
/// highlight sits (`mix(bg, fg, 0.17)` ≈ `#4b4d56`, 1.72:1) — the value the
|
||||
/// palette and menu look was tuned against. Anchoring *to* it keeps that look
|
||||
/// and pulls the light themes, which were as low as 1.20:1, up to match.
|
||||
pub mod state {
|
||||
/// Pointer feedback. Deliberately a whisper: it answers the mouse without
|
||||
/// competing with the selection it may be sitting next to.
|
||||
pub const HOVER: f32 = 1.18;
|
||||
/// The resting selection. Never the *only* signal — see [`super::Surface`].
|
||||
pub const SELECTED: f32 = 1.70;
|
||||
/// Held down. One step past selected so pressing a selected item still reads.
|
||||
pub const PRESSED: f32 = 2.10;
|
||||
/// Resting label text. 4.6:1 keeps a de-emphasised label at WCAG AA on every
|
||||
/// theme; the fixed `mix(fg, bg, 0.42)` it replaces drifted with the seed.
|
||||
pub const TEXT_RESTING: f32 = 4.6;
|
||||
}
|
||||
|
||||
/// The interaction-state ladder for one painting surface: the fills for each
|
||||
/// state plus the paired label colors.
|
||||
///
|
||||
/// # Both channels, always
|
||||
///
|
||||
/// A fill alone does not communicate selection. The app learned this the hard
|
||||
/// way in three separate places — `tab_strip`'s active chip, `tab_strip`'s
|
||||
/// chrome tiles and the settings sidebar each grew a hand-written
|
||||
/// fill-plus-text-color pair, while every site that *hadn't* been hand-fixed
|
||||
/// (segmented controls, the SSH profile list) shipped a fill and nothing else
|
||||
/// and could not be read. So the text colors ride along in this struct: take a
|
||||
/// `Surface`, take both channels.
|
||||
///
|
||||
/// * **Fill** answers *which one* — it locates the selection in the row.
|
||||
/// * **Text** (`text_selected` + `FontWeight::MEDIUM` vs `text_resting`)
|
||||
/// answers *that this is it* — it survives a low-contrast fill, an oddly
|
||||
/// seeded imported theme, and a color-blind reader.
|
||||
///
|
||||
/// A keyboard-driven single cursor (the command palette, a context menu) can get
|
||||
/// away with the fill alone because the eye tracks the one thing that moves.
|
||||
/// A *static* choice among visible siblings cannot.
|
||||
#[derive(Debug, Clone, Copy)]
|
||||
pub struct Surface {
|
||||
/// The surface itself — what a resting item paints on (i.e. no fill).
|
||||
pub base: u32,
|
||||
pub hover: u32,
|
||||
pub selected: u32,
|
||||
pub pressed: u32,
|
||||
/// Label color for a resting/unselected item on this surface.
|
||||
pub text_resting: u32,
|
||||
/// Label color for the selected item. Pair it with `FontWeight::MEDIUM`.
|
||||
pub text_selected: u32,
|
||||
}
|
||||
|
||||
/// Every surface the shell actually paints interactive rows on, published as a
|
||||
/// GPUI global by `apply_theme` so a render pass can read the ladder without
|
||||
/// re-resolving (and cloning) the theme registry every frame.
|
||||
///
|
||||
/// Which surface a widget picks matters: a menu row sits on `popover`, not on
|
||||
/// the window background, and a ladder anchored to the wrong surface is exactly
|
||||
/// how the context-menu highlight ended up at 1.20:1 while claiming to be the
|
||||
/// same 0.17 mix that reads fine on the window.
|
||||
#[derive(Debug, Clone)]
|
||||
pub struct Surfaces {
|
||||
/// The window background — settings sheets, panels, the terminal ground.
|
||||
pub window: Surface,
|
||||
/// The sunk sidebar rail.
|
||||
pub sidebar: Surface,
|
||||
/// Elevated surfaces: menus, dropdowns, the command palette.
|
||||
pub popover: Surface,
|
||||
}
|
||||
|
||||
impl Global for Surfaces {}
|
||||
|
||||
/// The active theme's contrast-conditioned accent (see [`legible_accent`]),
|
||||
/// published so a render pass can reach it without cloning the theme registry.
|
||||
///
|
||||
/// Deliberately its own global rather than a field on [`Surfaces`]: the accent is
|
||||
/// not a surface, and it has exactly one job — ink that must be *noticed* (the
|
||||
/// caret, the focus ring, a switch's checked track). Every neutral fill in the
|
||||
/// app comes from a `Surface`; this is the one thing that doesn't.
|
||||
pub struct ActiveAccent(pub u32);
|
||||
|
||||
impl Global for ActiveAccent {}
|
||||
|
||||
impl Theme {
|
||||
/// The representative solid background color.
|
||||
pub fn background_color(&self) -> u32 {
|
||||
@@ -125,15 +273,87 @@ impl Theme {
|
||||
border: mix(bg, fg, 0.16),
|
||||
secondary: mix(bg, fg, 0.09),
|
||||
muted: mix(bg, fg, 0.06),
|
||||
muted_foreground: mix(fg, bg, 0.42),
|
||||
muted_foreground: dim(fg, bg, state::TEXT_RESTING),
|
||||
popover: mix(bg, fg, 0.05),
|
||||
caret: self.caret.unwrap_or(self.accent),
|
||||
selection: self.selection.unwrap_or_else(|| mix(bg, fg, 0.20)),
|
||||
sidebar: mix(bg, fg, 0.03),
|
||||
sidebar_sel: mix(bg, fg, 0.12),
|
||||
sidebar_fg: mix(fg, bg, 0.28),
|
||||
list_active: mix(bg, fg, 0.17),
|
||||
list_hover: mix(bg, fg, 0.09),
|
||||
accent: legible_accent(bg, self.accent),
|
||||
}
|
||||
}
|
||||
|
||||
/// The interaction-state ladder for content painted on `base`.
|
||||
///
|
||||
/// Every rung blends `base` toward the (legibility-guaranteed) foreground
|
||||
/// until it clears its [`state`] contrast target *against `base`* — so the
|
||||
/// direction is "toward the text" by construction on light and dark themes
|
||||
/// alike. The old fixed-mix ladder had no such guarantee: because the
|
||||
/// segmented control's fill came from a stock grey rather than the theme,
|
||||
/// selecting a segment made it *darker* than its siblings on light themes
|
||||
/// and *lighter* on dark ones, by accident of where `#2f2f2f` happened to
|
||||
/// fall.
|
||||
pub fn surface(&self, base: u32) -> Surface {
|
||||
let bg = self.background_color();
|
||||
let fg = legible_foreground(bg, self.foreground);
|
||||
let selected = raise(base, fg, state::SELECTED);
|
||||
Surface {
|
||||
base,
|
||||
hover: raise(base, fg, state::HOVER),
|
||||
selected,
|
||||
pressed: raise(base, fg, state::PRESSED),
|
||||
// Dimmed from the foreground until it is merely AA-readable on this
|
||||
// surface, rather than a fixed blend — a resting label must stay
|
||||
// legible on an imported theme nobody vetted, too.
|
||||
text_resting: dim(fg, base, state::TEXT_RESTING),
|
||||
// Measured against the *selected fill*, not the surface: that fill is
|
||||
// the ground this particular label actually sits on. See `ink_on`.
|
||||
text_selected: ink_on(selected, fg, state::TEXT_RESTING),
|
||||
}
|
||||
}
|
||||
|
||||
/// The status palette, built from this theme's own ANSI red/green/yellow/cyan.
|
||||
///
|
||||
/// The normal (not bright) ANSI slots are the seeds: they are what the
|
||||
/// terminal in the same window paints, so a danger marker and an error line
|
||||
/// of shell output finally wear the same red. Where a slot is too pale or too
|
||||
/// dark for a role, [`legible_ink`] deepens it along its own hue rather than
|
||||
/// reaching for a colour the theme never declared.
|
||||
pub fn semantics(&self) -> Semantics {
|
||||
let bg = self.background_color();
|
||||
let fg = legible_foreground(bg, self.foreground);
|
||||
let ansi = |i: usize| -> u32 {
|
||||
let (r, g, b) = self.ansi16[i];
|
||||
(r as u32) << 16 | (g as u32) << 8 | b as u32
|
||||
};
|
||||
let build = |seed: u32| {
|
||||
let fill = legible_ink(bg, seed, ACCENT_FLOOR);
|
||||
Semantic {
|
||||
ink: legible_ink(bg, seed, TEXT_FLOOR),
|
||||
fill,
|
||||
on_fill: ink_on(fill, fg, TEXT_FLOOR),
|
||||
}
|
||||
};
|
||||
Semantics {
|
||||
danger: build(ansi(1)),
|
||||
success: build(ansi(2)),
|
||||
warning: build(ansi(3)),
|
||||
info: build(ansi(6)),
|
||||
link: build(ansi(6)),
|
||||
}
|
||||
}
|
||||
|
||||
/// The ladders for all three surfaces the shell paints rows on.
|
||||
pub fn surfaces(&self) -> Surfaces {
|
||||
let m = self.neutrals();
|
||||
let mut sidebar = self.surface(m.sidebar);
|
||||
// The rail's resting label is a tuned value (a lighter 0.28 dim, so rows
|
||||
// in a sunk column don't read as disabled), not the generic AA floor.
|
||||
sidebar.text_resting = m.sidebar_fg;
|
||||
Surfaces {
|
||||
window: self.surface(m.background),
|
||||
sidebar,
|
||||
popover: self.surface(m.popover),
|
||||
}
|
||||
}
|
||||
|
||||
@@ -179,6 +399,109 @@ impl Theme {
|
||||
}
|
||||
}
|
||||
|
||||
/// The shared bisection behind [`raise`] and [`dim`]: find the blend of `from`
|
||||
/// toward `toward` whose contrast against `from`-or-`toward` (whichever is the
|
||||
/// surface, passed as `against`) sits at `target`.
|
||||
///
|
||||
/// `against` must **not** sit strictly between the endpoints in luminance, or
|
||||
/// the ratio along the blend is V-shaped rather than monotone and a bisection
|
||||
/// would return an arbitrary one of the two answers. Every caller satisfies
|
||||
/// that: [`raise`] and [`dim`] pass one of the endpoints itself, and
|
||||
/// [`legible_accent`] passes the background, which an accent only reaches this
|
||||
/// code by being *close* to — while `fg` is guaranteed 4.5:1 away from it.
|
||||
fn bisect_contrast(from: u32, toward: u32, against: u32, target: f32) -> u32 {
|
||||
// 12 halvings resolve t to ~0.0002 — far finer than an 8-bit channel step,
|
||||
// so the result is exact in the only units that reach the screen.
|
||||
const STEPS: u32 = 12;
|
||||
let rising = contrast(toward, against) > contrast(from, against);
|
||||
// Unreachable target (e.g. a 4.6:1 label floor on a surface whose own
|
||||
// foreground only manages 4.5): clamp to the most extreme blend rather than
|
||||
// returning something arbitrary from the middle of the range.
|
||||
if rising && contrast(toward, against) <= target {
|
||||
return toward;
|
||||
}
|
||||
if !rising && contrast(toward, against) >= target {
|
||||
return toward;
|
||||
}
|
||||
let (mut lo, mut hi) = (0.0f32, 1.0f32);
|
||||
for _ in 0..STEPS {
|
||||
let m = 0.5 * (lo + hi);
|
||||
let reached = if rising {
|
||||
contrast(mix(from, toward, m), against) >= target
|
||||
} else {
|
||||
contrast(mix(from, toward, m), against) <= target
|
||||
};
|
||||
if reached { hi = m } else { lo = m }
|
||||
}
|
||||
mix(from, toward, hi)
|
||||
}
|
||||
|
||||
/// Lift a fill off `base` toward `toward` (always the foreground) until it
|
||||
/// clears `target` contrast against `base`.
|
||||
///
|
||||
/// This is the primitive that replaced fixed `mix(bg, fg, t)` state colors: the
|
||||
/// caller names the perceived step it wants and gets it on every theme, instead
|
||||
/// of naming a blend and getting whatever step that theme's seed implies.
|
||||
fn raise(base: u32, toward: u32, target: f32) -> u32 {
|
||||
bisect_contrast(base, toward, base, target)
|
||||
}
|
||||
|
||||
/// Dim `ink` toward `surface` until it sits *at* `target` contrast against
|
||||
/// `surface` — a de-emphasised label that is still guaranteed readable, rather
|
||||
/// than a fixed blend whose ratio drifts with the seed.
|
||||
fn dim(ink: u32, surface: u32, target: f32) -> u32 {
|
||||
bisect_contrast(ink, surface, surface, target)
|
||||
}
|
||||
|
||||
/// The label color for text sitting on `fill`: the theme's foreground when it
|
||||
/// still clears `target` there, otherwise that foreground pushed *past* itself
|
||||
/// (toward white on a dark fill, black on a light one) until it does.
|
||||
///
|
||||
/// This exists because the fill ladder and the text channel pull against each
|
||||
/// other. Raising a fill toward the foreground necessarily moves the ground
|
||||
/// closer to the label it carries, and on a theme whose foreground isn't an
|
||||
/// extreme — Catppuccin Latte's `#4c4f69` is only 7.4:1 on its own background —
|
||||
/// a 1.70:1 selected fill drags the selected label down to 4.14:1, *below* the
|
||||
/// resting labels around it. A selection whose text is harder to read than its
|
||||
/// neighbours' is not a selection.
|
||||
///
|
||||
/// Pushing along the fg→extreme axis rather than snapping to pure black/white
|
||||
/// keeps the theme's ink hue; Latte's selected label becomes a deeper version of
|
||||
/// the same blue-grey, not a foreign pure black.
|
||||
///
|
||||
/// Three tiers, in order of how much of the theme they preserve: the foreground
|
||||
/// itself, then the foreground deepened along its own side, then — only when that
|
||||
/// side simply cannot reach the target — the opposite extreme. That last tier is
|
||||
/// not hypothetical: the Light theme's danger fill is `#d1242f`, a mid-dark red
|
||||
/// against which even *pure black* tops out at 3.96:1. White text on a dark red
|
||||
/// button is the right answer there, and it is only reachable by looking the
|
||||
/// other way.
|
||||
fn ink_on(fill: u32, fg: u32, target: f32) -> u32 {
|
||||
if contrast(fg, fill) >= target {
|
||||
return fg;
|
||||
}
|
||||
// Push *away* from the fill along the axis the foreground already sits on —
|
||||
// darker ink gets darker, lighter ink lighter. Choosing the extreme by the
|
||||
// fill's own brightness instead is wrong at the midpoint: Latte's `#b8bac6`
|
||||
// fill reads as "dark" to a `< 0.5` luminance test, which sends its already
|
||||
// dark ink toward white and *lowers* the contrast it was called to raise.
|
||||
let near = if relative_luminance(fg) < relative_luminance(fill) {
|
||||
0x000000
|
||||
} else {
|
||||
0xffffff
|
||||
};
|
||||
let deepened = bisect_contrast(fg, near, fill, target);
|
||||
if contrast(deepened, fill) >= target {
|
||||
return deepened;
|
||||
}
|
||||
// The foreground's own side is exhausted. Take whichever extreme reads best.
|
||||
if contrast(fill, 0xffffff) >= contrast(fill, 0x000000) {
|
||||
0xffffff
|
||||
} else {
|
||||
0x000000
|
||||
}
|
||||
}
|
||||
|
||||
/// Blend `a` toward `b` by `t` (0.0 = all `a`, 1.0 = all `b`), per channel.
|
||||
pub(crate) fn mix(a: u32, b: u32, t: f32) -> u32 {
|
||||
let (ar, ag, ab) = (a >> 16 & 0xff, a >> 8 & 0xff, a & 0xff);
|
||||
@@ -211,6 +534,15 @@ fn relative_luminance(c: u32) -> f32 {
|
||||
0.2126 * chan(c >> 16 & 0xff) + 0.7152 * chan(c >> 8 & 0xff) + 0.0722 * chan(c & 0xff)
|
||||
}
|
||||
|
||||
/// The largest per-channel difference between two colors (0…255). A crude but
|
||||
/// hue-aware "are these the same colour" check — contrast alone can't tell red
|
||||
/// from green, since they can share a luminance.
|
||||
#[cfg(test)]
|
||||
fn channel_distance(a: u32, b: u32) -> u32 {
|
||||
let d = |sh: u32| (a >> sh & 0xff).abs_diff(b >> sh & 0xff);
|
||||
d(16).max(d(8)).max(d(0))
|
||||
}
|
||||
|
||||
/// WCAG contrast ratio between two colors (1.0 … 21.0).
|
||||
fn contrast(a: u32, b: u32) -> f32 {
|
||||
let (l1, l2) = (relative_luminance(a), relative_luminance(b));
|
||||
@@ -223,6 +555,63 @@ fn is_dark(bg: u32) -> bool {
|
||||
relative_luminance(bg) < 0.5
|
||||
}
|
||||
|
||||
/// Whether `a` is the lighter of two colors. Lets callers pick "the light end of
|
||||
/// this theme's axis" without caring which of background/foreground that is —
|
||||
/// e.g. a switch knob, which is near-white in both macOS appearances.
|
||||
pub(crate) fn is_lighter(a: u32, b: u32) -> bool {
|
||||
relative_luminance(a) > relative_luminance(b)
|
||||
}
|
||||
|
||||
/// The minimum contrast an accent must clear against the background before it is
|
||||
/// allowed to carry ink (a caret, a link, a focus ring). 3:1 is the WCAG
|
||||
/// large-text / non-text floor.
|
||||
const ACCENT_FLOOR: f32 = 3.0;
|
||||
|
||||
/// The WCAG AA text floor. What a coloured *label* must clear on its ground.
|
||||
const TEXT_FLOOR: f32 = 4.5;
|
||||
|
||||
/// Make a hued seed usable at `floor` against `bg`: keep it when it already
|
||||
/// clears, otherwise drive it *away from the background* — toward white on a dark
|
||||
/// theme, black on a light one — until it does.
|
||||
///
|
||||
/// This is why a seed colour can never be used raw. The bundled Light theme's
|
||||
/// accent `#00c2ff` manages 2.07:1 on white and the built-ins' accents span
|
||||
/// 2.07:1 to 8.43:1; the ANSI reds behind [`Semantics`] are just as uneven. Any
|
||||
/// unconditional use of one is a coin flip on some theme.
|
||||
///
|
||||
/// It drives toward black/white rather than toward the theme's foreground because
|
||||
/// the foreground is usually *tinted*, and blending into a tint destroys hue at
|
||||
/// exactly the moment hue matters most — when a seed is far from the floor and so
|
||||
/// has to travel far. On Rosé Pine Dawn, whose foreground is the purple-grey
|
||||
/// `#575279`, routing through it turned the ANSI red into `#9a5e7a` and the ANSI
|
||||
/// yellow into `#876a62`: two muddy mauves a user could not tell apart, which is
|
||||
/// no use at all for "did that fail or is it just a warning". Black and white are
|
||||
/// neutral, so the hue survives the trip.
|
||||
fn legible_ink(bg: u32, seed: u32, floor: f32) -> u32 {
|
||||
if contrast(seed, bg) >= floor {
|
||||
return seed;
|
||||
}
|
||||
// Whichever extreme the background is *further* from, exactly as
|
||||
// [`legible_foreground`] picks it — not `is_dark`, whose 0.5 luminance
|
||||
// threshold is the wrong question here. The two answers only diverge on a
|
||||
// midtone background (luminance 0.18…0.5), where `is_dark` still says "dark"
|
||||
// but black outreaches white: an imported scheme on a mid-grey ground would
|
||||
// have been driven to pure white and clamped there *below* the floor, losing
|
||||
// the hue and failing the job in one go. Every built-in is far enough from
|
||||
// the midpoint that this picks what `is_dark` did.
|
||||
let away = if contrast(0xffffff, bg) >= contrast(0x000000, bg) {
|
||||
0xffffff
|
||||
} else {
|
||||
0x000000
|
||||
};
|
||||
bisect_contrast(seed, away, bg, floor)
|
||||
}
|
||||
|
||||
/// The accent conditioned for ink (caret, focus ring, a switch's checked track).
|
||||
fn legible_accent(bg: u32, accent: u32) -> u32 {
|
||||
legible_ink(bg, accent, ACCENT_FLOOR)
|
||||
}
|
||||
|
||||
/// Guarantee a legible default text color: keep the authored `fg` if it clears
|
||||
/// the WCAG AA text threshold (4.5) against `bg`, otherwise fall back to pure
|
||||
/// black or white — whichever contrasts more. Protects hand-authored and
|
||||
@@ -980,6 +1369,336 @@ mod tests {
|
||||
}
|
||||
}
|
||||
|
||||
/// Every surface's state ladder must be *strictly ordered and separable* on
|
||||
/// every built-in — this is the regression guard for issue #197, where the
|
||||
/// segmented control's selected fill sat 1.03:1 from its unselected siblings
|
||||
/// on Dracula (and no better than 1.20:1 on any other bundled theme).
|
||||
///
|
||||
/// The assertions are deliberately below the [`state`] targets: they pin the
|
||||
/// *property* (a selection is distinguishable from resting and from hover on
|
||||
/// every surface of every theme), not the current taste, so retuning the
|
||||
/// constants doesn't force a test edit but abandoning the ladder does.
|
||||
#[test]
|
||||
fn state_ladder_is_separable_on_every_surface() {
|
||||
for t in builtins() {
|
||||
let s = t.surfaces();
|
||||
for (name, sf) in [
|
||||
("window", s.window),
|
||||
("sidebar", s.sidebar),
|
||||
("popover", s.popover),
|
||||
] {
|
||||
let sel_base = contrast(sf.selected, sf.base);
|
||||
let sel_hover = contrast(sf.selected, sf.hover);
|
||||
let hover_base = contrast(sf.hover, sf.base);
|
||||
assert!(
|
||||
sel_base >= 1.6,
|
||||
"{}/{name}: selected is only {sel_base:.2}:1 from the surface",
|
||||
t.id
|
||||
);
|
||||
assert!(
|
||||
sel_hover >= 1.3,
|
||||
"{}/{name}: selected is only {sel_hover:.2}:1 from hover",
|
||||
t.id
|
||||
);
|
||||
assert!(
|
||||
hover_base >= 1.1,
|
||||
"{}/{name}: hover is only {hover_base:.2}:1 from the surface",
|
||||
t.id
|
||||
);
|
||||
assert!(
|
||||
contrast(sf.pressed, sf.base) > sel_base,
|
||||
"{}/{name}: pressed must read past selected",
|
||||
t.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The whole point of a ratio target over a blend ratio: the *perceived* step
|
||||
/// is the same on every theme. A fixed `mix` put selected-vs-resting between
|
||||
/// 1.20:1 and 1.47:1 depending on the seed; these must all agree.
|
||||
#[test]
|
||||
fn state_ladder_is_theme_independent() {
|
||||
let ratios: Vec<f32> = builtins()
|
||||
.iter()
|
||||
.map(|t| {
|
||||
let w = t.surfaces().window;
|
||||
contrast(w.selected, w.base)
|
||||
})
|
||||
.collect();
|
||||
let (lo, hi) = ratios
|
||||
.iter()
|
||||
.fold((f32::MAX, 0.0f32), |(l, h), r| (l.min(*r), h.max(*r)));
|
||||
assert!(
|
||||
hi - lo < 0.05,
|
||||
"selected step drifts across themes: {lo:.2}:1 … {hi:.2}:1"
|
||||
);
|
||||
assert!(
|
||||
(lo - state::SELECTED).abs() < 0.05,
|
||||
"selected step {lo:.2}:1 missed its {:.2}:1 target",
|
||||
state::SELECTED
|
||||
);
|
||||
}
|
||||
|
||||
/// Anchoring `SELECTED` to 1.70 must leave the signed-off Dracula highlight
|
||||
/// where it was — the value the palette/menu look was tuned against. This is
|
||||
/// what makes the fix a no-op on the theme it was designed on and a lift for
|
||||
/// everything else; if a retune moves Dracula, that was a taste decision and
|
||||
/// wants to be a deliberate one.
|
||||
#[test]
|
||||
fn dracula_selection_matches_the_signed_off_grey() {
|
||||
let dracula = builtins().into_iter().find(|t| t.id == "dracula").unwrap();
|
||||
let bg = dracula.background_color();
|
||||
let legacy = mix(bg, dracula.foreground, 0.17); // the old `list_active`
|
||||
let now = dracula.surfaces().window.selected;
|
||||
assert!(
|
||||
contrast(now, legacy) < 1.05,
|
||||
"Dracula's selection moved: {now:#08x} vs the tuned {legacy:#08x}"
|
||||
);
|
||||
}
|
||||
|
||||
/// A resting label must clear WCAG AA on the surface it sits on, for every
|
||||
/// theme *and* every surface — a menu row's label sits on `popover`, not on
|
||||
/// the window background, and the fixed dim it replaced was anchored to the
|
||||
/// latter wherever it was used.
|
||||
#[test]
|
||||
fn resting_labels_stay_readable() {
|
||||
for t in builtins() {
|
||||
let s = t.surfaces();
|
||||
for (name, sf) in [("window", s.window), ("popover", s.popover)] {
|
||||
let ratio = contrast(sf.text_resting, sf.base);
|
||||
assert!(
|
||||
ratio >= 4.5,
|
||||
"{}/{name}: resting label only {ratio:.2}:1",
|
||||
t.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The text channel's two invariants, on every surface of every theme.
|
||||
///
|
||||
/// 1. A selected label is readable **on its own fill** — never merely on the
|
||||
/// surface it would have sat on unselected. Getting this wrong is subtle:
|
||||
/// raising the fill toward the foreground eats the label's contrast, and
|
||||
/// Catppuccin Latte's selected label landed at 4.14:1 (below the 4.57:1 of
|
||||
/// the *resting* labels beside it) before `ink_on` existed.
|
||||
/// 2. The two label colors differ enough to read as a step, so the channel
|
||||
/// still says something when the fill is washed out — a translucent
|
||||
/// window, a blurred background, an imported seed nobody vetted.
|
||||
#[test]
|
||||
fn label_channel_is_readable_and_stepped() {
|
||||
for t in builtins() {
|
||||
let s = t.surfaces();
|
||||
for (name, sf) in [
|
||||
("window", s.window),
|
||||
("sidebar", s.sidebar),
|
||||
("popover", s.popover),
|
||||
] {
|
||||
let on_fill = contrast(sf.text_selected, sf.selected);
|
||||
assert!(
|
||||
on_fill >= 4.5,
|
||||
"{}/{name}: selected label only {on_fill:.2}:1 on its own fill",
|
||||
t.id
|
||||
);
|
||||
let step = contrast(sf.text_selected, sf.text_resting);
|
||||
assert!(
|
||||
step >= 1.35,
|
||||
"{}/{name}: label step is only {step:.2}:1 — the channel says nothing",
|
||||
t.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A switch's two tracks must both be distinguishable *from each other* and
|
||||
/// from the surface, and the knob — one colour serving both states — has to
|
||||
/// stay visible on each.
|
||||
///
|
||||
/// The toggles shipped inverted on every dark theme (stock near-black knob on
|
||||
/// a near-white checked track, and invisible on the unchecked one) because
|
||||
/// `switch`, `switch_thumb` and `tokens.background` were all unset. This pins
|
||||
/// the arrangement that replaced it: knob at the light end of the theme's
|
||||
/// axis, unchecked track on the ladder, checked track on the accent.
|
||||
///
|
||||
/// The knob-on-checked-track floor is 1.25, not 3 — a white knob on a
|
||||
/// coloured track is separated by the component's `shadow_md`, exactly as it
|
||||
/// is in macOS, and demanding raw contrast there would force every accent to
|
||||
/// go dark.
|
||||
#[test]
|
||||
fn switch_tracks_and_knob_stay_legible() {
|
||||
for t in builtins() {
|
||||
let m = t.neutrals();
|
||||
let unchecked = t.surfaces().window.selected;
|
||||
let checked = m.accent;
|
||||
let knob = if is_lighter(m.background, m.foreground) {
|
||||
m.background
|
||||
} else {
|
||||
m.foreground
|
||||
};
|
||||
assert!(
|
||||
contrast(knob, unchecked) >= 1.25,
|
||||
"{}: knob {knob:#08x} lost on the unchecked track {unchecked:#08x}",
|
||||
t.id
|
||||
);
|
||||
assert!(
|
||||
contrast(knob, checked) >= 1.25,
|
||||
"{}: knob {knob:#08x} lost on the checked track {checked:#08x}",
|
||||
t.id
|
||||
);
|
||||
// The two states must not be near-identical greys, or the switch says
|
||||
// nothing but the knob's position.
|
||||
assert!(
|
||||
contrast(checked, unchecked) >= 1.3,
|
||||
"{}: checked and unchecked tracks are {:.2}:1 apart",
|
||||
t.id,
|
||||
contrast(checked, unchecked)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// Status colours must clear their floors on every theme, and — the point of
|
||||
/// deriving them from the theme's own ANSI-16 — must stay *recognisable* as
|
||||
/// red / green / yellow rather than converging on the foreground.
|
||||
///
|
||||
/// Before this, `danger` was gpui-component's stock `#f87171` on every theme:
|
||||
/// 2.45:1 on Catppuccin Latte (under even the 3:1 non-text floor) and a
|
||||
/// different red from the `#ff5555` the terminal beside it paints.
|
||||
#[test]
|
||||
fn semantic_colors_clear_their_floors() {
|
||||
for t in builtins() {
|
||||
let bg = t.background_color();
|
||||
let s = t.semantics();
|
||||
for (name, c) in [
|
||||
("danger", s.danger),
|
||||
("warning", s.warning),
|
||||
("success", s.success),
|
||||
("info", s.info),
|
||||
("link", s.link),
|
||||
] {
|
||||
assert!(
|
||||
contrast(c.ink, bg) >= TEXT_FLOOR - 0.01,
|
||||
"{}/{name}: ink {:#08x} only {:.2}:1 on the background",
|
||||
t.id,
|
||||
c.ink,
|
||||
contrast(c.ink, bg)
|
||||
);
|
||||
assert!(
|
||||
contrast(c.fill, bg) >= ACCENT_FLOOR - 0.01,
|
||||
"{}/{name}: fill {:#08x} only {:.2}:1 on the background",
|
||||
t.id,
|
||||
c.fill,
|
||||
contrast(c.fill, bg)
|
||||
);
|
||||
assert!(
|
||||
contrast(c.on_fill, c.fill) >= TEXT_FLOOR - 0.01,
|
||||
"{}/{name}: text on its own fill is only {:.2}:1",
|
||||
t.id,
|
||||
contrast(c.on_fill, c.fill)
|
||||
);
|
||||
}
|
||||
// Conditioning must not wash the hues into each other: a user has to
|
||||
// be able to tell an error from a success without reading the label.
|
||||
for (a, b, pair) in [
|
||||
(s.danger.ink, s.success.ink, "danger/success"),
|
||||
(s.danger.ink, s.warning.ink, "danger/warning"),
|
||||
(s.success.ink, s.warning.ink, "success/warning"),
|
||||
] {
|
||||
assert!(
|
||||
channel_distance(a, b) >= 40,
|
||||
"{}: {pair} collapsed to nearly the same colour ({a:#08x} vs {b:#08x})",
|
||||
t.id
|
||||
);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// Each status colour must stay recognisably its own theme's hue — that is
|
||||
/// the whole reason for sourcing them from ANSI-16 rather than a brand set.
|
||||
/// Where a seed already clears its floor it must pass through untouched.
|
||||
#[test]
|
||||
fn semantic_colors_keep_the_theme_hue() {
|
||||
let dracula = builtins().into_iter().find(|t| t.id == "dracula").unwrap();
|
||||
let ansi_red = {
|
||||
let (r, g, b) = dracula.ansi16[1];
|
||||
(r as u32) << 16 | (g as u32) << 8 | b as u32
|
||||
};
|
||||
assert_eq!(ansi_red, 0xff5555, "Dracula's ANSI red moved");
|
||||
// 4.53:1 on Dracula's background — already over AA, so it is used as-is
|
||||
// and the danger dot matches the terminal's own error output exactly.
|
||||
assert_eq!(dracula.semantics().danger.ink, ansi_red);
|
||||
}
|
||||
|
||||
/// Every theme's accent must be able to carry ink (caret, link, focus ring).
|
||||
/// The bundled Light theme's raw `#00c2ff` manages 2.07:1 on white, which is
|
||||
/// why this conditioning exists rather than using the seed directly.
|
||||
#[test]
|
||||
fn accents_are_conditioned_to_carry_ink() {
|
||||
for t in builtins() {
|
||||
let bg = t.background_color();
|
||||
let a = t.neutrals().accent;
|
||||
let ratio = contrast(a, bg);
|
||||
assert!(
|
||||
ratio >= ACCENT_FLOOR - 0.01,
|
||||
"{}: accent {a:#08x} only {ratio:.2}:1 on the background",
|
||||
t.id
|
||||
);
|
||||
}
|
||||
// ...and a seed that already clears the floor is passed through untouched,
|
||||
// so conditioning never dulls a theme that didn't need it.
|
||||
let rose = builtins()
|
||||
.into_iter()
|
||||
.find(|t| t.id == "rose_pine")
|
||||
.unwrap();
|
||||
assert_eq!(rose.neutrals().accent, rose.accent);
|
||||
}
|
||||
|
||||
/// `raise`/`dim` must land *just* past their targets from either direction,
|
||||
/// and clamp rather than return a mid-range guess when one is unreachable.
|
||||
///
|
||||
/// "Just past" is one 8-bit channel step, not zero: the tightest grey clearing
|
||||
/// 2.0:1 on black is `#404040` at 2.025:1, because a channel step near there
|
||||
/// moves the ratio by ~0.03. Anything tighter would be asserting sub-pixel
|
||||
/// precision the framebuffer can't hold.
|
||||
#[test]
|
||||
fn contrast_bisection_hits_its_target() {
|
||||
const SLACK: f32 = 0.05;
|
||||
// Reachable, rising: a fill lifted off black.
|
||||
let f = raise(0x000000, 0xffffff, 2.0);
|
||||
assert!((2.0..2.0 + SLACK).contains(&contrast(f, 0x000000)));
|
||||
// Reachable, rising: lifted off white — the direction flips, the API
|
||||
// doesn't (this is what the old fixed-mix ladder got wrong per theme).
|
||||
let f = raise(0xffffff, 0x000000, 2.0);
|
||||
assert!((2.0..2.0 + SLACK).contains(&contrast(f, 0xffffff)));
|
||||
// Reachable, falling: white ink dimmed to just above AA on black.
|
||||
let d = dim(0xffffff, 0x000000, 4.5);
|
||||
assert!((contrast(d, 0x000000) - 4.5).abs() < SLACK);
|
||||
// Unreachable: nothing between these two clears 21:1, so clamp to the
|
||||
// far endpoint instead of bisecting to something arbitrary.
|
||||
assert_eq!(raise(0x000000, 0x808080, 21.0), 0x808080);
|
||||
}
|
||||
|
||||
/// Conditioning has to take the extreme it can actually *reach*, which on a
|
||||
/// midtone ground is not the one `is_dark`'s 0.5 luminance threshold names.
|
||||
/// A mid-grey background is "dark" by that test, yet white tops out at
|
||||
/// 3.95:1 on it while black manages 5.32:1 — so driving toward white would
|
||||
/// clamp at pure white, below the floor and with the hue thrown away, in the
|
||||
/// one case where a status colour most needs both. Reachable only for an
|
||||
/// imported scheme; every built-in sits far enough from the midpoint that
|
||||
/// this picks the same extreme `is_dark` did.
|
||||
#[test]
|
||||
fn semantic_conditioning_survives_a_midtone_background() {
|
||||
let bg = 0x808080;
|
||||
for seed in [0xff5555u32, 0x50fa7b, 0xf1fa8c, 0x8be9fd] {
|
||||
let ink = legible_ink(bg, seed, TEXT_FLOOR);
|
||||
assert!(
|
||||
contrast(ink, bg) >= TEXT_FLOOR - 0.01,
|
||||
"{seed:#08x} conditioned to {ink:#08x}, only {:.2}:1 on a midtone ground",
|
||||
contrast(ink, bg)
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
/// A bad foreground is swapped for a legible black/white; a good one is kept.
|
||||
#[test]
|
||||
fn legible_foreground_rescues_unreadable_text() {
|
||||
|
||||
+12
-3
@@ -962,6 +962,9 @@ impl Tty7App {
|
||||
/// Newest first because that's the end you came from: you scrolled past the
|
||||
/// thing you want, and the list should start where your attention is.
|
||||
fn render_panel_outline(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
// This panel is a sunk rail (see the `sidebar` fill on its container), so
|
||||
// its rows read the sidebar ladder.
|
||||
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
|
||||
let Some(leaf) = self
|
||||
.tabs
|
||||
.get(self.active)
|
||||
@@ -1027,7 +1030,7 @@ impl Tty7App {
|
||||
.py(px(3.))
|
||||
.rounded(px(5.))
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.bg(cx.theme().sidebar_accent.opacity(0.55)))
|
||||
.hover(|s| s.bg(gpui::rgb(sf.hover)))
|
||||
.on_click(cx.listener(move |_this, _, _window, cx| {
|
||||
leaf.update(cx, |view, cx| {
|
||||
view.scroll_to_mark(row, cx);
|
||||
@@ -1074,6 +1077,7 @@ impl Tty7App {
|
||||
/// diff overlay's hunk cards, which need far more than 260px to be readable.
|
||||
/// Clicking a row opens the full overlay on that repo.
|
||||
fn render_panel_changes(&mut self, window: &mut Window, cx: &mut Context<Self>) -> AnyElement {
|
||||
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
|
||||
let cwd = self
|
||||
.tabs
|
||||
.get(self.active)
|
||||
@@ -1160,8 +1164,13 @@ impl Tty7App {
|
||||
.py(px(3.))
|
||||
.rounded(px(5.))
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.bg(cx.theme().sidebar_accent.opacity(0.55)))
|
||||
.when(selected, |s| s.bg(cx.theme().sidebar_accent))
|
||||
// The rail's own ladder. Hover used to be this fill at
|
||||
// 55% alpha, which on a light theme is a tint nobody
|
||||
// can see — the same mistake `chrome_tile_variant_for`
|
||||
// already documents having fixed in the title bar, made
|
||||
// again here because there was nothing to reuse.
|
||||
.hover(|s| s.bg(gpui::rgb(sf.hover)))
|
||||
.when(selected, |s| s.bg(gpui::rgb(sf.selected)))
|
||||
.on_click({
|
||||
let cwd = cwd.clone();
|
||||
let path = path.clone();
|
||||
|
||||
+180
-91
@@ -11,8 +11,7 @@ use gpui::{
|
||||
prelude::*, px, relative, rgb,
|
||||
};
|
||||
use gpui_component::InteractiveElementExt as _;
|
||||
use gpui_component::Selectable as _;
|
||||
use gpui_component::button::{Button, ButtonGroup, ButtonVariants as _};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::color_picker::{ColorPicker, ColorPickerState};
|
||||
use gpui_component::input::{Input, InputEvent, InputState};
|
||||
use gpui_component::link::Link;
|
||||
@@ -20,7 +19,6 @@ use gpui_component::menu::{ContextMenuExt as _, DropdownMenu as _, PopupMenu, Po
|
||||
use gpui_component::select::{SearchableVec, Select, SelectState};
|
||||
use gpui_component::sidebar::{Sidebar, SidebarCollapsible, SidebarMenu, SidebarMenuItem};
|
||||
use gpui_component::slider::{Slider, SliderState};
|
||||
use gpui_component::switch::Switch;
|
||||
use gpui_component::{
|
||||
ActiveTheme as _, Icon, IconName, Sizable as _, WindowExt as _, h_flex, v_flex,
|
||||
};
|
||||
@@ -1147,7 +1145,7 @@ impl Tty7App {
|
||||
.px_2p5()
|
||||
.mx_neg_2p5()
|
||||
.rounded_lg()
|
||||
.hover(|h| h.bg(theme.secondary.opacity(0.2)))
|
||||
.hover(|h| h.bg(gpui::rgb(cx.global::<presets::Surfaces>().window.hover)))
|
||||
.child(
|
||||
v_flex()
|
||||
.gap_0p5()
|
||||
@@ -1173,13 +1171,30 @@ impl Tty7App {
|
||||
.child(h_flex().flex_shrink_0().child(control))
|
||||
}
|
||||
|
||||
/// A segmented control (gpui-component's `ButtonGroup`, outline) for a small
|
||||
/// set of mutually-exclusive options — the refined stand-in for a raw row of
|
||||
/// radio circles, which read as an unstyled form beside the sheet's tuned
|
||||
/// steppers and chips. Joined outline segments with a soft-filled active one
|
||||
/// speak the same segmented language as the −│value│+ stepper; `small` pins
|
||||
/// every option control to the same 24px height as the selects beside them.
|
||||
/// `selected` is the active index; `on_pick` fires with the newly chosen one.
|
||||
/// A segmented control for a small set of mutually-exclusive options — the
|
||||
/// refined stand-in for a raw row of radio circles, which read as an unstyled
|
||||
/// form beside the sheet's tuned steppers and chips. Joined segments in a
|
||||
/// single outlined track, one of them filled, speak the same segmented
|
||||
/// language as the −│value│+ stepper right beside them; the 24px height
|
||||
/// matches the selects in the same rows. `selected` is the active index;
|
||||
/// `on_pick` fires with the newly chosen one.
|
||||
///
|
||||
/// # Why this is hand-rolled
|
||||
///
|
||||
/// It used to be gpui-component's `ButtonGroup::outline()` with
|
||||
/// `Button::selected`, and that is what issue #197 was reported against. That
|
||||
/// path derives the selected segment's fill from `Theme::input` and gives it
|
||||
/// the *same* border and the *same* label color as its unselected siblings —
|
||||
/// so the entire selection signal was one fill, and that fill came from a
|
||||
/// grey unrelated to the active theme. On Dracula it measured **1.03:1**.
|
||||
///
|
||||
/// `Theme::input` is now themed (see `ui::theme::apply_theme`), which fixes
|
||||
/// the stock control for inputs and selects. But a segmented control is the
|
||||
/// one place in the app where several options sit visibly side by side with a
|
||||
/// *static* selection, so it is precisely where a fill alone is not enough
|
||||
/// (see `presets::Surface`) — and the stock button exposes no way to vary the
|
||||
/// label's weight. Owning the 30 lines is cheaper than a fork patch, and it
|
||||
/// puts the control on the same ladder every hand-rolled surface reads.
|
||||
pub(crate) fn segmented(
|
||||
&self,
|
||||
id: &'static str,
|
||||
@@ -1188,19 +1203,78 @@ impl Tty7App {
|
||||
cx: &mut Context<Self>,
|
||||
on_pick: impl Fn(&mut Self, usize, &mut Window, &mut Context<Self>) + 'static,
|
||||
) -> AnyElement {
|
||||
ButtonGroup::new(id)
|
||||
.outline()
|
||||
.small()
|
||||
let sf = cx.global::<presets::Surfaces>().window;
|
||||
self.segmented_on(sf, id, options, selected, cx, on_pick)
|
||||
}
|
||||
|
||||
/// [`Self::segmented`] for a control that does *not* sit on the settings
|
||||
/// sheet. The track paints its own opaque ground, so it has to be told which
|
||||
/// one: dropped on the right panel's sunk rail, a window-surface track reads
|
||||
/// as a faintly darker box cut out of the column it sits in — and every rung
|
||||
/// above it was derived against the wrong ground.
|
||||
pub(crate) fn segmented_on(
|
||||
&self,
|
||||
sf: presets::Surface,
|
||||
id: &'static str,
|
||||
options: &'static [&'static str],
|
||||
selected: usize,
|
||||
cx: &mut Context<Self>,
|
||||
on_pick: impl Fn(&mut Self, usize, &mut Window, &mut Context<Self>) + 'static,
|
||||
) -> AnyElement {
|
||||
let border = cx.theme().border;
|
||||
let on_pick = std::rc::Rc::new(on_pick);
|
||||
h_flex()
|
||||
.id(id)
|
||||
.h(px(24.))
|
||||
.rounded_lg()
|
||||
.border_1()
|
||||
.border_color(border)
|
||||
// The track paints its own ground rather than letting the sheet show
|
||||
// through. Every rung of the ladder was derived against this colour,
|
||||
// so painting it is what makes those ratios true — a control that
|
||||
// leaves its ground to whatever it happens to be composited over is
|
||||
// the shape of the bug this whole change is about.
|
||||
.bg(gpui::rgb(sf.base))
|
||||
// One track, clipped so the end segments' fills follow its rounding
|
||||
// instead of squaring off the corners they sit in.
|
||||
.overflow_hidden()
|
||||
.children(options.iter().enumerate().map(|(i, label)| {
|
||||
// `(id, i)` keeps each segment's element id unique across the
|
||||
// several segmented controls on the page.
|
||||
Button::new((id, i)).label(*label).selected(i == selected)
|
||||
}))
|
||||
.on_click(cx.listener(move |this, clicks: &Vec<usize>, window, cx| {
|
||||
// Single-select: `clicks` carries just the newly chosen index.
|
||||
if let Some(&ix) = clicks.first() {
|
||||
on_pick(this, ix, window, cx);
|
||||
}
|
||||
let active = i == selected;
|
||||
let on_pick = on_pick.clone();
|
||||
h_flex()
|
||||
// `(id, i)` keeps each segment's element id unique across the
|
||||
// several segmented controls on the page.
|
||||
.id((id, i))
|
||||
.items_center()
|
||||
.justify_center()
|
||||
.h_full()
|
||||
.px_2p5()
|
||||
.text_sm()
|
||||
.cursor_pointer()
|
||||
// Hairlines *between* segments only — the track already owns
|
||||
// its outer edge, and a border on the first segment would
|
||||
// double it.
|
||||
.when(i > 0, |s| s.border_l_1().border_color(border))
|
||||
// Both channels, every time. The fill locates the selection in
|
||||
// the row; the label color and weight say it is the one — and
|
||||
// keep saying it on a translucent window, where the fill is
|
||||
// washing over whatever is behind the sheet.
|
||||
.when(active, |s| {
|
||||
s.bg(gpui::rgb(sf.selected))
|
||||
.text_color(gpui::rgb(sf.text_selected))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
})
|
||||
.when(!active, |s| {
|
||||
s.text_color(gpui::rgb(sf.text_resting))
|
||||
.hover(|h| h.bg(gpui::rgb(sf.hover)))
|
||||
})
|
||||
// Pressed reads past selected, so pushing the segment that is
|
||||
// already chosen still acknowledges the click.
|
||||
.active(|s| s.bg(gpui::rgb(sf.pressed)))
|
||||
.child(*label)
|
||||
.on_click(cx.listener(move |this, _, window, cx| {
|
||||
on_pick(this, i, window, cx);
|
||||
}))
|
||||
}))
|
||||
.into_any_element()
|
||||
}
|
||||
@@ -1210,7 +1284,10 @@ impl Tty7App {
|
||||
let theme = cx.theme();
|
||||
let foreground = theme.foreground;
|
||||
let border = theme.border;
|
||||
let hover_bg = theme.secondary.opacity(0.6);
|
||||
// Hover comes off the ladder; `stepper_bg` stays a soft resting tint —
|
||||
// it decorates a container rather than signalling a state, which is the
|
||||
// one job an alpha-multiplied grey is still fine for.
|
||||
let hover_bg = gpui::rgb(cx.global::<presets::Surfaces>().window.hover);
|
||||
let stepper_bg = theme.secondary.opacity(0.35);
|
||||
let font_size = self.font_size;
|
||||
let (font_select, font_bold_select, font_italic_select) = match self.active_settings() {
|
||||
@@ -1334,7 +1411,7 @@ impl Tty7App {
|
||||
let font_family_control = font_dropdown(&font_select);
|
||||
let font_bold_control = font_dropdown(&font_bold_select);
|
||||
let font_italic_control = font_dropdown(&font_italic_select);
|
||||
let ligature_switch = Switch::new("font-ligatures")
|
||||
let ligature_switch = crate::ui::theme::switch("font-ligatures", cx)
|
||||
.checked(font_ligatures)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_font_ligatures(*on, cx)))
|
||||
.into_any_element();
|
||||
@@ -1360,7 +1437,7 @@ impl Tty7App {
|
||||
);
|
||||
// Blink lives here beside the shape — one Cursor home, not "shape is
|
||||
// appearance, blink is behavior" split across two pages.
|
||||
let blink_switch = Switch::new("cursor-blink")
|
||||
let blink_switch = crate::ui::theme::switch("cursor-blink", cx)
|
||||
.checked(cursor_blink)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_cursor_blink(*on, cx)))
|
||||
.into_any_element();
|
||||
@@ -1463,7 +1540,7 @@ impl Tty7App {
|
||||
.child(format!("{:.0}%", opacity * 100.)),
|
||||
)
|
||||
.into_any_element();
|
||||
let blur_switch = Switch::new("window-blur")
|
||||
let blur_switch = crate::ui::theme::switch("window-blur", cx)
|
||||
.checked(blur)
|
||||
.on_click(
|
||||
cx.listener(|this, on: &bool, window, cx| this.set_window_blur(*on, window, cx)),
|
||||
@@ -1723,6 +1800,8 @@ impl Tty7App {
|
||||
/// saved-profile list (each row selects into the detail pane).
|
||||
fn render_ssh_master(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let muted = cx.theme().muted_foreground;
|
||||
// Rows in this list paint on the settings sheet, i.e. the window surface.
|
||||
let sf = cx.global::<presets::Surfaces>().window;
|
||||
let profiles = cx.global::<Config>().ssh_profiles.clone();
|
||||
let detail = self
|
||||
.active_settings()
|
||||
@@ -1790,12 +1869,18 @@ impl Tty7App {
|
||||
.py_2()
|
||||
.px_2()
|
||||
.rounded_md()
|
||||
.when(selected, |r| r.bg(cx.theme().secondary.opacity(0.4)))
|
||||
// The window ladder, both channels. This row used to tint with
|
||||
// `secondary.opacity(0.4)` — a 9% grey at 40% alpha, i.e. an
|
||||
// effective 3.6% tint, which put the selected row 1.05–1.12:1
|
||||
// from a resting one and 1.02–1.06:1 from a hovered one. It was
|
||||
// the second site named in issue #197. Multiplying a soft grey
|
||||
// by alpha is how a fill silently disappears: the result depends
|
||||
// on whatever it happens to be composited over, which is
|
||||
// exactly the unknown a per-surface ladder removes.
|
||||
.when(selected, |r| r.bg(gpui::rgb(sf.selected)))
|
||||
// A subtle hover fill so the whole row reads as the (clickable)
|
||||
// select affordance; the selected row keeps its own highlight.
|
||||
.when(!selected, |r| {
|
||||
r.hover(|s| s.bg(cx.theme().secondary.opacity(0.2)))
|
||||
})
|
||||
.when(!selected, |r| r.hover(|s| s.bg(gpui::rgb(sf.hover))))
|
||||
// Left-click anywhere on the row selects it — its edit form
|
||||
// opens in the detail pane. Clicks on the trailing ⋯ are
|
||||
// swallowed by its wrapper, so they don't also start an edit.
|
||||
@@ -1818,7 +1903,21 @@ impl Tty7App {
|
||||
v_flex()
|
||||
.min_w_0()
|
||||
.gap_0p5()
|
||||
.child(div().text_sm().truncate().child(title))
|
||||
.child(
|
||||
div()
|
||||
.text_sm()
|
||||
.truncate()
|
||||
// The label channel: the selected row's title
|
||||
// steps up in colour and weight, so which
|
||||
// profile is loaded in the detail pane reads
|
||||
// from the type and not from the fill alone.
|
||||
.when(selected, |d| {
|
||||
d.text_color(gpui::rgb(sf.text_selected))
|
||||
.font_weight(FontWeight::MEDIUM)
|
||||
})
|
||||
.when(!selected, |d| d.text_color(gpui::rgb(sf.text_resting)))
|
||||
.child(title),
|
||||
)
|
||||
.child(div().text_xs().text_color(muted).truncate().child(subtitle)),
|
||||
)
|
||||
.child(
|
||||
@@ -1964,13 +2063,13 @@ impl Tty7App {
|
||||
/// under the form or the empty-state hint.
|
||||
fn render_ssh_security_block(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let verify = cx.global::<Config>().verify_host_keys;
|
||||
let verify_switch = Switch::new("ssh-verify-host-keys")
|
||||
let verify_switch = crate::ui::theme::switch("ssh-verify-host-keys", cx)
|
||||
.checked(verify)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_verify_host_keys(*on, cx)))
|
||||
.into_any_element();
|
||||
|
||||
let warn_on_close = cx.global::<Config>().ssh_warn_on_close;
|
||||
let warn_switch = Switch::new("ssh-warn-on-close")
|
||||
let warn_switch = crate::ui::theme::switch("ssh-warn-on-close", cx)
|
||||
.checked(warn_on_close)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_ssh_warn_on_close(*on, cx)))
|
||||
.into_any_element();
|
||||
@@ -2639,7 +2738,7 @@ impl Tty7App {
|
||||
self.settings_row(
|
||||
"Agent forwarding",
|
||||
"Forward the local ssh-agent to the session.",
|
||||
Switch::new("ssh-form-agent")
|
||||
crate::ui::theme::switch("ssh-form-agent", cx)
|
||||
.checked(form.agent_forward)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| {
|
||||
if let Some(f) = this.ssh_form_mut() {
|
||||
@@ -2732,7 +2831,7 @@ impl Tty7App {
|
||||
self.settings_row(
|
||||
"X11 forwarding",
|
||||
"Request X11 forwarding (needs XQuartz on macOS).",
|
||||
Switch::new("ssh-form-x11")
|
||||
crate::ui::theme::switch("ssh-form-x11", cx)
|
||||
.checked(form.x11)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| {
|
||||
if let Some(f) = this.ssh_form_mut() {
|
||||
@@ -2748,7 +2847,7 @@ impl Tty7App {
|
||||
self.settings_row(
|
||||
"Shell integration",
|
||||
"Let the remote shell report prompts, exit codes and directory.",
|
||||
Switch::new("ssh-form-shell-integration")
|
||||
crate::ui::theme::switch("ssh-form-shell-integration", cx)
|
||||
.checked(form.shell_integration)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| {
|
||||
if let Some(f) = this.ssh_form_mut() {
|
||||
@@ -2771,7 +2870,7 @@ impl Tty7App {
|
||||
self.settings_row(
|
||||
"Skip banner",
|
||||
"Suppress the server login banner.",
|
||||
Switch::new("ssh-form-banner")
|
||||
crate::ui::theme::switch("ssh-form-banner", cx)
|
||||
.checked(form.skip_banner)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| {
|
||||
if let Some(f) = this.ssh_form_mut() {
|
||||
@@ -2978,11 +3077,11 @@ impl Tty7App {
|
||||
None => return div().into_any_element(),
|
||||
};
|
||||
|
||||
let link_switch = Switch::new("term-link-url")
|
||||
let link_switch = crate::ui::theme::switch("term-link-url", cx)
|
||||
.checked(link_url)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_link_url(*on, cx)))
|
||||
.into_any_element();
|
||||
let ssh_loopback_switch = Switch::new("term-ssh-loopback-forward")
|
||||
let ssh_loopback_switch = crate::ui::theme::switch("term-ssh-loopback-forward", cx)
|
||||
.checked(ssh_loopback_forward)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_ssh_loopback_forward(*on, cx)))
|
||||
.into_any_element();
|
||||
@@ -3005,17 +3104,17 @@ impl Tty7App {
|
||||
},
|
||||
);
|
||||
|
||||
let focus_switch = Switch::new("term-focus-follows")
|
||||
let focus_switch = crate::ui::theme::switch("term-focus-follows", cx)
|
||||
.checked(focus_follows)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_focus_follows_mouse(*on, cx)))
|
||||
.into_any_element();
|
||||
let mouse_hide_switch = Switch::new("term-mouse-hide")
|
||||
let mouse_hide_switch = crate::ui::theme::switch("term-mouse-hide", cx)
|
||||
.checked(mouse_hide)
|
||||
.on_click(
|
||||
cx.listener(|this, on: &bool, _w, cx| this.set_mouse_hide_while_typing(*on, cx)),
|
||||
)
|
||||
.into_any_element();
|
||||
let mouse_report_switch = Switch::new("term-mouse-report")
|
||||
let mouse_report_switch = crate::ui::theme::switch("term-mouse-report", cx)
|
||||
.checked(mouse_reporting)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_mouse_reporting(*on, cx)))
|
||||
.into_any_element();
|
||||
@@ -3143,30 +3242,30 @@ impl Tty7App {
|
||||
let copy_on_select = cfg.copy_on_select;
|
||||
let clip_trim = cfg.clipboard_trim_trailing_spaces;
|
||||
|
||||
let tab_completion_switch = Switch::new("term-tab-completion")
|
||||
let tab_completion_switch = crate::ui::theme::switch("term-tab-completion", cx)
|
||||
.checked(tab_completion)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_tab_completion(*on, cx)))
|
||||
.into_any_element();
|
||||
let history_search_switch = Switch::new("term-history-search")
|
||||
let history_search_switch = crate::ui::theme::switch("term-history-search", cx)
|
||||
.checked(history_search)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_history_search(*on, cx)))
|
||||
.into_any_element();
|
||||
let smart_select_switch = Switch::new("term-smart-select")
|
||||
let smart_select_switch = crate::ui::theme::switch("term-smart-select", cx)
|
||||
.checked(smart_select)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_smart_select(*on, cx)))
|
||||
.into_any_element();
|
||||
let copy_on_select_switch = Switch::new("term-copy-on-select")
|
||||
let copy_on_select_switch = crate::ui::theme::switch("term-copy-on-select", cx)
|
||||
.checked(copy_on_select)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_copy_on_select(*on, cx)))
|
||||
.into_any_element();
|
||||
let trim_switch = Switch::new("term-clip-trim")
|
||||
let trim_switch = crate::ui::theme::switch("term-clip-trim", cx)
|
||||
.checked(clip_trim)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_clipboard_trim(*on, cx)))
|
||||
.into_any_element();
|
||||
// macOS only: the Option/special-character split this toggle resolves
|
||||
// doesn't exist on other platforms, where Alt always carries Meta.
|
||||
let option_alt_row = cfg!(target_os = "macos").then(|| {
|
||||
let switch = Switch::new("term-option-as-alt")
|
||||
let switch = crate::ui::theme::switch("term-option-as-alt", cx)
|
||||
.checked(option_as_alt)
|
||||
.on_click(
|
||||
cx.listener(|this, on: &bool, _w, cx| this.set_macos_option_as_alt(*on, cx)),
|
||||
@@ -3398,15 +3497,15 @@ impl Tty7App {
|
||||
},
|
||||
);
|
||||
|
||||
let restore_switch = Switch::new("wt-restore-session")
|
||||
let restore_switch = crate::ui::theme::switch("wt-restore-session", cx)
|
||||
.checked(restore_session)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_restore_session(*on, cx)))
|
||||
.into_any_element();
|
||||
let remember_window_switch = Switch::new("wt-remember-window")
|
||||
let remember_window_switch = crate::ui::theme::switch("wt-remember-window", cx)
|
||||
.checked(remember_window_size)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_remember_window_size(*on, cx)))
|
||||
.into_any_element();
|
||||
let tray_switch = Switch::new("wt-tray-icon")
|
||||
let tray_switch = crate::ui::theme::switch("wt-tray-icon", cx)
|
||||
.checked(show_tray_icon)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| this.set_show_tray_icon(*on, cx)))
|
||||
.into_any_element();
|
||||
@@ -3597,7 +3696,7 @@ impl Tty7App {
|
||||
/// the OS — one card per light/dark slot.
|
||||
fn render_theme_selection(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let follow = cx.global::<Config>().theme_follow_system;
|
||||
let follow_switch = Switch::new("theme-follow-system")
|
||||
let follow_switch = crate::ui::theme::switch("theme-follow-system", cx)
|
||||
.checked(follow)
|
||||
.on_click(cx.listener(|this, on: &bool, window, cx| {
|
||||
this.set_theme_follow_system(*on, window, cx)
|
||||
@@ -3629,7 +3728,7 @@ impl Tty7App {
|
||||
let border = theme.border;
|
||||
let foreground = theme.foreground;
|
||||
let muted_fg = theme.muted_foreground;
|
||||
let hover_bg = theme.secondary.opacity(0.5);
|
||||
let hover_bg = gpui::rgb(cx.global::<presets::Surfaces>().window.hover);
|
||||
let surface = theme.secondary.opacity(0.28);
|
||||
|
||||
let config = cx.global::<Config>();
|
||||
@@ -3968,20 +4067,30 @@ impl Tty7App {
|
||||
.child(tok)
|
||||
};
|
||||
|
||||
// A preset toggle button, highlighted when active.
|
||||
let preset_button =
|
||||
|id: &'static str, label: &'static str, value: &'static str, on: bool| {
|
||||
Button::new(id).label(label).small().selected(on).on_click(
|
||||
cx.listener(move |this, _, _w, cx| this.set_keybinding_preset(value, cx)),
|
||||
)
|
||||
};
|
||||
// A prefix choice button (tmux preset only).
|
||||
let prefix_button =
|
||||
|id: &'static str, label: &'static str, value: &'static str, on: bool| {
|
||||
Button::new(id).label(label).small().selected(on).on_click(
|
||||
cx.listener(move |this, _, _w, cx| this.set_keybinding_prefix(value, cx)),
|
||||
)
|
||||
};
|
||||
// Preset and prefix are each a one-of-two choice among visible siblings —
|
||||
// a segmented control, and now built as one. They used to be loose
|
||||
// `Button::selected` pairs, which put them on gpui-component's
|
||||
// `tokens.button_active`: another field nothing set, so the "on" button
|
||||
// wore a stock grey with no relation to the theme (issue #197's failure
|
||||
// mode, in a different corner of the same page).
|
||||
let preset_control = self.segmented(
|
||||
"kb-preset",
|
||||
&["Default", "tmux"],
|
||||
usize::from(tmux),
|
||||
cx,
|
||||
|this, ix, _w, cx| {
|
||||
this.set_keybinding_preset(if ix == 0 { "default" } else { "tmux" }, cx)
|
||||
},
|
||||
);
|
||||
let prefix_control = self.segmented(
|
||||
"kb-prefix",
|
||||
&["Ctrl-B", "Ctrl-A"],
|
||||
usize::from(prefix == "ctrl-a"),
|
||||
cx,
|
||||
|this, ix, _w, cx| {
|
||||
this.set_keybinding_prefix(if ix == 0 { "ctrl-b" } else { "ctrl-a" }, cx)
|
||||
},
|
||||
);
|
||||
|
||||
let preset_row = h_flex()
|
||||
.items_center()
|
||||
@@ -4001,12 +4110,7 @@ impl Tty7App {
|
||||
"tmux remaps pane/tab actions onto prefix sequences (e.g. Ctrl-B then C).",
|
||||
)),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(preset_button("preset-default", "Default", "default", !tmux))
|
||||
.child(preset_button("preset-tmux", "tmux", "tmux", tmux)),
|
||||
);
|
||||
.child(h_flex().flex_shrink_0().child(preset_control));
|
||||
|
||||
let prefix_row = h_flex()
|
||||
.items_center()
|
||||
@@ -4019,22 +4123,7 @@ impl Tty7App {
|
||||
.text_color(foreground)
|
||||
.child("Prefix"),
|
||||
)
|
||||
.child(
|
||||
h_flex()
|
||||
.gap_1()
|
||||
.child(prefix_button(
|
||||
"prefix-ctrl-b",
|
||||
"Ctrl-B",
|
||||
"ctrl-b",
|
||||
prefix == "ctrl-b",
|
||||
))
|
||||
.child(prefix_button(
|
||||
"prefix-ctrl-a",
|
||||
"Ctrl-A",
|
||||
"ctrl-a",
|
||||
prefix == "ctrl-a",
|
||||
)),
|
||||
);
|
||||
.child(h_flex().flex_shrink_0().child(prefix_control));
|
||||
|
||||
let count = effective.len();
|
||||
let mut list = v_flex().mt_2();
|
||||
@@ -4344,7 +4433,7 @@ impl Tty7App {
|
||||
.gap_2()
|
||||
.items_center()
|
||||
.child(
|
||||
Switch::new("check-updates")
|
||||
crate::ui::theme::switch("check-updates", cx)
|
||||
.checked(check_for_updates)
|
||||
.on_click(cx.listener(|this, on: &bool, _w, cx| {
|
||||
this.set_check_for_updates(*on, cx)
|
||||
|
||||
+4
-2
@@ -1262,6 +1262,8 @@ impl Tty7App {
|
||||
/// "the parent folder" and matches the rows below rather than a toolbar action.
|
||||
fn render_sftp_go_up_row(&self, cx: &mut Context<Self>) -> AnyElement {
|
||||
let foreground = cx.theme().foreground;
|
||||
// Matches the directory rows below it, which paint on the popover surface.
|
||||
let sf = cx.global::<crate::ui::presets::Surfaces>().popover;
|
||||
h_flex()
|
||||
.id("sftp-go-up")
|
||||
.items_center()
|
||||
@@ -1271,7 +1273,7 @@ impl Tty7App {
|
||||
.py_1()
|
||||
.rounded(cx.theme().radius)
|
||||
.cursor_pointer()
|
||||
.hover(|s| s.bg(cx.theme().accent.opacity(0.5)))
|
||||
.hover(|s| s.bg(gpui::rgb(sf.hover)))
|
||||
.child(
|
||||
Icon::new(IconName::FolderOpen)
|
||||
.xsmall()
|
||||
@@ -1481,7 +1483,7 @@ impl Tty7App {
|
||||
let accent = cx.theme().accent;
|
||||
let border = cx.theme().border;
|
||||
let sidebar = cx.theme().sidebar;
|
||||
let hover = cx.theme().sidebar_accent.opacity(0.4);
|
||||
let hover = gpui::rgb(cx.global::<crate::ui::presets::Surfaces>().sidebar.hover);
|
||||
let expanded = self.sftp_panel.tray_expanded || history;
|
||||
|
||||
// The summary line: how many are moving and how far along the run is, as
|
||||
|
||||
+43
-40
@@ -13,16 +13,13 @@
|
||||
|
||||
use gpui::{
|
||||
Animation, AnimationExt as _, AnyElement, Axis, Bounds, Context, Div, FontWeight, MouseButton,
|
||||
MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, SharedString, Stateful, Window,
|
||||
WindowControlArea, canvas, deferred, div, ease_out_quint, linear_color_stop, linear_gradient,
|
||||
prelude::*, px,
|
||||
MouseDownEvent, MouseMoveEvent, MouseUpEvent, Pixels, SharedString, Stateful, Window, canvas,
|
||||
deferred, div, ease_out_quint, linear_color_stop, linear_gradient, prelude::*, px,
|
||||
};
|
||||
use gpui_component::button::{Button, ButtonVariants as _};
|
||||
use gpui_component::input::Input;
|
||||
use gpui_component::menu::{ContextMenu, ContextMenuExt as _};
|
||||
use gpui_component::{
|
||||
ActiveTheme as _, Icon, IconName, InteractiveElementExt as _, Sizable as _, h_flex, v_flex,
|
||||
};
|
||||
use gpui_component::{ActiveTheme as _, Icon, IconName, Sizable as _, h_flex, v_flex};
|
||||
use std::cell::{Cell, RefCell};
|
||||
use std::rc::Rc;
|
||||
|
||||
@@ -77,6 +74,8 @@ impl Tty7App {
|
||||
cx: &mut Context<Self>,
|
||||
) -> impl IntoElement + use<> {
|
||||
let active = self.active;
|
||||
// The rail is a sunk column, so its rows read the sidebar ladder.
|
||||
let sf = cx.global::<crate::ui::presets::Surfaces>().sidebar;
|
||||
// While the bare ⌘/Ctrl hold is armed (see `ui::hints`), each of the
|
||||
// first nine rows swaps its close affordance for a ⌘N badge — same slot
|
||||
// and footprint as the chips, so the vertical list gets the identical
|
||||
@@ -409,7 +408,7 @@ impl Tty7App {
|
||||
})
|
||||
.when(!is_active, |s| {
|
||||
s.text_color(cx.theme().sidebar_foreground)
|
||||
.hover(|s| s.bg(cx.theme().sidebar_accent.opacity(0.5)))
|
||||
.hover(|s| s.bg(gpui::rgb(sf.hover)))
|
||||
})
|
||||
// Held: a light dimming so the row under your cursor reads
|
||||
// as picked up. Not a lift — it stays in the rail's plane.
|
||||
@@ -489,12 +488,13 @@ impl Tty7App {
|
||||
// carries alpha; the inactive hover is a half-strength
|
||||
// wash), so flatten them against `sidebar` to get the
|
||||
// opaque colour the float must match.
|
||||
let backing = if is_active {
|
||||
cx.theme().sidebar.blend(cx.theme().sidebar_accent)
|
||||
// Both rungs are opaque ladder colours, so the float's
|
||||
// backing is just the rung itself — no flattening needed,
|
||||
// and no alpha to drift out of step with the row it copies.
|
||||
let backing: gpui::Hsla = if is_active {
|
||||
gpui::rgb(sf.selected).into()
|
||||
} else {
|
||||
cx.theme()
|
||||
.sidebar
|
||||
.blend(cx.theme().sidebar_accent.opacity(0.5))
|
||||
gpui::rgb(sf.hover).into()
|
||||
};
|
||||
let mut fade_from = backing;
|
||||
fade_from.a = 0.;
|
||||
@@ -751,11 +751,35 @@ impl Tty7App {
|
||||
let controls = h_flex()
|
||||
.flex_shrink_0()
|
||||
.h(px(TITLE_BAR_HEIGHT))
|
||||
// Same box as the real title bar this row stands in for, hairline
|
||||
// included: gpui-component's `TitleBar` draws a `border_b_1` inside its
|
||||
// own `TITLE_BAR_HEIGHT` (tty7 paints it transparent, but it still takes
|
||||
// its pixel), so the bar centres its contents on 19.5 while an
|
||||
// unbordered 40px row centres them on 20. Half a pixel is invisible on
|
||||
// the line-art tiles, and *not* on the solid brand mark: collapsing the
|
||||
// rail hands the mark from this row to the bar, and it visibly hopped up
|
||||
// as it went. Reserve the same pixel here and the handover is still.
|
||||
.border_b_1()
|
||||
.border_color(cx.theme().transparent)
|
||||
.items_center()
|
||||
.justify_end()
|
||||
.gap(px(2.))
|
||||
// Glyph's ink, not hit box, on the content edge — see `TILE_PAD`.
|
||||
.pr(px(crate::ui::app::tile_trailing_inset()))
|
||||
// The brand mark leads the row, on the rail's own content inset — the
|
||||
// line the search magnifier and every row label below it start on, so
|
||||
// it reads as the head of this column rather than a floating badge.
|
||||
// The spacer is what keeps the controls pinned right once the row has
|
||||
// a leading child (`justify_end` alone no longer does it).
|
||||
.when_some(crate::ui::app::window_mark(), |row, mark| {
|
||||
row.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.pl(px(crate::ui::app::CONTENT_INSET))
|
||||
.child(mark),
|
||||
)
|
||||
.child(div().flex_1())
|
||||
})
|
||||
// Both tiles are wrapped in an `occlude()` div, exactly like the
|
||||
// title-strip chrome. This row is a `WindowControlArea::Drag` (set
|
||||
// below), which on Windows maps to HTCAPTION — the OS claims the click
|
||||
@@ -934,34 +958,13 @@ impl Tty7App {
|
||||
//
|
||||
// The real `TitleBar` — which carries the window's drag region
|
||||
// — only spans the *right* column in this layout, so this strip
|
||||
// would be dead space you can't grab the window by. Make the
|
||||
// controls' own row act like the title bar it sits level with:
|
||||
// drag to move, double-click to zoom. Driven exactly like
|
||||
// `TitleBar` does it (and the settings overlay's stand-in
|
||||
// strip): a press arms a flag and the first *move* starts the
|
||||
// window move, so a plain click — and a double-click — still
|
||||
// lands intact, while the buttons on the right keep taking
|
||||
// their own clicks.
|
||||
.child({
|
||||
let should_move = Rc::new(Cell::new(false));
|
||||
controls
|
||||
.id("sidebar-titlebar-drag")
|
||||
.window_control_area(WindowControlArea::Drag)
|
||||
.on_mouse_down(MouseButton::Left, {
|
||||
let should_move = should_move.clone();
|
||||
move |_, _, _| should_move.set(true)
|
||||
})
|
||||
.on_mouse_up(MouseButton::Left, {
|
||||
let should_move = should_move.clone();
|
||||
move |_, _, _| should_move.set(false)
|
||||
})
|
||||
.on_mouse_move(move |_, window, _| {
|
||||
if should_move.replace(false) {
|
||||
window.start_window_move();
|
||||
}
|
||||
})
|
||||
.on_double_click(|_, window, _| window.titlebar_double_click())
|
||||
})
|
||||
// would be dead space you can't grab the window by. `title_bar_drag`
|
||||
// makes the controls' row act like the bar it sits level with:
|
||||
// drag to move, double-click to zoom, while the buttons on the
|
||||
// right keep taking their own clicks (they're `occlude()`d).
|
||||
.child(crate::ui::app::title_bar_drag(
|
||||
controls.id("sidebar-titlebar-drag"),
|
||||
))
|
||||
.child(top_bar)
|
||||
.child(crate::ui::scrollbar::with_vertical_scrollbar(
|
||||
"tab-sidebar-scrollbar",
|
||||
|
||||
@@ -1518,6 +1518,25 @@ impl Tty7App {
|
||||
// Negative off macOS only: the bar already inset us past the window
|
||||
// controls, and there the reserve *is* the clearance.
|
||||
.ml(px(crate::ui::app::title_bar_hug_offset()))
|
||||
// The brand mark follows the rail's controls into the strip, so
|
||||
// collapsing the sidebar doesn't strip the window's leading corner
|
||||
// back to nothing (see `app::window_mark`). The group is anchored by
|
||||
// its tiles' *hit boxes*, which start `tile_trailing_inset()` from
|
||||
// the window edge; the mark has no box, so it adds the difference
|
||||
// back to land its own ink on `CONTENT_INSET` like the rail's did.
|
||||
.when_some(crate::ui::app::window_mark(), |group, mark| {
|
||||
group.child(
|
||||
div()
|
||||
.flex_shrink_0()
|
||||
.pl(px(crate::ui::app::CONTENT_INSET
|
||||
- crate::ui::app::tile_trailing_inset()))
|
||||
// The mark is solid where the tiles are line work, so it
|
||||
// needs more air than the 2px that separates two tiles
|
||||
// before the "+" beside it stops reading as part of it.
|
||||
.pr(px(4.))
|
||||
.child(mark),
|
||||
)
|
||||
})
|
||||
.child(
|
||||
div().occlude().flex_shrink_0().child(
|
||||
self.attach_new_tab_menu(
|
||||
|
||||
+226
-10
@@ -374,6 +374,8 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) {
|
||||
sync_native_appearance(Some(theme.dark));
|
||||
}
|
||||
let m = theme.neutrals();
|
||||
let surfaces = theme.surfaces();
|
||||
let sem = theme.semantics();
|
||||
let active = theme.active_palette();
|
||||
// Read before `Theme::global_mut` borrows `cx`. macOS reports the overlay /
|
||||
// legacy scroller preference here; Windows reports the accessibility
|
||||
@@ -410,6 +412,13 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) {
|
||||
opacity,
|
||||
image: theme.image.clone(),
|
||||
});
|
||||
// Publish the interaction-state ladders. Every hand-rolled control in the
|
||||
// shell reads its resting/hover/selected fills and label colors from here
|
||||
// rather than picking a `Theme` colour field that looks about right — which
|
||||
// is how the same state ended up wearing four different greys (and one
|
||||
// invisible one) across the app. See `presets::Surface`.
|
||||
cx.set_global(surfaces.clone());
|
||||
cx.set_global(presets::ActiveAccent(m.accent));
|
||||
|
||||
let t = Theme::global_mut(cx);
|
||||
// The window base carries the theme's opacity so a translucent/blurred theme
|
||||
@@ -436,15 +445,23 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) {
|
||||
// Context menus and dropdowns highlight the hovered/selected row from
|
||||
// `tokens.accent` (fill) + `accent_foreground` (text) — see gpui-component's
|
||||
// `MenuItemElement`. Left unset, that highlight falls back to the stock
|
||||
// saturated accent, which snaps hard against this app's soft mix-based
|
||||
// palette (the "生硬" hover). Point it at the same soft fill the command
|
||||
// palette uses for its selected row (`list_active`, mix 0.17) so context
|
||||
// saturated accent, which snaps hard against this app's soft palette (the
|
||||
// "生硬" hover). Point it at the popover ladder's selected rung so context
|
||||
// menu, dropdown and palette share one hover language; keep the text at
|
||||
// `foreground` so it stays legible on the low-contrast fill instead of the
|
||||
// stock inverted accent text. The plain `accent`/`accent_foreground` fields
|
||||
// feed the same highlight in the input completion / code-action popovers, so
|
||||
// mirror both the fields and the tokens to keep every menu surface in step.
|
||||
let accent_fill = rgb(m.list_active);
|
||||
//
|
||||
// NOTE the surface: menu rows paint on `popover`, not on the window
|
||||
// background. This used to read the window ladder, which is why the menu
|
||||
// highlight measured as little as 1.20:1 against the panel it actually sat on
|
||||
// while nominally being the same fill that reads fine on the terminal ground.
|
||||
//
|
||||
// This does *not* mean "accent" — the field is gpui-component's name for a
|
||||
// row highlight, and pointing the theme's real accent at it is what would
|
||||
// give the saturated snap. `surfaces.popover.selected` says what it is.
|
||||
let accent_fill = rgb(surfaces.popover.selected);
|
||||
let accent_text: Hsla = rgb(m.foreground).into();
|
||||
t.accent = accent_fill.into();
|
||||
t.accent_foreground = accent_text;
|
||||
@@ -473,6 +490,135 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) {
|
||||
t.tokens.button_primary_hover = primary_hover.into();
|
||||
t.tokens.button_primary_active = primary_active.into();
|
||||
|
||||
// Status colours. The last family that was still gpui-component's stock
|
||||
// Tailwind (`red-400`, `yellow-400`, `green-400`) — a palette with no
|
||||
// relationship to the active theme, used at 33 sites. On Dracula that put a
|
||||
// `#f87171` delete button beside `#ff5555` terminal output: two reds, one
|
||||
// window. On the light themes it was worse than inconsistent, at 2.45:1 —
|
||||
// under even the non-text floor.
|
||||
//
|
||||
// They now come from each theme's *own* ANSI-16 (see `Theme::semantics`), so
|
||||
// a danger marker and an error line of shell output are the same red.
|
||||
//
|
||||
// Each family gets three roles because the plain field and the tokens are
|
||||
// read for different jobs: tty7's own sites use `Theme::danger` as a text /
|
||||
// small-mark colour (a 7px status dot in the run list, a label), while
|
||||
// gpui-component's buttons fill from `tokens.button_danger` and put
|
||||
// `*_foreground` on top of that fill. One value cannot serve both.
|
||||
// `ink` and `fill` each step one notch either side of themselves for
|
||||
// hover/active — the same shape the primary family above uses.
|
||||
let steps = |c: u32| {
|
||||
(
|
||||
Hsla::from(rgb(c)),
|
||||
Hsla::from(rgb(presets::mix(c, m.background, 0.15))),
|
||||
Hsla::from(rgb(presets::mix(c, m.foreground, 0.15))),
|
||||
)
|
||||
};
|
||||
|
||||
// ── Danger ──
|
||||
let (ink, ink_hover, ink_active) = steps(sem.danger.ink);
|
||||
let (fill, fill_hover, fill_active) = steps(sem.danger.fill);
|
||||
let on_fill = Hsla::from(rgb(sem.danger.on_fill));
|
||||
t.danger = ink;
|
||||
t.danger_hover = ink_hover;
|
||||
t.danger_active = ink_active;
|
||||
t.danger_foreground = on_fill;
|
||||
t.tokens.danger = fill.into();
|
||||
t.tokens.danger_hover = fill_hover.into();
|
||||
t.tokens.danger_active = fill_active.into();
|
||||
t.tokens.danger_foreground = on_fill.into();
|
||||
t.tokens.button_danger = fill.into();
|
||||
t.tokens.button_danger_hover = fill_hover.into();
|
||||
t.tokens.button_danger_active = fill_active.into();
|
||||
t.tokens.button_danger_foreground = on_fill.into();
|
||||
|
||||
// ── Warning ──
|
||||
let (ink, ink_hover, ink_active) = steps(sem.warning.ink);
|
||||
let (fill, fill_hover, fill_active) = steps(sem.warning.fill);
|
||||
let on_fill = Hsla::from(rgb(sem.warning.on_fill));
|
||||
t.warning = ink;
|
||||
t.warning_hover = ink_hover;
|
||||
t.warning_active = ink_active;
|
||||
t.warning_foreground = on_fill;
|
||||
t.tokens.warning = fill.into();
|
||||
t.tokens.warning_hover = fill_hover.into();
|
||||
t.tokens.warning_active = fill_active.into();
|
||||
t.tokens.warning_foreground = on_fill.into();
|
||||
t.tokens.button_warning = fill.into();
|
||||
t.tokens.button_warning_hover = fill_hover.into();
|
||||
t.tokens.button_warning_active = fill_active.into();
|
||||
t.tokens.button_warning_foreground = on_fill.into();
|
||||
|
||||
// ── Success ──
|
||||
let (ink, ink_hover, ink_active) = steps(sem.success.ink);
|
||||
let (fill, fill_hover, fill_active) = steps(sem.success.fill);
|
||||
let on_fill = Hsla::from(rgb(sem.success.on_fill));
|
||||
t.success = ink;
|
||||
t.success_hover = ink_hover;
|
||||
t.success_active = ink_active;
|
||||
t.success_foreground = on_fill;
|
||||
t.tokens.success = fill.into();
|
||||
t.tokens.success_hover = fill_hover.into();
|
||||
t.tokens.success_active = fill_active.into();
|
||||
t.tokens.success_foreground = on_fill.into();
|
||||
t.tokens.button_success = fill.into();
|
||||
t.tokens.button_success_hover = fill_hover.into();
|
||||
t.tokens.button_success_active = fill_active.into();
|
||||
t.tokens.button_success_foreground = on_fill.into();
|
||||
|
||||
// ── Info ──
|
||||
let (ink, ink_hover, ink_active) = steps(sem.info.ink);
|
||||
let (fill, fill_hover, fill_active) = steps(sem.info.fill);
|
||||
let on_fill = Hsla::from(rgb(sem.info.on_fill));
|
||||
t.info = ink;
|
||||
t.info_hover = ink_hover;
|
||||
t.info_active = ink_active;
|
||||
t.info_foreground = on_fill;
|
||||
t.tokens.info = fill.into();
|
||||
t.tokens.info_hover = fill_hover.into();
|
||||
t.tokens.info_active = fill_active.into();
|
||||
t.tokens.info_foreground = on_fill.into();
|
||||
t.tokens.button_info = fill.into();
|
||||
t.tokens.button_info_hover = fill_hover.into();
|
||||
t.tokens.button_info_active = fill_active.into();
|
||||
t.tokens.button_info_foreground = on_fill.into();
|
||||
|
||||
// Links. Unset, these resolve to near-white on dark themes and near-black on
|
||||
// light ones — i.e. the body text colour, so a link in the Markdown preview
|
||||
// (`ui::code_editor`, which renders through gpui-component's `TextView`)
|
||||
// looked exactly like prose. The theme's own cyan is what a terminal user
|
||||
// already reads as "this is a link".
|
||||
t.link = rgb(sem.link.ink).into();
|
||||
t.link_hover = rgb(presets::mix(sem.link.ink, m.foreground, 0.25)).into();
|
||||
t.link_active = rgb(presets::mix(sem.link.ink, m.background, 0.20)).into();
|
||||
t.tokens.link = Hsla::from(rgb(sem.link.ink)).into();
|
||||
t.tokens.link_hover = Hsla::from(rgb(presets::mix(sem.link.ink, m.foreground, 0.25))).into();
|
||||
t.tokens.link_active = Hsla::from(rgb(presets::mix(sem.link.ink, m.background, 0.20))).into();
|
||||
|
||||
// Switches. Three more fields nobody had set, and the reason the toggles read
|
||||
// inverted on every dark theme:
|
||||
//
|
||||
// * `switch_thumb` falls back to `tokens.background` — also unset — so the
|
||||
// knob was gpui-component's stock near-black. On the (near-white) checked
|
||||
// track that is a dark knob on a light track, the opposite of every system
|
||||
// switch; on the dark unchecked track it disappeared entirely.
|
||||
// * `switch` (the unchecked track) was the stock `#404040`, unrelated to the
|
||||
// theme.
|
||||
//
|
||||
// The knob takes the light end of the theme's own axis — it is a raised
|
||||
// physical object, and both macOS modes render it near-white — and the
|
||||
// component already draws it with `shadow_md`, which is what separates it from
|
||||
// a light track rather than raw contrast. The unchecked track is the window
|
||||
// ladder's `selected` rung: the same "this is filled" grey as everything else.
|
||||
let knob = if presets::is_lighter(m.background, m.foreground) {
|
||||
m.background
|
||||
} else {
|
||||
m.foreground
|
||||
};
|
||||
t.tokens.background = Hsla::from(rgb(m.background)).into();
|
||||
t.tokens.switch_thumb = Hsla::from(rgb(knob)).into();
|
||||
t.tokens.switch = Hsla::from(rgb(surfaces.window.selected)).into();
|
||||
|
||||
t.caret = rgb(m.caret).into();
|
||||
t.selection = rgb(m.selection).into(); // text selection highlight
|
||||
|
||||
@@ -523,24 +669,73 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) {
|
||||
// the `sidebar*` color fields — so those must be set on `tokens` or the
|
||||
// override is a no-op and the column falls back to the stock surface.
|
||||
let sidebar_bg = rgb(m.sidebar);
|
||||
let sidebar_sel = rgb(m.sidebar_sel);
|
||||
let sidebar_sel = rgb(surfaces.sidebar.selected);
|
||||
t.sidebar = sidebar_bg.into();
|
||||
t.tokens.sidebar = Hsla::from(sidebar_bg).into();
|
||||
t.sidebar_border = rgb(m.border).into();
|
||||
t.sidebar_foreground = rgb(m.sidebar_fg).into();
|
||||
t.sidebar_foreground = rgb(surfaces.sidebar.text_resting).into();
|
||||
t.sidebar_accent = sidebar_sel.into();
|
||||
t.tokens.sidebar_accent = Hsla::from(sidebar_sel).into();
|
||||
t.sidebar_accent_foreground = rgb(m.foreground).into();
|
||||
t.sidebar_accent_foreground = rgb(surfaces.sidebar.text_selected).into();
|
||||
|
||||
// Flatten gpui-component's list selection highlight (used by the command
|
||||
// palette) into a single soft fill — no blue ring, no accent tint — so it
|
||||
// matches this app's minimal aesthetic instead of the stock look. Keep
|
||||
// `active_highlight` on (the alternative path tints with the shared
|
||||
// `accent`), but make the ring colour equal the fill so the box disappears.
|
||||
//
|
||||
// The palette and its list paint on an elevated panel, so this is the popover
|
||||
// ladder — same reasoning as `accent` above.
|
||||
t.list.active_highlight = true;
|
||||
t.list_active = rgb(m.list_active).into();
|
||||
t.list_active_border = rgb(m.list_active).into();
|
||||
t.list_hover = rgb(m.list_hover).into();
|
||||
t.list_active = rgb(surfaces.popover.selected).into();
|
||||
t.list_active_border = rgb(surfaces.popover.selected).into();
|
||||
t.list_hover = rgb(surfaces.popover.hover).into();
|
||||
|
||||
// ── Stock widgets that were still wearing gpui-component's defaults ──────
|
||||
//
|
||||
// Everything above overrides a field because someone noticed it looking
|
||||
// off-theme. The fields *nobody noticed* are the actual hazard: they silently
|
||||
// keep the stock value, which is a fixed grey with no relationship to the
|
||||
// active theme, so whether a control reads is down to where that theme's
|
||||
// background happens to land relative to a hardcoded `#2f2f2f`.
|
||||
//
|
||||
// `input` is how issue #197 happened. Outline buttons (and inputs, selects,
|
||||
// switches) derive their resting *and* selected fills from it, so an unset
|
||||
// `input` put the selected segment of every segmented control at 1.03:1
|
||||
// against its neighbours on Dracula — and inverted the direction of the
|
||||
// change between light and dark themes. Pointing it at the window ladder ties
|
||||
// it to the theme; `Tty7App::segmented` no longer depends on this path at all
|
||||
// (it paints the ladder itself), but every other stock control still does.
|
||||
t.input = rgb(surfaces.window.selected).into();
|
||||
t.tokens.input = Hsla::from(rgb(surfaces.window.selected)).into();
|
||||
|
||||
// …but `input` only reaches the *outline* path, which reads the field live.
|
||||
// The plain (non-outline) button family is derived from `input` **once**,
|
||||
// inside the `apply_config` that `Theme::change` ran above — i.e. from the
|
||||
// stock `#2f2f2f`, before any of this function's overrides exist — and a
|
||||
// snapshot never sees the fix. So a plain `Button` still hovered and pressed
|
||||
// in that grey, and `Button::selected` (the terminal search bar's `Aa` / `.*`
|
||||
// toggles, the last two in the app) filled from `tokens.secondary_active` the
|
||||
// same way: on Dracula, ~1.03:1 against the surface behind it. That is issue
|
||||
// #197 again, one snapshot removed from the field that fixed it.
|
||||
//
|
||||
// Only the *state* rungs move — `tokens.button` (the resting fill) is left
|
||||
// alone, so a plain button keeps the flat look it has today and only its
|
||||
// hover/pressed/selected join the ladder.
|
||||
let button_hover: Hsla = rgb(surfaces.window.hover).into();
|
||||
let button_active: Hsla = rgb(surfaces.window.selected).into();
|
||||
t.tokens.button_hover = button_hover.into();
|
||||
t.tokens.button_active = button_active.into();
|
||||
t.tokens.secondary_hover = button_hover.into();
|
||||
t.tokens.secondary_active = button_active.into();
|
||||
t.tokens.button_secondary_hover = button_hover.into();
|
||||
t.tokens.button_secondary_active = button_active.into();
|
||||
|
||||
// Focus rings: the one place the theme's *real* accent belongs. A ring is ink
|
||||
// on the background at 1–2px, which is exactly the job `legible_accent`
|
||||
// conditions the seed for; the stock `neutral-300` was both off-theme and
|
||||
// indistinguishable from a border.
|
||||
t.ring = rgb(m.accent).into();
|
||||
|
||||
// `sync_native_appearance` above may have flipped the macOS app appearance,
|
||||
// which resets the traffic-light buttons to their default (higher) position.
|
||||
@@ -554,6 +749,27 @@ pub(crate) fn apply_theme(mut window: Option<&mut Window>, cx: &mut App) {
|
||||
}
|
||||
}
|
||||
|
||||
/// A `Switch` wearing the theme's accent on its checked track.
|
||||
///
|
||||
/// Every switch in the app goes through here rather than through `Switch::new`
|
||||
/// directly. gpui-component defaults the checked track to `tokens.primary`, which
|
||||
/// tty7 tunes for *primary buttons* — a near-white on dark themes, deliberately,
|
||||
/// because a primary button is a light slab with dark text. As a switch track
|
||||
/// that same colour swallows the (near-white) knob, so the two uses genuinely
|
||||
/// need two colours and the component only offers a per-instance override.
|
||||
///
|
||||
/// The accent is the right one, and this is the one control in the app that gets
|
||||
/// it. On/off differ by *hue* here because they cannot differ by lightness: the
|
||||
/// knob has already claimed the light end of the axis, so a checked track that
|
||||
/// reads as "brighter" is a track the knob vanishes into. It is the same reason
|
||||
/// every system switch is coloured. `Neutrals::accent` is contrast-conditioned
|
||||
/// (see `presets::legible_accent`), so a seed as pale as the Light theme's
|
||||
/// `#00c2ff` still lands on a track a white knob can sit on.
|
||||
pub(crate) fn switch(id: impl Into<gpui::ElementId>, cx: &App) -> gpui_component::switch::Switch {
|
||||
let accent = cx.global::<presets::ActiveAccent>().0;
|
||||
gpui_component::switch::Switch::new(id).color(Hsla::from(rgb(accent)))
|
||||
}
|
||||
|
||||
/// Apply `Config::mouse_hide_while_typing` to GPUI's cursor-hide policy: hide the
|
||||
/// pointer while typing when on, never when off. Called at startup and whenever
|
||||
/// the config changes (setter + hot-reload) so the switch takes effect live.
|
||||
|
||||
Reference in New Issue
Block a user