use std::borrow::Cow;
use std::fmt::Write;
use gwk_theme::marks::{GlyphSet, Mark, StateBinding};
use gwk_theme::{AnsiSlot, ColorTier, Paint, Token};
use ratatui::style::{Color, Modifier, Style};
pub const fn color(slot: AnsiSlot) -> Color {
match slot {
AnsiSlot::Black => Color::Black,
AnsiSlot::Red => Color::Red,
AnsiSlot::Green => Color::Green,
AnsiSlot::Yellow => Color::Yellow,
AnsiSlot::Blue => Color::Blue,
AnsiSlot::Magenta => Color::Magenta,
AnsiSlot::Cyan => Color::Cyan,
AnsiSlot::White => Color::Gray,
AnsiSlot::BrightBlack => Color::DarkGray,
AnsiSlot::BrightRed => Color::LightRed,
AnsiSlot::BrightGreen => Color::LightGreen,
AnsiSlot::BrightYellow => Color::LightYellow,
AnsiSlot::BrightBlue => Color::LightBlue,
AnsiSlot::BrightMagenta => Color::LightMagenta,
AnsiSlot::BrightCyan => Color::LightCyan,
AnsiSlot::BrightWhite => Color::White,
}
}
pub fn paint_style(paint: Paint) -> Style {
match paint {
Paint::Rgb(r, g, b) => Style::default().fg(Color::Rgb(r, g, b)),
Paint::Indexed(index) => Style::default().fg(Color::Indexed(index)),
Paint::Slot(slot) => Style::default().fg(color(slot)),
Paint::BoldSlot(slot) => Style::default()
.fg(color(slot))
.add_modifier(Modifier::BOLD),
Paint::Reset => Style::default().fg(Color::Reset),
Paint::ReverseVideo => Style::default().add_modifier(Modifier::REVERSED),
Paint::Unpainted => Style::default(),
}
}
pub fn token_style(token: &Token, tier: ColorTier) -> Style {
paint_style(token.paint(tier))
}
pub fn state_style(state: &StateBinding, tier: ColorTier) -> Style {
match gwk_theme::SIGNAL.iter().find(|t| t.name == state.token) {
Some(token) => token_style(token, tier),
None => Style::default(),
}
}
pub fn glyph(mark: &Mark, frame: usize, glyphs: GlyphSet) -> char {
mark.at(frame, glyphs)
}
pub fn safe_text(text: &str, cell_budget: usize) -> Cow<'_, str> {
let safe = |c: char| {
(c.is_ascii_graphic() || c == ' ') || (!c.is_ascii() && gwk_theme::marks::is_admissible(c))
};
let mut escaped: Option<String> = None;
let mut cells = 0;
for (offset, c) in text.char_indices() {
if safe(c) {
if cells == cell_budget {
return Cow::Owned(escaped.unwrap_or_else(|| text[..offset].to_string()));
}
if let Some(output) = &mut escaped {
output.push(c);
}
cells += 1;
} else {
let replacement = format!("\\u{{{:X}}}", c as u32);
if replacement.len() > cell_budget.saturating_sub(cells) {
return Cow::Owned(escaped.unwrap_or_else(|| text[..offset].to_string()));
}
let output = escaped.get_or_insert_with(|| {
let mut prefix = String::with_capacity(text.len().min(cell_budget));
prefix.push_str(&text[..offset]);
prefix
});
write!(output, "{replacement}").expect("writing an escape into a String cannot fail");
cells += replacement.len();
}
}
escaped.map_or(Cow::Borrowed(text), Cow::Owned)
}
pub fn safe_text_lines(text: &str, cell_width: usize, max_lines: usize) -> (Vec<String>, bool) {
if cell_width == 0 || max_lines == 0 {
return (Vec::new(), !text.is_empty());
}
let safe = |c: char| {
(c.is_ascii_graphic() || c == ' ') || (!c.is_ascii() && gwk_theme::marks::is_admissible(c))
};
let mut lines = vec![String::new()];
let mut cells = 0;
for c in text.chars() {
if safe(c) {
if cells == cell_width {
if lines.len() == max_lines {
return (lines, true);
}
lines.push(String::new());
cells = 0;
}
lines.last_mut().expect("one line always exists").push(c);
cells += 1;
continue;
}
for output in format!("\\u{{{:X}}}", c as u32).chars() {
if cells == cell_width {
if lines.len() == max_lines {
return (lines, true);
}
lines.push(String::new());
cells = 0;
}
lines
.last_mut()
.expect("one line always exists")
.push(output);
cells += 1;
}
}
(lines, false)
}
pub(crate) fn binding(name: &str) -> &'static StateBinding {
gwk_theme::marks::STATES
.iter()
.find(|s| s.name == name)
.expect("the state bindings are pinned")
}
#[cfg(test)]
mod tests {
use std::collections::BTreeSet;
use gwk_theme::SIGNAL;
use gwk_theme::marks::{MARKS, STATES, mark};
use super::*;
fn token(name: &str) -> &'static Token {
SIGNAL
.iter()
.find(|token| token.name == name)
.unwrap_or_else(|| panic!("no token {name:?}"))
}
#[test]
fn tier_16_slots_map_onto_the_renderers_shifted_names() {
assert_eq!(color(AnsiSlot::White), Color::Gray);
assert_eq!(color(AnsiSlot::BrightBlack), Color::DarkGray);
assert_eq!(color(AnsiSlot::BrightWhite), Color::White);
let mapped: BTreeSet<String> = AnsiSlot::ALL
.iter()
.map(|slot| format!("{:?}", color(*slot)))
.collect();
assert_eq!(mapped.len(), 16);
}
#[test]
fn unsafe_wire_text_is_retypable_and_safe_text_stays_borrowed() {
assert!(matches!(
safe_text("plain ASCII", usize::MAX),
Cow::Borrowed(_)
));
assert!(matches!(
safe_text("admitted ▸", usize::MAX),
Cow::Borrowed(_)
));
assert_eq!(
safe_text("unsafe ◆ 你好 ⚠\n", usize::MAX),
"unsafe \\u{25C6} \\u{4F60}\\u{597D} \\u{26A0}\\u{A}"
);
assert_eq!(safe_text("plain ASCII beyond", 5), "plain");
assert_eq!(safe_text("ok ◆ unseen", 3), "ok ");
assert_eq!(
safe_text_lines("abcdef", 3, 2),
(vec!["abc".into(), "def".into()], false)
);
assert_eq!(
safe_text_lines("◆ tail", 4, 2),
(vec!["\\u{2".into(), "5C6}".into()], true)
);
}
#[test]
fn tier_truecolor_and_256_carry_colour_and_mono_carries_none() {
assert_eq!(
token_style(token("gws_fail"), ColorTier::Truecolor),
Style::default().fg(Color::Rgb(0xFF, 0x6E, 0x6E))
);
assert_eq!(
token_style(token("gws_fail"), ColorTier::Xterm256),
Style::default().fg(Color::Indexed(203))
);
assert_eq!(
token_style(token("gws_fail"), ColorTier::Ansi16),
Style::default().fg(Color::LightRed)
);
assert_eq!(
token_style(token("gws_fail"), ColorTier::Mono),
Style::default().fg(Color::Reset)
);
}
#[test]
fn tier_bold_is_the_escalation_channel_at_sixteen_only() {
let bright = token("gws_hue_bright");
let at16 = token_style(bright, ColorTier::Ansi16);
assert_eq!(at16.fg, Some(Color::LightCyan));
assert!(at16.add_modifier.contains(Modifier::BOLD));
assert_eq!(
token_style(token("gws_hue"), ColorTier::Ansi16).fg,
Some(Color::LightCyan)
);
assert!(
!token_style(token("gws_hue"), ColorTier::Ansi16)
.add_modifier
.contains(Modifier::BOLD)
);
assert!(
!token_style(bright, ColorTier::Mono)
.add_modifier
.contains(Modifier::BOLD),
"no ratified row assigns weight at mono"
);
}
#[test]
fn tier_reverse_video_is_an_attribute_and_never_a_colour() {
for tier in ColorTier::ALL
.iter()
.filter(|t| matches!(t, ColorTier::Ansi16) || matches!(t, ColorTier::Mono))
{
for name in ["gws_focus", "gws_selection"] {
let style = token_style(token(name), *tier);
assert_eq!(style.fg, None, "{name} at {}", tier.as_str());
assert!(
style.add_modifier.contains(Modifier::REVERSED),
"{name} at {}",
tier.as_str()
);
}
}
}
#[test]
fn tier_an_unpainted_token_sets_no_foreground_at_all() {
for tier in ColorTier::ALL {
for name in ["gws_bg", "gws_surface", "gws_surface_2"] {
assert_eq!(token_style(token(name), *tier), Style::default());
}
assert_eq!(
token_style(token("gws_faint"), *tier) == Style::default(),
matches!(tier, ColorTier::Ansi16 | ColorTier::Mono),
"faint at {}",
tier.as_str()
);
}
}
#[test]
fn tier_every_state_resolves_to_a_style_at_every_tier() {
for tier in ColorTier::ALL {
for state in STATES {
if matches!(tier, ColorTier::Truecolor) {
assert!(
state_style(state, *tier).fg.is_some(),
"state {:?} has no colour at truecolor",
state.name
);
}
}
}
}
#[test]
fn a_cycling_mark_advances_and_a_static_one_does_not() {
let spinner = mark("spinner").expect("spinner");
let frames: BTreeSet<char> = (0..8)
.map(|f| glyph(spinner, f, GlyphSet::Unicode))
.collect();
assert_eq!(frames.len(), 8);
assert_eq!(
glyph(spinner, 8, GlyphSet::Unicode),
glyph(spinner, 0, GlyphSet::Unicode)
);
let done = mark("done").expect("done");
assert_eq!(
glyph(done, 0, GlyphSet::Unicode),
glyph(done, 7, GlyphSet::Unicode)
);
for entry in MARKS {
assert_eq!(glyph(entry, 0, GlyphSet::Ascii), entry.ascii);
assert_eq!(glyph(entry, 5, GlyphSet::Ascii), entry.ascii);
}
}
#[test]
fn tier_and_glyph_set_are_independent_escapes() {
let spinner = mark("spinner").expect("spinner");
for tier in ColorTier::ALL {
assert_eq!(
glyph(spinner, 3, GlyphSet::Unicode),
spinner.glyphs[3],
"the glyph set moved with the tier at {}",
tier.as_str()
);
}
assert_eq!(
token_style(token("gws_fail"), ColorTier::Truecolor).fg,
Some(Color::Rgb(0xFF, 0x6E, 0x6E)),
"the tier moved with the glyph set"
);
}
}