justerm-core 0.16.0

A pure terminal engine: VT byte stream to grid + scrollback + damage. No I/O, no rendering, theme-agnostic.
Documentation
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
427
428
429
430
431
432
433
434
435
436
437
438
439
440
441
442
443
444
445
446
447
448
449
450
451
452
453
454
455
456
457
458
459
460
461
462
463
464
465
466
467
468
469
470
471
472
473
474
475
476
477
478
479
480
481
482
483
484
485
486
487
488
489
490
491
492
493
494
495
496
497
498
499
500
501
502
503
504
505
506
507
508
509
510
511
512
513
514
515
516
517
518
519
520
521
522
523
524
525
526
527
528
529
530
531
532
533
534
535
536
537
538
539
540
541
542
543
544
545
546
547
548
549
550
551
552
553
554
555
556
557
558
559
560
561
562
563
564
565
566
567
568
569
570
571
572
573
574
575
576
577
578
579
580
581
582
583
584
585
586
587
588
589
590
591
592
593
594
595
596
597
598
599
600
601
602
603
604
605
606
607
608
609
610
611
612
613
614
615
616
617
618
619
620
621
622
623
624
625
626
627
628
629
630
631
632
633
634
635
636
637
638
639
640
641
642
643
644
645
646
647
648
649
650
//! #5 selection — engine-owned char/word/line/block selection, `selection_range`
//! (viewport line spans for highlight) and `selection_text` (copy across
//! scrollback). Built TDD, one behaviour per test; the risky coordinate cases
//! (cap eviction, region/RI rotate, resize reflow) are each pinned by a test so
//! "green" means the rotate is actually correct, not just correct-looking.

use justerm_core::{Engine, SelectionSpan, SelectionType, Side};

// ===========================================================================
// Char selection — the tracer bullet
// ===========================================================================

/// A char selection over one printed line copies exactly that text. Side::Left
/// at the start includes the start cell; Side::Right at the end includes the end
/// cell — so `[0,0)L .. (0,4)R` over "hello" is the whole word.
#[test]
fn char_select_one_line() {
    let mut term = Engine::new(80, 24);
    term.feed(b"hello");

    term.selection_begin(0, 0, Side::Left, SelectionType::Char);
    term.selection_extend(0, 4, Side::Right);

    assert_eq!(term.selection_text().as_deref(), Some("hello"));
}

/// A char selection across a hard line-end joins with `\n` and trims each line's
/// trailing blanks. "ab" / "cd" on two rows → "ab\ncd", not "ab<76 spaces>\ncd".
#[test]
fn char_select_multi_line_trims_and_joins_with_newline() {
    let mut term = Engine::new(80, 24);
    term.feed(b"ab\r\ncd");

    term.selection_begin(0, 0, Side::Left, SelectionType::Char);
    term.selection_extend(1, 1, Side::Right);

    assert_eq!(term.selection_text().as_deref(), Some("ab\ncd"));
}

/// A soft wrap (WRAPLINE) joins with no break — and spaces that sit at the wrap
/// boundary are real content, not trailing blanks, so they survive. "ab  " fills
/// a width-4 row and wraps into "cd"; the logical line is "ab  cd". Per-row
/// trimming would wrongly yield "abcd" — trimming is a logical-line-end concern.
#[test]
fn char_select_soft_wrap_joins_and_keeps_interior_spaces() {
    let mut term = Engine::new(4, 4);
    term.feed(b"ab  cd");

    term.selection_begin(0, 0, Side::Left, SelectionType::Char);
    term.selection_extend(1, 1, Side::Right);

    assert_eq!(term.selection_text().as_deref(), Some("ab  cd"));
}

/// A selection can span the scrollback→screen boundary. With "L0".."L3" fed to a
/// 2-row screen, "L0"/"L1" are in scrollback and "L2"/"L3" on screen. Scrolled up
/// by one, viewport row 0 = "L1" (history) and row 1 = "L2" (screen); selecting
/// both copies "L1\nL2" — the absolute coordinate bridges the two stores.
#[test]
fn char_select_across_scrollback_boundary() {
    let mut term = Engine::new(4, 2);
    term.feed(b"L0\r\nL1\r\nL2\r\nL3");
    term.scroll_up(1);

    term.selection_begin(0, 0, Side::Left, SelectionType::Char);
    term.selection_extend(1, 1, Side::Right);

    assert_eq!(term.selection_text().as_deref(), Some("L1\nL2"));
}

