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)
This commit is contained in:
ddidderr committed 2026-10-11 22:28:03 +02:00
1 parent 99d3ff9d41
commit 271f5e7bb5
16 files changed
+10070

No files matched your search

@@ -0,0 +1,61 @@
//! Ported Screen.zig tests. Shared helpers live here; the tests themselves
//! are split over `p*.rs` files in the order they appear in Screen.zig.
#![allow(
clippy::manual_assert_eq,
clippy::unwrap_used,
clippy::too_many_lines,
dead_code
)]
pub(super) use super::*;
pub(super) use crate::point::Coordinate;
mod p1;
mod p2;
mod p3;
mod p4;
mod p5;
/// `Screen.init(alloc, .{ .cols, .rows, .max_scrollback })`
pub(super) fn init(cols: u16, rows: u16, max_scrollback: usize) -> Screen {
Screen::new(Options::new(cols, rows, max_scrollback)).unwrap()
}
/// Point constructors with the Zig `.{ .tag = .{ .x, .y } }` shape.
pub(super) fn active(x: u16, y: u32) -> Point {
Point::Active(Coordinate { x, y })
}
pub(super) fn screen_pt(x: u16, y: u32) -> Point {
Point::Screen(Coordinate { x, y })
}
pub(super) fn history(x: u16, y: u32) -> Point {
Point::History(Coordinate { x, y })
}
pub(super) fn viewport(x: u16, y: u32) -> Point {
Point::Viewport(Coordinate { x, y })
}
/// `s.dumpStringAlloc(alloc, tl)`
pub(super) fn dump(s: &Screen, tl: Point) -> String {
s.dump_string_alloc(tl).unwrap()
}
/// `s.dumpStringAllocUnwrapped(alloc, tl)`
pub(super) fn dump_unwrapped(s: &Screen, tl: Point) -> String {
s.dump_string_alloc_unwrapped(tl).unwrap()
}
/// `s.pages.pin(pt).?`
pub(super) fn pin(s: &Screen, pt: Point) -> Pin {
s.pages.pin(pt).unwrap()
}
/// `s.pages.pointFromPin(tag, pin).?`
pub(super) fn pt_from_pin(s: &Screen, tag: Tag, p: Pin) -> Point {
s.pages.point_from_pin(tag, p).unwrap()
}
/// The page at the cursor.
pub(super) fn cursor_page(s: &Screen) -> &Page {
s.cursor_page()
}
@@ -0,0 +1,438 @@
use super::*;
use ghostty_vt::sgr::Attribute as A;
#[test]
fn screen_read_and_write() {
let mut s = init(80, 24, 1000);
assert_eq!(0, s.cursor.style_id);
s.test_write_string("hello, world").unwrap();
let str = dump(&s, screen_pt(0, 0));
assert_eq!("hello, world", str);
}
#[test]
fn screen_read_and_write_newline() {
let mut s = init(80, 24, 1000);
assert_eq!(0, s.cursor.style_id);
s.test_write_string("hello\nworld").unwrap();
let str = dump(&s, screen_pt(0, 0));
assert_eq!("hello\nworld", str);
}
#[test]
fn screen_read_and_write_scrollback() {
let mut s = init(80, 2, 1000);
s.test_write_string("hello\nworld\ntest").unwrap();
assert_eq!("hello\nworld\ntest", dump(&s, screen_pt(0, 0)));
assert_eq!("world\ntest", dump(&s, active(0, 0)));
}
#[test]
fn screen_read_and_write_no_scrollback_small() {
let mut s = init(80, 2, 0);
s.test_write_string("hello\nworld\ntest").unwrap();
assert_eq!("world\ntest", dump(&s, screen_pt(0, 0)));
assert_eq!("world\ntest", dump(&s, active(0, 0)));
}
#[test]
fn screen_read_and_write_no_scrollback_large() {
let mut s = init(80, 2, 0);
for i in 0..1_000 {
s.test_write_string(&format!("{i}\n")).unwrap();
}
s.test_write_string("1000").unwrap();
assert_eq!("999\n1000", dump(&s, screen_pt(0, 0)));
}
#[test]
fn screen_cursor_copy_x_y() {
let mut s = init(10, 10, 0);
s.cursor_absolute(2, 3);
assert!(s.cursor.x == 2);
assert!(s.cursor.y == 3);
let mut s2 = init(10, 10, 0);
s2.cursor_copy(&s.cursor, true).unwrap();
assert!(s2.cursor.x == 2);
assert!(s2.cursor.y == 3);
s2.test_write_string("Hello").unwrap();
assert_eq!("\n\n\n Hello", dump(&s2, screen_pt(0, 0)));
}
#[test]
fn screen_cursor_copy_style_deref() {
let s = init(10, 10, 0);
let mut s2 = init(10, 10, 0);
let page = s2.cursor_pin().node;
// Bold should create our style
s2.set_attribute(A::Bold).unwrap();
assert_eq!(1, s2.page(page).styles.count());
assert!(s2.cursor.style.flags.bold);
// Copy default style, should release our style
s2.cursor_copy(&s.cursor, true).unwrap();
assert!(!s2.cursor.style.flags.bold);
assert_eq!(0, s2.page(page).styles.count());
}
/// Builds the two-page setup shared by the "new page" cursorCopy tests.
fn cursor_on_second_page() -> Screen {
let mut s2 = init(10, 10, 2048);
// We need to get the cursor on a new page.
let first = s2.pages.first.unwrap();
let first_page_size = s2.page(first).capacity.rows;
// Fill the scrollback with blank lines until
// there are only 5 rows left on the first page.
s2.page_mut(first).pause_integrity_checks(true);
for _ in 0..first_page_size - 5 {
s2.test_write_string("\n").unwrap();
}
s2.page_mut(first).pause_integrity_checks(false);
s2.test_write_string("1\n2\n3\n4\n5\n6\n7\n8\n9\n10")
.unwrap();
// s2.pages.diagram(...):
//
// +----------+ = PAGE 0
// ... : :
// +-------------+ ACTIVE
// 4300 |1 | | 0
// 4301 |2 | | 1
// 4302 |3 | | 2
// 4303 |4 | | 3
// 4304 |5 | | 4
// +----------+ :
// +----------+ : = PAGE 1
// 0 |6 | | 5
// 1 |7 | | 6
// 2 |8 | | 7
// 3 |9 | | 8
// 4 |10 | | 9
// : ^ : : = PIN 0
// +----------+ :
// +-------------+
s2
}
#[test]
fn screen_cursor_copy_style_deref_new_page() {
let s = init(10, 10, 0);
let mut s2 = cursor_on_second_page();
// This should be PAGE 1
let page = s2.cursor_pin().node;
// It should be the last page in the list.
assert_eq!(Some(page), s2.pages.last);
// It should have a previous page.
assert!(s2.pages.node(page).unwrap().prev.is_some());
// The cursor should be at 2, 9
assert!(s2.cursor.x == 2);
assert!(s2.cursor.y == 9);
// Bold should create our style in page 1.
s2.set_attribute(A::Bold).unwrap();
assert_eq!(1, s2.page(page).styles.count());
assert!(s2.cursor.style.flags.bold);
// Copy the cursor for the first screen. This should release
// the style from page 1 and move the cursor back to page 0.
s2.cursor_copy(&s.cursor, true).unwrap();
assert!(!s2.cursor.style.flags.bold);
assert_eq!(0, s2.page(page).styles.count());
// The page after the page the cursor is now in should be page 1.
assert_eq!(
Some(page),
s2.pages.node(s2.cursor_pin().node).unwrap().next
);
// The cursor should be at 0, 0
assert!(s2.cursor.x == 0);
assert!(s2.cursor.y == 0);
}
#[test]
fn screen_cursor_copy_style_copy() {
let mut s = init(10, 10, 0);
s.set_attribute(A::Bold).unwrap();
let mut s2 = init(10, 10, 0);
let page = s2.cursor_pin().node;
s2.cursor_copy(&s.cursor, true).unwrap();
assert!(s2.cursor.style.flags.bold);
assert_eq!(1, s2.page(page).styles.count());
}
#[test]
fn screen_cursor_copy_hyperlink_deref() {
let s = init(10, 10, 0);
let mut s2 = init(10, 10, 0);
let page = s2.cursor_pin().node;
// Create a hyperlink for the cursor.
s2.start_hyperlink(b"https://example.com/", None).unwrap();
assert_eq!(1, s2.page(page).hyperlink_set.count());
assert!(s2.cursor.hyperlink_id != 0);
// Copy a cursor with no hyperlink, should release our hyperlink.
s2.cursor_copy(&s.cursor, true).unwrap();
assert_eq!(0, s2.page(page).hyperlink_set.count());
assert!(s2.cursor.hyperlink_id == 0);
}
#[test]
fn screen_cursor_copy_hyperlink_deref_new_page() {
let s = init(10, 10, 0);
let mut s2 = cursor_on_second_page();
// This should be PAGE 1
let page = s2.cursor_pin().node;
// It should be the last page in the list.
assert_eq!(Some(page), s2.pages.last);
// It should have a previous page.
assert!(s2.pages.node(page).unwrap().prev.is_some());
// The cursor should be at 2, 9
assert!(s2.cursor.x == 2);
assert!(s2.cursor.y == 9);
// Create a hyperlink for the cursor, should be in page 1.
s2.start_hyperlink(b"https://example.com/", None).unwrap();
assert_eq!(1, s2.page(page).hyperlink_set.count());
assert!(s2.cursor.hyperlink_id != 0);
// Copy the cursor for the first screen. This should release
// the hyperlink from page 1 and move the cursor back to page 0.
s2.cursor_copy(&s.cursor, true).unwrap();
assert_eq!(0, s2.page(page).hyperlink_set.count());
assert!(s2.cursor.hyperlink_id == 0);
// The page after the page the cursor is now in should be page 1.
assert_eq!(
Some(page),
s2.pages.node(s2.cursor_pin().node).unwrap().next
);
// The cursor should be at 0, 0
assert!(s2.cursor.x == 0);
assert!(s2.cursor.y == 0);
}
#[test]
fn screen_cursor_copy_hyperlink_copy() {
let mut s = init(10, 10, 0);
// Create a hyperlink for the cursor.
s.start_hyperlink(b"https://example.com/", None).unwrap();
assert_eq!(1, s.cursor_page().hyperlink_set.count());
assert!(s.cursor.hyperlink_id != 0);
let mut s2 = init(10, 10, 0);
let page = s2.cursor_pin().node;
assert_eq!(0, s2.page(page).hyperlink_set.count());
assert!(s2.cursor.hyperlink_id == 0);
// Copy the cursor with the hyperlink.
s2.cursor_copy(&s.cursor, true).unwrap();
assert_eq!(1, s2.page(page).hyperlink_set.count());
assert!(s2.cursor.hyperlink_id != 0);
}
#[test]
fn screen_cursor_copy_hyperlink_copy_disabled() {
let mut s = init(10, 10, 0);
// Create a hyperlink for the cursor.
s.start_hyperlink(b"https://example.com/", None).unwrap();
assert_eq!(1, s.cursor_page().hyperlink_set.count());
assert!(s.cursor.hyperlink_id != 0);
let mut s2 = init(10, 10, 0);
let page = s2.cursor_pin().node;
assert_eq!(0, s2.page(page).hyperlink_set.count());
assert!(s2.cursor.hyperlink_id == 0);
// Copy the cursor with the hyperlink.
s2.cursor_copy(&s.cursor, false).unwrap();
assert_eq!(0, s2.page(page).hyperlink_set.count());
assert!(s2.cursor.hyperlink_id == 0);
}
#[test]
fn screen_style_basics() {
let mut s = init(80, 24, 1000);
let page = s.cursor_pin().node;
assert_eq!(0, s.page(page).styles.count());
// Set a new style
s.set_attribute(A::Bold).unwrap();
assert!(s.cursor.style_id != 0);
assert_eq!(1, s.page(page).styles.count());
assert!(s.cursor.style.flags.bold);
// Set another style, we should still only have one since it was unused
s.set_attribute(A::Italic).unwrap();
assert!(s.cursor.style_id != 0);
assert_eq!(1, s.page(page).styles.count());
assert!(s.cursor.style.flags.italic);
}
#[test]
fn screen_style_reset_to_default() {
let mut s = init(80, 24, 1000);
let page = s.cursor_pin().node;
assert_eq!(0, s.page(page).styles.count());
// Set a new style
s.set_attribute(A::Bold).unwrap();
assert!(s.cursor.style_id != 0);
assert_eq!(1, s.page(page).styles.count());
// Reset to default
s.set_attribute(A::ResetBold).unwrap();
assert!(s.cursor.style_id == 0);
assert_eq!(0, s.page(page).styles.count());
}
#[test]
fn screen_style_reset_with_unset() {
let mut s = init(80, 24, 1000);
let page = s.cursor_pin().node;
assert_eq!(0, s.page(page).styles.count());
// Set a new style
s.set_attribute(A::Bold).unwrap();
assert!(s.cursor.style_id != 0);
assert_eq!(1, s.page(page).styles.count());
// Reset to default
s.set_attribute(A::Unset).unwrap();
assert!(s.cursor.style_id == 0);
assert_eq!(0, s.page(page).styles.count());
}
#[test]
fn screen_clear_rows_active_one_line() {
let mut s = init(80, 24, 1000);
s.test_write_string("hello, world").unwrap();
s.clear_rows(active(0, 0), None, false);
assert!(s.pages.is_dirty(active(0, 0)));
assert_eq!("", dump(&s, screen_pt(0, 0)));
}
#[test]
fn screen_clear_rows_active_multi_line() {
let mut s = init(80, 24, 1000);
s.test_write_string("hello\nworld").unwrap();
s.clear_rows(active(0, 0), None, false);
assert!(s.pages.is_dirty(active(0, 0)));
assert!(s.pages.is_dirty(active(0, 1)));
assert_eq!("", dump(&s, screen_pt(0, 0)));
}
#[test]
fn screen_clear_rows_active_styled_line() {
let mut s = init(80, 24, 1000);
s.set_attribute(A::Bold).unwrap();
s.test_write_string("hello world").unwrap();
s.set_attribute(A::Unset).unwrap();
// We should have one style
let page = s.cursor_pin().node;
assert_eq!(1, s.page(page).styles.count());
s.clear_rows(active(0, 0), None, false);
// We should have none because active cleared it
assert_eq!(0, s.page(page).styles.count());
assert_eq!("", dump(&s, screen_pt(0, 0)));
}
#[test]
fn screen_clear_rows_protected() {
let mut s = init(80, 24, 1000);
s.test_write_string("UNPROTECTED").unwrap();
s.cursor.protected = true;
s.test_write_string("PROTECTED").unwrap();
s.cursor.protected = false;
s.test_write_string("UNPROTECTED").unwrap();
s.test_write_string("\n").unwrap();
s.cursor.protected = true;
s.test_write_string("PROTECTED").unwrap();
s.cursor.protected = false;
s.test_write_string("UNPROTECTED").unwrap();
s.cursor.protected = true;
s.test_write_string("PROTECTED").unwrap();
s.cursor.protected = false;
s.clear_rows(active(0, 0), None, true);
assert_eq!(
" PROTECTED\nPROTECTED PROTECTED",
dump(&s, screen_pt(0, 0))
);
}
#[test]
fn screen_erase_rows_history() {
let mut s = init(5, 5, 1000);
s.test_write_string("1\n2\n3\n4\n5\n6").unwrap();
assert_eq!("2\n3\n4\n5\n6", dump(&s, active(0, 0)));
assert_eq!("1\n2\n3\n4\n5\n6", dump(&s, screen_pt(0, 0)));
s.erase_rows(history(0, 0), None);
assert_eq!("2\n3\n4\n5\n6", dump(&s, active(0, 0)));
assert_eq!("2\n3\n4\n5\n6", dump(&s, screen_pt(0, 0)));
}
#[test]
fn screen_erase_rows_history_with_more_lines() {
let mut s = init(5, 5, 1000);
s.test_write_string("A\nB\nC\n1\n2\n3\n4\n5\n6").unwrap();
assert_eq!("2\n3\n4\n5\n6", dump(&s, active(0, 0)));
assert_eq!("A\nB\nC\n1\n2\n3\n4\n5\n6", dump(&s, screen_pt(0, 0)));
s.erase_rows(history(0, 0), None);
assert_eq!("2\n3\n4\n5\n6", dump(&s, active(0, 0)));
assert_eq!("2\n3\n4\n5\n6", dump(&s, screen_pt(0, 0)));
}
#[test]
fn screen_erase_rows_active_partial() {
let mut s = init(5, 5, 0);
s.test_write_string("1\n2\n3").unwrap();
assert_eq!("1\n2\n3", dump(&s, active(0, 0)));
s.erase_rows(active(0, 0), Some(active(0, 1)));
assert_eq!("3", dump(&s, active(0, 0)));
assert_eq!("3", dump(&s, screen_pt(0, 0)));
}
@@ -0,0 +1,1080 @@
use super::*;
use ghostty_vt::{color::Rgb, sgr::Attribute as A};
fn bg155() -> A<'static> {
A::DirectColorBg(Rgb { r: 155, g: 0, b: 0 })
}
fn assert_bg155(s: &Screen, x: u16, y: u32) {
let cell = s.pages.get_cell(active(x, y)).unwrap();
assert!(cell.content_tag() == ContentTag::BgColorRgb);
assert_eq!(Rgb { r: 155, g: 0, b: 0 }, cell.color_rgb());
}
fn bold_style(s: &Screen) -> bool {
let page = s.cursor_page();
page.styles.get(s.cursor.style_id).unwrap().flags.bold
}
/// Scroll down enough to go to another page. Returns the start page.
fn scroll_to_new_page(s: &mut Screen) -> NodeId {
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);
start_page
}
#[test]
fn screen_cursor_down_across_pages_preserves_style() {
let mut s = init(10, 3, 1);
// Scroll down enough to go to another page
let start_page = scroll_to_new_page(&mut s);
// We need our page to change for this test o make sense. If this
// assertion fails then the bug is in the test: we should be scrolling
// above enough for a new page to show up.
assert!(start_page != s.cursor_pin().node);
// Scroll back to the previous page
s.cursor_up(1);
assert!(start_page == s.cursor_pin().node);
// Go back up, set a style
s.set_attribute(A::Bold).unwrap();
assert!(bold_style(&s));
// Go back down into the next page and we should have that style
s.cursor_down(1);
assert!(bold_style(&s));
}
#[test]
fn screen_cursor_up_across_pages_preserves_style() {
let mut s = init(10, 3, 1);
let start_page = scroll_to_new_page(&mut s);
assert!(start_page != s.cursor_pin().node);
// Go back up, set a style
s.set_attribute(A::Bold).unwrap();
assert!(bold_style(&s));
// Go back down into the prev page and we should have that style
s.cursor_up(1);
assert!(start_page == s.cursor_pin().node);
assert!(bold_style(&s));
}
#[test]
fn screen_cursor_absolute_across_pages_preserves_style() {
let mut s = init(10, 3, 1);
let start_page = scroll_to_new_page(&mut s);
assert!(start_page != s.cursor_pin().node);
// Go back up, set a style
s.set_attribute(A::Bold).unwrap();
assert!(bold_style(&s));
// Go back down into the prev page and we should have that style
s.cursor_absolute(1, 1);
assert!(start_page == s.cursor_pin().node);
assert!(bold_style(&s));
}
#[test]
fn screen_cursor_absolute_to_page_with_insufficient_capacity() {
// This test checks for a very specific edge case
// which previously resulted in memory corruption.
//
// The conditions for this edge case are as such:
// - The cursor has an associated style or other managed memory.
// - The cursor moves to a different page.
// - The new page is at capacity and must have its capacity adjusted.
let mut s = init(10, 3, 1);
let start_page = scroll_to_new_page(&mut s);
let new_page = s.cursor_pin().node;
// We need our page to change for this test to make sense. If this
// assertion fails then the bug is in the test: we should be scrolling
// above enough for a new page to show up.
assert!(start_page != new_page);
// Add styles to the start page until it reaches capacity.
{
// Pause integrity checks because they're slow and
// we're not testing this, this is just setup.
s.page_mut(start_page).pause_integrity_checks(true);
let mut n: u32 = 1;
while s
.page_mut(start_page)
.styles
.add(Style {
bg_color: style::Color::Rgb(Rgb {
r: (n & 0xff) as u8,
g: ((n >> 8) & 0xff) as u8,
b: ((n >> 16) & 0xff) as u8,
}),
..Style::default()
})
.is_ok()
{
n += 1;
}
s.page_mut(start_page).pause_integrity_checks(false);
s.page(start_page).assert_integrity();
}
// Set a style on the cursor.
s.set_attribute(A::Bold).unwrap();
{
let styleval = s.page(new_page).styles.get(s.cursor.style_id).unwrap();
assert!(styleval.flags.bold);
}
// Go back up into the start page and we should still have that style.
s.cursor_absolute(1, 1);
{
let cur_page = s.cursor_pin().node;
// The page we're on now should NOT equal start_page, since its
// capacity should have been adjusted, which invalidates our ptr.
assert!(start_page != cur_page);
// To make sure we DID change pages we check we're not on new_page.
assert!(new_page != cur_page);
let styleval = s.page(cur_page).styles.get(s.cursor.style_id).unwrap();
assert!(styleval.flags.bold);
}
s.cursor_page().assert_integrity();
s.page(new_page).assert_integrity();
}
#[test]
fn screen_scrolling() {
let mut s = init(10, 3, 0);
s.set_attribute(bg155()).unwrap();
s.test_write_string("1ABCD\n2EFGH\n3IJKL").unwrap();
// Scroll down, should still be bottom
s.cursor_down_scroll().unwrap();
assert_eq!("2EFGH\n3IJKL", dump(&s, viewport(0, 0)));
assert_bg155(&s, 0, 2);
// Everything is dirty because we have no scrollback
assert!(s.pages.is_dirty(active(0, 0)));
assert!(s.pages.is_dirty(active(0, 1)));
assert!(s.pages.is_dirty(active(0, 2)));
// Scrolling to the bottom does nothing
s.scroll(Scroll::Active);
assert_eq!("2EFGH\n3IJKL", dump(&s, viewport(0, 0)));
}
#[test]
fn screen_scrolling_with_a_single_row_screen_no_scrollback() {
let mut s = init(10, 1, 0);
s.test_write_string("1ABCD").unwrap();
// Scroll down, should still be bottom
s.cursor_down_scroll().unwrap();
assert_eq!("", dump(&s, viewport(0, 0)));
// Screen should be dirty
assert!(s.pages.is_dirty(active(0, 0)));
}
#[test]
fn screen_scrolling_with_a_single_row_screen_with_scrollback() {
let mut s = init(10, 1, 1);
s.test_write_string("1ABCD").unwrap();
// Scroll down, should still be bottom
s.cursor_down_scroll().unwrap();
assert_eq!("", dump(&s, viewport(0, 0)));
// Active should be dirty
assert!(s.pages.is_dirty(active(0, 0)));
// Scrollback also dirty because cursor moved from there
assert!(s.pages.is_dirty(screen_pt(0, 0)));
s.scroll(Scroll::DeltaRow(-1));
assert_eq!("1ABCD", dump(&s, viewport(0, 0)));
}
#[test]
fn screen_scrolling_across_pages_preserves_style() {
let mut s = init(10, 3, 1);
s.set_attribute(A::Bold).unwrap();
s.test_write_string("1ABCD\n2EFGH\n3IJKL").unwrap();
let start_page = s.pages.last.unwrap();
// Scroll down enough to go to another page
let rem = {
let p = s.page(start_page);
p.capacity.rows - p.size.rows + 1
};
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);
// We need our page to change for this test o make sense. If this
// assertion fails then the bug is in the test: we should be scrolling
// above enough for a new page to show up.
let page = s.pages.last.unwrap();
assert!(start_page != page);
let styleval = s.page(page).styles.get(s.cursor.style_id).unwrap();
assert!(styleval.flags.bold);
}
#[test]
fn screen_scroll_down_from_0() {
let mut s = init(10, 3, 0);
s.test_write_string("1ABCD\n2EFGH\n3IJKL").unwrap();
// Scrolling up does nothing, but allows it
s.scroll(Scroll::DeltaRow(-1));
assert!(s.pages.viewport == Viewport::Active);
assert_eq!("1ABCD\n2EFGH\n3IJKL", dump(&s, viewport(0, 0)));
}
#[test]
fn screen_scrollback_various_cases() {
let mut s = init(10, 3, 1);
s.test_write_string("1ABCD\n2EFGH\n3IJKL").unwrap();
s.cursor_down_scroll().unwrap();
assert_eq!("2EFGH\n3IJKL", dump(&s, viewport(0, 0)));
// Scrolling to the bottom
s.scroll(Scroll::Active);
assert_eq!("2EFGH\n3IJKL", dump(&s, viewport(0, 0)));
// Scrolling back should make it visible again
s.scroll(Scroll::DeltaRow(-1));
assert!(s.pages.viewport != Viewport::Active);
assert_eq!("1ABCD\n2EFGH\n3IJKL", dump(&s, viewport(0, 0)));
// Scrolling back again should do nothing
s.scroll(Scroll::DeltaRow(-1));
assert_eq!("1ABCD\n2EFGH\n3IJKL", dump(&s, viewport(0, 0)));
// Scrolling to the bottom
s.scroll(Scroll::Active);
assert_eq!("2EFGH\n3IJKL", dump(&s, viewport(0, 0)));
// Scrolling forward with no grow should do nothing
s.scroll(Scroll::DeltaRow(1));
assert_eq!("2EFGH\n3IJKL", dump(&s, viewport(0, 0)));
// Scrolling to the top should work
s.scroll(Scroll::Top);
assert_eq!("1ABCD\n2EFGH\n3IJKL", dump(&s, viewport(0, 0)));
// Should be able to easily clear active area only
s.clear_rows(active(0, 0), None, false);
assert_eq!("1ABCD", dump(&s, viewport(0, 0)));
// Scrolling to the bottom
s.scroll(Scroll::Active);
assert_eq!("", dump(&s, viewport(0, 0)));
}
#[test]
fn screen_scrollback_with_multi_row_delta() {
let mut s = init(10, 3, 3);
s.test_write_string("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL")
.unwrap();
// Scroll to top
s.scroll(Scroll::Top);
assert_eq!("1ABCD\n2EFGH\n3IJKL", dump(&s, viewport(0, 0)));
// Scroll down multiple
s.scroll(Scroll::DeltaRow(5));
assert!(s.pages.viewport == Viewport::Active);
assert_eq!("4ABCD\n5EFGH\n6IJKL", dump(&s, viewport(0, 0)));
}
#[test]
fn screen_scrollback_empty() {
let mut s = init(10, 3, 50);
s.test_write_string("1ABCD\n2EFGH\n3IJKL").unwrap();
s.scroll(Scroll::DeltaRow(1));
assert_eq!("1ABCD\n2EFGH\n3IJKL", dump(&s, viewport(0, 0)));
}
#[test]
fn screen_scrollback_doesnt_move_viewport_if_not_at_bottom() {
let mut s = init(10, 3, 3);
s.test_write_string("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH")
.unwrap();
// First test: we scroll up by 1, so we're not at the bottom anymore.
s.scroll(Scroll::DeltaRow(-1));
assert_eq!("2EFGH\n3IJKL\n4ABCD", dump(&s, viewport(0, 0)));
// Next, we scroll back down by 1, this grows the scrollback but we
// shouldn't move.
s.cursor_down_scroll().unwrap();
assert_eq!("2EFGH\n3IJKL\n4ABCD", dump(&s, viewport(0, 0)));
// Scroll again, this clears scrollback so we should move viewports
// but still see the same thing since our original view fits.
s.cursor_down_scroll().unwrap();
assert_eq!("2EFGH\n3IJKL\n4ABCD", dump(&s, viewport(0, 0)));
}
#[test]
fn screen_scrolling_moves_selection() {
let mut s = init(5, 3, 1);
s.test_write_string("1ABCD\n2EFGH\n3IJKL").unwrap();
// Select a single line
let sel = Selection::new(
pin(&s, active(0, 1)),
pin(&s, active(s.pages.cols - 1, 1)),
false,
);
s.select(Some(sel)).unwrap();
// Scroll down, should still be bottom
s.cursor_down_scroll().unwrap();
// Our selection should've moved up
{
let sel = s.selection.unwrap();
assert_eq!(
active(0, 0),
pt_from_pin(&s, Tag::Active, sel.start(&s.pages))
);
assert_eq!(
active(s.pages.cols - 1, 0),
pt_from_pin(&s, Tag::Active, sel.end(&s.pages))
);
}
// Test our contents rotated
assert_eq!("2EFGH\n3IJKL", dump(&s, viewport(0, 0)));
// Scrolling to the bottom does nothing
s.scroll(Scroll::Active);
// Our selection should've stayed the same
{
let sel = s.selection.unwrap();
assert_eq!(
active(0, 0),
pt_from_pin(&s, Tag::Active, sel.start(&s.pages))
);
assert_eq!(
active(s.pages.cols - 1, 0),
pt_from_pin(&s, Tag::Active, sel.end(&s.pages))
);
}
// Test our contents rotated
assert_eq!("2EFGH\n3IJKL", dump(&s, viewport(0, 0)));
// Scroll up again
s.cursor_down_scroll().unwrap();
// Test our contents rotated
assert_eq!("3IJKL", dump(&s, viewport(0, 0)));
// Our selection should be null because it left the screen.
{
let sel = s.selection.unwrap();
assert!(
s.pages
.point_from_pin(Tag::Active, sel.start(&s.pages))
.is_none()
);
assert!(
s.pages
.point_from_pin(Tag::Active, sel.end(&s.pages))
.is_none()
);
}
}
#[test]
fn screen_scrolling_moves_viewport() {
let mut s = init(10, 3, 1);
s.test_write_string("1ABCD\n2EFGH\n3IJKL\n").unwrap();
s.test_write_string("1ABCD\n2EFGH\n3IJKL").unwrap();
s.scroll(Scroll::DeltaRow(-2));
// Test our contents rotated
assert_eq!("2EFGH\n3IJKL\n1ABCD", dump(&s, viewport(0, 0)));
let tl = s.pages.get_top_left(Tag::Viewport).unwrap();
assert_eq!(screen_pt(0, 1), pt_from_pin(&s, Tag::Screen, tl));
}
#[test]
fn screen_scrolling_when_viewport_is_pruned() {
let mut s = init(215, 3, 1);
// Write some to create scrollback and move back into our scrollback.
s.test_write_string("1ABCD\n2EFGH\n3IJKL\n").unwrap();
s.test_write_string("1ABCD\n2EFGH\n3IJKL").unwrap();
s.scroll(Scroll::DeltaRow(-2));
// Our viewport is now somewhere pinned. Create so much scrollback
// that we prune it.
s.test_write_string("\n").unwrap();
for _ in 0..1000 {
s.test_write_string("1ABCD\n2EFGH\n3IJKL\n").unwrap();
}
s.test_write_string("1ABCD\n2EFGH\n3IJKL").unwrap();
let tl = s.pages.get_top_left(Tag::Viewport).unwrap();
assert_eq!(screen_pt(0, 0), pt_from_pin(&s, Tag::Screen, tl));
}
#[test]
fn screen_scroll_and_clear_full_screen() {
let mut s = init(10, 3, 5);
s.test_write_string("1ABCD\n2EFGH\n3IJKL").unwrap();
assert_eq!("1ABCD\n2EFGH\n3IJKL", dump(&s, viewport(0, 0)));
s.scroll_clear().unwrap();
assert_eq!("", dump(&s, viewport(0, 0)));
assert_eq!("1ABCD\n2EFGH\n3IJKL", dump(&s, screen_pt(0, 0)));
}
#[test]
fn screen_scroll_and_clear_partial_screen() {
let mut s = init(10, 3, 5);
s.test_write_string("1ABCD\n2EFGH").unwrap();
assert_eq!("1ABCD\n2EFGH", dump(&s, viewport(0, 0)));
s.scroll_clear().unwrap();
assert_eq!("", dump(&s, viewport(0, 0)));
assert_eq!("1ABCD\n2EFGH", dump(&s, screen_pt(0, 0)));
}
#[test]
fn screen_scroll_and_clear_empty_screen() {
let mut s = init(10, 3, 5);
s.scroll_clear().unwrap();
assert_eq!("", dump(&s, viewport(0, 0)));
assert_eq!("", dump(&s, screen_pt(0, 0)));
}
#[test]
fn screen_scroll_and_clear_ignore_blank_lines() {
let mut s = init(10, 3, 10);
s.test_write_string("1ABCD\n2EFGH").unwrap();
s.scroll_clear().unwrap();
assert_eq!("", dump(&s, viewport(0, 0)));
// Move back to top-left
s.cursor_absolute(0, 0);
// Write and clear
s.test_write_string("3ABCD\n").unwrap();
assert_eq!("3ABCD", dump(&s, active(0, 0)));
s.scroll_clear().unwrap();
assert_eq!("", dump(&s, viewport(0, 0)));
// Move back to top-left
s.cursor_absolute(0, 0);
s.test_write_string("X").unwrap();
assert_eq!("1ABCD\n2EFGH\n3ABCD\nX", dump(&s, screen_pt(0, 0)));
}
#[test]
fn screen_scroll_above_same_page() {
let mut s = init(10, 3, 10);
s.set_attribute(bg155()).unwrap();
s.test_write_string("1ABCD\n2EFGH\n3IJKL").unwrap();
s.cursor_absolute(0, 1);
s.pages.clear_dirty();
// At this point:
// +-------------+ ACTIVE
// +----------+ : = PAGE 0
// 0 |1ABCD00000| | 0
// 1 |2EFGH00000| | 1
// :^ : : = PIN 0
// 2 |3IJKL00000| | 2
// +----------+ :
// +-------------+
s.cursor_scroll_above().unwrap();
// +----------+ = PAGE 0
// 0 |1ABCD00000|
// +-------------+ ACTIVE
// 1 |2EFGH00000| | 0
// 2 | | | 1
// :^ : : = PIN 0
// 3 |3IJKL00000| | 2
// +----------+ :
// +-------------+
assert_eq!("2EFGH\n\n3IJKL", dump(&s, viewport(0, 0)));
assert_bg155(&s, 0, 1);
// Page 0 row 1 (active row 0) is dirty because the cursor moved off of it.
assert!(s.pages.is_dirty(active(0, 0)));
// Page 0 row 2 (active row 1) is dirty because it was cleared.
assert!(s.pages.is_dirty(active(0, 1)));
// Page 0 row 3 (active row 2) is dirty because it's new.
assert!(s.pages.is_dirty(active(0, 2)));
}
#[test]
fn screen_scroll_above_same_page_but_cursor_on_previous_page() {
let mut s = init(10, 5, 10);
// We need to get the cursor to a new page
let first = s.pages.first.unwrap();
let first_page_size = s.page(first).capacity.rows;
s.page_mut(first).pause_integrity_checks(true);
for _ in 0..first_page_size - 3 {
s.test_write_string("\n").unwrap();
}
s.page_mut(first).pause_integrity_checks(false);
s.set_attribute(bg155()).unwrap();
s.test_write_string("1A\n2B\n3C\n4D\n5E").unwrap();
s.cursor_absolute(0, 1);
s.pages.clear_dirty();
// Ensure we're still on the first page and have a second
assert!(s.cursor_pin().node == s.pages.first.unwrap());
assert!(s.pages.node(s.pages.first.unwrap()).unwrap().next.is_some());
// At this point:
// +----------+ = PAGE 0
// ... : :
// +-------------+ ACTIVE
// 4305 |1A00000000| | 0
// 4306 |2B00000000| | 1
// :^ : : = PIN 0
// 4307 |3C00000000| | 2
// +----------+ :
// +----------+ : = PAGE 1
// 0 |4D00000000| | 3
// 1 |5E00000000| | 4
// +----------+ :
// +-------------+
s.cursor_scroll_above().unwrap();
// +----------+ = PAGE 0
// ... : :
// 4305 |1A00000000|
// +-------------+ ACTIVE
// 4306 |2B00000000| | 0
// 4307 | | | 1
// :^ : : = PIN 0
// +----------+ :
// +----------+ : = PAGE 1
// 0 |3C00000000| | 2
// 1 |4D00000000| | 3
// 2 |5E00000000| | 4
// +----------+ :
// +-------------+
assert_eq!("2B\n\n3C\n4D\n5E", dump(&s, viewport(0, 0)));
assert_bg155(&s, 0, 1);
// Page 0's penultimate row is dirty because the cursor moved off of it.
assert!(s.pages.is_dirty(active(0, 0)));
// The rest of the rows are dirty because they've been modified or are new.
assert!(s.pages.is_dirty(active(0, 1)));
assert!(s.pages.is_dirty(active(0, 2)));
assert!(s.pages.is_dirty(active(0, 3)));
assert!(s.pages.is_dirty(active(0, 4)));
}
#[test]
fn screen_scroll_above_same_page_but_cursor_on_previous_page_last_row() {
let mut s = init(10, 5, 10);
// We need to get the cursor to a new page
let first = s.pages.first.unwrap();
let first_page_size = s.page(first).capacity.rows;
s.page_mut(first).pause_integrity_checks(true);
for _ in 0..first_page_size - 2 {
s.test_write_string("\n").unwrap();
}
s.page_mut(first).pause_integrity_checks(false);
s.set_attribute(bg155()).unwrap();
s.test_write_string("1A\n2B\n3C\n4D\n5E").unwrap();
s.cursor_absolute(0, 1);
s.pages.clear_dirty();
// Ensure we're still on the first page and have a second
assert!(s.cursor_pin().node == s.pages.first.unwrap());
assert!(s.pages.node(s.pages.first.unwrap()).unwrap().next.is_some());
// At this point:
// +----------+ = PAGE 0
// ... : :
// +-------------+ ACTIVE
// 4306 |1A00000000| | 0
// 4307 |2B00000000| | 1
// :^ : : = PIN 0
// +----------+ :
// +----------+ : = PAGE 1
// 0 |3C00000000| | 2
// 1 |4D00000000| | 3
// 2 |5E00000000| | 4
// +----------+ :
// +-------------+
s.cursor_scroll_above().unwrap();
// +----------+ = PAGE 0
// ... : :
// 4306 |1A00000000|
// +-------------+ ACTIVE
// 4307 |2B00000000| | 0
// +----------+ :
// +----------+ : = PAGE 1
// 0 | | | 1
// :^ : : = PIN 0
// 1 |3C00000000| | 2
// 2 |4D00000000| | 3
// 3 |5E00000000| | 4
// +----------+ :
// +-------------+
assert_eq!("2B\n\n3C\n4D\n5E", dump(&s, viewport(0, 0)));
assert_bg155(&s, 0, 1);
// Page 0's final row is dirty because the cursor moved off of it.
assert!(s.pages.is_dirty(active(0, 0)));
// Page 1's rows are all dirty because every row was moved.
assert!(s.pages.is_dirty(active(0, 1)));
assert!(s.pages.is_dirty(active(0, 2)));
assert!(s.pages.is_dirty(active(0, 3)));
assert!(s.pages.is_dirty(active(0, 4)));
// Attempt to clear the style from the cursor and
// then assert the integrity of both of our pages.
//
// This catches a case of memory corruption where the cursor
// is moved between pages without accounting for style refs.
s.set_attribute(A::ResetBg).unwrap();
s.page(s.pages.first.unwrap()).assert_integrity();
s.page(s.pages.last.unwrap()).assert_integrity();
}
#[test]
fn screen_scroll_above_creates_new_page() {
let mut s = init(10, 3, 10);
// We need to get the cursor to a new page
let first = s.pages.first.unwrap();
let first_page_size = s.page(first).capacity.rows;
s.page_mut(first).pause_integrity_checks(true);
for _ in 0..first_page_size - 3 {
s.test_write_string("\n").unwrap();
}
s.page_mut(first).pause_integrity_checks(false);
s.set_attribute(bg155()).unwrap();
s.test_write_string("1ABCD\n2EFGH\n3IJKL").unwrap();
s.cursor_absolute(0, 1);
s.pages.clear_dirty();
// Ensure we're still on the first page
assert!(s.cursor_pin().node == s.pages.first.unwrap());
// At this point:
// +----------+ = PAGE 0
// ... : :
// +-------------+ ACTIVE
// 4305 |1ABCD00000| | 0
// 4306 |2EFGH00000| | 1
// :^ : : = PIN 0
// 4307 |3IJKL00000| | 2
// +----------+ :
// +-------------+
s.cursor_scroll_above().unwrap();
// +----------+ = PAGE 0
// ... : :
// 4305 |1ABCD00000|
// +-------------+ ACTIVE
// 4306 |2EFGH00000| | 0
// 4307 | | | 1
// :^ : : = PIN 0
// +----------+ :
// +----------+ : = PAGE 1
// 0 |3IJKL00000| | 2
// +----------+ :
// +-------------+
assert_eq!("2EFGH\n\n3IJKL", dump(&s, viewport(0, 0)));
assert_bg155(&s, 0, 1);
// Page 0's penultimate row is dirty because the cursor moved off of it.
assert!(s.pages.is_dirty(active(0, 0)));
// Page 0's final row is dirty because it was cleared.
assert!(s.pages.is_dirty(active(0, 1)));
// Page 1's row is dirty because it's new.
assert!(s.pages.is_dirty(active(0, 2)));
}
#[test]
fn screen_scroll_above_with_cursor_on_non_final_row() {
let mut s = init(10, 4, 10);
// Get the cursor to be 2 rows above a new page
let first = s.pages.first.unwrap();
let first_page_size = s.page(first).capacity.rows;
s.page_mut(first).pause_integrity_checks(true);
for _ in 0..first_page_size - 3 {
s.test_write_string("\n").unwrap();
}
s.page_mut(first).pause_integrity_checks(false);
// Write 3 lines of text, forcing the last line into the first
// row of a new page. Move our cursor onto the previous page.
s.set_attribute(bg155()).unwrap();
s.test_write_string("1AB\n2BC\n3DE\n4FG").unwrap();
s.cursor_absolute(0, 1);
s.pages.clear_dirty();
// Ensure we're still on the first page. So our cursor is on the first
// page but we have two pages of data.
assert!(s.cursor_pin().node == s.pages.first.unwrap());
// +----------+ = PAGE 0
// ... : :
// +-------------+ ACTIVE
// 4305 |1AB0000000| | 0
// 4306 |2BC0000000| | 1
// :^ : : = PIN 0
// 4307 |3DE0000000| | 2
// +----------+ :
// +----------+ : = PAGE 1
// 0 |4FG0000000| | 3
// +----------+ :
// +-------------+
s.cursor_scroll_above().unwrap();
// +----------+ = PAGE 0
// ... : :
// 4305 |1AB0000000|
// +-------------+ ACTIVE
// 4306 |2BC0000000| | 0
// 4307 | | | 1
// :^ : : = PIN 0
// +----------+ :
// +----------+ : = PAGE 1
// 0 |3DE0000000| | 2
// 1 |4FG0000000| | 3
// +----------+ :
// +-------------+
assert_eq!("2BC\n\n3DE\n4FG", dump(&s, viewport(0, 0)));
assert_bg155(&s, 0, 1);
// Page 0's penultimate row is dirty because the cursor moved off of it.
assert!(s.pages.is_dirty(active(0, 0)));
// Page 0's final row is dirty because it was cleared.
assert!(s.pages.is_dirty(active(0, 1)));
// Page 1's row is dirty because it's new.
assert!(s.pages.is_dirty(active(0, 2)));
}
#[test]
fn screen_scroll_above_no_scrollback_bottom_of_page() {
let mut s = init(10, 3, 0);
let first = s.pages.first.unwrap();
let first_page_size = s.page(first).capacity.rows;
s.page_mut(first).pause_integrity_checks(true);
for _ in 0..first_page_size - 3 {
s.test_write_string("\n").unwrap();
}
s.page_mut(first).pause_integrity_checks(false);
s.set_attribute(bg155()).unwrap();
s.test_write_string("1ABCD\n2EFGH\n3IJKL").unwrap();
s.cursor_absolute(0, 1);
s.pages.clear_dirty();
// At this point:
// +-------------+ ACTIVE
// +----------+ : = PAGE 0
// 0 |1ABCD00000| | 0
// 1 |2EFGH00000| | 1
// :^ : : = PIN 0
// 2 |3IJKL00000| | 2
// +----------+ :
// +-------------+
s.cursor_scroll_above().unwrap();
// +----------+ = PAGE 0
// 0 |1ABCD00000|
// +-------------+ ACTIVE
// 1 |2EFGH00000| | 0
// 2 | | | 1
// :^ : : = PIN 0
// 3 |3IJKL00000| | 2
// +----------+ :
// +-------------+
assert_eq!("2EFGH\n\n3IJKL", dump(&s, viewport(0, 0)));
assert_bg155(&s, 0, 1);
// Page 0 row 1 (active row 0) is dirty because the cursor moved off of it.
assert!(s.pages.is_dirty(active(0, 0)));
// Page 0 row 2 (active row 1) is dirty because it was cleared.
assert!(s.pages.is_dirty(active(0, 1)));
// Page 0 row 3 (active row 2) is dirty because it is new.
assert!(s.pages.is_dirty(active(0, 2)));
}
#[test]
fn screen_clone() {
let mut s = init(10, 3, 10);
s.test_write_string("1ABCD\n2EFGH").unwrap();
assert_eq!("1ABCD\n2EFGH", dump(&s, active(0, 0)));
assert_eq!(5, s.cursor.x);
assert_eq!(1, s.cursor.y);
// Clone
let s2 = s.clone_screen(active(0, 0), None).unwrap();
assert_eq!("1ABCD\n2EFGH", dump(&s2, active(0, 0)));
assert_eq!(5, s2.cursor.x);
assert_eq!(1, s2.cursor.y);
// Write to s1, should not be in s2
s.test_write_string("\n34567").unwrap();
assert_eq!("1ABCD\n2EFGH\n34567", dump(&s, active(0, 0)));
assert_eq!("1ABCD\n2EFGH", dump(&s2, active(0, 0)));
assert_eq!(5, s2.cursor.x);
assert_eq!(1, s2.cursor.y);
}
#[test]
fn screen_clone_partial() {
let mut s = init(10, 3, 10);
s.test_write_string("1ABCD\n2EFGH").unwrap();
assert_eq!("1ABCD\n2EFGH", dump(&s, active(0, 0)));
assert_eq!(5, s.cursor.x);
assert_eq!(1, s.cursor.y);
// Clone
let s2 = s.clone_screen(active(0, 1), None).unwrap();
assert_eq!("2EFGH", dump(&s2, active(0, 0)));
// Cursor is shifted since we cloned partial
assert_eq!(5, s2.cursor.x);
assert_eq!(0, s2.cursor.y);
}
#[test]
fn screen_clone_partial_cursor_out_of_bounds() {
let mut s = init(10, 3, 10);
s.test_write_string("1ABCD\n2EFGH").unwrap();
assert_eq!("1ABCD\n2EFGH", dump(&s, active(0, 0)));
assert_eq!(5, s.cursor.x);
assert_eq!(1, s.cursor.y);
// Clone
let s2 = s.clone_screen(active(0, 0), Some(active(0, 0))).unwrap();
assert_eq!("1ABCD", dump(&s2, active(0, 0)));
// Cursor is shifted since we cloned partial
assert_eq!(0, s2.cursor.x);
assert_eq!(0, s2.cursor.y);
}
/// Common setup: 5x3 with scrollback 1 containing three lines.
fn sel_screen() -> Screen {
let mut s = init(5, 3, 1);
s.test_write_string("1ABCD\n2EFGH\n3IJKL").unwrap();
s
}
fn sel_points(s: &Screen) -> (Point, Point) {
let sel = s.selection.unwrap();
(
pt_from_pin(s, Tag::Active, sel.start(&s.pages)),
pt_from_pin(s, Tag::Active, sel.end(&s.pages)),
)
}
#[test]
fn screen_clone_contains_full_selection() {
let mut s = sel_screen();
// Select a single line
let sel = Selection::new(
pin(&s, active(0, 1)),
pin(&s, active(s.pages.cols - 1, 1)),
false,
);
s.select(Some(sel)).unwrap();
// Clone
let s2 = s.clone_screen(active(0, 0), None).unwrap();
// Our selection should remain valid
let (a, b) = sel_points(&s2);
assert_eq!(active(0, 1), a);
assert_eq!(active(s2.pages.cols - 1, 1), b);
}
#[test]
fn screen_clone_contains_none_of_selection() {
let mut s = sel_screen();
// Select a single line
let sel = Selection::new(
pin(&s, active(0, 0)),
pin(&s, active(s.pages.cols - 1, 0)),
false,
);
s.select(Some(sel)).unwrap();
// Clone
let s2 = s.clone_screen(active(0, 1), None).unwrap();
// Our selection should be null
assert!(s2.selection.is_none());
}
#[test]
fn screen_clone_contains_selection_start_cutoff() {
let mut s = sel_screen();
// Select a single line
let sel = Selection::new(
pin(&s, active(0, 0)),
pin(&s, active(s.pages.cols - 1, 1)),
false,
);
s.select(Some(sel)).unwrap();
// Clone
let s2 = s.clone_screen(active(0, 1), None).unwrap();
// Our selection should remain valid
let (a, b) = sel_points(&s2);
assert_eq!(active(0, 0), a);
assert_eq!(active(s2.pages.cols - 1, 0), b);
}
#[test]
fn screen_clone_contains_selection_end_cutoff() {
let mut s = sel_screen();
// Select a single line
let sel = Selection::new(pin(&s, active(0, 1)), pin(&s, active(2, 2)), false);
s.select(Some(sel)).unwrap();
// Clone
let s2 = s.clone_screen(active(0, 0), Some(active(0, 1))).unwrap();
// Our selection should remain valid
let (a, b) = sel_points(&s2);
assert_eq!(active(0, 1), a);
assert_eq!(active(s2.pages.cols - 1, 2), b);
}
#[test]
fn screen_clone_contains_selection_end_cutoff_reversed() {
let mut s = sel_screen();
// Select a single line
let sel = Selection::new(pin(&s, active(2, 2)), pin(&s, active(0, 1)), false);
s.select(Some(sel)).unwrap();
// Clone
let s2 = s.clone_screen(active(0, 0), Some(active(0, 1))).unwrap();
// Our selection should remain valid
let (a, b) = sel_points(&s2);
assert_eq!(active(0, 1), a);
assert_eq!(active(s2.pages.cols - 1, 2), b);
}
#[test]
fn screen_clone_contains_subset_of_selection() {
let mut s = init(5, 4, 1);
s.test_write_string("1ABCD\n2EFGH\n3IJKL\n4ABCD").unwrap();
// Select the full screen
let sel = Selection::new(pin(&s, active(0, 0)), pin(&s, active(0, 3)), false);
s.select(Some(sel)).unwrap();
// Clone
let s2 = s.clone_screen(active(0, 1), Some(active(0, 2))).unwrap();
// Our selection should remain valid
let (a, b) = sel_points(&s2);
assert_eq!(active(0, 0), a);
assert_eq!(active(s2.pages.cols - 1, 3), b);
}
#[test]
fn screen_clone_contains_subset_of_rectangle_selection() {
let mut s = init(5, 4, 1);
s.test_write_string("1ABCD\n2EFGH\n3IJKL\n4ABCD").unwrap();
// Select the full screen from x=1 to x=3
let sel = Selection::new(pin(&s, active(1, 0)), pin(&s, active(3, 3)), true);
s.select(Some(sel)).unwrap();
// Clone
let s2 = s.clone_screen(active(0, 1), Some(active(0, 2))).unwrap();
// Our selection should remain valid and be properly clipped
// preserving the columns of the start and end points of the
// selection.
let (a, b) = sel_points(&s2);
assert_eq!(active(1, 0), a);
assert_eq!(active(3, 3), b);
}
#[test]
fn screen_clone_basic() {
let mut s = init(10, 3, 0);
s.test_write_string("1ABCD\n2EFGH\n3IJKL").unwrap();
{
let s2 = s.clone_screen(active(0, 1), Some(active(0, 1))).unwrap();
// Test our contents rotated
assert_eq!("2EFGH", dump(&s2, active(0, 0)));
}
{
let s2 = s.clone_screen(active(0, 1), Some(active(0, 2))).unwrap();
// Test our contents rotated
assert_eq!("2EFGH\n3IJKL", dump(&s2, active(0, 0)));
}
}
@@ -0,0 +1,1236 @@
use super::*;
use crate::page::Wide;
use crate::screen::{PromptKind, SemanticContentSet};
fn cell_at(s: &Screen, pt: Point) -> Cell {
s.pages.get_cell(pt).unwrap()
}
fn row_at(s: &Screen, pt: Point) -> Row {
*pin(s, pt).row_and_cell(&s.pages).unwrap().0
}
fn resize(s: &mut Screen, cols: u16, rows: u16) {
s.resize(Resize::new(cols, rows)).unwrap();
}
fn resize_no_reflow(s: &mut Screen, cols: u16, rows: u16) {
s.resize(Resize {
reflow: false,
..Resize::new(cols, rows)
})
.unwrap();
}
fn cursor_cell_cp(s: &Screen) -> u32 {
cell_at(s, active(s.cursor.x, u32::from(s.cursor.y))).codepoint()
}
#[test]
fn screen_clone_empty_viewport() {
let s = init(10, 3, 0);
{
let s2 = s
.clone_screen(viewport(0, 0), Some(viewport(0, 0)))
.unwrap();
// Test our contents rotated
assert_eq!("", dump(&s2, viewport(0, 0)));
}
}
#[test]
fn screen_clone_one_line_viewport() {
let mut s = init(10, 3, 0);
s.test_write_string("1ABC").unwrap();
{
let s2 = s
.clone_screen(viewport(0, 0), Some(viewport(0, 0)))
.unwrap();
// Test our contents
assert_eq!("1ABC", dump(&s2, viewport(0, 0)));
}
}
#[test]
fn screen_clone_empty_active() {
let s = init(10, 3, 0);
{
let s2 = s.clone_screen(active(0, 0), Some(active(0, 0))).unwrap();
// Test our contents rotated
assert_eq!("", dump(&s2, active(0, 0)));
}
}
#[test]
fn screen_clone_one_line_active_with_extra_space() {
let mut s = init(10, 3, 0);
s.test_write_string("1ABC").unwrap();
{
let s2 = s.clone_screen(active(0, 0), None).unwrap();
// Test our contents rotated
assert_eq!("1ABC", dump(&s2, active(0, 0)));
}
}
#[test]
fn screen_clear_history_with_no_history() {
let mut s = init(10, 3, 3);
s.test_write_string("4ABCD\n5EFGH\n6IJKL").unwrap();
assert!(s.pages.viewport == Viewport::Active);
s.erase_rows(history(0, 0), None);
assert!(s.pages.viewport == Viewport::Active);
{
// Test our contents rotated
assert_eq!("4ABCD\n5EFGH\n6IJKL", dump(&s, viewport(0, 0)));
}
{
// Test our contents rotated
assert_eq!("4ABCD\n5EFGH\n6IJKL", dump(&s, screen_pt(0, 0)));
}
}
#[test]
fn screen_clear_history() {
let mut s = init(10, 3, 3);
s.test_write_string("1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH\n6IJKL")
.unwrap();
assert!(s.pages.viewport == Viewport::Active);
// Scroll to top
s.scroll(Scroll::Top);
{
// Test our contents rotated
assert_eq!("1ABCD\n2EFGH\n3IJKL", dump(&s, viewport(0, 0)));
}
s.erase_rows(history(0, 0), None);
assert!(s.pages.viewport == Viewport::Active);
{
// Test our contents rotated
assert_eq!("4ABCD\n5EFGH\n6IJKL", dump(&s, viewport(0, 0)));
}
{
// Test our contents rotated
assert_eq!("4ABCD\n5EFGH\n6IJKL", dump(&s, screen_pt(0, 0)));
}
}
#[test]
fn screen_clear_above_cursor() {
let mut s = init(10, 10, 3);
s.test_write_string("4ABCD\n5EFGH\n6IJKL").unwrap();
s.clear_rows(
active(0, 0),
Some(active(0, u32::from(s.cursor.y) - 1)),
false,
);
{
assert_eq!("\n\n6IJKL", dump(&s, viewport(0, 0)));
}
{
assert_eq!("\n\n6IJKL", dump(&s, screen_pt(0, 0)));
}
assert_eq!(5, s.cursor.x);
assert_eq!(2, s.cursor.y);
}
#[test]
fn screen_clear_above_cursor_with_history() {
let mut s = init(10, 3, 3);
s.test_write_string("1ABCD\n2EFGH\n3IJKL\n").unwrap();
s.test_write_string("4ABCD\n5EFGH\n6IJKL").unwrap();
s.clear_rows(
active(0, 0),
Some(active(0, u32::from(s.cursor.y) - 1)),
false,
);
{
assert_eq!("\n\n6IJKL", dump(&s, viewport(0, 0)));
}
{
assert_eq!("1ABCD\n2EFGH\n3IJKL\n\n\n6IJKL", dump(&s, screen_pt(0, 0)));
}
assert_eq!(5, s.cursor.x);
assert_eq!(2, s.cursor.y);
}
#[test]
fn screen_resize_no_reflow_more_rows() {
let mut s = init(10, 3, 0);
let str = "1ABCD\n2EFGH\n3IJKL";
s.test_write_string(str).unwrap();
// Resize
resize_no_reflow(&mut s, 10, 10);
{
assert_eq!(str, dump(&s, viewport(0, 0)));
}
}
#[test]
fn screen_resize_no_reflow_less_rows() {
let mut s = init(10, 3, 0);
let str = "1ABCD\n2EFGH\n3IJKL";
s.test_write_string(str).unwrap();
assert_eq!(5, s.cursor.x);
assert_eq!(2, s.cursor.y);
resize_no_reflow(&mut s, 10, 2);
// Since we shrunk, we should adjust our cursor
assert_eq!(5, s.cursor.x);
assert_eq!(1, s.cursor.y);
{
assert_eq!("2EFGH\n3IJKL", dump(&s, viewport(0, 0)));
}
}
fn bg_fill_rows(s: &mut Screen) {
// Write only a background color into the remaining rows
for y in 1..s.pages.rows {
let c = s.pages.get_cell_mut(active(0, u32::from(y))).unwrap();
let mut cell = Cell::default();
cell.set_content_tag(ContentTag::BgColorRgb);
cell.set_color_rgb(ghostty_vt::color::Rgb {
r: 0xFF,
g: 0,
b: 0,
});
*c = cell;
}
}
#[test]
#[ignore = "pagelist: PageList::resize (no reflow, fewer rows) does not trim trailing rows that only contain background-color cells; the content row is pushed to history instead"]
fn screen_resize_no_reflow_less_rows_trims_blank_lines() {
let mut s = init(10, 3, 0);
let str = "1ABCD";
s.test_write_string(str).unwrap();
bg_fill_rows(&mut s);
let (cx, cy) = (s.cursor.x, s.cursor.y);
resize_no_reflow(&mut s, 6, 2);
// Cursor should not move
assert_eq!(cx, s.cursor.x);
assert_eq!(cy, s.cursor.y);
{
assert_eq!("1ABCD", dump(&s, viewport(0, 0)));
}
}
#[test]
fn screen_resize_no_reflow_more_rows_trims_blank_lines() {
let mut s = init(10, 3, 0);
let str = "1ABCD";
s.test_write_string(str).unwrap();
bg_fill_rows(&mut s);
let (cx, cy) = (s.cursor.x, s.cursor.y);
resize_no_reflow(&mut s, 10, 7);
// Cursor should not move
assert_eq!(cx, s.cursor.x);
assert_eq!(cy, s.cursor.y);
{
assert_eq!("1ABCD", dump(&s, viewport(0, 0)));
}
}
#[test]
fn screen_resize_no_reflow_more_cols() {
let mut s = init(10, 3, 0);
let str = "1ABCD\n2EFGH\n3IJKL";
s.test_write_string(str).unwrap();
resize_no_reflow(&mut s, 20, 3);
{
assert_eq!(str, dump(&s, viewport(0, 0)));
}
}
#[test]
fn screen_resize_no_reflow_less_cols() {
let mut s = init(10, 3, 0);
let str = "1ABCD\n2EFGH\n3IJKL";
s.test_write_string(str).unwrap();
resize_no_reflow(&mut s, 4, 3);
{
let expected = "1ABC\n2EFG\n3IJK";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
}
#[test]
fn screen_resize_no_reflow_more_rows_with_scrollback_cursor_end() {
let mut s = init(7, 3, 2);
let str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH";
s.test_write_string(str).unwrap();
resize_no_reflow(&mut s, 7, 10);
{
assert_eq!(str, dump(&s, viewport(0, 0)));
}
}
#[test]
fn screen_resize_no_reflow_less_rows_with_scrollback() {
let mut s = init(7, 3, 2);
let str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH";
s.test_write_string(str).unwrap();
resize_no_reflow(&mut s, 7, 2);
{
let expected = "4ABCD\n5EFGH";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
}
// https://github.com/mitchellh/ghostty/issues/1030
#[test]
fn screen_resize_no_reflow_less_rows_with_empty_trailing() {
let mut s = init(5, 3, 5);
let str = "1\n2\n3\n4\n5\n6\n7\n8";
s.test_write_string(str).unwrap();
s.scroll_clear().unwrap();
s.cursor_absolute(0, 0);
s.test_write_string("A\nB").unwrap();
let (cx, cy) = (s.cursor.x, s.cursor.y);
resize_no_reflow(&mut s, 5, 2);
assert_eq!(cx, s.cursor.x);
assert_eq!(cy, s.cursor.y);
{
assert_eq!("A\nB", dump(&s, viewport(0, 0)));
}
}
#[test]
fn screen_resize_no_reflow_more_rows_with_soft_wrapping() {
let mut s = init(2, 3, 3);
let str = "1A2B\n3C4E\n5F6G";
s.test_write_string(str).unwrap();
// Every second row should be wrapped
for y in 0..6u32 {
let row = row_at(&s, screen_pt(0, y));
let wrapped = y % 2 == 0;
assert_eq!(wrapped, row.wrap());
}
// Resize
resize_no_reflow(&mut s, 2, 10);
{
let expected = "1A\n2B\n3C\n4E\n5F\n6G";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
// Every second row should be wrapped
for y in 0..6u32 {
let row = row_at(&s, screen_pt(0, y));
let wrapped = y % 2 == 0;
assert_eq!(wrapped, row.wrap());
}
}
#[test]
fn screen_resize_more_rows_no_scrollback() {
let mut s = init(5, 3, 0);
let str = "1ABCD\n2EFGH\n3IJKL";
s.test_write_string(str).unwrap();
let (cx, cy) = (s.cursor.x, s.cursor.y);
resize(&mut s, 5, 10);
// Cursor should not move
assert_eq!(cx, s.cursor.x);
assert_eq!(cy, s.cursor.y);
{
assert_eq!(str, dump(&s, viewport(0, 0)));
}
{
assert_eq!(str, dump(&s, screen_pt(0, 0)));
}
}
#[test]
fn screen_resize_more_rows_with_empty_scrollback() {
let mut s = init(5, 3, 10);
let str = "1ABCD\n2EFGH\n3IJKL";
s.test_write_string(str).unwrap();
let (cx, cy) = (s.cursor.x, s.cursor.y);
resize(&mut s, 5, 10);
// Cursor should not move
assert_eq!(cx, s.cursor.x);
assert_eq!(cy, s.cursor.y);
{
assert_eq!(str, dump(&s, viewport(0, 0)));
}
{
assert_eq!(str, dump(&s, screen_pt(0, 0)));
}
}
#[test]
fn screen_resize_more_rows_with_populated_scrollback() {
let mut s = init(5, 3, 5);
let str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH";
s.test_write_string(str).unwrap();
{
let expected = "3IJKL\n4ABCD\n5EFGH";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
// Set our cursor to be on the "4"
s.cursor_absolute(0, 1);
{
assert_eq!(u32::from('4'), cursor_cell_cp(&s));
}
// Resize
resize(&mut s, 5, 10);
// Cursor should still be on the "4"
{
assert_eq!(u32::from('4'), cursor_cell_cp(&s));
}
{
let expected = "3IJKL\n4ABCD\n5EFGH";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
}
#[test]
fn screen_resize_more_cols_no_reflow() {
let mut s = init(5, 3, 0);
let str = "1ABCD\n2EFGH\n3IJKL";
s.test_write_string(str).unwrap();
let (cx, cy) = (s.cursor.x, s.cursor.y);
resize(&mut s, 10, 3);
// Cursor should not move
assert_eq!(cx, s.cursor.x);
assert_eq!(cy, s.cursor.y);
{
assert_eq!(str, dump(&s, viewport(0, 0)));
}
{
assert_eq!(str, dump(&s, screen_pt(0, 0)));
}
}
// https://github.com/mitchellh/ghostty/issues/272#issuecomment-1676038963
#[test]
fn screen_resize_more_cols_perfect_split() {
let mut s = init(5, 3, 0);
let str = "1ABCD2EFGH3IJKL";
s.test_write_string(str).unwrap();
resize(&mut s, 10, 3);
{
assert_eq!("1ABCD2EFGH\n3IJKL", dump(&s, screen_pt(0, 0)));
}
}
// https://github.com/mitchellh/ghostty/issues/1159
#[test]
fn screen_resize_no_reflow_more_cols_with_scrollback_scrolled_up() {
let mut s = init(5, 3, 5);
let str = "1\n2\n3\n4\n5\n6\n7\n8";
s.test_write_string(str).unwrap();
// Cursor at bottom
assert_eq!(1, s.cursor.x);
assert_eq!(2, s.cursor.y);
s.scroll(Scroll::DeltaRow(-4));
{
assert_eq!("2\n3\n4", dump(&s, viewport(0, 0)));
}
resize(&mut s, 8, 3);
{
assert_eq!(str, dump(&s, screen_pt(0, 0)));
}
// Cursor remains at bottom
assert_eq!(1, s.cursor.x);
assert_eq!(2, s.cursor.y);
}
// https://github.com/mitchellh/ghostty/issues/1159
#[test]
fn screen_resize_no_reflow_less_cols_with_scrollback_scrolled_up() {
let mut s = init(5, 3, 5);
let str = "1\n2\n3\n4\n5\n6\n7\n8";
s.test_write_string(str).unwrap();
// Cursor at bottom
assert_eq!(1, s.cursor.x);
assert_eq!(2, s.cursor.y);
s.scroll(Scroll::DeltaRow(-4));
{
assert_eq!("2\n3\n4", dump(&s, viewport(0, 0)));
}
resize(&mut s, 4, 3);
{
assert_eq!(str, dump(&s, screen_pt(0, 0)));
}
{
assert_eq!("6\n7\n8", dump(&s, active(0, 0)));
}
// Cursor remains at bottom
assert_eq!(1, s.cursor.x);
assert_eq!(2, s.cursor.y);
// Old implementation doesn't do this but it makes sense to me:
// {
// assert_eq!("2\n3\n4", dump(&s, viewport(0, 0)));
// }
}
#[test]
fn screen_resize_more_cols_no_reflow_preserves_semantic_prompt() {
let mut s = init(5, 3, 0);
// Set one of the rows to be a prompt
s.cursor_set_semantic_content(SemanticContentSet::Output);
s.test_write_string("1ABCD\n").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Prompt(PromptKind::Initial));
s.test_write_string("2EFGH").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Output);
s.test_write_string("\n3IJKL").unwrap();
s.resize(Resize {
reflow: false,
..Resize::new(10, 3)
})
.unwrap();
let expected = "1ABCD\n2EFGH\n3IJKL";
{
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
{
assert_eq!(expected, dump(&s, screen_pt(0, 0)));
}
// Our one row should still be a semantic prompt, the others should not.
assert!(row_at(&s, active(0, 0)).semantic_prompt() == RowPrompt::None);
assert!(row_at(&s, active(0, 1)).semantic_prompt() == RowPrompt::Prompt);
assert!(row_at(&s, active(0, 2)).semantic_prompt() == RowPrompt::None);
}
#[test]
fn screen_resize_more_cols_with_reflow_that_fits_full_width() {
let mut s = init(5, 3, 0);
let str = "1ABCD2EFGH\n3IJKL";
s.test_write_string(str).unwrap();
// Verify we soft wrapped
{
let expected = "1ABCD\n2EFGH\n3IJKL";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
// Let's put our cursor on row 2, where the soft wrap is
s.cursor_absolute(0, 1);
{
assert_eq!(u32::from('2'), cursor_cell_cp(&s));
}
// Resize and verify we undid the soft wrap because we have space now
resize(&mut s, 10, 3);
{
assert_eq!(str, dump(&s, viewport(0, 0)));
}
// Our cursor should've moved
assert_eq!(5, s.cursor.x);
assert_eq!(0, s.cursor.y);
}
#[test]
fn screen_resize_more_cols_with_reflow_that_ends_in_newline() {
let mut s = init(6, 3, 0);
let str = "1ABCD2EFGH\n3IJKL";
s.test_write_string(str).unwrap();
// Verify we soft wrapped
{
let expected = "1ABCD2\nEFGH\n3IJKL";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
// Let's put our cursor on the last row
s.cursor_absolute(0, 2);
{
assert_eq!(u32::from('3'), cursor_cell_cp(&s));
}
// Resize and verify we undid the soft wrap because we have space now
resize(&mut s, 10, 3);
{
assert_eq!(str, dump(&s, viewport(0, 0)));
}
// Our cursor should still be on the 3
{
assert_eq!(u32::from('3'), cursor_cell_cp(&s));
}
}
#[test]
fn screen_resize_more_cols_with_reflow_that_forces_more_wrapping() {
let mut s = init(5, 3, 0);
let str = "1ABCD2EFGH\n3IJKL";
s.test_write_string(str).unwrap();
// Let's put our cursor on row 2, where the soft wrap is
s.cursor_absolute(0, 1);
{
assert_eq!(u32::from('2'), cursor_cell_cp(&s));
}
// Verify we soft wrapped
{
let expected = "1ABCD\n2EFGH\n3IJKL";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
// Resize and verify we undid the soft wrap because we have space now
resize(&mut s, 7, 3);
{
let expected = "1ABCD2E\nFGH\n3IJKL";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
// Our cursor should've moved
assert_eq!(5, s.cursor.x);
assert_eq!(0, s.cursor.y);
}
#[test]
fn screen_resize_more_cols_with_reflow_that_unwraps_multiple_times() {
let mut s = init(5, 3, 0);
let str = "1ABCD2EFGH3IJKL";
s.test_write_string(str).unwrap();
// Let's put our cursor on row 2, where the soft wrap is
s.cursor_absolute(0, 2);
{
assert_eq!(u32::from('3'), cursor_cell_cp(&s));
}
// Verify we soft wrapped
{
let expected = "1ABCD\n2EFGH\n3IJKL";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
// Resize and verify we undid the soft wrap because we have space now
resize(&mut s, 15, 3);
{
let expected = "1ABCD2EFGH3IJKL";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
// Our cursor should've moved
assert_eq!(10, s.cursor.x);
assert_eq!(0, s.cursor.y);
}
#[test]
fn screen_resize_more_cols_with_populated_scrollback() {
let mut s = init(5, 3, 5);
let str = "1ABCD\n2EFGH\n3IJKL\n4ABCD5EFGH";
s.test_write_string(str).unwrap();
{
let expected = "3IJKL\n4ABCD\n5EFGH";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
// // Set our cursor to be on the "5"
s.cursor_absolute(0, 2);
{
assert_eq!(u32::from('5'), cursor_cell_cp(&s));
}
// Resize
resize(&mut s, 10, 3);
{
let expected = "2EFGH\n3IJKL\n4ABCD5EFGH";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
// Cursor should still be on the "5"
{
assert_eq!(u32::from('5'), cursor_cell_cp(&s));
}
}
#[test]
fn screen_resize_more_cols_with_reflow() {
let mut s = init(2, 3, 5);
let str = "1ABC\n2DEF\n3ABC\n4DEF";
s.test_write_string(str).unwrap();
// Let's put our cursor on row 2, where the soft wrap is
s.cursor_absolute(0, 2);
{
assert_eq!(u32::from('E'), cursor_cell_cp(&s));
}
// Verify we soft wrapped
{
let expected = "BC\n4D\nEF";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
// Resize and verify we undid the soft wrap because we have space now
resize(&mut s, 7, 3);
{
let expected = "1ABC\n2DEF\n3ABC\n4DEF";
assert_eq!(expected, dump(&s, screen_pt(0, 0)));
}
// Our cursor should've moved
assert_eq!(2, s.cursor.x);
assert_eq!(2, s.cursor.y);
}
#[test]
fn screen_resize_more_rows_and_cols_with_wrapping() {
let mut s = init(2, 4, 0);
let str = "1A2B\n3C4D";
s.test_write_string(str).unwrap();
{
let expected = "1A\n2B\n3C\n4D";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
resize(&mut s, 5, 10);
// Cursor should move due to wrapping
assert_eq!(3, s.cursor.x);
assert_eq!(1, s.cursor.y);
{
assert_eq!(str, dump(&s, viewport(0, 0)));
}
{
assert_eq!(str, dump(&s, screen_pt(0, 0)));
}
}
#[test]
fn screen_resize_less_rows_no_scrollback() {
let mut s = init(5, 3, 0);
let str = "1ABCD\n2EFGH\n3IJKL";
s.test_write_string(str).unwrap();
s.cursor_absolute(0, 0);
let (cx, cy) = (s.cursor.x, s.cursor.y);
resize(&mut s, 5, 1);
// Cursor should not move
assert_eq!(cx, s.cursor.x);
assert_eq!(cy, s.cursor.y);
{
assert_eq!("3IJKL", dump(&s, viewport(0, 0)));
}
{
assert_eq!("3IJKL", dump(&s, screen_pt(0, 0)));
}
}
#[test]
fn screen_resize_less_rows_moving_cursor() {
let mut s = init(5, 3, 0);
let str = "1ABCD\n2EFGH\n3IJKL";
s.test_write_string(str).unwrap();
// Put our cursor on the last line
s.cursor_absolute(1, 2);
{
assert_eq!(u32::from('I'), cursor_cell_cp(&s));
}
// Resize
resize(&mut s, 5, 1);
{
assert_eq!("3IJKL", dump(&s, viewport(0, 0)));
}
{
assert_eq!("3IJKL", dump(&s, screen_pt(0, 0)));
}
// Cursor should be on the last line
assert_eq!(1, s.cursor.x);
assert_eq!(0, s.cursor.y);
}
#[test]
fn screen_resize_less_rows_with_empty_scrollback() {
let mut s = init(5, 3, 10);
let str = "1ABCD\n2EFGH\n3IJKL";
s.test_write_string(str).unwrap();
resize(&mut s, 5, 1);
{
assert_eq!(str, dump(&s, screen_pt(0, 0)));
}
{
assert_eq!("3IJKL", dump(&s, viewport(0, 0)));
}
}
#[test]
fn screen_resize_less_rows_with_populated_scrollback() {
let mut s = init(5, 3, 5);
let str = "1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH";
s.test_write_string(str).unwrap();
{
let expected = "3IJKL\n4ABCD\n5EFGH";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
// Resize
resize(&mut s, 5, 1);
{
assert_eq!(str, dump(&s, screen_pt(0, 0)));
}
{
assert_eq!("5EFGH", dump(&s, viewport(0, 0)));
}
}
#[test]
fn screen_resize_less_rows_with_full_scrollback() {
let mut s = init(5, 3, 3);
let str = "00000\n1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH";
s.test_write_string(str).unwrap();
{
let expected = "3IJKL\n4ABCD\n5EFGH";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
assert_eq!(4, s.cursor.x);
assert_eq!(2, s.cursor.y);
// Resize
resize(&mut s, 5, 2);
// Cursor should stay in the same relative place (bottom of the
// screen, same character).
assert_eq!(4, s.cursor.x);
assert_eq!(1, s.cursor.y);
{
let expected = "00000\n1ABCD\n2EFGH\n3IJKL\n4ABCD\n5EFGH";
assert_eq!(expected, dump(&s, screen_pt(0, 0)));
}
{
let expected = "4ABCD\n5EFGH";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
}
#[test]
fn screen_resize_less_cols_no_reflow() {
let mut s = init(5, 3, 0);
let str = "1AB\n2EF\n3IJ";
s.test_write_string(str).unwrap();
s.cursor_absolute(0, 0);
let (cx, cy) = (s.cursor.x, s.cursor.y);
resize(&mut s, 3, 3);
// Cursor should not move
assert_eq!(cx, s.cursor.x);
assert_eq!(cy, s.cursor.y);
{
assert_eq!(str, dump(&s, viewport(0, 0)));
}
{
assert_eq!(str, dump(&s, screen_pt(0, 0)));
}
}
#[test]
#[ignore = "pagelist: reflow keeps trailing blank rows (total_rows 4 > 3) instead of trimming them like Zig, pushing the first row into history"]
fn screen_resize_less_cols_with_reflow_but_row_space() {
let mut s = init(5, 3, 1);
let str = "1ABCD";
s.test_write_string(str).unwrap();
// Put our cursor on the end
s.cursor_absolute(4, 0);
{
assert_eq!(u32::from('D'), cursor_cell_cp(&s));
}
resize(&mut s, 3, 3);
{
assert_eq!("1AB\nCD", dump(&s, viewport(0, 0)));
}
{
assert_eq!("1AB\nCD", dump(&s, screen_pt(0, 0)));
}
// Cursor should be on the last line
assert_eq!(1, s.cursor.x);
assert_eq!(1, s.cursor.y);
}
#[test]
fn screen_resize_less_cols_with_reflow_with_trimmed_rows() {
let mut s = init(5, 3, 0);
let str = "3IJKL\n4ABCD\n5EFGH";
s.test_write_string(str).unwrap();
resize(&mut s, 3, 3);
{
assert_eq!("CD\n5EF\nGH", dump(&s, viewport(0, 0)));
}
{
assert_eq!("CD\n5EF\nGH", dump(&s, screen_pt(0, 0)));
}
}
#[test]
fn screen_resize_less_cols_with_reflow_with_trimmed_rows_and_scrollback() {
let mut s = init(5, 3, 1);
let str = "3IJKL\n4ABCD\n5EFGH";
s.test_write_string(str).unwrap();
resize(&mut s, 3, 3);
{
assert_eq!("CD\n5EF\nGH", dump(&s, viewport(0, 0)));
}
{
let expected = "3IJ\nKL\n4AB\nCD\n5EF\nGH";
assert_eq!(expected, dump(&s, screen_pt(0, 0)));
}
}
#[test]
fn screen_resize_less_cols_with_reflow_previously_wrapped() {
let mut s = init(5, 3, 0);
let str = "3IJKL4ABCD5EFGH";
s.test_write_string(str).unwrap();
// Check
{
let expected = "3IJKL\n4ABCD\n5EFGH";
assert_eq!(expected, dump(&s, screen_pt(0, 0)));
}
resize(&mut s, 3, 3);
// {
// const contents = try s.testString(alloc, .viewport);
// const expected = "CD\n5EF\nGH";
// }
{
let expected = "ABC\nD5E\nFGH";
assert_eq!(expected, dump(&s, screen_pt(0, 0)));
}
}
#[test]
fn screen_resize_less_cols_with_reflow_and_scrollback() {
let mut s = init(5, 3, 5);
let str = "1A\n2B\n3C\n4D\n5E";
s.test_write_string(str).unwrap();
// Put our cursor on the end
s.cursor_absolute(1, s.pages.rows - 1);
{
assert_eq!(u32::from('E'), cursor_cell_cp(&s));
}
resize(&mut s, 3, 3);
{
let expected = "3C\n4D\n5E";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
// Cursor should be on the last line
assert_eq!(1, s.cursor.x);
assert_eq!(2, s.cursor.y);
}
#[test]
fn screen_resize_less_cols_with_reflow_previously_wrapped_and_scrollback() {
let mut s = init(5, 3, 2);
let str = "1ABCD2EFGH3IJKL4ABCD5EFGH";
s.test_write_string(str).unwrap();
// Check
{
let expected = "3IJKL\n4ABCD\n5EFGH";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
// Put our cursor on the end
s.cursor_absolute(s.pages.cols - 1, s.pages.rows - 1);
{
assert_eq!(u32::from('H'), cursor_cell_cp(&s));
}
resize(&mut s, 3, 3);
{
let expected = "CD5\nEFG\nH";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
{
let expected = "1AB\nCD2\nEFG\nH3I\nJKL\n4AB\nCD5\nEFG\nH";
assert_eq!(expected, dump(&s, screen_pt(0, 0)));
}
// Cursor should be on the last line
assert_eq!(0, s.cursor.x);
assert_eq!(2, s.cursor.y);
{
assert_eq!(u32::from('H'), cursor_cell_cp(&s));
}
}
#[test]
fn screen_resize_less_cols_with_scrollback_keeps_cursor_row() {
let mut s = init(5, 3, 5);
let str = "1A\n2B\n3C\n4D\n5E";
s.test_write_string(str).unwrap();
// Lets do a scroll and clear operation
s.scroll_clear().unwrap();
// Move our cursor to the beginning
s.cursor_absolute(0, 0);
resize(&mut s, 3, 3);
{
assert_eq!("", dump(&s, viewport(0, 0)));
}
// Cursor should be on the last line
assert_eq!(0, s.cursor.x);
assert_eq!(0, s.cursor.y);
}
#[test]
fn screen_resize_more_rows_less_cols_with_reflow_with_scrollback() {
let mut s = init(5, 3, 3);
let str = "1ABCD\n2EFGH3IJKL\n4MNOP";
s.test_write_string(str).unwrap();
{
let expected = "1ABCD\n2EFGH\n3IJKL\n4MNOP";
assert_eq!(expected, dump(&s, screen_pt(0, 0)));
}
{
let expected = "2EFGH\n3IJKL\n4MNOP";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
resize(&mut s, 2, 10);
{
let expected = "BC\nD\n2E\nFG\nH3\nIJ\nKL\n4M\nNO\nP";
assert_eq!(expected, dump(&s, viewport(0, 0)));
}
{
let expected = "1A\nBC\nD\n2E\nFG\nH3\nIJ\nKL\n4M\nNO\nP";
assert_eq!(expected, dump(&s, screen_pt(0, 0)));
}
}
// This seems like it should work fine but for some reason in practice
// in the initial implementation I found this bug! This is a regression
// test for that.
#[test]
fn screen_resize_more_rows_then_shrink_again() {
let mut s = init(5, 3, 10);
let str = "1ABC";
s.test_write_string(str).unwrap();
// Grow
resize(&mut s, 5, 10);
{
assert_eq!(str, dump(&s, viewport(0, 0)));
}
{
assert_eq!(str, dump(&s, screen_pt(0, 0)));
}
// Shrink
resize(&mut s, 5, 3);
{
assert_eq!(str, dump(&s, screen_pt(0, 0)));
}
{
assert_eq!(str, dump(&s, viewport(0, 0)));
}
// Grow again
resize(&mut s, 5, 10);
{
assert_eq!(str, dump(&s, viewport(0, 0)));
}
{
assert_eq!(str, dump(&s, screen_pt(0, 0)));
}
}
#[test]
fn screen_resize_less_cols_to_eliminate_wide_char() {
let mut s = init(2, 1, 0);
let str = "😀";
s.test_write_string(str).unwrap();
{
assert_eq!(str, dump(&s, screen_pt(0, 0)));
}
{
let cell = cell_at(&s, screen_pt(0, 0));
assert_eq!(Wide::Wide, cell.wide());
assert_eq!(u32::from('😀'), cell.codepoint());
}
// Resize to 1 column can't fit a wide char. So it should be deleted.
resize(&mut s, 1, 1);
{
assert_eq!("", dump(&s, screen_pt(0, 0)));
}
{
let cell = cell_at(&s, screen_pt(0, 0));
assert_eq!(0, cell.codepoint());
assert_eq!(Wide::Narrow, cell.wide());
}
}
#[test]
#[ignore = "pagelist: reflow keeps trailing blank rows, pushing the wrapped first row into history (erased by no_scrollback)"]
fn screen_resize_less_cols_to_wrap_wide_char() {
let mut s = init(3, 3, 0);
let str = "x😀";
s.test_write_string(str).unwrap();
{
assert_eq!(str, dump(&s, screen_pt(0, 0)));
}
{
let cell = cell_at(&s, screen_pt(1, 0));
assert_eq!(Wide::Wide, cell.wide());
assert_eq!(u32::from('😀'), cell.codepoint());
}
{
let cell = cell_at(&s, screen_pt(2, 0));
assert_eq!(Wide::SpacerTail, cell.wide());
}
resize(&mut s, 2, 3);
{
assert_eq!("x\n😀", dump(&s, screen_pt(0, 0)));
}
{
let cell = cell_at(&s, screen_pt(1, 0));
assert_eq!(Wide::SpacerHead, cell.wide());
assert!(row_at(&s, screen_pt(0, 0)).wrap());
}
}
#[test]
fn screen_resize_less_cols_to_eliminate_wide_char_with_row_space() {
let mut s = init(2, 2, 0);
let str = "😀";
s.test_write_string(str).unwrap();
{
assert_eq!(str, dump(&s, screen_pt(0, 0)));
}
{
let cell = cell_at(&s, screen_pt(0, 0));
assert_eq!(Wide::Wide, cell.wide());
assert_eq!(u32::from('😀'), cell.codepoint());
}
{
let cell = cell_at(&s, screen_pt(1, 0));
assert_eq!(Wide::SpacerTail, cell.wide());
}
resize(&mut s, 1, 2);
{
assert_eq!("", dump(&s, screen_pt(0, 0)));
}
}
#[test]
fn screen_resize_more_cols_with_wide_spacer_head() {
let mut s = init(3, 2, 0);
let str = " 😀";
s.test_write_string(str).unwrap();
{
assert_eq!(" \n😀", dump(&s, screen_pt(0, 0)));
}
// So this is the key point: we end up with a wide spacer head at
// the end of row 1, then the emoji, then a wide spacer tail on row 2.
// We should expect that if we resize to more cols, the wide spacer
// head is replaced with the emoji.
{
let cell = cell_at(&s, screen_pt(2, 0));
assert_eq!(Wide::SpacerHead, cell.wide());
}
{
let cell = cell_at(&s, screen_pt(0, 1));
assert_eq!(Wide::Wide, cell.wide());
}
{
let cell = cell_at(&s, screen_pt(1, 1));
assert_eq!(Wide::SpacerTail, cell.wide());
}
resize(&mut s, 4, 2);
{
assert_eq!(str, dump(&s, screen_pt(0, 0)));
}
{
let cell = cell_at(&s, screen_pt(2, 0));
assert_eq!(Wide::Wide, cell.wide());
assert_eq!(u32::from('😀'), cell.codepoint());
}
{
let cell = cell_at(&s, screen_pt(3, 0));
assert_eq!(Wide::SpacerTail, cell.wide());
}
}
@@ -0,0 +1,751 @@
use super::*;
/// Asserts the selection start/end map to the expected points in `tag`.
fn expect_sel(s: &Screen, sel: &Selection, tag: Tag, start: (u16, u32), end: (u16, u32)) {
let mk = |(x, y): (u16, u32)| Point::new(tag, Coordinate { x, y });
assert_eq!(
mk(start),
s.pages.point_from_pin(tag, sel.start(&s.pages)).unwrap()
);
assert_eq!(
mk(end),
s.pages.point_from_pin(tag, sel.end(&s.pages)).unwrap()
);
}
fn line(s: &Screen, x: u16, y: u32) -> Selection {
s.select_line(SelectLine::new(pin(s, active(x, y))))
.unwrap()
}
fn sel_str(s: &Screen, sel: Selection, trim: bool) -> String {
s.selection_string(SelectionString { sel, trim })
}
const BOUNDARY: &[u32] = &[
0,
' ' as u32,
'\t' as u32,
'\'' as u32,
'"' as u32,
'│' as u32,
'`' as u32,
'|' as u32,
':' as u32,
';' as u32,
',' as u32,
'(' as u32,
')' as u32,
'[' as u32,
']' as u32,
'{' as u32,
'}' as u32,
'<' as u32,
'>' as u32,
'$' as u32,
];
fn word(s: &Screen, x: u16, y: u32) -> Selection {
s.select_word(pin(s, active(x, y)), BOUNDARY).unwrap()
}
#[test]
fn screen_resize_more_cols_with_wide_spacer_head_multiple_lines() {
let mut s = init(3, 3, 0);
let str = "xxxyy😀";
s.test_write_string(str).unwrap();
assert_eq!("xxx\nyy\n😀", dump(&s, screen_pt(0, 0)));
// Similar to the "wide spacer head" test, but this time we'er going
// to increase our columns such that multiple rows are unwrapped.
assert_eq!(
Wide::SpacerHead,
s.pages.get_cell(screen_pt(2, 1)).unwrap().wide()
);
assert_eq!(
Wide::Wide,
s.pages.get_cell(screen_pt(0, 2)).unwrap().wide()
);
assert_eq!(
Wide::SpacerTail,
s.pages.get_cell(screen_pt(1, 2)).unwrap().wide()
);
s.resize(Resize::new(8, 2)).unwrap();
assert_eq!(str, dump(&s, screen_pt(0, 0)));
{
let cell = s.pages.get_cell(screen_pt(5, 0)).unwrap();
assert_eq!(Wide::Wide, cell.wide());
assert_eq!('😀' as u32, cell.codepoint());
}
assert_eq!(
Wide::SpacerTail,
s.pages.get_cell(screen_pt(6, 0)).unwrap().wide()
);
}
#[test]
fn screen_resize_more_cols_requiring_a_wide_spacer_head() {
let mut s = init(2, 2, 0);
let str = "xx😀";
s.test_write_string(str).unwrap();
assert_eq!("xx\n😀", dump(&s, screen_pt(0, 0)));
assert_eq!(
Wide::Wide,
s.pages.get_cell(screen_pt(0, 1)).unwrap().wide()
);
assert_eq!(
Wide::SpacerTail,
s.pages.get_cell(screen_pt(1, 1)).unwrap().wide()
);
// This resizes to 3 columns, which isn't enough space for our wide
// char to enter row 1. But we need to mark the wide spacer head on the
// end of the first row since we're wrapping to the next row.
s.resize(Resize::new(3, 2)).unwrap();
assert_eq!("xx\n😀", dump(&s, screen_pt(0, 0)));
assert_eq!(
Wide::SpacerHead,
s.pages.get_cell(screen_pt(2, 0)).unwrap().wide()
);
{
let cell = s.pages.get_cell(screen_pt(0, 1)).unwrap();
assert_eq!(Wide::Wide, cell.wide());
assert_eq!('😀' as u32, cell.codepoint());
}
assert_eq!(
Wide::SpacerTail,
s.pages.get_cell(screen_pt(1, 1)).unwrap().wide()
);
}
#[test]
fn screen_resize_more_cols_with_cursor_at_prompt() {
let mut s = init(10, 3, 5);
s.test_write_string("ABCDE\n").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Prompt(PromptKind::Initial));
s.test_write_string("> ").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Input(InputClear::ClearEol));
s.test_write_string("echo").unwrap();
assert_eq!("ABCDE\n> echo", dump(&s, viewport(0, 0)));
s.resize(Resize {
prompt_redraw: Redraw::True,
..Resize::new(20, 3)
})
.unwrap();
// Cursor should not move
assert_eq!(6, s.cursor.x);
assert_eq!(1, s.cursor.y);
assert_eq!("ABCDE", dump(&s, viewport(0, 0)));
}
#[test]
fn screen_resize_more_cols_with_cursor_not_at_prompt() {
let mut s = init(10, 3, 5);
s.test_write_string("ABCDE\n").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Prompt(PromptKind::Initial));
s.test_write_string("> ").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Input(InputClear::ClearEol));
s.test_write_string("echo\n").unwrap();
s.test_write_string("output").unwrap();
assert_eq!("ABCDE\n> echo\noutput", dump(&s, viewport(0, 0)));
s.resize(Resize {
prompt_redraw: Redraw::True,
..Resize::new(20, 3)
})
.unwrap();
// Cursor should not move
assert_eq!(6, s.cursor.x);
assert_eq!(2, s.cursor.y);
assert_eq!("ABCDE\n> echo\noutput", dump(&s, viewport(0, 0)));
}
#[test]
fn screen_resize_with_prompt_redraw_last_clears_only_one_line() {
let mut s = init(10, 4, 5);
s.test_write_string("ABCDE\n").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Prompt(PromptKind::Initial));
s.test_write_string("> ").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Input(InputClear::ClearExplicit));
s.test_write_string("hello\n").unwrap();
s.test_write_string("world").unwrap();
assert_eq!("ABCDE\n> hello\nworld", dump(&s, viewport(0, 0)));
// Cursor is at end of "world" line with semantic_content = .input
s.resize(Resize {
prompt_redraw: Redraw::Last,
..Resize::new(20, 4)
})
.unwrap();
// With .last, only the current line where cursor is should be cleared
assert_eq!("ABCDE\n> hello", dump(&s, viewport(0, 0)));
}
#[test]
fn screen_resize_with_prompt_redraw_last_multiline_prompt_clears_only_last_line() {
let mut s = init(20, 5, 5);
// Create a 3-line prompt: 1 initial + 2 continuation lines
s.cursor_set_semantic_content(SemanticContentSet::Prompt(PromptKind::Initial));
s.test_write_string("line1\n").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Prompt(PromptKind::Continuation));
s.test_write_string("line2\n").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Prompt(PromptKind::Continuation));
s.test_write_string("line3").unwrap();
assert_eq!("line1\nline2\nline3", dump(&s, viewport(0, 0)));
// Cursor is at end of line3 (the last continuation line)
s.resize(Resize {
prompt_redraw: Redraw::Last,
..Resize::new(30, 5)
})
.unwrap();
// With .last, only line3 (where cursor is) should be cleared
assert_eq!("line1\nline2", dump(&s, viewport(0, 0)));
}
#[test]
fn screen_select_untracked() {
let mut s = init(10, 10, 0);
s.test_write_string("ABC DEF\n 123\n456").unwrap();
assert!(s.selection.is_none());
let tracked = s.pages.count_tracked_pins();
let sel = Selection::new(pin(&s, active(0, 0)), pin(&s, active(3, 0)), false);
s.select(Some(sel)).unwrap();
assert_eq!(tracked + 2, s.pages.count_tracked_pins());
s.select(None).unwrap();
assert_eq!(tracked, s.pages.count_tracked_pins());
}
#[test]
fn screen_select_all() {
let mut s = init(10, 10, 0);
{
s.test_write_string("ABC DEF\n 123\n456").unwrap();
let sel = s.select_all().unwrap();
expect_sel(&s, &sel, Tag::Screen, (0, 0), (2, 2));
}
{
s.test_write_string("\nFOO\n BAR\n BAZ\n QWERTY\n 12345678")
.unwrap();
let sel = s.select_all().unwrap();
expect_sel(&s, &sel, Tag::Screen, (0, 0), (8, 7));
}
}
#[test]
fn screen_select_line() {
let mut s = init(10, 10, 0);
s.test_write_string("ABC DEF\n 123\n456").unwrap();
// Going forward
let sel = line(&s, 0, 0);
expect_sel(&s, &sel, Tag::Screen, (0, 0), (7, 0));
// Going backward
let sel = line(&s, 7, 0);
expect_sel(&s, &sel, Tag::Screen, (0, 0), (7, 0));
// Going forward and backward
let sel = line(&s, 3, 0);
expect_sel(&s, &sel, Tag::Screen, (0, 0), (7, 0));
// Outside active area
let sel = line(&s, 9, 0);
expect_sel(&s, &sel, Tag::Screen, (0, 0), (7, 0));
}
#[test]
fn screen_select_line_across_soft_wrap() {
let mut s = init(5, 10, 0);
s.test_write_string(" 12 34012 \n 123").unwrap();
// Going forward
let sel = line(&s, 1, 0);
expect_sel(&s, &sel, Tag::Screen, (1, 0), (3, 1));
}
#[test]
fn screen_select_line_across_full_soft_wrap() {
let mut s = init(5, 5, 0);
s.test_write_string("1ABCD2EFGH\n3IJKL").unwrap();
let sel = line(&s, 2, 1);
expect_sel(&s, &sel, Tag::Screen, (0, 0), (4, 1));
}
#[test]
fn screen_select_line_across_soft_wrap_ignores_blank_lines() {
let mut s = init(5, 10, 0);
s.test_write_string(" 12 34012 \n 123").unwrap();
// Going forward
let sel = line(&s, 1, 0);
expect_sel(&s, &sel, Tag::Screen, (1, 0), (3, 1));
// Going backward
let sel = line(&s, 1, 1);
expect_sel(&s, &sel, Tag::Screen, (1, 0), (3, 1));
// Going forward and backward
let sel = line(&s, 3, 0);
expect_sel(&s, &sel, Tag::Screen, (1, 0), (3, 1));
}
#[test]
fn screen_select_line_disabled_whitespace_trimming() {
let mut s = init(5, 10, 0);
s.test_write_string(" 12 34012 \n 123").unwrap();
// Going forward
let sel = s
.select_line(SelectLine {
whitespace: None,
..SelectLine::new(pin(&s, active(1, 0)))
})
.unwrap();
expect_sel(&s, &sel, Tag::Screen, (0, 0), (4, 2));
// Non-wrapped
let sel = s
.select_line(SelectLine {
whitespace: None,
..SelectLine::new(pin(&s, active(1, 3)))
})
.unwrap();
expect_sel(&s, &sel, Tag::Screen, (0, 3), (4, 3));
}
#[test]
fn screen_select_line_with_scrollback() {
let mut s = init(2, 3, 5);
s.test_write_string("1A\n2B\n3C\n4D\n5E").unwrap();
// Selecting first line
let sel = line(&s, 0, 0);
expect_sel(&s, &sel, Tag::Active, (0, 0), (1, 0));
// Selecting last line
let sel = line(&s, 0, 2);
expect_sel(&s, &sel, Tag::Active, (0, 2), (1, 2));
}
// https://github.com/mitchellh/ghostty/issues/1329
#[test]
fn screen_select_line_semantic_prompt_boundary() {
let mut s = init(5, 10, 0);
s.test_write_string("ABCDE\n").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Prompt(PromptKind::Initial));
s.test_write_string("A ").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Output);
s.test_write_string("> ").unwrap();
assert_eq!("ABCDE\nA \n> ", dump(&s, screen_pt(0, 0)));
// Selecting output stops at the prompt even if soft-wrapped
{
let sel = line(&s, 1, 1);
assert_eq!("A", sel_str(&s, sel, false));
}
{
let sel = line(&s, 1, 2);
expect_sel(&s, &sel, Tag::Active, (0, 2), (0, 2));
}
}
#[test]
fn screen_select_line_semantic_prompt_to_input_boundary() {
let mut s = init(10, 5, 0);
// Write prompt followed by user input on same row: "$>command"
// Using non-whitespace to avoid whitespace trimming affecting the test
s.cursor_set_semantic_content(SemanticContentSet::Prompt(PromptKind::Initial));
s.test_write_string("$>").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Input(InputClear::ClearExplicit));
s.test_write_string("command").unwrap();
// Selecting from prompt should only select prompt
let sel = line(&s, 0, 0);
expect_sel(&s, &sel, Tag::Active, (0, 0), (1, 0));
// Selecting from input should only select input
let sel = line(&s, 5, 0);
expect_sel(&s, &sel, Tag::Active, (2, 0), (8, 0));
}
#[test]
fn screen_select_line_semantic_input_to_output_boundary() {
let mut s = init(10, 5, 0);
// Row 0: user input
s.cursor_set_semantic_content(SemanticContentSet::Input(InputClear::ClearExplicit));
s.test_write_string("ls -la\n").unwrap();
// Row 1: command output
s.cursor_set_semantic_content(SemanticContentSet::Output);
s.test_write_string("file.txt").unwrap();
// Selecting from input should only select input
let sel = line(&s, 2, 0);
assert_eq!("ls -la", sel_str(&s, sel, false));
// Selecting from output should only select output
let sel = line(&s, 2, 1);
assert_eq!("file.txt", sel_str(&s, sel, false));
}
#[test]
fn screen_select_line_semantic_mid_row_boundary() {
let mut s = init(10, 5, 0);
// Single row with output then prompt then input: "out$>cmd"
// Using non-whitespace to avoid whitespace trimming affecting the test
s.cursor_set_semantic_content(SemanticContentSet::Output);
s.test_write_string("out").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Prompt(PromptKind::Initial));
s.test_write_string("$>").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Input(InputClear::ClearExplicit));
s.test_write_string("cmd").unwrap();
// Selecting from output should stop at prompt
let sel = line(&s, 1, 0);
expect_sel(&s, &sel, Tag::Active, (0, 0), (2, 0));
// Selecting from prompt should only select prompt
let sel = line(&s, 3, 0);
expect_sel(&s, &sel, Tag::Active, (3, 0), (4, 0));
// Selecting from input should only select input
let sel = line(&s, 6, 0);
expect_sel(&s, &sel, Tag::Active, (5, 0), (7, 0));
}
#[test]
fn screen_select_line_semantic_boundary_soft_wrap_with_mid_row_transition() {
let mut s = init(5, 5, 0);
// Row 0: prompt "$ " + input "cmd" (soft-wraps)
// Row 1: input continues "12" + output "out"
s.cursor_set_semantic_content(SemanticContentSet::Prompt(PromptKind::Initial));
s.test_write_string("$ ").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Input(InputClear::ClearExplicit));
s.test_write_string("cmd12").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Output);
s.test_write_string("out").unwrap();
// Verify layout
assert_eq!("$ cmd\n12out", dump(&s, screen_pt(0, 0)));
// Selecting from input on row 0 should get all input across soft-wrap
let sel = line(&s, 3, 0);
assert_eq!("cmd12", sel_str(&s, sel, false));
// Selecting from input on row 1 should get all input across soft-wrap
let sel = line(&s, 0, 1);
assert_eq!("cmd12", sel_str(&s, sel, false));
// Selecting from output should only get output
let sel = line(&s, 3, 1);
assert_eq!("out", sel_str(&s, sel, false));
}
#[test]
fn screen_select_line_semantic_boundary_disabled() {
let mut s = init(10, 5, 0);
// Write prompt followed by input
s.cursor_set_semantic_content(SemanticContentSet::Prompt(PromptKind::Initial));
s.test_write_string("$ ").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Input(InputClear::ClearExplicit));
s.test_write_string("command").unwrap();
// With semantic_prompt_boundary = false, should select entire line
let sel = s
.select_line(SelectLine {
semantic_prompt_boundary: false,
..SelectLine::new(pin(&s, active(0, 0)))
})
.unwrap();
assert_eq!("$ command", sel_str(&s, sel, false));
}
#[test]
fn screen_select_line_semantic_boundary_first_cell_of_row() {
let mut s = init(5, 5, 0);
// Row 0: input that soft-wraps
// Row 1: output starts at first cell
s.cursor_set_semantic_content(SemanticContentSet::Input(InputClear::ClearExplicit));
s.test_write_string("12345").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Output);
s.test_write_string("ABCDE").unwrap();
// Verify soft-wrap happened
{
let p = pin(&s, active(0, 0));
let (row, _) = p.row_and_cell(&s.pages).unwrap();
assert!(row.wrap());
}
// Selecting from input should stop before output on row 1
let sel = line(&s, 2, 0);
expect_sel(&s, &sel, Tag::Active, (0, 0), (4, 0));
// Selecting from output should only get output
let sel = line(&s, 2, 1);
expect_sel(&s, &sel, Tag::Active, (0, 1), (4, 1));
}
#[test]
fn screen_select_line_semantic_all_same_content() {
let mut s = init(5, 5, 0);
// All prompt content that soft-wraps
s.cursor_set_semantic_content(SemanticContentSet::Prompt(PromptKind::Initial));
s.test_write_string("prompt text").unwrap();
// Verify soft-wrap
assert_eq!("promp\nt tex\nt", dump(&s, screen_pt(0, 0)));
// Should select all prompt content across soft-wraps
let sel = line(&s, 2, 1);
assert_eq!("prompt text", sel_str(&s, sel, false));
}
#[test]
fn screen_select_word() {
let mut s = init(10, 10, 0);
s.test_write_string("ABC DEF\n 123\n456").unwrap();
// Going forward
let sel = word(&s, 0, 0);
expect_sel(&s, &sel, Tag::Screen, (0, 0), (2, 0));
// Going backward
let sel = word(&s, 2, 0);
expect_sel(&s, &sel, Tag::Screen, (0, 0), (2, 0));
// Going forward and backward
let sel = word(&s, 1, 0);
expect_sel(&s, &sel, Tag::Screen, (0, 0), (2, 0));
// Whitespace
let sel = word(&s, 3, 0);
expect_sel(&s, &sel, Tag::Screen, (3, 0), (4, 0));
// Whitespace single char
let sel = word(&s, 0, 1);
expect_sel(&s, &sel, Tag::Screen, (0, 1), (0, 1));
// End of screen
let sel = word(&s, 1, 2);
expect_sel(&s, &sel, Tag::Screen, (0, 2), (2, 2));
}
#[test]
fn screen_select_word_across_soft_wrap() {
let mut s = init(5, 10, 0);
s.test_write_string(" 1234012\n 123").unwrap();
assert_eq!(" 1234\n012\n 123", dump(&s, screen_pt(0, 0)));
// Going forward
let sel = word(&s, 1, 0);
expect_sel(&s, &sel, Tag::Screen, (1, 0), (2, 1));
// Going backward
let sel = word(&s, 1, 1);
expect_sel(&s, &sel, Tag::Screen, (1, 0), (2, 1));
// Going forward and backward
let sel = word(&s, 3, 0);
expect_sel(&s, &sel, Tag::Screen, (1, 0), (2, 1));
}
#[test]
fn screen_select_word_whitespace_across_soft_wrap() {
let mut s = init(5, 10, 0);
s.test_write_string("1 1\n 123").unwrap();
// Going forward
let sel = word(&s, 1, 0);
expect_sel(&s, &sel, Tag::Screen, (1, 0), (2, 1));
// Going backward
let sel = word(&s, 1, 1);
expect_sel(&s, &sel, Tag::Screen, (1, 0), (2, 1));
// Going forward and backward
let sel = word(&s, 3, 0);
expect_sel(&s, &sel, Tag::Screen, (1, 0), (2, 1));
}
#[test]
fn screen_select_word_with_character_boundary() {
let cases = [
" 'abc' \n123",
" \"abc\" \n123",
" │abc│ \n123",
" `abc` \n123",
" |abc| \n123",
" :abc: \n123",
" ;abc; \n123",
" ,abc, \n123",
" (abc( \n123",
" )abc) \n123",
" [abc[ \n123",
" ]abc] \n123",
" {abc{ \n123",
" }abc} \n123",
" <abc< \n123",
" >abc> \n123",
" $abc$ \n123",
];
for case in cases {
let mut s = init(20, 10, 0);
s.test_write_string(case).unwrap();
// Inside character forward
let sel = word(&s, 2, 0);
expect_sel(&s, &sel, Tag::Screen, (2, 0), (4, 0));
// Inside character backward
let sel = word(&s, 4, 0);
expect_sel(&s, &sel, Tag::Screen, (2, 0), (4, 0));
// Inside character bidirectional
let sel = word(&s, 3, 0);
expect_sel(&s, &sel, Tag::Screen, (2, 0), (4, 0));
// On quote
// NOTE: this behavior is not ideal, so we can change this one day,
// but I think its also not that important compared to the above.
let sel = word(&s, 1, 0);
expect_sel(&s, &sel, Tag::Screen, (0, 0), (1, 0));
}
}
#[test]
fn screen_select_output() {
let mut s = init(10, 15, 0);
// Build content with cell-level semantic content:
// Row 0-1: output1 (output)
// Row 2: prompt2 (prompt)
// Row 3: input2 (input)
// Row 4-7: output2 (output, with overflow causing wrap)
// Row 8: "$ " (prompt) + "input3" (input)
// Row 9-11: output3 (output)
s.cursor_set_semantic_content(SemanticContentSet::Output);
s.test_write_string("output1\n").unwrap();
s.test_write_string("output1\n").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Prompt(PromptKind::Initial));
s.test_write_string("prompt2\n").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Input(InputClear::ClearExplicit));
s.test_write_string("input2\n").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Output);
s.test_write_string("output2output2output2output2\n")
.unwrap();
s.test_write_string("output2\n").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Prompt(PromptKind::Initial));
s.test_write_string("$ ").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Input(InputClear::ClearExplicit));
s.test_write_string("input3\n").unwrap();
s.cursor_set_semantic_content(SemanticContentSet::Output);
s.test_write_string("output3\n").unwrap();
s.test_write_string("output3\n").unwrap();
s.test_write_string("output3").unwrap();
// First output block (rows 0-1), should select those rows
{
let sel = s.select_output(pin(&s, active(1, 1))).unwrap();
assert_eq!("output1\noutput1", sel_str(&s, sel, false));
}
// Second output block (rows 4-7)
{
let sel = s.select_output(pin(&s, active(3, 7))).unwrap();
assert_eq!(
"output2output2output2output2\noutput2",
sel_str(&s, sel, false)
);
}
// Third output block (rows 9-11)
{
let sel = s.select_output(pin(&s, active(2, 10))).unwrap();
expect_sel(&s, &sel, Tag::Active, (0, 9), (6, 11));
}
// Click on prompt should return null
assert!(s.select_output(pin(&s, active(1, 8))).is_none());
// Click on input should return null
assert!(s.select_output(pin(&s, active(5, 8))).is_none());
}
#[test]
fn screen_selection_string_basic() {
let mut s = init(5, 3, 0);
s.test_write_string("1ABCD\n2EFGH\n3IJKL").unwrap();
let sel = Selection::new(pin(&s, screen_pt(0, 1)), pin(&s, screen_pt(2, 2)), false);
assert_eq!("2EFGH\n3IJ", sel_str(&s, sel, true));
}
#[test]
fn screen_selection_string_start_outside_of_written_area() {
let mut s = init(5, 10, 0);
s.test_write_string("1ABCD\n2EFGH\n3IJKL").unwrap();
let sel = Selection::new(pin(&s, screen_pt(0, 5)), pin(&s, screen_pt(2, 6)), false);
assert_eq!("", sel_str(&s, sel, true));
}
#[test]
fn screen_selection_string_end_outside_of_written_area() {
let mut s = init(5, 10, 0);
s.test_write_string("1ABCD\n2EFGH\n3IJKL").unwrap();
let sel = Selection::new(pin(&s, screen_pt(0, 2)), pin(&s, screen_pt(2, 6)), false);
assert_eq!("3IJKL", sel_str(&s, sel, true));
}
#[test]
fn screen_selection_string_trim_space() {
let mut s = init(5, 3, 0);
s.test_write_string("1AB \n2EFGH\n3IJKL").unwrap();
let sel = Selection::new(pin(&s, screen_pt(0, 0)), pin(&s, screen_pt(2, 1)), false);
assert_eq!("1AB\n2EF", sel_str(&s, sel, true));
// No trim
assert_eq!("1AB \n2EF", sel_str(&s, sel, false));
}
#[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 = Selection::new(pin(&s, screen_pt(0, 0)), pin(&s, screen_pt(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));
}
@@ -0,0 +1,1076 @@
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);
}