From b91d5ef5a34031104276d999baaaa8511694a58d Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Tue, 4 Aug 2026 15:06:29 -0400 Subject: [PATCH] feat(terminal): answer OSC color queries and track dynamic colors MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Programs ask the terminal what color it is before deciding whether to draw for a light or a dark background. Nothing answered, so every one of them blocked until it timed out and guessed. The emulator answers now, through `take_pending_writes`, which already exists for exactly this: the replies a terminal owes to device queries. alacritty parses the sequence, tracks what a program set, and hands back a formatter with the query's own prefix and terminator already captured, so the only missing piece was the color itself. That comes from the session profile, which the emulator is now constructed with. The alternative was parsing the sequences off the PTY stream, the way shell integration is tracked. That would have meant reimplementing color parsing, the runtime table, reply formatting, and terminator tracking, all of which the emulator already does — and getting the terminator wrong, since it is only visible to whoever parsed the sequence. Reading what the emulator already knows is both less code and more faithful. Colors resolve in three layers: what a program set, else the session profile, else the table the specification defines. A reset clears only the first, so the profile is unreachable from the byte stream and there is always something to restore. That is what the specification asks for, describing a reset as restoring "the color specified by the corresponding X resource". `Emulator` gains `color(slot)`, which every backend answers from its own state, plus a `palette()` snapshot of all 259 slots. The screenshot renderer and `expect --fg/--bg` take that snapshot rather than the emulator, so neither holds the session lock while it renders, and neither knows which backend produced the colors. Five conformance cases cover queries, terminator echo, set-then-reset, unconfigured indices, and that a cell follows whatever its slot now holds. They run against every backend, so a future one cannot answer differently. An end-to-end test drives a real program through the whole path: it reads the configured background, sets its own, resets, and gets the configured one back. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas Rebasing onto a main that had since gained window titles folded the follow-up commits into this one, so it also carries: color resolution moved into the emulator so a screenshot and an assertion both see what a program set, `ColorSlot` naming the three dynamic colors instead of numbering them, snapshot coverage pinning that a snapshot records the slot rather than the color it resolves to, and the fix that answers queries in the order they were asked rather than appending every color reply last. --- SKILL.md | 6 + crates/shell-use-cli/src/monitor.rs | 2 +- .../shell-use-cli/tests/session_lifecycle.rs | 105 ++++++ crates/shell-use/src/assert/color.rs | 82 ++++- crates/shell-use/src/assert/snapshot.rs | 37 +++ crates/shell-use/src/engine.rs | 24 +- crates/shell-use/src/profile.rs | 154 ++++++--- crates/shell-use/src/render/svg.rs | 114 +++++-- crates/shell-use/src/session.rs | 5 +- crates/shell-use/src/terminal/alacritty.rs | 211 ++++++++++-- crates/shell-use/src/terminal/conformance.rs | 303 +++++++++++++++++- crates/shell-use/src/terminal/emu.rs | 37 ++- 12 files changed, 961 insertions(+), 119 deletions(-) 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("