/// The anchor side decides cell inclusion: Right at the start excludes that cell,
/// Left at the end excludes it. And a degenerate range (the two sides invert the
/// same cell) is empty, never a panic.
#[test]
fn selection_side_excludes_and_degenerate_is_empty() {
    let mut term = Engine::new(80, 24);
    term.feed(b"hello");

    term.selection_begin(0, 0, Side::Right, SelectionType::Char);
    term.selection_extend(0, 4, Side::Left);
    assert_eq!(term.selection_text().as_deref(), Some("ell")); // 'h', 'o' excluded

    // Sides invert the same cell → empty (must not index-panic).
    term.selection_begin(0, 2, Side::Right, SelectionType::Char);
    term.selection_extend(0, 2, Side::Left);
    assert_eq!(term.selection_text().as_deref(), Some(""));
}

/// A Word selection snaps to word boundaries (double-click). Clicking inside
/// "bar" selects the whole word, stopping at the surrounding spaces.
#[test]
fn word_select_expands_to_word_boundaries() {
    let mut term = Engine::new(80, 24);
    term.feed(b"foo bar baz");

    term.selection_begin(0, 5, Side::Left, SelectionType::Word); // col 5 = 'a' in "bar"

    assert_eq!(term.selection_text().as_deref(), Some("bar"));
}

/// A Word selection follows a word across a soft wrap. "abcdef" fills a width-4
/// row ("abcd") and wraps ("ef"); double-clicking the "ef" half selects the
/// whole logical word "abcdef".
#[test]
fn word_select_crosses_soft_wrap() {
    let mut term = Engine::new(4, 4);
    term.feed(b"abcdef");

    term.selection_begin(1, 0, Side::Left, SelectionType::Word); // 'e' on the wrapped row

    assert_eq!(term.selection_text().as_deref(), Some("abcdef"));
}

/// A Word selection on the alt screen must NOT cross into *primary* scrollback. The
/// alt buffer is separate, but justerm stores `[scrollback ++ grid]` as one linear
/// buffer and the last primary scrollback row can carry WRAPLINE. Without the
/// `on_alt` floor, `word_start`'s `prev_pos` up-walk treated that primary row as a
/// soft-wrap continuation of alt row 0 and swallowed it ("abcdeWORD"). Same family
/// as `search()` (#144) and `viewport_logical_lines` (#113). Regression for #207.
#[test]
fn word_select_on_alt_does_not_cross_into_primary_scrollback() {
    let mut term = Engine::new(5, 2);
    term.feed(b"abcdefghijklmno"); // primary: "abcde"(WRAPLINE) evicts to scrollback
    term.feed(b"\x1b[?1049h\x1b[H"); // enter alt (separate buffer), home cursor
    term.feed(b"WORD"); // alt row 0 = "WORD"

    term.selection_begin(0, 0, Side::Left, SelectionType::Word); // double-click alt "WORD"

    // Only the alt word — NOT "abcdeWORD" (the primary scrollback WRAPLINE row).
    assert_eq!(term.selection_text().as_deref(), Some("WORD"));
}

/// A Line selection takes whole lines regardless of the click column, trimmed.
#[test]
fn line_select_takes_whole_lines() {
    let mut term = Engine::new(80, 24);
    term.feed(b"hello world");

    term.selection_begin(0, 3, Side::Left, SelectionType::Line);

    assert_eq!(term.selection_text().as_deref(), Some("hello world"));
}

/// A Block selection is rectangular: the same column range on each row, joined
/// by newlines, each row trimmed. Columns 1..=2 over "abcd"/"efgh" → "bc\nfg".
#[test]
fn block_select_is_rectangular() {
    let mut term = Engine::new(80, 24);
    term.feed(b"abcd\r\nefgh");

    term.selection_begin(0, 1, Side::Left, SelectionType::Block);
    term.selection_extend(1, 2, Side::Right);

    assert_eq!(term.selection_text().as_deref(), Some("bc\nfg"));
}

/// A wide glyph occupies two cells (lead + spacer); copying a selection over it
/// emits the glyph once — the WIDE_CHAR_SPACER cell is skipped, not turned into a
/// stray blank.
#[test]
fn selection_skips_wide_char_spacer() {
    let mut term = Engine::new(80, 24);
    term.feed("한국".as_bytes()); // two width-2 glyphs → cols 0..=3

    term.selection_begin(0, 0, Side::Left, SelectionType::Char);
    term.selection_extend(0, 3, Side::Right); // through both glyphs incl. spacers

    assert_eq!(term.selection_text().as_deref(), Some("한국"));
}

