Skip to main content

slt/
cell.rs

1//! Single terminal cell β€” the smallest unit of the render buffer.
2
3use compact_str::CompactString;
4use unicode_segmentation::UnicodeSegmentation;
5
6use crate::style::Style;
7
8/// Maximum UTF-8 bytes retained for one terminal grapheme.
9///
10/// This bounds output work for pathological combining-mark sequences while
11/// leaving enough room for common ZWJ emoji clusters.
12pub(crate) const MAX_CELL_SYMBOL_BYTES: usize = 32;
13
14/// Replace terminal control code points with the visible replacement glyph.
15#[inline]
16pub(crate) fn sanitize_cell_char(ch: char) -> char {
17    let value = ch as u32;
18    if value < 0x20 || value == 0x7f || (0x80..=0x9f).contains(&value) {
19        '\u{FFFD}'
20    } else {
21        ch
22    }
23}
24
25/// Normalize an arbitrary string into one bounded, terminal-safe grapheme.
26///
27/// An empty result is reserved for continuation cells. Additional graphemes
28/// are discarded because a `Cell` represents exactly one display atom.
29pub(crate) fn normalize_cell_symbol(symbol: &str) -> CompactString {
30    let Some(grapheme) = symbol.graphemes(true).next() else {
31        return CompactString::new("");
32    };
33
34    let mut normalized = CompactString::new("");
35    for ch in grapheme.chars() {
36        let ch = sanitize_cell_char(ch);
37        if normalized.len().saturating_add(ch.len_utf8()) > MAX_CELL_SYMBOL_BYTES {
38            break;
39        }
40        normalized.push(ch);
41    }
42    normalized
43}
44
45// Compile-time size assertion for `Cell`.
46//
47// `Cell` is composed of `symbol: CompactString` + `style: Style` +
48// `hyperlink: Option<CompactString>`. Upstream changes to any of these
49// (e.g., `CompactString` inline-storage tweaks, `Style` field additions,
50// or hyperlink type swaps) can silently grow the struct until runtime.
51// A 64-byte budget keeps each cell within one cache line.
52//
53// If an intentional growth pushes us past 64 B, raise this bound and
54// document why β€” but do not silently let it drift.
55const _: () = assert!(
56    std::mem::size_of::<Cell>() <= 64,
57    "Cell exceeds one cache line (64 B). If the size increase is intentional, update this bound and document why."
58);
59
60/// A single terminal cell containing a character and style.
61///
62/// Each cell holds one grapheme cluster (stored as a [`CompactString`] for
63/// inline storage of short strings β€” no heap allocation for ≀24 bytes).
64/// Wide graphemes occupy adjacent cells. The leading cell stores the
65/// grapheme and every continuation cell has an empty `symbol`.
66#[derive(Debug, Clone, PartialEq, Eq)]
67pub struct Cell {
68    /// The grapheme cluster displayed in this cell. Defaults to a single space.
69    pub symbol: CompactString,
70    /// The visual style (colors and modifiers) for this cell.
71    pub style: Style,
72    /// Optional OSC 8 hyperlink URL. When set, the terminal renders this cell
73    /// as a clickable link.
74    pub hyperlink: Option<CompactString>,
75}
76
77impl Default for Cell {
78    fn default() -> Self {
79        Self {
80            symbol: CompactString::const_new(" "),
81            style: Style::new(),
82            hyperlink: None,
83        }
84    }
85}
86
87impl Cell {
88    /// Replace the cell's symbol with the given string slice.
89    ///
90    /// Only the first extended grapheme cluster is retained. C0, DEL, and C1
91    /// controls are replaced with `U+FFFD`, and the optional hyperlink is
92    /// cleared so direct symbol replacement cannot inherit stale link state.
93    pub fn set_symbol(&mut self, s: &str) -> &mut Self {
94        self.symbol = normalize_cell_symbol(s);
95        self.hyperlink = None;
96        self
97    }
98
99    /// Replace the cell's symbol with a single character.
100    pub fn set_char(&mut self, ch: char) -> &mut Self {
101        self.symbol.clear();
102        self.symbol.push(sanitize_cell_char(ch));
103        self.hyperlink = None;
104        self
105    }
106
107    /// Return whether this cell continues a grapheme stored in a prior cell.
108    ///
109    /// Empty symbols are reserved as continuation metadata; ordinary blank
110    /// cells contain a single space.
111    #[inline]
112    pub fn is_continuation(&self) -> bool {
113        self.symbol.is_empty()
114    }
115
116    /// Mark this cell as a continuation of a preceding wide grapheme.
117    pub(crate) fn set_continuation(&mut self, style: Style) -> &mut Self {
118        self.symbol.clear();
119        self.style = style;
120        self.hyperlink = None;
121        self
122    }
123
124    /// Return a defensively normalized symbol for terminal output.
125    ///
126    /// This is required at the flush boundary because `symbol` remains public
127    /// for compatibility and callers can mutate it without using the setters.
128    pub(crate) fn normalized_symbol(&self) -> CompactString {
129        normalize_cell_symbol(&self.symbol)
130    }
131
132    /// Set the cell's style.
133    pub fn set_style(&mut self, style: Style) -> &mut Self {
134        self.style = style;
135        self
136    }
137
138    /// Reset the cell to a blank space with default style.
139    pub fn reset(&mut self) {
140        self.symbol.clear();
141        self.symbol.push(' ');
142        self.style = Style::new();
143        self.hyperlink = None;
144    }
145}
146
147#[cfg(test)]
148mod tests {
149    use super::*;
150
151    #[test]
152    fn cell_size_within_cache_line() {
153        let size = std::mem::size_of::<Cell>();
154        assert!(
155            size <= 64,
156            "Cell size = {size}B; exceeds 64B cache-line budget. If intentional, update the const-assert and this test together."
157        );
158    }
159
160    #[test]
161    fn setters_keep_one_safe_grapheme_and_clear_links() {
162        let mut cell = Cell::default();
163        cell.hyperlink = Some(CompactString::new("https://example.com"));
164        cell.set_symbol("πŸ‘©β€πŸ’»tail\x1b");
165
166        assert_eq!(cell.symbol, "πŸ‘©β€πŸ’»");
167        assert!(cell.hyperlink.is_none());
168
169        cell.set_char('\x1b');
170        assert_eq!(cell.symbol, "\u{FFFD}");
171    }
172
173    #[test]
174    fn empty_symbol_is_explicit_continuation_state() {
175        let mut cell = Cell::default();
176        assert!(!cell.is_continuation());
177        cell.set_continuation(Style::new());
178        assert!(cell.is_continuation());
179    }
180
181    #[test]
182    fn normalized_symbol_defends_against_direct_public_mutation() {
183        let mut cell = Cell::default();
184        cell.symbol = CompactString::new("\x1b]52;c;payload");
185        assert_eq!(cell.normalized_symbol(), "\u{FFFD}");
186    }
187}