Skip to main content

saorsa_core/
cell.rs

1//! Cell type — a single terminal cell.
2
3use crate::style::Style;
4use unicode_width::UnicodeWidthStr;
5
6/// A single cell in the terminal screen buffer.
7#[derive(Clone, Debug, PartialEq, Eq)]
8pub struct Cell {
9    /// The grapheme cluster displayed in this cell.
10    pub grapheme: String,
11    /// The style of this cell.
12    pub style: Style,
13    /// Display width (1 for most chars, 2 for CJK/emoji, 0 for continuation).
14    pub width: u8,
15}
16
17impl Cell {
18    /// Create a new cell, auto-detecting width from the grapheme.
19    pub fn new(grapheme: impl Into<String>, style: Style) -> Self {
20        let grapheme = grapheme.into();
21        let width = UnicodeWidthStr::width(grapheme.as_str()) as u8;
22        Self {
23            grapheme,
24            style,
25            width,
26        }
27    }
28
29    /// Create a blank cell (space, default style, width 1).
30    pub fn blank() -> Self {
31        Self {
32            grapheme: " ".into(),
33            style: Style::default(),
34            width: 1,
35        }
36    }
37
38    /// Returns true if this is a blank cell (space with default style).
39    pub fn is_blank(&self) -> bool {
40        self.grapheme == " " && self.style.is_empty() && self.width == 1
41    }
42
43    /// Returns true if this is a wide character (width > 1).
44    pub fn is_wide(&self) -> bool {
45        self.width > 1
46    }
47
48    /// Returns true if this is a continuation cell (width == 0).
49    ///
50    /// Continuation cells occupy the second column of a wide character.
51    pub fn is_continuation(&self) -> bool {
52        self.width == 0
53    }
54
55    /// Create a continuation cell (placeholder for the second cell of a wide character).
56    pub fn continuation() -> Self {
57        Self {
58            grapheme: String::new(),
59            style: Style::default(),
60            width: 0,
61        }
62    }
63}
64
65#[cfg(test)]
66mod tests {
67    use super::*;
68    use crate::color::{Color, NamedColor};
69
70    #[test]
71    fn blank_cell() {
72        let c = Cell::blank();
73        assert!(c.is_blank());
74        assert_eq!(c.width, 1);
75    }
76
77    #[test]
78    fn ascii_cell() {
79        let c = Cell::new("A", Style::default());
80        assert_eq!(c.width, 1);
81        assert!(!c.is_wide());
82    }
83
84    #[test]
85    fn cjk_cell() {
86        let c = Cell::new("\u{4e16}", Style::default()); // 世
87        assert_eq!(c.width, 2);
88        assert!(c.is_wide());
89    }
90
91    #[test]
92    fn continuation_cell() {
93        let c = Cell::continuation();
94        assert_eq!(c.width, 0);
95        assert!(c.grapheme.is_empty());
96    }
97
98    #[test]
99    fn styled_not_blank() {
100        let c = Cell::new(" ", Style::new().fg(Color::Named(NamedColor::Red)));
101        assert!(!c.is_blank());
102    }
103
104    #[test]
105    fn space_default_is_blank() {
106        let c = Cell::new(" ", Style::default());
107        assert!(c.is_blank());
108    }
109
110    // --- Task 6: Unicode cell tests ---
111
112    #[test]
113    fn cell_from_emoji_width_two() {
114        let c = Cell::new("\u{1f389}", Style::default()); // 🎉
115        assert_eq!(c.width, 2);
116        assert!(c.is_wide());
117    }
118
119    #[test]
120    fn cell_from_combining_mark_width_zero() {
121        // U+0301 combining acute accent alone
122        let c = Cell::new("\u{0301}", Style::default());
123        assert_eq!(c.width, 0);
124    }
125
126    #[test]
127    fn cell_from_cjk_width_two() {
128        let c = Cell::new("\u{6f22}", Style::default()); // 漢
129        assert_eq!(c.width, 2);
130        assert!(c.is_wide());
131    }
132
133    #[test]
134    fn cell_from_ascii_width_one() {
135        let c = Cell::new("A", Style::default());
136        assert_eq!(c.width, 1);
137        assert!(!c.is_wide());
138    }
139
140    #[test]
141    fn cell_equality_same_grapheme_and_style() {
142        let style = Style::new().fg(Color::Named(NamedColor::Green));
143        let c1 = Cell::new("X", style.clone());
144        let c2 = Cell::new("X", style);
145        assert_eq!(c1, c2);
146    }
147
148    #[test]
149    fn cell_inequality_different_width() {
150        // ASCII "A" (width 1) vs CJK "世" (width 2)
151        let c1 = Cell::new("A", Style::default());
152        let c2 = Cell::new("\u{4e16}", Style::default());
153        assert_ne!(c1, c2);
154        assert_ne!(c1.width, c2.width);
155    }
156
157    // --- Multi-codepoint emoji cell tests ---
158
159    #[test]
160    fn cell_from_zwj_emoji_width_two() {
161        // ZWJ family emoji: man + ZWJ + woman + ZWJ + girl
162        let c = Cell::new(
163            "\u{1F468}\u{200D}\u{1F469}\u{200D}\u{1F467}",
164            Style::default(),
165        );
166        assert_eq!(c.width, 2);
167        assert!(c.is_wide());
168    }
169
170    #[test]
171    fn cell_from_flag_emoji_width_two() {
172        // US flag: regional indicator U + regional indicator S
173        let c = Cell::new("\u{1F1FA}\u{1F1F8}", Style::default());
174        assert_eq!(c.width, 2);
175        assert!(c.is_wide());
176    }
177
178    #[test]
179    fn cell_from_skin_tone_emoji_width_two() {
180        // Thumbs up + medium skin tone
181        let c = Cell::new("\u{1F44D}\u{1F3FD}", Style::default());
182        assert_eq!(c.width, 2);
183        assert!(c.is_wide());
184    }
185
186    #[test]
187    fn cell_continuation_after_emoji() {
188        // Continuation cell should be width 0 regardless of what preceded it
189        let cont = Cell::continuation();
190        assert!(cont.is_continuation());
191        assert_eq!(cont.width, 0);
192        assert!(cont.grapheme.is_empty());
193    }
194}