Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
10 changes: 9 additions & 1 deletion e2e/test_keyframe.py
Original file line number Diff line number Diff line change
Expand Up @@ -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+===================+"
)
Expand Down Expand Up @@ -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.
Expand Down
37 changes: 36 additions & 1 deletion src/cli/screen.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down Expand Up @@ -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(&params.replace(':', ";"));
} else {
out.push_str(params);
}
rest = tail;
}
out.push_str(rest);
out
}