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 /// A row that soft-wrapped (auto-wrap) into the next — distinguishing it from a hard
31 /// CR/LF line-end so reflow (#7) can merge and re-split logical lines.
32 ///
33 /// **Wire-only.** The live grid holds this on the `Row` (`Grid::is_row_wrapped`); it used
34 /// to live here, where every whole-cell write and clear destroyed it and ordinary typing
35 /// in the last column silently split the logical line (#538). The wire has no per-row
36 /// slot, so it is derived back onto a span's last cell at encode time — which is why the
37 /// storage could move without a format change. On a cell read from the live grid this bit
38 /// is never set.
39 const WRAPLINE = 1 << 10;
40 // bits 11..=13 are the underline STYLE (#829) — a 3-bit field, not flags; read and
41 // written through `underline_style` / `set_underline_style`, never with `insert`.
42 // bits 14..=15 reserved (hyperlink id).
43 }
44}
45
46/// How a cell's underline is drawn — `SGR 4 : Ps` (#829).
47///
48/// **This is the storage, and `None` is a member of it.** There is no second boolean saying
49/// whether the cell is underlined: [`CellFlags::UNDERLINE`] survives as a *derived* view bit so
50/// existing consumers keep working, and the style is its only writer. So the two cannot disagree —
51/// which is not a stylistic preference but the defect three of the four references demonstrably
52/// pay for. alacritty's display layer inserts a plain `UNDERLINE` over a cell that already carries
53/// a curl and its renderer draws both rects; xterm.js has two readers that resolve the same
54/// conflict in opposite directions, pinned by a test; xterm leaves both bits set after
55/// `CSI 4m; CSI 21m` and lets each consumer pick a resolution. ghostty is the one where it is not
56/// representable — its `underline` is an `enum(u3)` with `none` among the members and there is no
57/// `underline: bool` — and that is the shape here.
58///
59/// Stored in the **content** word (bits 26..=28), not in a row side map: the packed cell is not
60/// full, so [the side-map
61/// invariant](https://github.com/kihyun1998/justerm/blob/master/docs/map/invariant/row-keyed-side-maps.md)
62/// does not apply, and packing is what makes the style ride along for free everywhere a cell
63/// moves — reflow, scroll, and the blank a wrapping wide glyph leaves behind. **One path needed an
64/// explicit carry and is worth naming rather than counting in**: `promote_cluster_to_wide` /
65/// `relocate_cluster_wide` *synthesise* the pair's spacer from the pen rather than moving it, so
66/// they take the style from the lead the same way they already take its extended attrs (ADR-0025
67/// D4). A refuting pass found that one; the other three are free.
68#[derive(Clone, Copy, PartialEq, Eq, Hash, Debug, Default)]
69#[repr(u8)]
70pub enum UnderlineStyle {
71 /// Not underlined. The absence is a member rather than a separate flag.
72 #[default]
73 None = 0,
74 /// `SGR 4` or `4:1` — one straight line.
75 Single = 1,
76 /// Two straight lines — `4:2`, and also the legacy `SGR 21`, which is the **only** value with
77 /// a second spelling (#830). Both land on this field, so `SGR 24` clears either of them.
78 ///
79 /// The legacy form is not unanimous in the prior art and the spec is what settles it: `vte`
80 /// reads `21` as *cancel bold*, so an application meaning "stop bold" gets a double underline
81 /// here and keeps its bold. `SGR 22` is the arm that cancels bold.
82 Double = 2,
83 /// `4:3` — a curl. The mark #829 carries end to end.
84 Curly = 3,
85 /// `4:4` — a dotted line (#830). Drawn with a whole number of dots per cell, so the pattern
86 /// does not restart at a cell boundary.
87 Dotted = 4,
88 /// `4:5` — a dashed line (#830). One period per cell, with the dash split across the boundary
89 /// so adjacent cells' dashes join.
90 Dashed = 5,
91}
92
93impl UnderlineStyle {
94 /// The style for a raw 3-bit field, normalising anything outside the enum to
95 /// [`Single`](Self::Single) — the packing has eight representable values and six meanings, and
96 /// a total function here is what keeps `Cell`'s bit pattern canonical.
97 fn from_bits(v: u32) -> Self {
98 match v {
99 0 => Self::None,
100 2 => Self::Double,
101 3 => Self::Curly,
102 4 => Self::Dotted,
103 5 => Self::Dashed,
104 _ => Self::Single,
105 }
106 }
107}
108
109/// The underline style's field in the **view** (`CellFlags`), bits 11..=13.
110const CF_USTYLE_SHIFT: u32 = 11;
111const CF_USTYLE_MASK: u32 = 0b111 << CF_USTYLE_SHIFT;
112
113impl CellFlags {
114 /// The underline style this flag set carries.
115 pub fn underline_style(self) -> UnderlineStyle {
116 UnderlineStyle::from_bits((self.bits() as u32 & CF_USTYLE_MASK) >> CF_USTYLE_SHIFT)
117 }
118
119 /// Set the underline style, and [`UNDERLINE`](Self::UNDERLINE) with it.
120 ///
121 /// The single writer of both: setting a style arms the derived flag and clearing it disarms
122 /// the flag, so no caller can produce a set that disagrees with itself.
123 pub fn set_underline_style(&mut self, style: UnderlineStyle) {
124 let bits = (self.bits() as u32 & !CF_USTYLE_MASK) | ((style as u32) << CF_USTYLE_SHIFT);
125 *self = CellFlags::from_bits_retain(bits as u16);
126 self.set(CellFlags::UNDERLINE, style != UnderlineStyle::None);
127 }
128}
129
130// --- packed bit layout (#44) ----------------------------------------------
131//
132// Three 32-bit words mirroring xterm.js's `BufferLine` cell (verified against
133// `xtermjs/xterm.js@master` `src/common/buffer/Constants.ts`). The fg/bg colour
134// words are byte-identical to xterm's `Attributes` + `FgFlags`/`BgFlags`; the
135// content word keeps justerm's explicit layout-marker flags where xterm stores a
136// 2-bit `wcwidth` value (justerm's model is flag-based — the spacer markers are load-bearing for
137// overwrite/selection/reflow; WRAPLINE is wire-only, the live flag is on the `Row`, #538).
138//
139// content u32: codepoint(21) | COMBINED(1) | WIDE | SPACER | WRAP | reserved
140// fg/bg u32: colour value(24) | colour mode(2) | flags(6)
141//
142// COMBINED_PRESENT (content) and LINK_PRESENT (bg, xterm's HAS_EXTENDED slot) are
143// both live: combining clusters (#45) and OSC 8 hyperlink indices (#46) live in
144// per-row, column-keyed maps, and these bits gate every read of them. The cell is
145// now pure packed words — three u32, no `Option` field (the epic's 12 B target).
146
147const CODEPOINT_MASK: u32 = 0x001F_FFFF; // bits 0..21
148const C_COMBINED: u32 = 1 << 21; // a combining cluster lives in the row's map at this column (#45)
149const C_WIDE: u32 = 1 << 22;
150const C_SPACER: u32 = 1 << 23;
151const C_WRAP: u32 = 1 << 24;
152// The vacated column left when a width-2 glyph wraps off the right edge (#113):
153// a blank that holds no character but isn't a hard line-end. Unlike C_SPACER it
154// has *no wide lead to its left*, so the overwrite/erase repair paths (which key
155// off C_SPACER) must not treat it as one — it's a separate marker the text
156// extractors skip. Engine-internal: it stays in the content word and never
157// reaches `flags()` / the wire (a frame-mode consumer gets the already-correct
158// text, and the cell renders as the blank it is).
159const C_LEADING_SPACER: u32 = 1 << 25;
160const CONTENT_MARKER_MASK: u32 = C_WIDE | C_SPACER | C_WRAP;
161// The underline style (#829) — a 3-bit field, bits 26..=28 of the content word. It is deliberately
162// *outside* `CONTENT_MARKER_MASK`: that mask means "this column means something even though its
163// codepoint is a space", and a decoration does not, exactly as a colour does not (see
164// `Cell::is_blank`). Bits 29..=31 stay free.
165const C_USTYLE_SHIFT: u32 = 26;
166const C_USTYLE_MASK: u32 = 0b111 << C_USTYLE_SHIFT;
167
168const COLOR_VALUE_MASK: u32 = 0x00FF_FFFF; // bits 0..24
169const COLOR_MODE_SHIFT: u32 = 24; // bits 24..26
170const CM_DEFAULT: u32 = 0;
171const CM_INDEXED: u32 = 1;
172const CM_RGB: u32 = 2;
173
174// fg flags, bits 26..32 — xterm FgFlags order (HIDDEN == xterm INVISIBLE).
175const FG_INVERSE: u32 = 1 << 26;
176const FG_BOLD: u32 = 1 << 27;
177const FG_UNDERLINE: u32 = 1 << 28;
178const FG_BLINK: u32 = 1 << 29;
179const FG_HIDDEN: u32 = 1 << 30;
180const FG_STRIKE: u32 = 1 << 31;
181const FG_FLAG_MASK: u32 = FG_INVERSE | FG_BOLD | FG_UNDERLINE | FG_BLINK | FG_HIDDEN | FG_STRIKE;
182
183// bg flags, bits 26..28 — xterm BgFlags order.
184const BG_ITALIC: u32 = 1 << 26;
185const BG_DIM: u32 = 1 << 27;
186// LINK_PRESENT: an OSC 8 hyperlink URI lives in the row's link map at this
187// column (#46; the URI itself rather than a pool index since #628). Reuses xterm's `BgFlags.HAS_EXTENDED = 0x10000000` (bit 28)
188// exactly — in xterm this is a *shared* "extended attrs present" gate (link +
189// underline colour). justerm keeps the two concerns in *separate* per-row maps
190// (as combining and links are separate, #520/ADR-none), so it gates each with
191// its own bit rather than xterm's one shared object.
192const BG_LINK: u32 = 1 << 28;
193// UCOLOR_PRESENT (#520): a non-default underline colour (SGR 58) lives in the
194// row's ucolor map at this column. Its own presence bit, gating a separate map —
195// the 12-byte cell (three packed words) has no room for a fourth colour, so the
196// colour rides a side map exactly as the hyperlink does. **The underline style did NOT land
197// here** — this comment used to send the next reader to bits 30,31 for it, and two bits cannot
198// hold six values; it went to the CONTENT word at bits 26..=28 (#829). Bits 30,31 stay free for a
199// second extended attr.
200const BG_UCOLOR: u32 = 1 << 29;
201const BG_FLAG_MASK: u32 = BG_ITALIC | BG_DIM;
202
203/// Pack a colour reference into the low 26 bits of a colour word (mode + value);
204/// the high 6 bits are left for the SGR flags.
205fn pack_color(c: Color) -> u32 {
206 match c {
207 Color::Default => CM_DEFAULT << COLOR_MODE_SHIFT,
208 Color::Indexed(i) => (CM_INDEXED << COLOR_MODE_SHIFT) | i as u32,
209 Color::Rgb(r, g, b) => {
210 (CM_RGB << COLOR_MODE_SHIFT) | (r as u32) << 16 | (g as u32) << 8 | b as u32
211 }
212 }
213}
214
215/// Inverse of [`pack_color`] — reads only the mode + value bits, ignoring the
216/// flag bits that share the word.
217fn unpack_color(w: u32) -> Color {
218 match (w >> COLOR_MODE_SHIFT) & 0b11 {
219 CM_INDEXED => Color::Indexed((w & 0xFF) as u8),
220 CM_RGB => Color::Rgb((w >> 16) as u8, (w >> 8) as u8, w as u8),
221 _ => Color::Default, // CM_DEFAULT (and the unused mode 3) resolve to Default
222 }
223}
224
225/// Scatter a `CellFlags` bit set (as a `u32`) into the three words' flag-bit
226/// positions: `(content_markers, fg_flags, bg_flags)`. Branchless — each group is
227/// masked and shifted in one step. The `CellFlags` bit values are frozen by the
228/// wire format (`serialize` encodes `flags().bits()`), so the source positions are
229/// fixed; see the shift comments. One place for store / insert / remove to share.
230#[inline]
231fn flag_words(f: u32) -> (u32, u32, u32) {
232 // The underline style owns the underline (#829). A caller that set only `UNDERLINE` means a
233 // single one, so it is normalised here rather than stored as a styleless underline — which is
234 // both the single-owner rule and what keeps the packing canonical, the property `Cell`'s
235 // derived `Eq` is a bitwise compare because of.
236 let mut style = UnderlineStyle::from_bits((f & CF_USTYLE_MASK) >> CF_USTYLE_SHIFT);
237 if style == UnderlineStyle::None && f & 0x0008 != 0 {
238 style = UnderlineStyle::Single;
239 }
240 let content = ((f & 0x0700) << 14) // WIDE/SPACER/WRAP bits 8,9,10 -> 22,23,24
241 | ((style as u32) << C_USTYLE_SHIFT); // underline style -> 26,27,28
242 let fg = ((f & 0x0001) << 27) // BOLD bit 0 -> 27
243 | ((f & 0x0020) << 21) // INVERSE bit 5 -> 26
244 | ((f & 0x0010) << 25) // BLINK bit 4 -> 29
245 | ((f & 0x00C0) << 24); // HIDDEN/STRIKE bits 6,7 -> 30,31
246 // `FG_UNDERLINE` is deliberately **not written** (#829). The style in the content word is the
247 // single owner, and a mutation proved a derived copy here would be write-only: nothing reads
248 // it — `flags()` derives `UNDERLINE` from the style, and the wire carries
249 // `encode_color(cell.fg())`, a tagged colour, so the fg word's flag bits never leave the
250 // process. A duplicate nobody reads is still a duplicate: it is named like every authoritative
251 // flag beside it, so the next reader uses it, which is exactly how xterm.js ended up with two
252 // readers resolving the same question in opposite directions. The bit stays reserved for its
253 // xterm-mirror position and stays zero, cleared with the rest by `FG_FLAG_MASK`.
254 let bg = ((f & 0x0004) << 24) // ITALIC bit 2 -> 26
255 | ((f & 0x0002) << 26); // DIM bit 1 -> 27
256 (content, fg, bg)
257}
258
259/// One character position: a base glyph, fg/bg colour references, and flags.
260/// Combining marks (#45) and an OSC 8 hyperlink (#46) attach via per-row maps,
261/// signalled by the `COMBINED_PRESENT` / `LINK_PRESENT` bits — the cell itself is
262/// three packed words, no `Option` field. All access is through the accessor seam
263/// (#44); construct with [`Cell::from_parts`] or [`Cell::default`].
264///
265/// `Eq` is a derived bitwise compare, which is exact because the packing is
266/// canonical — every logical cell maps to one bit pattern (unused bits stay 0).
267#[derive(Clone, Copy, PartialEq, Eq)]
268pub struct Cell {
269 content: u32,
270 fg: u32,
271 bg: u32,
272}
273
274impl Default for Cell {
275 fn default() -> Self {
276 // The packed form of a blank cell: ' ' (U+0020) in the codepoint field,
277 // every other word zero (Default colours, no flags, no combining/link
278 // bits). Built directly rather than through `from_parts` so scroll/erase
279 // blanking — which constructs defaults by the rowful — stays a cheap copy.
280 Cell {
281 content: ' ' as u32,
282 fg: 0,
283 bg: 0,
284 }
285 }
286}
287
288impl core::fmt::Debug for Cell {
289 fn fmt(&self, f: &mut core::fmt::Formatter<'_>) -> core::fmt::Result {
290 f.debug_struct("Cell")
291 .field("c", &self.c())
292 .field("fg", &self.fg())
293 .field("bg", &self.bg())
294 .field("flags", &self.flags())
295 .field("combined", &self.is_combined())
296 .field("linked", &self.is_linked())
297 // Every cell-state bit that lives OUTSIDE `flags()` — the three presence
298 // bits and the engine-internal leading-spacer marker. `flags()` above
299 // covers the rest; these do not appear in it, so omitting one makes two
300 // unequal cells print as two identical lines. Not hypothetical: #531 read
301 // exactly that way in a failing `assert_eq!(frame, …)` (the ucolor bit was
302 // missing here), and `leading_spacer` reproduced it a second time in the
303 // same session. A bit added to this struct is added here too.
304 .field("ucolored", &self.is_ucolored())
305 .field("leading_spacer", &self.is_leading_spacer())
306 .finish()
307 }
308}
309
310impl Cell {
311 /// Assemble a cell from its logical parts. The single construction seam —
312 /// `Pen::cell` and the wire decoder funnel through here, so the bit-packing
313 /// lives in exactly one place (#44).
314 pub fn from_parts(c: char, fg: Color, bg: Color, flags: CellFlags) -> Self {
315 let mut cell = Cell {
316 content: c as u32, // a `char` is <= U+10FFFF, so it fits the 21-bit field
317 fg: pack_color(fg),
318 bg: pack_color(bg),
319 };
320 cell.store_flags(flags);
321 cell
322 }
323
324 /// Replace the flag bits across the three words from `flags`, preserving the
325 /// codepoint, colours, and the dormant presence bits. The inverse is
326 /// [`Cell::flags`].
327 fn store_flags(&mut self, flags: CellFlags) {
328 let (content, fg, bg) = flag_words(flags.bits() as u32);
329 self.content = (self.content & !(CONTENT_MARKER_MASK | C_USTYLE_MASK)) | content;
330 self.fg = (self.fg & !FG_FLAG_MASK) | fg;
331 self.bg = (self.bg & !BG_FLAG_MASK) | bg;
332 }
333
334 /// The base code point.
335 pub fn c(&self) -> char {
336 char::from_u32(self.content & CODEPOINT_MASK)
337 .expect("codepoint bits always hold a valid char")
338 }
339
340 /// Does this cell hold no **content** — no glyph and no layout marker?
341 ///
342 /// A blank the app never wrote and one it erased to a coloured background are both blank: the
343 /// background is not content. But a wide-char spacer, a leading-spacer wrap artefact, or a
344 /// combining-cluster carrier all *mean* something at their column even though their base
345 /// code point is a space — they are not blank. Used by reflow to find where a hard-ended
346 /// line ends (mirrors xterm.js `getTrimmedLength` / alacritty `line_length`, which likewise
347 /// test content, not the background); it says nothing about a cell's colour.
348 pub fn is_blank(&self) -> bool {
349 // Space codepoint, and none of the content-marker bits set. `content` holds the codepoint
350 // plus the COMBINED / WIDE / SPACER / WRAP / LEADING_SPACER markers, so a single check on
351 // the whole word covers every "means something here" case at once.
352 self.content & (CODEPOINT_MASK | CONTENT_MARKER_MASK | C_COMBINED | C_LEADING_SPACER)
353 == ' ' as u32
354 }
355
356 /// How this cell's underline is drawn (#829). [`UnderlineStyle::None`] means not underlined —
357 /// there is no separate boolean to consult, and [`CellFlags::UNDERLINE`] is derived from this.
358 pub fn underline_style(&self) -> UnderlineStyle {
359 UnderlineStyle::from_bits((self.content & C_USTYLE_MASK) >> C_USTYLE_SHIFT)
360 }
361
362 /// The foreground colour reference.
363 pub fn fg(&self) -> Color {
364 unpack_color(self.fg)
365 }
366
367 /// The background colour reference.
368 pub fn bg(&self) -> Color {
369 unpack_color(self.bg)
370 }
371
372 /// The cell's flags (SGR attributes + layout markers), reassembled from the
373 /// three words — the branchless inverse of `Cell::store_flags`.
374 pub fn flags(&self) -> CellFlags {
375 // `UNDERLINE` is *derived* from the style rather than read from `FG_UNDERLINE` (#829), so a
376 // reader can never be handed a set where the flag and the style disagree.
377 let style = (self.content & C_USTYLE_MASK) >> C_USTYLE_SHIFT;
378 let bits = ((self.content & CONTENT_MARKER_MASK) >> 14) // 22,23,24 -> 8,9,10
379 | (style << CF_USTYLE_SHIFT) // 26,27,28 -> 11,12,13
380 | if style == 0 { 0 } else { 0x0008 } // UNDERLINE, derived
381 | ((self.fg & FG_BOLD) >> 27) // 27 -> 0
382 | ((self.fg & FG_INVERSE) >> 21) // 26 -> 5
383 | ((self.fg & FG_BLINK) >> 25) // 29 -> 4
384 | ((self.fg & (FG_HIDDEN | FG_STRIKE)) >> 24) // 30,31 -> 6,7
385 | ((self.bg & BG_ITALIC) >> 24) // 26 -> 2
386 | ((self.bg & BG_DIM) >> 26); // 27 -> 1
387 CellFlags::from_bits_retain(bits as u16)
388 }
389
390 /// Does this column carry combining marks? When true, the cluster lives in
391 /// the row's combining map at this column (#45) — a flag-gated cache: never
392 /// read the map without first checking this bit.
393 pub fn is_combined(&self) -> bool {
394 self.content & C_COMBINED != 0
395 }
396
397 /// Does this column carry an OSC 8 hyperlink? When true, the URI lives in the
398 /// row's link map at this column (#46; the URI itself rather than an index into a
399 /// buffer-wide pool since #628) — flag-gated like combining: never read the link
400 /// map without first checking this bit.
401 pub fn is_linked(&self) -> bool {
402 self.bg & BG_LINK != 0
403 }
404
405 /// Does this column carry a non-default underline colour (SGR 58, #520)? When
406 /// true, the `Color` reference lives in the row's ucolor map at this column —
407 /// flag-gated exactly like the hyperlink: never read the ucolor map without
408 /// first checking this bit.
409 pub fn is_ucolored(&self) -> bool {
410 self.bg & BG_UCOLOR != 0
411 }
412
413 /// Overwrite the base code point, preserving the layout markers.
414 pub fn set_c(&mut self, c: char) {
415 self.content = (self.content & !CODEPOINT_MASK) | c as u32;
416 }
417
418 /// Overwrite the background colour (the BCE erase fill, #16), preserving the
419 /// bg-word flag bits.
420 pub fn set_bg(&mut self, bg: Color) {
421 self.bg = pack_color(bg) | (self.bg & !(COLOR_VALUE_MASK | (0b11 << COLOR_MODE_SHIFT)));
422 }
423
424 /// Mark (or unmark) this column as carrying combining marks in the row map.
425 pub fn set_combined(&mut self, on: bool) {
426 if on {
427 self.content |= C_COMBINED;
428 } else {
429 self.content &= !C_COMBINED;
430 }
431 }
432
433 /// Mark (or unmark) this column as carrying an OSC 8 hyperlink in the row map.
434 pub fn set_linked(&mut self, on: bool) {
435 if on {
436 self.bg |= BG_LINK;
437 } else {
438 self.bg &= !BG_LINK;
439 }
440 }
441
442 /// Mark (or unmark) this column as carrying a non-default underline colour in
443 /// the row's ucolor map (#520). Mirror of [`Cell::set_linked`].
444 pub fn set_ucolored(&mut self, on: bool) {
445 if on {
446 self.bg |= BG_UCOLOR;
447 } else {
448 self.bg &= !BG_UCOLOR;
449 }
450 }
451
452 /// Add the given flags (leaving the others set). Sets the word bits directly —
453 /// no round-trip through `flags()`/`store_flags`.
454 ///
455 /// **The underline is a field, not a bit, so it is *replaced* rather than OR-ed (#829).**
456 /// Bit-OR is the right operation for every other member and the wrong one for a 3-bit value:
457 /// OR-ing `Dotted` (4) into a `Single` (1) cell yields `Dashed` (5), a style neither the
458 /// caller nor the parser asked for, and a bit pattern no canonical cell has — which would
459 /// break the property this type's derived `Eq` is a bitwise compare because of. A completeness
460 /// pass found this; nothing in this repository reached it, but `Cell` is published.
461 pub fn insert_flags(&mut self, flags: CellFlags) {
462 let (content, fg, bg) = flag_words(flags.bits() as u32);
463 self.content |= content & !C_USTYLE_MASK;
464 self.fg |= fg;
465 self.bg |= bg;
466 // `flag_words` already normalised a bare `UNDERLINE` to `Single`, so "names the underline
467 // at all" is exactly "the normalised style is not `None`".
468 let named = UnderlineStyle::from_bits((content & C_USTYLE_MASK) >> C_USTYLE_SHIFT);
469 if named != UnderlineStyle::None {
470 self.set_underline_style(named);
471 }
472 }
473
474 /// Clear the given flags (leaving the others as they are).
475 ///
476 /// **Naming the underline clears the whole field (#829)**, whichever way it was named — the
477 /// `UNDERLINE` flag or a style value. Masking the bits off instead would turn one style into
478 /// another (clearing `UNDERLINE`, which normalises to `Single` = `0b001`, subtracts a bit from
479 /// `Curly` = `0b011` and leaves `Double`), so a method documented as clearing a flag would
480 /// return a cell that is still underlined, in a style no input can produce.
481 pub fn remove_flags(&mut self, flags: CellFlags) {
482 let (content, fg, bg) = flag_words(flags.bits() as u32);
483 self.content &= !(content & !C_USTYLE_MASK);
484 self.fg &= !fg;
485 self.bg &= !bg;
486 let named = UnderlineStyle::from_bits((content & C_USTYLE_MASK) >> C_USTYLE_SHIFT);
487 if named != UnderlineStyle::None {
488 self.set_underline_style(UnderlineStyle::None);
489 }
490 }
491
492 /// Set the underline style on this cell, arming or disarming the derived `UNDERLINE` view bit
493 /// with it. The one writer of the field on a built cell.
494 pub fn set_underline_style(&mut self, style: UnderlineStyle) {
495 self.content = (self.content & !C_USTYLE_MASK) | ((style as u32) << C_USTYLE_SHIFT);
496 }
497
498 /// Reset to a blank **default** cell — default background included.
499 ///
500 /// That is rarely what a terminal operation wants on its own: a blank the engine creates
501 /// carries the current background (BCE for an erase, and the same for a structural repair,
502 /// #530). Callers pair this with `set_bg`; `Term::free_cell` and the erase paths are the
503 /// places that do. Using it bare leaves an uncoloured notch in a coloured run.
504 pub fn reset(&mut self) {
505 *self = Cell::default();
506 }
507
508 /// Is this the lead cell of a width-2 glyph? Direct content-bit query — the
509 /// hot overwrite/erase/reflow paths use this instead of reconstructing the
510 /// full `flags()` to test one marker.
511 pub fn is_wide(&self) -> bool {
512 self.content & C_WIDE != 0
513 }
514
515 /// Is this the trailing spacer cell of a width-2 glyph?
516 pub fn is_wide_spacer(&self) -> bool {
517 self.content & C_SPACER != 0
518 }
519
520 /// Is this the blank column vacated when a wide glyph wrapped off the right
521 /// edge (#113)? It holds no character; unlike a trailing spacer it has no
522 /// wide lead to its left, so only the *text* extractors skip it.
523 pub fn is_leading_spacer(&self) -> bool {
524 self.content & C_LEADING_SPACER != 0
525 }
526
527 /// Does this column hold no text — either half of a wide glyph's trailing
528 /// spacer or a wide-wrap leading spacer? Used by the text extractors (search,
529 /// selection text, logical lines) to skip non-character columns.
530 pub fn is_spacer(&self) -> bool {
531 self.content & (C_SPACER | C_LEADING_SPACER) != 0
532 }
533
534 /// Drop the leading-spacer marker, leaving the cell otherwise untouched.
535 ///
536 /// The marker claims two things at once, and it has to go when **either** stops holding, or
537 /// the text extractors keep skipping a column that is now a real blank: the row still
538 /// soft-wraps (`Term::end_wrap` owns that half — #538, #540), and the continuation still
539 /// begins with the wide lead that could not fit (`Term::repair_wrap_artefact_above` owns that
540 /// one — #534). Clearing is deliberately one-way: nothing here re-arms the marker, because a
541 /// wide glyph typed at column 0 of the next row did not *wrap* from anywhere.
542 pub fn clear_leading_spacer(&mut self) {
543 self.content &= !C_LEADING_SPACER;
544 }
545
546 /// Mark this column as the leading spacer of a wrapped wide glyph.
547 ///
548 /// **Records** that the column is blank; it does not make it so. The caller must have
549 /// written the blank first — this only ORs a marker onto whatever cell is there. Setting
550 /// it over a live glyph leaves a cell the text extractors skip while a renderer still
551 /// draws it, which is exactly the defect #528 fixed (`Term::vacate_for_wrap` is the one
552 /// place that establishes the precondition; reflow is the other set site, #533).
553 pub fn set_leading_spacer(&mut self) {
554 self.content |= C_LEADING_SPACER;
555 }
556
557 /// Does this **wire** cell end a soft-wrapped row? See `CellFlags::WRAPLINE` — on the live
558 /// grid this is always false and `Grid::is_row_wrapped` is the question to ask (#538).
559 pub fn is_wrapline(&self) -> bool {
560 self.content & C_WRAP != 0
561 }
562}
563
564#[cfg(test)]
565mod tests {
566 use super::{Cell, CellFlags, UnderlineStyle};
567 use crate::color::Color;
568
569 /// Size pin: slice C moves `link` out of the cell into the row's link map (a
570 /// cell now signals a hyperlink with only the `LINK_PRESENT` bg bit), so `Cell`
571 /// is **12 bytes** — three packed `u32` words, matching xterm.js's `BufferLine`
572 /// cell. This is the epic's target (#43): combining and link both ride per-row
573 /// maps, the cell is pure packed words. Flood throughput is
574 /// memory-bandwidth-bound, so this size is touched on every print/scroll-blank.
575 /// [#42, #46]
576 #[test]
577 fn cell_is_12_bytes() {
578 assert_eq!(std::mem::size_of::<Cell>(), 12);
579 }
580
581 /// The packing must be lossless: every colour reference read back equal in
582 /// both the fg and bg word, including the tag-distinguished trio that must not
583 /// collapse (`Default` / `Indexed(0)` / `Rgb(0,0,0)`).
584 #[test]
585 fn every_colour_round_trips_in_both_words() {
586 let colours = [
587 Color::Default,
588 Color::Indexed(0),
589 Color::Indexed(255),
590 Color::Rgb(0, 0, 0),
591 Color::Rgb(255, 128, 1),
592 ];
593 for &fg in &colours {
594 for &bg in &colours {
595 let cell = Cell::from_parts('x', fg, bg, CellFlags::empty());
596 assert_eq!(cell.fg(), fg, "fg {fg:?} / bg {bg:?}");
597 assert_eq!(cell.bg(), bg, "fg {fg:?} / bg {bg:?}");
598 }
599 }
600 }
601
602 /// Every flag bit — SGR attributes (split across the fg/bg words) and the
603 /// layout markers (in the content word) — round-trips, alone and combined.
604 #[test]
605 fn every_flag_round_trips() {
606 // `UNDERLINE` is the one flag that deliberately does **not** round-trip as given (#829).
607 // The underline style is the single owner and `None` is one of its members, so a cell
608 // built with the flag alone is a cell with a *single* underline and reads back carrying
609 // that style — "underlined with no style" is not a state this model has. That is what
610 // keeps the packing canonical, which this type's derived `Eq` is a bitwise compare
611 // because of, and it is asserted here rather than accommodated so the normalisation
612 // cannot be mistaken for a leak.
613 let mut normalised_underline = CellFlags::UNDERLINE;
614 normalised_underline.set_underline_style(UnderlineStyle::Single);
615
616 let all = CellFlags::all();
617 for bit in all.iter() {
618 let cell = Cell::from_parts('x', Color::Default, Color::Default, bit);
619 let expected = if bit == CellFlags::UNDERLINE {
620 normalised_underline
621 } else {
622 bit
623 };
624 assert_eq!(cell.flags(), expected, "single {bit:?}");
625 }
626 let cell = Cell::from_parts('x', Color::Default, Color::Default, all);
627 assert_eq!(
628 cell.flags(),
629 all.union(normalised_underline),
630 "all flags at once",
631 );
632 }
633
634 /// The codepoint occupies 21 bits — the full Unicode range, up to the
635 /// maximum scalar value, survives alongside flags set in the same word.
636 #[test]
637 fn codepoint_round_trips_to_the_unicode_max() {
638 for c in ['a', ' ', '한', '🦀', '\u{10FFFF}'] {
639 let cell = Cell::from_parts(c, Color::Default, Color::Default, CellFlags::WIDE_CHAR);
640 assert_eq!(cell.c(), c, "codepoint {c:?}");
641 assert!(cell.flags().contains(CellFlags::WIDE_CHAR));
642 }
643 }
644
645 /// The combining-presence bit (content word) and link-presence bit (bg word)
646 /// are independent of each other, of the codepoint/markers, of the colours, and
647 /// of the SGR flags — toggling one must disturb none of the others.
648 #[test]
649 fn combined_and_linked_bits_are_independent() {
650 let mut cell = Cell::from_parts(
651 'e',
652 Color::Indexed(3),
653 Color::Rgb(1, 2, 3),
654 CellFlags::WIDE_CHAR | CellFlags::DIM,
655 );
656 assert!(!cell.is_combined());
657 assert!(!cell.is_linked());
658
659 cell.set_combined(true);
660 cell.set_linked(true);
661 assert!(cell.is_combined() && cell.is_linked());
662 // Everything else survives both bits being set.
663 assert_eq!(cell.c(), 'e');
664 assert_eq!(cell.fg(), Color::Indexed(3));
665 assert_eq!(
666 cell.bg(),
667 Color::Rgb(1, 2, 3),
668 "link bit shares the bg word"
669 );
670 assert!(cell.flags().contains(CellFlags::WIDE_CHAR | CellFlags::DIM));
671
672 cell.set_linked(false);
673 assert!(cell.is_combined() && !cell.is_linked());
674 cell.set_combined(false);
675 assert!(!cell.is_combined() && !cell.is_linked());
676 assert_eq!(
677 cell.bg(),
678 Color::Rgb(1, 2, 3),
679 "bg colour intact after clearing"
680 );
681
682 let spacer = Cell::from_parts(
683 ' ',
684 Color::Default,
685 Color::Default,
686 CellFlags::WIDE_CHAR_SPACER,
687 );
688 assert!(spacer.is_wide_spacer());
689 assert!(!Cell::default().is_wide_spacer());
690 }
691
692 /// The underline-colour presence bit (#520) is its own bg-word bit, independent
693 /// of the link bit that shares the word and of the bg colour value — toggling
694 /// it disturbs neither, and it does NOT grow the cell (the 12-byte pin above
695 /// still holds because the colour rides a side map, not the cell).
696 #[test]
697 fn the_ucolor_presence_bit_is_independent_of_the_link_bit_and_bg_colour() {
698 let mut cell = Cell::from_parts('u', Color::Default, Color::Rgb(1, 2, 3), CellFlags::DIM);
699 assert!(!cell.is_ucolored());
700 assert!(!cell.is_linked());
701
702 cell.set_ucolored(true);
703 cell.set_linked(true);
704 assert!(cell.is_ucolored() && cell.is_linked());
705 // The bg colour and the DIM flag (both in the bg word) survive both bits.
706 assert_eq!(cell.bg(), Color::Rgb(1, 2, 3));
707 assert!(cell.flags().contains(CellFlags::DIM));
708
709 // Clearing one leaves the other and the colour intact.
710 cell.set_linked(false);
711 assert!(cell.is_ucolored() && !cell.is_linked());
712 cell.set_ucolored(false);
713 assert!(!cell.is_ucolored());
714 assert_eq!(
715 cell.bg(),
716 Color::Rgb(1, 2, 3),
717 "bg colour intact after clearing"
718 );
719 }
720}