feat: replace vt100 with wezterm-term for the screen emulator
Some checks failed
CI / Version Badge (pull_request) Has been cancelled
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / test-matrix (macos-latest) (pull_request) Has been cancelled
CI / test-matrix (ubuntu-latest) (pull_request) Has been cancelled
CI / Test (pull_request) Has been cancelled
CI / Integration Tests (macos-latest) (pull_request) Has been cancelled
CI / Integration Tests (ubuntu-latest) (pull_request) Has been cancelled
CI / Nix Build (macos-latest) (pull_request) Has been cancelled
CI / Nix Build (ubuntu-latest) (pull_request) Has been cancelled
Some checks failed
CI / Version Badge (pull_request) Has been cancelled
CI / Check (pull_request) Has been cancelled
CI / Format (pull_request) Has been cancelled
CI / Clippy (pull_request) Has been cancelled
CI / test-matrix (macos-latest) (pull_request) Has been cancelled
CI / test-matrix (ubuntu-latest) (pull_request) Has been cancelled
CI / Test (pull_request) Has been cancelled
CI / Integration Tests (macos-latest) (pull_request) Has been cancelled
CI / Integration Tests (ubuntu-latest) (pull_request) Has been cancelled
CI / Nix Build (macos-latest) (pull_request) Has been cancelled
CI / Nix Build (ubuntu-latest) (pull_request) Has been cancelled
Swap the rendering core. The previous backend (vt100 0.16) only stored
six attributes — fg/bg color, bold, dim, italic, underline, inverse —
and silently dropped everything else when re-emitting the screen. With
modern Claude Code that meant losing OSC 8 hyperlinks, strikethrough,
fancy underline styles, underline color, and so on.
New stack:
* wezterm-term::Terminal handles the byte-driven VT emulation. Its
cell model preserves the full modern attribute set including
hyperlinks and underline color.
* termwiz::surface::Surface is used as a "mirror of last emitted
state." Each render diffs the terminal's current visible lines
against this mirror to compute the minimum change set.
* termwiz::render::TerminfoRenderer encodes those Changes as bytes
using a TrueColor + hyperlink-capable capability profile.
A defensive OSC 8 close is appended to every render to handle the case
where the diff ends inside a hyperlinked region (the renderer only
emits the close on transition to a non-hyperlinked cell).
Both termwiz and wezterm-term are pulled from the wezterm git repo at
a pinned rev so the Line types unify across the dep graph.
Test results, against the color-preservation harness from the previous
commit (run via cargo test color_preservation -- --ignored on master):
baseline (vt100): 0 / 5 passing
after this commit: 3 / 5 passing (osc8, strikethrough, double underline)
The two still-ignored tests (curly underline, underline color) are
upstream renderer limitations: termwiz's TerminfoRenderer only emits
Single/Double underline and never emits SGR 58. The cell model stores
both correctly; only the emit step drops them. Tests are kept #[ignore]
with comments calling out the upstream gap.
Total: 118 passed, 4 ignored, 0 failed.
This commit is contained in:
@@ -25,7 +25,7 @@ nix = { version = "0.30", features = ["term", "signal", "poll", "process", "fs"]
|
||||
libc = "0.2"
|
||||
serde = { version = "1", features = ["derive"] }
|
||||
toml = "0.8"
|
||||
vt100 = "0.16"
|
||||
termwiz = "0.23"
|
||||
termwiz = { git = "https://github.com/wezterm/wezterm", rev = "577474d89ee61aef4a48145cdec82a638d874751" }
|
||||
wezterm-term = { git = "https://github.com/wezterm/wezterm", rev = "577474d89ee61aef4a48145cdec82a638d874751" }
|
||||
log = "0.4"
|
||||
env_logger = "0.11"
|
||||
|
||||
@@ -4,17 +4,20 @@
|
||||
//! force a render, capture what the proxy emits to its stdout side, and
|
||||
//! assert that the styling survives the round-trip.
|
||||
//!
|
||||
//! They currently FAIL on master because the underlying `vt100 0.16`
|
||||
//! emulator only models a handful of attributes (fg/bg color, bold, dim,
|
||||
//! italic, underline, inverse). Anything else — OSC 8 hyperlinks,
|
||||
//! strikethrough, fancy underline styles, underline color — is silently
|
||||
//! dropped on render.
|
||||
//! After the swap from `vt100 0.16` to `wezterm-term` + `termwiz` Surface
|
||||
//! diffing, most of the modern attribute set is preserved end-to-end:
|
||||
//! OSC 8 hyperlinks, strikethrough, double underline, and truecolor all
|
||||
//! round-trip. Two known gaps remain — both are limitations of termwiz's
|
||||
//! `TerminfoRenderer`, not of the cell model:
|
||||
//!
|
||||
//! They are gated behind `#[ignore]` so CI stays green. Run them with:
|
||||
//! cargo test -p claude-chill --lib color_preservation -- --ignored
|
||||
//! * Curly / dotted / dashed underline styles (`SGR 4:3`, `4:4`, `4:5`):
|
||||
//! stored on the cell, never emitted on render.
|
||||
//! * Underline color (`SGR 58`): stored on the cell, never emitted on
|
||||
//! render.
|
||||
//!
|
||||
//! The expectation is that they pass once the emulator is swapped to
|
||||
//! termwiz's `Surface`. Remove the `#[ignore]` attributes at that point.
|
||||
//! Tests for those two are kept `#[ignore]` so CI stays green. They will
|
||||
//! pass if/when termwiz's renderer learns to emit them (or we replace
|
||||
//! the emit step with a custom serializer).
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
@@ -119,7 +122,6 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore] // FIXME: passes once VT emulator swap to termwiz lands
|
||||
fn osc8_hyperlink_survives_render() {
|
||||
// OSC 8 hyperlinks — used for clickable file paths / URLs.
|
||||
// Format: ESC ] 8 ; ; URL ESC \ ... ESC ] 8 ; ; ESC \
|
||||
@@ -142,7 +144,6 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore] // FIXME: passes once VT emulator swap to termwiz lands
|
||||
fn strikethrough_survives_render() {
|
||||
// SGR 9 = strikethrough. vt100 0.16 has no field for it.
|
||||
let input = b"\x1b[9mstruck through text\x1b[0m";
|
||||
@@ -155,7 +156,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore] // FIXME: passes once VT emulator swap to termwiz lands
|
||||
#[ignore] // termwiz TerminfoRenderer only emits Single/Double underline.
|
||||
fn curly_underline_survives_render() {
|
||||
// SGR 4:3 = curly underline (kitty/wezterm/ghostty all support).
|
||||
let input = b"\x1b[4:3mcurly underline\x1b[0m";
|
||||
@@ -168,7 +169,7 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore] // FIXME: passes once VT emulator swap to termwiz lands
|
||||
#[ignore] // termwiz TerminfoRenderer doesn't emit SGR 58 underline color.
|
||||
fn underline_color_survives_render() {
|
||||
// SGR 58:2::R:G:B = truecolor underline color.
|
||||
let input = b"\x1b[4m\x1b[58:2::255:0:0mcolored underline\x1b[0m";
|
||||
@@ -181,7 +182,6 @@ mod tests {
|
||||
}
|
||||
|
||||
#[test]
|
||||
#[ignore] // FIXME: passes once VT emulator swap to termwiz lands
|
||||
fn double_underline_survives_render() {
|
||||
// SGR 21 = doubly underlined.
|
||||
let input = b"\x1b[21mdouble underlined\x1b[0m";
|
||||
|
||||
@@ -10,3 +10,4 @@ pub mod key_parser;
|
||||
pub mod line_buffer;
|
||||
pub mod proxy;
|
||||
pub mod redraw_throttler;
|
||||
pub mod screen_engine;
|
||||
|
||||
@@ -6,6 +6,7 @@ use crate::escape_sequences::{
|
||||
};
|
||||
use crate::history_filter::HistoryFilter;
|
||||
use crate::line_buffer::LineBuffer;
|
||||
use crate::screen_engine::ScreenEngine;
|
||||
use anyhow::{Context, Result};
|
||||
use log::debug;
|
||||
use memchr::memmem;
|
||||
@@ -102,8 +103,7 @@ pub struct Proxy {
|
||||
original_termios: Option<Termios>,
|
||||
history: LineBuffer,
|
||||
history_filter: HistoryFilter,
|
||||
vt_parser: vt100::Parser,
|
||||
vt_prev_screen: Option<vt100::Screen>,
|
||||
screen: ScreenEngine,
|
||||
last_output_time: Option<Instant>,
|
||||
last_render_time: Option<Instant>,
|
||||
last_stdin_time: Option<Instant>,
|
||||
@@ -270,7 +270,7 @@ impl Proxy {
|
||||
let stdout_fd = io::stdout();
|
||||
write_all(&stdout_fd, BRACKETED_PASTE_ENABLE)?;
|
||||
|
||||
let vt_parser = vt100::Parser::new(winsize.ws_row, winsize.ws_col, 0);
|
||||
let screen = ScreenEngine::new(winsize.ws_row, winsize.ws_col);
|
||||
|
||||
// Seed history with clear screen so replay starts fresh
|
||||
let mut history = LineBuffer::new(config.max_history_lines);
|
||||
@@ -288,8 +288,7 @@ impl Proxy {
|
||||
pty_master: pty.master,
|
||||
child,
|
||||
original_termios: terminal_guard.take(),
|
||||
vt_parser,
|
||||
vt_prev_screen: None,
|
||||
screen,
|
||||
last_output_time: None,
|
||||
last_render_time: None,
|
||||
last_stdin_time: None,
|
||||
@@ -330,7 +329,7 @@ impl Proxy {
|
||||
rows: u16,
|
||||
cols: u16,
|
||||
) -> Self {
|
||||
let vt_parser = vt100::Parser::new(rows, cols, 0);
|
||||
let screen = ScreenEngine::new(rows, cols);
|
||||
let mut history = LineBuffer::new(config.max_history_lines);
|
||||
history.push_bytes(CLEAR_SCREEN);
|
||||
history.push_bytes(CURSOR_HOME);
|
||||
@@ -343,8 +342,7 @@ impl Proxy {
|
||||
pty_master,
|
||||
child,
|
||||
original_termios: None,
|
||||
vt_parser,
|
||||
vt_prev_screen: None,
|
||||
screen,
|
||||
last_output_time: None,
|
||||
last_render_time: None,
|
||||
last_stdin_time: None,
|
||||
@@ -403,8 +401,8 @@ impl Proxy {
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
pub(crate) fn vt_screen_text(&self) -> String {
|
||||
self.vt_parser.screen().contents()
|
||||
pub(crate) fn vt_screen_text(&mut self) -> String {
|
||||
self.screen.screen_text()
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
@@ -532,7 +530,7 @@ impl Proxy {
|
||||
// Feed VT but NOT history while in alt screen
|
||||
// Alt screen content (TUI editors, etc.) shouldn't be in lookback history
|
||||
if feed_vt {
|
||||
self.vt_parser.process(data);
|
||||
self.screen.process(data);
|
||||
}
|
||||
return self.process_output_alt_screen(data, stdout_fd);
|
||||
}
|
||||
@@ -545,7 +543,7 @@ impl Proxy {
|
||||
|
||||
// Feed data to VT emulator (unless already fed by caller)
|
||||
if feed_vt {
|
||||
self.vt_parser.process(data);
|
||||
self.screen.process(data);
|
||||
}
|
||||
self.vt_render_pending = true;
|
||||
self.last_output_time = Some(Instant::now());
|
||||
@@ -621,7 +619,7 @@ impl Proxy {
|
||||
|
||||
// Force full VT render to restore main screen content
|
||||
debug!("process_output_alt_screen: rendering VT screen after alt exit");
|
||||
self.vt_prev_screen = None;
|
||||
self.screen.force_full_next();
|
||||
self.render_vt_screen(stdout_fd)?;
|
||||
|
||||
// Data after ALT_EXIT was already fed to VT and history when we processed
|
||||
@@ -828,32 +826,14 @@ impl Proxy {
|
||||
}
|
||||
|
||||
fn render_vt_screen<F: AsFd>(&mut self, stdout_fd: &F) -> Result<()> {
|
||||
let is_diff = self.vt_prev_screen.is_some();
|
||||
let body = self.screen.render()?;
|
||||
self.output_buffer.clear();
|
||||
self.output_buffer.extend_from_slice(SYNC_START);
|
||||
|
||||
match &self.vt_prev_screen {
|
||||
Some(prev) => {
|
||||
// Diff-based render: only send changes
|
||||
self.output_buffer
|
||||
.extend_from_slice(&self.vt_parser.screen().contents_diff(prev));
|
||||
}
|
||||
None => {
|
||||
// First render: full screen
|
||||
self.output_buffer
|
||||
.extend_from_slice(&self.vt_parser.screen().contents_formatted());
|
||||
}
|
||||
}
|
||||
|
||||
self.output_buffer
|
||||
.extend_from_slice(&self.vt_parser.screen().cursor_state_formatted());
|
||||
self.output_buffer.extend_from_slice(&body);
|
||||
self.output_buffer.extend_from_slice(SYNC_END);
|
||||
|
||||
debug!(
|
||||
"render_vt_screen: diff={} output_len={}\n",
|
||||
is_diff,
|
||||
self.output_buffer.len()
|
||||
);
|
||||
debug!("render_vt_screen: output_len={}", self.output_buffer.len());
|
||||
|
||||
// Can't use write_to_terminal here due to borrow checker - can't pass
|
||||
// &self.output_buffer while also taking &mut self
|
||||
Self::update_kitty_mode_helper(
|
||||
@@ -864,8 +844,6 @@ impl Proxy {
|
||||
);
|
||||
write_all(stdout_fd, &self.output_buffer)?;
|
||||
|
||||
// Store current screen for next diff
|
||||
self.vt_prev_screen = Some(self.vt_parser.screen().clone());
|
||||
self.vt_render_pending = false;
|
||||
self.last_render_time = Some(Instant::now());
|
||||
Ok(())
|
||||
@@ -942,7 +920,7 @@ impl Proxy {
|
||||
write_all(stdout_fd, &self.output_buffer)?;
|
||||
|
||||
// Force full VT render on next output since terminal now shows history
|
||||
self.vt_prev_screen = None;
|
||||
self.screen.force_full_next();
|
||||
Ok(())
|
||||
}
|
||||
|
||||
@@ -1164,7 +1142,7 @@ impl Proxy {
|
||||
|
||||
// Force full render since terminal was showing history
|
||||
debug!("exit_lookback_mode: rendering VT screen");
|
||||
self.vt_prev_screen = None;
|
||||
self.screen.force_full_next();
|
||||
self.render_vt_screen(stdout_fd)?;
|
||||
|
||||
Ok(())
|
||||
@@ -1177,11 +1155,7 @@ impl Proxy {
|
||||
winsize.ws_row, winsize.ws_col
|
||||
);
|
||||
// Resize VT emulator
|
||||
self.vt_parser
|
||||
.screen_mut()
|
||||
.set_size(winsize.ws_row, winsize.ws_col);
|
||||
// Force full render on next frame since size changed
|
||||
self.vt_prev_screen = None;
|
||||
self.screen.resize(winsize.ws_row, winsize.ws_col);
|
||||
// Forward to child process
|
||||
unsafe {
|
||||
libc::ioctl(
|
||||
|
||||
264
crates/claude-chill/src/screen_engine.rs
Normal file
264
crates/claude-chill/src/screen_engine.rs
Normal file
@@ -0,0 +1,264 @@
|
||||
//! Screen emulator backed by `wezterm-term`.
|
||||
//!
|
||||
//! This replaces the previous `vt100` backing. wezterm-term gives us full
|
||||
//! modern attribute coverage — OSC 8 hyperlinks, strikethrough, fancy
|
||||
//! underline styles, underline color — that vt100 0.16 dropped on render.
|
||||
//!
|
||||
//! Architecture:
|
||||
//! - `wezterm_term::Terminal` is the byte-driven VT emulator. Feed bytes
|
||||
//! via `advance_bytes`; query state via `screen().visible_lines()`.
|
||||
//! - A `termwiz::surface::Surface` mirrors the last bytes we *emitted*
|
||||
//! downstream; the next render diffs current visible lines against
|
||||
//! this Surface to compute the minimum change set.
|
||||
//! - `termwiz::render::TerminfoRenderer` serializes those Changes into
|
||||
//! bytes using a TrueColor / hyperlink-capable capability set.
|
||||
|
||||
use anyhow::Result;
|
||||
use std::io::Write;
|
||||
use std::sync::Arc;
|
||||
use termwiz::caps::{Capabilities, ColorLevel, ProbeHints};
|
||||
use termwiz::render::RenderTty;
|
||||
use termwiz::render::terminfo::TerminfoRenderer;
|
||||
use termwiz::surface::{Change, Position, Surface};
|
||||
use wezterm_term::color::ColorPalette;
|
||||
use wezterm_term::{Terminal, TerminalConfiguration, TerminalSize};
|
||||
|
||||
#[derive(Debug)]
|
||||
struct ProxyTermConfig;
|
||||
|
||||
impl TerminalConfiguration for ProxyTermConfig {
|
||||
fn color_palette(&self) -> ColorPalette {
|
||||
ColorPalette::default()
|
||||
}
|
||||
}
|
||||
|
||||
/// `Write + RenderTty` adapter that buffers everything in memory.
|
||||
struct CapturedTty {
|
||||
buf: Vec<u8>,
|
||||
cols: usize,
|
||||
rows: usize,
|
||||
}
|
||||
|
||||
impl Write for CapturedTty {
|
||||
fn write(&mut self, data: &[u8]) -> std::io::Result<usize> {
|
||||
self.buf.extend_from_slice(data);
|
||||
Ok(data.len())
|
||||
}
|
||||
|
||||
fn flush(&mut self) -> std::io::Result<()> {
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
impl RenderTty for CapturedTty {
|
||||
fn get_size_in_cells(&mut self) -> termwiz::Result<(usize, usize)> {
|
||||
Ok((self.cols, self.rows))
|
||||
}
|
||||
}
|
||||
|
||||
pub struct ScreenEngine {
|
||||
terminal: Terminal,
|
||||
/// Mirror of what we've *already emitted* downstream. Diffing the
|
||||
/// current terminal screen against this gives us the minimal change
|
||||
/// set to send.
|
||||
mirror: Surface,
|
||||
renderer: TerminfoRenderer,
|
||||
rows: usize,
|
||||
cols: usize,
|
||||
/// When set, the next render emits the full screen instead of a diff.
|
||||
force_full: bool,
|
||||
}
|
||||
|
||||
impl ScreenEngine {
|
||||
pub fn new(rows: u16, cols: u16) -> Self {
|
||||
let rows = rows as usize;
|
||||
let cols = cols as usize;
|
||||
|
||||
let size = TerminalSize {
|
||||
rows,
|
||||
cols,
|
||||
..Default::default()
|
||||
};
|
||||
let config: Arc<dyn TerminalConfiguration + Send + Sync> = Arc::new(ProxyTermConfig);
|
||||
// We don't currently surface terminal responses (DA queries, cursor
|
||||
// position reports, etc) back upstream. Matches the previous vt100
|
||||
// behavior, which simply ignored them.
|
||||
let writer: Box<dyn Write + Send> = Box::new(std::io::sink());
|
||||
let terminal = Terminal::new(
|
||||
size,
|
||||
config,
|
||||
"claude-chill",
|
||||
env!("CARGO_PKG_VERSION"),
|
||||
writer,
|
||||
);
|
||||
|
||||
let mirror = Surface::new(cols, rows);
|
||||
|
||||
let caps = Capabilities::new_with_hints(
|
||||
ProbeHints::default()
|
||||
.color_level(Some(ColorLevel::TrueColor))
|
||||
.hyperlinks(Some(true))
|
||||
.bracketed_paste(Some(true)),
|
||||
)
|
||||
.expect("Capabilities::new_with_hints");
|
||||
let renderer = TerminfoRenderer::new(caps);
|
||||
|
||||
Self {
|
||||
terminal,
|
||||
mirror,
|
||||
renderer,
|
||||
rows,
|
||||
cols,
|
||||
force_full: true,
|
||||
}
|
||||
}
|
||||
|
||||
/// Feed bytes from the child pty into the emulator.
|
||||
pub fn process(&mut self, data: &[u8]) {
|
||||
self.terminal.advance_bytes(data);
|
||||
}
|
||||
|
||||
pub fn resize(&mut self, rows: u16, cols: u16) {
|
||||
let rows = rows as usize;
|
||||
let cols = cols as usize;
|
||||
self.terminal.resize(TerminalSize {
|
||||
rows,
|
||||
cols,
|
||||
..Default::default()
|
||||
});
|
||||
self.mirror.resize(cols, rows);
|
||||
self.rows = rows;
|
||||
self.cols = cols;
|
||||
self.force_full = true;
|
||||
}
|
||||
|
||||
/// Mark the next `render()` call as a full screen redraw rather than a diff.
|
||||
pub fn force_full_next(&mut self) {
|
||||
self.force_full = true;
|
||||
}
|
||||
|
||||
/// Build the change stream needed to bring downstream from `mirror` to
|
||||
/// the current emulator state, encode it as bytes, and update `mirror`.
|
||||
pub fn render(&mut self) -> Result<Vec<u8>> {
|
||||
let lines = {
|
||||
let screen = self.terminal.screen();
|
||||
let rows = self.rows as i64;
|
||||
let start = screen.phys_row(0);
|
||||
let end = screen.phys_row(rows - 1) + 1;
|
||||
screen.lines_in_phys_range(start..end)
|
||||
};
|
||||
let line_refs: Vec<&_> = lines.iter().collect();
|
||||
|
||||
// If forced, reset the mirror so diff_lines emits the full screen.
|
||||
if self.force_full {
|
||||
self.mirror = Surface::new(self.cols, self.rows);
|
||||
self.force_full = false;
|
||||
}
|
||||
|
||||
let mut changes = self.mirror.diff_lines(line_refs);
|
||||
|
||||
// Position the downstream cursor where the emulator's cursor is.
|
||||
let cursor = self.terminal.cursor_pos();
|
||||
let row = cursor.y.max(0) as usize;
|
||||
let col = cursor.x;
|
||||
changes.push(Change::CursorPosition {
|
||||
x: Position::Absolute(col),
|
||||
y: Position::Absolute(row),
|
||||
});
|
||||
|
||||
// Keep the mirror in sync with what we just emitted (minus the final
|
||||
// cursor reposition, which isn't a content change).
|
||||
self.mirror.add_changes(changes.clone());
|
||||
|
||||
let mut tty = CapturedTty {
|
||||
buf: Vec::new(),
|
||||
cols: self.cols,
|
||||
rows: self.rows,
|
||||
};
|
||||
self.renderer
|
||||
.render_to(&changes, &mut tty)
|
||||
.map_err(|e| anyhow::anyhow!("renderer: {}", e))?;
|
||||
|
||||
// Defensive OSC 8 close: TerminfoRenderer only emits the hyperlink
|
||||
// close when transitioning to a non-hyperlinked cell. If our diff
|
||||
// ends inside the hyperlinked region (rest of the row was blank
|
||||
// and didn't need updating), the close is never emitted and the
|
||||
// downstream terminal may keep the hyperlink active across the
|
||||
// boundary. Appending a close here is a no-op when there's no
|
||||
// active hyperlink.
|
||||
tty.buf.extend_from_slice(b"\x1b]8;;\x1b\\");
|
||||
|
||||
Ok(tty.buf)
|
||||
}
|
||||
|
||||
/// Plain-text contents of the visible screen, for tests / lookback.
|
||||
#[cfg(test)]
|
||||
pub fn screen_text(&mut self) -> String {
|
||||
let lines = {
|
||||
let screen = self.terminal.screen();
|
||||
let rows = self.rows as i64;
|
||||
let start = screen.phys_row(0);
|
||||
let end = screen.phys_row(rows - 1) + 1;
|
||||
screen.lines_in_phys_range(start..end)
|
||||
};
|
||||
let mut out = String::new();
|
||||
for (i, line) in lines.iter().enumerate() {
|
||||
if i > 0 {
|
||||
out.push('\n');
|
||||
}
|
||||
out.push_str(&line.as_str());
|
||||
}
|
||||
out
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
|
||||
fn contains(haystack: &[u8], needle: &[u8]) -> bool {
|
||||
haystack.windows(needle.len()).any(|w| w == needle)
|
||||
}
|
||||
|
||||
fn render(input: &[u8]) -> Vec<u8> {
|
||||
let mut e = ScreenEngine::new(24, 80);
|
||||
e.process(input);
|
||||
e.render().expect("render")
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc8_hyperlink_round_trip() {
|
||||
let mut input = Vec::new();
|
||||
input.extend_from_slice(b"\x1b]8;;https://example.com\x1b\\");
|
||||
input.extend_from_slice(b"link");
|
||||
input.extend_from_slice(b"\x1b]8;;\x1b\\");
|
||||
let out = render(&input);
|
||||
assert!(
|
||||
contains(&out, b"\x1b]8;;https://example.com"),
|
||||
"OSC 8 missing in: {:?}",
|
||||
String::from_utf8_lossy(&out)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strikethrough_round_trip() {
|
||||
let out = render(b"\x1b[9mstruck\x1b[0m");
|
||||
assert!(
|
||||
contains(&out, b"[9m") || contains(&out, b";9m"),
|
||||
"SGR 9 missing in: {:?}",
|
||||
String::from_utf8_lossy(&out)
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truecolor_round_trip() {
|
||||
let out = render(b"\x1b[38;2;255;128;0morange\x1b[0m");
|
||||
assert!(
|
||||
contains(&out, b"38:2::255:128:0")
|
||||
|| contains(&out, b"38;2;255;128;0")
|
||||
|| contains(&out, b"38:2:255:128:0"),
|
||||
"truecolor fg missing in: {:?}",
|
||||
String::from_utf8_lossy(&out)
|
||||
);
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user