1use bitflags::bitflags;
9use compact_str::CompactString;
10use unicode_width::UnicodeWidthStr;
11
12#[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 pub fn index(self) -> u8 {
70 self as u8
71 }
72
73 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 Named(NamedColor),
83 Idx(u8),
85 Rgb(u8, u8, u8),
86}
87
88impl Color {
89 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 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#[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 pub const fn is_underlined(self) -> bool {
129 !matches!(self, UnderlineStyle::None)
130 }
131
132 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 #[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
161pub const CONTINUATION: &str = "";
163
164#[derive(Debug, Clone, PartialEq, Eq)]
165pub struct EmuCell {
166 pub ch: CompactString,
169 pub fg: Option<Color>,
171 pub bg: Option<Color>,
173 pub underline: UnderlineStyle,
174 pub underline_color: Option<Color>,
178 pub attrs: Attrs,
179}
180
181impl EmuCell {
182 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
205pub fn display_width(s: &str) -> usize {
217 s.width()
218}
219
220pub 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 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
244pub 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 #[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 #[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 #[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 #[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}