tastty-core 0.1.0

Sans-IO core of the tastty terminal session library: VT parser, screen buffer, and byte encoders.
use super::*;

fn make_parser(rows: u16, cols: u16, scrollback: usize) -> crate::Parser {
    crate::Parser::new(TerminalSize { rows, cols }, scrollback)
}

#[test]
fn selection_initially_none() {
    let screen = Screen::new(crate::grid::Size { rows: 5, cols: 10 }, 100);
    assert_eq!(screen.selection_range(), None);
    assert_eq!(screen.selected_text(), None);
}

#[test]
fn selection_start_then_clear() {
    let mut parser = make_parser(3, 10, 100);
    parser.process(b"abc");
    parser
        .screen_mut()
        .selection_start(AbsolutePosition { row: 0, col: 0 }, SelectionMode::Linear);
    parser
        .screen_mut()
        .selection_extend(AbsolutePosition { row: 0, col: 2 });
    assert!(parser.screen().selection_range().is_some());
    parser.screen_mut().selection_clear();
    assert_eq!(parser.screen().selection_range(), None);
    assert_eq!(parser.screen().selected_text(), None);
}

#[test]
fn selection_linear_single_line() {
    let mut parser = make_parser(3, 20, 100);
    parser.process(b"hello world");
    parser
        .screen_mut()
        .selection_start(AbsolutePosition { row: 0, col: 0 }, SelectionMode::Linear);
    parser
        .screen_mut()
        .selection_extend(AbsolutePosition { row: 0, col: 4 });
    assert_eq!(parser.screen().selected_text().as_deref(), Some("hello"),);
}

#[test]
fn selection_linear_two_hard_lines() {
    let mut parser = make_parser(5, 20, 100);
    parser.process(b"line one\r\nline two\r\nline three\r\n");
    parser
        .screen_mut()
        .selection_start(AbsolutePosition { row: 0, col: 0 }, SelectionMode::Linear);
    parser
        .screen_mut()
        .selection_extend(AbsolutePosition { row: 1, col: 7 });
    assert_eq!(
        parser.screen().selected_text().as_deref(),
        Some("line one\nline two"),
    );
}

#[test]
fn selection_linear_wrapped_no_newline() {
    // 5 cols, "helloworld" wraps from row 0 into row 1.
    let mut parser = make_parser(5, 5, 100);
    parser.process(b"helloworld");
    parser
        .screen_mut()
        .selection_start(AbsolutePosition { row: 0, col: 0 }, SelectionMode::Linear);
    parser
        .screen_mut()
        .selection_extend(AbsolutePosition { row: 1, col: 4 });
    assert_eq!(
        parser.screen().selected_text().as_deref(),
        Some("helloworld"),
    );
}

#[test]
fn selection_linear_trims_trailing_whitespace_per_row() {
    // row 0 has "hi" then blanks; selection covers full row plus next.
    let mut parser = make_parser(3, 10, 100);
    parser.process(b"hi\r\nthere");
    parser
        .screen_mut()
        .selection_start(AbsolutePosition { row: 0, col: 0 }, SelectionMode::Linear);
    parser
        .screen_mut()
        .selection_extend(AbsolutePosition { row: 1, col: 4 });
    // Trailing blanks on row 0 are trimmed; "\n" injected between
    // unwrapped rows.
    assert_eq!(
        parser.screen().selected_text().as_deref(),
        Some("hi\nthere"),
    );
}

#[test]
fn selection_linear_wide_cell_in_range() {
    // A B 世 C D where 世 occupies 2 cells (cols 2-3).
    let mut parser = make_parser(3, 10, 100);
    parser.process("AB\u{4e16}CD".as_bytes());
    parser
        .screen_mut()
        .selection_start(AbsolutePosition { row: 0, col: 0 }, SelectionMode::Linear);
    parser
        .screen_mut()
        .selection_extend(AbsolutePosition { row: 0, col: 5 });
    assert_eq!(
        parser.screen().selected_text().as_deref(),
        Some("AB\u{4e16}CD"),
    );
}

