Skip to main content

justerm_core/
cell.rs

1//! The cell — one character position in the grid (see CONTEXT.md "Cell").
2
3use crate::color::Color;
4
5bitflags::bitflags! {
6    /// Per-cell flags: the standard SGR attributes plus layout markers.
7    ///
8    /// The high bits are intentionally left free so underline-style + underline
9    /// colour and an OSC 8 hyperlink id can be added later without a format
10    /// change (see `docs/architecture.md` "Cell").
11    #[derive(Clone, Copy, PartialEq, Eq, Debug, Default)]
12    pub struct CellFlags: u16 {
13        // --- standard SGR attributes ---
14        const BOLD          = 1 << 0;
15        const DIM           = 1 << 1;
16        const ITALIC        = 1 << 2;
17        const UNDERLINE     = 1 << 3;
18        const BLINK         = 1 << 4;
19        const INVERSE       = 1 << 5;
20        const HIDDEN        = 1 << 6;
21        const STRIKETHROUGH = 1 << 7;
22
23        // --- layout markers (not SGR): a width-2 glyph occupies two cells ---
24        /// The first cell of a width-2 glyph; holds the actual character.
25        const WIDE_CHAR        = 1 << 8;
26        /// The trailing cell of a width-2 glyph. A distinct marker, *not* a
27        /// plain blank — overwrite, erase, selection, and cursor positioning all
28        /// depend on knowing this column belongs to the wide char to its left.
29        const WIDE_CHAR_SPACER = 1 << 9;
30        /// Set on the last cell of a row that soft-wrapped (auto-wrap) into the
31        /// next — distinguishes a soft wrap from a hard CR/LF line-end so reflow
32        /// (#7) can merge and re-split logical lines.
33        const WRAPLINE = 1 << 10;
34        // bits 11..=15 reserved (underline style/colour, hyperlink id).
35    }
36}
37
38// --- packed bit layout (#44) ----------------------------------------------
39//
40// Three 32-bit words mirroring xterm.js's `BufferLine` cell (verified against
41// `xtermjs/xterm.js@master` `src/common/buffer/Constants.ts`). The fg/bg colour
42// words are byte-identical to xterm's `Attributes` + `FgFlags`/`BgFlags`; the
43// content word keeps justerm's explicit layout-marker flags where xterm stores a
44// 2-bit `wcwidth` value (justerm's model is flag-based — the spacer and per-cell
45// WRAPLINE are load-bearing for overwrite/selection/reflow).
46//
47//   content u32: codepoint(21) | COMBINED(1) | WIDE | SPACER | WRAP | reserved
48//   fg/bg   u32: colour value(24) | colour mode(2) | flags(6)
49//
50// COMBINED_PRESENT (content) and LINK_PRESENT (bg, xterm's HAS_EXTENDED slot) are
51// both live: combining clusters (#45) and OSC 8 hyperlink indices (#46) live in
52// per-row, column-keyed maps, and these bits gate every read of them. The cell is
53// now pure packed words — three u32, no `Option` field (the epic's 12 B target).
54
55const CODEPOINT_MASK: u32 = 0x001F_FFFF; // bits 0..21
56const C_COMBINED: u32 = 1 << 21; // a combining cluster lives in the row's map at this column (#45)
57const C_WIDE: u32 = 1 << 22;
58const C_SPACER: u32 = 1 << 23;
59const C_WRAP: u32 = 1 << 24;
60const CONTENT_MARKER_MASK: u32 = C_WIDE | C_SPACER | C_WRAP;
61
62const COLOR_VALUE_MASK: u32 = 0x00FF_FFFF; // bits 0..24
63const COLOR_MODE_SHIFT: u32 = 24; // bits 24..26
64const CM_DEFAULT: u32 = 0;
65const CM_INDEXED: u32 = 1;
66const CM_RGB: u32 = 2;
67
68// fg flags, bits 26..32 — xterm FgFlags order (HIDDEN == xterm INVISIBLE).
69const FG_INVERSE: u32 = 1 << 26;
70const FG_BOLD: u32 = 1 << 27;
71const FG_UNDERLINE: u32 = 1 << 28;
72const FG_BLINK: u32 = 1 << 29;
73const FG_HIDDEN: u32 = 1 << 30;
74const FG_STRIKE: u32 = 1 << 31;
75const FG_FLAG_MASK: u32 = FG_INVERSE | FG_BOLD | FG_UNDERLINE | FG_BLINK | FG_HIDDEN | FG_STRIKE;
76
77// bg flags, bits 26..28 — xterm BgFlags order.
78const BG_ITALIC: u32 = 1 << 26;
79const BG_DIM: u32 = 1 << 27;
80// LINK_PRESENT: an OSC 8 hyperlink index lives in the row's link map at this
81// column (#46). Reuses xterm's `BgFlags.HAS_EXTENDED = 0x10000000` (bit 28)
82// exactly — in xterm this is a *shared* "extended attrs present" gate (link +
83// underline colour); justerm models only the link, so it is link-only here and
84// would widen to an extended-attrs map if underline colour is ever added.
85const BG_LINK: u32 = 1 << 28;
86const BG_FLAG_MASK: u32 = BG_ITALIC | BG_DIM;
87
88/// Pack a colour reference into the low 26 bits of a colour word (mode + value);
89/// the high 6 bits are left for the SGR flags.
90fn pack_color(c: Color) -> u32 {
91    match c {
92        Color::Default => CM_DEFAULT << COLOR_MODE_SHIFT,
93        Color::Indexed(i) => (CM_INDEXED << COLOR_MODE_SHIFT) | i as u32,
94        Color::Rgb(r, g, b) => {
95            (CM_RGB << COLOR_MODE_SHIFT) | (r as u32) << 16 | (g as u32) << 8 | b as u32
96        }
97    }
98}
99
100/// Inverse of [`pack_color`] — reads only the mode + value bits, ignoring the
101/// flag bits that share the word.
102fn unpack_color(w: u32) -> Color {
103    match (w >> COLOR_MODE_SHIFT) & 0b11 {
104        CM_INDEXED => Color::Indexed((w & 0xFF) as u8),
105        CM_RGB => Color::Rgb((w >> 16) as u8, (w >> 8) as u8, w as u8),
106        _ => Color::Default, // CM_DEFAULT (and the unused mode 3) resolve to Default
107    }
108}
109
110/// Scatter a `CellFlags` bit set (as a `u32`) into the three words' flag-bit
111/// positions: `(content_markers, fg_flags, bg_flags)`. Branchless — each group is
112/// masked and shifted in one step. The `CellFlags` bit values are frozen by the
113/// wire format (`serialize` encodes `flags().bits()`), so the source positions are
114/// fixed; see the shift comments. One place for store / insert / remove to share.
115#[inline]
116fn flag_words(f: u32) -> (u32, u32, u32) {
117    let content = (f & 0x0700) << 14; // WIDE/SPACER/WRAP bits 8,9,10 -> 22,23,24
118    let fg = ((f & 0x0001) << 27)     // BOLD     bit 0  -> 27
119        | ((f & 0x0020) << 21)        // INVERSE  bit 5  -> 26
120        | ((f & 0x0018) << 25)        // UNDERLINE/BLINK bits 3,4 -> 28,29
121        | ((f & 0x00C0) << 24); // HIDDEN/STRIKE   bits 6,7 -> 30,31
122    let bg = ((f & 0x0004) << 24)     // ITALIC bit 2 -> 26
123        | ((f & 0x0002) << 26); // DIM    bit 1 -> 27
124    (content, fg, bg)
125}
126
127/// One character position: a base glyph, fg/bg colour references, and flags.
128/// Combining marks (#45) and an OSC 8 hyperlink (#46) attach via per-row maps,
129/// signalled by the `COMBINED_PRESENT` / `LINK_PRESENT` bits — the cell itself is
130/// three packed words, no `Option` field. All access is through the accessor seam
131/// (#44); construct with [`Cell::from_parts`] or [`Cell::default`].
132///
133/// `Eq` is a derived bitwise compare, which is exact because the packing is
134/// canonical — every logical cell maps to one bit pattern (unused bits stay 0).
135#[derive(Clone, Copy, PartialEq, Eq)]
136pub struct Cell {
137    content: u32,
138    fg: u32,
139    bg: u32,
140}
141
142impl Default for Cell {
143    fn default() -> Self {
144        // The packed form of a blank cell: ' ' (U+0020) in the codepoint field,
145        // every other word zero (Default colours, no flags, no combining/link
146        // bits). Built directly rather than through `from_parts` so scroll/erase
147        // blanking — which constructs defaults by the rowful — stays a cheap copy.
148        Cell {
149            content: ' ' as u32,
150            fg: 0,
151            bg: 0,
152        }
153    }
154}
155
156impl core::fmt::Debug for Cell {
157    fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
158        f.debug_struct("Cell")
159            .field("c", &self.c())
160            .field("fg", &self.fg())
161            .field("bg", &self.bg())
162            .field("flags", &self.flags())
163            .field("combined", &self.is_combined())
164            .field("linked", &self.is_linked())
165            .finish()
166    }
167}
168
169impl Cell {
170    /// Assemble a cell from its logical parts. The single construction seam —
171    /// `Pen::cell` and the wire decoder funnel through here, so the bit-packing
172    /// lives in exactly one place (#44).
173    pub fn from_parts(c: char, fg: Color, bg: Color, flags: CellFlags) -> Self {
174        let mut cell = Cell {
175            content: c as u32, // a `char` is <= U+10FFFF, so it fits the 21-bit field
176            fg: pack_color(fg),
177            bg: pack_color(bg),
178        };
179        cell.store_flags(flags);
180        cell
181    }
182
183    /// Replace the flag bits across the three words from `flags`, preserving the
184    /// codepoint, colours, and the dormant presence bits. The inverse is
185    /// [`Cell::flags`].
186    fn store_flags(&mut self, flags: CellFlags) {
187        let (content, fg, bg) = flag_words(flags.bits() as u32);
188        self.content = (self.content & !CONTENT_MARKER_MASK) | content;
189        self.fg = (self.fg & !FG_FLAG_MASK) | fg;
190        self.bg = (self.bg & !BG_FLAG_MASK) | bg;
191    }
192
193    /// The base code point.
194    pub fn c(&self) -> char {
195        char::from_u32(self.content & CODEPOINT_MASK)
196            .expect("codepoint bits always hold a valid char")
197    }
198
199    /// The foreground colour reference.
200    pub fn fg(&self) -> Color {
201        unpack_color(self.fg)
202    }
203
204    /// The background colour reference.
205    pub fn bg(&self) -> Color {
206        unpack_color(self.bg)
207    }
208
209    /// The cell's flags (SGR attributes + layout markers), reassembled from the
210    /// three words — the branchless inverse of [`Cell::store_flags`].
211    pub fn flags(&self) -> CellFlags {
212        let bits = ((self.content & CONTENT_MARKER_MASK) >> 14)         // 22,23,24 -> 8,9,10
213            | ((self.fg & FG_BOLD) >> 27)                              // 27 -> 0
214            | ((self.fg & FG_INVERSE) >> 21)                           // 26 -> 5
215            | ((self.fg & (FG_UNDERLINE | FG_BLINK)) >> 25)            // 28,29 -> 3,4
216            | ((self.fg & (FG_HIDDEN | FG_STRIKE)) >> 24)              // 30,31 -> 6,7
217            | ((self.bg & BG_ITALIC) >> 24)                           // 26 -> 2
218            | ((self.bg & BG_DIM) >> 26); // 27 -> 1
219        CellFlags::from_bits_retain(bits as u16)
220    }
221
222    /// Does this column carry combining marks? When true, the cluster lives in
223    /// the row's combining map at this column (#45) — a flag-gated cache: never
224    /// read the map without first checking this bit.
225    pub fn is_combined(&self) -> bool {
226        self.content & C_COMBINED != 0
227    }
228
229    /// Does this column carry an OSC 8 hyperlink? When true, the hyperlink-pool
230    /// index lives in the row's link map at this column (#46) — flag-gated like
231    /// combining: never read the link map without first checking this bit.
232    pub fn is_linked(&self) -> bool {
233        self.bg & BG_LINK != 0
234    }
235
236    /// Overwrite the base code point, preserving the layout markers.
237    pub fn set_c(&mut self, c: char) {
238        self.content = (self.content & !CODEPOINT_MASK) | c as u32;
239    }
240
241    /// Overwrite the background colour (the BCE erase fill, #16), preserving the
242    /// bg-word flag bits.
243    pub fn set_bg(&mut self, bg: Color) {
244        self.bg = pack_color(bg) | (self.bg & !(COLOR_VALUE_MASK | (0b11 << COLOR_MODE_SHIFT)));
245    }
246
247    /// Mark (or unmark) this column as carrying combining marks in the row map.
248    pub fn set_combined(&mut self, on: bool) {
249        if on {
250            self.content |= C_COMBINED;
251        } else {
252            self.content &= !C_COMBINED;
253        }
254    }
255
256    /// Mark (or unmark) this column as carrying an OSC 8 hyperlink in the row map.
257    pub fn set_linked(&mut self, on: bool) {
258        if on {
259            self.bg |= BG_LINK;
260        } else {
261            self.bg &= !BG_LINK;
262        }
263    }
264
265    /// Add the given flags (leaving the others set). Sets the word bits directly —
266    /// no round-trip through `flags()`/`store_flags`.
267    pub fn insert_flags(&mut self, flags: CellFlags) {
268        let (content, fg, bg) = flag_words(flags.bits() as u32);
269        self.content |= content;
270        self.fg |= fg;
271        self.bg |= bg;
272    }
273
274    /// Clear the given flags (leaving the others as they are).
275    pub fn remove_flags(&mut self, flags: CellFlags) {
276        let (content, fg, bg) = flag_words(flags.bits() as u32);
277        self.content &= !content;
278        self.fg &= !fg;
279        self.bg &= !bg;
280    }
281
282    /// Reset to a blank default cell.
283    pub fn reset(&mut self) {
284        *self = Cell::default();
285    }
286
287    /// Is this the lead cell of a width-2 glyph? Direct content-bit query — the
288    /// hot overwrite/erase/reflow paths use this instead of reconstructing the
289    /// full `flags()` to test one marker.
290    pub fn is_wide(&self) -> bool {
291        self.content & C_WIDE != 0
292    }
293
294    /// Is this the trailing spacer cell of a width-2 glyph?
295    pub fn is_wide_spacer(&self) -> bool {
296        self.content & C_SPACER != 0
297    }
298
299    /// Did this row soft-wrap into the next (WRAPLINE on its last cell)?
300    pub fn is_wrapline(&self) -> bool {
301        self.content & C_WRAP != 0
302    }
303}
304
305#[cfg(test)]
306mod tests {
307    use super::{Cell, CellFlags};
308    use crate::color::Color;
309
310    /// Size pin: slice C moves `link` out of the cell into the row's link map (a
311    /// cell now signals a hyperlink with only the `LINK_PRESENT` bg bit), so `Cell`
312    /// is **12 bytes** — three packed `u32` words, matching xterm.js's `BufferLine`
313    /// cell. This is the epic's target (#43): combining and link both ride per-row
314    /// maps, the cell is pure packed words. Flood throughput is
315    /// memory-bandwidth-bound, so this size is touched on every print/scroll-blank.
316    /// [#42, #46]
317    #[test]
318    fn cell_is_12_bytes() {
319        assert_eq!(std::mem::size_of::<Cell>(), 12);
320    }
321
322    /// The packing must be lossless: every colour reference read back equal in
323    /// both the fg and bg word, including the tag-distinguished trio that must not
324    /// collapse (`Default` / `Indexed(0)` / `Rgb(0,0,0)`).
325    #[test]
326    fn every_colour_round_trips_in_both_words() {
327        let colours = [
328            Color::Default,
329            Color::Indexed(0),
330            Color::Indexed(255),
331            Color::Rgb(0, 0, 0),
332            Color::Rgb(255, 128, 1),
333        ];
334        for &fg in &colours {
335            for &bg in &colours {
336                let cell = Cell::from_parts('x', fg, bg, CellFlags::empty());
337                assert_eq!(cell.fg(), fg, "fg {fg:?} / bg {bg:?}");
338                assert_eq!(cell.bg(), bg, "fg {fg:?} / bg {bg:?}");
339            }
340        }
341    }
342
343    /// Every flag bit — SGR attributes (split across the fg/bg words) and the
344    /// layout markers (in the content word) — round-trips, alone and combined.
345    #[test]
346    fn every_flag_round_trips() {
347        let all = CellFlags::all();
348        for bit in all.iter() {
349            let cell = Cell::from_parts('x', Color::Default, Color::Default, bit);
350            assert_eq!(cell.flags(), bit, "single {bit:?}");
351        }
352        let cell = Cell::from_parts('x', Color::Default, Color::Default, all);
353        assert_eq!(cell.flags(), all, "all flags at once");
354    }
355
356    /// The codepoint occupies 21 bits — the full Unicode range, up to the
357    /// maximum scalar value, survives alongside flags set in the same word.
358    #[test]
359    fn codepoint_round_trips_to_the_unicode_max() {
360        for c in ['a', ' ', '한', '🦀', '\u{10FFFF}'] {
361            let cell = Cell::from_parts(c, Color::Default, Color::Default, CellFlags::WIDE_CHAR);
362            assert_eq!(cell.c(), c, "codepoint {c:?}");
363            assert!(cell.flags().contains(CellFlags::WIDE_CHAR));
364        }
365    }
366
367    /// The combining-presence bit (content word) and link-presence bit (bg word)
368    /// are independent of each other, of the codepoint/markers, of the colours, and
369    /// of the SGR flags — toggling one must disturb none of the others.
370    #[test]
371    fn combined_and_linked_bits_are_independent() {
372        let mut cell = Cell::from_parts(
373            'e',
374            Color::Indexed(3),
375            Color::Rgb(1, 2, 3),
376            CellFlags::WIDE_CHAR | CellFlags::DIM,
377        );
378        assert!(!cell.is_combined());
379        assert!(!cell.is_linked());
380
381        cell.set_combined(true);
382        cell.set_linked(true);
383        assert!(cell.is_combined() && cell.is_linked());
384        // Everything else survives both bits being set.
385        assert_eq!(cell.c(), 'e');
386        assert_eq!(cell.fg(), Color::Indexed(3));
387        assert_eq!(
388            cell.bg(),
389            Color::Rgb(1, 2, 3),
390            "link bit shares the bg word"
391        );
392        assert!(cell.flags().contains(CellFlags::WIDE_CHAR | CellFlags::DIM));
393
394        cell.set_linked(false);
395        assert!(cell.is_combined() && !cell.is_linked());
396        cell.set_combined(false);
397        assert!(!cell.is_combined() && !cell.is_linked());
398        assert_eq!(
399            cell.bg(),
400            Color::Rgb(1, 2, 3),
401            "bg colour intact after clearing"
402        );
403
404        let spacer = Cell::from_parts(
405            ' ',
406            Color::Default,
407            Color::Default,
408            CellFlags::WIDE_CHAR_SPACER,
409        );
410        assert!(spacer.is_wide_spacer());
411        assert!(!Cell::default().is_wide_spacer());
412    }
413}