diff --git a/SKILL.md b/SKILL.md index b24390a..07a7d69 100644 --- a/SKILL.md +++ b/SKILL.md @@ -327,6 +327,12 @@ stable across profiles. The palette is what a screenshot paints **and** what `expect --fg/--bg` matches a `#rrggbb` against, so the two always agree. +Programs can also set and query colours at runtime with `OSC 4/10/11/12` and +reset them with `OSC 104/110/111/112`. A query is answered with the colour +currently showing; a reset restores the profile's colour, which no escape +sequence can change. Note that a program setting a colour also changes what a +screenshot of that session looks like. + ## Supported shells & integration `open --shell S` accepts: `bash`, `zsh`, `fish`, `powershell`, `pwsh`, `cmd`, diff --git a/crates/shell-use-cli/src/monitor.rs b/crates/shell-use-cli/src/monitor.rs index c2e2eb9..729ba34 100644 --- a/crates/shell-use-cli/src/monitor.rs +++ b/crates/shell-use-cli/src/monitor.rs @@ -395,7 +395,7 @@ mod tests { ]; for want in styles { - let mut emu = AlacrittyEmu::new(10, 2, 0); + let mut emu = AlacrittyEmu::new(10, 2, &shell_use::profile::Profile::default()); emu.process(want.sgr().as_bytes()); emu.process(b"x"); let got = Style::from(&emu.viewable_rows()[0][0]); diff --git a/crates/shell-use-cli/tests/session_lifecycle.rs b/crates/shell-use-cli/tests/session_lifecycle.rs index fb18594..4d0eb69 100644 --- a/crates/shell-use-cli/tests/session_lifecycle.rs +++ b/crates/shell-use-cli/tests/session_lifecycle.rs @@ -343,6 +343,111 @@ fn an_unknown_profile_is_rejected() { ); } +/// A program that asks the terminal what color it is gets an answer. +/// +/// This is how tools decide whether they are on a light or a dark background. +/// A terminal that stays silent leaves them blocked until they time out and +/// guess, so this drives the whole path: daemon, emulator, and the reply on +/// its way back up the PTY. +/// +/// Unix only, because the probe has to put its own terminal in raw mode to +/// read a reply that arrives without a newline and must not be echoed, and +/// `termios` does not exist on Windows CPython. The reply itself is not +/// platform specific: how it is formatted is covered by conformance cases +/// that run against every backend, and the write that carries it to the child +/// is the same `pty.write` every `type` and `submit` on Windows already uses. +#[cfg(unix)] +#[test] +fn a_color_query_is_answered_over_the_pty() { + let sandbox = Sandbox::new("osc-query"); + let probe = sandbox.home.join("probe.py"); + std::fs::write( + &probe, + r#" +import os, sys, termios, tty, select + +# Unbuffered reads: a buffered reader would take bytes off the fd that +# select() then cannot see, and the reply would look truncated. +def ask(fd, query): + os.write(1, query) + buf = b"" + while select.select([fd], [], [], 2.0)[0]: + buf += os.read(fd, 64) + if buf.endswith(b"\x07"): + break + return buf.decode("utf8", "replace") + +fd = sys.stdin.fileno() +old = termios.tcgetattr(fd) +try: + tty.setraw(fd) + configured = ask(fd, b"\x1b]11;?\x07") + # Every dynamic colour, not just the background: a program that sets the + # foreground and cursor has to be answered about those too. + os.write(1, b"\x1b]10;#abcdef\x07\x1b]11;#654321\x07\x1b]12;#fedcba\x07") + fg = ask(fd, b"\x1b]10;?\x07") + overridden = ask(fd, b"\x1b]11;?\x07") + cursor = ask(fd, b"\x1b]12;?\x07") + os.write(1, b"\x1b]111\x07") + restored = ask(fd, b"\x1b]11;?\x07") +finally: + termios.tcsetattr(fd, termios.TCSADRAIN, old) + +strip = lambda s: s.replace("\x1b", "").replace("\x07", "") +print("\r\nRESULT %s %s %s %s %s\r" % ( + strip(configured), strip(fg), strip(overridden), strip(cursor), strip(restored))) +"#, + ) + .expect("write probe"); + + // Wide enough that the report is one unwrapped line: `text` returns the + // grid, so a wrapped reply would be split across rows. + sandbox.ok(&["run", "--cols", "200", "--", "bash", "--norc"]); + sandbox.ok(&[ + "submit", + &format!("python3 {}", probe.to_str().expect("utf-8 path")), + ]); + // Wait for the line this test reads, not for the command. + // + // The probe prints nothing until it is done: its queries go to the + // terminal, which answers them rather than echoing them, so the screen + // stays unchanged for as long as python takes to start. `bash --norc` has + // no shell integration, so `wait command` falls back to "the prompt came + // back and the screen is idle", and on a loaded machine an idle screen + // arrives long before the report does. + sandbox.ok(&["wait", "text", "RESULT", "--timeout", "30000"]); + let text = sandbox.ok(&["text", "--full"]); + + let line = text + .lines() + .find(|l| l.contains("RESULT")) + .unwrap_or_else(|| panic!("the probe never reported: {text}")); + + // The default profile's background is black, so the terminal reports it, + // then the color the program set, then the configured one again. + assert!( + line.contains("]11;rgb:0000/0000/0000"), + "the configured background should be reported: {line}" + ); + assert!( + line.contains("]11;rgb:6565/4343/2121"), + "a set background should be reported back: {line}" + ); + assert!( + line.contains("]10;rgb:abab/cdcd/efef"), + "a set foreground should be reported back: {line}" + ); + assert!( + line.contains("]12;rgb:fefe/dcdc/baba"), + "a set cursor color should be reported back: {line}" + ); + assert_eq!( + line.matches("]11;rgb:0000/0000/0000").count(), + 2, + "a reset should restore the configured background: {line}" + ); +} + #[test] fn state_reports_effective_timeouts() { let sandbox = Sandbox::new("state-timeouts"); diff --git a/crates/shell-use/src/assert/color.rs b/crates/shell-use/src/assert/color.rs index 56105ef..ed6b245 100644 --- a/crates/shell-use/src/assert/color.rs +++ b/crates/shell-use/src/assert/color.rs @@ -1,7 +1,7 @@ //! Color parsing and comparison for `expect --fg/--bg`. use super::super::terminal::cell::Color; -use crate::profile::Colors; +use crate::terminal::emu::Emulator; /// The spelling of [`Expected::Default`], on the command line and in messages. pub const DEFAULT: &str = "default"; @@ -74,7 +74,7 @@ fn parse_hex(hex: &str) -> anyhow::Result<(u8, u8, u8)> { /// 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 { +pub fn matches(cell: Option, expected: &Expected, colors: &dyn Emulator) -> bool { let Some(cell) = cell else { return matches!(expected, Expected::Default); }; @@ -89,7 +89,7 @@ pub fn matches(cell: Option, expected: &Expected, colors: &Colors) -> boo } /// Render a cell's color in the same space as the expected value, for messages. -pub fn describe_cell(cell: Option, expected: &Expected, colors: &Colors) -> String { +pub fn describe_cell(cell: Option, expected: &Expected, colors: &dyn Emulator) -> String { let Some(cell) = cell else { return DEFAULT.to_string(); }; @@ -124,7 +124,23 @@ pub fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 { #[cfg(test)] mod tests { use super::*; + use crate::profile::{Colors, Profile}; + use crate::terminal::alacritty::AlacrittyEmu; use crate::terminal::cell::Color; + use crate::terminal::emu::Emulator; + + /// A real emulator, so these exercise the same resolution path a session + /// uses rather than a stand-in that could drift from it. + fn emu_with(colors: Colors) -> AlacrittyEmu { + AlacrittyEmu::new( + 10, + 2, + &Profile { + colors, + ..Default::default() + }, + ) + } #[test] fn parse_forms() { @@ -144,7 +160,7 @@ mod tests { #[test] fn matches_palette_and_default() { - let c = Colors::default(); + let c = emu_with(Colors::default()); let idx = |i| Some(Color::from_index(i)); assert!(matches(idx(9), &Expected::Ansi256(9), &c)); assert!(!matches(idx(2), &Expected::Ansi256(9), &c)); @@ -161,7 +177,7 @@ mod tests { /// `default` keyword, which is the way to assert on it. #[test] fn default_color_matches_only_default() { - let c = Colors::default(); + let c = emu_with(Colors::default()); assert!(!matches(None, &Expected::Ansi256(0), &c)); assert!(!matches(None, &Expected::Hex(0, 0, 0), &c)); assert!(matches(None, &Expected::Default, &c)); @@ -170,7 +186,7 @@ mod tests { #[test] fn a_colored_cell_is_not_default() { - let c = Colors::default(); + let c = emu_with(Colors::default()); let red = Some(Color::from_index(1)); assert!(!matches(red, &Expected::Default, &c)); assert!(matches(red, &Expected::Ansi256(1), &c)); @@ -190,7 +206,7 @@ mod tests { /// value for every slot, because both come from the profile. #[test] fn an_assertion_matches_the_color_a_screenshot_paints() { - let colors = Colors::default(); + let colors = emu_with(Colors::default()); for index in 0u8..=255 { let cell = Some(Color::from_index(index)); let painted = colors.resolve(cell, true); @@ -206,14 +222,62 @@ mod tests { } } + /// An assertion compares against what the terminal is *currently* + /// showing, so a program that recolors a slot changes what matches. + /// + /// This is the other half of the screenshot test: both read the same + /// state, so a colour a screenshot paints is a colour an assertion + /// matches, at every point in a session rather than only at the start. + #[test] + fn an_assertion_follows_a_color_a_program_set() { + use crate::terminal::emu::Emulator; + let mut emu = emu_with(Colors::default()); + let red = Some(Color::from_index(1)); + let configured = Colors::default().red; + + assert!(matches( + red, + &Expected::Hex(configured.r, configured.g, configured.b), + &emu + )); + + emu.process(b"\x1b]4;1;#22c55e\x07"); + assert!( + matches(red, &Expected::Hex(0x22, 0xc5, 0x5e), &emu), + "the assertion follows the colour the program set" + ); + assert!( + !matches( + red, + &Expected::Hex(configured.r, configured.g, configured.b), + &emu + ), + "the configured colour is no longer what slot 1 shows" + ); + assert!( + matches(red, &Expected::Ansi256(1), &emu), + "the index is unaffected: it names a slot, not a colour" + ); + + emu.process(b"\x1b]104;1\x07"); + assert!( + matches( + red, + &Expected::Hex(configured.r, configured.g, configured.b), + &emu + ), + "a reset restores the configured colour" + ); + } + /// A profile's palette is what an assertion compares against, so two /// profiles genuinely disagree rather than sharing one hardcoded table. #[test] fn a_recolored_profile_moves_what_an_assertion_matches() { - let colors = Colors { + let colors = emu_with(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)); diff --git a/crates/shell-use/src/assert/snapshot.rs b/crates/shell-use/src/assert/snapshot.rs index 2707033..df3b6a6 100644 --- a/crates/shell-use/src/assert/snapshot.rs +++ b/crates/shell-use/src/assert/snapshot.rs @@ -199,6 +199,43 @@ mod tests { } } + /// A snapshot records the palette *slot* a cell chose, never the color + /// that slot resolves to. + /// + /// This is what lets a saved baseline outlive a profile change: the same + /// screen recorded under two profiles that disagree about what red looks + /// like still produces the same snapshot, so recoloring a terminal does + /// not invalidate every snapshot in a suite. + #[test] + fn a_snapshot_records_the_slot_rather_than_the_color() { + let colored = EmuCell { + ch: "x".into(), + fg: Some(Color::from_index(1)), + ..EmuCell::blank() + }; + let out = serialize(&[vec![colored]], 1, true, None); + assert!( + out.contains("\"fg\": 1"), + "the slot is recorded, not an rgb value: {out}" + ); + assert!( + !out.contains('#'), + "a palette color must not be resolved into the snapshot: {out}" + ); + } + + /// A true-color cell names its own color, so that one *is* recorded + /// literally: no profile can change what `38;2;r;g;b` means. + #[test] + fn a_true_color_cell_records_its_own_value() { + let rgb = EmuCell { + ch: "x".into(), + fg: Some(Color::Rgb(0x11, 0x22, 0x33)), + ..EmuCell::blank() + }; + assert!(serialize(&[vec![rgb]], 1, true, None).contains("#112233")); + } + fn cell(s: &str) -> EmuCell { EmuCell { ch: s.into(), diff --git a/crates/shell-use/src/engine.rs b/crates/shell-use/src/engine.rs index a51a28c..8d08b90 100644 --- a/crates/shell-use/src/engine.rs +++ b/crates/shell-use/src/engine.rs @@ -1176,9 +1176,18 @@ 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, &session.profile.colors) - { + if let Some(error) = check_colors( + &cells, + &fg, + &bg, + not, + session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .emu + .as_ref(), + ) { last_error = Some(error); false } else { @@ -1219,7 +1228,7 @@ fn check_colors( fg: &Option, bg: &Option, not: bool, - colors: &crate::profile::Colors, + colors: &dyn crate::terminal::emu::Emulator, ) -> Option { let want = !not; if let Some(spec) = fg { @@ -1353,7 +1362,12 @@ fn screenshot( let svg = crate::render::svg::render_svg( &rows, session.cols, - &session.profile.colors, + session + .state + .lock() + .unwrap_or_else(std::sync::PoisonError::into_inner) + .emu + .as_ref(), title.as_deref(), ); std::fs::write(&path, svg) diff --git a/crates/shell-use/src/profile.rs b/crates/shell-use/src/profile.rs index ec98799..698afd7 100644 --- a/crates/shell-use/src/profile.rs +++ b/crates/shell-use/src/profile.rs @@ -22,7 +22,7 @@ use std::path::{Path, PathBuf}; use serde::{Deserialize, Deserializer, Serialize, Serializer}; -use crate::terminal::cell::{Color, NamedColor}; +use crate::terminal::cell::NamedColor; /// Rows of scrollback a profile retains when it does not say otherwise. /// @@ -193,47 +193,85 @@ impl Colors { /// 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. + /// Slots 0-15 come from the profile; everything above comes from the + /// xterm table, which no profile can move. 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) - } + _ => xterm_color(index), } } +} - /// 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), - } +/// A color a program can address. +/// +/// `OSC 4` names a palette entry and `OSC 10/11/12` name the three defaults. +/// Emulators number these however they like internally, so each backend +/// translates its own layout and that numbering never reaches here. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum ColorSlot { + Indexed(u8), + Foreground, + Background, + Cursor, +} + +/// The xterm 256-color table, which is the same in every terminal. +/// +/// Slots 0-15 here are the classic VGA colors, and a profile overrides them. +/// The rest is the 6x6x6 color cube and the 24-step gray ramp, which the +/// specification fixes and no profile can move: `--fg 196` has to mean the +/// same thing in every session. +static XTERM_256: [Rgb; 256] = build_xterm_256(); + +const fn build_xterm_256() -> [Rgb; 256] { + let mut table = [Rgb::new(0, 0, 0); 256]; + + // 0-15: VGA. + let vga = [ + (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), + ]; + let mut i = 0; + while i < 16 { + table[i] = Rgb::new(vga[i].0, vga[i].1, vga[i].2); + i += 1; + } + + // 16-231: a 6x6x6 cube whose levels step 0, 95, 135, 175, 215, 255. + let levels = [0u8, 95, 135, 175, 215, 255]; + while i < 232 { + let n = i - 16; + table[i] = Rgb::new(levels[(n / 36) % 6], levels[(n / 6) % 6], levels[n % 6]); + i += 1; } + + // 232-255: a gray ramp from 8 to 238 in steps of 10. + while i < 256 { + let v = (i - 232) as u8 * 10 + 8; + table[i] = Rgb::new(v, v, v); + i += 1; + } + table +} + +/// The color a slot has when nothing has overridden it. +pub fn xterm_color(index: u8) -> Rgb { + XTERM_256[index as usize] } /// The settings a session runs with. @@ -448,15 +486,47 @@ mod tests { assert_eq!(Colors::slot_name(16), None, "only 0-15 are configurable"); } + /// The 16 configurable slots come from the profile; the rest come from the + /// xterm table, which is the same in every terminal. #[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); + fn only_the_ansi_slots_follow_the_profile() { + let recolored = Colors { + red: Rgb::new(1, 2, 3), + ..Default::default() + }; + assert_eq!(recolored.rgb(1), Rgb::new(1, 2, 3), "slot 1 follows it"); + for index in 16u8..=255 { + assert_eq!( + recolored.rgb(index), + xterm_color(index), + "slot {index} is fixed by the specification" + ); + } + } + + /// Spot-check the static table against the values the specification + /// defines, so a typo in 256 entries cannot pass unnoticed. + #[test] + fn the_xterm_table_matches_the_specification() { + assert_eq!(xterm_color(0), Rgb::new(0, 0, 0), "VGA black"); + assert_eq!(xterm_color(1), Rgb::new(128, 0, 0), "VGA red"); + assert_eq!(xterm_color(15), Rgb::new(255, 255, 255), "VGA bright white"); + assert_eq!( + xterm_color(16), + Rgb::new(0, 0, 0), + "the cube starts at black" + ); + assert_eq!(xterm_color(196), Rgb::new(255, 0, 0), "cube red"); + assert_eq!( + xterm_color(231), + Rgb::new(255, 255, 255), + "the cube ends white" + ); + assert_eq!(xterm_color(232), Rgb::new(8, 8, 8), "the ramp starts at 8"); 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" + xterm_color(255), + Rgb::new(238, 238, 238), + "the ramp ends at 238" ); } diff --git a/crates/shell-use/src/render/svg.rs b/crates/shell-use/src/render/svg.rs index e10b222..a86c509 100644 --- a/crates/shell-use/src/render/svg.rs +++ b/crates/shell-use/src/render/svg.rs @@ -11,8 +11,9 @@ use std::fmt::Write; use super::nerd_font::NerdFont; -use crate::profile::{Colors, Rgb}; +use crate::profile::{ColorSlot, Rgb}; use crate::terminal::cell::{truncate_to_columns, Attrs, EmuCell}; +use crate::terminal::emu::Emulator; const CELL_W: f32 = 10.0; const CELL_H: f32 = 21.0; @@ -47,7 +48,7 @@ fn cell_at(row: &[EmuCell], x: usize) -> &EmuCell { } /// Resolved background color for a cell (honoring inverse). -fn bg_of(cell: &EmuCell, colors: &Colors) -> Rgb { +fn bg_of(cell: &EmuCell, colors: &dyn Emulator) -> Rgb { let bg = colors.resolve(cell.bg, false); let fg = colors.resolve(cell.fg, true); if cell.has(Attrs::INVERSE) { @@ -67,7 +68,7 @@ struct Style { invisible: bool, } -fn style_of(cell: &EmuCell, colors: &Colors) -> Style { +fn style_of(cell: &EmuCell, colors: &dyn Emulator) -> Style { let mut fg = colors.resolve(cell.fg, true); let bg = colors.resolve(cell.bg, false); if cell.has(Attrs::INVERSE) { @@ -114,7 +115,7 @@ fn run_text(row: &[EmuCell], start: usize, end: usize) -> String { /// computed width would distort it. It is instead truncated to what fits, and /// kept clear of the traffic lights by reserving the same margin on both /// sides, which also keeps it centred on the space that remains. -fn write_title(out: &mut String, title: &str, width: f32, colors: &Colors) { +fn write_title(out: &mut String, title: &str, width: f32, colors: &dyn Emulator) { const GAP: f32 = 8.0; let available = width - 2.0 * (DOTS_RIGHT + GAP); // A monospace advance, scaled from the grid font's known cell width. @@ -136,7 +137,7 @@ fn write_title(out: &mut String, title: &str, width: f32, colors: &Colors) { baseline = HEADER_H / 2.0 + TITLE_FONT_SIZE * 0.35, // The dim grey of the palette, so the title reads as chrome next to // the terminal's own foreground. - fill = hex(colors.rgb(8)), + fill = hex(colors.color(ColorSlot::Indexed(8))), esc = escape(&shown), ); } @@ -148,7 +149,7 @@ fn write_title(out: &mut String, title: &str, width: f32, colors: &Colors) { pub fn render_svg( rows: &[Vec], cols: u16, - colors: &Colors, + colors: &dyn Emulator, title: Option<&str>, ) -> String { let nerd_font = NerdFont::new(rows, FONT_SIZE); @@ -167,7 +168,7 @@ pub fn render_svg( let _ = write!( out, r#""#, - hex(colors.background) + hex(colors.resolve(None, false)) ); for (i, dot) in ["#ff5f56", "#ffbd2e", "#27c93f"].iter().enumerate() { let cx = MARGIN_X + 5.0 + i as f32 * 20.0; @@ -189,7 +190,7 @@ pub fn render_svg( while x + run < cols && bg_of(cell_at(row, x + run), colors) == bg { run += 1; } - if bg != colors.background { + if bg != colors.resolve(None, false) { let rx = x0 + x as f32 * CELL_W; let ry = y0 + y as f32 * CELL_H; let rw = run as f32 * CELL_W; @@ -266,8 +267,16 @@ pub fn render_svg( #[cfg(test)] mod tests { use super::*; + use crate::profile::{ColorSlot, Profile}; + use crate::terminal::alacritty::AlacrittyEmu; use crate::terminal::cell::Color; + /// A real emulator: the renderer resolves through the same path a session + /// uses, so a stand-in could not drift from it. + fn colors() -> AlacrittyEmu { + AlacrittyEmu::new(10, 2, &Profile::default()) + } + fn cell(ch: &str, fg: Option, bg: Option) -> EmuCell { EmuCell { ch: ch.into(), @@ -277,18 +286,67 @@ mod tests { } } + /// A program that repaints the terminal repaints the screenshot. + /// + /// The renderer draws what the terminal is currently showing, not what it + /// was configured with, so a background set with `OSC 11` is the one that + /// gets painted. Nothing else covers the path from an escape sequence to + /// a rendered pixel. + #[test] + fn a_screenshot_follows_colors_a_program_set() { + use crate::terminal::emu::Emulator; + let mut emu = colors(); + let rows = vec![vec![cell("x", Some(Color::from_index(1)), None)]]; + + let before = render_svg(&rows, 1, &emu, None); + assert!(before.contains(&hex(Profile::default().colors.red))); + assert!(before.contains(&hex(Profile::default().colors.background))); + + // The program picks its own background and recolors palette slot 1. + emu.process(b"\x1b]11;#3b0764\x07\x1b]4;1;#22c55e\x07"); + + let after = render_svg(&rows, 1, &emu, None); + assert!( + after.contains("#3b0764"), + "the window is painted with the background the program set" + ); + assert!( + after.contains("#22c55e"), + "a cell follows the slot the program recolored" + ); + assert!( + !after.contains(&hex(Profile::default().colors.red)), + "the configured red is no longer what slot 1 shows" + ); + } + + /// And a reset puts the configured colors back on screen. + #[test] + fn a_screenshot_returns_to_the_profile_after_a_reset() { + use crate::terminal::emu::Emulator; + let mut emu = colors(); + let rows = vec![vec![cell("x", Some(Color::from_index(1)), None)]]; + + emu.process(b"\x1b]11;#3b0764\x07\x1b]4;1;#22c55e\x07"); + emu.process(b"\x1b]111\x07\x1b]104;1\x07"); + + let after = render_svg(&rows, 1, &emu, None); + assert!(after.contains(&hex(Profile::default().colors.background))); + assert!(after.contains(&hex(Profile::default().colors.red))); + } + #[test] fn emits_valid_svg_with_text_and_color() { let rows = vec![vec![ cell("h", Some(Color::from_index(1)), None), cell("i", Some(Color::from_index(1)), None), ]]; - let svg = render_svg(&rows, 2, &Colors::default(), None); + let svg = render_svg(&rows, 2, &colors(), None); assert!(svg.starts_with("")); assert!(svg.contains("textLength")); assert!( - svg.contains(&hex(Colors::default().rgb(1))), + svg.contains(&hex(colors().color(ColorSlot::Indexed(1)))), "slot 1 is painted with the profile color" ); assert!(svg.contains(">hi")); @@ -298,7 +356,7 @@ mod tests { #[test] fn emits_window_chrome() { - let svg = render_svg(&[vec![cell(" ", None, None)]], 1, &Colors::default(), None); + let svg = render_svg(&[vec![cell(" ", None, None)]], 1, &colors(), None); assert!(svg.contains("<")); } @@ -323,9 +381,9 @@ 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, &Colors::default(), None); + let svg = render_svg(&rows, 1, &colors(), None); assert!( - svg.contains(&hex(Colors::default().rgb(4))), + svg.contains(&hex(colors().color(ColorSlot::Indexed(4)))), "slot 4 is painted with the profile color" ); } @@ -338,7 +396,7 @@ mod tests { cell(glyph, None, None), cell("b", None, None), ]]; - let svg = render_svg(&rows, 3, &Colors::default(), None); + let svg = render_svg(&rows, 3, &colors(), None); assert!(svg.contains(r#"")); @@ -382,8 +435,8 @@ mod tests { #[test] fn draws_the_window_title_centred_in_the_bar() { let rows = vec![vec![cell("x", None, None); 40]]; - let bare = render_svg(&rows, 40, &Colors::default(), None); - let titled = render_svg(&rows, 40, &Colors::default(), Some("vim: notes.md")); + let bare = render_svg(&rows, 40, &colors(), None); + let titled = render_svg(&rows, 40, &colors(), Some("vim: notes.md")); assert!( !bare.contains("text-anchor=\"middle\""), @@ -406,7 +459,7 @@ mod tests { fn truncates_a_title_that_does_not_fit() { let rows = vec![vec![cell("x", None, None); 20]]; let long = "a-very-long-window-title-that-cannot-possibly-fit"; - let svg = render_svg(&rows, 20, &Colors::default(), Some(long)); + let svg = render_svg(&rows, 20, &colors(), Some(long)); assert!(!svg.contains(long), "the full title cannot have been drawn"); let drawn = svg @@ -427,7 +480,7 @@ mod tests { #[test] fn budgets_a_wide_glyph_title_by_column() { let rows = vec![vec![cell("x", None, None); 24]]; - let svg = render_svg(&rows, 24, &Colors::default(), Some(&"你".repeat(40))); + let svg = render_svg(&rows, 24, &colors(), Some(&"你".repeat(40))); let drawn = svg .split("text-anchor=\"middle\" xml:space=\"preserve\">") .nth(1) @@ -455,12 +508,7 @@ mod tests { #[test] fn escapes_markup_in_the_title() { let rows = vec![vec![cell("x", None, None); 40]]; - let svg = render_svg( - &rows, - 40, - &Colors::default(), - Some(""), - ); + let svg = render_svg(&rows, 40, &colors(), Some("")); assert!(!svg.contains("