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