/// A Word selection over a CJK run selects the whole word from ANY column in it. A
/// wide glyph's trailing spacer is a space, so the cell-by-cell walk used to stop
/// *inside* the glyph and cut every CJK word at its first character (#535). The walk
/// must pass through a trailing wide spacer, the way it already passes through the
/// wrap artefact — ADR-0025 D2: reads gate on the spacer marker uniformly.
#[test]
fn word_select_expands_over_a_cjk_word() {
    let mut term = Engine::new(80, 24);
    term.feed("한글날".as_bytes()); // three width-2 glyphs → cols 0..=5

    // From the first glyph's lead...
    term.selection_begin(0, 0, Side::Left, SelectionType::Word);
    assert_eq!(term.selection_text().as_deref(), Some("한글날"));

    // ...and from a middle glyph's lead — the whole word, not just its tail.
    term.selection_begin(0, 2, Side::Left, SelectionType::Word); // lead of 글
    assert_eq!(term.selection_text().as_deref(), Some("한글날"));
}

/// A word mixing ASCII and CJK ("ab한글cd") has no boundary inside it, so a
/// double-click anywhere selects the whole run — the trailing spacers between the
/// wide glyphs must not end it. (The pre-fix walk cut it to "ab한".)
#[test]
fn word_select_spans_a_mixed_ascii_cjk_word() {
    let mut term = Engine::new(80, 24);
    term.feed("ab한글cd".as_bytes());

    term.selection_begin(0, 0, Side::Left, SelectionType::Word);

    assert_eq!(term.selection_text().as_deref(), Some("ab한글cd"));
}

/// The Word selection's *range* covers both cells of every wide glyph, so the
/// highlight does not bisect one (#535 acceptance: assert the range, not only the
/// text). "한글날" occupies cols 0..=5 inclusive.
#[test]
fn word_select_range_spans_full_wide_glyphs() {
    let mut term = Engine::new(80, 24);
    term.feed("한글날".as_bytes());

    term.selection_begin(0, 0, Side::Left, SelectionType::Word);

    assert_eq!(
        term.selection_range(),
        vec![SelectionSpan {
            row: 0,
            left: 0,
            right: 5
        }]
    );
}

/// A trailing spacer has no character of its own — its meaning is its **lead's** (ADR-0025
/// D4: the pair reads as one unit). So the walk must not step onto the spacer of a lead that
/// is *itself* a word boundary. U+3000 IDEOGRAPHIC SPACE is the trigger: the one
/// East-Asian-Wide char `is_whitespace()` accepts, and ordinary in CJK prose. Treating its
/// spacer as transparent starts the highlight on the space's right half, bisecting the pair —
/// the exact defect this feature's range assertion exists to prevent.
#[test]
fn word_select_does_not_start_on_a_wide_boundary_glyphs_spacer() {
    let mut term = Engine::new(10, 3);
    term.feed("\u{3000}abc".as_bytes()); //   fills cols 0..=1; "abc" is cols 2..=4

    term.selection_begin(0, 2, Side::Left, SelectionType::Word); // double-click 'a'

    assert_eq!(term.selection_text().as_deref(), Some("abc"));
    assert_eq!(
        term.selection_range(),
        vec![SelectionSpan {
            row: 0,
            left: 2,
            right: 4
        }],
        "the highlight starts at 'a', not on the ideographic space's second cell"
    );
}

/// A trailing spacer that has **no wide lead** continues nothing, so it must end a word. A
/// spacer only *stands for* a glyph; taking it at its word merges the runs on either side —
/// the same clipboard corruption `is_wrap_artefact` guards for the leading kind. The orphan
/// state is #529's (a width promotion under mode 2027 strands a lead-less `C_SPACER`); the
/// assertion holds either way, since with #529 fixed there is simply no orphan to cross.
#[test]
fn word_select_stops_at_a_lead_less_orphan_spacer() {
    let mut term = Engine::new(6, 4);
    term.feed(b"\x1b[?2027h"); // grapheme clustering: width promotion can strand a spacer
    term.feed(b"\x1b[2;2H");
    term.feed("".as_bytes());
    term.feed(b"\x1b[2;4Hxy");
    term.feed(b"\x1b[1;6H");
    term.feed("\u{25B6}\u{FE0F}".as_bytes()); // ▶️ promoted to wide; row 1 gains an orphan spacer

    term.selection_begin(1, 0, Side::Left, SelectionType::Word);

    assert_eq!(
        term.selection_text().as_deref(),
        Some("\u{25B6}\u{FE0F}"),
        "the walk stops at the orphan spacer — it must not swallow the following word"
    );
}

