Files
ghostty-rs/crates/ghostty-terminal/src/formatter/tests/p1.rs
T
ddidderr 6a374b6953 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)
2026-10-11 22:50:22 +02:00

590 lines
18 KiB
Rust

//! "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));
}