use bitflags::bitflags;
use compact_str::CompactString;
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
#[repr(u8)]
pub enum NamedColor {
Black = 0,
Red,
Green,
Yellow,
Blue,
Magenta,
Cyan,
White,
BrightBlack,
BrightRed,
BrightGreen,
BrightYellow,
BrightBlue,
BrightMagenta,
BrightCyan,
BrightWhite,
}
impl NamedColor {
pub const ALL: [NamedColor; 16] = [
NamedColor::Black,
NamedColor::Red,
NamedColor::Green,
NamedColor::Yellow,
NamedColor::Blue,
NamedColor::Magenta,
NamedColor::Cyan,
NamedColor::White,
NamedColor::BrightBlack,
NamedColor::BrightRed,
NamedColor::BrightGreen,
NamedColor::BrightYellow,
NamedColor::BrightBlue,
NamedColor::BrightMagenta,
NamedColor::BrightCyan,
NamedColor::BrightWhite,
];
pub fn index(self) -> u8 {
self as u8
}
pub fn from_index(i: u8) -> Option<Self> {
Self::ALL.get(i as usize).copied()
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum Color {
Named(NamedColor),
Idx(u8),
Rgb(u8, u8, u8),
}
impl Color {
pub fn from_index(i: u8) -> Self {
match NamedColor::from_index(i) {
Some(named) => Color::Named(named),
None => Color::Idx(i),
}
}
pub fn to_index(self) -> u8 {
match self {
Color::Named(n) => n.index(),
Color::Idx(i) => i,
Color::Rgb(r, g, b) => crate::assert::color::rgb_to_ansi256(r, g, b),
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub enum UnderlineStyle {
#[default]
None,
Single,
Double,
Curly,
Dotted,
Dashed,
}
impl UnderlineStyle {
pub const fn is_underlined(self) -> bool {
!matches!(self, UnderlineStyle::None)
}
pub const fn name(self) -> &'static str {
match self {
UnderlineStyle::None => "none",
UnderlineStyle::Single => "single",
UnderlineStyle::Double => "double",
UnderlineStyle::Curly => "curly",
UnderlineStyle::Dotted => "dotted",
UnderlineStyle::Dashed => "dashed",
}
}
}
bitflags! {
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Attrs: u8 {
const BOLD = 1 << 0;
const DIM = 1 << 1;
const ITALIC = 1 << 2;
const INVERSE = 1 << 3;
const INVISIBLE = 1 << 4;
const STRIKE = 1 << 5;
const BLINK = 1 << 6;
}
}
pub const CONTINUATION: &str = "";
#[derive(Debug, Clone, PartialEq, Eq)]
pub struct EmuCell {
pub ch: CompactString,
pub fg: Option<Color>,
pub bg: Option<Color>,
pub underline: UnderlineStyle,
pub underline_color: Option<Color>,
pub attrs: Attrs,
}
impl EmuCell {
pub const fn blank() -> Self {
EmuCell {
ch: CompactString::const_new(" "),
fg: None,
bg: None,
underline: UnderlineStyle::None,
underline_color: None,
attrs: Attrs::empty(),
}
}
pub fn has(&self, attr: Attrs) -> bool {
self.attrs.contains(attr)
}
}
impl Default for EmuCell {
fn default() -> Self {
EmuCell::blank()
}
}
pub fn display_width(s: &str) -> usize {
s.width()
}
pub fn truncate_to_columns(s: &str, columns: usize) -> String {
if columns == 0 {
return String::new();
}
if display_width(s) <= columns {
return s.to_string();
}
let budget = columns - 1;
let mut cut = 0;
for (offset, _) in s.char_indices() {
if display_width(&s[..offset]) > budget {
break;
}
cut = offset;
}
format!("{}\u{2026}", &s[..cut])
}
pub fn rows_to_strings(rows: &[Vec<EmuCell>]) -> Vec<String> {
rows.iter()
.map(|row| row.iter().map(|c| c.ch.as_str()).collect::<String>())
.collect()
}
#[cfg(test)]
mod tests {
use super::*;
fn cell(s: &str) -> EmuCell {
EmuCell {
ch: CompactString::from(s),
..EmuCell::blank()
}
}
#[test]
fn index_splits_named_from_palette() {
assert_eq!(Color::from_index(0), Color::Named(NamedColor::Black));
assert_eq!(Color::from_index(9), Color::Named(NamedColor::BrightRed));
assert_eq!(Color::from_index(15), Color::Named(NamedColor::BrightWhite));
assert_eq!(Color::from_index(16), Color::Idx(16));
assert_eq!(Color::from_index(255), Color::Idx(255));
for i in 0..=255u8 {
assert_eq!(Color::from_index(i).to_index(), i, "roundtrip {i}");
}
}
#[test]
fn continuation_cells_do_not_widen_a_row() {
let rows = vec![vec![cell("你"), cell(CONTINUATION), cell("a"), cell(" ")]];
assert_eq!(rows_to_strings(&rows), vec!["你a "]);
}
#[test]
fn blank_is_a_space_not_a_continuation() {
assert_eq!(EmuCell::blank().ch, " ");
assert_ne!(EmuCell::blank().ch, CONTINUATION);
}
}
#[cfg(test)]
mod width_tests {
use super::*;
#[test]
fn a_sequence_is_narrower_than_its_characters() {
for (name, text, columns) in [
(
"family",
"\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466}",
2,
),
("skin tone", "\u{1f44d}\u{1f3fd}", 2),
("keycap", "1\u{fe0f}\u{20e3}", 2),
("heart with a variation selector", "\u{2764}\u{fe0f}", 2),
("flag", "\u{1f1fa}\u{1f1f8}", 2),
] {
assert_eq!(display_width(text), columns, "{name} is {columns} columns");
}
}
#[test]
fn width_counts_columns_not_characters() {
assert_eq!(display_width("hello"), 5);
assert_eq!(
display_width("\u{4f60}\u{597d}"),
4,
"each CJK glyph takes two"
);
assert_eq!(display_width("e\u{301}"), 1, "a combining mark adds none");
assert_eq!(display_width(""), 0);
}
#[test]
fn truncation_stays_within_its_budget() {
for text in [
"a-very-long-title-that-will-not-fit",
"\u{4f60}\u{597d}\u{4e16}\u{754c}\u{4f60}\u{597d}",
"\u{1f468}\u{200d}\u{1f469}\u{200d}\u{1f467}\u{200d}\u{1f466} building",
"\u{1f680} deploy \u{4f60}\u{597d} done",
] {
for budget in 0..12 {
let cut = truncate_to_columns(text, budget);
assert!(
display_width(&cut) <= budget,
"{text:?} cut to {budget} came out {} wide: {cut:?}",
display_width(&cut)
);
}
}
}
#[test]
fn truncation_leaves_a_string_that_fits_alone() {
assert_eq!(truncate_to_columns("fits", 10), "fits");
assert_eq!(truncate_to_columns("fits", 4), "fits");
assert_eq!(
truncate_to_columns("\u{4f60}\u{597d}", 4),
"\u{4f60}\u{597d}"
);
}
}