/// The trailing-spacer fix must NOT make a *leading* spacer transparent everywhere:
/// the wrap artefact is only an artefact at the last column of a wrapped row, and a
/// marker a row-shift (DCH) carried inward must still end a word (#528/#532 position
/// rule). On a 6-col screen "abcde" + 한 wraps 한, leaving col 5 a leading-spacer
/// artefact; DCH 2 shifts it to col 3 mid-row. A double-click on "cde" stops there —
/// it does not swallow the wrapped 한 on the next row. Holds whether or not #534 has
/// cleared the stale marker: a plain blank ends the word just the same.
#[test]
fn word_select_stops_at_a_mid_row_wrap_marker() {
    let mut term = Engine::new(6, 4);
    term.feed("abcde".as_bytes());
    term.feed("".as_bytes()); // 한 cannot fit in col 5 → wraps; col 5 = leading-spacer artefact
    term.feed(b"\x1b[1;1H\x1b[2P"); // DCH 2 at row 0 → "cde", the marker migrates to col 3

    term.selection_begin(0, 0, Side::Left, SelectionType::Word); // double-click "c"

    // The word is "cde"; the walk must stop *at* the stale marker (col 3), not pass
    // through it. Assert the RANGE — the text alone cannot tell the two apart, because
    // the marker cell is a blank that text extraction drops either way (right=2 here vs
    // right=3 if the marker were wrongly treated as transparent).
    assert_eq!(term.selection_text().as_deref(), Some("cde"));
    assert_eq!(
        term.selection_range(),
        vec![SelectionSpan {
            row: 0,
            left: 0,
            right: 2
        }]
    );
}

// ===========================================================================
// The word-boundary set is injected policy (#545, ADR-0017)
// ===========================================================================

/// A **non-breaking** space does not end a word. The predicate used to be
/// `char::is_whitespace()`, the Unicode `White_Space` property, which accepts every
/// space-like codepoint — so `1<NNBSP>234`, an ordinary locale-formatted number,
/// double-clicked as `1`. Breaking a word on a space whose entire purpose is "do not
/// break here" is backwards, and all three references treat these as word characters
/// because their sets are literal (alacritty `SEMANTIC_ESCAPE_CHARS` `term/mod.rs:45`,
/// ghostty `selection_codepoints.zig:7-28`, xterm.js `OptionsService.ts:55`).
#[test]
fn the_default_set_does_not_break_a_word_at_a_non_breaking_space() {
    // U+00A0 NBSP, U+2007 FIGURE SPACE, U+202F NNBSP, U+205F MEDIUM MATHEMATICAL
    // SPACE — the four Unicode `Line_Break=GL` (glue) codepoints.
    for sep in ['\u{00A0}', '\u{2007}', '\u{202F}', '\u{205F}'] {
        let mut term = Engine::new(80, 24);
        let text = format!("ab{sep}cd");
        term.feed(text.as_bytes());

        term.selection_begin(0, 0, Side::Left, SelectionType::Word);

        assert_eq!(
            term.selection_text().as_deref(),
            Some(text.as_str()),
            "U+{:04X} is glue, so it joins the run instead of ending it",
            sep as u32
        );
    }
}

/// The ASCII space still ends a word — and it is the *control* for the test above, not a
/// restatement of `word_select_expands_to_word_boundaries`: the two flank the one
/// distinction the default draws inside `char::is_whitespace()`'s old set.
#[test]
fn the_default_set_still_breaks_a_word_at_an_ascii_space() {
    let mut term = Engine::new(80, 24);
    term.feed("ab cd".as_bytes());

    term.selection_begin(0, 0, Side::Left, SelectionType::Word);

    assert_eq!(term.selection_text().as_deref(), Some("ab"));
}

