feat(terminal): port render state, highlights and the formatter

- render.rs ports render.zig: RenderState, the per-frame snapshot the
  renderer consumes (rows, cells, styles, graphemes, selection,
  highlights, cursor, colors), updated incrementally with Zig's full
  redraw triggers and per-page/per-row dirty tracking. Cells are stored
  struct-of-arrays; use the accessors.
- highlight.rs ports highlight.zig (untracked, tracked, flattened).
- formatter.rs ports formatter.zig: plain, VT and HTML output for
  terminal, screen, page list and page, with selection, rectangle,
  unwrap, trim, palette, codepoint maps, hyperlinks and pin/point maps.
  Screen's test dump helpers now delegate to it.

Cargo.toml also picks up dependencies for the kitty graphics and search
modules that are being ported concurrently.

Test Plan:
- cargo test -p ghostty-terminal --lib (1243 passed; formatter 100/100,
  render 15/15 Zig tests)
This commit is contained in:
ddidderr committed 2026-10-11 22:50:22 +02:00
1 parent f43c0cd00f
commit 6a374b6953
13 files changed
+5510 -356

No files matched your search

@@ -0,0 +1,53 @@
//! Ported formatter.zig tests, split by Zig source line range:
//! p1 = "Page plain ..." (Zig lines 1580-3287), p2 = "Page VT ...", "PageList ..."
//! and "TerminalFormatter ..." (3288-4458), p3 = "Screen ...", "Terminal vt ...",
//! "Page html ..." up to "Page VT style reset properly closes styles"
//! (4459-5617), p4 = "Page codepoint_map ..." onwards (5618-end).
#![allow(dead_code, clippy::needless_range_loop)]
use super::*;
use crate::{
page::Page,
page_list::Pin,
point::{Coordinate, Point},
terminal::{Options as TermOptions, Terminal},
};
mod p1;
mod p2;
mod p3;
mod p4;
/// Zig `Terminal.init(alloc, .{ .cols, .rows })`.
pub(super) fn term(cols: u16, rows: u16) -> Terminal {
Terminal::new(TermOptions::new(cols, rows)).unwrap()
}
/// Zig `var s = t.vtStream(); try s.nextSlice(bytes)` (fresh stream per call;
/// use `t.vt_stream()` directly if parser state must persist).
pub(super) fn feed(t: &mut Terminal, bytes: &[u8]) {
let mut s = t.vt_stream();
s.next_slice(bytes).unwrap();
}
/// Zig `&pages.pages.last.?.data`.
pub(super) fn last_page(t: &Terminal) -> &Page {
let pages = &t.screen().pages;
&pages.node(pages.last.unwrap()).unwrap().data
}
/// Zig `&pages.pages.first.?.data`.
pub(super) fn first_page(t: &Terminal) -> &Page {
let pages = &t.screen().pages;
&pages.node(pages.first.unwrap()).unwrap().data
}
/// Zig `pages.pages.first.?.data.capacity.rows`.
pub(super) fn first_page_rows(t: &Terminal) -> u16 {
first_page(t).capacity.rows
}
/// Format a whole `PageFormatter` into a string, returning the output.
pub(super) fn fmt_str(f: PageFormatter<'_>) -> String {
f.format_string().unwrap()
}
@@ -0,0 +1,589 @@
//! "Page plain ..." tests (formatter.zig lines 1580-3287).
use super::*;
struct Out {
s: String,
st: TrailingState,
map: Vec<Coordinate>,
}
/// Runs a PageFormatter over the last page with a point map attached.
fn run(t: &Terminal, opts: &Options, cfg: impl FnOnce(&mut PageFormatter<'_>)) -> Out {
let page = last_page(t);
let mut map = Vec::new();
let mut buf = Vec::new();
let st = {
let mut f = PageFormatter::new(page, opts);
f.point_map = Some(&mut map);
cfg(&mut f);
f.format_with_state(&mut buf).unwrap()
};
Out {
s: String::from_utf8(buf).unwrap(),
st,
map,
}
}
fn c(x: u16, y: u32) -> Coordinate {
Coordinate { x, y }
}
/// `n` consecutive cells on row `y` starting at column `x0`.
fn span(x0: u16, n: u16, y: u32) -> Vec<Coordinate> {
(0..n).map(|i| c(x0 + i, y)).collect()
}
fn setup(cols: u16, rows: u16, input: &str) -> Terminal {
let mut t = term(cols, rows);
feed(&mut t, input.as_bytes());
// Verify we have only a single page
let pages = &t.screen().pages;
assert!(pages.first.is_some());
assert_eq!(pages.first, pages.last);
t
}
fn unwrap_opts() -> Options {
Options {
unwrap: true,
..Options::plain()
}
}
#[test]
fn page_plain_single_line() {
let t = setup(80, 24, "hello, world");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |_| {});
assert_eq!(o.s, "hello, world");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 12));
assert_eq!(o.map, span(0, 12, 0));
}
#[test]
fn page_plain_single_line_soft_wrapped_unwrapped() {
let t = setup(3, 5, "hello!");
let o = run(&t, &unwrap_opts(), |_| {});
assert_eq!(o.s, "hello!");
let mut e = span(0, 3, 0);
e.extend(span(0, 3, 1));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_single_wide_char() {
let t = setup(80, 24, "1A⚡");
let page = last_page(&t);
let opts = Options::plain();
// Full string
let o = run(&t, &opts, |_| {});
assert_eq!(o.s, "1A⚡");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 4));
assert_eq!(o.map.len(), o.s.len());
for i in 2..o.s.len() {
assert_eq!(o.map[i], c(2, 0));
}
// Wide only (from start)
let o = run(&t, &opts, |f| f.start_x = 2);
assert_eq!(o.s, "⚡");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 4));
assert_eq!(o.map, vec![c(2, 0); o.s.len()]);
// Wide only (from tail)
let o = run(&t, &opts, |f| f.start_x = 3);
assert_eq!(o.s, "⚡");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 4));
assert_eq!(o.map, vec![c(2, 0); o.s.len()]);
}
#[test]
fn page_plain_single_wide_char_soft_wrapped_unwrapped() {
let t = setup(3, 24, "1A⚡");
let page = last_page(&t);
let opts = unwrap_opts();
// Full string
let o = run(&t, &opts, |_| {});
assert_eq!(o.s, "1A⚡");
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
assert_eq!(o.st.cells, usize::from(page.size.cols - 2));
assert_eq!(o.map.len(), o.s.len());
for i in 2..o.s.len() {
assert_eq!(o.map[i], c(0, 1));
}
// Full string (ending on spacer head)
let o = run(&t, &opts, |f| {
f.end_x = Some(2);
f.end_y = Some(0);
});
assert_eq!(o.s, "1A⚡");
assert_eq!(o.map.len(), o.s.len());
for i in 2..o.s.len() {
assert_eq!(o.map[i], c(0, 1));
}
// Wide only (from start)
let o = run(&t, &opts, |f| f.start_x = 2);
assert_eq!(o.s, "⚡");
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
assert_eq!(o.st.cells, usize::from(page.size.cols - 2));
assert_eq!(o.map, vec![c(0, 1); o.s.len()]);
// Wide only (from tail). In Zig the formatter carried start_x = 2 over
// from the previous block; start_y = 1, start_x = 1.
let o = run(&t, &opts, |f| {
f.start_y = 1;
f.start_x = 1;
});
assert_eq!(o.s, "⚡");
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
assert_eq!(o.st.cells, usize::from(page.size.cols - 2));
assert_eq!(o.map, vec![c(0, 1); o.s.len()]);
}
#[test]
fn page_plain_multiline() {
let t = setup(80, 24, "hello\r\nworld");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |_| {});
assert_eq!(o.s, "hello\nworld");
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
let mut e = span(0, 5, 0);
e.push(c(4, 0)); // \n
e.extend(span(0, 5, 1));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_multiline_rectangle() {
let t = setup(80, 24, "hello\r\nworld");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| {
f.start_x = 1;
f.end_x = Some(3);
f.rectangle = true;
});
assert_eq!(o.s, "ell\norl");
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
assert_eq!(o.st.cells, 0);
let mut e = span(1, 3, 0);
e.push(c(3, 0)); // \n
e.extend(span(1, 3, 1));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_multi_blank_lines() {
let t = setup(80, 24, "hello\r\n\r\n\r\nworld");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |_| {});
assert_eq!(o.s, "hello\n\n\nworld");
assert_eq!(o.st.rows, usize::from(page.size.rows - 3));
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
let mut e = span(0, 5, 0);
e.push(c(4, 0)); // \n after row 0
e.push(c(0, 1)); // \n after blank row 1
e.push(c(0, 2)); // \n after blank row 2
e.extend(span(0, 5, 3));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_trailing_blank_lines() {
let t = setup(80, 24, "hello\r\nworld\r\n\r\n");
let page = last_page(&t);
// We expect there to be no trailing newlines because we can't
// differentiate trailing blank lines as being meaningful because the
// page formatter can't see the cursor position.
let o = run(&t, &Options::plain(), |_| {});
assert_eq!(o.s, "hello\nworld");
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
let mut e = span(0, 5, 0);
e.push(c(4, 0));
e.extend(span(0, 5, 1));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_trailing_whitespace() {
let t = setup(80, 24, "hello \r\nworld ");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |_| {});
assert_eq!(o.s, "hello\nworld");
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
let mut e = span(0, 5, 0);
e.push(c(4, 0));
e.extend(span(0, 5, 1));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_trailing_whitespace_no_trim() {
let t = setup(80, 24, "hello \r\nworld ");
let page = last_page(&t);
let opts = Options {
trim: false,
..Options::plain()
};
let o = run(&t, &opts, |_| {});
assert_eq!(o.s, "hello \nworld ");
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
assert_eq!(o.st.cells, usize::from(page.size.cols - 7));
let mut e = span(0, 8, 0);
e.push(c(7, 0));
e.extend(span(0, 7, 1));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_with_prior_trailing_state_rows() {
let t = setup(80, 24, "hello");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| {
f.trailing_state = Some(TrailingState { rows: 2, cells: 0 });
});
assert_eq!(o.s, "\n\nhello");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
let mut e = vec![c(0, 0), c(0, 1)];
e.extend(span(0, 5, 0));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_with_prior_trailing_state_cells_no_wrapped_line() {
let t = setup(80, 24, "hello");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| {
f.trailing_state = Some(TrailingState { rows: 0, cells: 3 });
});
// Blank cells are reset when row is not a wrap continuation
assert_eq!(o.s, "hello");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
assert_eq!(o.map, span(0, 5, 0));
}
#[test]
fn page_plain_with_prior_trailing_state_cells_with_wrap_continuation() {
let mut t = setup(80, 24, "world");
// Surgically modify the first row to be a wrap continuation
{
let pages = &mut t.screen_mut().pages;
let id = pages.last.unwrap();
pages
.node_mut(id)
.unwrap()
.data
.get_row_mut(0)
.unwrap()
.set_wrap_continuation(true);
}
let page = last_page(&t);
let o = run(&t, &unwrap_opts(), |f| {
f.trailing_state = Some(TrailingState { rows: 0, cells: 3 });
});
// Blank cells are preserved when row is a wrap continuation with unwrap enabled
assert_eq!(o.s, " world");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
// 3 spaces from prior trailing state (can't go back beyond (0,0)) + "world"
let mut e = vec![c(0, 0); 3];
e.extend(span(0, 5, 0));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_soft_wrapped_without_unwrap() {
let t = setup(10, 24, "hello world test");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |_| {});
// Without unwrap, wrapped lines show as separate lines
assert_eq!(o.s, "hello worl\nd test");
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
assert_eq!(o.st.cells, usize::from(page.size.cols - 6));
let mut e = span(0, 10, 0);
e.push(c(9, 0)); // \n
e.extend(span(0, 6, 1));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_soft_wrapped_with_unwrap() {
let t = setup(10, 24, "hello world test");
let page = last_page(&t);
let o = run(&t, &unwrap_opts(), |_| {});
// With unwrap, wrapped lines are joined together
assert_eq!(o.s, "hello world test");
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
assert_eq!(o.st.cells, usize::from(page.size.cols - 6));
let mut e = span(0, 10, 0);
e.extend(span(0, 6, 1));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_soft_wrapped_3_lines_without_unwrap() {
let t = setup(10, 24, "hello world this is a test");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |_| {});
assert_eq!(o.s, "hello worl\nd this is\na test");
assert_eq!(o.st.rows, usize::from(page.size.rows - 2));
assert_eq!(o.st.cells, usize::from(page.size.cols - 6));
let mut e = span(0, 10, 0);
e.push(c(9, 0)); // \n
e.extend(span(0, 9, 1));
e.push(c(8, 1)); // \n
e.extend(span(0, 6, 2));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_soft_wrapped_3_lines_with_unwrap() {
let t = setup(10, 24, "hello world this is a test");
let page = last_page(&t);
let o = run(&t, &unwrap_opts(), |_| {});
assert_eq!(o.s, "hello world this is a test");
assert_eq!(o.st.rows, usize::from(page.size.rows - 2));
assert_eq!(o.st.cells, usize::from(page.size.cols - 6));
let mut e = span(0, 10, 0);
e.extend(span(0, 10, 1));
e.extend(span(0, 6, 2));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_start_y_subset() {
let t = setup(80, 24, "hello\r\nworld\r\ntest");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| f.start_y = 1);
assert_eq!(o.s, "world\ntest");
assert_eq!(o.st.rows, usize::from(page.size.rows - 2));
assert_eq!(o.st.cells, usize::from(page.size.cols - 4));
let mut e = span(0, 5, 1);
e.push(c(4, 1)); // \n
e.extend(span(0, 4, 2));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_end_y_subset() {
let t = setup(80, 24, "hello\r\nworld\r\ntest");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| f.end_y = Some(1));
assert_eq!(o.s, "hello\nworld");
assert_eq!(o.st.rows, 1);
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
let mut e = span(0, 5, 0);
e.push(c(4, 0)); // \n
e.extend(span(0, 5, 1));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_start_y_and_end_y_range() {
let t = setup(80, 24, "hello\r\nworld\r\ntest\r\nfoo");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| {
f.start_y = 1;
f.end_y = Some(2);
});
assert_eq!(o.s, "world\ntest");
assert_eq!(o.st.rows, 1);
assert_eq!(o.st.cells, usize::from(page.size.cols - 4));
let mut e = span(0, 5, 1);
e.push(c(4, 1)); // \n
e.extend(span(0, 4, 2));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_start_y_out_of_bounds() {
let t = setup(80, 24, "hello");
let o = run(&t, &Options::plain(), |f| f.start_y = 30);
assert_eq!(o.s, "");
assert_eq!(o.st.rows, 0);
assert_eq!(o.st.cells, 0);
assert_eq!(o.map.len(), 0);
}
#[test]
fn page_plain_end_y_greater_than_rows() {
let t = setup(80, 24, "hello");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| f.end_y = Some(30));
// Should clamp to page.size.rows and work normally
assert_eq!(o.s, "hello");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
assert_eq!(o.map, span(0, 5, 0));
}
#[test]
fn page_plain_end_y_less_than_start_y() {
let t = setup(80, 24, "hello");
let o = run(&t, &Options::plain(), |f| {
f.start_y = 5;
f.end_y = Some(2);
});
assert_eq!(o.s, "");
assert_eq!(o.st.rows, 0);
assert_eq!(o.st.cells, 0);
assert_eq!(o.map.len(), 0);
}
#[test]
fn page_plain_start_x_on_first_row_only() {
let t = setup(80, 24, "hello world");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| f.start_x = 6);
assert_eq!(o.s, "world");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 11));
assert_eq!(o.map, span(6, 5, 0));
}
#[test]
fn page_plain_end_x_on_last_row_only() {
let t = setup(80, 24, "first line\r\nsecond line\r\nthird line");
let o = run(&t, &Options::plain(), |f| {
f.end_y = Some(2);
f.end_x = Some(4);
});
assert_eq!(o.s, "first line\nsecond line\nthird");
assert_eq!(o.st.rows, 1);
assert_eq!(o.st.cells, 0);
let mut e = span(0, 10, 0);
e.push(c(9, 0)); // \n
e.extend(span(0, 11, 1));
e.push(c(10, 1)); // \n
e.extend(span(0, 5, 2));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_start_x_and_end_x_multiline() {
let t = setup(80, 24, "hello world\r\ntest case\r\nfoo bar");
let o = run(&t, &Options::plain(), |f| {
f.start_x = 6;
f.end_y = Some(2);
f.end_x = Some(2);
});
// First row: "world" (start_x=6 to end of row)
// Second row: "test case" (full row)
// Third row: "foo" (start to end_x=2, inclusive)
assert_eq!(o.s, "world\ntest case\nfoo");
assert_eq!(o.st.rows, 1);
assert_eq!(o.st.cells, 0);
let mut e = span(6, 5, 0);
e.push(c(10, 0)); // \n
e.extend(span(0, 9, 1));
e.push(c(8, 1)); // \n
e.extend(span(0, 3, 2));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_start_x_out_of_bounds() {
let t = setup(80, 24, "hello");
let o = run(&t, &Options::plain(), |f| f.start_x = 100);
assert_eq!(o.s, "");
assert_eq!(o.st.rows, 0);
assert_eq!(o.st.cells, 0);
assert_eq!(o.map.len(), 0);
}
#[test]
fn page_plain_end_x_greater_than_cols() {
let t = setup(80, 24, "hello");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| f.end_x = Some(100));
assert_eq!(o.s, "hello");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
assert_eq!(o.map, span(0, 5, 0));
}
#[test]
fn page_plain_end_x_less_than_start_x_single_row() {
let t = setup(80, 24, "hello");
let o = run(&t, &Options::plain(), |f| {
f.start_x = 10;
f.end_y = Some(0);
f.end_x = Some(5);
});
assert_eq!(o.s, "");
assert_eq!(o.st.rows, 0);
assert_eq!(o.st.cells, 0);
assert_eq!(o.map.len(), 0);
}
#[test]
fn page_plain_start_y_non_zero_ignores_trailing_state() {
let t = setup(80, 24, "hello\r\nworld");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| {
f.start_y = 1;
f.trailing_state = Some(TrailingState { rows: 5, cells: 10 });
});
// Should NOT output the 5 newlines from trailing_state because start_y is non-zero
assert_eq!(o.s, "world");
assert_eq!(o.st.rows, usize::from(page.size.rows - 1));
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
assert_eq!(o.map, span(0, 5, 1));
}
#[test]
fn page_plain_start_x_non_zero_ignores_trailing_state() {
let t = setup(80, 24, "hello world");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| {
f.start_x = 6;
f.trailing_state = Some(TrailingState { rows: 2, cells: 8 });
});
// Should NOT output the 2 newlines or 8 spaces from trailing_state
assert_eq!(o.s, "world");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 11));
assert_eq!(o.map, span(6, 5, 0));
}
#[test]
fn page_plain_start_y_and_start_x_zero_uses_trailing_state() {
let t = setup(80, 24, "hello");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |f| {
f.start_y = 0;
f.start_x = 0;
f.trailing_state = Some(TrailingState { rows: 2, cells: 0 });
});
// SHOULD output the 2 newlines from trailing_state
assert_eq!(o.s, "\n\nhello");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 5));
let mut e = vec![c(0, 0), c(0, 1)];
e.extend(span(0, 5, 0));
assert_eq!(o.map, e);
}
#[test]
fn page_plain_single_line_with_styling() {
let t = setup(80, 24, "hello, \x1b[1mworld\x1b[0m");
let page = last_page(&t);
let o = run(&t, &Options::plain(), |_| {});
assert_eq!(o.s, "hello, world");
assert_eq!(o.st.rows, usize::from(page.size.rows));
assert_eq!(o.st.cells, usize::from(page.size.cols - 12));
assert_eq!(o.map, span(0, 12, 0));
}
@@ -0,0 +1,686 @@
use super::*;
/// Format the last page of `t` with a page formatter and a point map.
fn page_fmt(t: &Terminal, opts: &Options) -> (String, Vec<Coordinate>) {
let mut point_map = Vec::new();
let mut f = PageFormatter::new(last_page(t), opts);
f.point_map = Some(&mut point_map);
let out = f.format_string().unwrap();
(out, point_map)
}
fn trim_start<'a>(s: &'a str, chars: &[char]) -> &'a str {
s.trim_start_matches(chars)
}
fn coord(x: u16, y: u32) -> Coordinate {
Coordinate { x, y }
}
#[test]
fn page_vt_single_line_plain_text() {
let mut t = term(80, 24);
feed(&mut t, b"hello");
let (output, point_map) = page_fmt(&t, &Options::vt());
assert_eq!(output, "hello");
assert_eq!(point_map.len(), output.len());
for i in 0..5 {
assert_eq!(point_map[i], coord(i as u16, 0));
}
}
#[test]
fn page_vt_single_line_with_bold() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[1mhello\x1b[0m");
let (output, point_map) = page_fmt(&t, &Options::vt());
assert_eq!(output, "\x1b[0m\x1b[1mhello\x1b[0m");
assert_eq!(point_map.len(), output.len());
for i in 0..8 {
assert_eq!(point_map[i], coord(0, 0));
}
for i in 0..5 {
assert_eq!(point_map[8 + i], coord(i as u16, 0));
}
}
#[test]
fn page_vt_multiple_styles() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[1mhello \x1b[3mworld\x1b[0m");
let (output, point_map) = page_fmt(&t, &Options::vt());
assert_eq!(
output,
"\x1b[0m\x1b[1mhello \x1b[0m\x1b[1m\x1b[3mworld\x1b[0m"
);
assert_eq!(point_map.len(), output.len());
}
#[test]
fn page_vt_with_foreground_color() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[31mred\x1b[0m");
let (output, point_map) = page_fmt(&t, &Options::vt());
assert_eq!(output, "\x1b[0m\x1b[38;5;1mred\x1b[0m");
assert_eq!(point_map.len(), output.len());
for i in 0..13 {
assert_eq!(point_map[i], coord(0, 0));
}
for i in 0..3 {
assert_eq!(point_map[13 + i], coord(i as u16, 0));
}
}
#[test]
fn page_vt_with_background_and_foreground_colors() {
let mut t = term(80, 24);
feed(&mut t, b"hello");
let mut opts = Options::vt();
opts.background = Some(Rgb {
r: 0x12,
g: 0x34,
b: 0x56,
});
opts.foreground = Some(Rgb {
r: 0xab,
g: 0xcd,
b: 0xef,
});
let output = fmt_str(PageFormatter::new(last_page(&t), &opts));
assert_eq!(
output,
"\x1b]10;rgb:ab/cd/ef\x1b\\\x1b]11;rgb:12/34/56\x1b\\hello"
);
}
#[test]
fn page_vt_multi_line_with_styles() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[1mfirst\x1b[0m\r\n\x1b[3msecond\x1b[0m");
let (output, point_map) = page_fmt(&t, &Options::vt());
// Note: style is reset before newline to prevent background colors from
// bleeding to the next line's leading cells.
assert_eq!(
output,
"\x1b[0m\x1b[1mfirst\x1b[0m\r\n\x1b[0m\x1b[3msecond\x1b[0m"
);
assert_eq!(point_map.len(), output.len());
}
#[test]
fn page_vt_duplicate_style_not_emitted_twice() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[1mhel\x1b[1mlo\x1b[0m");
let (output, point_map) = page_fmt(&t, &Options::vt());
assert_eq!(output, "\x1b[0m\x1b[1mhello\x1b[0m");
assert_eq!(point_map.len(), output.len());
}
fn node_of_first(t: &Terminal) -> crate::page_list::NodeId {
t.screen().pages.first.unwrap()
}
fn node_of_last(t: &Terminal) -> crate::page_list::NodeId {
t.screen().pages.last.unwrap()
}
fn list_fmt(t: &Terminal, opts: Options) -> (String, Vec<Pin>) {
let mut pin_map = Vec::new();
let mut f = PageListFormatter::new(&t.screen().pages, opts);
f.pin_map = Some(&mut pin_map);
let out = f.format_string().unwrap();
(out, pin_map)
}
#[test]
fn pagelist_plain_single_line() {
let mut t = term(80, 24);
feed(&mut t, b"hello, world");
let (output, pin_map) = list_fmt(&t, Options::plain());
assert_eq!(output, "hello, world");
assert_eq!(pin_map.len(), output.len());
let node = node_of_first(&t);
for i in 0..output.len() {
assert_eq!(
pin_map[i],
Pin {
node,
x: i as u16,
y: 0,
garbage: false
}
);
}
}
#[test]
fn pagelist_plain_spanning_two_pages() {
let mut t = term(80, 24);
let first_page_rows = first_page_rows(&t);
// Fill the first page almost completely
for _ in 0..first_page_rows - 1 {
feed(&mut t, b"\r\n");
}
feed(&mut t, b"page one");
// Verify we're still on one page
assert_eq!(t.screen().pages.first, t.screen().pages.last);
// Add one more newline to push content to a second page
feed(&mut t, b"\r\n");
assert_ne!(t.screen().pages.first, t.screen().pages.last);
// Write content on the second page
feed(&mut t, b"page two");
let (full_output, pin_map) = list_fmt(&t, Options::plain());
let output = trim_start(&full_output, &['\n']);
assert_eq!(output, "page one\npage two");
assert_eq!(pin_map.len(), full_output.len());
let first_node = node_of_first(&t);
let last_node = node_of_last(&t);
let trimmed_count = full_output.len() - output.len();
// First part (trimmed blank lines) maps to first node
for p in &pin_map[..trimmed_count] {
assert_eq!(p.node, first_node);
}
// "page one" (8 chars) maps to first node
for i in 0..8 {
let idx = trimmed_count + i;
assert_eq!(pin_map[idx].node, first_node);
assert_eq!(pin_map[idx].x, i as u16);
}
// \n - maps to last node as it represents the transition to new page
assert_eq!(pin_map[trimmed_count + 8].node, last_node);
// "page two" (8 chars) maps to last node
for i in 0..8 {
let idx = trimmed_count + 9 + i;
assert_eq!(pin_map[idx].node, last_node);
assert_eq!(pin_map[idx].x, i as u16);
}
}
#[test]
fn pagelist_soft_wrapped_line_spanning_two_pages_without_unwrap() {
let mut t = term(10, 3);
let first_page_rows = first_page_rows(&t);
// Fill the first page with soft-wrapped content
for _ in 0..first_page_rows - 1 {
feed(&mut t, b"\r\n");
}
feed(&mut t, b"hello world test");
// Verify we're on two pages due to wrapping
assert_ne!(t.screen().pages.first, t.screen().pages.last);
let (full_output, pin_map) = list_fmt(&t, Options::plain());
let output = trim_start(&full_output, &['\n']);
assert_eq!(output, "hello worl\nd test");
assert_eq!(pin_map.len(), full_output.len());
let first_node = node_of_first(&t);
let last_node = node_of_last(&t);
let trimmed_count = full_output.len() - output.len();
for p in &pin_map[..trimmed_count] {
assert_eq!(p.node, first_node);
}
for i in 0..10 {
assert_eq!(pin_map[trimmed_count + i].node, first_node);
}
// \n - maps to last node as it represents the transition to new page
assert_eq!(pin_map[trimmed_count + 10].node, last_node);
// "d test" (6 chars) maps to last node
for i in 0..6 {
assert_eq!(pin_map[trimmed_count + 11 + i].node, last_node);
}
}
#[test]
fn pagelist_soft_wrapped_line_spanning_two_pages_with_unwrap() {
let mut t = term(10, 3);
let first_page_rows = first_page_rows(&t);
for _ in 0..first_page_rows - 1 {
feed(&mut t, b"\r\n");
}
feed(&mut t, b"hello world test");
assert_ne!(t.screen().pages.first, t.screen().pages.last);
let mut opts = Options::plain();
opts.unwrap = true;
let (full_output, pin_map) = list_fmt(&t, opts);
let output = trim_start(&full_output, &['\r', '\n']);
assert_eq!(output, "hello world test");
assert_eq!(pin_map.len(), full_output.len());
let first_node = node_of_first(&t);
let last_node = node_of_last(&t);
let trimmed_count = full_output.len() - output.len();
for p in &pin_map[..trimmed_count] {
assert_eq!(p.node, first_node);
}
for i in 0..10 {
assert_eq!(pin_map[trimmed_count + i].node, first_node);
}
for i in 0..6 {
assert_eq!(pin_map[trimmed_count + 10 + i].node, last_node);
}
}
#[test]
fn pagelist_vt_spanning_two_pages() {
let mut t = term(80, 24);
let first_page_rows = first_page_rows(&t);
// Fill the first page almost completely
for _ in 0..first_page_rows - 1 {
feed(&mut t, b"\r\n");
}
feed(&mut t, b"\x1b[1mpage one");
assert_eq!(t.screen().pages.first, t.screen().pages.last);
feed(&mut t, b"\r\n");
assert_ne!(t.screen().pages.first, t.screen().pages.last);
// New content is still styled
feed(&mut t, b"page two");
let (full_output, pin_map) = list_fmt(&t, Options::vt());
let output = trim_start(&full_output, &['\r', '\n']);
assert_eq!(
output,
"\x1b[0m\x1b[1mpage one\x1b[0m\r\n\x1b[0m\x1b[1mpage two\x1b[0m"
);
assert_eq!(pin_map.len(), full_output.len());
let first_node = node_of_first(&t);
let last_node = node_of_last(&t);
let first_count = pin_map.iter().filter(|p| p.node == first_node).count();
let last_count = pin_map.iter().filter(|p| p.node == last_node).count();
assert!(first_count > 0);
assert!(last_count > 0);
}
#[test]
fn pagelist_plain_with_x_offset_on_single_page() {
let mut t = term(80, 24);
feed(&mut t, b"hello world\r\ntest case\r\nfoo bar");
let node = node_of_first(&t);
let mut pin_map = Vec::new();
let mut f = PageListFormatter::new(&t.screen().pages, Options::plain());
f.top_left = Some(Pin {
node,
y: 0,
x: 6,
garbage: false,
});
f.bottom_right = Some(Pin {
node,
y: 2,
x: 2,
garbage: false,
});
f.pin_map = Some(&mut pin_map);
let output = f.format_string().unwrap();
assert_eq!(output, "world\ntest case\nfoo");
assert_eq!(pin_map.len(), output.len());
for p in &pin_map {
assert_eq!(p.node, node);
}
// "world" starts at x=6, y=0
for i in 0..5 {
assert_eq!(pin_map[i].x, 6 + i as u16);
assert_eq!(pin_map[i].y, 0);
}
}
#[test]
fn pagelist_plain_with_x_offset_spanning_two_pages() {
let mut t = term(80, 24);
let first_page_rows = first_page_rows(&t);
for _ in 0..first_page_rows - 1 {
feed(&mut t, b"\r\n");
}
feed(&mut t, b"hello world");
assert_eq!(t.screen().pages.first, t.screen().pages.last);
feed(&mut t, b"\r\n");
assert_ne!(t.screen().pages.first, t.screen().pages.last);
feed(&mut t, b"foo bar test");
let first_node = node_of_first(&t);
let last_node = node_of_last(&t);
let first_rows = t.screen().pages.node(first_node).unwrap().data.size.rows;
let mut pin_map = Vec::new();
let mut f = PageListFormatter::new(&t.screen().pages, Options::plain());
f.top_left = Some(Pin {
node: first_node,
y: first_rows - 1,
x: 6,
garbage: false,
});
f.bottom_right = Some(Pin {
node: last_node,
y: 1,
x: 2,
garbage: false,
});
f.pin_map = Some(&mut pin_map);
let full_output = f.format_string().unwrap();
let output = trim_start(&full_output, &['\n']);
assert_eq!(output, "world\nfoo");
assert_eq!(pin_map.len(), full_output.len());
let trimmed_count = full_output.len() - output.len();
// "world" (5 chars) from first page
for i in 0..5 {
let idx = trimmed_count + i;
assert_eq!(pin_map[idx].node, first_node);
assert_eq!(pin_map[idx].x, 6 + i as u16);
}
// \n - maps to last node as it represents the transition to new page
assert_eq!(pin_map[trimmed_count + 5].node, last_node);
// "foo" (3 chars) from last page
for i in 0..3 {
let idx = trimmed_count + 6 + i;
assert_eq!(pin_map[idx].node, last_node);
assert_eq!(pin_map[idx].x, i as u16);
}
}
#[test]
fn pagelist_plain_with_start_x_only() {
let mut t = term(80, 24);
feed(&mut t, b"hello world");
let node = node_of_first(&t);
let mut pin_map = Vec::new();
let mut f = PageListFormatter::new(&t.screen().pages, Options::plain());
f.top_left = Some(Pin {
node,
y: 0,
x: 6,
garbage: false,
});
f.pin_map = Some(&mut pin_map);
let output = f.format_string().unwrap();
assert_eq!(output, "world");
assert_eq!(pin_map.len(), output.len());
for i in 0..5 {
assert_eq!(pin_map[i].node, node);
assert_eq!(pin_map[i].x, 6 + i as u16);
assert_eq!(pin_map[i].y, 0);
}
}
#[test]
fn pagelist_plain_with_end_x_only() {
let mut t = term(80, 24);
feed(&mut t, b"hello world\r\ntest");
let node = node_of_first(&t);
let mut pin_map = Vec::new();
let mut f = PageListFormatter::new(&t.screen().pages, Options::plain());
f.bottom_right = Some(Pin {
node,
y: 1,
x: 2,
garbage: false,
});
f.pin_map = Some(&mut pin_map);
let output = f.format_string().unwrap();
assert_eq!(output, "hello world\ntes");
assert_eq!(pin_map.len(), output.len());
for i in 0..11 {
assert_eq!(pin_map[i].node, node);
assert_eq!(pin_map[i].x, i as u16);
assert_eq!(pin_map[i].y, 0);
}
// \n
assert_eq!(pin_map[11].node, node);
for i in 0..3 {
assert_eq!(pin_map[12 + i].node, node);
assert_eq!(pin_map[12 + i].x, i as u16);
assert_eq!(pin_map[12 + i].y, 1);
}
}
fn rect_format(t: &Terminal, tl: (u16, u32), br: (u16, u32)) -> String {
let pages = &t.screen().pages;
let mut f = PageListFormatter::new(pages, Options::plain());
f.top_left = Some(
pages
.pin(Point::Screen(Coordinate { x: tl.0, y: tl.1 }))
.unwrap(),
);
f.bottom_right = Some(
pages
.pin(Point::Screen(Coordinate { x: br.0, y: br.1 }))
.unwrap(),
);
f.rectangle = true;
f.format_string().unwrap()
}
#[test]
fn pagelist_plain_rectangle_basic() {
let mut t = term(30, 5);
feed(&mut t, b"Lorem ipsum dolor\r\n");
feed(&mut t, b"sit amet, consectetur\r\n");
feed(&mut t, b"adipiscing elit, sed do\r\n");
feed(&mut t, b"eiusmod tempor incididunt\r\n");
feed(&mut t, b"ut labore et dolore");
let output = rect_format(&t, (2, 1), (6, 3));
assert_eq!(output, "t ame\nipisc\nusmod");
}
#[test]
fn pagelist_plain_rectangle_with_eol() {
let mut t = term(30, 5);
feed(&mut t, b"Lorem ipsum dolor\r\n");
feed(&mut t, b"sit amet, consectetur\r\n");
feed(&mut t, b"adipiscing elit, sed do\r\n");
feed(&mut t, b"eiusmod tempor incididunt\r\n");
feed(&mut t, b"ut labore et dolore");
let output = rect_format(&t, (12, 0), (26, 4));
assert_eq!(
output,
"dolor\nnsectetur\nlit, sed do\nor incididunt\n dolore"
);
}
#[test]
fn pagelist_plain_rectangle_more_complex_with_breaks() {
let mut t = term(30, 8);
feed(&mut t, b"Lorem ipsum dolor\r\n");
feed(&mut t, b"sit amet, consectetur\r\n");
feed(&mut t, b"adipiscing elit, sed do\r\n");
feed(&mut t, b"eiusmod tempor incididunt\r\n");
feed(&mut t, b"ut labore et dolore\r\n");
feed(&mut t, b"\r\n");
feed(&mut t, b"magna aliqua. Ut enim\r\n");
feed(&mut t, b"ad minim veniam, quis");
let output = rect_format(&t, (11, 2), (26, 7));
assert_eq!(
output,
"elit, sed do\npor incididunt\nt dolore\n\na. Ut enim\nniam, quis"
);
}
#[test]
fn terminal_formatter_plain_no_selection() {
let mut t = term(80, 24);
feed(&mut t, b"hello\r\nworld");
let f = TerminalFormatter::new(&t, Options::plain());
assert_eq!(f.format_string().unwrap(), "hello\nworld");
}
#[test]
fn terminal_formatter_vt_with_palette() {
let mut t = term(80, 24);
// Modify some palette colors using VT sequences
feed(&mut t, b"\x1b]4;0;rgb:12/34/56\x1b\\");
feed(&mut t, b"\x1b]4;1;rgb:ab/cd/ef\x1b\\");
feed(&mut t, b"\x1b]4;255;rgb:ff/00/ff\x1b\\");
feed(&mut t, b"test");
let output = TerminalFormatter::new(&t, Options::vt())
.format_string()
.unwrap();
// Create a second terminal and apply the output
let mut t2 = term(80, 24);
feed(&mut t2, output.as_bytes());
// Verify the palettes match
for i in [0, 1, 255] {
assert_eq!(t.colors.palette.current[i], t2.colors.palette.current[i]);
}
}
fn line2_selection(t: &Terminal) -> Selection {
let pages = &t.screen().pages;
Selection::new(
pages.pin(Point::Active(Coordinate { x: 0, y: 1 })).unwrap(),
pages.pin(Point::Active(Coordinate { x: 4, y: 1 })).unwrap(),
false,
)
}
#[test]
fn terminal_formatter_with_selection() {
let mut t = term(80, 24);
feed(&mut t, b"line1\r\nline2\r\nline3");
let sel = line2_selection(&t);
let mut f = TerminalFormatter::new(&t, Options::plain());
f.content = Content::Selection(Some(sel));
assert_eq!(f.format_string().unwrap(), "line2");
}
#[test]
fn terminal_formatter_plain_with_pin_map() {
let mut t = term(80, 24);
feed(&mut t, b"hello, world");
let mut pin_map = Vec::new();
let mut f = TerminalFormatter::new(&t, Options::plain());
f.pin_map = Some(&mut pin_map);
let output = f.format_string().unwrap();
assert_eq!(output, "hello, world");
assert_eq!(pin_map.len(), output.len());
let node = node_of_first(&t);
for i in 0..output.len() {
assert_eq!(
pin_map[i],
Pin {
node,
x: i as u16,
y: 0,
garbage: false
}
);
}
}
#[test]
fn terminal_formatter_plain_multiline_with_pin_map() {
let mut t = term(80, 24);
feed(&mut t, b"hello\r\nworld");
let mut pin_map = Vec::new();
let mut f = TerminalFormatter::new(&t, Options::plain());
f.pin_map = Some(&mut pin_map);
let output = f.format_string().unwrap();
assert_eq!(output, "hello\nworld");
assert_eq!(pin_map.len(), output.len());
let node = node_of_first(&t);
for i in 0..5 {
assert_eq!(pin_map[i].node, node);
assert_eq!(pin_map[i].x, i as u16);
assert_eq!(pin_map[i].y, 0);
}
// "\n" maps to end of first line
assert_eq!(pin_map[5].node, node);
for i in 0..5 {
let idx = 6 + i;
assert_eq!(pin_map[idx].node, node);
assert_eq!(pin_map[idx].x, i as u16);
assert_eq!(pin_map[idx].y, 1);
}
}
#[test]
fn terminal_formatter_vt_with_palette_and_pin_map() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b]4;0;rgb:12/34/56\x1b\\");
feed(&mut t, b"test");
let mut pin_map = Vec::new();
let mut f = TerminalFormatter::new(&t, Options::vt());
f.pin_map = Some(&mut pin_map);
let output = f.format_string().unwrap();
// Palette bytes should be mapped to top left
assert_eq!(pin_map.len(), output.len());
let node = node_of_first(&t);
for p in &pin_map {
assert_eq!(p.node, node);
}
}
#[test]
fn terminal_formatter_with_selection_and_pin_map() {
let mut t = term(80, 24);
feed(&mut t, b"line1\r\nline2\r\nline3");
let sel = line2_selection(&t);
let mut pin_map = Vec::new();
let mut f = TerminalFormatter::new(&t, Options::plain());
f.content = Content::Selection(Some(sel));
f.pin_map = Some(&mut pin_map);
let output = f.format_string().unwrap();
assert_eq!(output, "line2");
assert_eq!(pin_map.len(), output.len());
let node = node_of_first(&t);
for i in 0..5 {
assert_eq!(pin_map[i].node, node);
assert_eq!(pin_map[i].x, i as u16);
assert_eq!(pin_map[i].y, 1);
}
}
@@ -0,0 +1,530 @@
use super::*;
use ghostty_vt::modes::Mode;
/// Format the screen with the given extras, returning output and pin map.
fn screen_vt(t: &Terminal, mutate: impl FnOnce(&mut ScreenFormatter<'_>)) -> (String, Vec<Pin>) {
let mut pin_map: Vec<Pin> = Vec::new();
let out = {
let mut f = ScreenFormatter::new(t.screen(), Options::vt());
mutate(&mut f);
f.pin_map = Some(&mut pin_map);
f.format_string().unwrap()
};
(out, pin_map)
}
fn replay(output: &str) -> Terminal {
let mut t2 = term(80, 24);
feed(&mut t2, output.as_bytes());
t2
}
fn first_node(t: &Terminal) -> crate::page_list::NodeId {
t.screen().pages.first.unwrap()
}
fn page_fmt(t: &Terminal, opts: &Options) -> String {
PageFormatter::new(last_page(t), opts)
.format_string()
.unwrap()
}
const HTML_OPEN: &str = "<div style=\"font-family: monospace; white-space: pre;\">";
#[test]
fn screen_plain_single_line() {
let mut t = term(80, 24);
feed(&mut t, b"hello, world");
let mut pin_map = Vec::new();
let output = {
let mut f = ScreenFormatter::new(t.screen(), Options::plain());
f.pin_map = Some(&mut pin_map);
f.format_string().unwrap()
};
assert_eq!(output, "hello, world");
assert_eq!(output.len(), pin_map.len());
let node = first_node(&t);
for (i, p) in pin_map.iter().enumerate() {
assert_eq!(
*p,
Pin {
node,
x: i as u16,
y: 0,
garbage: false
}
);
}
}
#[test]
fn screen_plain_multiline() {
let mut t = term(80, 24);
feed(&mut t, b"hello\r\nworld");
let mut pin_map = Vec::new();
let output = {
let mut f = ScreenFormatter::new(t.screen(), Options::plain());
f.pin_map = Some(&mut pin_map);
f.format_string().unwrap()
};
assert_eq!(output, "hello\nworld");
assert_eq!(output.len(), pin_map.len());
let node = first_node(&t);
for i in 0..5 {
assert_eq!(pin_map[i].node, node);
assert_eq!(pin_map[i].x, i as u16);
assert_eq!(pin_map[i].y, 0);
}
assert_eq!(pin_map[5].node, node);
for i in 0..5 {
let idx = 6 + i;
assert_eq!(pin_map[idx].node, node);
assert_eq!(pin_map[idx].x, i as u16);
assert_eq!(pin_map[idx].y, 1);
}
}
#[test]
fn screen_plain_with_selection() {
let mut t = term(80, 24);
feed(&mut t, b"line1\r\nline2\r\nline3");
let pages = &t.screen().pages;
let a = pages.pin(Point::Active(Coordinate { x: 0, y: 1 })).unwrap();
let b = pages.pin(Point::Active(Coordinate { x: 4, y: 1 })).unwrap();
let mut pin_map = Vec::new();
let output = {
let mut f = ScreenFormatter::new(t.screen(), Options::plain());
f.content = Content::Selection(Some(Selection::new(a, b, false)));
f.pin_map = Some(&mut pin_map);
f.format_string().unwrap()
};
assert_eq!(output, "line2");
assert_eq!(output.len(), pin_map.len());
let node = first_node(&t);
for i in 0..5 {
assert_eq!(pin_map[i].node, node);
assert_eq!(pin_map[i].x, i as u16);
assert_eq!(pin_map[i].y, 1);
}
}
#[test]
fn screen_vt_with_cursor_position() {
let mut t = term(80, 24);
feed(&mut t, b"hello\r\nworld");
let (output, pin_map) = screen_vt(&t, |f| f.extra.cursor = true);
let t2 = replay(&output);
assert_eq!(t.screen().cursor.x, t2.screen().cursor.x);
assert_eq!(t.screen().cursor.y, t2.screen().cursor.y);
assert_eq!(output.len(), pin_map.len());
let node = first_node(&t);
for p in &pin_map {
assert_eq!(p.node, node);
}
}
#[test]
fn screen_vt_with_style() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[1;31mhello");
let (output, pin_map) = screen_vt(&t, |f| f.extra.style = true);
let t2 = replay(&output);
assert!(t.screen().cursor.style.eql(t2.screen().cursor.style));
assert_eq!(output.len(), pin_map.len());
let node = first_node(&t);
for p in &pin_map {
assert_eq!(p.node, node);
}
}
#[test]
fn screen_vt_with_hyperlink() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b]8;;http://example.com\x1b\\hello");
let (output, pin_map) = screen_vt(&t, |f| f.extra.hyperlink = true);
let t2 = replay(&output);
let l1 = &t.screen().cursor.hyperlink;
let l2 = &t2.screen().cursor.hyperlink;
assert_eq!(l1.is_some(), l2.is_some());
if let (Some(a), Some(b)) = (l1, l2) {
assert_eq!(a.uri, b.uri);
}
assert_eq!(output.len(), pin_map.len());
let node = first_node(&t);
for p in &pin_map {
assert_eq!(p.node, node);
}
}
#[test]
fn screen_vt_with_protection() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[1\"qhello");
let (output, pin_map) = screen_vt(&t, |f| f.extra.protection = true);
let t2 = replay(&output);
assert_eq!(t.screen().cursor.protected, t2.screen().cursor.protected);
assert_eq!(output.len(), pin_map.len());
let node = first_node(&t);
for p in &pin_map {
assert_eq!(p.node, node);
}
}
#[test]
fn screen_vt_with_kitty_keyboard() {
let mut t = term(80, 24);
// Set kitty keyboard flags (disambiguate + report_events = 3)
feed(&mut t, b"\x1b[=3;1uhello");
let (output, pin_map) = screen_vt(&t, |f| f.extra.kitty_keyboard = true);
let t2 = replay(&output);
let flags1 = t.screen().kitty_keyboard.current().int();
let flags2 = t2.screen().kitty_keyboard.current().int();
assert_eq!(flags1, flags2);
assert_eq!(output.len(), pin_map.len());
let node = first_node(&t);
for p in &pin_map {
assert_eq!(p.node, node);
}
}
#[test]
fn screen_vt_with_charsets() {
use ghostty_vt::charsets::Slots;
let mut t = term(80, 24);
// Set G0 to DEC special and shift to G1
feed(&mut t, b"\x1b(0\x0ehello");
let (output, pin_map) = screen_vt(&t, |f| f.extra.charsets = true);
let t2 = replay(&output);
let (c1, c2) = (&t.screen().charset, &t2.screen().charset);
assert_eq!(c1.gl, c2.gl);
assert_eq!(c1.gr, c2.gr);
assert_eq!(c1.charsets.get(Slots::G0), c2.charsets.get(Slots::G0));
assert_eq!(output.len(), pin_map.len());
let node = first_node(&t);
for p in &pin_map {
assert_eq!(p.node, node);
}
}
fn term_vt(t: &Terminal, mutate: impl FnOnce(&mut TerminalFormatter<'_>)) -> String {
let mut f = TerminalFormatter::new(t, Options::vt());
mutate(&mut f);
f.format_string().unwrap()
}
#[test]
fn terminal_vt_with_scrolling_region() {
let mut t = term(80, 24);
// Set scrolling region: top=5, bottom=20
feed(&mut t, b"\x1b[6;21rhello");
let output = term_vt(&t, |f| f.extra.scrolling_region = true);
let t2 = replay(&output);
assert_eq!(t.scrolling_region.top, t2.scrolling_region.top);
assert_eq!(t.scrolling_region.bottom, t2.scrolling_region.bottom);
assert_eq!(t.scrolling_region.left, t2.scrolling_region.left);
assert_eq!(t.scrolling_region.right, t2.scrolling_region.right);
}
#[test]
fn terminal_vt_with_modes() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[?2004h"); // Bracketed paste
feed(&mut t, b"\x1b[?1000h"); // Mouse event normal
feed(&mut t, b"\x1b[?7l"); // Disable wraparound (default is true)
feed(&mut t, b"hello");
let output = term_vt(&t, |f| f.extra.modes = true);
let t2 = replay(&output);
for m in [
Mode::BracketedPaste,
Mode::MouseEventNormal,
Mode::Wraparound,
] {
assert_eq!(t.modes.get(m), t2.modes.get(m));
}
}
#[test]
fn terminal_vt_with_tabstops() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[3g"); // Clear all tabs
feed(&mut t, b"\x1b[5G\x1bH"); // Set tab at column 5
feed(&mut t, b"\x1b[15G\x1bH"); // Set tab at column 15
feed(&mut t, b"\x1b[30G\x1bH"); // Set tab at column 30
feed(&mut t, b"hello");
let output = term_vt(&t, |f| f.extra.tabstops = true);
let t2 = replay(&output);
for c in [4, 14, 29] {
assert_eq!(t.tabstops.get(c), t2.tabstops.get(c));
assert!(t2.tabstops.get(c));
}
assert!(!t2.tabstops.get(8)); // Not a tab
}
#[test]
fn terminal_vt_with_keyboard_modes() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[>4;2m");
feed(&mut t, b"hello");
let output = term_vt(&t, |f| f.extra.keyboard = true);
let t2 = replay(&output);
assert_eq!(t.flags.modify_other_keys_2, t2.flags.modify_other_keys_2);
assert!(t2.flags.modify_other_keys_2);
}
#[test]
fn terminal_vt_with_pwd() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b]7;file://host/home/user\x1b\\hello");
let output = term_vt(&t, |f| f.extra.pwd = true);
let t2 = replay(&output);
assert_eq!(t.pwd, t2.pwd);
}
#[test]
fn page_html_with_multiple_styles() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[1mbold\x1b[3mitalic\x1b[0mnormal");
let output = page_fmt(&t, &Options::html());
assert_eq!(
output,
"<div style=\"font-family: monospace; white-space: pre;\">\
<div style=\"display: inline;font-weight: bold;\">bold</div>\
<div style=\"display: inline;font-weight: bold;font-style: italic;\">italic</div>\
normal\
</div>"
);
}
#[test]
fn page_html_plain_text() {
let mut t = term(80, 24);
feed(&mut t, b"hello, world");
let output = page_fmt(&t, &Options::html());
assert_eq!(output, format!("{HTML_OPEN}hello, world</div>"));
}
#[test]
fn page_html_with_colors() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[31;44mcolored");
let output = page_fmt(&t, &Options::html());
assert_eq!(
output,
"<div style=\"font-family: monospace; white-space: pre;\">\
<div style=\"display: inline;color: var(--vt-palette-1);background-color: var(--vt-palette-4);\">colored</div>\
</div>"
);
}
#[test]
fn terminal_formatter_html_with_palette() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b]4;0;rgb:12/34/56\x1b\\");
feed(&mut t, b"\x1b]4;1;rgb:ab/cd/ef\x1b\\");
feed(&mut t, b"\x1b]4;255;rgb:ff/00/ff\x1b\\");
feed(&mut t, b"test");
let mut f = TerminalFormatter::new(&t, Options::html());
f.extra.palette = true;
let output = f.format_string().unwrap();
assert!(output.contains("<style>:root{"));
assert!(output.contains("--vt-palette-0: #123456;"));
assert!(output.contains("--vt-palette-1: #abcdef;"));
assert!(output.contains("--vt-palette-255: #ff00ff;"));
assert!(output.contains("}</style>"));
assert!(output.contains("test"));
}
#[test]
fn page_html_with_background_and_foreground_colors() {
let mut t = term(80, 24);
feed(&mut t, b"hello");
let mut opts = Options::html();
opts.background = Some(Rgb {
r: 0x12,
g: 0x34,
b: 0x56,
});
opts.foreground = Some(Rgb {
r: 0xab,
g: 0xcd,
b: 0xef,
});
let output = page_fmt(&t, &opts);
assert_eq!(
output,
"<div style=\"font-family: monospace; white-space: pre;background-color: #123456;color: #abcdef;\">hello</div>"
);
}
#[test]
fn page_html_with_escaping() {
let mut t = term(80, 24);
feed(&mut t, b"<tag>&\"'text");
let opts = Options::html();
let mut point_map: Vec<Coordinate> = Vec::new();
let output = {
let mut f = PageFormatter::new(last_page(&t), &opts);
f.point_map = Some(&mut point_map);
f.format_string().unwrap()
};
assert_eq!(
output,
format!("{HTML_OPEN}&lt;tag&gt;&amp;&quot;&#39;text</div>")
);
assert_eq!(output.len(), point_map.len());
let offset = HTML_OPEN.len();
for c in &point_map[..offset] {
assert_eq!(*c, Coordinate { x: 0, y: 0 });
}
let at = |i: usize, x: u16| {
assert_eq!(point_map[offset + i], Coordinate { x, y: 0 }, "index {i}");
};
for i in 0..4 {
at(i, 0);
}
at(4, 1);
at(5, 2);
at(6, 3);
for i in 0..4 {
at(7 + i, 4);
}
for i in 0..5 {
at(11 + i, 5);
}
for i in 0..6 {
at(16 + i, 6);
}
for i in 0..5 {
at(22 + i, 7);
}
at(27, 8);
at(28, 9);
at(29, 10);
at(30, 11);
}
#[test]
fn page_html_with_unicode_as_numeric_entities() {
let mut t = term(80, 24);
feed(&mut t, "╰─ ❯".as_bytes());
let output = page_fmt(&t, &Options::html());
assert_eq!(output, format!("{HTML_OPEN}&#9584;&#9472; &#10095;</div>"));
}
#[test]
fn page_html_ascii_characters_unchanged() {
let mut t = term(80, 24);
feed(&mut t, b"hello world");
let output = page_fmt(&t, &Options::html());
assert_eq!(output, format!("{HTML_OPEN}hello world</div>"));
}
#[test]
fn page_html_mixed_ascii_and_unicode() {
let mut t = term(80, 24);
feed(&mut t, "test ╰─❯ ok".as_bytes());
let output = page_fmt(&t, &Options::html());
assert_eq!(
output,
format!("{HTML_OPEN}test &#9584;&#9472;&#10095; ok</div>")
);
}
#[test]
fn page_vt_with_palette_option_emits_rgb() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b]4;1;rgb:ab/cd/ef\x1b\\");
feed(&mut t, b"\x1b[31mred");
// Without palette option - should emit palette index
let output = page_fmt(&t, &Options::vt());
assert_eq!(output, "\x1b[0m\x1b[38;5;1mred\x1b[0m");
// With palette option - should emit RGB directly
let mut opts = Options::vt();
opts.palette = Some(t.colors.palette.current);
let output = page_fmt(&t, &opts);
assert_eq!(output, "\x1b[0m\x1b[38;2;171;205;239mred\x1b[0m");
}
#[test]
fn page_html_with_palette_option_emits_rgb() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b]4;1;rgb:ab/cd/ef\x1b\\");
feed(&mut t, b"\x1b[31mred");
let output = page_fmt(&t, &Options::html());
assert_eq!(
output,
"<div style=\"font-family: monospace; white-space: pre;\">\
<div style=\"display: inline;color: var(--vt-palette-1);\">red</div>\
</div>"
);
let mut opts = Options::html();
opts.palette = Some(t.colors.palette.current);
let output = page_fmt(&t, &opts);
assert_eq!(
output,
"<div style=\"font-family: monospace; white-space: pre;\">\
<div style=\"display: inline;color: rgb(171, 205, 239);\">red</div>\
</div>"
);
}
#[test]
fn page_vt_style_reset_properly_closes_styles() {
let mut t = term(80, 24);
feed(&mut t, b"\x1b[1mbold\x1b[0mnormal");
let output = page_fmt(&t, &Options::vt());
assert_eq!(output, "\x1b[0m\x1b[1mbold\x1b[0mnormal");
}
@@ -0,0 +1,327 @@
use super::*;
fn map_entry(range: (char, char), replacement: Replacement) -> CodepointMap {
CodepointMap {
range: [range.0 as u32, range.1 as u32],
replacement,
}
}
/// Format the last page of `t` with `opts`, optionally collecting a point map.
fn run(t: &Terminal, opts: &Options, point_map: Option<&mut Vec<Coordinate>>) -> String {
let mut f = PageFormatter::new(last_page(t), opts);
f.point_map = point_map;
f.format_string().unwrap()
}
fn plain_with_map(entries: Vec<CodepointMap>) -> Options {
let mut opts = Options::plain();
opts.codepoint_map = Some(entries);
opts
}
fn c(x: u16, y: u32) -> Coordinate {
Coordinate { x, y }
}
#[test]
fn page_codepoint_map_single_replacement() {
let mut t = term(80, 24);
feed(&mut t, b"hello world");
// Replace 'o' with 'x'
let opts = plain_with_map(vec![map_entry(
('o', 'o'),
Replacement::Codepoint('x' as u32),
)]);
let mut point_map = Vec::new();
let output = run(&t, &opts, Some(&mut point_map));
assert_eq!(output, "hellx wxrld");
// Verify point map - each output byte should map to original cell position
assert_eq!(output.len(), point_map.len());
for (i, coord) in point_map.iter().enumerate() {
assert_eq!(*coord, c(i as u16, 0));
}
}
#[test]
fn page_codepoint_map_conflicting_replacement_prefers_last() {
let mut t = term(80, 24);
feed(&mut t, b"hello");
// Replace 'o' with 'x', then with 'y' - should prefer last
let opts = plain_with_map(vec![
map_entry(('o', 'o'), Replacement::Codepoint('x' as u32)),
map_entry(('o', 'o'), Replacement::Codepoint('y' as u32)),
]);
assert_eq!(run(&t, &opts, None), "helly");
}
#[test]
fn page_codepoint_map_replace_with_string() {
let mut t = term(80, 24);
feed(&mut t, b"hello");
// Replace 'o' with a multi-byte string
let opts = plain_with_map(vec![map_entry(
('o', 'o'),
Replacement::String("XYZ".into()),
)]);
let mut point_map = Vec::new();
let output = run(&t, &opts, Some(&mut point_map));
assert_eq!(output, "hellXYZ");
// Verify point map - string replacements should all map to the original cell
assert_eq!(output.len(), point_map.len());
for i in 0..4 {
assert_eq!(point_map[i], c(i as u16, 0));
}
// All bytes of the replacement string "XYZ" should point to position 4 (where 'o' was)
assert_eq!(point_map[4], c(4, 0)); // X
assert_eq!(point_map[5], c(4, 0)); // Y
assert_eq!(point_map[6], c(4, 0)); // Z
}
#[test]
fn page_codepoint_map_range_replacement() {
let mut t = term(80, 24);
feed(&mut t, b"abcdefg");
// Replace 'b' through 'e' with 'X'
let opts = plain_with_map(vec![map_entry(
('b', 'e'),
Replacement::Codepoint('X' as u32),
)]);
assert_eq!(run(&t, &opts, None), "aXXXXfg");
}
#[test]
fn page_codepoint_map_multiple_ranges() {
let mut t = term(80, 24);
feed(&mut t, b"hello world");
// Replace 'a'-'m' with 'A' and 'n'-'z' with 'Z'
let opts = plain_with_map(vec![
map_entry(('a', 'm'), Replacement::Codepoint('A' as u32)),
map_entry(('n', 'z'), Replacement::Codepoint('Z' as u32)),
]);
// h e l l o w o r l d
// A A A A Z Z Z Z A A
assert_eq!(run(&t, &opts, None), "AAAAZ ZZZAA");
}
#[test]
fn page_codepoint_map_unicode_replacement() {
let mut t = term(80, 24);
feed(&mut t, "hello \u{26A1} world".as_bytes());
// Replace lightning bolt with fire emoji
let opts = plain_with_map(vec![map_entry(
('\u{26A1}', '\u{26A1}'),
Replacement::String("\u{1F525}".into()),
)]);
let mut point_map = Vec::new();
let output = run(&t, &opts, Some(&mut point_map));
assert_eq!(output, "hello \u{1F525} world");
// Verify point map
assert_eq!(output.len(), point_map.len());
// Note: the bolt is a wide character occupying cells 6-7
for i in 0..6 {
assert_eq!(point_map[i], c(i as u16, 0));
}
// The fire emoji is 4 UTF-8 bytes, all should map to cell 6 (where the bolt was)
let fire_start = 6; // "hello " is 6 bytes
for i in 0..4 {
assert_eq!(point_map[fire_start + i], c(6, 0));
}
// " world" follows
let world_start = fire_start + 4;
for i in 0..6 {
assert_eq!(point_map[world_start + i], c(8 + i as u16, 0));
}
}
#[test]
fn page_codepoint_map_with_styled_formats() {
let mut t = term(10, 24);
feed(&mut t, b"\x1b[31mred text\x1b[0m");
// Replace 'e' with 'X' in styled text
let mut opts = Options::vt();
opts.codepoint_map = Some(vec![map_entry(
('e', 'e'),
Replacement::Codepoint('X' as u32),
)]);
// Should preserve styles while replacing text
// "red text" becomes "rXd tXxt"
// VT format uses \x1b[38;5;1m for palette color 1
assert_eq!(run(&t, &opts, None), "\x1b[0m\x1b[38;5;1mrXd tXxt\x1b[0m");
}
#[test]
fn page_codepoint_map_empty_map() {
let mut t = term(80, 24);
feed(&mut t, b"hello world");
// Empty map should not change anything
let opts = plain_with_map(Vec::new());
assert_eq!(run(&t, &opts, None), "hello world");
// A None map is equivalent.
let mut opts = Options::plain();
opts.codepoint_map = None;
assert_eq!(run(&t, &opts, None), "hello world");
}
#[test]
fn page_vt_background_color_on_trailing_blank_cells() {
// This test reproduces a bug where trailing cells with background color
// but no text are emitted as plain spaces without SGR sequences.
// This causes TUIs like htop to lose background colors on rehydration.
let mut t = term(20, 5);
// Simulate a TUI row: "CPU:" with text, then trailing cells with red background
// to end of line (no text after the colored region).
// \x1b[41m sets red background, then EL fills rest of row with that bg.
feed(&mut t, b"CPU:\x1b[41m\x1b[K");
// Reset colors and move to next line with different content
feed(&mut t, b"\x1b[0m\r\nline2");
let mut opts = Options::vt();
opts.trim = false; // Don't trim so we can see the trailing behavior
let output = run(&t, &opts, None);
// The output should preserve the red background SGR for trailing cells on line 1.
// The red background should appear BEFORE the newline, not after.
// Find position of CRLF
let crlf_pos = output.find("\r\n").expect("crlf");
// Check that red background (48;5;1) appears BEFORE the newline (on line 1)
let line1 = &output[..crlf_pos];
let has_red_bg_line1 = line1.contains("\x1b[41m") || line1.contains("\x1b[48;5;1m");
assert!(has_red_bg_line1);
}
const HTML_OPEN: &str = "<div style=\"font-family: monospace; white-space: pre;\">";
fn html(t: &Terminal, point_map: Option<&mut Vec<Coordinate>>) -> String {
run(t, &Options::html(), point_map)
}
#[test]
fn page_html_with_hyperlinks() {
let mut t = term(80, 24);
// Start a hyperlink, write some text, end it
feed(
&mut t,
b"\x1b]8;;https://example.com\x1b\\link text\x1b]8;;\x1b\\ normal",
);
assert_eq!(
html(&t, None),
format!("{HTML_OPEN}<a href=\"https://example.com\">link text</a> normal</div>")
);
}
#[test]
fn page_html_with_multiple_hyperlinks() {
let mut t = term(80, 24);
// Two different hyperlinks
feed(
&mut t,
b"\x1b]8;;https://first.com\x1b\\first\x1b]8;;\x1b\\ ",
);
feed(
&mut t,
b"\x1b]8;;https://second.com\x1b\\second\x1b]8;;\x1b\\",
);
assert_eq!(
html(&t, None),
format!(
"{HTML_OPEN}<a href=\"https://first.com\">first</a> <a href=\"https://second.com\">second</a></div>"
)
);
}
#[test]
fn page_html_with_hyperlink_escaping() {
let mut t = term(80, 24);
// URL with special characters that need escaping
feed(
&mut t,
b"\x1b]8;;https://example.com?a=1&b=2\x1b\\link\x1b]8;;\x1b\\",
);
assert_eq!(
html(&t, None),
format!("{HTML_OPEN}<a href=\"https://example.com?a=1&amp;b=2\">link</a></div>")
);
}
#[test]
fn page_html_with_styled_hyperlink() {
let mut t = term(80, 24);
// Bold hyperlink
feed(
&mut t,
b"\x1b]8;;https://example.com\x1b\\\x1b[1mbold link\x1b[0m\x1b]8;;\x1b\\",
);
assert_eq!(
html(&t, None),
format!(
"{HTML_OPEN}<div style=\"display: inline;font-weight: bold;\"><a href=\"https://example.com\">bold link</div></a></div>"
)
);
}
#[test]
fn page_html_hyperlink_closes_style_before_anchor() {
let mut t = term(80, 24);
// Styled hyperlink followed by plain text
feed(
&mut t,
b"\x1b]8;;https://example.com\x1b\\\x1b[1mbold\x1b[0m plain",
);
assert_eq!(
html(&t, None),
format!(
"{HTML_OPEN}<div style=\"display: inline;font-weight: bold;\"><a href=\"https://example.com\">bold</div> plain</a></div>"
)
);
}
#[test]
fn page_html_hyperlink_point_map_maps_closing_to_previous_cell() {
let mut t = term(80, 24);
feed(
&mut t,
b"\x1b]8;;https://example.com\x1b\\link\x1b]8;;\x1b\\ normal",
);
let mut point_map = Vec::new();
let output = html(&t, Some(&mut point_map));
let expected_output =
format!("{HTML_OPEN}<a href=\"https://example.com\">link</a> normal</div>");
assert_eq!(output, expected_output);
assert_eq!(expected_output.len(), point_map.len());
// The </a> closing tag bytes should all map to the last cell of the link
let closing_idx = expected_output.find("</a>").unwrap();
let expected_coord = point_map[closing_idx - 1];
for i in closing_idx..closing_idx + "</a>".len() {
assert_eq!(point_map[i], expected_coord);
}
}