justerm-core 0.7.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
//! The cell — one character position in the grid (see CONTEXT.md "Cell").

use crate::color::Color;

bitflags::bitflags! {
    /// Per-cell flags: the standard SGR attributes plus layout markers.
    ///
    /// The high bits are intentionally left free so underline-style + underline
    /// colour and an OSC 8 hyperlink id can be added later without a format
    /// change (see `docs/architecture.md` "Cell").
    #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
    pub struct CellFlags: u16 {
        // --- standard SGR attributes ---
        const BOLD          = 1 << 0;
        const DIM           = 1 << 1;
        const ITALIC        = 1 << 2;
        const UNDERLINE     = 1 << 3;
        const BLINK         = 1 << 4;
        const INVERSE       = 1 << 5;
        const HIDDEN        = 1 << 6;
        const STRIKETHROUGH = 1 << 7;

        // --- layout markers (not SGR): a width-2 glyph occupies two cells ---
        /// The first cell of a width-2 glyph; holds the actual character.
        const WIDE_CHAR        = 1 << 8;
        /// The trailing cell of a width-2 glyph. A distinct marker, *not* a
        /// plain blank — overwrite, erase, selection, and cursor positioning all
        /// depend on knowing this column belongs to the wide char to its left.
        const WIDE_CHAR_SPACER = 1 << 9;
        /// Set on the last cell of a row that soft-wrapped (auto-wrap) into the
        /// next — distinguishes a soft wrap from a hard CR/LF line-end so reflow
        /// (#7) can merge and re-split logical lines.
        const WRAPLINE = 1 << 10;
        // bits 11..=15 reserved (underline style/colour, hyperlink id).
    }
}

// --- packed bit layout (#44) ----------------------------------------------
//
// Three 32-bit words mirroring xterm.js's `BufferLine` cell (verified against
// `xtermjs/xterm.js@master` `src/common/buffer/Constants.ts`). The fg/bg colour
// words are byte-identical to xterm's `Attributes` + `FgFlags`/`BgFlags`; the
// content word keeps justerm's explicit layout-marker flags where xterm stores a
// 2-bit `wcwidth` value (justerm's model is flag-based — the spacer and per-cell
// WRAPLINE are load-bearing for overwrite/selection/reflow).
//
//   content u32: codepoint(21) | COMBINED(1) | WIDE | SPACER | WRAP | reserved
//   fg/bg   u32: colour value(24) | colour mode(2) | flags(6)
//
// COMBINED_PRESENT (content) and LINK_PRESENT (bg, xterm's HAS_EXTENDED slot) are
// both live: combining clusters (#45) and OSC 8 hyperlink indices (#46) live in
// per-row, column-keyed maps, and these bits gate every read of them. The cell is
// now pure packed words — three u32, no `Option` field (the epic's 12 B target).

const CODEPOINT_MASK: u32 = 0x001F_FFFF; // bits 0..21
const C_COMBINED: u32 = 1 << 21; // a combining cluster lives in the row's map at this column (#45)
const C_WIDE: u32 = 1 << 22;
const C_SPACER: u32 = 1 << 23;
const C_WRAP: u32 = 1 << 24;
// The vacated column left when a width-2 glyph wraps off the right edge (#113):
// a blank that holds no character but isn't a hard line-end. Unlike C_SPACER it
// has *no wide lead to its left*, so the overwrite/erase repair paths (which key
// off C_SPACER) must not treat it as one — it's a separate marker the text
// extractors skip. Engine-internal: it stays in the content word and never
// reaches `flags()` / the wire (a frame-mode consumer gets the already-correct
// text, and the cell renders as the blank it is).
const C_LEADING_SPACER: u32 = 1 << 25;
const CONTENT_MARKER_MASK: u32 = C_WIDE | C_SPACER | C_WRAP;

const COLOR_VALUE_MASK: u32 = 0x00FF_FFFF; // bits 0..24
const COLOR_MODE_SHIFT: u32 = 24; // bits 24..26
const CM_DEFAULT: u32 = 0;
const CM_INDEXED: u32 = 1;
const CM_RGB: u32 = 2;