/// U+3000 IDEOGRAPHIC SPACE stays a boundary, and that is justerm's one deliberate
/// divergence from all three references — none of their default sets carries it, so on
/// alacritty and xterm.js ` abc` is a single word. It is the only East-Asian-Wide char
/// `White_Space` accepts (measured over U+0000–U+10FFFF), i.e. the only one whose
/// presence in the set is load-bearing for CJK double-click. #535 kept it *on the
/// grounds that core had no injection point*; now that it has one, the same answer is
/// re-stated as a default the consumer may override rather than as a property of the
/// predicate (ADR-0025's #535 amendment asks for exactly this restatement).
#[test]
fn the_default_set_breaks_a_word_at_an_ideographic_space() {
    let mut term = Engine::new(10, 3);
    term.feed("\u{3000}".as_bytes());

    term.selection_begin(0, 0, Side::Left, SelectionType::Word);

    assert_eq!(term.selection_text().as_deref(), Some(""));
}

/// The same fix, driven by bytes a real program really emitted — the reach #545 filed as
/// *unmeasured*, measured. On the RHEL 9 box, `printf "%'d" 1234567` under **fr_FR.utf8,
/// cs_CZ.utf8 and ru_RU.utf8** writes `31 e2 80 af 32 33 34 e2 80 af 35 36 37`, i.e. glibc
/// groups thousands with **U+202F NARROW NO-BREAK SPACE**; de_DE.utf8 groups with `.` and
/// is the control. So this is not a Unicode curiosity reachable only by a crafted feed: it
/// is the default output of a standard formatting directive, and before this change
/// double-clicking such a number selected the single digit `1`.
///
/// The literal below is that captured byte sequence, kept as bytes rather than as a
/// `format!` over `'\u{202F}'` so the test cannot drift from what was recorded.
#[test]
fn a_locale_grouped_number_from_a_real_printf_selects_whole() {
    let mut term = Engine::new(80, 24);
    term.feed(b"\x31\xe2\x80\xaf\x32\x33\x34\xe2\x80\xaf\x35\x36\x37");

    term.selection_begin(0, 0, Side::Left, SelectionType::Word);

    assert_eq!(
        term.selection_text().as_deref(),
        Some("1\u{202F}234\u{202F}567")
    );
}

/// The consumer replaces the set, and the walk obeys it. This is the whole point of
/// #545 — ADR-0017 routes *which characters separate words* to the consumer (mechanism
/// in core, policy injected), and all three references expose it as a user knob
/// (`semantic_escape_chars` / `selection-word-chars` / `wordSeparator`).
#[test]
fn a_consumer_supplied_separator_set_replaces_the_default() {
    let mut term = Engine::new(80, 24);
    term.feed("a.b c".as_bytes());

    // '.' is deliberately absent from the default (a path stays one word); a consumer
    // that wants prose semantics adds it.
    term.set_word_separators(". ");
    term.selection_begin(0, 0, Side::Left, SelectionType::Word);

    assert_eq!(term.selection_text().as_deref(), Some("a"));
    assert_eq!(term.word_separators(), ". ");
}

/// **The blank cell's character is always a boundary, whatever the consumer supplies.**
/// A row's unwritten padding packs `' '` (`Cell::default`), so `' '` does double duty
/// that nothing else covers: it terminates the walk at the end of written text, and it
/// is the backstop that stops the walk stepping onto the trailing spacer of a *wide*
/// separator. Drop it and both fail at once — measured: `" abc"` double-clicked on
/// 'a' starts the highlight at col 1, on the ideographic space's second cell, and then
/// runs to the row's end because the padding no longer stops it.
///
/// So the setter forces `' '` in rather than trusting the caller, which is ghostty's
/// design one codepoint over: it prepends its own blank (NUL) to every parsed set at
/// the config intake (`src/config/Config.zig:6160-6161`, *"Always include null as first
/// boundary"*) rather than special-casing it in `selectWord`. Enforcing at intake means
/// the walk never has to remember.
#[test]
fn an_injected_set_always_keeps_the_blank_cell_a_boundary() {
    let mut term = Engine::new(10, 3);
    term.feed("\u{3000}abc".as_bytes()); //   fills cols 0..=1; "abc" is cols 2..=4

    // A set with no space in it at all — legal input under every reference's knob.
    term.set_word_separators("\u{3000}");
    term.selection_begin(0, 2, Side::Left, SelectionType::Word);

    // The behaviour first, the mechanism second: the range is what the floor exists to
    // protect, and asserting `contains(' ')` ahead of it would short-circuit the test
    // before it ever measured the defect.
    assert_eq!(
        term.selection_range(),
        vec![SelectionSpan {
            row: 0,
            left: 2,
            right: 4
        }],
        "without the floor this starts at col 1 — on the ideographic space's second cell"
    );
    assert_eq!(term.selection_text().as_deref(), Some("abc"));
    assert!(
        term.word_separators().contains(' '),
        "the setter forces the blank cell's char in"
    );
}

