diff --git a/e2e/test_keyframe.py b/e2e/test_keyframe.py index 1de0415..af04c99 100644 --- a/e2e/test_keyframe.py +++ b/e2e/test_keyframe.py @@ -39,7 +39,8 @@ # marker; this is the "base paint" a real TUI does once on startup. BASE_PAINT = ( "\x1b[?1049h\x1b[2J\x1b[H" - "\x1b[1;1H+==== DASHBOARD ====+" + "\x1b[1;1H\x1b[38;2;137;180;250;48;2;24;24;37m" + "+==== DASHBOARD ====+\x1b[0m" "\x1b[2;1H| loading... |" "\x1b[3;1H+===================+" ) @@ -121,6 +122,13 @@ def test_late_joiner_sees_static_chrome_of_long_running_tui( "history was not evicted - raise NUM_UPDATES " f"(snapshot head: {snapshot[:80]!r})" ) + assert "38;2;137;180;250" in snapshot, ( + "the keyframe lost the base paint's truecolor" + ) + assert "38:2:137:180:250" not in snapshot, ( + "the keyframe kept avt's ambiguous RGB form, which xterm.js " + "reads shifted by one channel" + ) # Behavior under test: a freshly-loaded viewer renders the static # chrome, not just the changing cell. diff --git a/src/cli/screen.rs b/src/cli/screen.rs index 22aaf47..041d13b 100644 --- a/src/cli/screen.rs +++ b/src/cli/screen.rs @@ -90,7 +90,8 @@ impl Keyframer { } // avt uses the 8-bit CSI (U+009B); rewrite to the 7-bit `ESC [` form // that every terminal and xterm.js accept. - Some(dump.replace('\u{9b}', "\x1b[").into_bytes()) + let dump = dump.replace('\u{9b}', "\x1b["); + Some(semicolon_sgr_params(&dump).into_bytes()) } fn apply_resize(&mut self, size: TermSize) { @@ -149,3 +150,37 @@ impl Keyframer { } } } + +/// avt writes truecolor as `38:2:R:G:B`, which T.416 does not allow: the +/// color-space field is positional, not optional, so a reader following the +/// spec takes G and B for R and G and defaults B to 0 - blue arrives as +/// yellow-green. xterm.js follows the spec (xtermjs/xterm.js#5792 was closed +/// as working-as-intended), so the emitter is what has to change. +/// +/// The only colons avt puts inside an SGR are colors, and screen text can +/// never contain an ESC, so rewriting every colon in a `...m` sequence is +/// enough. A dump's trailing sequence can be unterminated - `Vt::dump` +/// reproduces in-flight parser state - so the end of the dump terminates one +/// too. A param that already spells the empty color space (`38:2::R:G:B`) is +/// left alone: that form is valid, and flattening its `::` to `;;` would +/// reintroduce the very shift this removes. +fn semicolon_sgr_params(dump: &str) -> String { + let mut out = String::with_capacity(dump.len()); + let mut rest = dump; + while let Some(start) = rest.find("\x1b[") { + let (head, body) = rest.split_at(start + 2); + out.push_str(head); + let end = body + .find(|c: char| !matches!(c, '0'..='9' | ';' | ':')) + .unwrap_or(body.len()); + let (params, tail) = body.split_at(end); + if (tail.starts_with('m') || tail.is_empty()) && !params.contains("::") { + out.push_str(¶ms.replace(':', ";")); + } else { + out.push_str(params); + } + rest = tail; + } + out.push_str(rest); + out +}