// fg flags, bits 26..32 — xterm FgFlags order (HIDDEN == xterm INVISIBLE).
const FG_INVERSE: u32 = 1 << 26;
const FG_BOLD: u32 = 1 << 27;
const FG_UNDERLINE: u32 = 1 << 28;
const FG_BLINK: u32 = 1 << 29;
const FG_HIDDEN: u32 = 1 << 30;
const FG_STRIKE: u32 = 1 << 31;
const FG_FLAG_MASK: u32 = FG_INVERSE | FG_BOLD | FG_UNDERLINE | FG_BLINK | FG_HIDDEN | FG_STRIKE;

// bg flags, bits 26..28 — xterm BgFlags order.
const BG_ITALIC: u32 = 1 << 26;
const BG_DIM: u32 = 1 << 27;
// LINK_PRESENT: an OSC 8 hyperlink index lives in the row's link map at this
// column (#46). Reuses xterm's `BgFlags.HAS_EXTENDED = 0x10000000` (bit 28)
// exactly — in xterm this is a *shared* "extended attrs present" gate (link +
// underline colour); justerm models only the link, so it is link-only here and
// would widen to an extended-attrs map if underline colour is ever added.
const BG_LINK: u32 = 1 << 28;
const BG_FLAG_MASK: u32 = BG_ITALIC | BG_DIM;

/// Pack a colour reference into the low 26 bits of a colour word (mode + value);
/// the high 6 bits are left for the SGR flags.
fn pack_color(c: Color) -> u32 {
    match c {
        Color::Default => CM_DEFAULT << COLOR_MODE_SHIFT,
        Color::Indexed(i) => (CM_INDEXED << COLOR_MODE_SHIFT) | i as u32,
        Color::Rgb(r, g, b) => {
            (CM_RGB << COLOR_MODE_SHIFT) | (r as u32) << 16 | (g as u32) << 8 | b as u32
        }
    }
}

/// Inverse of [`pack_color`] — reads only the mode + value bits, ignoring the
/// flag bits that share the word.
fn unpack_color(w: u32) -> Color {
    match (w >> COLOR_MODE_SHIFT) & 0b11 {
        CM_INDEXED => Color::Indexed((w & 0xFF) as u8),
        CM_RGB => Color::Rgb((w >> 16) as u8, (w >> 8) as u8, w as u8),
        _ => Color::Default, // CM_DEFAULT (and the unused mode 3) resolve to Default
    }
}

/// Scatter a `CellFlags` bit set (as a `u32`) into the three words' flag-bit
/// positions: `(content_markers, fg_flags, bg_flags)`. Branchless — each group is
/// masked and shifted in one step. The `CellFlags` bit values are frozen by the
/// wire format (`serialize` encodes `flags().bits()`), so the source positions are
/// fixed; see the shift comments. One place for store / insert / remove to share.
#[inline]
fn flag_words(f: u32) -> (u32, u32, u32) {
    let content = (f & 0x0700) << 14; // WIDE/SPACER/WRAP bits 8,9,10 -> 22,23,24
    let fg = ((f & 0x0001) << 27)     // BOLD     bit 0  -> 27
        | ((f & 0x0020) << 21)        // INVERSE  bit 5  -> 26
        | ((f & 0x0018) << 25)        // UNDERLINE/BLINK bits 3,4 -> 28,29
        | ((f & 0x00C0) << 24); // HIDDEN/STRIKE   bits 6,7 -> 30,31
    let bg = ((f & 0x0004) << 24)     // ITALIC bit 2 -> 26
        | ((f & 0x0002) << 26); // DIM    bit 1 -> 27
    (content, fg, bg)
}

