From 64313a58a1f750f47b05b29ca30538b4cc220910 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 4 Aug 2026 14:24:03 -0400 Subject: [PATCH] feat(profile): add terminal profiles and unify color resolution A screenshot and a color assertion disagreed about what a cell was painted. `render/svg.rs` carried a private sixteen-color table and `assert/color.rs` carried a different one, so `expect --fg "#800000"` passed on a cell the screenshot drew `#e88388`. Both tables are deleted here and both callers resolve through one profile, which is what makes them agree by construction rather than by coincidence. The palette had to become configurable to fix it anyway: the two tables could only be collapsed by choosing which one was right, and that choice belongs to the user rather than to whichever module was read first. The shipped default is the VGA/xterm palette that `TERM=xterm-256color` already promises, which is what the assertion side used. A profile is read from `shell-use.toml` and sets scrollback and colors. Only the sixteen ANSI slots and the three defaults are configurable; indices 16-255 are the xterm color cube and gray ramp, which are fixed by the spec, so a config that could move them would let two sessions disagree about what `--fg 196` means. Profiles are named, and `--profile` selects one. The file is looked up nearest first, project before user, so a repository can pin the terminal its tests expect. Resolution happens in the CLI rather than the daemon: the daemon is long-lived and shared, so it has no single working directory to resolve a project-local config against, and a resolved profile travels on `Request::Open` the same way timeouts already do. Absent settings take the default, and the field is `#[serde(default)]`, so a client that predates this keeps the behavior it had. Scrollback moves from a hardcoded 5,000 to a configurable 10,000, matching alacritty's own default. Two things are deliberately errors rather than silent fallbacks: an unknown profile name, which reports the ones that exist, and a config file that does not parse, which would otherwise run the session with settings nobody asked for. A *missing* file stays fine, since running without one is normal. Screenshots will look different: the default background is now black rather than the previous dark blue-gray, and the palette is saturated rather than muted. Both are recoverable in a profile. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas --- Cargo.lock | 83 +++ Cargo.toml | 1 + README.md | 50 +- SKILL.md | 24 +- crates/shell-use-cli/src/cli.rs | 26 + crates/shell-use-cli/src/main.rs | 4 + .../shell-use-cli/tests/session_lifecycle.rs | 92 +++ crates/shell-use/Cargo.toml | 1 + crates/shell-use/src/assert/color.rs | 138 +++-- crates/shell-use/src/engine.rs | 24 +- crates/shell-use/src/lib.rs | 1 + crates/shell-use/src/profile.rs | 522 ++++++++++++++++++ crates/shell-use/src/protocol.rs | 7 + crates/shell-use/src/render/svg.rs | 121 ++-- crates/shell-use/src/session.rs | 7 +- crates/shell-use/tests/runtime.rs | 3 + 16 files changed, 940 insertions(+), 164 deletions(-) create mode 100644 crates/shell-use/src/profile.rs diff --git a/Cargo.lock b/Cargo.lock index e00cc8e..1572b43 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -392,6 +392,12 @@ version = "1.0.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "34aa73646ffb006b8f5147f3dc182bd4bcb190227ce861fc4a4844bf8e3cb2c0" +[[package]] +name = "equivalent" +version = "1.0.2" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "877a4ace8713b0bcf2a4e7eec82529c029f1d0619886d18145fea96c3ffe5c0f" + [[package]] name = "errno" version = "0.3.14" @@ -544,6 +550,12 @@ dependencies = [ "wasi", ] +[[package]] +name = "hashbrown" +version = "0.17.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "ed5909b6e89a2db4456e54cd5f673791d7eca6732202bbf2a9cc504fe2f9b84a" + [[package]] name = "heck" version = "0.5.0" @@ -565,6 +577,16 @@ dependencies = [ "windows-sys 0.61.2", ] +[[package]] +name = "indexmap" +version = "2.14.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "d466e9454f08e4a911e14806c24e16fba1b4c121d1ea474396f396069cf949d9" +dependencies = [ + "equivalent", + "hashbrown", +] + [[package]] name = "interprocess" version = "2.4.2" @@ -1117,6 +1139,15 @@ dependencies = [ "zmij", ] +[[package]] +name = "serde_spanned" +version = "1.1.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "6662b5879511e06e8999a8a235d848113e942c9124f211511b16466ee2995f26" +dependencies = [ + "serde_core", +] + [[package]] name = "serial2" version = "0.2.37" @@ -1164,6 +1195,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "toml", "ttf-parser", ] @@ -1352,6 +1384,45 @@ dependencies = [ "pin-project-lite", ] +[[package]] +name = "toml" +version = "0.9.12+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf92845e79fc2e2def6a5d828f0801e29a2f8acc037becc5ab08595c7d5e9863" +dependencies = [ + "indexmap", + "serde_core", + "serde_spanned", + "toml_datetime", + "toml_parser", + "toml_writer", + "winnow 0.7.15", +] + +[[package]] +name = "toml_datetime" +version = "0.7.5+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "92e1cfed4a3038bc5a127e35a2d360f145e1f4b971b551a2ba5fd7aedf7e1347" +dependencies = [ + "serde_core", +] + +[[package]] +name = "toml_parser" +version = "1.1.3+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "1d38ac1cf9b95face32296c0a3ede1fdc270627c9d9c02a7274dd6d960dc4d56" +dependencies = [ + "winnow 1.0.4", +] + +[[package]] +name = "toml_writer" +version = "1.1.2+spec-1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "7d56353a2a665ad0f41a421187180aab746c8c325620617ad883a99a1cbe66d2" + [[package]] name = "ttf-parser" version = "0.25.1" @@ -1530,6 +1601,18 @@ version = "0.52.6" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "589f6da84c646204747d1270a2a5661ea66ed1cced2631d546fdfb155959f9ec" +[[package]] +name = "winnow" +version = "0.7.15" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "df79d97927682d2fd8adb29682d1140b343be4ac0f08fd68b7765d9c059d3945" + +[[package]] +name = "winnow" +version = "1.0.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "23b97319f7b8343df12cc98938e5c3eb436064524c8d2b4e30a1d3a36eecdf81" + [[package]] name = "winreg" version = "0.10.1" diff --git a/Cargo.toml b/Cargo.toml index 89f1c8e..c13d9a9 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ regex = "1.12.4" serde = { version = "1.0.228", features = ["derive"] } serde_json = "1.0.150" sha2 = "0.10.9" +toml = "0.9" shell-use = { path = "crates/shell-use" } ttf-parser = { version = "0.25.1", default-features = false, features = ["std"] } diff --git a/README.md b/README.md index ac6b08a..27a36a2 100644 --- a/README.md +++ b/README.md @@ -161,7 +161,7 @@ prints a session's effective timeouts. | Command | Description | | ------------------------------------------------------------ | ------------------------------------------- | -| `open [--shell S] [--cols N --rows N] [--cwd D] [--env K=V] [--timeout- MS]` | Spawn a shell session. | +| `open [--shell S] [--cols N --rows N] [--cwd D] [--env K=V] [--config F] [--profile P] [--timeout- MS]` | Spawn a shell session. | | `run [args...]` | Spawn a session running a program directly. | | `sessions` | List active sessions. | | `close [--all]` | Close the current session (or all). | @@ -295,6 +295,54 @@ Every command returns a stable exit code so an agent can branch on the failure c With `--json`, failures also carry a `"kind"` field (`assertion`/`usage`/`no_session`/`internal`). +## Configuration + +Settings live in a `shell-use.toml` with named profiles. Everything is +optional, so a file only states what it changes: + +```toml +[profiles.default] +scrollback = 10000 # rows kept beyond the visible screen + +[profiles.default.colors] +background = "#000000" +foreground = "#c0c0c0" +cursor = "#c0c0c0" +red = "#800000" # any of the 16 ANSI slots, by name + +[profiles.ci] +scrollback = 500 # inherits the default palette +``` + +```bash +shell-use open # profile "default" +shell-use open --profile ci +shell-use open --config ./other.toml --profile ci +``` + +Looked up nearest first: `./shell-use.toml`, then +`~/.shell-use/shell-use.toml`. `--config` or `SHELL_USE_CONFIG` replaces the +search. Running without a config file is normal; a file that fails to parse is +an error rather than a silent fallback. + +Resolution happens in the CLI, not the daemon — the daemon is long-lived and +shared, so it has no working directory to resolve a project-local config +against. + +### Colors + +A terminal grid stores colour *indices*, not colours. What index 1 looks like +is the profile's choice, and shell-use needs that choice twice: to draw a +screenshot, and to answer `expect --fg "#rrggbb"`. **Both read the same table**, +so a colour an assertion matches is the colour a screenshot paints. + +Only the 16 ANSI slots and the three defaults are configurable. Indices 16-255 +are the xterm colour cube and grey ramp, fixed by the spec, so `--fg 196` means +the same thing in every profile. + +The shipped palette is the classic VGA/xterm one that `TERM=xterm-256color` +promises. + ## Supported shells - bash diff --git a/SKILL.md b/SKILL.md index a0084da..fc185db 100644 --- a/SKILL.md +++ b/SKILL.md @@ -60,7 +60,7 @@ without parsing text: | Command | Description | | --- | --- | -| `open [--shell S] [--cols N] [--rows N] [--cwd D] [--env K=V]...` | Spawn a shell session (auto-starts the daemon). `--env` is repeatable. | +| `open [--shell S] [--cols N] [--rows N] [--cwd D] [--env K=V] [--config F] [--profile P]...` | Spawn a shell session (auto-starts the daemon). `--env` is repeatable. | | `run [args...] [--cols N] [--rows N] [--cwd D] [--env K=V]...` | Spawn a session running a program directly (no shell). | | `sessions` | List active sessions. | | `close [--all]` | Close the current session (or every session with `--all`). | @@ -299,6 +299,28 @@ of `ShellUseError`. On its first call a client also checks that the daemon's version matches the package and raises `VersionMismatchError` if they differ; stop the daemon (`daemon_stop`) so it restarts on the matching binary. +## Configuration + +`shell-use.toml` holds named profiles; `--profile NAME` selects one and +`--config PATH` picks the file. Looked up nearest first: `./shell-use.toml` +then `~/.shell-use/shell-use.toml`. No file is fine; an unparseable one errors. + +```toml +[profiles.ci] +scrollback = 500 + +[profiles.ci.colors] +red = "#ff0000" +``` + +A profile sets `scrollback` (default 10000) and colors: `foreground`, +`background`, `cursor`, and the 16 ANSI slots by name (`red`, `bright_red`, +...). Indices 16-255 are spec-defined and not configurable, so `--fg 196` is +stable across profiles. + +The palette is what a screenshot paints **and** what `expect --fg/--bg` matches +a `#rrggbb` against, so the two always agree. + ## Supported shells & integration `open --shell S` accepts: `bash`, `zsh`, `fish`, `powershell`, `pwsh`, `cmd`, diff --git a/crates/shell-use-cli/src/cli.rs b/crates/shell-use-cli/src/cli.rs index e8b5538..a405e6d 100644 --- a/crates/shell-use-cli/src/cli.rs +++ b/crates/shell-use-cli/src/cli.rs @@ -34,6 +34,28 @@ impl From for Shell { } } +/// Which terminal profile a session runs with. +#[derive(Args, Clone, Default)] +pub struct ProfileArgs { + /// Config file to read (default: ./shell-use.toml, then + /// ~/.shell-use/shell-use.toml). + #[arg(long, value_name = "PATH")] + pub config: Option, + /// Named profile from the config file (default: `default`). + #[arg(long, value_name = "NAME")] + pub profile: Option, +} + +impl ProfileArgs { + /// Resolve to concrete settings. Done here, in the client, because the + /// daemon is long-lived and shared and so has no working directory to + /// resolve a project-local config against. + pub fn resolve(&self) -> anyhow::Result { + let cwd = std::env::current_dir().unwrap_or_else(|_| std::path::PathBuf::from(".")); + shell_use::profile::resolve(self.config.as_deref(), self.profile.as_deref(), &cwd) + } +} + /// Per-class default timeouts for a session, in milliseconds. #[derive(Args, Clone, Copy, Default)] pub struct TimeoutArgs { @@ -114,6 +136,8 @@ pub enum Command { #[arg(long, conflicts_with = "wait_ready")] no_wait_ready: bool, #[command(flatten)] + profile: ProfileArgs, + #[command(flatten)] timeouts: TimeoutArgs, }, /// Spawn a session running a program directly. @@ -143,6 +167,8 @@ pub enum Command { #[arg(long, conflicts_with = "wait_ready")] no_wait_ready: bool, #[command(flatten)] + profile: ProfileArgs, + #[command(flatten)] timeouts: TimeoutArgs, }, /// Close the current session (or all sessions). diff --git a/crates/shell-use-cli/src/main.rs b/crates/shell-use-cli/src/main.rs index 245e401..4575398 100644 --- a/crates/shell-use-cli/src/main.rs +++ b/crates/shell-use-cli/src/main.rs @@ -132,10 +132,12 @@ fn build_request(command: Command) -> anyhow::Result { env, wait_ready, no_wait_ready, + profile, timeouts, } => Request::Open { shell: shell.map(Into::into), program: None, + profile: profile.resolve()?, cols, rows, cwd, @@ -152,6 +154,7 @@ fn build_request(command: Command) -> anyhow::Result { env, wait_ready, no_wait_ready, + profile, timeouts, } => { let mut prog = vec![program]; @@ -159,6 +162,7 @@ fn build_request(command: Command) -> anyhow::Result { Request::Open { shell: None, program: Some(prog), + profile: profile.resolve()?, cols, rows, cwd, diff --git a/crates/shell-use-cli/tests/session_lifecycle.rs b/crates/shell-use-cli/tests/session_lifecycle.rs index 7e2e6f0..67e95e7 100644 --- a/crates/shell-use-cli/tests/session_lifecycle.rs +++ b/crates/shell-use-cli/tests/session_lifecycle.rs @@ -251,6 +251,98 @@ fn a_session_timeout_default_applies_to_later_commands() { ); } +/// The color a screenshot paints is the color an assertion matches. +/// +/// These came from two separate hardcoded tables that disagreed on every ANSI +/// slot, so `expect --fg "#800000"` passed on a cell the screenshot painted +/// `#e88388`. Both now resolve through the session profile, and this drives the +/// whole path — daemon, renderer, assertion — rather than the resolver alone. +#[test] +fn a_screenshot_and_an_assertion_agree_on_a_color() { + let sandbox = Sandbox::new("palette-agree"); + // Printed lowercase so the match is the output, not the echoed command. + let print_red = r#"printf "\033[31m%s\033[0m\n" "$(echo QRSX | tr A-Z a-z)""#; + sandbox.ok(&["run", "--cols", "44", "--", "bash", "--norc"]); + sandbox.ok(&["submit", print_red]); + sandbox.ok(&["wait", "command"]); + + // The default profile is the VGA palette, so slot 1 is #800000. + sandbox.ok(&["expect", "text", "qrsx", "--fg", "#800000"]); + + let svg = sandbox.home.join("shot.svg"); + let path = svg.to_str().expect("utf-8 path"); + sandbox.ok(&["screenshot", "--out", path]); + let drawing = std::fs::read_to_string(&svg).expect("read screenshot"); + assert!( + drawing.contains("fill=\"#800000\""), + "the screenshot must paint the color the assertion matched" + ); +} + +/// A profile's palette drives both, so recoloring a slot moves the screenshot +/// and the assertion together. +#[test] +fn a_custom_profile_recolors_screenshots_and_assertions_together() { + let sandbox = Sandbox::new("palette-profile"); + let config = sandbox.home.join("custom.toml"); + std::fs::write(&config, "[profiles.neon.colors]\nred = \"#ff00ff\"\n").expect("write config"); + let config_path = config.to_str().expect("utf-8 path"); + + let print_red = r#"printf "\033[31m%s\033[0m\n" "$(echo QRSX | tr A-Z a-z)""#; + sandbox.ok(&[ + "run", + "--config", + config_path, + "--profile", + "neon", + "--cols", + "44", + "--", + "bash", + "--norc", + ]); + sandbox.ok(&["submit", print_red]); + sandbox.ok(&["wait", "command"]); + + sandbox.ok(&["expect", "text", "qrsx", "--fg", "#ff00ff"]); + let out = sandbox.run(&["expect", "text", "qrsx", "--fg", "#800000"]); + assert!( + !out.status.success(), + "the profile replaced the default red, so the default must no longer match" + ); + + let svg = sandbox.home.join("neon.svg"); + let path = svg.to_str().expect("utf-8 path"); + sandbox.ok(&["screenshot", "--out", path]); + let drawing = std::fs::read_to_string(&svg).expect("read screenshot"); + assert!( + drawing.contains("fill=\"#ff00ff\""), + "the screenshot follows the profile too" + ); +} + +/// A profile that does not exist is an error naming the ones that do, rather +/// than a session that silently ran with the defaults. +#[test] +fn an_unknown_profile_is_rejected() { + let sandbox = Sandbox::new("palette-unknown"); + let config = sandbox.home.join("c.toml"); + std::fs::write(&config, "[profiles.ci]\n").expect("write config"); + let out = sandbox.run(&[ + "open", + "--config", + config.to_str().expect("utf-8 path"), + "--profile", + "nope", + ]); + assert!(!out.status.success(), "an unknown profile must not open"); + let msg = String::from_utf8_lossy(&out.stderr) + String::from_utf8_lossy(&out.stdout); + assert!( + msg.contains("ci"), + "the error should name the real profile: {msg}" + ); +} + #[test] fn state_reports_effective_timeouts() { let sandbox = Sandbox::new("state-timeouts"); diff --git a/crates/shell-use/Cargo.toml b/crates/shell-use/Cargo.toml index 4871ebb..2caea64 100644 --- a/crates/shell-use/Cargo.toml +++ b/crates/shell-use/Cargo.toml @@ -24,4 +24,5 @@ regex.workspace = true serde.workspace = true serde_json.workspace = true sha2.workspace = true +toml.workspace = true ttf-parser.workspace = true diff --git a/crates/shell-use/src/assert/color.rs b/crates/shell-use/src/assert/color.rs index 8ebf828..56105ef 100644 --- a/crates/shell-use/src/assert/color.rs +++ b/crates/shell-use/src/assert/color.rs @@ -1,6 +1,7 @@ //! Color parsing and comparison for `expect --fg/--bg`. use super::super::terminal::cell::Color; +use crate::profile::Colors; /// The spelling of [`Expected::Default`], on the command line and in messages. pub const DEFAULT: &str = "default"; @@ -66,80 +67,35 @@ fn parse_hex(hex: &str) -> anyhow::Result<(u8, u8, u8)> { /// Does a cell's resolved color match the expected color? /// /// A cell that set no color of its own matches only `default`. It cannot match -/// a concrete value, because which value it paints is the viewer's theme's -/// choice and not something the grid knows. -pub fn matches(cell: Option, expected: &Expected) -> bool { +/// a concrete value: which value it paints is the profile's choice, and the +/// grid only records that the cell chose nothing. +/// +/// A concrete `#rrggbb` is resolved through the session profile, the same table +/// the screenshot renderer draws with. These used to be two separate hardcoded +/// tables that disagreed on every ANSI slot, so `expect --fg "#800000"` passed +/// on a cell a screenshot painted `#e88388`. +pub fn matches(cell: Option, expected: &Expected, colors: &Colors) -> bool { let Some(cell) = cell else { return matches!(expected, Expected::Default); }; match expected { Expected::Default => false, Expected::Ansi256(n) => cell.to_index() == *n, - Expected::Hex(er, eg, eb) | Expected::Rgb(er, eg, eb) => rgb_of(cell) == (*er, *eg, *eb), - } -} - -fn rgb_of(c: Color) -> (u8, u8, u8) { - match c { - Color::Rgb(r, g, b) => (r, g, b), - c => ansi256_to_rgb(c.to_index()), + Expected::Hex(er, eg, eb) | Expected::Rgb(er, eg, eb) => { + let got = colors.resolve(Some(cell), true); + (got.r, got.g, got.b) == (*er, *eg, *eb) + } } } /// Render a cell's color in the same space as the expected value, for messages. -pub fn describe_cell(cell: Option, expected: &Expected) -> String { +pub fn describe_cell(cell: Option, expected: &Expected, colors: &Colors) -> String { let Some(cell) = cell else { return DEFAULT.to_string(); }; match expected { Expected::Default | Expected::Ansi256(_) => cell.to_index().to_string(), - _ => { - let (r, g, b) = rgb_of(cell); - format!("#{r:02x}{g:02x}{b:02x}") - } - } -} - -const ANSI16: [(u8, u8, u8); 16] = [ - (0, 0, 0), - (128, 0, 0), - (0, 128, 0), - (128, 128, 0), - (0, 0, 128), - (128, 0, 128), - (0, 128, 128), - (192, 192, 192), - (128, 128, 128), - (255, 0, 0), - (0, 255, 0), - (255, 255, 0), - (0, 0, 255), - (255, 0, 255), - (0, 255, 255), - (255, 255, 255), -]; - -pub fn ansi256_to_rgb(n: u8) -> (u8, u8, u8) { - match n { - 0..=15 => ANSI16[n as usize], - 16..=231 => { - let i = n as u16 - 16; - let r = (i / 36) % 6; - let g = (i / 6) % 6; - let b = i % 6; - let conv = |c: u16| -> u8 { - if c == 0 { - 0 - } else { - (c * 40 + 55) as u8 - } - }; - (conv(r), conv(g), conv(b)) - } - 232..=255 => { - let v = (n as u16 - 232) * 10 + 8; - (v as u8, v as u8, v as u8) - } + _ => colors.resolve(Some(cell), true).to_hex(), } } @@ -188,13 +144,15 @@ mod tests { #[test] fn matches_palette_and_default() { + let c = Colors::default(); let idx = |i| Some(Color::from_index(i)); - assert!(matches(idx(9), &Expected::Ansi256(9))); - assert!(!matches(idx(2), &Expected::Ansi256(9))); - assert!(matches(idx(196), &Expected::Ansi256(196))); + assert!(matches(idx(9), &Expected::Ansi256(9), &c)); + assert!(!matches(idx(2), &Expected::Ansi256(9), &c)); + assert!(matches(idx(196), &Expected::Ansi256(196), &c)); assert!(matches( Some(Color::Rgb(255, 0, 0)), - &Expected::Rgb(255, 0, 0) + &Expected::Rgb(255, 0, 0), + &c )); } @@ -203,17 +161,19 @@ mod tests { /// `default` keyword, which is the way to assert on it. #[test] fn default_color_matches_only_default() { - assert!(!matches(None, &Expected::Ansi256(0))); - assert!(!matches(None, &Expected::Hex(0, 0, 0))); - assert!(matches(None, &Expected::Default)); - assert_eq!(describe_cell(None, &Expected::Ansi256(0)), "default"); + let c = Colors::default(); + assert!(!matches(None, &Expected::Ansi256(0), &c)); + assert!(!matches(None, &Expected::Hex(0, 0, 0), &c)); + assert!(matches(None, &Expected::Default, &c)); + assert_eq!(describe_cell(None, &Expected::Ansi256(0), &c), "default"); } #[test] fn a_colored_cell_is_not_default() { + let c = Colors::default(); let red = Some(Color::from_index(1)); - assert!(!matches(red, &Expected::Default)); - assert!(matches(red, &Expected::Ansi256(1))); + assert!(!matches(red, &Expected::Default, &c)); + assert!(matches(red, &Expected::Ansi256(1), &c)); assert!(matches!( Expected::parse("default").unwrap(), Expected::Default @@ -222,12 +182,44 @@ mod tests { Expected::parse("DEFAULT").unwrap(), Expected::Default )); - assert_eq!(describe_cell(red, &Expected::Default), "1"); + assert_eq!(describe_cell(red, &Expected::Default, &c), "1"); + } + + /// The regression test for the bug this module used to carry: the color a + /// screenshot paints and the color an assertion matches are now the same + /// value for every slot, because both come from the profile. + #[test] + fn an_assertion_matches_the_color_a_screenshot_paints() { + let colors = Colors::default(); + for index in 0u8..=255 { + let cell = Some(Color::from_index(index)); + let painted = colors.resolve(cell, true); + assert!( + matches( + cell, + &Expected::Hex(painted.r, painted.g, painted.b), + &colors + ), + "slot {index} paints {} but does not match it", + painted.to_hex() + ); + } } + /// A profile's palette is what an assertion compares against, so two + /// profiles genuinely disagree rather than sharing one hardcoded table. #[test] - fn ansi256_cube_roundtrip() { - let (r, g, b) = ansi256_to_rgb(196); - assert_eq!((r, g, b), (255, 0, 0)); + fn a_recolored_profile_moves_what_an_assertion_matches() { + let colors = Colors { + red: crate::profile::Rgb::new(1, 2, 3), + ..Default::default() + }; + let red = Some(Color::from_index(1)); + assert!(matches(red, &Expected::Hex(1, 2, 3), &colors)); + assert!(!matches(red, &Expected::Hex(128, 0, 0), &colors)); + assert!( + matches(red, &Expected::Ansi256(1), &colors), + "the index is unaffected by the palette" + ); } } diff --git a/crates/shell-use/src/engine.rs b/crates/shell-use/src/engine.rs index 33d418f..34d55ff 100644 --- a/crates/shell-use/src/engine.rs +++ b/crates/shell-use/src/engine.rs @@ -48,6 +48,7 @@ fn req_summary(req: &Request) -> String { Request::Open { shell, program, + profile, cols, rows, cwd, @@ -55,7 +56,8 @@ fn req_summary(req: &Request) -> String { wait_ready, timeouts, } => format!( - "Open {{ shell: {shell:?}, program: {program:?}, {cols}x{rows}, cwd: {cwd:?}, wait_ready: {wait_ready:?}, timeouts: {timeouts:?}, env: <{} vars> }}", + "Open {{ shell: {shell:?}, program: {program:?}, scrollback: {}, {cols}x{rows}, cwd: {cwd:?}, wait_ready: {wait_ready:?}, timeouts: {timeouts:?}, env: <{} vars> }}", + profile.scrollback, env.len() ), other => format!("{other:?}"), @@ -88,6 +90,7 @@ impl Engine { Request::Open { shell, program, + profile, cols, rows, cwd, @@ -95,7 +98,9 @@ impl Engine { wait_ready, timeouts, } => ( - self.open(shell, program, cols, rows, cwd, env, wait_ready, timeouts), + self.open( + shell, program, profile, cols, rows, cwd, env, wait_ready, timeouts, + ), false, ), Request::Close => { @@ -115,6 +120,7 @@ impl Engine { &self, shell: Option, program: Option>, + profile: crate::profile::Profile, cols: u16, rows: u16, cwd: Option, @@ -129,6 +135,7 @@ impl Engine { match Session::open( shell, program.clone(), + profile, cols, rows, cwd, @@ -720,7 +727,7 @@ fn expect_text( let ok = poll_until( || match locator::find(&grid(s, full), &pattern, strict) { Ok(Some(cells)) if !cells.is_empty() => { - if let Some(err) = check_colors(&cells, &fg, &bg, not) { + if let Some(err) = check_colors(&cells, &fg, &bg, not, &s.profile.colors) { last_err = Some(err); false } else { @@ -750,17 +757,18 @@ fn check_colors( fg: &Option, bg: &Option, not: bool, + colors: &crate::profile::Colors, ) -> Option { let want = !not; if let Some(spec) = fg { let expected = Expected::parse(spec).ok()?; for c in cells { - if color::matches(c.cell.fg, &expected) != want { + if color::matches(c.cell.fg, &expected, colors) != want { return Some(format!( "expected fg {} {}, found {} in cell '{}' at {},{}", if not { "absent" } else { "present" }, expected.describe(), - color::describe_cell(c.cell.fg, &expected), + color::describe_cell(c.cell.fg, &expected, colors), c.cell.ch, c.x, c.y @@ -771,12 +779,12 @@ fn check_colors( if let Some(spec) = bg { let expected = Expected::parse(spec).ok()?; for c in cells { - if color::matches(c.cell.bg, &expected) != want { + if color::matches(c.cell.bg, &expected, colors) != want { return Some(format!( "expected bg {} {}, found {} in cell '{}' at {},{}", if not { "absent" } else { "present" }, expected.describe(), - color::describe_cell(c.cell.bg, &expected), + color::describe_cell(c.cell.bg, &expected, colors), c.cell.ch, c.x, c.y @@ -862,7 +870,7 @@ fn screenshot(s: &Session, full: bool, path: Option) -> Response { let rows = grid(s, full); match path { Some(path) => { - let svg = crate::render::svg::render_svg(&rows, s.cols); + let svg = crate::render::svg::render_svg(&rows, s.cols, &s.profile.colors); match std::fs::write(&path, svg) { Ok(()) => Response::with(json!({ "path": path })), Err(e) => Response::internal(e.to_string()), diff --git a/crates/shell-use/src/lib.rs b/crates/shell-use/src/lib.rs index b9d041b..4053554 100644 --- a/crates/shell-use/src/lib.rs +++ b/crates/shell-use/src/lib.rs @@ -3,6 +3,7 @@ pub mod config; pub mod engine; pub mod input; pub mod logger; +pub mod profile; pub mod protocol; pub mod render; pub mod runtime; diff --git a/crates/shell-use/src/profile.rs b/crates/shell-use/src/profile.rs new file mode 100644 index 0000000..ec98799 --- /dev/null +++ b/crates/shell-use/src/profile.rs @@ -0,0 +1,522 @@ +//! Terminal profiles: the settings a session runs with. +//! +//! A profile is chosen when a session opens and fixed for its lifetime. It is +//! read from a TOML file so a project can commit the terminal its tests expect, +//! rather than depending on whatever the machine happens to default to. +//! +//! # Colors are resolved here, not by the emulator +//! +//! A terminal grid stores color *indices*, not colors: a cell painted with +//! `SGR 31` records palette slot 1, and what that looks like is the viewer's +//! choice. Nothing in the emulator needs a palette — xterm.js's `theme` option +//! is inert in a headless terminal, and alacritty has no palette at all. +//! +//! shell-use has to make that choice twice: once to draw a screenshot, and once +//! to answer `expect --fg "#rrggbb"`. Those answers have to agree. They used to +//! come from two separate hardcoded tables that disagreed on all sixteen ANSI +//! slots, so `expect --fg "#800000"` passed on a cell the screenshot painted +//! `#e88388`. [`Colors`] is the single table both now read. + +use std::collections::BTreeMap; +use std::path::{Path, PathBuf}; + +use serde::{Deserialize, Deserializer, Serialize, Serializer}; + +use crate::terminal::cell::{Color, NamedColor}; + +/// Rows of scrollback a profile retains when it does not say otherwise. +/// +/// The emulators do not agree on their own defaults (alacritty 10,000, +/// xterm.js 1,000), so this is always set explicitly rather than inherited. +pub const DEFAULT_SCROLLBACK: usize = 10_000; + +/// The file a profile is read from, under the config directory. +pub const CONFIG_FILE: &str = "shell-use.toml"; + +/// The profile used when none is named. +pub const DEFAULT_PROFILE: &str = "default"; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub struct Rgb { + pub r: u8, + pub g: u8, + pub b: u8, +} + +impl Rgb { + pub const fn new(r: u8, g: u8, b: u8) -> Self { + Rgb { r, g, b } + } + + pub fn to_hex(self) -> String { + format!("#{:02x}{:02x}{:02x}", self.r, self.g, self.b) + } + + /// Parse `#rgb` or `#rrggbb`. The leading `#` is optional so a TOML value + /// that lost it to a stray quote still reads sensibly. + pub fn parse(s: &str) -> Result { + let hex = s.trim().trim_start_matches('#'); + let read = |i: usize, n: usize| -> Result { + u8::from_str_radix(&hex[i..i + n], 16) + .map(|v| if n == 1 { v * 17 } else { v }) + .map_err(|_| format!("invalid hex color {s:?}")) + }; + match hex.len() { + 3 => Ok(Rgb::new(read(0, 1)?, read(1, 1)?, read(2, 1)?)), + 6 => Ok(Rgb::new(read(0, 2)?, read(2, 2)?, read(4, 2)?)), + _ => Err(format!("color must be #rgb or #rrggbb (got {s:?})")), + } + } +} + +impl Serialize for Rgb { + fn serialize(&self, s: S) -> Result { + s.serialize_str(&self.to_hex()) + } +} + +impl<'de> Deserialize<'de> for Rgb { + fn deserialize>(d: D) -> Result { + let raw = String::deserialize(d)?; + Rgb::parse(&raw).map_err(serde::de::Error::custom) + } +} + +/// The colors a session paints with. +/// +/// Only the sixteen ANSI slots are configurable. Indices 16-255 are the xterm +/// color cube and gray ramp, which are defined by the spec rather than by a +/// theme, so [`Colors::rgb`] computes them instead of storing them. A config +/// that could override them would let two sessions disagree about what +/// `--fg 196` means. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Colors { + /// The color text takes when a cell set none of its own. + pub foreground: Rgb, + /// The color an unpainted cell takes. + pub background: Rgb, + /// Tracked for `OSC 12`; nothing draws a cursor yet. + pub cursor: Rgb, + + pub black: Rgb, + pub red: Rgb, + pub green: Rgb, + pub yellow: Rgb, + pub blue: Rgb, + pub magenta: Rgb, + pub cyan: Rgb, + pub white: Rgb, + pub bright_black: Rgb, + pub bright_red: Rgb, + pub bright_green: Rgb, + pub bright_yellow: Rgb, + pub bright_blue: Rgb, + pub bright_magenta: Rgb, + pub bright_cyan: Rgb, + pub bright_white: Rgb, +} + +impl Default for Colors { + /// The classic VGA/xterm palette, which is what `TERM=xterm-256color` + /// promises and what the assertion layer already compared against. + fn default() -> Self { + Colors { + foreground: Rgb::new(192, 192, 192), + background: Rgb::new(0, 0, 0), + cursor: Rgb::new(192, 192, 192), + + black: Rgb::new(0, 0, 0), + red: Rgb::new(128, 0, 0), + green: Rgb::new(0, 128, 0), + yellow: Rgb::new(128, 128, 0), + blue: Rgb::new(0, 0, 128), + magenta: Rgb::new(128, 0, 128), + cyan: Rgb::new(0, 128, 128), + white: Rgb::new(192, 192, 192), + bright_black: Rgb::new(128, 128, 128), + bright_red: Rgb::new(255, 0, 0), + bright_green: Rgb::new(0, 255, 0), + bright_yellow: Rgb::new(255, 255, 0), + bright_blue: Rgb::new(0, 0, 255), + bright_magenta: Rgb::new(255, 0, 255), + bright_cyan: Rgb::new(0, 255, 255), + bright_white: Rgb::new(255, 255, 255), + } + } +} + +impl Colors { + /// The sixteen ANSI slots, in palette order. + pub fn ansi(&self) -> [Rgb; 16] { + [ + self.black, + self.red, + self.green, + self.yellow, + self.blue, + self.magenta, + self.cyan, + self.white, + self.bright_black, + self.bright_red, + self.bright_green, + self.bright_yellow, + self.bright_blue, + self.bright_magenta, + self.bright_cyan, + self.bright_white, + ] + } + + /// The name a slot goes by in the config file. + pub fn slot_name(index: u8) -> Option<&'static str> { + Some(match NamedColor::from_index(index)? { + NamedColor::Black => "black", + NamedColor::Red => "red", + NamedColor::Green => "green", + NamedColor::Yellow => "yellow", + NamedColor::Blue => "blue", + NamedColor::Magenta => "magenta", + NamedColor::Cyan => "cyan", + NamedColor::White => "white", + NamedColor::BrightBlack => "bright_black", + NamedColor::BrightRed => "bright_red", + NamedColor::BrightGreen => "bright_green", + NamedColor::BrightYellow => "bright_yellow", + NamedColor::BrightBlue => "bright_blue", + NamedColor::BrightMagenta => "bright_magenta", + NamedColor::BrightCyan => "bright_cyan", + NamedColor::BrightWhite => "bright_white", + }) + } + + /// Resolve any 256-color index. + /// + /// Slots 0-15 come from the profile. The color cube (16-231) and gray ramp + /// (232-255) are fixed by the xterm spec and identical under every profile. + pub fn rgb(&self, index: u8) -> Rgb { + match index { + 0..=15 => self.ansi()[index as usize], + 16..=231 => { + let i = index as u16 - 16; + let level = |c: u16| -> u8 { + if c == 0 { + 0 + } else { + (c * 40 + 55) as u8 + } + }; + Rgb::new(level((i / 36) % 6), level((i / 6) % 6), level(i % 6)) + } + 232..=255 => { + let v = ((index as u16 - 232) * 10 + 8) as u8; + Rgb::new(v, v, v) + } + } + } + + /// Resolve a cell's color, where `None` is the terminal default. + /// + /// This is the one function both the screenshot renderer and `expect + /// --fg/--bg` call, which is what keeps them agreeing. + pub fn resolve(&self, color: Option, is_fg: bool) -> Rgb { + match color { + None => { + if is_fg { + self.foreground + } else { + self.background + } + } + Some(Color::Named(n)) => self.rgb(n.index()), + Some(Color::Idx(i)) => self.rgb(i), + Some(Color::Rgb(r, g, b)) => Rgb::new(r, g, b), + } + } +} + +/// The settings a session runs with. +#[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct Profile { + /// Rows retained beyond the visible screen. + pub scrollback: usize, + pub colors: Colors, +} + +impl Default for Profile { + fn default() -> Self { + Profile { + scrollback: DEFAULT_SCROLLBACK, + colors: Colors::default(), + } + } +} + +/// A parsed config file: named profiles, and nothing else. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +#[serde(default, deny_unknown_fields)] +pub struct ConfigFile { + pub profiles: BTreeMap, +} + +impl ConfigFile { + pub fn parse(toml_text: &str) -> anyhow::Result { + Ok(toml::from_str(toml_text)?) + } + + pub fn load(path: &Path) -> anyhow::Result { + let text = std::fs::read_to_string(path) + .map_err(|e| anyhow::anyhow!("could not read {}: {e}", path.display()))?; + Self::parse(&text).map_err(|e| anyhow::anyhow!("{}: {e}", path.display())) + } + + /// The named profile, or the built-in defaults when nothing is named and + /// the file defines no `default`. + pub fn profile(&self, name: Option<&str>) -> anyhow::Result { + match name { + Some(name) => self.profiles.get(name).copied().ok_or_else(|| { + let known: Vec<&str> = self.profiles.keys().map(String::as_str).collect(); + if known.is_empty() { + anyhow::anyhow!("no profile {name:?}; the config file defines none") + } else { + anyhow::anyhow!("no profile {name:?}; found: {}", known.join(", ")) + } + }), + None => Ok(self + .profiles + .get(DEFAULT_PROFILE) + .copied() + .unwrap_or_default()), + } + } +} + +/// Where a config file is looked for, nearest first. +/// +/// A project-local file wins so a repository can pin the terminal its tests +/// expect. `SHELL_USE_CONFIG` overrides both, which is also how a test suite +/// pins a config without depending on the working directory. +pub fn search_paths(cwd: &Path) -> Vec { + if let Ok(explicit) = std::env::var("SHELL_USE_CONFIG") { + return vec![PathBuf::from(explicit)]; + } + vec![ + cwd.join(CONFIG_FILE), + crate::config::home_dir().join(CONFIG_FILE), + ] +} + +/// Resolve a profile: an explicit file if given, else the first file found on +/// the search path, else the built-in defaults. +/// +/// A missing file is not an error — shell-use runs without one. A file that +/// exists but does not parse *is* an error, because silently ignoring it would +/// run the session with settings the user did not ask for. +pub fn resolve( + explicit_config: Option<&Path>, + profile_name: Option<&str>, + cwd: &Path, +) -> anyhow::Result { + if let Some(path) = explicit_config { + return ConfigFile::load(path)?.profile(profile_name); + } + for path in search_paths(cwd) { + if path.is_file() { + return ConfigFile::load(&path)?.profile(profile_name); + } + } + match profile_name { + Some(name) => anyhow::bail!("no profile {name:?}: no config file found"), + None => Ok(Profile::default()), + } +} + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn hex_colors_round_trip() { + for raw in ["#000000", "#ffffff", "#800000", "#c0c0c0"] { + assert_eq!(Rgb::parse(raw).unwrap().to_hex(), raw); + } + assert_eq!(Rgb::parse("#f00").unwrap(), Rgb::new(255, 0, 0)); + assert_eq!(Rgb::parse("800000").unwrap(), Rgb::new(128, 0, 0)); + } + + #[test] + fn a_bad_color_says_what_it_wanted() { + for raw in ["", "#12", "#1234567", "nope", "#gggggg"] { + let err = Rgb::parse(raw).unwrap_err(); + assert!( + err.contains("color") || err.contains("hex"), + "{raw:?}: {err}" + ); + } + } + + /// A profile that says nothing is the built-in default, so a config file is + /// never required. + #[test] + fn an_empty_config_yields_the_defaults() { + let cfg = ConfigFile::parse("").unwrap(); + assert_eq!(cfg.profile(None).unwrap(), Profile::default()); + assert_eq!(Profile::default().scrollback, 10_000); + } + + /// Every field is individually optional, so a profile can set one color + /// without restating the palette. + #[test] + fn a_partial_profile_keeps_the_other_defaults() { + let cfg = ConfigFile::parse( + r##" + [profiles.ci] + scrollback = 50 + + [profiles.ci.colors] + red = "#ff0000" + "##, + ) + .unwrap(); + let p = cfg.profile(Some("ci")).unwrap(); + assert_eq!(p.scrollback, 50); + assert_eq!(p.colors.red, Rgb::new(255, 0, 0), "the override applies"); + assert_eq!( + p.colors.green, + Colors::default().green, + "an unset slot keeps its default" + ); + assert_eq!( + p.colors.background, + Colors::default().background, + "an unset default color is untouched" + ); + } + + #[test] + fn an_unknown_profile_names_the_ones_that_exist() { + let cfg = ConfigFile::parse("[profiles.ci]\n[profiles.demo]\n").unwrap(); + let err = cfg.profile(Some("nope")).unwrap_err().to_string(); + assert!(err.contains("ci") && err.contains("demo"), "{err}"); + } + + /// A typo in a key is an error rather than a setting that silently does + /// nothing. + #[test] + fn an_unknown_key_is_rejected() { + let err = ConfigFile::parse("[profiles.ci]\nscrollbacks = 10\n") + .unwrap_err() + .to_string(); + assert!(err.contains("scrollbacks"), "{err}"); + } + + /// Above the sixteen configurable slots the palette is spec, not + /// preference, so profiles cannot disagree about what `--fg 196` means. + #[test] + fn the_color_cube_ignores_the_profile() { + let recolored = Colors { + red: Rgb::new(1, 2, 3), + ..Default::default() + }; + for n in 16u8..=255 { + assert_eq!(recolored.rgb(n), Colors::default().rgb(n), "index {n}"); + } + assert_eq!(Colors::default().rgb(196), Rgb::new(255, 0, 0)); + assert_eq!(Colors::default().rgb(232), Rgb::new(8, 8, 8)); + assert_eq!(recolored.rgb(1), Rgb::new(1, 2, 3), "but slot 1 follows it"); + } + + /// Every configurable slot is reachable by the name the file uses, so the + /// documented key set and the resolver cannot drift apart. + #[test] + fn every_ansi_slot_has_a_config_key() { + for i in 0u8..16 { + let name = Colors::slot_name(i).unwrap_or_else(|| panic!("slot {i} unnamed")); + let toml = format!("[profiles.p.colors]\n{name} = \"#010203\"\n"); + let p = ConfigFile::parse(&toml) + .unwrap() + .profile(Some("p")) + .unwrap(); + assert_eq!( + p.colors.rgb(i), + Rgb::new(1, 2, 3), + "setting {name:?} must move slot {i}" + ); + } + assert_eq!(Colors::slot_name(16), None, "only 0-15 are configurable"); + } + + #[test] + fn a_cell_that_set_no_color_takes_the_profile_default() { + let c = Colors::default(); + assert_eq!(c.resolve(None, true), c.foreground); + assert_eq!(c.resolve(None, false), c.background); + assert_eq!( + c.resolve(Some(Color::Rgb(1, 2, 3)), true), + Rgb::new(1, 2, 3), + "a true-color cell is itself whatever the profile says" + ); + } + + /// A project-local file wins over the user's, so a repository can pin the + /// terminal its tests expect. `SHELL_USE_CONFIG` overrides both. + #[test] + fn the_search_order_puts_the_project_first() { + static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(()); + let _guard = ENV_LOCK.lock().unwrap(); + + let old = std::env::var_os("SHELL_USE_CONFIG"); + std::env::remove_var("SHELL_USE_CONFIG"); + let cwd = Path::new("/tmp/some-project"); + let result = std::panic::catch_unwind(|| { + let paths = search_paths(cwd); + assert_eq!(paths.len(), 2); + assert_eq!(paths[0], cwd.join(CONFIG_FILE), "the project file is first"); + assert!( + paths[1].ends_with(CONFIG_FILE) && paths[1] != paths[0], + "the user file is second: {:?}", + paths[1] + ); + + std::env::set_var("SHELL_USE_CONFIG", "/tmp/pinned.toml"); + let pinned = search_paths(cwd); + assert_eq!( + pinned, + vec![PathBuf::from("/tmp/pinned.toml")], + "an explicit config replaces the search entirely" + ); + }); + std::env::remove_var("SHELL_USE_CONFIG"); + if let Some(value) = old { + std::env::set_var("SHELL_USE_CONFIG", value); + } + result.unwrap(); + } + + /// Running without a config file is normal, so a missing one is not an + /// error. A file that exists but does not parse is, because ignoring it + /// would silently run with settings nobody asked for. + #[test] + fn a_missing_config_defaults_but_a_broken_one_fails() { + let dir = std::env::temp_dir().join(format!("su-profile-{}", std::process::id())); + std::fs::create_dir_all(&dir).unwrap(); + + let missing = dir.join("absent.toml"); + assert!( + resolve(Some(&missing), None, &dir).is_err(), + "named-but-absent is an error" + ); + + let broken = dir.join("broken.toml"); + std::fs::write(&broken, "[profiles.ci]\nscrollback = \"lots\"\n").unwrap(); + let err = resolve(Some(&broken), None, &dir).unwrap_err().to_string(); + assert!( + err.contains("broken.toml"), + "the error names the file: {err}" + ); + + std::fs::remove_dir_all(&dir).ok(); + } +} diff --git a/crates/shell-use/src/protocol.rs b/crates/shell-use/src/protocol.rs index 78d96df..8d1e154 100644 --- a/crates/shell-use/src/protocol.rs +++ b/crates/shell-use/src/protocol.rs @@ -35,6 +35,12 @@ pub enum Request { Open { shell: Option, program: Option>, + /// Terminal settings, already resolved from the config file by the + /// client. The daemon never reads that file: it is long-lived and + /// shared, so it has no single working directory to resolve a + /// project-local config against. + #[serde(default)] + profile: crate::profile::Profile, cols: u16, rows: u16, cwd: Option, @@ -293,6 +299,7 @@ mod tests { Request::Open { shell: None, program: None, + profile: Default::default(), cols: 80, rows: 30, cwd: None, diff --git a/crates/shell-use/src/render/svg.rs b/crates/shell-use/src/render/svg.rs index 9278220..ddce6a1 100644 --- a/crates/shell-use/src/render/svg.rs +++ b/crates/shell-use/src/render/svg.rs @@ -11,7 +11,8 @@ use std::fmt::Write; use super::nerd_font::NerdFont; -use crate::terminal::cell::{Attrs, Color, EmuCell}; +use crate::profile::{Colors, Rgb}; +use crate::terminal::cell::{Attrs, EmuCell}; const CELL_W: f32 = 10.0; const CELL_H: f32 = 21.0; @@ -24,63 +25,13 @@ const DOT_R: f32 = 7.0; const FONT_STACK: &str = "'Cascadia Code','JetBrains Mono','Fira Code',Menlo,Consolas,'DejaVu Sans Mono',monospace"; -struct Theme { - palette: [(u8, u8, u8); 16], - default_fg: (u8, u8, u8), - default_bg: (u8, u8, u8), +fn hex(c: Rgb) -> String { + c.to_hex() } -impl Default for Theme { - fn default() -> Self { - Theme { - palette: [ - (40, 45, 53), - (232, 131, 136), - (168, 204, 140), - (219, 171, 121), - (113, 190, 242), - (210, 144, 228), - (102, 194, 205), - (185, 191, 202), - (111, 119, 131), - (232, 131, 136), - (168, 204, 140), - (219, 171, 121), - (115, 190, 243), - (210, 144, 227), - (102, 194, 205), - (255, 255, 255), - ], - default_fg: (185, 191, 202), - default_bg: (40, 45, 53), - } - } -} - -impl Theme { - fn resolve(&self, color: Option, is_fg: bool) -> (u8, u8, u8) { - match color { - None => { - if is_fg { - self.default_fg - } else { - self.default_bg - } - } - Some(Color::Named(n)) => self.palette[n.index() as usize], - Some(Color::Idx(i)) => crate::assert::color::ansi256_to_rgb(i), - Some(Color::Rgb(r, g, b)) => (r, g, b), - } - } -} - -fn hex((r, g, b): (u8, u8, u8)) -> String { - format!("#{r:02x}{g:02x}{b:02x}") -} - -fn dim((r, g, b): (u8, u8, u8)) -> (u8, u8, u8) { +fn dim(c: Rgb) -> Rgb { let s = |v: u8| (v as f32 * 0.6) as u8; - (s(r), s(g), s(b)) + Rgb::new(s(c.r), s(c.g), s(c.b)) } static BLANK: EmuCell = EmuCell::blank(); @@ -90,9 +41,9 @@ fn cell_at(row: &[EmuCell], x: usize) -> &EmuCell { } /// Resolved background color for a cell (honoring inverse). -fn bg_of(cell: &EmuCell, theme: &Theme) -> (u8, u8, u8) { - let bg = theme.resolve(cell.bg, false); - let fg = theme.resolve(cell.fg, true); +fn bg_of(cell: &EmuCell, colors: &Colors) -> Rgb { + let bg = colors.resolve(cell.bg, false); + let fg = colors.resolve(cell.fg, true); if cell.has(Attrs::INVERSE) { fg } else { @@ -102,7 +53,7 @@ fn bg_of(cell: &EmuCell, theme: &Theme) -> (u8, u8, u8) { #[derive(PartialEq)] struct Style { - fg: (u8, u8, u8), + fg: Rgb, bold: bool, italic: bool, underline: bool, @@ -110,9 +61,9 @@ struct Style { invisible: bool, } -fn style_of(cell: &EmuCell, theme: &Theme) -> Style { - let mut fg = theme.resolve(cell.fg, true); - let bg = theme.resolve(cell.bg, false); +fn style_of(cell: &EmuCell, colors: &Colors) -> Style { + let mut fg = colors.resolve(cell.fg, true); + let bg = colors.resolve(cell.bg, false); if cell.has(Attrs::INVERSE) { fg = bg; } @@ -151,8 +102,7 @@ fn run_text(row: &[EmuCell], start: usize, end: usize) -> String { } /// Render a grid to a standalone SVG document. -pub fn render_svg(rows: &[Vec], cols: u16) -> String { - let theme = Theme::default(); +pub fn render_svg(rows: &[Vec], cols: u16, colors: &Colors) -> String { let nerd_font = NerdFont::new(rows, FONT_SIZE); let cols = cols as usize; let x0 = MARGIN_X; @@ -169,7 +119,7 @@ pub fn render_svg(rows: &[Vec], cols: u16) -> String { let _ = write!( out, r#""#, - hex(theme.default_bg) + hex(colors.background) ); for (i, dot) in ["#ff5f56", "#ffbd2e", "#27c93f"].iter().enumerate() { let cx = MARGIN_X + 5.0 + i as f32 * 20.0; @@ -183,12 +133,12 @@ pub fn render_svg(rows: &[Vec], cols: u16) -> String { for (y, row) in rows.iter().enumerate() { let mut x = 0; while x < cols { - let bg = bg_of(cell_at(row, x), &theme); + let bg = bg_of(cell_at(row, x), colors); let mut run = 1; - while x + run < cols && bg_of(cell_at(row, x + run), &theme) == bg { + while x + run < cols && bg_of(cell_at(row, x + run), colors) == bg { run += 1; } - if bg != theme.default_bg { + if bg != colors.background { let rx = x0 + x as f32 * CELL_W; let ry = y0 + y as f32 * CELL_H; let rw = run as f32 * CELL_W; @@ -206,9 +156,9 @@ pub fn render_svg(rows: &[Vec], cols: u16) -> String { let baseline = y0 + y as f32 * CELL_H + FONT_BASELINE; let mut x = 0; while x < cols { - let style = style_of(cell_at(row, x), &theme); + let style = style_of(cell_at(row, x), colors); let mut run = 1; - while x + run < cols && style_of(cell_at(row, x + run), &theme) == style { + while x + run < cols && style_of(cell_at(row, x + run), colors) == style { run += 1; } if !style.invisible { @@ -265,6 +215,7 @@ pub fn render_svg(rows: &[Vec], cols: u16) -> String { #[cfg(test)] mod tests { use super::*; + use crate::terminal::cell::Color; fn cell(ch: &str, fg: Option, bg: Option) -> EmuCell { EmuCell { @@ -281,11 +232,14 @@ mod tests { cell("h", Some(Color::from_index(1)), None), cell("i", Some(Color::from_index(1)), None), ]]; - let svg = render_svg(&rows, 2); + let svg = render_svg(&rows, 2, &Colors::default()); assert!(svg.starts_with("")); assert!(svg.contains("textLength")); - assert!(svg.contains(&hex((232, 131, 136)))); + assert!( + svg.contains(&hex(Colors::default().rgb(1))), + "slot 1 is painted with the profile color" + ); assert!(svg.contains(">hi")); assert!(!svg.contains("")); assert!(!svg.contains("<")); } @@ -318,8 +272,11 @@ mod tests { #[test] fn background_run_emitted_for_non_default_bg() { let rows = vec![vec![cell(" ", None, Some(Color::from_index(4)))]]; - let svg = render_svg(&rows, 1); - assert!(svg.contains(&hex((113, 190, 242)))); + let svg = render_svg(&rows, 1, &Colors::default()); + assert!( + svg.contains(&hex(Colors::default().rgb(4))), + "slot 4 is painted with the profile color" + ); } #[test] @@ -330,7 +287,7 @@ mod tests { cell(glyph, None, None), cell("b", None, None), ]]; - let svg = render_svg(&rows, 3); + let svg = render_svg(&rows, 3, &Colors::default()); assert!(svg.contains(r#"")); diff --git a/crates/shell-use/src/session.rs b/crates/shell-use/src/session.rs index 41cbd35..ea5c05a 100644 --- a/crates/shell-use/src/session.rs +++ b/crates/shell-use/src/session.rs @@ -7,6 +7,7 @@ use std::thread::JoinHandle; use std::time::Instant; use crate::logger::Logger; +use crate::profile::Profile; use crate::shell::{self, Shell}; use crate::terminal::alacritty::AlacrittyEmu; use crate::terminal::emu::Emulator; @@ -26,6 +27,8 @@ pub struct TermState { pub struct Session { pub shell: Option, + /// Settings this session was opened with, fixed for its lifetime. + pub profile: Profile, pub cols: u16, pub rows: u16, /// Per-class timeout defaults for the lifetime of this session. @@ -49,6 +52,7 @@ impl Session { pub fn open( shell: Option, program: Option>, + profile: Profile, cols: u16, rows: u16, cwd: Option, @@ -76,7 +80,7 @@ impl Session { }; let state = Arc::new(Mutex::new(TermState { - emu: Box::new(AlacrittyEmu::new(cols, rows, 5_000)), + emu: Box::new(AlacrittyEmu::new(cols, rows, profile.scrollback)), tracker: CommandTracker::new(), last_change: Instant::now(), awaiting_start: None, @@ -139,6 +143,7 @@ impl Session { Ok(Session { shell, + profile, cols, rows, timeouts, diff --git a/crates/shell-use/tests/runtime.rs b/crates/shell-use/tests/runtime.rs index 0044715..df3420a 100644 --- a/crates/shell-use/tests/runtime.rs +++ b/crates/shell-use/tests/runtime.rs @@ -13,6 +13,7 @@ fn named_runtimes_share_a_process_local_terminal() { Request::Open { shell: None, program: None, + profile: Default::default(), cols: DEFAULT_COLS, rows: DEFAULT_ROWS, cwd: None, @@ -75,6 +76,7 @@ fn named_runtimes_share_a_process_local_terminal() { Request::Open { shell: None, program: None, + profile: Default::default(), cols: DEFAULT_COLS, rows: DEFAULT_ROWS, cwd: None, @@ -99,6 +101,7 @@ fn unrelated_session_state_does_not_wait_behind_another_session() { Request::Open { shell: None, program: None, + profile: Default::default(), cols: DEFAULT_COLS, rows: DEFAULT_ROWS, cwd: None,