#[test]
fn selection_linear_normalizes_when_extended_backward() {
    // Anchor at col 5, extend backward to col 0; the selected
    // text should still read "hello" in row-major order.
    let mut parser = make_parser(3, 20, 100);
    parser.process(b"hello world");
    parser
        .screen_mut()
        .selection_start(AbsolutePosition { row: 0, col: 4 }, SelectionMode::Linear);
    parser
        .screen_mut()
        .selection_extend(AbsolutePosition { row: 0, col: 0 });
    assert_eq!(parser.screen().selected_text().as_deref(), Some("hello"),);
}

#[test]
fn selection_scrollback_stable_after_scroll() {
    // Anchor a selection over "line0" while it is visible, then
    // push enough rows to scroll it into scrollback. The
    // selected text must still read "line0" because the abs
    // coordinates are unchanged by scrolling.
    let mut parser = make_parser(3, 10, 100);
    parser.process(b"line0\r\n");
    let start = parser
        .screen()
        .visible_to_absolute(Position { row: 0, col: 0 })
        .unwrap();
    let end = parser
        .screen()
        .visible_to_absolute(Position { row: 0, col: 4 })
        .unwrap();
    parser
        .screen_mut()
        .selection_start(start, SelectionMode::Linear);
    parser.screen_mut().selection_extend(end);
    assert_eq!(parser.screen().selected_text().as_deref(), Some("line0"));
    for i in 1..6 {
        parser.process(format!("line{i}\r\n").as_bytes());
    }
    // "line0" is now in scrollback. Selection still resolves it.
    assert_eq!(
        parser.screen().selected_text().as_deref(),
        Some("line0"),
        "absolute selection coordinates must remain valid once the row scrolls into history",
    );
}

#[test]
fn selection_line_mode_full_lines() {
    // Line mode: pointing into the middle of two rows should
    // cover both rows in full.
    let mut parser = make_parser(5, 20, 100);
    parser.process(b"first line\r\nsecond line\r\nthird line\r\n");
    parser
        .screen_mut()
        .selection_start(AbsolutePosition { row: 0, col: 4 }, SelectionMode::Line);
    parser
        .screen_mut()
        .selection_extend(AbsolutePosition { row: 1, col: 2 });
    assert_eq!(
        parser.screen().selected_text().as_deref(),
        Some("first line\nsecond line"),
    );
}

#[test]
fn selection_word_snaps_alphanumeric_run() {
    let mut parser = make_parser(3, 30, 100);
    parser.process(b"hello world");
    // Click in the middle of "world" with Word mode; snap to
    // the full word.
    parser
        .screen_mut()
        .selection_start(AbsolutePosition { row: 0, col: 8 }, SelectionMode::Word);
    assert_eq!(parser.screen().selected_text().as_deref(), Some("world"),);
}

#[test]
fn selection_word_snaps_to_word_at_cursor_end() {
    // Anchor in "hello", extend into "world"; selection covers
    // the whole "hello" through whole "world".
    let mut parser = make_parser(3, 30, 100);
    parser.process(b"hello world there");
    parser
        .screen_mut()
        .selection_start(AbsolutePosition { row: 0, col: 1 }, SelectionMode::Word);
    parser
        .screen_mut()
        .selection_extend(AbsolutePosition { row: 0, col: 9 });
    assert_eq!(
        parser.screen().selected_text().as_deref(),
        Some("hello world"),
    );
}

#[test]
fn selection_word_punctuation_is_its_own_class() {
    // "foo+bar" -- clicking on '+' selects only the '+' (its own
    // punctuation class), not the surrounding alphanumerics.
    let mut parser = make_parser(3, 20, 100);
    parser.process(b"foo+bar");
    parser
        .screen_mut()
        .selection_start(AbsolutePosition { row: 0, col: 3 }, SelectionMode::Word);
    assert_eq!(parser.screen().selected_text().as_deref(), Some("+"));
}

