justerm-core 0.12.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
//! #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
        }]
    );
}

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