/// RIS (`ESC c`) resets the *terminal*, not the consumer's configuration, so the
/// injected set survives it. This is not a free property here: `full_reset` rebuilds
/// `Term` wholesale (`*self = Term::with_scrollback(..)`) and carries only an explicit
/// short list across, so a field added without touching it is silently reverted the
/// first time an application runs `reset`. All three references are immune by
/// construction instead — alacritty's `reset_state` never touches `self.config`,
/// xterm.js keeps it in `OptionsService`, ghostty passes the set in per call — so none
/// of them needs a test like this and justerm does.
#[test]
fn a_full_reset_keeps_the_consumer_supplied_separator_set() {
    let mut term = Engine::new(80, 24);
    term.set_word_separators(". ");

    term.feed(b"\x1bc"); // RIS
    term.feed("a.b".as_bytes());
    term.selection_begin(0, 0, Side::Left, SelectionType::Word);

    // In force first, stored second — the reverse order reports "the field was reset"
    // where the defect is "the walk went back to the built-in set".
    assert_eq!(
        term.selection_text().as_deref(),
        Some("a"),
        "the set is still in force after RIS, not just still stored"
    );
    assert_eq!(term.word_separators(), ". ");
}

// ===========================================================================
// selection_range — viewport line spans for highlight (option (a))
// ===========================================================================

/// `selection_range` reports one span per visible row, columns inclusive.
#[test]
fn selection_range_reports_viewport_spans() {
    let mut term = Engine::new(80, 24);
    term.feed(b"hello");

    term.selection_begin(0, 0, Side::Left, SelectionType::Char);
    term.selection_extend(0, 4, Side::Right);

    assert_eq!(
        term.selection_range(),
        vec![SelectionSpan {
            row: 0,
            left: 0,
            right: 4
        }]
    );
}

/// While scrolled, `selection_range` maps absolute lines to viewport rows and
/// drops the parts scrolled off-screen. A Line selection over abs lines 2..=4 on
/// a 3-row screen: at the bottom it fills rows 0..2; scrolled up by one it shifts
/// to rows 1..2 and the third line (now below the viewport) is dropped.
#[test]
fn selection_range_clips_to_viewport_when_scrolled() {
    let mut term = Engine::new(4, 3);
    term.feed(b"L0\r\nL1\r\nL2\r\nL3\r\nL4"); // sb=[L0,L1], screen=[L2,L3,L4]

    term.selection_begin(0, 0, Side::Left, SelectionType::Line); // abs 2
    term.selection_extend(2, 0, Side::Left); // abs 4

    assert_eq!(
        term.selection_range(),
        vec![
            SelectionSpan {
                row: 0,
                left: 0,
                right: 3
            },
            SelectionSpan {
                row: 1,
                left: 0,
                right: 3
            },
            SelectionSpan {
                row: 2,
                left: 0,
                right: 3
            },
        ]
    );

    term.scroll_up(1); // viewport now abs 1..=3; selection abs 2..=4
    assert_eq!(
        term.selection_range(),
        vec![
            SelectionSpan {
                row: 1,
                left: 0,
                right: 3
            }, // abs 2
            SelectionSpan {
                row: 2,
                left: 0,
                right: 3
            }, // abs 3
               // abs 4 is now below the viewport → dropped
        ]
    );
}

// ===========================================================================
// Coordinate stability — the risky absolute-coordinate cases
// ===========================================================================

