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;
60// The vacated column left when a width-2 glyph wraps off the right edge (#113):
61// a blank that holds no character but isn't a hard line-end. Unlike C_SPACER it
62// has *no wide lead to its left*, so the overwrite/erase repair paths (which key
63// off C_SPACER) must not treat it as one — it's a separate marker the text
64// extractors skip. Engine-internal: it stays in the content word and never
65// reaches `flags()` / the wire (a frame-mode consumer gets the already-correct
66// text, and the cell renders as the blank it is).
67const C_LEADING_SPACER: u32 = 1 << 25;
68const CONTENT_MARKER_MASK: u32 = C_WIDE | C_SPACER | C_WRAP;
69
70const COLOR_VALUE_MASK: u32 = 0x00FF_FFFF; // bits 0..24
71const COLOR_MODE_SHIFT: u32 = 24; // bits 24..26
72const CM_DEFAULT: u32 = 0;
73const CM_INDEXED: u32 = 1;
74const CM_RGB: u32 = 2;
75
76// fg flags, bits 26..32 — xterm FgFlags order (HIDDEN == xterm INVISIBLE).
77const FG_INVERSE: u32 = 1 << 26;
78const FG_BOLD: u32 = 1 << 27;
79const FG_UNDERLINE: u32 = 1 << 28;
80const FG_BLINK: u32 = 1 << 29;
81const FG_HIDDEN: u32 = 1 << 30;
82const FG_STRIKE: u32 = 1 << 31;
83const FG_FLAG_MASK: u32 = FG_INVERSE | FG_BOLD | FG_UNDERLINE | FG_BLINK | FG_HIDDEN | FG_STRIKE;
84
85// bg flags, bits 26..28 — xterm BgFlags order.
86const BG_ITALIC: u32 = 1 << 26;
87const BG_DIM: u32 = 1 << 27;
88// LINK_PRESENT: an OSC 8 hyperlink index lives in the row's link map at this
89// column (#46). Reuses xterm's `BgFlags.HAS_EXTENDED = 0x10000000` (bit 28)
90// exactly — in xterm this is a *shared* "extended attrs present" gate (link +
91// underline colour). justerm keeps the two concerns in *separate* per-row maps
92// (as combining and links are separate, #520/ADR-none), so it gates each with
93// its own bit rather than xterm's one shared object.
94const BG_LINK: u32 = 1 << 28;
95// UCOLOR_PRESENT (#520): a non-default underline colour (SGR 58) lives in the
96// row's ucolor map at this column. Its own presence bit, gating a separate map —
97// the 12-byte cell (three packed words) has no room for a fourth colour, so the
98// colour rides a side map exactly as the hyperlink does (bits 30,31 stay free
99// for a later underline *style* / a second extended attr).
100const BG_UCOLOR: u32 = 1 << 29;
101const BG_FLAG_MASK: u32 = BG_ITALIC | BG_DIM;
102
103/// Pack a colour reference into the low 26 bits of a colour word (mode + value);
104/// the high 6 bits are left for the SGR flags.
105fn pack_color(c: Color) -> u32 {
106 match c {
107 Color::Default => CM_DEFAULT << COLOR_MODE_SHIFT,
108 Color::Indexed(i) => (CM_INDEXED << COLOR_MODE_SHIFT) | i as u32,
109 Color::Rgb(r, g, b) => {
110 (CM_RGB << COLOR_MODE_SHIFT) | (r as u32) << 16 | (g as u32) << 8 | b as u32
111 }
112 }
113}
114
115/// Inverse of [`pack_color`] — reads only the mode + value bits, ignoring the
116/// flag bits that share the word.
117fn unpack_color(w: u32) -> Color {
118 match (w >> COLOR_MODE_SHIFT) & 0b11 {
119 CM_INDEXED => Color::Indexed((w & 0xFF) as u8),
120 CM_RGB => Color::Rgb((w >> 16) as u8, (w >> 8) as u8, w as u8),
121 _ => Color::Default, // CM_DEFAULT (and the unused mode 3) resolve to Default
122 }
123}
124
125/// Scatter a `CellFlags` bit set (as a `u32`) into the three words' flag-bit
126/// positions: `(content_markers, fg_flags, bg_flags)`. Branchless — each group is
127/// masked and shifted in one step. The `CellFlags` bit values are frozen by the
128/// wire format (`serialize` encodes `flags().bits()`), so the source positions are
129/// fixed; see the shift comments. One place for store / insert / remove to share.
130#[inline]
131fn flag_words(f: u32) -> (u32, u32, u32) {
132 let content = (f & 0x0700) << 14; // WIDE/SPACER/WRAP bits 8,9,10 -> 22,23,24
133 let fg = ((f & 0x0001) << 27) // BOLD bit 0 -> 27
134 | ((f & 0x0020) << 21) // INVERSE bit 5 -> 26
135 | ((f & 0x0018) << 25) // UNDERLINE/BLINK bits 3,4 -> 28,29
136 | ((f & 0x00C0) << 24); // HIDDEN/STRIKE bits 6,7 -> 30,31
137 let bg = ((f & 0x0004) << 24) // ITALIC bit 2 -> 26
138 | ((f & 0x0002) << 26); // DIM bit 1 -> 27
139 (content, fg, bg)
140}
141
142/// One character position: a base glyph, fg/bg colour references, and flags.
143/// Combining marks (#45) and an OSC 8 hyperlink (#46) attach via per-row maps,
144/// signalled by the `COMBINED_PRESENT` / `LINK_PRESENT` bits — the cell itself is
145/// three packed words, no `Option` field. All access is through the accessor seam
146/// (#44); construct with [`Cell::from_parts`] or [`Cell::default`].
147///
148/// `Eq` is a derived bitwise compare, which is exact because the packing is
149/// canonical — every logical cell maps to one bit pattern (unused bits stay 0).
150#[derive(Clone, Copy, PartialEq, Eq)]
151pub struct Cell {
152 content: u32,
153 fg: u32,
154 bg: u32,
155}
156
157impl Default for Cell {
158 fn default() -> Self {
159 // The packed form of a blank cell: ' ' (U+0020) in the codepoint field,
160 // every other word zero (Default colours, no flags, no combining/link
161 // bits). Built directly rather than through `from_parts` so scroll/erase
162 // blanking — which constructs defaults by the rowful — stays a cheap copy.
163 Cell {
164 content: ' ' as u32,
165 fg: 0,
166 bg: 0,
167 }
168 }
169}
170
171impl core::fmt::Debug for Cell {
172 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
173 f.debug_struct("Cell")
174 .field("c", &self.c())
175 .field("fg", &self.fg())
176 .field("bg", &self.bg())
177 .field("flags", &self.flags())
178 .field("combined", &self.is_combined())
179 .field("linked", &self.is_linked())
180 .finish()
181 }
182}
183
184impl Cell {
185 /// Assemble a cell from its logical parts. The single construction seam —
186 /// `Pen::cell` and the wire decoder funnel through here, so the bit-packing
187 /// lives in exactly one place (#44).
188 pub fn from_parts(c: char, fg: Color, bg: Color, flags: CellFlags) -> Self {
189 let mut cell = Cell {
190 content: c as u32, // a `char` is <= U+10FFFF, so it fits the 21-bit field
191 fg: pack_color(fg),
192 bg: pack_color(bg),
193 };
194 cell.store_flags(flags);
195 cell
196 }
197
198 /// Replace the flag bits across the three words from `flags`, preserving the
199 /// codepoint, colours, and the dormant presence bits. The inverse is
200 /// [`Cell::flags`].
201 fn store_flags(&mut self, flags: CellFlags) {
202 let (content, fg, bg) = flag_words(flags.bits() as u32);
203 self.content = (self.content & !CONTENT_MARKER_MASK) | content;
204 self.fg = (self.fg & !FG_FLAG_MASK) | fg;
205 self.bg = (self.bg & !BG_FLAG_MASK) | bg;
206 }
207
208 /// The base code point.
209 pub fn c(&self) -> char {
210 char::from_u32(self.content & CODEPOINT_MASK)
211 .expect("codepoint bits always hold a valid char")
212 }
213
214 /// The foreground colour reference.
215 pub fn fg(&self) -> Color {
216 unpack_color(self.fg)
217 }
218
219 /// The background colour reference.
220 pub fn bg(&self) -> Color {
221 unpack_color(self.bg)
222 }
223
224 /// The cell's flags (SGR attributes + layout markers), reassembled from the
225 /// three words — the branchless inverse of `Cell::store_flags`.
226 pub fn flags(&self) -> CellFlags {
227 let bits = ((self.content & CONTENT_MARKER_MASK) >> 14) // 22,23,24 -> 8,9,10
228 | ((self.fg & FG_BOLD) >> 27) // 27 -> 0
229 | ((self.fg & FG_INVERSE) >> 21) // 26 -> 5
230 | ((self.fg & (FG_UNDERLINE | FG_BLINK)) >> 25) // 28,29 -> 3,4
231 | ((self.fg & (FG_HIDDEN | FG_STRIKE)) >> 24) // 30,31 -> 6,7
232 | ((self.bg & BG_ITALIC) >> 24) // 26 -> 2
233 | ((self.bg & BG_DIM) >> 26); // 27 -> 1
234 CellFlags::from_bits_retain(bits as u16)
235 }
236
237 /// Does this column carry combining marks? When true, the cluster lives in
238 /// the row's combining map at this column (#45) — a flag-gated cache: never
239 /// read the map without first checking this bit.
240 pub fn is_combined(&self) -> bool {
241 self.content & C_COMBINED != 0
242 }
243
244 /// Does this column carry an OSC 8 hyperlink? When true, the hyperlink-pool
245 /// index lives in the row's link map at this column (#46) — flag-gated like
246 /// combining: never read the link map without first checking this bit.
247 pub fn is_linked(&self) -> bool {
248 self.bg & BG_LINK != 0
249 }
250
251 /// Does this column carry a non-default underline colour (SGR 58, #520)? When
252 /// true, the `Color` reference lives in the row's ucolor map at this column —
253 /// flag-gated exactly like the hyperlink: never read the ucolor map without
254 /// first checking this bit.
255 pub fn is_ucolored(&self) -> bool {
256 self.bg & BG_UCOLOR != 0
257 }
258
259 /// Overwrite the base code point, preserving the layout markers.
260 pub fn set_c(&mut self, c: char) {
261 self.content = (self.content & !CODEPOINT_MASK) | c as u32;
262 }
263
264 /// Overwrite the background colour (the BCE erase fill, #16), preserving the
265 /// bg-word flag bits.
266 pub fn set_bg(&mut self, bg: Color) {
267 self.bg = pack_color(bg) | (self.bg & !(COLOR_VALUE_MASK | (0b11 << COLOR_MODE_SHIFT)));
268 }
269
270 /// Mark (or unmark) this column as carrying combining marks in the row map.
271 pub fn set_combined(&mut self, on: bool) {
272 if on {
273 self.content |= C_COMBINED;
274 } else {
275 self.content &= !C_COMBINED;
276 }
277 }
278
279 /// Mark (or unmark) this column as carrying an OSC 8 hyperlink in the row map.
280 pub fn set_linked(&mut self, on: bool) {
281 if on {
282 self.bg |= BG_LINK;
283 } else {
284 self.bg &= !BG_LINK;
285 }
286 }
287
288 /// Mark (or unmark) this column as carrying a non-default underline colour in
289 /// the row's ucolor map (#520). Mirror of [`Cell::set_linked`].
290 pub fn set_ucolored(&mut self, on: bool) {
291 if on {
292 self.bg |= BG_UCOLOR;
293 } else {
294 self.bg &= !BG_UCOLOR;
295 }
296 }
297
298 /// Add the given flags (leaving the others set). Sets the word bits directly —
299 /// no round-trip through `flags()`/`store_flags`.
300 pub fn insert_flags(&mut self, flags: CellFlags) {
301 let (content, fg, bg) = flag_words(flags.bits() as u32);
302 self.content |= content;
303 self.fg |= fg;
304 self.bg |= bg;
305 }
306
307 /// Clear the given flags (leaving the others as they are).
308 pub fn remove_flags(&mut self, flags: CellFlags) {
309 let (content, fg, bg) = flag_words(flags.bits() as u32);
310 self.content &= !content;
311 self.fg &= !fg;
312 self.bg &= !bg;
313 }
314
315 /// Reset to a blank default cell.
316 pub fn reset(&mut self) {
317 *self = Cell::default();
318 }
319
320 /// Is this the lead cell of a width-2 glyph? Direct content-bit query — the
321 /// hot overwrite/erase/reflow paths use this instead of reconstructing the
322 /// full `flags()` to test one marker.
323 pub fn is_wide(&self) -> bool {
324 self.content & C_WIDE != 0
325 }
326
327 /// Is this the trailing spacer cell of a width-2 glyph?
328 pub fn is_wide_spacer(&self) -> bool {
329 self.content & C_SPACER != 0
330 }
331
332 /// Is this the blank column vacated when a wide glyph wrapped off the right
333 /// edge (#113)? It holds no character; unlike a trailing spacer it has no
334 /// wide lead to its left, so only the *text* extractors skip it.
335 pub fn is_leading_spacer(&self) -> bool {
336 self.content & C_LEADING_SPACER != 0
337 }
338
339 /// Does this column hold no text — either half of a wide glyph's trailing
340 /// spacer or a wide-wrap leading spacer? Used by the text extractors (search,
341 /// selection text, logical lines) to skip non-character columns.
342 pub fn is_spacer(&self) -> bool {
343 self.content & (C_SPACER | C_LEADING_SPACER) != 0
344 }
345
346 /// Mark this (blank) column as the leading spacer of a wrapped wide glyph.
347 pub fn set_leading_spacer(&mut self) {
348 self.content |= C_LEADING_SPACER;
349 }
350
351 /// Did this row soft-wrap into the next (WRAPLINE on its last cell)?
352 pub fn is_wrapline(&self) -> bool {
353 self.content & C_WRAP != 0
354 }
355}
356
357#[cfg(test)]
358mod tests {
359 use super::{Cell, CellFlags};
360 use crate::color::Color;
361
362 /// Size pin: slice C moves `link` out of the cell into the row's link map (a
363 /// cell now signals a hyperlink with only the `LINK_PRESENT` bg bit), so `Cell`
364 /// is **12 bytes** — three packed `u32` words, matching xterm.js's `BufferLine`
365 /// cell. This is the epic's target (#43): combining and link both ride per-row
366 /// maps, the cell is pure packed words. Flood throughput is
367 /// memory-bandwidth-bound, so this size is touched on every print/scroll-blank.
368 /// [#42, #46]
369 #[test]
370 fn cell_is_12_bytes() {
371 assert_eq!(std::mem::size_of::<Cell>(), 12);
372 }
373
374 /// The packing must be lossless: every colour reference read back equal in
375 /// both the fg and bg word, including the tag-distinguished trio that must not
376 /// collapse (`Default` / `Indexed(0)` / `Rgb(0,0,0)`).
377 #[test]
378 fn every_colour_round_trips_in_both_words() {
379 let colours = [
380 Color::Default,
381 Color::Indexed(0),
382 Color::Indexed(255),
383 Color::Rgb(0, 0, 0),
384 Color::Rgb(255, 128, 1),
385 ];
386 for &fg in &colours {
387 for &bg in &colours {
388 let cell = Cell::from_parts('x', fg, bg, CellFlags::empty());
389 assert_eq!(cell.fg(), fg, "fg {fg:?} / bg {bg:?}");
390 assert_eq!(cell.bg(), bg, "fg {fg:?} / bg {bg:?}");
391 }
392 }
393 }
394
395 /// Every flag bit — SGR attributes (split across the fg/bg words) and the
396 /// layout markers (in the content word) — round-trips, alone and combined.
397 #[test]
398 fn every_flag_round_trips() {
399 let all = CellFlags::all();
400 for bit in all.iter() {
401 let cell = Cell::from_parts('x', Color::Default, Color::Default, bit);
402 assert_eq!(cell.flags(), bit, "single {bit:?}");
403 }
404 let cell = Cell::from_parts('x', Color::Default, Color::Default, all);
405 assert_eq!(cell.flags(), all, "all flags at once");
406 }
407
408 /// The codepoint occupies 21 bits — the full Unicode range, up to the
409 /// maximum scalar value, survives alongside flags set in the same word.
410 #[test]
411 fn codepoint_round_trips_to_the_unicode_max() {
412 for c in ['a', ' ', '한', '🦀', '\u{10FFFF}'] {
413 let cell = Cell::from_parts(c, Color::Default, Color::Default, CellFlags::WIDE_CHAR);
414 assert_eq!(cell.c(), c, "codepoint {c:?}");
415 assert!(cell.flags().contains(CellFlags::WIDE_CHAR));
416 }
417 }
418
419 /// The combining-presence bit (content word) and link-presence bit (bg word)
420 /// are independent of each other, of the codepoint/markers, of the colours, and
421 /// of the SGR flags — toggling one must disturb none of the others.
422 #[test]
423 fn combined_and_linked_bits_are_independent() {
424 let mut cell = Cell::from_parts(
425 'e',
426 Color::Indexed(3),
427 Color::Rgb(1, 2, 3),
428 CellFlags::WIDE_CHAR | CellFlags::DIM,
429 );
430 assert!(!cell.is_combined());
431 assert!(!cell.is_linked());
432
433 cell.set_combined(true);
434 cell.set_linked(true);
435 assert!(cell.is_combined() && cell.is_linked());
436 // Everything else survives both bits being set.
437 assert_eq!(cell.c(), 'e');
438 assert_eq!(cell.fg(), Color::Indexed(3));
439 assert_eq!(
440 cell.bg(),
441 Color::Rgb(1, 2, 3),
442 "link bit shares the bg word"
443 );
444 assert!(cell.flags().contains(CellFlags::WIDE_CHAR | CellFlags::DIM));
445
446 cell.set_linked(false);
447 assert!(cell.is_combined() && !cell.is_linked());
448 cell.set_combined(false);
449 assert!(!cell.is_combined() && !cell.is_linked());
450 assert_eq!(
451 cell.bg(),
452 Color::Rgb(1, 2, 3),
453 "bg colour intact after clearing"
454 );
455
456 let spacer = Cell::from_parts(
457 ' ',
458 Color::Default,
459 Color::Default,
460 CellFlags::WIDE_CHAR_SPACER,
461 );
462 assert!(spacer.is_wide_spacer());
463 assert!(!Cell::default().is_wide_spacer());
464 }
465
466 /// The underline-colour presence bit (#520) is its own bg-word bit, independent
467 /// of the link bit that shares the word and of the bg colour value — toggling
468 /// it disturbs neither, and it does NOT grow the cell (the 12-byte pin above
469 /// still holds because the colour rides a side map, not the cell).
470 #[test]
471 fn the_ucolor_presence_bit_is_independent_of_the_link_bit_and_bg_colour() {
472 let mut cell = Cell::from_parts('u', Color::Default, Color::Rgb(1, 2, 3), CellFlags::DIM);
473 assert!(!cell.is_ucolored());
474 assert!(!cell.is_linked());
475
476 cell.set_ucolored(true);
477 cell.set_linked(true);
478 assert!(cell.is_ucolored() && cell.is_linked());
479 // The bg colour and the DIM flag (both in the bg word) survive both bits.
480 assert_eq!(cell.bg(), Color::Rgb(1, 2, 3));
481 assert!(cell.flags().contains(CellFlags::DIM));
482
483 // Clearing one leaves the other and the colour intact.
484 cell.set_linked(false);
485 assert!(cell.is_ucolored() && !cell.is_linked());
486 cell.set_ucolored(false);
487 assert!(!cell.is_ucolored());
488 assert_eq!(
489 cell.bg(),
490 Color::Rgb(1, 2, 3),
491 "bg colour intact after clearing"
492 );
493 }
494}