/// One character position: a base glyph, fg/bg colour references, and flags.
/// Combining marks (#45) and an OSC 8 hyperlink (#46) attach via per-row maps,
/// signalled by the `COMBINED_PRESENT` / `LINK_PRESENT` bits — the cell itself is
/// three packed words, no `Option` field. All access is through the accessor seam
/// (#44); construct with [`Cell::from_parts`] or [`Cell::default`].
///
/// `Eq` is a derived bitwise compare, which is exact because the packing is
/// canonical — every logical cell maps to one bit pattern (unused bits stay 0).
#[derive(Clone, Copy, PartialEq, Eq)]
pub struct Cell {
    content: u32,
    fg: u32,
    bg: u32,
}

impl Default for Cell {
    fn default() -> Self {
        // The packed form of a blank cell: ' ' (U+0020) in the codepoint field,
        // every other word zero (Default colours, no flags, no combining/link
        // bits). Built directly rather than through `from_parts` so scroll/erase
        // blanking — which constructs defaults by the rowful — stays a cheap copy.
        Cell {
            content: ' ' as u32,
            fg: 0,
            bg: 0,
        }
    }
}

impl core::fmt::Debug for Cell {
    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
        f.debug_struct("Cell")
            .field("c", &self.c())
            .field("fg", &self.fg())
            .field("bg", &self.bg())
            .field("flags", &self.flags())
            .field("combined", &self.is_combined())
            .field("linked", &self.is_linked())
            .finish()
    }
}

impl Cell {
    /// Assemble a cell from its logical parts. The single construction seam —
    /// `Pen::cell` and the wire decoder funnel through here, so the bit-packing
    /// lives in exactly one place (#44).
    pub fn from_parts(c: char, fg: Color, bg: Color, flags: CellFlags) -> Self {
        let mut cell = Cell {
            content: c as u32, // a `char` is <= U+10FFFF, so it fits the 21-bit field
            fg: pack_color(fg),
            bg: pack_color(bg),
        };
        cell.store_flags(flags);
        cell
    }

    /// Replace the flag bits across the three words from `flags`, preserving the
    /// codepoint, colours, and the dormant presence bits. The inverse is
    /// [`Cell::flags`].
    fn store_flags(&mut self, flags: CellFlags) {
        let (content, fg, bg) = flag_words(flags.bits() as u32);
        self.content = (self.content & !CONTENT_MARKER_MASK) | content;
        self.fg = (self.fg & !FG_FLAG_MASK) | fg;
        self.bg = (self.bg & !BG_FLAG_MASK) | bg;
    }

    /// The base code point.
    pub fn c(&self) -> char {
        char::from_u32(self.content & CODEPOINT_MASK)
            .expect("codepoint bits always hold a valid char")
    }

    /// The foreground colour reference.
    pub fn fg(&self) -> Color {
        unpack_color(self.fg)
    }

    /// The background colour reference.
    pub fn bg(&self) -> Color {
        unpack_color(self.bg)
    }

    /// The cell's flags (SGR attributes + layout markers), reassembled from the
    /// three words — the branchless inverse of [`Cell::store_flags`].
    pub fn flags(&self) -> CellFlags {
        let bits = ((self.content & CONTENT_MARKER_MASK) >> 14)         // 22,23,24 -> 8,9,10
            | ((self.fg & FG_BOLD) >> 27)                              // 27 -> 0
            | ((self.fg & FG_INVERSE) >> 21)                           // 26 -> 5
            | ((self.fg & (FG_UNDERLINE | FG_BLINK)) >> 25)            // 28,29 -> 3,4
            | ((self.fg & (FG_HIDDEN | FG_STRIKE)) >> 24)              // 30,31 -> 6,7
            | ((self.bg & BG_ITALIC) >> 24)                           // 26 -> 2
            | ((self.bg & BG_DIM) >> 26); // 27 -> 1
        CellFlags::from_bits_retain(bits as u16)
    }

    /// Does this column carry combining marks? When true, the cluster lives in
    /// the row's combining map at this column (#45) — a flag-gated cache: never
    /// read the map without first checking this bit.
    pub fn is_combined(&self) -> bool {
        self.content & C_COMBINED != 0
    }

