Files
ghostty-rs/crates/ghostty-terminal/src/screen/tests/p5.rs
T
ddidderr 1ba5af0658 feat(terminal): port Screen, ScreenSet, Tabstops and Selection
Ports ZIG/src/terminal/{Screen,ScreenSet,Tabstops,Selection}.zig on top
of the page/PageList layer. The cursor holds a TrackedPin; Zig's cached
page_row/page_cell pointers become accessor methods. Selection methods
take the PageList explicitly because tracked bounds are handles.

Screen tests use minimal plain-text dump/selection-string helpers that
follow the formatter's semantics; they will delegate to the real
formatter once formatter.zig is ported.

Gaps found in the PageList layer are recorded for the parity pass in
docs/rewrite/notes/ghostty-terminal-pagelist-requests.md; three resize
tests are #[ignore]d pending those fixes (PageList::resize keeps
trailing blank rows Zig drops). Kitty image storage and StringMap are
placeholders.

Test Plan:
- cargo test -p ghostty-terminal (443 passed, 3 ignored; Screen 183/184,
  Selection 18/18, Tabstops 4/5 - skipped tests use allocator failure
  injection)
2026-10-11 22:28:03 +02:00

1077 lines
34 KiB
Rust

use super::*;
use crate::page::STD_CAPACITY;
use crate::style::Color;
use ghostty_vt::color::Rgb;
use ghostty_vt::sgr::Attribute as A;
fn sel_str(s: &Screen, sel: Selection, trim: bool) -> String {
s.selection_string(SelectionString { sel, trim })
}
fn sel_active(s: &Screen, a: (u16, u32), b: (u16, u32), rect: bool) -> Selection {
Selection::new(pin(s, active(a.0, a.1)), pin(s, active(b.0, b.1)), rect)
}
fn sel_screen(s: &Screen, a: (u16, u32), b: (u16, u32), rect: bool) -> Selection {
Selection::new(
pin(s, screen_pt(a.0, a.1)),
pin(s, screen_pt(b.0, b.1)),
rect,
)
}
#[test]
fn screen_selection_string_trim_empty_line() {
let mut s = init(5, 5, 0);
s.test_write_string("1AB \n\n2EFGH\n3IJKL").unwrap();
let sel = sel_screen(&s, (0, 0), (2, 2), false);
assert_eq!("1AB\n\n2EF", sel_str(&s, sel, true));
// No trim
assert_eq!("1AB \n\n2EF", sel_str(&s, sel, false));
}
#[test]
fn screen_selection_string_soft_wrap() {
let mut s = init(5, 3, 0);
s.test_write_string("1ABCD2EFGH3IJKL").unwrap();
let sel = sel_screen(&s, (0, 1), (2, 2), false);
assert_eq!("2EFGH3IJ", sel_str(&s, sel, true));
}
#[test]
fn screen_selection_string_wide_char() {
let mut s = init(5, 3, 0);
let str = "1A\u{26A1}";
s.test_write_string(str).unwrap();
{
let sel = sel_screen(&s, (0, 0), (3, 0), false);
assert_eq!(str, sel_str(&s, sel, true));
}
{
let sel = sel_screen(&s, (0, 0), (2, 0), false);
assert_eq!(str, sel_str(&s, sel, true));
}
{
let sel = sel_screen(&s, (3, 0), (3, 0), false);
assert_eq!("\u{26A1}", sel_str(&s, sel, true));
}
}
#[test]
fn screen_selection_string_wide_char_with_header() {
let mut s = init(5, 3, 0);
let str = "1ABC\u{26A1}";
s.test_write_string(str).unwrap();
let sel = sel_screen(&s, (0, 0), (4, 0), false);
assert_eq!(str, sel_str(&s, sel, true));
}
// https://github.com/mitchellh/ghostty/issues/289
#[test]
fn screen_selection_string_empty_with_soft_wrap() {
let mut s = init(5, 2, 0);
// Let me describe the situation that caused this because this
// test is not obvious. By writing an emoji below, we introduce
// one cell with the emoji and one cell as a "wide char spacer".
// We then soft wrap the line by writing spaces.
//
// By selecting only the tail, we'd select nothing and we had
// a logic error that would cause a crash.
s.test_write_string("\u{1F468}").unwrap();
s.test_write_string(" ").unwrap();
let sel = sel_screen(&s, (1, 0), (2, 0), false);
assert_eq!("\u{1F468}", sel_str(&s, sel, true));
}
#[test]
fn screen_selection_string_with_zero_width_joiner() {
let mut s = init(10, 1, 0);
let str = "\u{1F468}\u{200D}"; // this has a ZWJ
s.test_write_string(str).unwrap();
// Integrity check
{
let p = pin(&s, screen_pt(0, 0));
let cell = s.pin_cell(p);
assert_eq!(0x1F468, cell.codepoint());
assert_eq!(Wide::Wide, cell.wide());
let cps = p.grapheme(&s.pages).unwrap();
assert_eq!(1, cps.len());
}
// The real test
{
let sel = sel_screen(&s, (0, 0), (1, 0), false);
assert_eq!("\u{1F468}\u{200D}", sel_str(&s, sel, true));
}
}
const LOREM: &str = "Lorem ipsum dolor\nsit amet, consectetur\nadipiscing elit, sed do\neiusmod tempor incididunt\nut labore et dolore";
#[test]
fn screen_selection_string_rectangle_basic() {
let mut s = init(30, 5, 0);
let sel = sel_screen(&s, (2, 1), (6, 3), true);
let expected = "t ame\nipisc\nusmod";
s.test_write_string(LOREM).unwrap();
assert_eq!(expected, sel_str(&s, sel, true));
}
#[test]
fn screen_selection_string_rectangle_w_eol() {
let mut s = init(30, 5, 0);
let sel = sel_screen(&s, (12, 0), (26, 4), true);
let expected = "dolor\nnsectetur\nlit, sed do\nor incididunt\n dolore";
s.test_write_string(LOREM).unwrap();
assert_eq!(expected, sel_str(&s, sel, true));
}
#[test]
fn screen_selection_string_rectangle_more_complex_w_breaks() {
let mut s = init(30, 8, 0);
let str = "Lorem ipsum dolor\nsit amet, consectetur\nadipiscing elit, sed do\neiusmod tempor incididunt\nut labore et dolore\n\nmagna aliqua. Ut enim\nad minim veniam, quis";
let sel = sel_screen(&s, (11, 2), (26, 7), true);
let expected = "elit, sed do\npor incididunt\nt dolore\n\na. Ut enim\nniam, quis";
s.test_write_string(str).unwrap();
assert_eq!(expected, sel_str(&s, sel, true));
}
#[test]
fn screen_selection_string_multi_page() {
let mut s = init(10, 3, 2048);
let first = s.pages.first.unwrap();
let first_page_size = s.page(first).capacity.rows;
// Lazy way to seek to the first page boundary.
s.page_mut(first).pause_integrity_checks(true);
for _ in 0..first_page_size - 1 {
s.test_write_string("\n").unwrap();
}
s.page_mut(first).pause_integrity_checks(false);
s.test_write_string("123456789\n!@#$%^&*(\n123456789")
.unwrap();
{
let sel = sel_active(&s, (0, 0), (2, 2), false);
assert_eq!("123456789\n!@#$%^&*(\n123", sel_str(&s, sel, true));
}
}
#[test]
fn screen_line_iterator() {
let mut s = init(5, 5, 0);
s.test_write_string("1ABCD\n2EFGH").unwrap();
// Test the line iterator
let mut iter = s.line_iterator(pin(&s, viewport(0, 0)));
{
let sel = iter.next().unwrap();
assert_eq!("1ABCD", sel_str(&s, sel, false));
}
{
let sel = iter.next().unwrap();
assert_eq!("2EFGH", sel_str(&s, sel, false));
}
}
#[test]
fn screen_line_iterator_soft_wrap() {
let mut s = init(5, 5, 0);
s.test_write_string("1ABCD2EFGH\n3ABCD").unwrap();
// Test the line iterator
let mut iter = s.line_iterator(pin(&s, viewport(0, 0)));
{
let sel = iter.next().unwrap();
assert_eq!("1ABCD2EFGH", sel_str(&s, sel, false));
}
{
let sel = iter.next().unwrap();
assert_eq!("3ABCD", sel_str(&s, sel, false));
}
// assert!(iter.next().is_none());
}
#[test]
fn screen_hyperlink_start_end() {
let mut s = init(5, 5, 0);
assert!(s.cursor.hyperlink_id == 0);
assert_eq!(0, s.cursor_page().hyperlink_set.count());
s.start_hyperlink(b"http://example.com", None).unwrap();
assert!(s.cursor.hyperlink_id != 0);
assert_eq!(1, s.cursor_page().hyperlink_set.count());
s.end_hyperlink();
assert!(s.cursor.hyperlink_id == 0);
assert_eq!(0, s.cursor_page().hyperlink_set.count());
}
#[test]
fn screen_hyperlink_reuse() {
let mut s = init(5, 5, 0);
assert!(s.cursor.hyperlink_id == 0);
assert_eq!(0, s.cursor_page().hyperlink_set.count());
// Use it for the first time
s.start_hyperlink(b"http://example.com", None).unwrap();
assert!(s.cursor.hyperlink_id != 0);
let id = s.cursor.hyperlink_id;
// Reuse the same hyperlink, expect we have the same ID
s.start_hyperlink(b"http://example.com", None).unwrap();
assert_eq!(id, s.cursor.hyperlink_id);
assert_eq!(1, s.cursor_page().hyperlink_set.count());
s.end_hyperlink();
assert!(s.cursor.hyperlink_id == 0);
assert_eq!(0, s.cursor_page().hyperlink_set.count());
}
#[test]
fn screen_hyperlink_cursor_state_on_resize() {
// This test depends on underlying PageList implementation so
// it may be invalid one day. It's here to document/verify the
// current behavior.
let mut s = init(5, 10, 0);
// Start a hyperlink
s.start_hyperlink(b"http://example.com", None).unwrap();
assert!(s.cursor.hyperlink_id != 0);
assert_eq!(1, s.cursor_page().hyperlink_set.count());
// Resize. Any column growth will trigger a page to be reallocated.
s.resize(Resize::new(10, 10)).unwrap();
assert!(s.cursor.hyperlink_id != 0);
assert_eq!(1, s.cursor_page().hyperlink_set.count());
s.end_hyperlink();
assert!(s.cursor.hyperlink_id == 0);
assert_eq!(0, s.cursor_page().hyperlink_set.count());
}
#[test]
fn screen_cursor_set_hyperlink_oom_uri_too_large_for_string_alloc() {
let mut s = init(80, 24, 0);
// Start a hyperlink with a URI that just barely fits in the string alloc.
// This will ensure that additional string alloc space is needed for the
// redundant copy of the URI when the page is re-alloced.
let uri = vec![b'a'; STD_CAPACITY.string_bytes as usize - 8];
s.start_hyperlink(&uri, None).unwrap();
// Figure out how many cells should can have hyperlinks in this page,
// and write twice that number, to guarantee the capacity needs to be
// increased at some point.
let base_capacity = s.cursor_page().hyperlink_capacity();
let base_string_bytes = s.cursor_page().capacity.string_bytes;
for _ in 0..base_capacity * 2 {
s.cursor_set_hyperlink().unwrap();
if s.cursor.x >= s.pages.cols - 1 {
s.cursor_down_or_scroll().unwrap();
s.cursor_horizontal_absolute(0);
} else {
s.cursor_right(1);
}
}
// Make sure the capacity really did increase.
assert!(base_capacity < s.cursor_page().hyperlink_capacity());
// And that our string_bytes increased as well.
assert!(base_string_bytes < s.cursor_page().capacity.string_bytes);
}
#[test]
fn screen_increase_capacity_cursor_style_ref_count_preserved() {
let mut s = init(5, 5, 0);
s.set_attribute(A::Bold).unwrap();
s.test_write_string("1ABCD").unwrap();
// We should have one page and it should be our cursor page
assert!(s.pages.first == s.pages.last);
assert!(s.pages.first == Some(s.cursor_pin().node));
let old_style = s.cursor.style;
{
let page = s.page(s.pages.last.unwrap());
// 5 chars + cursor = 6 refs
assert_eq!(6, page.styles.ref_count(s.cursor.style_id));
}
// This forces the page to change via increase_capacity.
let new_node = s
.increase_capacity(s.cursor_pin().node, Some(IncreaseCapacity::GraphemeBytes))
.unwrap();
// Cursor's page_pin should now point to the new node
assert!(s.cursor_pin().node == new_node);
// Cursor row/cell are derived from the pin; verify they resolve.
let cp = s.cursor_pin();
let (row, cell) = cp.row_and_cell(&s.pages).unwrap();
assert_eq!(s.cursor_row(), *row);
assert_eq!(s.cursor_cell(), *cell);
// Style should be preserved
assert_eq!(old_style, s.cursor.style);
assert!(s.cursor.style_id != style::DEFAULT_ID);
// After increase_capacity, the 5 chars are cloned (5 refs) and
// the cursor's style is re-added (1 ref) = 6 total.
{
let page = s.page(s.pages.last.unwrap());
let ref_count = page.styles.ref_count(s.cursor.style_id);
assert_eq!(6, ref_count);
}
}
#[test]
fn screen_increase_capacity_cursor_hyperlink_ref_count_preserved() {
let mut s = init(5, 5, 0);
s.start_hyperlink(b"https://example.com/", None).unwrap();
s.test_write_string("1ABCD").unwrap();
// We should have one page and it should be our cursor page
assert!(s.pages.first == s.pages.last);
assert!(s.pages.first == Some(s.cursor_pin().node));
{
let page = s.page(s.pages.last.unwrap());
// Cursor has the hyperlink active = 1 count in hyperlink_set
assert_eq!(1, page.hyperlink_set.count());
assert!(s.cursor.hyperlink_id != 0);
assert!(s.cursor.hyperlink.is_some());
}
// This forces the page to change via increase_capacity.
s.increase_capacity(s.cursor_pin().node, Some(IncreaseCapacity::GraphemeBytes))
.unwrap();
// Hyperlink should be preserved with correct URI
assert!(s.cursor.hyperlink.is_some());
assert!(s.cursor.hyperlink_id != 0);
assert_eq!(
b"https://example.com/".as_slice(),
s.cursor.hyperlink.as_ref().unwrap().uri.as_slice()
);
// After increase_capacity, the hyperlink is re-added to the new page.
{
let page = s.page(s.pages.last.unwrap());
assert_eq!(1, page.hyperlink_set.count());
}
}
#[test]
fn screen_increase_capacity_cursor_with_both_style_and_hyperlink_preserved() {
let mut s = init(5, 5, 0);
// Set both a non-default style AND an active hyperlink.
// Write one character first with bold to mark the row as styled,
// then start the hyperlink and write more characters.
s.set_attribute(A::Bold).unwrap();
s.start_hyperlink(b"https://example.com/", None).unwrap();
s.test_write_string("1ABCD").unwrap();
// We should have one page and it should be our cursor page
assert!(s.pages.first == s.pages.last);
assert!(s.pages.first == Some(s.cursor_pin().node));
let old_style = s.cursor.style;
{
let page = s.page(s.pages.last.unwrap());
// 5 chars + cursor = 6 refs for bold style
assert_eq!(6, page.styles.ref_count(s.cursor.style_id));
// Cursor has the hyperlink active = 1 count in hyperlink_set
assert_eq!(1, page.hyperlink_set.count());
assert!(s.cursor.style_id != style::DEFAULT_ID);
assert!(s.cursor.hyperlink_id != 0);
assert!(s.cursor.hyperlink.is_some());
}
// This forces the page to change via increase_capacity.
s.increase_capacity(s.cursor_pin().node, Some(IncreaseCapacity::GraphemeBytes))
.unwrap();
// Style should be preserved
assert_eq!(old_style, s.cursor.style);
assert!(s.cursor.style_id != style::DEFAULT_ID);
// Hyperlink should be preserved with correct URI
assert!(s.cursor.hyperlink.is_some());
assert!(s.cursor.hyperlink_id != 0);
assert_eq!(
b"https://example.com/".as_slice(),
s.cursor.hyperlink.as_ref().unwrap().uri.as_slice()
);
// After increase_capacity, both style and hyperlink are re-added to the new page.
{
let page = s.page(s.pages.last.unwrap());
let ref_count = page.styles.ref_count(s.cursor.style_id);
assert_eq!(6, ref_count);
assert_eq!(1, page.hyperlink_set.count());
}
}
#[test]
fn screen_increase_capacity_non_cursor_page_returns_early() {
// Test that calling increase_capacity on a page that is NOT the cursor's
// page properly delegates to pages.increase_capacity without doing the
// extra cursor accounting (style/hyperlink re-adding).
let mut s = init(80, 24, 10000);
// Set up a custom style and hyperlink on the cursor
s.set_attribute(A::Bold).unwrap();
s.start_hyperlink(b"https://example.com/", None).unwrap();
s.test_write_string("Hello").unwrap();
// Store cursor state before growing pages
let old_style = s.cursor.style;
let old_style_id = s.cursor.style_id;
let old_hyperlink = s.cursor.hyperlink.clone();
let old_hyperlink_id = s.cursor.hyperlink_id;
// The cursor is on the first (and only) page
assert!(s.pages.first == s.pages.last);
assert!(Some(s.cursor_pin().node) == s.pages.first);
// Grow pages until we have multiple pages. The cursor's pin stays on
// the first page since we're just adding rows.
let first_page_node = s.pages.first.unwrap();
s.page_mut(first_page_node).pause_integrity_checks(true);
let remaining = {
let p = s.page(first_page_node);
p.capacity.rows - p.size.rows
};
for _ in 0..remaining {
s.pages.grow().unwrap();
}
s.page_mut(first_page_node).pause_integrity_checks(false);
s.pages.grow().unwrap();
// Now we have two pages
assert!(s.pages.first != s.pages.last);
let second_page = s.pages.last.unwrap();
// Cursor should still be on the first page (where it was created)
assert!(Some(s.cursor_pin().node) == s.pages.first);
assert!(s.cursor_pin().node != second_page);
let second_page_styles_cap = s.page(second_page).capacity.styles;
let cursor_page_styles_cap = s.cursor_page().capacity.styles;
// Call increase_capacity on the second page (NOT the cursor's page)
let new_second_page = s
.increase_capacity(second_page, Some(IncreaseCapacity::Styles))
.unwrap();
// The second page should have increased capacity
assert_eq!(
second_page_styles_cap * 2,
s.page(new_second_page).capacity.styles
);
// The cursor's page (first page) should be unchanged
assert_eq!(cursor_page_styles_cap, s.cursor_page().capacity.styles);
// Cursor state should be completely unchanged since we didn't touch its page
assert_eq!(old_style, s.cursor.style);
assert_eq!(old_style_id, s.cursor.style_id);
assert_eq!(old_hyperlink, s.cursor.hyperlink);
assert_eq!(old_hyperlink_id, s.cursor.hyperlink_id);
// Verify hyperlink is still valid
assert!(s.cursor.hyperlink.is_some());
assert_eq!(
b"https://example.com/".as_slice(),
s.cursor.hyperlink.as_ref().unwrap().uri.as_slice()
);
}
/// Fill a page's style set until `add` fails, with unique bg colors.
fn fill_styles(page: &mut Page) {
page.pause_integrity_checks(true);
let mut n: u32 = 1;
while page
.styles
.add(Style {
bg_color: Color::Rgb(Rgb {
r: (n >> 16) as u8,
g: (n >> 8) as u8,
b: n as u8,
}),
..Style::default()
})
.is_ok()
{
n += 1;
}
page.pause_integrity_checks(false);
page.assert_integrity();
}
#[test]
fn screen_cursor_down_to_page_with_insufficient_capacity() {
// Regression test for https://github.com/ghostty-org/ghostty/issues/10282
//
// This test exposes a use-after-realloc bug in cursorDown (and similar
// cursor movement functions). The bug pattern:
//
// 1. cursorDown creates a by-value copy of the pin via page_pin.down(n)
// 2. cursorChangePin is called, which may trigger adjustCapacity
// if the target page's style map is full
// 3. adjustCapacity frees the old page and creates a new one
// 4. The local pin copy still points to the freed page
// 5. rowAndCell() on the stale pin accesses freed memory
// Small screen to make page boundary crossing easy to set up
let mut s = init(10, 3, 1);
// Scroll down enough to create a second page
let start_page = s.pages.last.unwrap();
let rem = s.page(start_page).capacity.rows;
s.page_mut(start_page).pause_integrity_checks(true);
for _ in 0..rem {
s.cursor_down_or_scroll().unwrap();
}
s.page_mut(start_page).pause_integrity_checks(false);
// Cursor should now be on a new page
let new_page = s.cursor_pin().node;
assert!(start_page != new_page);
// Fill new_page's style map to capacity. When we move INTO this page
// with a style set, adjustCapacity will be triggered.
fill_styles(s.page_mut(new_page));
// Move cursor to start of active area and set a style
s.cursor_absolute(0, 0);
s.set_attribute(A::Bold).unwrap();
assert!(s.cursor.style.flags.bold);
assert!(s.cursor.style_id != style::DEFAULT_ID);
// Find the row just before the page boundary
let mut found = false;
for row in 0..s.pages.rows - 1 {
s.cursor_absolute(0, row);
let cur_node = s.cursor_pin().node;
if let Some(next_pin) = s.cursor_pin().down(&s.pages, 1)
&& next_pin.node != cur_node
{
// Cursor is at 'row', moving down crosses to new_page
assert!(next_pin.node == new_page);
// This cursorDown triggers the bug: the local page_pin copy
// becomes stale after adjustCapacity, causing rowAndCell()
// to access freed memory.
s.cursor_down(1);
// If the fix is applied, verify correct state
assert!(s.cursor.y == row + 1);
assert!(s.cursor.style.flags.bold);
found = true;
break;
}
}
// Didn't find boundary
assert!(found);
}
#[test]
fn screen_set_attribute_increases_capacity_when_style_map_is_full() {
// Tests that setAttribute succeeds when the style map is full by
// increasing page capacity. When capacity is at max and increaseCapacity
// returns OutOfSpace, manualStyleUpdate will split the page instead.
// Use a small screen with multiple rows
let mut s = init(10, 5, 10);
// Write content to multiple rows
s.test_write_string("line1\nline2\nline3\nline4\nline5")
.unwrap();
// Get the page and fill its style map to capacity
let page = s.cursor_pin().node;
let original_styles_capacity = s.page(page).capacity.styles;
// Fill the style map to capacity
fill_styles(s.page_mut(page));
// Now try to set a new unique attribute that would require a new style slot
// This should succeed by increasing capacity (or splitting if at max capacity)
s.set_attribute(A::Bold).unwrap();
// The style should have been applied (bold flag set)
assert!(s.cursor.style.flags.bold);
// The cursor should have a valid non-default style_id
assert!(s.cursor.style_id != style::DEFAULT_ID);
// Either the capacity increased or the page was split/changed
let current_page = s.cursor_pin().node;
let capacity_increased = s.page(current_page).capacity.styles > original_styles_capacity;
let page_changed = current_page != page;
assert!(capacity_increased || page_changed);
}
#[test]
fn screen_set_attribute_splits_page_on_out_of_space_at_max_styles() {
let mut s = init(10, 10, 0);
// Write content to multiple rows so we have something to split
s.test_write_string("line1\nline2\nline3\nline4\nline5")
.unwrap();
// Remember the original node
let original_node = s.cursor_pin().node;
// Increase the page's style capacity to max by repeatedly calling increase_capacity
// Use Screen.increase_capacity to properly maintain cursor state
let max_styles = size::CellCountInt::MAX;
while s.cursor_page().capacity.styles < max_styles {
if s.increase_capacity(s.cursor_pin().node, Some(IncreaseCapacity::Styles))
.is_err()
{
break;
}
}
// Get the page reference after increase_capacity - cursor may have moved
let node = s.cursor_pin().node;
assert_eq!(max_styles, s.page(node).capacity.styles);
// Fill the style map to capacity
fill_styles(s.page_mut(node));
// Track the node before setAttribute
let node_before_set = s.cursor_pin().node;
// Now try to set a new unique attribute that would require a new style slot
// At max capacity, increase_capacity will return OutOfSpace, triggering page split
s.set_attribute(A::Bold).unwrap();
// The style should have been applied (bold flag set)
assert!(s.cursor.style.flags.bold);
// The cursor should have a valid non-default style_id
assert!(s.cursor.style_id != style::DEFAULT_ID);
// The page should have been split
let nb = s.pages.node(node_before_set);
let page_was_split = s.cursor_pin().node != node_before_set
|| nb.is_some_and(|n| n.next.is_some())
|| nb.is_some_and(|n| n.prev.is_some())
|| s.cursor_pin().node != original_node;
assert!(page_was_split);
}
// "selectionString map allocation failure cleanup" is skipped: it relies on
// Zig tripwire allocation-failure injection (selectionString_tw), which has
// no Rust counterpart.
fn setup_prompt(cols: u16, click: Click) -> Screen {
let mut s = init(cols, 5, 0);
// Enable line click mode
s.semantic_prompt.click = SemanticClick::Cl(click);
s
}
fn prompt(s: &mut Screen) {
s.cursor_set_semantic_content(SemanticContentSet::Prompt(PromptKind::Initial));
}
fn input(s: &mut Screen) {
s.cursor_set_semantic_content(SemanticContentSet::Input(InputClear::ClearExplicit));
}
fn output(s: &mut Screen) {
s.cursor_set_semantic_content(SemanticContentSet::Output);
}
fn click_at(s: &Screen, x: u16, y: u32) -> PromptClickMove {
let p = pin(s, active(x, y));
s.prompt_click_move(p)
}
#[test]
fn screen_prompt_click_move_line_right_basic() {
let mut s = setup_prompt(20, Click::Line);
prompt(&mut s);
s.test_write_string("> ").unwrap();
input(&mut s);
s.test_write_string("hello").unwrap();
// Move cursor back to start of input (column 2, the 'h')
s.cursor_absolute(2, 0);
// Click on first 'l' (column 4), should require 2 right movements (h->e->l)
let result = click_at(&s, 4, 0);
assert_eq!(2, result.right);
assert_eq!(0, result.left);
}
#[test]
fn screen_prompt_click_move_line_right_cursor_not_on_input() {
let mut s = setup_prompt(20, Click::Line);
prompt(&mut s);
s.test_write_string("> ").unwrap();
input(&mut s);
s.test_write_string("hello").unwrap();
output(&mut s);
// Move cursor back to prompt area (column 0, the '>')
s.cursor_absolute(0, 0);
// Cursor is on prompt, not input - should return zero
let result = click_at(&s, 4, 0);
assert_eq!(PromptClickMove::ZERO, result);
}
#[test]
fn screen_prompt_click_move_line_right_click_on_same_position() {
let mut s = setup_prompt(20, Click::Line);
prompt(&mut s);
s.test_write_string("> ").unwrap();
input(&mut s);
s.test_write_string("hello").unwrap();
// Move cursor to column 4
s.cursor_absolute(4, 0);
// Click on same position - no movement needed
let result = click_at(&s, 4, 0);
assert_eq!(PromptClickMove::ZERO, result);
}
#[test]
fn screen_prompt_click_move_line_right_skips_non_input_cells() {
let mut s = setup_prompt(20, Click::Line);
// Write: "> h" then output "X" then input "llo"
prompt(&mut s);
s.test_write_string("> ").unwrap();
input(&mut s);
s.test_write_string("h").unwrap();
output(&mut s);
s.test_write_string("X").unwrap();
input(&mut s);
s.test_write_string("llo").unwrap();
// Move cursor to column 2 (the 'h')
s.cursor_absolute(2, 0);
// Click on 'l' at column 5 - should skip the 'X' output cell
// Movement: h (start) -> l (col 4) -> l (col 5) = 2 right movements
let result = click_at(&s, 5, 0);
assert_eq!(2, result.right);
assert_eq!(0, result.left);
}
#[test]
fn screen_prompt_click_move_line_right_soft_wrapped_line() {
let mut s = setup_prompt(10, Click::Line);
// Write a prompt and input that wraps
prompt(&mut s);
s.test_write_string("> ").unwrap();
input(&mut s);
// Write 8 chars of input, first row has 2 for prompt + 8 input = 10 cols
s.test_write_string("abcdefgh").unwrap();
// Continue on next row (soft-wrapped)
s.test_write_string("ij").unwrap();
// Verify soft wrap occurred
assert_eq!("> abcdefgh\nij", dump(&s, viewport(0, 0)));
// Move cursor to column 2 (the 'a')
s.cursor_absolute(2, 0);
// Click on 'j' at column 1, row 1 - should count all input cells
// Movement: a->b->c->d->e->f->g->h->i->j = 9 right movements
let result = click_at(&s, 1, 1);
assert_eq!(9, result.right);
assert_eq!(0, result.left);
}
#[test]
fn screen_prompt_click_move_disabled_when_click_is_none() {
let mut s = setup_prompt(20, Click::Line);
// Click mode is .none by default (disabled)
s.semantic_prompt.click = SemanticClick::None;
assert_eq!(SemanticClick::None, s.semantic_prompt.click);
prompt(&mut s);
s.test_write_string("> ").unwrap();
input(&mut s);
s.test_write_string("hello").unwrap();
// Move cursor to start of input
s.cursor_absolute(2, 0);
// Click should return zero since click mode is disabled
let result = click_at(&s, 4, 0);
assert_eq!(PromptClickMove::ZERO, result);
}
#[test]
fn screen_prompt_click_move_line_right_stops_at_hard_wrap() {
let mut s = setup_prompt(20, Click::Line);
prompt(&mut s);
s.test_write_string("> ").unwrap();
input(&mut s);
s.test_write_string("hello").unwrap();
// Hard wrap (newline)
s.test_write_string("\n").unwrap();
s.test_write_string("world").unwrap();
// Move cursor to column 2 (the 'h')
s.cursor_absolute(2, 0);
// Click on 'w' at column 0, row 1 - but line mode stops at hard wrap
// Should only move to end of first line: h->e->l->l->o = 4 movements
let result = click_at(&s, 0, 1);
// Should stop at end of first line, not cross hard wrap
assert_eq!(5, result.right);
assert_eq!(0, result.left);
}
#[test]
fn screen_prompt_click_move_line_right_stops_at_non_continuation_row() {
let mut s = setup_prompt(20, Click::Line);
// Row 0: PROMPT "> hello"
prompt(&mut s);
s.test_write_string("> ").unwrap();
input(&mut s);
s.test_write_string("hello\n").unwrap();
// Row 1: CONTINUATION "world"
s.cursor_set_semantic_content(SemanticContentSet::Prompt(PromptKind::Continuation));
input(&mut s);
s.test_write_string("world\n").unwrap();
// Row 2: NEW PROMPT "> again"
prompt(&mut s);
s.test_write_string("> ").unwrap();
input(&mut s);
s.test_write_string("again").unwrap();
// Verify content
assert_eq!("> hello\nworld\n> again", dump(&s, viewport(0, 0)));
// Move cursor to 'w' at column 0, row 1
s.cursor_absolute(0, 1);
// Click on 'a' at column 2, row 2 - but row 2 is a new prompt
// Should stop at end of "world": w->o->r->l->d = 4 movements
let result = click_at(&s, 2, 2);
// Should stop at 'd' (end of world), not cross to new prompt
assert_eq!(5, result.right);
assert_eq!(0, result.left);
}
#[test]
fn screen_prompt_click_move_line_left_basic() {
let mut s = setup_prompt(20, Click::Line);
prompt(&mut s);
s.test_write_string("> ").unwrap();
input(&mut s);
s.test_write_string("hello").unwrap();
// Cursor is at column 7 (after 'o'), move it to column 6 (the 'o')
s.cursor_absolute(6, 0);
// Click on 'h' (column 2), should require 4 left movements (o->l->l->e->h)
let result = click_at(&s, 2, 0);
assert_eq!(0, result.right);
assert_eq!(4, result.left);
}
#[test]
fn screen_prompt_click_move_line_left_skips_non_input_cells() {
let mut s = setup_prompt(20, Click::Line);
// Write: "> h" then output "X" then input "llo"
prompt(&mut s);
s.test_write_string("> ").unwrap();
input(&mut s);
s.test_write_string("h").unwrap();
output(&mut s);
s.test_write_string("X").unwrap();
input(&mut s);
s.test_write_string("llo").unwrap();
// Move cursor to column 6 (the 'o')
s.cursor_absolute(6, 0);
// Click on 'h' at column 2 - should skip the 'X' output cell
// Movement: o->l->l->h = 3 left movements (skipping X)
let result = click_at(&s, 2, 0);
assert_eq!(0, result.right);
assert_eq!(3, result.left);
}
#[test]
fn screen_prompt_click_move_line_left_soft_wrapped_line() {
let mut s = setup_prompt(10, Click::Line);
// Write a prompt and input that wraps
prompt(&mut s);
s.test_write_string("> ").unwrap();
input(&mut s);
// Write 8 chars of input, first row has 2 for prompt + 8 input = 10 cols
s.test_write_string("abcdefgh").unwrap();
// Continue on next row (soft-wrapped)
s.test_write_string("ij").unwrap();
// Verify soft wrap occurred
assert_eq!("> abcdefgh\nij", dump(&s, viewport(0, 0)));
// Cursor is at column 2, row 1 (after 'j'). Move to 'j' at column 1.
s.cursor_absolute(1, 1);
// Click on 'a' at column 2, row 0 - should count all input cells backwards
// Movement: j->i->h->g->f->e->d->c->b->a = 9 left movements
let result = click_at(&s, 2, 0);
assert_eq!(0, result.right);
assert_eq!(9, result.left);
}
#[test]
fn screen_prompt_click_move_line_left_stops_at_hard_wrap() {
let mut s = setup_prompt(20, Click::Line);
prompt(&mut s);
s.test_write_string("> ").unwrap();
input(&mut s);
s.test_write_string("hello").unwrap();
// Hard wrap (newline)
s.test_write_string("\n").unwrap();
s.test_write_string("world").unwrap();
// Move cursor to 'd' at column 4, row 1 (an actual input cell)
s.cursor_absolute(4, 1);
// Click on 'h' at column 2, row 0 - but line mode stops at hard wrap
// Should only move to start of second line, not cross to row 0
let result = click_at(&s, 2, 0);
// Should stop at start of second line: d->l->r->o->w = 4 movements
assert_eq!(0, result.right);
assert_eq!(4, result.left);
}
#[test]
fn screen_prompt_click_move_click_right_of_input_same_line() {
let mut s = setup_prompt(20, Click::Line);
// Set up: "> hello" where "> " is prompt and "hello" is input
// Clicking to the right of the 'o' should move cursor past the input
prompt(&mut s);
s.test_write_string("> ").unwrap();
input(&mut s);
s.test_write_string("hello").unwrap();
// Move cursor to start of input (column 2, the 'h')
s.cursor_absolute(2, 0);
// Click beyond the input (column 15) - should move to one past the 'o'
let result = click_at(&s, 15, 0);
assert_eq!(5, result.right);
assert_eq!(0, result.left);
}
#[test]
fn screen_prompt_click_move_click_right_of_input_cursor_at_end() {
let mut s = setup_prompt(20, Click::Line);
prompt(&mut s);
s.test_write_string("> ").unwrap();
input(&mut s);
s.test_write_string("hello").unwrap();
// Cursor is already at column 7 (one past 'o') after writing
// Click beyond the input (column 15) - no movement needed since
// cursor is already at the end position
let result = click_at(&s, 15, 0);
assert_eq!(0, result.right);
assert_eq!(0, result.left);
}
#[test]
fn screen_prompt_click_move_click_right_of_input_on_lower_line() {
let mut s = setup_prompt(20, Click::Line);
prompt(&mut s);
s.test_write_string("> ").unwrap();
input(&mut s);
s.test_write_string("hello").unwrap();
// Move cursor to start of input (column 2, the 'h')
s.cursor_absolute(2, 0);
// Click on a lower line (row 1) - should move to end of input
// This is outside the prompt area so should clamp to end
let result = click_at(&s, 5, 1);
// From 'h', we need to pass e, l, l, o (4 cells) + 1 past end = 5
assert_eq!(5, result.right);
assert_eq!(0, result.left);
}
#[test]
fn screen_prompt_click_move_click_right_of_input_cursor_at_end_lower_line() {
let mut s = setup_prompt(20, Click::Line);
prompt(&mut s);
s.test_write_string("> ").unwrap();
input(&mut s);
s.test_write_string("hello").unwrap();
// Cursor is at column 7 after writing (one past 'o')
// Click on a lower line (row 1) - cursor already at end, no movement needed
let result = click_at(&s, 5, 1);
assert_eq!(0, result.right);
assert_eq!(0, result.left);
}
#[test]
fn screen_prompt_click_move_click_right_of_input_cursor_on_last_char() {
let mut s = setup_prompt(20, Click::Line);
prompt(&mut s);
s.test_write_string("> ").unwrap();
input(&mut s);
s.test_write_string("hello").unwrap();
// Move cursor to last input char (column 6, the 'o')
s.cursor_absolute(6, 0);
// Click beyond the input (column 15)
let result = click_at(&s, 15, 0);
assert_eq!(1, result.right);
assert_eq!(0, result.left);
}