Skip to main content

tui_test/terminal/
cell.rs

1//! Backend-neutral grid vocabulary.
2//!
3//! Every consumer of the terminal grid (render, assert, monitor, locator)
4//! speaks these types and nothing else, so swapping the emulator backend
5//! behind [`crate::terminal::emu::Emulator`] is invisible to them. Nothing in
6//! this module may depend on a specific emulator crate.
7
8use bitflags::bitflags;
9use compact_str::CompactString;
10use unicode_width::UnicodeWidthStr;
11
12/// The 16 themeable palette slots (ANSI 0-15).
13///
14/// Split out from [`Color::Idx`] by *numeric range*, not by how the escape
15/// sequence spelled it, so `SGR 31` and `SGR 38;5;1` both land here.
16///
17/// The backends do not agree on whether that spelling survives parsing:
18/// alacritty keeps it (`Named` vs `Indexed`) and so does xterm.js (`CM_P16`
19/// vs `CM_P256`), but ghostty flattens both into one `.palette` value. A model
20/// that preserved the distinction would therefore be unimplementable on
21/// ghostty. The usual reason to want it, painting bold text bright, does not
22/// need it either: ghostty keys that off the index (`bold && idx < 8 =>
23/// palette[idx + 8]`), which applies to `38;5;1` just as much as to `31`.
24///
25/// What every supported backend agrees on is that 0-15 are the slots a theme
26/// may override, which is the only distinction any consumer here acts on.
27#[derive(Debug, Clone, Copy, PartialEq, Eq)]
28#[repr(u8)]
29pub enum NamedColor {
30    Black = 0,
31    Red,
32    Green,
33    Yellow,
34    Blue,
35    Magenta,
36    Cyan,
37    White,
38    BrightBlack,
39    BrightRed,
40    BrightGreen,
41    BrightYellow,
42    BrightBlue,
43    BrightMagenta,
44    BrightCyan,
45    BrightWhite,
46}
47
48impl NamedColor {
49    pub const ALL: [NamedColor; 16] = [
50        NamedColor::Black,
51        NamedColor::Red,
52        NamedColor::Green,
53        NamedColor::Yellow,
54        NamedColor::Blue,
55        NamedColor::Magenta,
56        NamedColor::Cyan,
57        NamedColor::White,
58        NamedColor::BrightBlack,
59        NamedColor::BrightRed,
60        NamedColor::BrightGreen,
61        NamedColor::BrightYellow,
62        NamedColor::BrightBlue,
63        NamedColor::BrightMagenta,
64        NamedColor::BrightCyan,
65        NamedColor::BrightWhite,
66    ];
67
68    /// The palette slot this name occupies (0-15).
69    pub fn index(self) -> u8 {
70        self as u8
71    }
72
73    /// The name for a palette slot, or `None` outside 0-15.
74    pub fn from_index(i: u8) -> Option<Self> {
75        Self::ALL.get(i as usize).copied()
76    }
77}
78
79#[derive(Debug, Clone, Copy, PartialEq, Eq)]
80pub enum Color {
81    /// A themeable palette slot, ANSI 0-15.
82    Named(NamedColor),
83    /// A fixed 256-color palette index, 16-255.
84    Idx(u8),
85    Rgb(u8, u8, u8),
86}
87
88impl Color {
89    /// Build from a 256-color index, routing 0-15 to [`Color::Named`] so the
90    /// same index yields the same value no matter which backend produced it.
91    pub fn from_index(i: u8) -> Self {
92        match NamedColor::from_index(i) {
93            Some(named) => Color::Named(named),
94            None => Color::Idx(i),
95        }
96    }
97
98    /// The 256-color index for this color, approximating RGB.
99    pub fn to_index(self) -> u8 {
100        match self {
101            Color::Named(n) => n.index(),
102            Color::Idx(i) => i,
103            Color::Rgb(r, g, b) => crate::assert::color::rgb_to_ansi256(r, g, b),
104        }
105    }
106}
107
108/// The shape of a cell's underline.
109///
110/// [`UnderlineStyle::None`] is a value, not an absence: it is the shape an
111/// un-underlined cell has. Wrapping this in an `Option` would give two ways to
112/// spell "not underlined" and force every reader through a `map` to reach the
113/// shape, which is why the style and its color sit flat on the cell rather
114/// than inside a nested struct.
115#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
116pub enum UnderlineStyle {
117    #[default]
118    None,
119    Single,
120    Double,
121    Curly,
122    Dotted,
123    Dashed,
124}
125
126impl UnderlineStyle {
127    /// Is the cell underlined at all?
128    pub const fn is_underlined(self) -> bool {
129        !matches!(self, UnderlineStyle::None)
130    }
131
132    /// The name this style goes by on the wire.
133    pub const fn name(self) -> &'static str {
134        match self {
135            UnderlineStyle::None => "none",
136            UnderlineStyle::Single => "single",
137            UnderlineStyle::Double => "double",
138            UnderlineStyle::Curly => "curly",
139            UnderlineStyle::Dotted => "dotted",
140            UnderlineStyle::Dashed => "dashed",
141        }
142    }
143}
144
145bitflags! {
146    /// Boolean SGR attributes, one bit each: the render and monitor loops copy
147    /// and compare a cell's style per column, so keeping it to a single byte
148    /// keeps those comparisons to a single integer compare.
149    #[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
150    pub struct Attrs: u8 {
151        const BOLD      = 1 << 0;
152        const DIM       = 1 << 1;
153        const ITALIC    = 1 << 2;
154        const INVERSE   = 1 << 3;
155        const INVISIBLE = 1 << 4;
156        const STRIKE    = 1 << 5;
157        const BLINK     = 1 << 6;
158    }
159}
160
161/// The grapheme stored in the cell that follows a double-width character.
162pub const CONTINUATION: &str = "";
163
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct EmuCell {
166    /// The cell's grapheme. A blank cell holds `" "`; [`CONTINUATION`] (the
167    /// empty string) is reserved for the cell trailing a double-width char.
168    pub ch: CompactString,
169    /// `None` means the terminal's default foreground.
170    pub fg: Option<Color>,
171    /// `None` means the terminal's default background.
172    pub bg: Option<Color>,
173    pub underline: UnderlineStyle,
174    /// `None` means the underline takes the cell's foreground color. Carried
175    /// even when there is no underline, the same way `fg` outlives the
176    /// grapheme it colors.
177    pub underline_color: Option<Color>,
178    pub attrs: Attrs,
179}
180
181impl EmuCell {
182    /// A blank, unstyled cell.
183    pub const fn blank() -> Self {
184        EmuCell {
185            ch: CompactString::const_new(" "),
186            fg: None,
187            bg: None,
188            underline: UnderlineStyle::None,
189            underline_color: None,
190            attrs: Attrs::empty(),
191        }
192    }
193
194    pub fn has(&self, attr: Attrs) -> bool {
195        self.attrs.contains(attr)
196    }
197}
198
199impl Default for EmuCell {
200    fn default() -> Self {
201        EmuCell::blank()
202    }
203}
204
205/// How many terminal columns a string occupies.
206///
207/// A terminal lays text out by column, not by character: a CJK glyph is one
208/// `char` but two columns, and a combining mark is one `char` but none.
209///
210/// Measured over the whole string rather than by summing characters, because
211/// a sequence can be narrower than its parts: an emoji joined by zero-width
212/// joiners (`\u{200d}`) renders as a single glyph, and a base character
213/// followed by a variation selector or a keycap mark is one unit too. Summing
214/// per character reports a family emoji as eight columns where a terminal
215/// draws two.
216pub fn display_width(s: &str) -> usize {
217    s.width()
218}
219
220/// Shorten `s` to at most `columns` terminal columns, marking a cut with `…`.
221///
222/// The cut point is found by measuring real prefixes rather than by adding up
223/// character widths, so the result is exactly as wide as it was measured to be
224/// even when the cut lands inside a sequence.
225pub fn truncate_to_columns(s: &str, columns: usize) -> String {
226    if columns == 0 {
227        return String::new();
228    }
229    if display_width(s) <= columns {
230        return s.to_string();
231    }
232    // One column is held back for the ellipsis that marks the cut.
233    let budget = columns - 1;
234    let mut cut = 0;
235    for (offset, _) in s.char_indices() {
236        if display_width(&s[..offset]) > budget {
237            break;
238        }
239        cut = offset;
240    }
241    format!("{}\u{2026}", &s[..cut])
242}
243
244/// Join a grid of cells into one string per row.
245///
246/// Continuation cells contribute nothing: a double-width character already
247/// carries both its columns, so emitting a filler for the second one would
248/// widen the row by one and shift every column after it.
249pub fn rows_to_strings(rows: &[Vec<EmuCell>]) -> Vec<String> {
250    rows.iter()
251        .map(|row| row.iter().map(|c| c.ch.as_str()).collect::<String>())
252        .collect()
253}
254
255#[cfg(test)]
256mod tests {
257    use super::*;
258
259    fn cell(s: &str) -> EmuCell {
260        EmuCell {
261            ch: CompactString::from(s),
262            ..EmuCell::blank()
263        }
264    }
265
266    #[test]
267    fn index_splits_named_from_palette() {
268        assert_eq!(Color::from_index(0), Color::Named(NamedColor::Black));
269        assert_eq!(Color::from_index(9), Color::Named(NamedColor::BrightRed));
270        assert_eq!(Color::from_index(15), Color::Named(NamedColor::BrightWhite));
271        assert_eq!(Color::from_index(16), Color::Idx(16));
272        assert_eq!(Color::from_index(255), Color::Idx(255));
273        for i in 0..=255u8 {
274            assert_eq!(Color::from_index(i).to_index(), i, "roundtrip {i}");
275        }
276    }
277
278    #[test]
279    fn continuation_cells_do_not_widen_a_row() {
280        let rows = vec![vec![cell("你"), cell(CONTINUATION), cell("a"), cell(" ")]];
281        assert_eq!(rows_to_strings(&rows), vec!["你a "]);
282    }
283
284    #[test]
285    fn blank_is_a_space_not_a_continuation() {
286        assert_eq!(EmuCell::blank().ch, " ");
287        assert_ne!(EmuCell::blank().ch, CONTINUATION);
288    }
289}
290
291#[cfg(test)]
292mod width_tests {
293    use super::*;
294
295    /// A sequence is measured as the glyph it renders as, not as the sum of
296    /// its parts. Summing per character is the mistake that draws a frame
297    /// around a family emoji six columns too wide.
298    #[test]
299    fn a_sequence_is_narrower_than_its_characters() {
300        for (name, text, columns) in [
301            (
302                "family",
303                "\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}",
304                2,
305            ),
306            ("skin tone", "\u{1f44d}\u{1f3fd}", 2),
307            ("keycap", "1\u{fe0f}\u{20e3}", 2),
308            ("heart with a variation selector", "\u{2764}\u{fe0f}", 2),
309            ("flag", "\u{1f1fa}\u{1f1f8}", 2),
310        ] {
311            assert_eq!(display_width(text), columns, "{name} is {columns} columns");
312        }
313    }
314
315    /// The ordinary cases the grid already relies on.
316    #[test]
317    fn width_counts_columns_not_characters() {
318        assert_eq!(display_width("hello"), 5);
319        assert_eq!(
320            display_width("\u{4f60}\u{597d}"),
321            4,
322            "each CJK glyph takes two"
323        );
324        assert_eq!(display_width("e\u{301}"), 1, "a combining mark adds none");
325        assert_eq!(display_width(""), 0);
326    }
327
328    /// Truncation never exceeds its budget, whatever it has to cut through.
329    ///
330    /// The result is measured rather than assumed: a cut inside a sequence
331    /// changes how the remainder renders, so only measuring the real prefix
332    /// keeps the promise this function makes to a frame drawn around it.
333    #[test]
334    fn truncation_stays_within_its_budget() {
335        for text in [
336            "a-very-long-title-that-will-not-fit",
337            "\u{4f60}\u{597d}\u{4e16}\u{754c}\u{4f60}\u{597d}",
338            "\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466} building",
339            "\u{1f680} deploy \u{4f60}\u{597d} done",
340        ] {
341            for budget in 0..12 {
342                let cut = truncate_to_columns(text, budget);
343                assert!(
344                    display_width(&cut) <= budget,
345                    "{text:?} cut to {budget} came out {} wide: {cut:?}",
346                    display_width(&cut)
347                );
348            }
349        }
350    }
351
352    /// A string that already fits is returned whole, with no ellipsis.
353    #[test]
354    fn truncation_leaves_a_string_that_fits_alone() {
355        assert_eq!(truncate_to_columns("fits", 10), "fits");
356        assert_eq!(truncate_to_columns("fits", 4), "fits");
357        assert_eq!(
358            truncate_to_columns("\u{4f60}\u{597d}", 4),
359            "\u{4f60}\u{597d}"
360        );
361    }
362}