    /// Does this column carry an OSC 8 hyperlink? When true, the hyperlink-pool
    /// index lives in the row's link map at this column (#46) — flag-gated like
    /// combining: never read the link map without first checking this bit.
    pub fn is_linked(&self) -> bool {
        self.bg & BG_LINK != 0
    }

    /// Overwrite the base code point, preserving the layout markers.
    pub fn set_c(&mut self, c: char) {
        self.content = (self.content & !CODEPOINT_MASK) | c as u32;
    }

    /// Overwrite the background colour (the BCE erase fill, #16), preserving the
    /// bg-word flag bits.
    pub fn set_bg(&mut self, bg: Color) {
        self.bg = pack_color(bg) | (self.bg & !(COLOR_VALUE_MASK | (0b11 << COLOR_MODE_SHIFT)));
    }

    /// Mark (or unmark) this column as carrying combining marks in the row map.
    pub fn set_combined(&mut self, on: bool) {
        if on {
            self.content |= C_COMBINED;
        } else {
            self.content &= !C_COMBINED;
        }
    }

    /// Mark (or unmark) this column as carrying an OSC 8 hyperlink in the row map.
    pub fn set_linked(&mut self, on: bool) {
        if on {
            self.bg |= BG_LINK;
        } else {
            self.bg &= !BG_LINK;
        }
    }

    /// Add the given flags (leaving the others set). Sets the word bits directly —
    /// no round-trip through `flags()`/`store_flags`.
    pub fn insert_flags(&mut self, flags: CellFlags) {
        let (content, fg, bg) = flag_words(flags.bits() as u32);
        self.content |= content;
        self.fg |= fg;
        self.bg |= bg;
    }

    /// Clear the given flags (leaving the others as they are).
    pub fn remove_flags(&mut self, flags: CellFlags) {
        let (content, fg, bg) = flag_words(flags.bits() as u32);
        self.content &= !content;
        self.fg &= !fg;
        self.bg &= !bg;
    }

    /// Reset to a blank default cell.
    pub fn reset(&mut self) {
        *self = Cell::default();
    }

    /// Is this the lead cell of a width-2 glyph? Direct content-bit query — the
    /// hot overwrite/erase/reflow paths use this instead of reconstructing the
    /// full `flags()` to test one marker.
    pub fn is_wide(&self) -> bool {
        self.content & C_WIDE != 0
    }

    /// Is this the trailing spacer cell of a width-2 glyph?
    pub fn is_wide_spacer(&self) -> bool {
        self.content & C_SPACER != 0
    }

    /// Is this the blank column vacated when a wide glyph wrapped off the right
    /// edge (#113)? It holds no character; unlike a trailing spacer it has no
    /// wide lead to its left, so only the *text* extractors skip it.
    pub fn is_leading_spacer(&self) -> bool {
        self.content & C_LEADING_SPACER != 0
    }

    /// Does this column hold no text — either half of a wide glyph's trailing
    /// spacer or a wide-wrap leading spacer? Used by the text extractors (search,
    /// selection text, logical lines) to skip non-character columns.
    pub fn is_spacer(&self) -> bool {
        self.content & (C_SPACER | C_LEADING_SPACER) != 0
    }

    /// Mark this (blank) column as the leading spacer of a wrapped wide glyph.
    pub fn set_leading_spacer(&mut self) {
        self.content |= C_LEADING_SPACER;
    }

    /// Did this row soft-wrap into the next (WRAPLINE on its last cell)?
    pub fn is_wrapline(&self) -> bool {
        self.content & C_WRAP != 0
    }
}

#[cfg(test)]
mod tests {
    use super::{Cell, CellFlags};
    use crate::color::Color;

