From f6b6e4c2aa00a3571c4e1eb07f005b702e39dcd4 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 29 Jul 2026 14:55:58 -0400 Subject: [PATCH 01/13] refactor: extract terminal emulator behind a backend trait The grid was produced directly by an alacritty-specific `Emu` struct, so supporting another emulator meant editing every consumer. Split the seam: - `terminal/cell.rs` holds the neutral grid vocabulary (`EmuCell`, `Color`, `rows_to_strings`) with no emulator dependency - `terminal/emu.rs` defines the `Emulator` trait, the whole contract between the PTY and the grid - `terminal/alacritty.rs` (moved from `emu.rs`) implements it Command/exit/cwd tracking moves out of the emulator into `TermState`. It is derived from the raw PTY byte stream and never touched the alacritty grid, so keeping it outside the trait means every backend reports identical shell integration by construction instead of reimplementing it. `integration.rs` is unchanged. Consumers already spoke `EmuCell`/`Color` rather than alacritty types, so this is import churn for them. No behavior change. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas --- src/assert/color.rs | 4 +- src/assert/snapshot.rs | 2 +- src/daemon/mod.rs | 33 +++--- src/daemon/session.rs | 13 ++- src/monitor.rs | 2 +- src/render/nerd_font.rs | 4 +- src/render/svg.rs | 2 +- src/terminal/alacritty.rs | 158 +++++++++++++++++++++++++ src/terminal/cell.rs | 56 +++++++++ src/terminal/emu.rs | 235 ++++++-------------------------------- src/terminal/locator.rs | 2 +- src/terminal/mod.rs | 2 + 12 files changed, 288 insertions(+), 225 deletions(-) create mode 100644 src/terminal/alacritty.rs create mode 100644 src/terminal/cell.rs diff --git a/src/assert/color.rs b/src/assert/color.rs index 7e0f62c..507ce88 100644 --- a/src/assert/color.rs +++ b/src/assert/color.rs @@ -1,6 +1,6 @@ //! Color parsing and comparison for `expect --fg/--bg`. -use super::super::terminal::emu::Color; +use super::super::terminal::cell::Color; #[derive(Debug, Clone)] pub enum Expected { @@ -161,7 +161,7 @@ pub fn rgb_to_ansi256(r: u8, g: u8, b: u8) -> u8 { #[cfg(test)] mod tests { use super::*; - use crate::terminal::emu::Color; + use crate::terminal::cell::Color; #[test] fn parse_forms() { diff --git a/src/assert/snapshot.rs b/src/assert/snapshot.rs index 409e8c9..ca3b36f 100644 --- a/src/assert/snapshot.rs +++ b/src/assert/snapshot.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use serde_json::{json, Map, Value}; -use super::super::terminal::emu::{Color, EmuCell}; +use super::super::terminal::cell::{Color, EmuCell}; pub enum SnapshotStatus { Passed, diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index df7ca62..a16c7b0 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -20,7 +20,7 @@ use crate::input::{keys, mouse}; use crate::ipc; use crate::monitor; use crate::protocol::{ErrorKind, GetField, MouseAction, Request, Response}; -use crate::terminal::emu::{rows_to_strings, Color, EmuCell}; +use crate::terminal::cell::{rows_to_strings, Color, EmuCell}; use crate::terminal::locator::{self, Pattern}; use logger::Logger; use session::{Session, TermState}; @@ -313,7 +313,7 @@ fn wait_ready(s: &Session) { loop { { let st = s.state.lock().unwrap(); - if st.emu.tracker().is_ready() || st.exited.is_some() { + if st.tracker.is_ready() || st.exited.is_some() { return; } } @@ -415,11 +415,11 @@ fn state(s: &Session) -> Response { "cols": cols, "rows": rows, "cursor": { "x": cx, "y": cy }, - "cwd": st.emu.tracker().cwd(), - "last_command": st.emu.tracker().last_command(), - "last_exit": st.emu.tracker().last_exit(), + "cwd": st.tracker.cwd(), + "last_command": st.tracker.last_command(), + "last_exit": st.tracker.last_exit(), "exited": st.exited, - "ready": st.emu.tracker().is_ready(), + "ready": st.tracker.is_ready(), "text": text, })) } @@ -458,10 +458,10 @@ fn color_json(c: Color) -> serde_json::Value { fn get(s: &Session, field: GetField) -> Response { let st = s.state.lock().unwrap(); let value = match field { - GetField::Command => json!(st.emu.tracker().last_command()), - GetField::Output => json!(st.emu.tracker().last_output()), - GetField::ExitCode => json!(st.emu.tracker().last_exit()), - GetField::Cwd => json!(st.emu.tracker().cwd()), + GetField::Command => json!(st.tracker.last_command()), + GetField::Output => json!(st.tracker.last_output()), + GetField::ExitCode => json!(st.tracker.last_exit()), + GetField::Cwd => json!(st.tracker.cwd()), GetField::Cursor => { let (x, y) = st.emu.cursor(); json!({ "x": x, "y": y }) @@ -604,15 +604,15 @@ fn wait_idle(s: &Session, timeout_ms: u64) -> Response { fn wait_command(s: &Session, timeout_ms: u64) -> Response { let quiet = Duration::from_millis(300); - let baseline = s.state.lock().unwrap().emu.tracker().finished_count(); + let baseline = s.state.lock().unwrap().tracker.finished_count(); let ok = poll_until( || { let st = s.state.lock().unwrap(); - if st.exited.is_some() || st.emu.tracker().finished_count() > baseline { + if st.exited.is_some() || st.tracker.finished_count() > baseline { return true; } let idle = st.last_change.elapsed() >= quiet; - idle && (!st.emu.tracker().started() || st.emu.tracker().is_ready()) + idle && (!st.tracker.started() || st.tracker.is_ready()) }, timeout_ms, ); @@ -748,10 +748,10 @@ fn check_colors( fn expect_exit_code(s: &Session, code: i32) -> Response { poll_until( - || s.state.lock().unwrap().emu.tracker().last_exit().is_some(), + || s.state.lock().unwrap().tracker.last_exit().is_some(), config::DEFAULT_EXPECT_TIMEOUT_MS, ); - let actual = s.state.lock().unwrap().emu.tracker().last_exit(); + let actual = s.state.lock().unwrap().tracker.last_exit(); match actual { Some(a) if a == code => Response::ok(), Some(a) => Response::assertion(format!("expected exit code {code}, got {a}")), @@ -764,8 +764,7 @@ fn expect_output(s: &Session, text: &str, regex: bool) -> Response { .state .lock() .unwrap() - .emu - .tracker() + .tracker .last_output() .map(|o| o.to_string()); let Some(output) = output else { diff --git a/src/daemon/session.rs b/src/daemon/session.rs index fd44b06..ec97827 100644 --- a/src/daemon/session.rs +++ b/src/daemon/session.rs @@ -8,12 +8,17 @@ use std::time::Instant; use crate::daemon::logger::Logger; use crate::shell::{self, Shell}; -use crate::terminal::emu::Emu; +use crate::terminal::alacritty::AlacrittyEmu; +use crate::terminal::emu::Emulator; +use crate::terminal::integration::CommandTracker; use crate::terminal::pty::{Pty, SpawnOptions}; use crate::trace::recorder::Recorder; pub struct TermState { - pub emu: Emu, + pub emu: Box, + /// Shell-integration state, derived from the raw PTY stream rather than + /// the emulator, so it is identical across backends. + pub tracker: CommandTracker, pub last_change: Instant, pub exited: Option, } @@ -60,7 +65,8 @@ impl Session { }; let state = Arc::new(Mutex::new(TermState { - emu: Emu::new(cols, rows, 5_000), + emu: Box::new(AlacrittyEmu::new(cols, rows, 5_000)), + tracker: CommandTracker::new(), last_change: Instant::now(), exited: None, })); @@ -94,6 +100,7 @@ impl Session { let pending = { let mut st = reader_state.lock().unwrap(); st.emu.process(&buf[..n]); + st.tracker.feed(&buf[..n]); st.last_change = Instant::now(); st.emu.take_pending_writes() }; diff --git a/src/monitor.rs b/src/monitor.rs index 48e4394..e706c22 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -11,7 +11,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; -use crate::terminal::emu::{Color, EmuCell}; +use crate::terminal::cell::{Color, EmuCell}; const BORDER: &str = "\x1b[38;5;240m"; const RESET: &str = "\x1b[0m"; diff --git a/src/render/nerd_font.rs b/src/render/nerd_font.rs index 0b035a0..f40241e 100644 --- a/src/render/nerd_font.rs +++ b/src/render/nerd_font.rs @@ -3,7 +3,7 @@ use std::fmt::Write; use ttf_parser::{Face, OutlineBuilder}; -use crate::terminal::emu::EmuCell; +use crate::terminal::cell::EmuCell; // Nerd Fonts Symbols v3.4.0 is MIT-licensed; see the adjacent LICENSE. const FONT_DATA: &[u8] = include_bytes!(concat!( @@ -208,7 +208,7 @@ fn is_powerline_separator(c: char) -> bool { #[cfg(test)] mod tests { use super::*; - use crate::terminal::emu::Color; + use crate::terminal::cell::Color; fn cell(ch: &str) -> EmuCell { EmuCell { diff --git a/src/render/svg.rs b/src/render/svg.rs index d14a070..0dc9c2f 100644 --- a/src/render/svg.rs +++ b/src/render/svg.rs @@ -11,7 +11,7 @@ use std::fmt::Write; use super::nerd_font::NerdFont; -use crate::terminal::emu::{Color, EmuCell}; +use crate::terminal::cell::{Color, EmuCell}; const CELL_W: f32 = 10.0; const CELL_H: f32 = 21.0; diff --git a/src/terminal/alacritty.rs b/src/terminal/alacritty.rs new file mode 100644 index 0000000..edb3e4b --- /dev/null +++ b/src/terminal/alacritty.rs @@ -0,0 +1,158 @@ +//! [`Emulator`] backend built on `alacritty_terminal`. Translates the +//! alacritty grid into shell-use's neutral cell vocabulary, plus a capture +//! proxy that queues the terminal's replies so the reader can forward them +//! back to the PTY. + +use std::sync::{Arc, Mutex}; + +use alacritty_terminal::event::{Event, EventListener}; +use alacritty_terminal::grid::Dimensions; +use alacritty_terminal::index::{Column, Line}; +use alacritty_terminal::term::cell::Flags as AlacFlags; +use alacritty_terminal::term::test::TermSize; +use alacritty_terminal::term::{Config as AlacConfig, Term}; +use alacritty_terminal::vte::ansi; + +use crate::terminal::cell::{Color, EmuCell}; +use crate::terminal::emu::Emulator; + +fn color_from_alac(c: ansi::Color) -> Color { + match c { + ansi::Color::Named(named) => match named { + ansi::NamedColor::Foreground | ansi::NamedColor::Background => Color::Default, + other => Color::Idx(other as u8), + }, + ansi::Color::Spec(rgb) => Color::Rgb(rgb.r, rgb.g, rgb.b), + ansi::Color::Indexed(i) => Color::Idx(i), + } +} + +fn cell_from_alac(c: &alacritty_terminal::term::cell::Cell) -> EmuCell { + let flags = c.flags; + let spacer = flags.contains(AlacFlags::WIDE_CHAR_SPACER) + || flags.contains(AlacFlags::LEADING_WIDE_CHAR_SPACER); + let ch = if spacer || (c.c == ' ' && !flags.contains(AlacFlags::WIDE_CHAR)) { + String::new() + } else { + c.c.to_string() + }; + EmuCell { + ch, + fg: color_from_alac(c.fg), + bg: color_from_alac(c.bg), + bold: flags.contains(AlacFlags::BOLD), + dim: flags.contains(AlacFlags::DIM), + italic: flags.contains(AlacFlags::ITALIC), + underline: flags.intersects( + AlacFlags::UNDERLINE + | AlacFlags::DOUBLE_UNDERLINE + | AlacFlags::DOTTED_UNDERLINE + | AlacFlags::DASHED_UNDERLINE + | AlacFlags::UNDERCURL, + ), + inverse: flags.contains(AlacFlags::INVERSE), + invisible: flags.contains(AlacFlags::HIDDEN), + strike: flags.contains(AlacFlags::STRIKEOUT), + } +} + +#[derive(Default, Clone)] +struct CaptureProxy { + pending: Arc>>, +} + +impl EventListener for CaptureProxy { + fn send_event(&self, ev: Event) { + if let Event::PtyWrite(bytes) = ev { + if let Ok(mut buf) = self.pending.lock() { + buf.extend_from_slice(bytes.as_bytes()); + } + } + } +} + +pub struct AlacrittyEmu { + term: Term, + processor: ansi::Processor, + cols: u16, + rows: u16, + pending: Arc>>, +} + +impl AlacrittyEmu { + pub fn new(cols: u16, rows: u16, scrollback: usize) -> Self { + let size = TermSize::new(cols as usize, rows as usize); + let config = AlacConfig { + scrolling_history: scrollback, + ..Default::default() + }; + let pending: Arc>> = Arc::default(); + let proxy = CaptureProxy { + pending: pending.clone(), + }; + AlacrittyEmu { + term: Term::new(config, &size, proxy), + processor: ansi::Processor::new(), + cols, + rows, + pending, + } + } + + fn rows_in_range(&self, start: i32, end: i32) -> Vec> { + let grid = self.term.grid(); + let mut out = Vec::with_capacity((end - start).max(0) as usize); + for line in start..end { + let mut row = Vec::with_capacity(self.cols as usize); + for col in 0..self.cols as usize { + let cell = &grid[Line(line)][Column(col)]; + row.push(cell_from_alac(cell)); + } + out.push(row); + } + out + } +} + +impl Emulator for AlacrittyEmu { + fn process(&mut self, bytes: &[u8]) { + self.processor.advance(&mut self.term, bytes); + } + + fn take_pending_writes(&mut self) -> Vec { + match self.pending.lock() { + Ok(mut buf) => std::mem::take(&mut *buf), + Err(_) => Vec::new(), + } + } + + fn resize(&mut self, cols: u16, rows: u16) { + self.term + .resize(TermSize::new(cols as usize, rows as usize)); + self.cols = cols; + self.rows = rows; + } + + fn size(&self) -> (u16, u16) { + (self.cols, self.rows) + } + + fn cursor(&self) -> (u16, u16) { + let p = self.term.grid().cursor.point; + let y = p.line.0.max(0).min(self.rows as i32 - 1) as u16; + let x = (p.column.0 as u16).min(self.cols.saturating_sub(1)); + (x, y) + } + + fn viewable_rows(&self) -> Vec> { + self.rows_in_range(0, self.rows as i32) + } + + fn full_rows(&self) -> Vec> { + let grid = self.term.grid(); + let total = grid.total_lines() as i32; + let screen = grid.screen_lines() as i32; + let history = (total - screen).max(0); + self.rows_in_range(-history, screen) + } +} diff --git a/src/terminal/cell.rs b/src/terminal/cell.rs new file mode 100644 index 0000000..a56ea3a --- /dev/null +++ b/src/terminal/cell.rs @@ -0,0 +1,56 @@ +//! Backend-neutral grid vocabulary. +//! +//! Every consumer of the terminal grid (render, assert, monitor, locator) +//! speaks these types and nothing else, so swapping the emulator backend +//! behind [`crate::terminal::emu::Emulator`] is invisible to them. Nothing in +//! this module may depend on a specific emulator crate. + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum Color { + Default, + Idx(u8), + Rgb(u8, u8, u8), +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub struct EmuCell { + /// Empty string means a blank cell (rendered as a space). + pub ch: String, + pub fg: Color, + pub bg: Color, + pub bold: bool, + pub dim: bool, + pub italic: bool, + pub underline: bool, + pub inverse: bool, + pub invisible: bool, + pub strike: bool, +} + +impl Default for EmuCell { + fn default() -> Self { + EmuCell { + ch: String::new(), + fg: Color::Default, + bg: Color::Default, + bold: false, + dim: false, + italic: false, + underline: false, + inverse: false, + invisible: false, + strike: false, + } + } +} + +/// Join a grid of cells into one string per row (blank cells → spaces). +pub fn rows_to_strings(rows: &[Vec]) -> Vec { + rows.iter() + .map(|row| { + row.iter() + .map(|c| if c.ch.is_empty() { " " } else { c.ch.as_str() }) + .collect::() + }) + .collect() +} diff --git a/src/terminal/emu.rs b/src/terminal/emu.rs index e3505aa..83c0ba7 100644 --- a/src/terminal/emu.rs +++ b/src/terminal/emu.rs @@ -1,202 +1,43 @@ -//! Terminal-emulator wrapper around `alacritty_terminal`. Exposes the small -//! grid/cell/color surface the rest of shell-use consumes, plus a capture -//! proxy that queues the terminal's replies so the reader can forward them -//! back to the PTY. - -use std::sync::{Arc, Mutex}; - -use alacritty_terminal::event::{Event, EventListener}; -use alacritty_terminal::grid::Dimensions; -use alacritty_terminal::index::{Column, Line}; -use alacritty_terminal::term::cell::Flags as AlacFlags; -use alacritty_terminal::term::test::TermSize; -use alacritty_terminal::term::{Config as AlacConfig, Term}; -use alacritty_terminal::vte::ansi; - -use crate::terminal::integration::CommandTracker; - -#[derive(Debug, Clone, Copy, PartialEq, Eq)] -pub enum Color { - Default, - Idx(u8), - Rgb(u8, u8, u8), -} - -impl Color { - fn from_alac(c: ansi::Color) -> Self { - match c { - ansi::Color::Named(named) => match named { - ansi::NamedColor::Foreground | ansi::NamedColor::Background => Color::Default, - other => Color::Idx(other as u8), - }, - ansi::Color::Spec(rgb) => Color::Rgb(rgb.r, rgb.g, rgb.b), - ansi::Color::Indexed(i) => Color::Idx(i), - } - } -} - -#[derive(Debug, Clone)] -pub struct EmuCell { - /// Empty string means a blank cell (rendered as a space). - pub ch: String, - pub fg: Color, - pub bg: Color, - pub bold: bool, - pub dim: bool, - pub italic: bool, - pub underline: bool, - pub inverse: bool, - pub invisible: bool, - pub strike: bool, -} - -fn cell_from_alac(c: &alacritty_terminal::term::cell::Cell) -> EmuCell { - let flags = c.flags; - let spacer = flags.contains(AlacFlags::WIDE_CHAR_SPACER) - || flags.contains(AlacFlags::LEADING_WIDE_CHAR_SPACER); - let ch = if spacer || (c.c == ' ' && !flags.contains(AlacFlags::WIDE_CHAR)) { - String::new() - } else { - c.c.to_string() - }; - EmuCell { - ch, - fg: Color::from_alac(c.fg), - bg: Color::from_alac(c.bg), - bold: flags.contains(AlacFlags::BOLD), - dim: flags.contains(AlacFlags::DIM), - italic: flags.contains(AlacFlags::ITALIC), - underline: flags.intersects( - AlacFlags::UNDERLINE - | AlacFlags::DOUBLE_UNDERLINE - | AlacFlags::DOTTED_UNDERLINE - | AlacFlags::DASHED_UNDERLINE - | AlacFlags::UNDERCURL, - ), - inverse: flags.contains(AlacFlags::INVERSE), - invisible: flags.contains(AlacFlags::HIDDEN), - strike: flags.contains(AlacFlags::STRIKEOUT), - } -} - -#[derive(Default, Clone)] -struct CaptureProxy { - pending: Arc>>, -} - -impl EventListener for CaptureProxy { - fn send_event(&self, ev: Event) { - if let Event::PtyWrite(bytes) = ev { - if let Ok(mut buf) = self.pending.lock() { - buf.extend_from_slice(bytes.as_bytes()); - } - } - } -} - -pub struct Emu { - term: Term, - processor: ansi::Processor, - tracker: CommandTracker, - cols: u16, - rows: u16, - pending: Arc>>, -} - -impl Emu { - pub fn new(cols: u16, rows: u16, scrollback: usize) -> Self { - let size = TermSize::new(cols as usize, rows as usize); - let config = AlacConfig { - scrolling_history: scrollback, - ..Default::default() - }; - let pending: Arc>> = Arc::default(); - let proxy = CaptureProxy { - pending: pending.clone(), - }; - Emu { - term: Term::new(config, &size, proxy), - processor: ansi::Processor::new(), - tracker: CommandTracker::new(), - cols, - rows, - pending, - } - } - - /// Feed PTY bytes through the terminal emulator and the command tracker. - pub fn process(&mut self, bytes: &[u8]) { - self.processor.advance(&mut self.term, bytes); - self.tracker.feed(bytes); - } - - /// Access the command tracker. - pub fn tracker(&self) -> &CommandTracker { - &self.tracker - } - - pub fn take_pending_writes(&mut self) -> Vec { - match self.pending.lock() { - Ok(mut buf) => std::mem::take(&mut *buf), - Err(_) => Vec::new(), - } - } - - pub fn resize(&mut self, cols: u16, rows: u16) { - self.term - .resize(TermSize::new(cols as usize, rows as usize)); - self.cols = cols; - self.rows = rows; - } - - pub fn size(&self) -> (u16, u16) { - (self.cols, self.rows) - } +//! The terminal-emulator seam. +//! +//! shell-use drives a PTY and reads back a cell grid. [`Emulator`] is the +//! entire contract between the two, so an emulator backend can be swapped +//! without touching render, assert, monitor, or the daemon. +//! +//! Command/exit/cwd tracking is deliberately *not* part of this trait: it is +//! derived from the raw PTY byte stream by [`crate::terminal::integration`], +//! which is backend-independent. Keeping it out means every backend reports +//! identical shell-integration behavior by construction rather than by +//! reimplementation. + +use crate::terminal::cell::EmuCell; + +/// A headless terminal emulator: bytes in, cell grid out. +/// +/// Implementations must be `Send`; the daemon shares the emulator across its +/// reader, request, and monitor threads behind a mutex. A backend whose native +/// handle is `!Send` is expected to confine that handle to its own thread and +/// implement this trait on a `Send` handle. +pub trait Emulator: Send { + /// Feed PTY output bytes into the emulator. + fn process(&mut self, bytes: &[u8]); + + /// Drain bytes the emulator wants written back to the PTY (device + /// attribute replies, cursor position reports, and similar). The caller + /// forwards these to the PTY. + fn take_pending_writes(&mut self) -> Vec; + + fn resize(&mut self, cols: u16, rows: u16); + + /// Current grid size as `(cols, rows)`. + fn size(&self) -> (u16, u16); /// Cursor position as `(x, y)` (column, row), 0-based, clamped to screen. - pub fn cursor(&self) -> (u16, u16) { - let p = self.term.grid().cursor.point; - let y = p.line.0.max(0).min(self.rows as i32 - 1) as u16; - let x = (p.column.0 as u16).min(self.cols.saturating_sub(1)); - (x, y) - } + fn cursor(&self) -> (u16, u16); - /// Visible screen as rows of cells. - pub fn viewable_rows(&self) -> Vec> { - self.rows_in_range(0, self.rows as i32) - } - - /// History + visible screen as rows of cells. - pub fn full_rows(&self) -> Vec> { - let grid = self.term.grid(); - let total = grid.total_lines() as i32; - let screen = grid.screen_lines() as i32; - let history = (total - screen).max(0); - self.rows_in_range(-history, screen) - } - - fn rows_in_range(&self, start: i32, end: i32) -> Vec> { - let grid = self.term.grid(); - let mut out = Vec::with_capacity((end - start).max(0) as usize); - for line in start..end { - let mut row = Vec::with_capacity(self.cols as usize); - for col in 0..self.cols as usize { - let cell = &grid[Line(line)][Column(col)]; - row.push(cell_from_alac(cell)); - } - out.push(row); - } - out - } -} + /// Visible screen as rows of cells. Always `rows` entries of `cols` cells. + fn viewable_rows(&self) -> Vec>; -/// Join a grid of cells into one string per row (blank cells → spaces). -pub fn rows_to_strings(rows: &[Vec]) -> Vec { - rows.iter() - .map(|row| { - row.iter() - .map(|c| if c.ch.is_empty() { " " } else { c.ch.as_str() }) - .collect::() - }) - .collect() + /// Scrollback history followed by the visible screen. + fn full_rows(&self) -> Vec>; } diff --git a/src/terminal/locator.rs b/src/terminal/locator.rs index b62f110..821d5c4 100644 --- a/src/terminal/locator.rs +++ b/src/terminal/locator.rs @@ -3,7 +3,7 @@ use regex::Regex; -use super::emu::EmuCell; +use super::cell::EmuCell; pub enum Pattern { Text(String), diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 0f38b89..2be5d5f 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -1,3 +1,5 @@ +pub mod alacritty; +pub mod cell; pub mod emu; pub mod integration; pub mod locator; From 522e3a3381640f2fe2f621052d4f2741060abccf Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 29 Jul 2026 14:56:21 -0400 Subject: [PATCH 02/13] test: add backend-agnostic emulator conformance suite Swapping the emulator behind the `Emulator` trait silently changes what `expect`, `snapshot`, and the SVG renderer see, and the grid had no tests at all. Add a macro-generated suite that every backend opts into with one line, so a second backend is verified against the same contract as the first. Covers grid shape, blank-vs-space cells, SGR attributes, palette/RGB/default colors, wide-char spacers, cursor position and clamping, resize, scrollback retention and ordering, alternate screen, erase, PTY write-back for DSR, and escape sequences split across reads. Each case is its own test, so a failure names the part of the contract that broke. 17 cases pass against the alacritty backend. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas --- src/terminal/alacritty.rs | 7 + src/terminal/conformance.rs | 290 ++++++++++++++++++++++++++++++++++++ src/terminal/mod.rs | 2 + 3 files changed, 299 insertions(+) create mode 100644 src/terminal/conformance.rs diff --git a/src/terminal/alacritty.rs b/src/terminal/alacritty.rs index edb3e4b..2bd9b93 100644 --- a/src/terminal/alacritty.rs +++ b/src/terminal/alacritty.rs @@ -156,3 +156,10 @@ impl Emulator for AlacrittyEmu { self.rows_in_range(-history, screen) } } + +#[cfg(test)] +mod tests { + use super::*; + + crate::emulator_conformance_tests!(|c, r, s| Box::new(AlacrittyEmu::new(c, r, s))); +} diff --git a/src/terminal/conformance.rs b/src/terminal/conformance.rs new file mode 100644 index 0000000..6b35396 --- /dev/null +++ b/src/terminal/conformance.rs @@ -0,0 +1,290 @@ +//! Backend-agnostic conformance suite for [`crate::terminal::emu::Emulator`]. +//! +//! Every backend must produce the same grid for the same bytes, otherwise +//! swapping emulators silently changes what `expect`, `snapshot`, and the SVG +//! renderer see. Backends opt in with one line: +//! +//! ```ignore +//! crate::emulator_conformance_tests!(|c, r, s| Box::new(AlacrittyEmu::new(c, r, s))); +//! ``` +//! +//! Each case becomes a separate `#[test]` in the calling module, so a failure +//! names the exact part of the contract that broke. + +/// Generates the conformance tests for one backend. `$make` builds a boxed +/// emulator from `(cols, rows, scrollback)`. +#[macro_export] +macro_rules! emulator_conformance_tests { + ($make:expr) => { + #[allow(unused_imports)] + use $crate::terminal::cell::{rows_to_strings, Color}; + #[allow(unused_imports)] + use $crate::terminal::emu::Emulator; + + fn conformance_emu(cols: u16, rows: u16, scrollback: usize) -> Box { + let make: fn(u16, u16, usize) -> Box = $make; + make(cols, rows, scrollback) + } + + /// The grid is always exactly `rows` x `cols`, regardless of content. + #[test] + fn conformance_grid_shape_is_exact() { + let mut e = conformance_emu(10, 4, 100); + e.process(b"hi"); + let rows = e.viewable_rows(); + assert_eq!(rows.len(), 4, "row count must equal terminal rows"); + for row in &rows { + assert_eq!(row.len(), 10, "every row must be full width"); + } + assert_eq!(e.size(), (10, 4)); + } + + /// Printable text lands on the grid, and untouched cells are blank. + #[test] + fn conformance_text_and_blank_cells() { + let mut e = conformance_emu(10, 2, 100); + e.process(b"abc"); + let rows = e.viewable_rows(); + assert_eq!(rows[0][0].ch, "a"); + assert_eq!(rows[0][1].ch, "b"); + assert_eq!(rows[0][2].ch, "c"); + assert_eq!( + rows[0][3].ch, "", + "blank cells must be the empty string, not a space" + ); + assert_eq!(rows_to_strings(&rows)[0], "abc "); + } + + /// CR/LF moves to the next row rather than wrapping text together. + #[test] + fn conformance_newline_moves_row() { + let mut e = conformance_emu(10, 3, 100); + e.process(b"one\r\ntwo"); + let text = rows_to_strings(&e.viewable_rows()); + assert_eq!(text[0].trim_end(), "one"); + assert_eq!(text[1].trim_end(), "two"); + } + + /// Every SGR attribute the cell vocabulary exposes round-trips. + #[test] + fn conformance_sgr_attributes() { + let mut e = conformance_emu(20, 2, 100); + // bold, dim, italic, underline, inverse, hidden, strikethrough + e.process(b"\x1b[1;2;3;4;7;8;9mX\x1b[0mY"); + let rows = e.viewable_rows(); + let x = &rows[0][0]; + assert!(x.bold, "bold"); + assert!(x.dim, "dim"); + assert!(x.italic, "italic"); + assert!(x.underline, "underline"); + assert!(x.inverse, "inverse"); + assert!(x.invisible, "invisible"); + assert!(x.strike, "strike"); + + let y = &rows[0][1]; + assert!(!y.bold && !y.dim && !y.italic, "SGR 0 must reset"); + assert!(!y.underline && !y.inverse && !y.invisible && !y.strike); + } + + /// Extended underline styles still report as `underline`. + #[test] + fn conformance_extended_underline_is_underline() { + let mut e = conformance_emu(10, 2, 100); + e.process(b"\x1b[4:3mU"); + assert!( + e.viewable_rows()[0][0].underline, + "curly underline must map to underline" + ); + } + + /// Named, 256-palette, and 24-bit colors map onto the color vocabulary. + #[test] + fn conformance_colors() { + let mut e = conformance_emu(20, 2, 100); + e.process(b"\x1b[31mR"); + e.process(b"\x1b[38;5;196mP"); + e.process(b"\x1b[38;2;10;20;30mT"); + e.process(b"\x1b[0mD"); + let rows = e.viewable_rows(); + assert_eq!(rows[0][0].fg, Color::Idx(1), "named red is palette index 1"); + assert_eq!(rows[0][1].fg, Color::Idx(196), "256-color palette index"); + assert_eq!(rows[0][2].fg, Color::Rgb(10, 20, 30), "24-bit truecolor"); + assert_eq!( + rows[0][3].fg, + Color::Default, + "reset returns to default, not an index" + ); + } + + /// Background colors are tracked independently of foreground. + #[test] + fn conformance_background_color() { + let mut e = conformance_emu(10, 2, 100); + e.process(b"\x1b[44mB"); + let cell = e.viewable_rows()[0][0].clone(); + assert_eq!(cell.bg, Color::Idx(4), "named blue background"); + assert_eq!(cell.fg, Color::Default); + } + + /// A double-width char occupies its cell; its spacer reads as blank so + /// text extraction keeps column alignment. + #[test] + fn conformance_wide_char_spacer_is_blank() { + let mut e = conformance_emu(10, 2, 100); + e.process("你a".as_bytes()); + let rows = e.viewable_rows(); + assert_eq!(rows[0][0].ch, "你"); + assert_eq!(rows[0][1].ch, "", "wide-char spacer must be blank"); + assert_eq!(rows[0][2].ch, "a", "next char sits after the spacer"); + } + + /// Cursor tracks writes and absolute positioning, 0-based as (x, y). + #[test] + fn conformance_cursor_position() { + let mut e = conformance_emu(10, 4, 100); + e.process(b"abc"); + assert_eq!(e.cursor(), (3, 0), "cursor follows printed text"); + + e.process(b"\x1b[3;5H"); + assert_eq!(e.cursor(), (4, 2), "CUP is 1-based, cursor() is 0-based"); + } + + /// The cursor never escapes the grid, even when driven past the edge. + #[test] + fn conformance_cursor_clamped_to_grid() { + let mut e = conformance_emu(10, 4, 100); + e.process(b"\x1b[999;999H"); + let (x, y) = e.cursor(); + assert!(x < 10, "cursor x {x} must stay inside 10 cols"); + assert!(y < 4, "cursor y {y} must stay inside 4 rows"); + } + + /// Resizing updates the reported size and the grid shape. + #[test] + fn conformance_resize() { + let mut e = conformance_emu(10, 4, 100); + e.process(b"hello"); + e.resize(20, 6); + assert_eq!(e.size(), (20, 6)); + let rows = e.viewable_rows(); + assert_eq!(rows.len(), 6); + assert_eq!(rows[0].len(), 20); + } + + /// Scrolled-off lines leave the viewport but stay in `full_rows`. + #[test] + fn conformance_scrollback_retained() { + let mut e = conformance_emu(10, 3, 100); + e.process(b"L1\r\nL2\r\nL3\r\nL4\r\nL5\r\nL6"); + + let view = rows_to_strings(&e.viewable_rows()); + assert_eq!(view.len(), 3); + assert_eq!(view[2].trim_end(), "L6", "viewport shows the newest line"); + assert!( + !view.iter().any(|r| r.trim_end() == "L1"), + "L1 must have scrolled out of the viewport" + ); + + let full = rows_to_strings(&e.full_rows()); + assert!( + full.len() > view.len(), + "full_rows must include scrollback: {} vs {}", + full.len(), + view.len() + ); + assert!( + full.iter().any(|r| r.trim_end() == "L1"), + "scrolled-off L1 must survive in full_rows" + ); + assert_eq!( + full.last().map(|r| r.trim_end().to_string()), + Some("L6".to_string()), + "full_rows ends with the newest line (history first, screen last)" + ); + } + + /// With no scrolling, history is empty and both views agree. + #[test] + fn conformance_full_rows_without_history() { + let mut e = conformance_emu(10, 4, 100); + e.process(b"only"); + assert_eq!( + e.full_rows().len(), + e.viewable_rows().len(), + "no scroll means no extra history rows" + ); + } + + /// Queries that require an answer are queued for the PTY, and draining + /// is destructive so replies are not sent twice. + #[test] + fn conformance_pty_write_back() { + let mut e = conformance_emu(10, 4, 100); + assert!( + e.take_pending_writes().is_empty(), + "nothing pending before any query" + ); + + // Device Status Report: the terminal must answer with a position. + e.process(b"\x1b[6n"); + let reply = e.take_pending_writes(); + assert!( + !reply.is_empty(), + "DSR must produce a reply, or programs will hang waiting" + ); + assert!( + reply.starts_with(b"\x1b["), + "reply should be a CSI sequence, got {:?}", + String::from_utf8_lossy(&reply) + ); + assert!( + e.take_pending_writes().is_empty(), + "draining must consume the queue" + ); + } + + /// The alternate screen hides primary content and restores it on exit. + #[test] + fn conformance_alt_screen_round_trip() { + let mut e = conformance_emu(10, 3, 100); + e.process(b"primary"); + e.process(b"\x1b[?1049h"); + let alt = rows_to_strings(&e.viewable_rows()); + assert!( + !alt.iter().any(|r| r.contains("primary")), + "alt screen must start clear" + ); + + e.process(b"\x1b[?1049l"); + let back = rows_to_strings(&e.viewable_rows()); + assert!( + back.iter().any(|r| r.contains("primary")), + "leaving alt screen restores primary content" + ); + } + + /// Erase sequences clear cells back to blank. + #[test] + fn conformance_erase_clears_cells() { + let mut e = conformance_emu(10, 2, 100); + e.process(b"abcdef"); + e.process(b"\x1b[H\x1b[2J"); + let text = rows_to_strings(&e.viewable_rows()); + assert_eq!(text[0], " ", "ED 2 clears the screen"); + } + + /// Byte-split escape sequences still parse; PTY reads chunk arbitrarily. + #[test] + fn conformance_split_escape_sequence() { + let mut e = conformance_emu(10, 2, 100); + e.process(b"\x1b["); + e.process(b"31m"); + e.process(b"R"); + assert_eq!( + e.viewable_rows()[0][0].fg, + Color::Idx(1), + "a sequence split across process() calls must still apply" + ); + } + }; +} diff --git a/src/terminal/mod.rs b/src/terminal/mod.rs index 2be5d5f..a451e81 100644 --- a/src/terminal/mod.rs +++ b/src/terminal/mod.rs @@ -1,5 +1,7 @@ pub mod alacritty; pub mod cell; +#[cfg(test)] +pub mod conformance; pub mod emu; pub mod integration; pub mod locator; From 0fe2e03225ee6ea5600a83b1d9f611fef8e8ea19 Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 29 Jul 2026 15:11:13 -0400 Subject: [PATCH 03/13] test: tighten emulator conformance assertions Review found several cases asserted far less than their names claimed, so a materially broken backend could pass the suite that exists to gate backends: - cursor clamping accepted any in-bounds value, including (0, 0); now pins the exact bottom-right cell - DSR accepted any CSI prefix; now pins the exact cursor position report - erase and blank cells checked rendered text only, so a backend that dropped colors or attributes passed; now compares whole cells against the default - resize checked only the reported size, never that content survived - full_rows compared lengths, not contents, and never pinned that history is followed by exactly the viewport, which `grid(full = true)` relies on Also covers areas that were untested: split UTF-8 across reads (the reader uses a fixed 8 KiB buffer, so real output splits codepoints), autowrap, tabs, bare CR overwrite, backspace, erase-to-end-of-line, and the scrollback limit, which no case exercised despite the daemon requesting 5000 rows. Verified by mutation: a backend that reports the cursor at the origin, renders blank cells as spaces, or drops scrollback now fails 8 cases; before it passed all of them. Tabs pin only cursor advance and landing column. Alacritty stores a literal tab in the skipped cells and other emulators store blanks, so asserting the rendered text there would fail a correct backend. The macro no longer emits `use` statements into the caller's module; a second backend whose test module imports Color or Emulator itself would have hit E0252. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas --- src/terminal/conformance.rs | 300 ++++++++++++++++++++++++++++-------- 1 file changed, 239 insertions(+), 61 deletions(-) diff --git a/src/terminal/conformance.rs b/src/terminal/conformance.rs index 6b35396..984f85a 100644 --- a/src/terminal/conformance.rs +++ b/src/terminal/conformance.rs @@ -10,22 +10,36 @@ //! //! Each case becomes a separate `#[test]` in the calling module, so a failure //! names the exact part of the contract that broke. +//! +//! Assertions here encode the *contract*, not one implementation. Where real +//! emulators legitimately diverge (reflow when shrinking below content width) +//! the test pins only the part that is universal and says why it stops short. /// Generates the conformance tests for one backend. `$make` builds a boxed /// emulator from `(cols, rows, scrollback)`. +/// +/// The body is fully path-qualified because it expands into the caller's +/// module; it must not collide with whatever that module already imports. #[macro_export] macro_rules! emulator_conformance_tests { ($make:expr) => { - #[allow(unused_imports)] - use $crate::terminal::cell::{rows_to_strings, Color}; - #[allow(unused_imports)] - use $crate::terminal::emu::Emulator; - - fn conformance_emu(cols: u16, rows: u16, scrollback: usize) -> Box { - let make: fn(u16, u16, usize) -> Box = $make; + fn conformance_emu( + cols: u16, + rows: u16, + scrollback: usize, + ) -> Box { + let make: fn(u16, u16, usize) -> Box = $make; make(cols, rows, scrollback) } + /// Row text with trailing blanks removed, for readable assertions. + fn conformance_text(rows: &[Vec<$crate::terminal::cell::EmuCell>]) -> Vec { + $crate::terminal::cell::rows_to_strings(rows) + .into_iter() + .map(|r| r.trim_end().to_string()) + .collect() + } + /// The grid is always exactly `rows` x `cols`, regardless of content. #[test] fn conformance_grid_shape_is_exact() { @@ -39,7 +53,21 @@ macro_rules! emulator_conformance_tests { assert_eq!(e.size(), (10, 4)); } + /// `full_rows` is as rectangular as `viewable_rows`; ragged history + /// rows would misalign the boxed snapshot output. + #[test] + fn conformance_full_rows_are_rectangular() { + let mut e = conformance_emu(10, 3, 100); + e.process(b"a\r\nbb\r\nccc\r\ndddd\r\neeeee"); + for row in e.full_rows() { + assert_eq!(row.len(), 10, "history rows must be full width too"); + } + } + /// Printable text lands on the grid, and untouched cells are blank. + /// + /// A blank cell must be *fully* default: `expect --bg` and the SVG + /// renderer read color off cells the shell never painted. #[test] fn conformance_text_and_blank_cells() { let mut e = conformance_emu(10, 2, 100); @@ -49,10 +77,14 @@ macro_rules! emulator_conformance_tests { assert_eq!(rows[0][1].ch, "b"); assert_eq!(rows[0][2].ch, "c"); assert_eq!( - rows[0][3].ch, "", - "blank cells must be the empty string, not a space" + rows[0][3], + $crate::terminal::cell::EmuCell::default(), + "an untouched cell must be blank with default colors and no attributes" + ); + assert_eq!( + $crate::terminal::cell::rows_to_strings(&rows)[0], + "abc " ); - assert_eq!(rows_to_strings(&rows)[0], "abc "); } /// CR/LF moves to the next row rather than wrapping text together. @@ -60,9 +92,55 @@ macro_rules! emulator_conformance_tests { fn conformance_newline_moves_row() { let mut e = conformance_emu(10, 3, 100); e.process(b"one\r\ntwo"); - let text = rows_to_strings(&e.viewable_rows()); - assert_eq!(text[0].trim_end(), "one"); - assert_eq!(text[1].trim_end(), "two"); + let text = conformance_text(&e.viewable_rows()); + assert_eq!(text[0], "one"); + assert_eq!(text[1], "two"); + } + + /// A bare CR returns to column 0 so later bytes overwrite in place. + /// Progress bars and readline redraws depend on this. + #[test] + fn conformance_carriage_return_overwrites() { + let mut e = conformance_emu(10, 2, 100); + e.process(b"12345\rab"); + assert_eq!(conformance_text(&e.viewable_rows())[0], "ab345"); + } + + /// Backspace moves the cursor back without erasing; the next byte + /// overwrites in place. + #[test] + fn conformance_backspace_moves_cursor() { + let mut e = conformance_emu(10, 2, 100); + e.process(b"abc\x08X"); + assert_eq!(conformance_text(&e.viewable_rows())[0], "abX"); + assert_eq!(e.cursor(), (3, 0)); + } + + /// Text longer than the row wraps onto the next row. `expect text` and + /// `locator::find` flatten the grid, so wrap placement is observable. + #[test] + fn conformance_autowrap_at_right_margin() { + let mut e = conformance_emu(5, 3, 100); + e.process(b"abcdefgh"); + let text = conformance_text(&e.viewable_rows()); + assert_eq!(text[0], "abcde", "first row fills to the margin"); + assert_eq!(text[1], "fgh", "the remainder continues on the next row"); + } + + /// A tab advances the cursor to the next 8-column tab stop. + /// + /// What a backend leaves *in* the skipped cells is genuinely + /// divergent — alacritty stores a literal `\t` there, others store + /// blanks — so this pins only cursor advance and landing column, + /// which every emulator agrees on. + #[test] + fn conformance_tab_advances_to_stop() { + let mut e = conformance_emu(20, 2, 100); + e.process(b"a\tb"); + let rows = e.viewable_rows(); + assert_eq!(rows[0][0].ch, "a"); + assert_eq!(rows[0][8].ch, "b", "next glyph lands on the 8-column stop"); + assert_eq!(e.cursor(), (9, 0), "cursor sits just past the tab stop"); } /// Every SGR attribute the cell vocabulary exposes round-trips. @@ -82,6 +160,7 @@ macro_rules! emulator_conformance_tests { assert!(x.strike, "strike"); let y = &rows[0][1]; + assert_eq!(y.ch, "Y"); assert!(!y.bold && !y.dim && !y.italic, "SGR 0 must reset"); assert!(!y.underline && !y.inverse && !y.invisible && !y.strike); } @@ -106,12 +185,24 @@ macro_rules! emulator_conformance_tests { e.process(b"\x1b[38;2;10;20;30mT"); e.process(b"\x1b[0mD"); let rows = e.viewable_rows(); - assert_eq!(rows[0][0].fg, Color::Idx(1), "named red is palette index 1"); - assert_eq!(rows[0][1].fg, Color::Idx(196), "256-color palette index"); - assert_eq!(rows[0][2].fg, Color::Rgb(10, 20, 30), "24-bit truecolor"); + assert_eq!( + rows[0][0].fg, + $crate::terminal::cell::Color::Idx(1), + "named red is palette index 1" + ); + assert_eq!( + rows[0][1].fg, + $crate::terminal::cell::Color::Idx(196), + "256-color palette index" + ); + assert_eq!( + rows[0][2].fg, + $crate::terminal::cell::Color::Rgb(10, 20, 30), + "24-bit truecolor" + ); assert_eq!( rows[0][3].fg, - Color::Default, + $crate::terminal::cell::Color::Default, "reset returns to default, not an index" ); } @@ -122,8 +213,12 @@ macro_rules! emulator_conformance_tests { let mut e = conformance_emu(10, 2, 100); e.process(b"\x1b[44mB"); let cell = e.viewable_rows()[0][0].clone(); - assert_eq!(cell.bg, Color::Idx(4), "named blue background"); - assert_eq!(cell.fg, Color::Default); + assert_eq!( + cell.bg, + $crate::terminal::cell::Color::Idx(4), + "named blue background" + ); + assert_eq!(cell.fg, $crate::terminal::cell::Color::Default); } /// A double-width char occupies its cell; its spacer reads as blank so @@ -138,6 +233,19 @@ macro_rules! emulator_conformance_tests { assert_eq!(rows[0][2].ch, "a", "next char sits after the spacer"); } + /// A multi-byte character split across reads must still decode. The + /// reader fills a fixed 8 KiB buffer, so this happens on real output; + /// a backend that decoded each chunk independently would corrupt the + /// grid here while passing every other case. + #[test] + fn conformance_split_utf8_sequence() { + let mut e = conformance_emu(10, 2, 100); + let text = "héllo".as_bytes(); + e.process(&text[..2]); // splits the two-byte 'é' + e.process(&text[2..]); + assert_eq!(conformance_text(&e.viewable_rows())[0], "héllo"); + } + /// Cursor tracks writes and absolute positioning, 0-based as (x, y). #[test] fn conformance_cursor_position() { @@ -149,43 +257,75 @@ macro_rules! emulator_conformance_tests { assert_eq!(e.cursor(), (4, 2), "CUP is 1-based, cursor() is 0-based"); } - /// The cursor never escapes the grid, even when driven past the edge. + /// Positioning past the edge clamps to the last cell, rather than + /// wrapping, saturating to zero, or escaping the grid. #[test] fn conformance_cursor_clamped_to_grid() { let mut e = conformance_emu(10, 4, 100); e.process(b"\x1b[999;999H"); - let (x, y) = e.cursor(); - assert!(x < 10, "cursor x {x} must stay inside 10 cols"); - assert!(y < 4, "cursor y {y} must stay inside 4 rows"); + assert_eq!( + e.cursor(), + (9, 3), + "out-of-range CUP clamps to the bottom-right cell" + ); } - /// Resizing updates the reported size and the grid shape. + /// Growing the terminal updates the reported size and keeps content. #[test] - fn conformance_resize() { + fn conformance_resize_grow_preserves_content() { let mut e = conformance_emu(10, 4, 100); e.process(b"hello"); e.resize(20, 6); + assert_eq!(e.size(), (20, 6)); let rows = e.viewable_rows(); assert_eq!(rows.len(), 6); assert_eq!(rows[0].len(), 20); + assert_eq!( + conformance_text(&rows)[0], + "hello", + "growing must not discard existing content" + ); } - /// Scrolled-off lines leave the viewport but stay in `full_rows`. + /// Shrinking reports the new size and keeps content that still fits. + /// + /// Reflow of content wider than the new width legitimately differs + /// between emulators, so this pins only the narrow, universal case. #[test] - fn conformance_scrollback_retained() { + fn conformance_resize_shrink_keeps_fitting_content() { + let mut e = conformance_emu(20, 6, 100); + e.process(b"hey"); + e.resize(10, 4); + + assert_eq!(e.size(), (10, 4)); + let rows = e.viewable_rows(); + assert_eq!(rows.len(), 4); + assert_eq!(rows[0].len(), 10); + assert!( + conformance_text(&rows).iter().any(|r| r == "hey"), + "content narrower than the new width must survive shrinking" + ); + } + + /// Scrolled-off lines leave the viewport but stay in `full_rows`, and + /// `full_rows` ends with exactly the viewport. `grid(full = true)` in + /// the daemon depends on that ordering. + #[test] + fn conformance_scrollback_retained_and_ordered() { let mut e = conformance_emu(10, 3, 100); e.process(b"L1\r\nL2\r\nL3\r\nL4\r\nL5\r\nL6"); - let view = rows_to_strings(&e.viewable_rows()); - assert_eq!(view.len(), 3); - assert_eq!(view[2].trim_end(), "L6", "viewport shows the newest line"); + let view = e.viewable_rows(); + let view_text = conformance_text(&view); + assert_eq!(view_text.len(), 3); + assert_eq!(view_text[2], "L6", "viewport shows the newest line"); assert!( - !view.iter().any(|r| r.trim_end() == "L1"), + !view_text.iter().any(|r| r == "L1"), "L1 must have scrolled out of the viewport" ); - let full = rows_to_strings(&e.full_rows()); + let full = e.full_rows(); assert!( full.len() > view.len(), "full_rows must include scrollback: {} vs {}", @@ -193,49 +333,69 @@ macro_rules! emulator_conformance_tests { view.len() ); assert!( - full.iter().any(|r| r.trim_end() == "L1"), + conformance_text(&full).iter().any(|r| r == "L1"), "scrolled-off L1 must survive in full_rows" ); assert_eq!( - full.last().map(|r| r.trim_end().to_string()), - Some("L6".to_string()), - "full_rows ends with the newest line (history first, screen last)" + &full[full.len() - view.len()..], + &view[..], + "full_rows must be history followed by exactly the viewport" ); } - /// With no scrolling, history is empty and both views agree. + /// With no scrolling, history is empty and `full_rows` *is* the + /// viewport. #[test] fn conformance_full_rows_without_history() { let mut e = conformance_emu(10, 4, 100); e.process(b"only"); assert_eq!( - e.full_rows().len(), - e.viewable_rows().len(), - "no scroll means no extra history rows" + e.full_rows(), + e.viewable_rows(), + "no scroll means full_rows matches the viewport exactly" + ); + } + + /// The scrollback limit is honored. A backend that ignores it grows + /// without bound in a long-lived daemon session. + #[test] + fn conformance_scrollback_is_bounded() { + let rows = 3u16; + let scrollback = 2usize; + let mut e = conformance_emu(10, rows, scrollback); + for i in 0..20 { + e.process(format!("line{i}\r\n").as_bytes()); + } + let total = e.full_rows().len(); + assert!( + total >= rows as usize, + "full_rows ({total}) must still contain the viewport" + ); + assert!( + total <= rows as usize + scrollback + 1, + "full_rows ({total}) must respect the {scrollback}-row scrollback limit" ); } /// Queries that require an answer are queued for the PTY, and draining - /// is destructive so replies are not sent twice. + /// is destructive so replies are not sent twice. The reply must be + /// available as soon as `process` returns: a backend that parsed + /// asynchronously would hang every program that probes the terminal. #[test] fn conformance_pty_write_back() { let mut e = conformance_emu(10, 4, 100); - assert!( - e.take_pending_writes().is_empty(), - "nothing pending before any query" - ); + // A backend may queue its own startup handshake; only the reply to + // our query matters. + let _ = e.take_pending_writes(); - // Device Status Report: the terminal must answer with a position. - e.process(b"\x1b[6n"); + // Device Status Report: the terminal must answer with the cursor + // position, 1-based, as CSI ; R. + e.process(b"\x1b[3;5H\x1b[6n"); let reply = e.take_pending_writes(); - assert!( - !reply.is_empty(), - "DSR must produce a reply, or programs will hang waiting" - ); - assert!( - reply.starts_with(b"\x1b["), - "reply should be a CSI sequence, got {:?}", - String::from_utf8_lossy(&reply) + assert_eq!( + String::from_utf8_lossy(&reply), + "\x1b[3;5R", + "DSR must report the cursor position, or programs hang waiting" ); assert!( e.take_pending_writes().is_empty(), @@ -249,28 +409,46 @@ macro_rules! emulator_conformance_tests { let mut e = conformance_emu(10, 3, 100); e.process(b"primary"); e.process(b"\x1b[?1049h"); - let alt = rows_to_strings(&e.viewable_rows()); + let alt = conformance_text(&e.viewable_rows()); assert!( !alt.iter().any(|r| r.contains("primary")), "alt screen must start clear" ); e.process(b"\x1b[?1049l"); - let back = rows_to_strings(&e.viewable_rows()); + let back = conformance_text(&e.viewable_rows()); assert!( back.iter().any(|r| r.contains("primary")), "leaving alt screen restores primary content" ); } - /// Erase sequences clear cells back to blank. + /// Erase resets cells to fully default, not merely to a space. #[test] fn conformance_erase_clears_cells() { let mut e = conformance_emu(10, 2, 100); e.process(b"abcdef"); e.process(b"\x1b[H\x1b[2J"); - let text = rows_to_strings(&e.viewable_rows()); - assert_eq!(text[0], " ", "ED 2 clears the screen"); + for (x, cell) in e.viewable_rows()[0].iter().enumerate() { + assert_eq!( + cell, + &$crate::terminal::cell::EmuCell::default(), + "ED 2 must reset cell {x} to a default cell" + ); + } + } + + /// Erase to end of line clears from the cursor rightward only. + #[test] + fn conformance_erase_line_from_cursor() { + let mut e = conformance_emu(10, 2, 100); + e.process(b"abcdef"); + e.process(b"\x1b[1;4H\x1b[K"); + assert_eq!( + conformance_text(&e.viewable_rows())[0], + "abc", + "EL clears from the cursor to end of line, keeping the prefix" + ); } /// Byte-split escape sequences still parse; PTY reads chunk arbitrarily. @@ -282,7 +460,7 @@ macro_rules! emulator_conformance_tests { e.process(b"R"); assert_eq!( e.viewable_rows()[0][0].fg, - Color::Idx(1), + $crate::terminal::cell::Color::Idx(1), "a sequence split across process() calls must still apply" ); } From aacfc958fdd3f7b41b13d103ee4163641d185f7b Mon Sep 17 00:00:00 2001 From: Ayman Bagabas Date: Wed, 29 Jul 2026 16:53:25 -0400 Subject: [PATCH 04/13] refactor: make the neutral cell model lossless MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The grid vocabulary flattened away detail that the emulator had already parsed, and each loss surfaced somewhere downstream: - `Color::Default` was indistinguishable from ANSI 0, so `expect --fg 0` matched a default-colored cell and claimed it was black when the theme paints it light gray. Colors are now `Option`, and a default cell matches no expected value at all. - Palette slots 0-15 are themeable and 16-255 are fixed, but both landed in `Color::Idx`, leaving every consumer to re-derive the split. They are now `Color::Named` and `Color::Idx`, divided by numeric range rather than by how the escape sequence spelled it, so backends that carry only a palette index agree with backends that carry a name. - Five distinct underline styles collapsed into one bool, and the underline's own color was dropped entirely. `Option` keeps both. - A blank cell and the second column of a double-width character were both the empty string, and text extraction substituted a space for each. That invented a column: `你a` extracted as `你 a`, shifting every subsequent column and breaking text matching. Blanks now hold a real space and the empty string means continuation, which extraction skips. Attributes move into a `bitflags` set, which adds blink and turns the per-cell style comparison in the render and monitor loops into a single integer compare. `ch` becomes a `CompactString`, inline for anything up to 24 bytes, so the grid no longer heap-allocates per cell and can hold the combining marks the backend was discarding. The JSON wire format is unchanged: named and indexed colors both serialize to their palette index and underline stays a boolean, so the JS and Python bindings keep working. The one visible difference is that a continuation cell now reports `""` instead of a space, which the binding types document. Co-authored-by: Copilot <223556219+Copilot@users.noreply.github.com> Signed-off-by: Ayman Bagabas --- Cargo.lock | 54 +++++- Cargo.toml | 2 + bindings/js/src/types.ts | 1 + bindings/python/src/shell_use/types.py | 2 + src/assert/color.rs | 75 +++++---- src/assert/snapshot.rs | 56 +++---- src/daemon/mod.rs | 35 ++-- src/monitor.rs | 95 +++++------ src/render/nerd_font.rs | 14 +- src/render/svg.rs | 89 ++++------ src/terminal/alacritty.rs | 98 ++++++++--- src/terminal/cell.rs | 219 +++++++++++++++++++++---- src/terminal/conformance.rs | 105 ++++++++---- src/terminal/locator.rs | 15 +- 14 files changed, 543 insertions(+), 317 deletions(-) diff --git a/Cargo.lock b/Cargo.lock index a9f7f7f..ae8f237 100644 --- a/Cargo.lock +++ b/Cargo.lock @@ -24,7 +24,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "bda177466b9524d59f1b12f0dd30b68696788e9992a7e959021c4a0ed96fcf59" dependencies = [ "base64", - "bitflags 2.13.0", + "bitflags 2.13.1", "home", "libc", "log", @@ -124,13 +124,22 @@ checksum = "bef38d45163c2f1dde094a7dfd33ccf595c92905c8f8f4fdc18d06fb1037718a" [[package]] name = "bitflags" -version = "2.13.0" +version = "2.13.1" source = "registry+https://github.com/rust-lang/crates.io-index" -checksum = "b4388bee8683e3d04af747c73422af53102d2bd24d9eadb6cbc100baef4b43f8" +checksum = "b588b76d00fde79687d7646a9b5bdf3cc0f655e0bbd080335a95d7e96f3587da" dependencies = [ "serde_core", ] +[[package]] +name = "castaway" +version = "0.2.4" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "dec551ab6e7578819132c713a93c022a05d60159dc86e7a7050223577484c55a" +dependencies = [ + "rustversion", +] + [[package]] name = "cfg-if" version = "1.0.4" @@ -189,6 +198,19 @@ version = "1.0.5" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "1d07550c9036bf2ae0c684c4297d503f838287c83c53686d05370d0e139ae570" +[[package]] +name = "compact_str" +version = "0.10.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "79fcda08c33bb58b97008b2cdada6622500e949e060f5913361763121abd2416" +dependencies = [ + "castaway", + "cfg-if", + "itoa", + "static_assertions", + "zmij", +] + [[package]] name = "concurrent-queue" version = "2.5.0" @@ -232,7 +254,7 @@ version = "0.28.1" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "829d955a0bb380ef178a640b91779e3987da38c9aea133b20614cfed8cdea9c6" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "crossterm_winapi", "mio", "parking_lot", @@ -498,7 +520,7 @@ version = "0.28.0" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ab2156c4fce2f8df6c499cc1c763e4394b7482525bf2a9701c9d79d215f519e4" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "cfg-if", "cfg_aliases", "libc", @@ -627,7 +649,7 @@ version = "0.5.18" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "ed2bf2547551a7053d6fdfafda3f938979645c44812fbfcda098faae3f1a362d" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", ] [[package]] @@ -676,7 +698,7 @@ version = "0.38.44" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "fdb5bc1ae2baa591800df16c9ca78619bf65c0488b41b96ccec5d11220d8c154" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.4.15", @@ -689,7 +711,7 @@ version = "1.1.4" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "b6fe4565b9518b83ef4f91bb47ce29620ca828bd32cb7e408f0062e9930ba190" dependencies = [ - "bitflags 2.13.0", + "bitflags 2.13.1", "errno", "libc", "linux-raw-sys 0.12.1", @@ -707,6 +729,12 @@ dependencies = [ "rustix 1.1.4", ] +[[package]] +name = "rustversion" +version = "1.0.23" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "cf54715a573b99ac80df0bc206da022bcd442c974952c7b9720069370852e21f" + [[package]] name = "scopeguard" version = "1.2.0" @@ -783,7 +811,9 @@ version = "0.0.1-beta.5" dependencies = [ "alacritty_terminal", "anyhow", + "bitflags 2.13.1", "clap", + "compact_str", "crossterm", "dialoguer", "dirs", @@ -855,6 +885,12 @@ version = "1.15.2" source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "8ed6a63f02c8539c91a8685a86f4099661ba3da017932f6ebbea6de3f0fa7c90" +[[package]] +name = "static_assertions" +version = "1.1.0" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "a2eb9349b6444b326872e140eb1cf5e7c522154d69e7a0ffb0fb81c06b37543f" + [[package]] name = "strsim" version = "0.11.1" @@ -943,7 +979,7 @@ source = "registry+https://github.com/rust-lang/crates.io-index" checksum = "a5924018406ce0063cd67f8e008104968b74b563ee1b85dde3ed1f7cb87d3dbd" dependencies = [ "arrayvec", - "bitflags 2.13.0", + "bitflags 2.13.1", "cursor-icon", "log", "memchr", diff --git a/Cargo.toml b/Cargo.toml index d5dfc11..23a4454 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -29,3 +29,5 @@ serde_json = "1.0.150" crossterm = "0.28" ttf-parser = { version = "0.25.1", default-features = false, features = ["std"] } dialoguer = { version = "0.11", default-features = false } +compact_str = "0.10.0" +bitflags = "2.13.1" diff --git a/bindings/js/src/types.ts b/bindings/js/src/types.ts index 0831b47..83cbd48 100644 --- a/bindings/js/src/types.ts +++ b/bindings/js/src/types.ts @@ -24,6 +24,7 @@ export interface Size { export interface Cell { x: number; y: number; + /** The cell's grapheme; `" "` when blank, `""` for the second column of a double-width character. */ char: string; fg: Color; bg: Color; diff --git a/bindings/python/src/shell_use/types.py b/bindings/python/src/shell_use/types.py index 8746cae..589fedc 100644 --- a/bindings/python/src/shell_use/types.py +++ b/bindings/python/src/shell_use/types.py @@ -10,6 +10,8 @@ class Cell: x: int y: int + #: The cell's grapheme; ``" "`` when blank, ``""`` for the second column + #: of a double-width character. char: str fg: Color bg: Color diff --git a/src/assert/color.rs b/src/assert/color.rs index 507ce88..4569108 100644 --- a/src/assert/color.rs +++ b/src/assert/color.rs @@ -53,43 +53,39 @@ fn parse_hex(hex: &str) -> anyhow::Result<(u8, u8, u8)> { } /// Does a cell's resolved color match the expected color? -pub fn matches(cell: Color, expected: &Expected) -> bool { +/// +/// A cell using the terminal default never matches: the default is whatever +/// the viewer's theme picks, so no concrete value can be claimed for it. +pub fn matches(cell: Option, expected: &Expected) -> bool { + let Some(cell) = cell else { + return false; + }; match expected { - Expected::Ansi256(n) => match cell { - Color::Default => *n == 0, - Color::Idx(i) => i == *n, - Color::Rgb(r, g, b) => rgb_to_ansi256(r, g, b) == *n, - }, + Expected::Ansi256(n) => cell.to_index() == *n, Expected::Hex(er, eg, eb) | Expected::Rgb(er, eg, eb) => { - let (er, eg, eb) = (*er, *eg, *eb); - match cell { - Color::Default => er == 0 && eg == 0 && eb == 0, - Color::Idx(i) => { - let (r, g, b) = ansi256_to_rgb(i); - r == er && g == eg && b == eb - } - Color::Rgb(r, g, b) => r == er && g == eg && b == eb, - } + let rgb = match cell { + Color::Rgb(r, g, b) => (r, g, b), + c => ansi256_to_rgb(c.to_index()), + }; + rgb == (*er, *eg, *eb) } } } /// Render a cell's color in the same space as the expected value, for messages. -pub fn describe_cell(cell: Color, expected: &Expected) -> String { +pub fn describe_cell(cell: Option, expected: &Expected) -> String { + let Some(cell) = cell else { + return "default".to_string(); + }; match expected { - Expected::Ansi256(_) => match cell { - Color::Default => "0".to_string(), - Color::Idx(i) => i.to_string(), - Color::Rgb(r, g, b) => rgb_to_ansi256(r, g, b).to_string(), - }, - _ => match cell { - Color::Default => "#000000".to_string(), - Color::Idx(i) => { - let (r, g, b) = ansi256_to_rgb(i); - format!("#{r:02x}{g:02x}{b:02x}") - } - Color::Rgb(r, g, b) => format!("#{r:02x}{g:02x}{b:02x}"), - }, + Expected::Ansi256(_) => cell.to_index().to_string(), + _ => { + let (r, g, b) = match cell { + Color::Rgb(r, g, b) => (r, g, b), + c => ansi256_to_rgb(c.to_index()), + }; + format!("#{r:02x}{g:02x}{b:02x}") + } } } @@ -181,10 +177,23 @@ mod tests { #[test] fn matches_palette_and_default() { - assert!(matches(Color::Idx(9), &Expected::Ansi256(9))); - assert!(!matches(Color::Idx(2), &Expected::Ansi256(9))); - assert!(matches(Color::Default, &Expected::Ansi256(0))); - assert!(matches(Color::Rgb(255, 0, 0), &Expected::Rgb(255, 0, 0))); + let idx = |i| Some(Color::from_index(i)); + assert!(matches(idx(9), &Expected::Ansi256(9))); + assert!(!matches(idx(2), &Expected::Ansi256(9))); + assert!(matches(idx(196), &Expected::Ansi256(196))); + assert!(matches( + Some(Color::Rgb(255, 0, 0)), + &Expected::Rgb(255, 0, 0) + )); + } + + /// A default-colored cell used to match `--fg 0`, claiming it was black + /// when the theme actually paints it light gray. + #[test] + fn default_color_matches_nothing() { + assert!(!matches(None, &Expected::Ansi256(0))); + assert!(!matches(None, &Expected::Hex(0, 0, 0))); + assert_eq!(describe_cell(None, &Expected::Ansi256(0)), "default"); } #[test] diff --git a/src/assert/snapshot.rs b/src/assert/snapshot.rs index ca3b36f..fe06f70 100644 --- a/src/assert/snapshot.rs +++ b/src/assert/snapshot.rs @@ -4,7 +4,7 @@ use std::path::{Path, PathBuf}; use serde_json::{json, Map, Value}; -use super::super::terminal::cell::{Color, EmuCell}; +use super::super::terminal::cell::{Attrs, Color, EmuCell}; pub enum SnapshotStatus { Passed, @@ -27,11 +27,11 @@ fn snapshot_path(base: &Path, name: &str) -> PathBuf { snapshot_dir(base).join(format!("{}.snap", sanitize(name))) } -fn color_value(c: Color) -> Value { +fn color_value(c: Option) -> Value { match c { - Color::Default => Value::String("default".to_string()), - Color::Idx(i) => json!(i), - Color::Rgb(r, g, b) => Value::String(format!("#{r:02x}{g:02x}{b:02x}")), + None => Value::String("default".to_string()), + Some(Color::Rgb(r, g, b)) => Value::String(format!("#{r:02x}{g:02x}{b:02x}")), + Some(c) => json!(c.to_index()), } } @@ -43,43 +43,27 @@ fn shift(prev: &EmuCell, cur: &EmuCell) -> Map { if prev.bg != cur.bg { m.insert("bg".into(), color_value(cur.bg)); } - if prev.bold != cur.bold { - m.insert("bold".into(), json!(cur.bold)); - } - if prev.dim != cur.dim { - m.insert("dim".into(), json!(cur.dim)); - } - if prev.italic != cur.italic { - m.insert("italic".into(), json!(cur.italic)); - } - if prev.underline != cur.underline { - m.insert("underline".into(), json!(cur.underline)); - } - if prev.inverse != cur.inverse { - m.insert("inverse".into(), json!(cur.inverse)); - } - if prev.invisible != cur.invisible { - m.insert("invisible".into(), json!(cur.invisible)); + for (attr, key) in [ + (Attrs::BOLD, "bold"), + (Attrs::DIM, "dim"), + (Attrs::ITALIC, "italic"), + (Attrs::INVERSE, "inverse"), + (Attrs::INVISIBLE, "invisible"), + (Attrs::STRIKE, "strike"), + (Attrs::BLINK, "blink"), + ] { + if prev.has(attr) != cur.has(attr) { + m.insert(key.into(), json!(cur.has(attr))); + } } - if prev.strike != cur.strike { - m.insert("strike".into(), json!(cur.strike)); + if prev.underline.is_some() != cur.underline.is_some() { + m.insert("underline".into(), json!(cur.underline.is_some())); } m } fn baseline() -> EmuCell { - EmuCell { - ch: String::new(), - fg: Color::Default, - bg: Color::Default, - bold: false, - dim: false, - italic: false, - underline: false, - inverse: false, - invisible: false, - strike: false, - } + EmuCell::blank() } /// Serialize a grid into a boxed text view plus (optionally) a color shift map. diff --git a/src/daemon/mod.rs b/src/daemon/mod.rs index a16c7b0..3c0c2d1 100644 --- a/src/daemon/mod.rs +++ b/src/daemon/mod.rs @@ -20,7 +20,7 @@ use crate::input::{keys, mouse}; use crate::ipc; use crate::monitor; use crate::protocol::{ErrorKind, GetField, MouseAction, Request, Response}; -use crate::terminal::cell::{rows_to_strings, Color, EmuCell}; +use crate::terminal::cell::{rows_to_strings, Attrs, Color, EmuCell}; use crate::terminal::locator::{self, Pattern}; use logger::Logger; use session::{Session, TermState}; @@ -433,13 +433,13 @@ fn cells(s: &Session, x: u16, y: u16, w: u16, h: u16) -> Response { out.push(json!({ "x": col, "y": row, - "char": if cell.ch.is_empty() { " ".to_string() } else { cell.ch.clone() }, + "char": cell.ch.as_str(), "fg": color_json(cell.fg), "bg": color_json(cell.bg), - "bold": cell.bold, - "italic": cell.italic, - "underline": cell.underline, - "inverse": cell.inverse, + "bold": cell.has(Attrs::BOLD), + "italic": cell.has(Attrs::ITALIC), + "underline": cell.underline.is_some(), + "inverse": cell.has(Attrs::INVERSE), })); } } @@ -447,11 +447,14 @@ fn cells(s: &Session, x: u16, y: u16, w: u16, h: u16) -> Response { Response::with(json!({ "cells": out })) } -fn color_json(c: Color) -> serde_json::Value { +/// Wire form: `"default"`, a 256-color index, or `"#rrggbb"`. Named and +/// indexed colors both serialize to their palette index, so the split between +/// them stays internal and the language bindings are unaffected. +fn color_json(c: Option) -> serde_json::Value { match c { - Color::Default => json!("default"), - Color::Idx(i) => json!(i), - Color::Rgb(r, g, b) => json!(format!("#{r:02x}{g:02x}{b:02x}")), + None => json!("default"), + Some(Color::Rgb(r, g, b)) => json!(format!("#{r:02x}{g:02x}{b:02x}")), + Some(c) => json!(c.to_index()), } } @@ -712,11 +715,7 @@ fn check_colors( if not { "absent" } else { "present" }, expected.describe(), color::describe_cell(c.cell.fg, &expected), - if c.cell.ch.is_empty() { - " " - } else { - &c.cell.ch - }, + c.cell.ch, c.x, c.y )); @@ -732,11 +731,7 @@ fn check_colors( if not { "absent" } else { "present" }, expected.describe(), color::describe_cell(c.cell.bg, &expected), - if c.cell.ch.is_empty() { - " " - } else { - &c.cell.ch - }, + c.cell.ch, c.x, c.y )); diff --git a/src/monitor.rs b/src/monitor.rs index e706c22..83f8f45 100644 --- a/src/monitor.rs +++ b/src/monitor.rs @@ -11,7 +11,7 @@ use std::sync::atomic::{AtomicBool, Ordering}; use std::sync::Arc; use std::time::Duration; -use crate::terminal::cell::{Color, EmuCell}; +use crate::terminal::cell::{Attrs, Color, EmuCell, Underline, UnderlineStyle}; const BORDER: &str = "\x1b[38;5;240m"; const RESET: &str = "\x1b[0m"; @@ -80,23 +80,16 @@ fn content(out: &mut String, f: &Frame, inner_w: usize, inner_h: usize) { let row = f.grid.get(y); let mut last: Option