/// When the scrollback cap evicts the oldest line, every absolute index shifts
/// down by one — the selection anchors must follow so they keep pointing at the
/// same content. With a 2-line cap, selecting "L2" then pushing one more line
/// (which evicts "L0") must still copy "L2", not the line that slid into its old
/// absolute slot.
#[test]
fn selection_follows_cap_eviction() {
    let mut term = Engine::with_scrollback(4, 2, 2); // cap = 2 scrollback lines
    term.feed(b"L0\r\nL1\r\nL2\r\nL3"); // sb=[L0,L1], screen=[L2,L3]

    // Select "L2" (screen row 0, abs 2) as a whole line.
    term.selection_begin(0, 0, Side::Left, SelectionType::Line);
    assert_eq!(term.selection_text().as_deref(), Some("L2"));

    // One more line: scroll pushes "L2" to history and the cap evicts "L0",
    // shifting all absolute indices down by one.
    term.feed(b"\r\nL4"); // sb=[L1,L2], screen=[L3,L4]

    assert_eq!(term.selection_text().as_deref(), Some("L2"));
}

/// A scroll-region scroll (top margin > 0) moves content *within* the screen, so
/// absolute indices in the region shift — unlike a top-anchored scroll, which is
/// absorbed by scrollback growth. The selection must rotate with the content.
/// Region rows 2..=4, screen "A/B/C/D"; select "C"; a region scroll slides "C"
/// up one row, and the selection still copies "C".
#[test]
fn selection_rotates_with_region_scroll() {
    let mut term = Engine::new(4, 4);
    term.feed(b"\x1b[2;4r"); // DECSTBM: region rows 2..4 (0-based 1..=3), homes cursor
    term.feed(b"A\r\nB\r\nC\r\nD"); // rows 0=A,1=B,2=C,3=D

    term.selection_begin(2, 0, Side::Left, SelectionType::Line); // "C" (abs 2)
    assert_eq!(term.selection_text().as_deref(), Some("C"));

    term.feed(b"\r\n"); // line-feed at the bottom margin → region scrolls up

    assert_eq!(term.selection_text().as_deref(), Some("C"));
}

/// Reverse index (RI) at the top margin scrolls the region *down*; the selection
/// rotates the other way. Same region; select "C"; RI slides it down one row.
#[test]
fn selection_rotates_with_reverse_index() {
    let mut term = Engine::new(4, 4);
    term.feed(b"\x1b[2;4r");
    term.feed(b"A\r\nB\r\nC\r\nD"); // rows 0=A,1=B,2=C,3=D
    term.feed(b"\x1b[2;1H"); // cursor to the region top (0-based row 1)

    term.selection_begin(2, 0, Side::Left, SelectionType::Line); // "C" (abs 2)
    assert_eq!(term.selection_text().as_deref(), Some("C"));

    term.feed(b"\x1bM"); // RI at top margin → region scrolls down

    assert_eq!(term.selection_text().as_deref(), Some("C"));
}

/// A column resize reflows soft-wrapped lines, moving content's absolute
/// coordinates — the selection anchors must reflow with it. "abcdef" on one
/// width-6 row, fully selected, then narrowed to width 3 (wrapping into
/// "abc"/"def"): the selection still copies "abcdef".
#[test]
fn selection_survives_reflow_on_resize() {
    let mut term = Engine::new(6, 4);
    term.feed(b"abcdef"); // row 0 = "abcdef"

    term.selection_begin(0, 0, Side::Left, SelectionType::Char);
    term.selection_extend(0, 5, Side::Right);
    assert_eq!(term.selection_text().as_deref(), Some("abcdef"));

    term.resize(3, 4); // narrow → "abc"(wrap)/"def"

    assert_eq!(term.selection_text().as_deref(), Some("abcdef"));
}

/// The selection belongs to the primary screen; switching to/from the alt screen
/// clears it (it can't meaningfully survive a screen swap).
#[test]
fn selection_clears_on_alt_screen_switch() {
    let mut term = Engine::new(80, 24);
    term.feed(b"hello");

    term.selection_begin(0, 0, Side::Left, SelectionType::Char);
    term.selection_extend(0, 4, Side::Right);
    assert_eq!(term.selection_text().as_deref(), Some("hello"));

    term.feed(b"\x1b[?1049h"); // enter alt screen
    assert_eq!(term.selection_text(), None);

    // A selection made on the alt screen is dropped when leaving it.
    term.feed(b"\x1b[Hworld"); // home the cursor (alt enter doesn't), then print
    term.selection_begin(0, 0, Side::Left, SelectionType::Char);
    term.selection_extend(0, 4, Side::Right);
    assert_eq!(term.selection_text().as_deref(), Some("world"));

    term.feed(b"\x1b[?1049l"); // leave alt screen
    assert_eq!(term.selection_text(), None);
}