    /// Size pin: slice C moves `link` out of the cell into the row's link map (a
    /// cell now signals a hyperlink with only the `LINK_PRESENT` bg bit), so `Cell`
    /// is **12 bytes** — three packed `u32` words, matching xterm.js's `BufferLine`
    /// cell. This is the epic's target (#43): combining and link both ride per-row
    /// maps, the cell is pure packed words. Flood throughput is
    /// memory-bandwidth-bound, so this size is touched on every print/scroll-blank.
    /// [#42, #46]
    #[test]
    fn cell_is_12_bytes() {
        assert_eq!(std::mem::size_of::<Cell>(), 12);
    }

    /// The packing must be lossless: every colour reference read back equal in
    /// both the fg and bg word, including the tag-distinguished trio that must not
    /// collapse (`Default` / `Indexed(0)` / `Rgb(0,0,0)`).
    #[test]
    fn every_colour_round_trips_in_both_words() {
        let colours = [
            Color::Default,
            Color::Indexed(0),
            Color::Indexed(255),
            Color::Rgb(0, 0, 0),
            Color::Rgb(255, 128, 1),
        ];
        for &fg in &colours {
            for &bg in &colours {
                let cell = Cell::from_parts('x', fg, bg, CellFlags::empty());
                assert_eq!(cell.fg(), fg, "fg {fg:?} / bg {bg:?}");
                assert_eq!(cell.bg(), bg, "fg {fg:?} / bg {bg:?}");
            }
        }
    }

    /// Every flag bit — SGR attributes (split across the fg/bg words) and the
    /// layout markers (in the content word) — round-trips, alone and combined.
    #[test]
    fn every_flag_round_trips() {
        let all = CellFlags::all();
        for bit in all.iter() {
            let cell = Cell::from_parts('x', Color::Default, Color::Default, bit);
            assert_eq!(cell.flags(), bit, "single {bit:?}");
        }
        let cell = Cell::from_parts('x', Color::Default, Color::Default, all);
        assert_eq!(cell.flags(), all, "all flags at once");
    }

    /// The codepoint occupies 21 bits — the full Unicode range, up to the
    /// maximum scalar value, survives alongside flags set in the same word.
    #[test]
    fn codepoint_round_trips_to_the_unicode_max() {
        for c in ['a', ' ', '', '🦀', '\u{10FFFF}'] {
            let cell = Cell::from_parts(c, Color::Default, Color::Default, CellFlags::WIDE_CHAR);
            assert_eq!(cell.c(), c, "codepoint {c:?}");
            assert!(cell.flags().contains(CellFlags::WIDE_CHAR));
        }
    }

    /// The combining-presence bit (content word) and link-presence bit (bg word)
    /// are independent of each other, of the codepoint/markers, of the colours, and
    /// of the SGR flags — toggling one must disturb none of the others.
    #[test]
    fn combined_and_linked_bits_are_independent() {
        let mut cell = Cell::from_parts(
            'e',
            Color::Indexed(3),
            Color::Rgb(1, 2, 3),
            CellFlags::WIDE_CHAR | CellFlags::DIM,
        );
        assert!(!cell.is_combined());
        assert!(!cell.is_linked());

        cell.set_combined(true);
        cell.set_linked(true);
        assert!(cell.is_combined() && cell.is_linked());
        // Everything else survives both bits being set.
        assert_eq!(cell.c(), 'e');
        assert_eq!(cell.fg(), Color::Indexed(3));
        assert_eq!(
            cell.bg(),
            Color::Rgb(1, 2, 3),
            "link bit shares the bg word"
        );
        assert!(cell.flags().contains(CellFlags::WIDE_CHAR | CellFlags::DIM));

        cell.set_linked(false);
        assert!(cell.is_combined() && !cell.is_linked());
        cell.set_combined(false);
        assert!(!cell.is_combined() && !cell.is_linked());
        assert_eq!(
            cell.bg(),
            Color::Rgb(1, 2, 3),
            "bg colour intact after clearing"
        );

        let spacer = Cell::from_parts(
            ' ',
            Color::Default,
            Color::Default,
            CellFlags::WIDE_CHAR_SPACER,
        );
        assert!(spacer.is_wide_spacer());
        assert!(!Cell::default().is_wide_spacer());
    }
}