#[test]
fn selection_word_underscore_is_word() {
    let mut parser = make_parser(3, 20, 100);
    parser.process(b"foo_bar baz");
    parser
        .screen_mut()
        .selection_start(AbsolutePosition { row: 0, col: 4 }, SelectionMode::Word);
    assert_eq!(parser.screen().selected_text().as_deref(), Some("foo_bar"));
}

#[test]
fn selection_block_single_row_is_horizontal_slice() {
    let mut parser = make_parser(3, 20, 100);
    parser.process(b"abcdefghij");
    parser
        .screen_mut()
        .selection_start(AbsolutePosition { row: 0, col: 2 }, SelectionMode::Block);
    parser
        .screen_mut()
        .selection_extend(AbsolutePosition { row: 0, col: 6 });
    assert_eq!(parser.screen().selected_text().as_deref(), Some("cdefg"));
}

#[test]
fn selection_block_multi_row_joins_with_newline() {
    let mut parser = make_parser(5, 20, 100);
    parser.process(b"abcdefghij\r\nklmnopqrst\r\nuvwxyz0123\r\n");
    parser
        .screen_mut()
        .selection_start(AbsolutePosition { row: 0, col: 2 }, SelectionMode::Block);
    parser
        .screen_mut()
        .selection_extend(AbsolutePosition { row: 2, col: 5 });
    // Each row's slice [2..=5] is "cdef", "mnop", "wxyz". Block
    // mode always uses '\n' regardless of wrap state.
    assert_eq!(
        parser.screen().selected_text().as_deref(),
        Some("cdef\nmnop\nwxyz"),
    );
}

#[test]
fn selection_block_corners_normalize() {
    // Block mode given top-right and bottom-left corners must
    // produce the same rectangle as bottom-left + top-right.
    let mut parser = make_parser(5, 20, 100);
    parser.process(b"abcdefghij\r\nklmnopqrst\r\n");
    parser
        .screen_mut()
        .selection_start(AbsolutePosition { row: 0, col: 6 }, SelectionMode::Block);
    parser
        .screen_mut()
        .selection_extend(AbsolutePosition { row: 1, col: 2 });
    // min_col=2, max_col=6 across rows 0..=1.
    assert_eq!(
        parser.screen().selected_text().as_deref(),
        Some("cdefg\nmnopq"),
    );
}

#[test]
fn selection_block_left_edge_on_wide_continuation_includes_wide_head() {
    // A 世 B C D where 世 is at cols 1-2 (1=head, 2=trail).
    // Block left edge at col 2 (the trail) should include the
    // wide head: a block selection should never start mid-glyph.
    let mut parser = make_parser(3, 10, 100);
    parser.process("A\u{4e16}BCD".as_bytes());
    parser
        .screen_mut()
        .selection_start(AbsolutePosition { row: 0, col: 2 }, SelectionMode::Block);
    parser
        .screen_mut()
        .selection_extend(AbsolutePosition { row: 0, col: 4 });
    // Includes the wide head (col 1) so the rectangle reads
    // "世BC".
    assert_eq!(
        parser.screen().selected_text().as_deref(),
        Some("\u{4e16}BC"),
    );
}

#[test]
fn selection_clears_on_size_change() {
    // Resizing invalidates scrollback row indices; the selection
    // must drop rather than carry stale coordinates forward.
    let mut parser = make_parser(3, 20, 100);
    parser.process(b"hello world");
    parser
        .screen_mut()
        .selection_start(AbsolutePosition { row: 0, col: 0 }, SelectionMode::Linear);
    parser
        .screen_mut()
        .selection_extend(AbsolutePosition { row: 0, col: 4 });
    parser
        .screen_mut()
        .set_size(TerminalSize { rows: 5, cols: 30 });
    assert_eq!(parser.screen().selection_range(), None);
}