diff --git a/Cargo.lock b/Cargo.lock index f3fb95b..707fde0 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" @@ -1119,6 +1141,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" @@ -1166,6 +1197,7 @@ dependencies = [ "serde", "serde_json", "sha2", + "toml", "ttf-parser", ] @@ -1355,6 +1387,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" @@ -1533,6 +1604,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 8085310..9ea06ff 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 7a53063..f4ed83d 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). | @@ -297,6 +297,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 d673019..c964dff 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`). | @@ -303,6 +303,28 @@ the applicable [exit-code table](#exit-codes): `ExpectationError` (1), `UsageError` (2), `NoSessionError` (3), and `InternalError` (5), all subclasses of `ShellUseError`. +## 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/bindings/js/native/lib.rs b/bindings/js/native/lib.rs index beabd81..846ebf0 100644 --- a/bindings/js/native/lib.rs +++ b/bindings/js/native/lib.rs @@ -444,6 +444,7 @@ fn open_options(value: Option) -> std::result::Result u16_value(cols, "cols")?, @@ -465,6 +466,7 @@ fn run_options(value: RunOptions) -> std::result::Result 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 a027da8..e2e1e4d 100644 --- a/crates/shell-use-cli/src/main.rs +++ b/crates/shell-use-cli/src/main.rs @@ -133,10 +133,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, @@ -153,6 +155,7 @@ fn build_request(command: Command) -> anyhow::Result { env, wait_ready, no_wait_ready, + profile, timeouts, } => { let mut prog = vec![program]; @@ -160,6 +163,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/src/protocol.rs b/crates/shell-use-cli/src/protocol.rs index b86ccd6..edb2e99 100644 --- a/crates/shell-use-cli/src/protocol.rs +++ b/crates/shell-use-cli/src/protocol.rs @@ -14,6 +14,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: shell_use::profile::Profile, cols: u16, rows: u16, cwd: Option, @@ -132,6 +138,7 @@ impl Request { Request::Open { shell, program, + profile, cols, rows, cwd, @@ -145,6 +152,7 @@ impl Request { .next() .ok_or_else(|| ShellUseError::usage("empty program"))?; Ok(Operation::Run(RunOptions { + profile, program: executable, args: parts.collect(), cols, @@ -156,6 +164,7 @@ impl Request { })) } else { Ok(Operation::Open(OpenOptions { + profile, shell, cols, rows, @@ -341,6 +350,7 @@ mod tests { Request::Open { shell: None, program: None, + profile: Default::default(), cols: 80, rows: 30, cwd: None, 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/api.rs b/crates/shell-use/src/api.rs index 5a593c7..f00eec2 100644 --- a/crates/shell-use/src/api.rs +++ b/crates/shell-use/src/api.rs @@ -30,6 +30,11 @@ impl Timeouts { #[derive(Debug, Clone)] pub struct OpenOptions { pub shell: 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. + pub profile: crate::profile::Profile, pub cols: u16, pub rows: u16, pub cwd: Option, @@ -42,6 +47,7 @@ impl Default for OpenOptions { fn default() -> Self { Self { shell: None, + profile: crate::profile::Profile::default(), cols: crate::config::DEFAULT_COLS, rows: crate::config::DEFAULT_ROWS, cwd: None, @@ -56,6 +62,11 @@ impl Default for OpenOptions { pub struct RunOptions { pub program: String, pub args: Vec, + /// 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. + pub profile: crate::profile::Profile, pub cols: u16, pub rows: u16, pub cwd: Option, 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 a3d3c3c..997abe0 100644 --- a/crates/shell-use/src/engine.rs +++ b/crates/shell-use/src/engine.rs @@ -47,11 +47,14 @@ pub struct LiveFrame { pub shell: Option<&'static str>, } +/// One-line operation description for the verbose log. Open and Run redact env +/// values (they may contain secrets) and report only the variable count. fn operation_summary(operation: &Operation) -> String { match operation { Operation::Open(options) => format!( - "Open {{ shell: {:?}, {}x{}, cwd: {:?}, wait_ready: {:?}, timeouts: {:?}, env: <{} vars> }}", + "Open {{ shell: {:?}, scrollback: {}, {}x{}, cwd: {:?}, wait_ready: {:?}, timeouts: {:?}, env: <{} vars> }}", options.shell, + options.profile.scrollback, options.cols, options.rows, options.cwd, @@ -60,9 +63,10 @@ fn operation_summary(operation: &Operation) -> String { options.env.len() ), Operation::Run(options) => format!( - "Run {{ program: {:?}, args: {:?}, {}x{}, cwd: {:?}, wait_ready: {:?}, timeouts: {:?}, env: <{} vars> }}", + "Run {{ program: {:?}, args: {:?}, scrollback: {}, {}x{}, cwd: {:?}, wait_ready: {:?}, timeouts: {:?}, env: <{} vars> }}", options.program, options.args, + options.profile.scrollback, options.cols, options.rows, options.cwd, @@ -133,6 +137,7 @@ impl Engine { self.spawn( options.shell, None, + options.profile, options.cols, options.rows, options.cwd, @@ -149,6 +154,7 @@ impl Engine { self.spawn( None, Some(program), + options.profile, options.cols, options.rows, options.cwd, @@ -163,6 +169,7 @@ impl Engine { &self, shell: Option, program: Option>, + profile: crate::profile::Profile, cols: u16, rows: u16, cwd: Option, @@ -184,6 +191,7 @@ impl Engine { let session = TerminalSession::open( shell, program.clone(), + profile, cols, rows, cwd, @@ -999,7 +1007,9 @@ fn expect_text( || { matched = match locator::find(&grid(session, full), &pattern, strict) { Ok(Some(cells)) if !cells.is_empty() => { - if let Some(error) = check_colors(&cells, &fg, &bg, not) { + if let Some(error) = + check_colors(&cells, &fg, &bg, not, &session.profile.colors) + { last_error = Some(error); false } else { @@ -1040,17 +1050,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 cell in cells { - if color::matches(cell.cell.fg, &expected) != want { + if color::matches(cell.cell.fg, &expected, colors) != want { return Some(format!( "expected fg {} {}, found {} in cell '{}' at {},{}", if not { "absent" } else { "present" }, expected.describe(), - color::describe_cell(cell.cell.fg, &expected), + color::describe_cell(cell.cell.fg, &expected, colors), cell.cell.ch, cell.x, cell.y @@ -1061,12 +1072,12 @@ fn check_colors( if let Some(spec) = bg { let expected = Expected::parse(spec).ok()?; for cell in cells { - if color::matches(cell.cell.bg, &expected) != want { + if color::matches(cell.cell.bg, &expected, colors) != want { return Some(format!( "expected bg {} {}, found {} in cell '{}' at {},{}", if not { "absent" } else { "present" }, expected.describe(), - color::describe_cell(cell.cell.bg, &expected), + color::describe_cell(cell.cell.bg, &expected, colors), cell.cell.ch, cell.x, cell.y @@ -1166,7 +1177,7 @@ fn screenshot( let rows = grid(session, full); match path { Some(path) => { - let svg = crate::render::svg::render_svg(&rows, session.cols); + let svg = crate::render::svg::render_svg(&rows, session.cols, &session.profile.colors); std::fs::write(&path, svg) .map_err(|error| ShellUseError::internal(error.to_string()))?; Ok(ScreenshotResult::Path(path)) diff --git a/crates/shell-use/src/lib.rs b/crates/shell-use/src/lib.rs index ffa3784..078f6a3 100644 --- a/crates/shell-use/src/lib.rs +++ b/crates/shell-use/src/lib.rs @@ -4,6 +4,7 @@ pub mod config; pub mod engine; pub mod input; pub mod logger; +pub mod profile; pub mod render; pub mod runtime; pub mod shell; 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/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 4be0396..757af55 100644 --- a/crates/shell-use/src/session.rs +++ b/crates/shell-use/src/session.rs @@ -8,6 +8,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; @@ -27,6 +28,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. @@ -51,6 +54,7 @@ impl Session { pub fn open( shell: Option, program: Option>, + profile: Profile, cols: u16, rows: u16, cwd: Option, @@ -78,7 +82,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, @@ -149,6 +153,7 @@ impl Session { Ok(Session { shell, + profile, cols, rows, timeouts,