use std::collections::BTreeMap;
use std::sync::Arc;
use crate::color::{ColorSystem, SimpleColor as Color};
pub const NULL_STYLE: Style = Style {
color: None,
bgcolor: None,
bold: None,
dim: None,
italic: None,
underline: None,
blink: None,
blink2: None,
reverse: None,
conceal: None,
strike: None,
underline2: None,
frame: None,
encircle: None,
overline: None,
};
#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
pub struct Style {
pub color: Option<Color>,
pub bgcolor: Option<Color>,
pub bold: Option<bool>,
pub dim: Option<bool>,
pub italic: Option<bool>,
pub underline: Option<bool>,
pub blink: Option<bool>,
pub blink2: Option<bool>,
pub reverse: Option<bool>,
pub conceal: Option<bool>,
pub strike: Option<bool>,
pub underline2: Option<bool>,
pub frame: Option<bool>,
pub encircle: Option<bool>,
pub overline: Option<bool>,
}
impl Style {
pub fn new() -> Self {
Self::default()
}
pub fn color(color: Color) -> Self {
Self {
color: Some(color),
..Default::default()
}
}
pub fn bgcolor(color: Color) -> Self {
Self {
bgcolor: Some(color),
..Default::default()
}
}
pub fn with_color(mut self, color: Color) -> Self {
self.color = Some(color);
self
}
pub fn with_bgcolor(mut self, color: Color) -> Self {
self.bgcolor = Some(color);
self
}
pub fn with_bold(mut self, bold: bool) -> Self {
self.bold = Some(bold);
self
}
pub fn with_dim(mut self, dim: bool) -> Self {
self.dim = Some(dim);
self
}
pub fn with_italic(mut self, italic: bool) -> Self {
self.italic = Some(italic);
self
}
pub fn with_underline(mut self, underline: bool) -> Self {
self.underline = Some(underline);
self
}
pub fn with_reverse(mut self, reverse: bool) -> Self {
self.reverse = Some(reverse);
self
}
pub fn with_strike(mut self, strike: bool) -> Self {
self.strike = Some(strike);
self
}
pub fn with_blink2(mut self, blink2: bool) -> Self {
self.blink2 = Some(blink2);
self
}
pub fn with_conceal(mut self, conceal: bool) -> Self {
self.conceal = Some(conceal);
self
}
pub fn with_underline2(mut self, underline2: bool) -> Self {
self.underline2 = Some(underline2);
self
}
pub fn with_frame(mut self, frame: bool) -> Self {
self.frame = Some(frame);
self
}
pub fn with_encircle(mut self, encircle: bool) -> Self {
self.encircle = Some(encircle);
self
}
pub fn with_overline(mut self, overline: bool) -> Self {
self.overline = Some(overline);
self
}
pub fn combine(&self, other: &Style) -> Self {
Style {
color: other.color.or(self.color),
bgcolor: other.bgcolor.or(self.bgcolor),
bold: other.bold.or(self.bold),
dim: other.dim.or(self.dim),
italic: other.italic.or(self.italic),
underline: other.underline.or(self.underline),
blink: other.blink.or(self.blink),
blink2: other.blink2.or(self.blink2),
reverse: other.reverse.or(self.reverse),
conceal: other.conceal.or(self.conceal),
strike: other.strike.or(self.strike),
underline2: other.underline2.or(self.underline2),
frame: other.frame.or(self.frame),
encircle: other.encircle.or(self.encircle),
overline: other.overline.or(self.overline),
}
}
pub fn parse(s: &str) -> Option<Self> {
let mut style = Style::new();
let mut on_background = false;
let mut words = s.split_whitespace().peekable();
while let Some(word) = words.next() {
let word_lower = word.to_lowercase();
if word_lower == "on" {
on_background = true;
continue;
}
if on_background {
if let Some(color) = Color::parse(&word_lower) {
style.bgcolor = Some(color);
on_background = false;
continue;
}
on_background = false;
}
if let Some(named) = crate::theme::get_default_style(&word_lower) {
style = style.combine(&named);
continue;
}
if word_lower == "not" {
if let Some(&next_word) = words.peek() {
let next_lower = next_word.to_lowercase();
match next_lower.as_str() {
"bold" | "b" => {
style.bold = Some(false);
words.next();
continue;
}
"dim" | "d" => {
style.dim = Some(false);
words.next();
continue;
}
"italic" | "i" => {
style.italic = Some(false);
words.next();
continue;
}
"underline" | "u" => {
style.underline = Some(false);
words.next();
continue;
}
"blink" => {
style.blink = Some(false);
words.next();
continue;
}
"blink2" => {
style.blink2 = Some(false);
words.next();
continue;
}
"reverse" | "r" => {
style.reverse = Some(false);
words.next();
continue;
}
"conceal" | "c" => {
style.conceal = Some(false);
words.next();
continue;
}
"strike" | "s" => {
style.strike = Some(false);
words.next();
continue;
}
"underline2" | "uu" => {
style.underline2 = Some(false);
words.next();
continue;
}
"frame" => {
style.frame = Some(false);
words.next();
continue;
}
"encircle" => {
style.encircle = Some(false);
words.next();
continue;
}
"overline" | "o" => {
style.overline = Some(false);
words.next();
continue;
}
_ => {}
}
}
continue;
}
match word_lower.as_str() {
"bold" | "b" => style.bold = Some(true),
"dim" | "d" => style.dim = Some(true),
"italic" | "i" => style.italic = Some(true),
"underline" | "u" => style.underline = Some(true),
"blink" => style.blink = Some(true),
"blink2" => style.blink2 = Some(true),
"reverse" | "r" => style.reverse = Some(true),
"conceal" | "c" => style.conceal = Some(true),
"strike" | "s" => style.strike = Some(true),
"underline2" | "uu" => style.underline2 = Some(true),
"frame" => style.frame = Some(true),
"encircle" => style.encircle = Some(true),
"overline" | "o" => style.overline = Some(true),
_ => {
if let Some(color) = Color::parse(&word_lower) {
style.color = Some(color);
}
}
}
}
Some(style)
}
pub fn is_null(&self) -> bool {
*self == NULL_STYLE
}
pub fn render(&self, text: &str, color_system: ColorSystem) -> String {
if text.is_empty() {
return String::new();
}
let attrs = self.make_ansi_codes(color_system);
if attrs.is_empty() {
text.to_string()
} else {
format!("\x1b[{}m{}\x1b[0m", attrs, text)
}
}
pub fn render_open(&self, text: &str, color_system: ColorSystem) -> String {
if text.is_empty() {
return String::new();
}
let attrs = self.make_ansi_codes(color_system);
if attrs.is_empty() {
text.to_string()
} else {
format!("\x1b[{}m{}", attrs, text)
}
}
fn make_ansi_codes(&self, color_system: ColorSystem) -> String {
let mut sgr: Vec<String> = Vec::new();
if self.bold == Some(false) || self.dim == Some(false) {
sgr.push("22".to_string());
}
if self.italic == Some(false) {
sgr.push("23".to_string());
}
if self.underline == Some(false) || self.underline2 == Some(false) {
sgr.push("24".to_string());
}
if self.blink == Some(false) || self.blink2 == Some(false) {
sgr.push("25".to_string());
}
if self.reverse == Some(false) {
sgr.push("27".to_string());
}
if self.conceal == Some(false) {
sgr.push("28".to_string());
}
if self.strike == Some(false) {
sgr.push("29".to_string());
}
if self.frame == Some(false) || self.encircle == Some(false) {
sgr.push("54".to_string());
}
if self.overline == Some(false) {
sgr.push("55".to_string());
}
if self.bold == Some(true) {
sgr.push("1".to_string());
}
if self.dim == Some(true) {
sgr.push("2".to_string());
}
if self.italic == Some(true) {
sgr.push("3".to_string());
}
if self.underline == Some(true) {
sgr.push("4".to_string());
}
if self.blink == Some(true) {
sgr.push("5".to_string());
}
if self.blink2 == Some(true) {
sgr.push("6".to_string());
}
if self.reverse == Some(true) {
sgr.push("7".to_string());
}
if self.conceal == Some(true) {
sgr.push("8".to_string());
}
if self.strike == Some(true) {
sgr.push("9".to_string());
}
if self.underline2 == Some(true) {
sgr.push("21".to_string());
}
if self.frame == Some(true) {
sgr.push("51".to_string());
}
if self.encircle == Some(true) {
sgr.push("52".to_string());
}
if self.overline == Some(true) {
sgr.push("53".to_string());
}
if let Some(color) = self.color {
let downgraded = color.downgrade(color_system);
sgr.extend(downgraded.get_ansi_codes(true));
}
if let Some(bgcolor) = self.bgcolor {
let downgraded = bgcolor.downgrade(color_system);
sgr.extend(downgraded.get_ansi_codes(false));
}
sgr.join(";")
}
pub fn get_html_style(&self) -> String {
let mut css: Vec<String> = Vec::new();
let (color, bgcolor) = if self.reverse == Some(true) {
(self.bgcolor, self.color)
} else {
(self.color, self.bgcolor)
};
if let Some(c) = color {
let hex = c.get_hex();
css.push(format!("color: {}", hex));
css.push(format!("text-decoration-color: {}", hex));
}
if let Some(c) = bgcolor {
let hex = c.get_hex();
css.push(format!("background-color: {}", hex));
}
if self.bold == Some(true) {
css.push("font-weight: bold".to_string());
}
if self.italic == Some(true) {
css.push("font-style: italic".to_string());
}
let mut decorations = Vec::new();
if self.underline == Some(true) {
decorations.push("underline");
}
if self.strike == Some(true) {
decorations.push("line-through");
}
if self.overline == Some(true) {
decorations.push("overline");
}
if !decorations.is_empty() {
css.push(format!("text-decoration: {}", decorations.join(" ")));
}
css.join("; ")
}
pub fn chain(styles: &[Style]) -> Self {
let mut result = Style::new();
for style in styles {
result = result.combine(style);
}
result
}
pub fn from_color(color: Option<Color>, bgcolor: Option<Color>) -> Self {
Self {
color,
bgcolor,
..Default::default()
}
}
pub fn on<I, K>(meta: Option<BTreeMap<String, MetaValue>>, handlers: I) -> StyleMeta
where
I: IntoIterator<Item = (K, MetaValue)>,
K: Into<String>,
{
let mut merged = meta.unwrap_or_default();
for (key, value) in handlers {
merged.insert(format!("@{}", key.into()), value);
}
StyleMeta {
meta: if merged.is_empty() {
None
} else {
Some(Arc::new(merged))
},
..Default::default()
}
}
pub fn normalize(style: &str) -> String {
let normalized = style.trim().to_lowercase();
if normalized.is_empty() {
return "none".to_string();
}
fn is_attr(word: &str) -> bool {
matches!(
word,
"bold"
| "b"
| "dim"
| "d"
| "italic"
| "i"
| "underline"
| "u"
| "blink"
| "blink2"
| "reverse"
| "r"
| "conceal"
| "c"
| "strike"
| "s"
| "underline2"
| "uu"
| "frame"
| "encircle"
| "overline"
| "o"
)
}
let mut words = normalized.split_whitespace().peekable();
while let Some(word) = words.next() {
match word {
"on" => match words.next() {
Some(color) if Color::parse(color).is_some() => {}
_ => return normalized,
},
"not" => match words.next() {
Some(attr) if is_attr(attr) => {}
_ => return normalized,
},
_ => {
if is_attr(word)
|| Color::parse(word).is_some()
|| crate::theme::get_default_style(word).is_some()
{
continue;
}
return normalized;
}
}
}
let parsed = Self::parse(&normalized).unwrap_or_default();
let canonical = parsed.to_markup_string();
if canonical.is_empty() {
"none".to_string()
} else {
canonical
}
}
pub fn pick_first(values: &[Option<Style>]) -> Style {
values
.iter()
.flatten()
.copied()
.next()
.expect("expected at least one non-None style")
}
pub fn test(&self, text: &str, color_system: ColorSystem) -> String {
self.render(text, color_system)
}
pub fn background_style(&self) -> Self {
Style {
bgcolor: self.bgcolor,
..Default::default()
}
}
pub fn without_color(&self) -> Self {
Style {
color: None,
bgcolor: None,
bold: self.bold,
dim: self.dim,
italic: self.italic,
underline: self.underline,
blink: self.blink,
blink2: self.blink2,
reverse: self.reverse,
conceal: self.conceal,
strike: self.strike,
underline2: self.underline2,
frame: self.frame,
encircle: self.encircle,
overline: self.overline,
}
}
pub fn has_transparent_background(&self) -> bool {
matches!(self.bgcolor, None | Some(Color::Default))
}
pub fn to_markup_string(&self) -> String {
use crate::color::ANSI_COLOR_NAMES;
let mut parts: Vec<&str> = Vec::new();
macro_rules! attr {
($field:ident, $name:expr, $neg:expr) => {
match self.$field {
Some(true) => parts.push($name),
Some(false) => parts.push($neg),
None => {}
}
};
($field:ident, $name:expr) => {
if self.$field == Some(true) {
parts.push($name);
}
};
}
attr!(bold, "bold", "not bold");
attr!(dim, "dim", "not dim");
attr!(italic, "italic", "not italic");
attr!(underline, "underline", "not underline");
attr!(blink, "blink", "not blink");
attr!(blink2, "blink2", "not blink2");
attr!(reverse, "reverse", "not reverse");
attr!(conceal, "conceal", "not conceal");
attr!(strike, "strike", "not strike");
attr!(underline2, "underline2", "not underline2");
attr!(frame, "frame", "not frame");
attr!(encircle, "encircle", "not encircle");
attr!(overline, "overline", "not overline");
let mut owned_parts: Vec<String> = parts.iter().map(|s| s.to_string()).collect();
if let Some(ref color) = self.color {
owned_parts.push(simple_color_name(color, &ANSI_COLOR_NAMES));
}
if let Some(ref bgcolor) = self.bgcolor {
owned_parts.push(format!(
"on {}",
simple_color_name(bgcolor, &ANSI_COLOR_NAMES)
));
}
owned_parts.join(" ")
}
}
fn simple_color_name(color: &Color, color_names: &std::collections::HashMap<&str, u8>) -> String {
match color {
Color::Default => String::new(),
Color::Standard(n) => {
for (name, &idx) in color_names.iter() {
if idx == *n {
return name.to_string();
}
}
format!("color({})", n)
}
Color::EightBit(n) => format!("color({})", n),
Color::Rgb { r, g, b } => format!("#{:02x}{:02x}{:02x}", r, g, b),
}
}
#[derive(Debug, Clone)]
pub struct StyleStack {
_stack: Vec<Style>,
}
impl StyleStack {
pub fn new(default_style: Style) -> Self {
StyleStack {
_stack: vec![default_style],
}
}
pub fn current(&self) -> Style {
*self._stack.last().unwrap()
}
pub fn push(&mut self, style: Style) {
let combined = self.current().combine(&style);
self._stack.push(combined);
}
pub fn pop(&mut self) -> Style {
self._stack.pop();
self.current()
}
}
impl std::ops::Add for Style {
type Output = Self;
fn add(self, other: Self) -> Self {
self.combine(&other)
}
}
#[derive(Debug, Clone, PartialEq, Eq, Default)]
pub struct StyleMeta {
pub link: Option<Arc<str>>,
pub link_id: Option<Arc<str>>,
pub meta: Option<Arc<BTreeMap<String, MetaValue>>>,
}
impl StyleMeta {
pub fn new() -> Self {
Self::default()
}
pub fn with_link(link: impl Into<Arc<str>>) -> Self {
StyleMeta {
link: Some(link.into()),
..Default::default()
}
}
pub fn is_empty(&self) -> bool {
self.link.is_none() && self.link_id.is_none() && self.meta.is_none()
}
pub fn combine(&self, other: &StyleMeta) -> Self {
StyleMeta {
link: other.link.clone().or_else(|| self.link.clone()),
link_id: other.link_id.clone().or_else(|| self.link_id.clone()),
meta: match (&self.meta, &other.meta) {
(Some(a), Some(b)) => {
let mut merged = (**a).clone();
merged.extend((**b).clone());
Some(Arc::new(merged))
}
(None, Some(b)) => Some(b.clone()),
(Some(a), None) => Some(a.clone()),
(None, None) => None,
},
}
}
}
#[derive(Debug, Clone, PartialEq, Eq)]
pub enum MetaValue {
None,
Bool(bool),
Int(i64),
Str(Arc<str>),
List(Vec<MetaValue>),
Tuple(Vec<MetaValue>),
Map(BTreeMap<String, MetaValue>),
}
impl Default for MetaValue {
fn default() -> Self {
MetaValue::None
}
}
impl MetaValue {
pub fn str(value: impl Into<Arc<str>>) -> Self {
MetaValue::Str(value.into())
}
pub fn parse_python_literal(input: &str) -> Option<Self> {
struct Parser<'a> {
s: &'a str,
i: usize,
}
impl<'a> Parser<'a> {
fn new(s: &'a str) -> Self {
Self { s, i: 0 }
}
fn is_eof(&self) -> bool {
self.i >= self.s.len()
}
fn rest(&self) -> &'a str {
&self.s[self.i..]
}
fn skip_ws(&mut self) {
while let Some(ch) = self.peek() {
if ch.is_whitespace() {
self.bump(ch);
} else {
break;
}
}
}
fn peek(&self) -> Option<char> {
self.rest().chars().next()
}
fn bump(&mut self, ch: char) {
self.i += ch.len_utf8();
}
fn eat(&mut self, expected: char) -> bool {
self.skip_ws();
if self.peek() == Some(expected) {
self.bump(expected);
true
} else {
false
}
}
fn parse_value(&mut self) -> Option<MetaValue> {
self.skip_ws();
let ch = self.peek()?;
match ch {
'\'' | '"' => self.parse_string().map(|s| MetaValue::Str(Arc::from(s))),
'[' => self.parse_list(),
'{' => self.parse_map(),
'(' => self.parse_parens(),
'-' | '0'..='9' => self.parse_int().map(MetaValue::Int),
_ => self.parse_ident_or_keyword(),
}
}
fn parse_ident_or_keyword(&mut self) -> Option<MetaValue> {
self.skip_ws();
let start = self.i;
while let Some(ch) = self.peek() {
if ch.is_ascii_alphanumeric() || ch == '_' {
self.bump(ch);
} else {
break;
}
}
if self.i == start {
return None;
}
let ident = &self.s[start..self.i];
match ident {
"None" => Some(MetaValue::None),
"True" => Some(MetaValue::Bool(true)),
"False" => Some(MetaValue::Bool(false)),
_ => None,
}
}
fn parse_int(&mut self) -> Option<i64> {
self.skip_ws();
let start = self.i;
if self.peek() == Some('-') {
self.bump('-');
}
let mut saw_digit = false;
while let Some(ch) = self.peek() {
if ch.is_ascii_digit() {
saw_digit = true;
self.bump(ch);
} else {
break;
}
}
if !saw_digit {
self.i = start;
return None;
}
self.s[start..self.i].parse::<i64>().ok()
}
fn parse_string(&mut self) -> Option<String> {
self.skip_ws();
let quote = self.peek()?;
if quote != '\'' && quote != '"' {
return None;
}
self.bump(quote);
let mut out = String::new();
while let Some(ch) = self.peek() {
self.bump(ch);
if ch == quote {
return Some(out);
}
if ch == '\\' {
let esc = self.peek()?;
self.bump(esc);
match esc {
'n' => out.push('\n'),
'r' => out.push('\r'),
't' => out.push('\t'),
'\\' => out.push('\\'),
'\'' => out.push('\''),
'"' => out.push('"'),
other => out.push(other),
}
} else {
out.push(ch);
}
}
None
}
fn parse_list(&mut self) -> Option<MetaValue> {
if !self.eat('[') {
return None;
}
let mut items = Vec::new();
loop {
self.skip_ws();
if self.eat(']') {
break;
}
let value = self.parse_value()?;
items.push(value);
self.skip_ws();
if self.eat(']') {
break;
}
if !self.eat(',') {
return None;
}
}
Some(MetaValue::List(items))
}
fn parse_map(&mut self) -> Option<MetaValue> {
if !self.eat('{') {
return None;
}
let mut map: BTreeMap<String, MetaValue> = BTreeMap::new();
loop {
self.skip_ws();
if self.eat('}') {
break;
}
let key = match self.parse_value()? {
MetaValue::Str(s) => s.to_string(),
_ => return None,
};
if !self.eat(':') {
return None;
}
let value = self.parse_value()?;
map.insert(key, value);
self.skip_ws();
if self.eat('}') {
break;
}
if !self.eat(',') {
return None;
}
}
Some(MetaValue::Map(map))
}
fn parse_parens(&mut self) -> Option<MetaValue> {
if !self.eat('(') {
return None;
}
self.skip_ws();
if self.eat(')') {
return Some(MetaValue::Tuple(Vec::new()));
}
let first = self.parse_value()?;
self.skip_ws();
if self.eat(')') {
return Some(first);
}
if !self.eat(',') {
return None;
}
let mut items = vec![first];
loop {
self.skip_ws();
if self.eat(')') {
break;
}
let value = self.parse_value()?;
items.push(value);
self.skip_ws();
if self.eat(')') {
break;
}
if !self.eat(',') {
return None;
}
}
Some(MetaValue::Tuple(items))
}
}
let mut p = Parser::new(input);
let value = p.parse_value()?;
p.skip_ws();
if !p.is_eof() {
return None;
}
Some(value)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn test_style_builder() {
let style = Style::new().with_bold(true).with_color(Color::Standard(1));
assert_eq!(style.bold, Some(true));
assert_eq!(style.color, Some(Color::Standard(1)));
}
#[test]
fn test_style_combine() {
let base = Style::new().with_bold(true);
let overlay = Style::new().with_italic(true);
let combined = base.combine(&overlay);
assert_eq!(combined.bold, Some(true));
assert_eq!(combined.italic, Some(true));
}
#[test]
fn test_style_parse() {
let style = Style::parse("bold red").unwrap();
assert_eq!(style.bold, Some(true));
assert_eq!(style.color, Some(Color::Standard(1)));
}
#[test]
fn test_style_parse_background() {
let style = Style::parse("on blue").unwrap();
assert_eq!(style.color, None);
assert_eq!(style.bgcolor, Some(Color::Standard(4)));
let style = Style::parse("bold red on blue").unwrap();
assert_eq!(style.bold, Some(true));
assert_eq!(style.color, Some(Color::Standard(1)));
assert_eq!(style.bgcolor, Some(Color::Standard(4)));
}
#[test]
fn test_null_style_is_default() {
assert_eq!(NULL_STYLE, Style::default());
assert!(NULL_STYLE.is_null());
}
#[test]
fn test_null_style_all_none() {
assert_eq!(NULL_STYLE.color, None);
assert_eq!(NULL_STYLE.bgcolor, None);
assert_eq!(NULL_STYLE.bold, None);
assert_eq!(NULL_STYLE.dim, None);
assert_eq!(NULL_STYLE.italic, None);
assert_eq!(NULL_STYLE.underline, None);
assert_eq!(NULL_STYLE.blink, None);
assert_eq!(NULL_STYLE.blink2, None);
assert_eq!(NULL_STYLE.reverse, None);
assert_eq!(NULL_STYLE.conceal, None);
assert_eq!(NULL_STYLE.strike, None);
assert_eq!(NULL_STYLE.underline2, None);
assert_eq!(NULL_STYLE.frame, None);
assert_eq!(NULL_STYLE.encircle, None);
assert_eq!(NULL_STYLE.overline, None);
}
#[test]
fn test_is_null() {
assert!(Style::new().is_null());
assert!(!Style::new().with_bold(true).is_null());
assert!(!Style::new().with_color(Color::Standard(1)).is_null());
}
#[test]
fn test_render_empty_text() {
let style = Style::new().with_bold(true);
assert_eq!(style.render("", ColorSystem::TrueColor), "");
}
#[test]
fn test_render_null_style() {
let style = Style::new();
assert_eq!(style.render("Hello", ColorSystem::TrueColor), "Hello");
}
#[test]
fn test_render_bold() {
let style = Style::new().with_bold(true);
let rendered = style.render("Hello", ColorSystem::TrueColor);
assert_eq!(rendered, "\x1b[1mHello\x1b[0m");
}
#[test]
fn test_render_multiple_attributes() {
let style = Style::new().with_bold(true).with_italic(true);
let rendered = style.render("Hello", ColorSystem::TrueColor);
assert_eq!(rendered, "\x1b[1;3mHello\x1b[0m");
}
#[test]
fn test_render_all_attributes() {
let style = Style {
bold: Some(true),
dim: Some(true),
italic: Some(true),
underline: Some(true),
blink: Some(true),
reverse: Some(true),
strike: Some(true),
..Default::default()
};
let rendered = style.render("X", ColorSystem::TrueColor);
assert_eq!(rendered, "\x1b[1;2;3;4;5;7;9mX\x1b[0m");
}
#[test]
fn test_render_with_standard_color() {
let style = Style::new().with_color(Color::Standard(1)); let rendered = style.render("Hi", ColorSystem::TrueColor);
assert_eq!(rendered, "\x1b[31mHi\x1b[0m");
}
#[test]
fn test_render_with_bright_color() {
let style = Style::new().with_color(Color::Standard(9)); let rendered = style.render("Hi", ColorSystem::TrueColor);
assert_eq!(rendered, "\x1b[91mHi\x1b[0m");
}
#[test]
fn test_render_with_256_color() {
let style = Style::new().with_color(Color::EightBit(196));
let rendered = style.render("Hi", ColorSystem::TrueColor);
assert_eq!(rendered, "\x1b[38;5;196mHi\x1b[0m");
}
#[test]
fn test_render_with_rgb_color() {
let style = Style::new().with_color(Color::Rgb {
r: 255,
g: 128,
b: 0,
});
let rendered = style.render("Hi", ColorSystem::TrueColor);
assert_eq!(rendered, "\x1b[38;2;255;128;0mHi\x1b[0m");
}
#[test]
fn test_render_with_bgcolor() {
let style = Style::new().with_bgcolor(Color::Standard(4)); let rendered = style.render("Hi", ColorSystem::TrueColor);
assert_eq!(rendered, "\x1b[44mHi\x1b[0m");
}
#[test]
fn test_render_with_fg_and_bg() {
let style = Style::new()
.with_color(Color::Standard(1)) .with_bgcolor(Color::Standard(7)); let rendered = style.render("Hi", ColorSystem::TrueColor);
assert_eq!(rendered, "\x1b[31;47mHi\x1b[0m");
}
#[test]
fn test_render_bold_and_color() {
let style = Style::new().with_bold(true).with_color(Color::Standard(2)); let rendered = style.render("OK", ColorSystem::TrueColor);
assert_eq!(rendered, "\x1b[1;32mOK\x1b[0m");
}
#[test]
fn test_render_color_downgrade_to_256() {
let style = Style::new().with_color(Color::Rgb { r: 255, g: 0, b: 0 });
let rendered = style.render("X", ColorSystem::EightBit);
assert!(rendered.contains("38;5;"));
assert!(!rendered.contains("38;2;"));
}
#[test]
fn test_render_color_downgrade_to_standard() {
let style = Style::new().with_color(Color::Rgb { r: 255, g: 0, b: 0 });
let rendered = style.render("X", ColorSystem::Standard);
assert!(!rendered.contains("38;5;"));
assert!(!rendered.contains("38;2;"));
}
#[test]
fn test_html_style_empty() {
let style = Style::new();
assert_eq!(style.get_html_style(), "");
}
#[test]
fn test_html_style_bold() {
let style = Style::new().with_bold(true);
assert_eq!(style.get_html_style(), "font-weight: bold");
}
#[test]
fn test_html_style_italic() {
let style = Style::new().with_italic(true);
assert_eq!(style.get_html_style(), "font-style: italic");
}
#[test]
fn test_html_style_underline() {
let style = Style::new().with_underline(true);
assert_eq!(style.get_html_style(), "text-decoration: underline");
}
#[test]
fn test_html_style_strike() {
let style = Style::new().with_strike(true);
assert_eq!(style.get_html_style(), "text-decoration: line-through");
}
#[test]
fn test_with_reverse_builder_sets_flag() {
let style = Style::new().with_reverse(true);
assert_eq!(style.reverse, Some(true));
}
#[test]
fn test_html_style_color_rgb() {
let style = Style::new().with_color(Color::Rgb { r: 255, g: 0, b: 0 });
let css = style.get_html_style();
assert!(css.contains("color: #ff0000"));
assert!(css.contains("text-decoration-color: #ff0000"));
}
#[test]
fn test_html_style_bgcolor() {
let style = Style::new().with_bgcolor(Color::Rgb { r: 0, g: 0, b: 255 });
let css = style.get_html_style();
assert!(css.contains("background-color: #0000ff"));
}
#[test]
fn test_html_style_reverse_swaps_colors() {
let style = Style {
color: Some(Color::Rgb { r: 255, g: 0, b: 0 }),
bgcolor: Some(Color::Rgb { r: 0, g: 0, b: 255 }),
reverse: Some(true),
..Default::default()
};
let css = style.get_html_style();
assert!(css.contains("color: #0000ff"));
assert!(css.contains("background-color: #ff0000"));
}
#[test]
fn test_html_style_combined() {
let style = Style::new()
.with_bold(true)
.with_italic(true)
.with_color(Color::Rgb {
r: 255,
g: 128,
b: 0,
});
let css = style.get_html_style();
assert!(css.contains("font-weight: bold"));
assert!(css.contains("font-style: italic"));
assert!(css.contains("color: #ff8000"));
}
#[test]
fn test_html_style_standard_color() {
let style = Style::new().with_color(Color::Standard(1));
let css = style.get_html_style();
assert!(css.contains("color: #"));
}
#[test]
fn test_html_style_underline_and_strike_combined() {
let style = Style::new().with_underline(true).with_strike(true);
let css = style.get_html_style();
assert!(css.contains("text-decoration: underline line-through"));
assert_eq!(css.matches("text-decoration").count(), 1);
}
#[test]
fn test_make_ansi_codes_empty() {
let style = Style::new();
assert_eq!(style.make_ansi_codes(ColorSystem::TrueColor), "");
}
#[test]
fn test_make_ansi_codes_attributes_only() {
let style = Style::new().with_bold(true).with_dim(true);
assert_eq!(style.make_ansi_codes(ColorSystem::TrueColor), "1;2");
}
#[test]
fn test_make_ansi_codes_false_attributes_emit_reset() {
let style = Style {
bold: Some(false),
italic: Some(true),
..Default::default()
};
assert_eq!(style.make_ansi_codes(ColorSystem::TrueColor), "22;3");
}
#[test]
fn test_parse_not_bold() {
let style = Style::parse("not bold").unwrap();
assert_eq!(style.bold, Some(false));
}
#[test]
fn test_parse_not_italic() {
let style = Style::parse("not italic").unwrap();
assert_eq!(style.italic, Some(false));
}
#[test]
fn test_parse_not_underline() {
let style = Style::parse("not underline").unwrap();
assert_eq!(style.underline, Some(false));
}
#[test]
fn test_parse_not_dim() {
let style = Style::parse("not dim").unwrap();
assert_eq!(style.dim, Some(false));
}
#[test]
fn test_parse_not_blink() {
let style = Style::parse("not blink").unwrap();
assert_eq!(style.blink, Some(false));
}
#[test]
fn test_parse_not_reverse() {
let style = Style::parse("not reverse").unwrap();
assert_eq!(style.reverse, Some(false));
}
#[test]
fn test_parse_not_strike() {
let style = Style::parse("not strike").unwrap();
assert_eq!(style.strike, Some(false));
}
#[test]
fn test_parse_mixed_attributes_with_negation() {
let style = Style::parse("bold not italic red").unwrap();
assert_eq!(style.bold, Some(true));
assert_eq!(style.italic, Some(false));
assert_eq!(style.color, Some(Color::Standard(1)));
}
#[test]
fn test_make_ansi_codes_bold_false_emits_22() {
let style = Style {
bold: Some(false),
..Default::default()
};
assert_eq!(style.make_ansi_codes(ColorSystem::TrueColor), "22");
}
#[test]
fn test_make_ansi_codes_italic_false_emits_23() {
let style = Style {
italic: Some(false),
..Default::default()
};
assert_eq!(style.make_ansi_codes(ColorSystem::TrueColor), "23");
}
#[test]
fn test_make_ansi_codes_underline_false_emits_24() {
let style = Style {
underline: Some(false),
..Default::default()
};
assert_eq!(style.make_ansi_codes(ColorSystem::TrueColor), "24");
}
#[test]
fn test_make_ansi_codes_blink_false_emits_25() {
let style = Style {
blink: Some(false),
..Default::default()
};
assert_eq!(style.make_ansi_codes(ColorSystem::TrueColor), "25");
}
#[test]
fn test_make_ansi_codes_reverse_false_emits_27() {
let style = Style {
reverse: Some(false),
..Default::default()
};
assert_eq!(style.make_ansi_codes(ColorSystem::TrueColor), "27");
}
#[test]
fn test_make_ansi_codes_strike_false_emits_29() {
let style = Style {
strike: Some(false),
..Default::default()
};
assert_eq!(style.make_ansi_codes(ColorSystem::TrueColor), "29");
}
#[test]
fn test_make_ansi_codes_dim_false_emits_22() {
let style = Style {
dim: Some(false),
..Default::default()
};
assert_eq!(style.make_ansi_codes(ColorSystem::TrueColor), "22");
}
#[test]
fn test_make_ansi_codes_bold_false_dim_true() {
let style = Style {
bold: Some(false),
dim: Some(true),
..Default::default()
};
assert_eq!(style.make_ansi_codes(ColorSystem::TrueColor), "22;2");
}
#[test]
fn test_render_with_false_attribute() {
let style = Style {
bold: Some(false),
..Default::default()
};
let rendered = style.render("Hi", ColorSystem::TrueColor);
assert_eq!(rendered, "\x1b[22mHi\x1b[0m");
}
#[test]
fn test_parse_shorthand_b_for_bold() {
let style = Style::parse("b").unwrap();
assert_eq!(style.bold, Some(true));
}
#[test]
fn test_parse_shorthand_i_for_italic() {
let style = Style::parse("i").unwrap();
assert_eq!(style.italic, Some(true));
}
#[test]
fn test_parse_shorthand_u_for_underline() {
let style = Style::parse("u").unwrap();
assert_eq!(style.underline, Some(true));
}
#[test]
fn test_parse_shorthand_s_for_strike() {
let style = Style::parse("s").unwrap();
assert_eq!(style.strike, Some(true));
}
#[test]
fn test_parse_shorthand_combined() {
let style = Style::parse("b i red").unwrap();
assert_eq!(style.bold, Some(true));
assert_eq!(style.italic, Some(true));
assert_eq!(style.color, Some(Color::Standard(1)));
}
#[test]
fn test_parse_shorthand_negation() {
let style = Style::parse("not b").unwrap();
assert_eq!(style.bold, Some(false));
let style = Style::parse("not i").unwrap();
assert_eq!(style.italic, Some(false));
}
#[test]
fn test_parse_new_attributes() {
let style = Style::parse("overline").unwrap();
assert_eq!(style.overline, Some(true));
let style = Style::parse("blink2").unwrap();
assert_eq!(style.blink2, Some(true));
let style = Style::parse("conceal").unwrap();
assert_eq!(style.conceal, Some(true));
let style = Style::parse("underline2").unwrap();
assert_eq!(style.underline2, Some(true));
let style = Style::parse("frame").unwrap();
assert_eq!(style.frame, Some(true));
let style = Style::parse("encircle").unwrap();
assert_eq!(style.encircle, Some(true));
}
#[test]
fn test_parse_new_attribute_shorthand() {
let style = Style::parse("o").unwrap();
assert_eq!(style.overline, Some(true));
let style = Style::parse("uu").unwrap();
assert_eq!(style.underline2, Some(true));
let style = Style::parse("c").unwrap();
assert_eq!(style.conceal, Some(true));
}
#[test]
fn test_parse_not_new_attributes() {
let style = Style::parse("not overline").unwrap();
assert_eq!(style.overline, Some(false));
let style = Style::parse("not blink2").unwrap();
assert_eq!(style.blink2, Some(false));
let style = Style::parse("not conceal").unwrap();
assert_eq!(style.conceal, Some(false));
let style = Style::parse("not underline2").unwrap();
assert_eq!(style.underline2, Some(false));
let style = Style::parse("not frame").unwrap();
assert_eq!(style.frame, Some(false));
let style = Style::parse("not encircle").unwrap();
assert_eq!(style.encircle, Some(false));
}
#[test]
fn test_builder_new_attributes() {
let style = Style::new().with_overline(true);
assert_eq!(style.overline, Some(true));
let style = Style::new().with_blink2(true);
assert_eq!(style.blink2, Some(true));
let style = Style::new().with_conceal(true);
assert_eq!(style.conceal, Some(true));
let style = Style::new().with_underline2(true);
assert_eq!(style.underline2, Some(true));
let style = Style::new().with_frame(true);
assert_eq!(style.frame, Some(true));
let style = Style::new().with_encircle(true);
assert_eq!(style.encircle, Some(true));
}
#[test]
fn test_render_new_attributes() {
let style = Style::new().with_overline(true);
assert_eq!(
style.render("X", ColorSystem::TrueColor),
"\x1b[53mX\x1b[0m"
);
let style = Style::new().with_blink2(true);
assert_eq!(style.render("X", ColorSystem::TrueColor), "\x1b[6mX\x1b[0m");
let style = Style::new().with_conceal(true);
assert_eq!(style.render("X", ColorSystem::TrueColor), "\x1b[8mX\x1b[0m");
let style = Style::new().with_underline2(true);
assert_eq!(
style.render("X", ColorSystem::TrueColor),
"\x1b[21mX\x1b[0m"
);
let style = Style::new().with_frame(true);
assert_eq!(
style.render("X", ColorSystem::TrueColor),
"\x1b[51mX\x1b[0m"
);
let style = Style::new().with_encircle(true);
assert_eq!(
style.render("X", ColorSystem::TrueColor),
"\x1b[52mX\x1b[0m"
);
}
#[test]
fn test_render_new_attributes_off() {
let style = Style::new().with_overline(false);
assert_eq!(
style.render("X", ColorSystem::TrueColor),
"\x1b[55mX\x1b[0m"
);
let style = Style::new().with_conceal(false);
assert_eq!(
style.render("X", ColorSystem::TrueColor),
"\x1b[28mX\x1b[0m"
);
let style = Style::new().with_frame(false);
assert_eq!(
style.render("X", ColorSystem::TrueColor),
"\x1b[54mX\x1b[0m"
);
}
#[test]
fn test_combine_new_attributes() {
let a = Style::new().with_overline(true);
let b = Style::new().with_blink2(true);
let combined = a.combine(&b);
assert_eq!(combined.overline, Some(true));
assert_eq!(combined.blink2, Some(true));
}
#[test]
fn test_render_all_new_attributes() {
let style = Style {
blink2: Some(true),
conceal: Some(true),
underline2: Some(true),
frame: Some(true),
encircle: Some(true),
overline: Some(true),
..Default::default()
};
let rendered = style.render("X", ColorSystem::TrueColor);
assert_eq!(rendered, "\x1b[6;8;21;51;52;53mX\x1b[0m");
}
#[test]
fn test_chain() {
let a = Style::new().with_bold(true);
let b = Style::new()
.with_italic(true)
.with_color(Color::Standard(1));
let c = Style::new().with_underline(true);
let result = Style::chain(&[a, b, c]);
assert_eq!(result.bold, Some(true));
assert_eq!(result.italic, Some(true));
assert_eq!(result.underline, Some(true));
assert_eq!(result.color, Some(Color::Standard(1)));
}
#[test]
fn test_from_color() {
let style = Style::from_color(Some(Color::Standard(1)), Some(Color::Standard(4)));
assert_eq!(style.color, Some(Color::Standard(1)));
assert_eq!(style.bgcolor, Some(Color::Standard(4)));
assert_eq!(style.bold, None);
assert_eq!(style.italic, None);
}
#[test]
fn test_on_builds_meta_with_handlers() {
let meta = Style::on(
None,
[
("click", MetaValue::str("handler")),
("focus", MetaValue::Bool(true)),
],
);
assert!(meta.link.is_none());
assert!(meta.link_id.is_none());
let map = meta.meta.as_ref().expect("meta should be present");
assert_eq!(map.get("@click"), Some(&MetaValue::str("handler")));
assert_eq!(map.get("@focus"), Some(&MetaValue::Bool(true)));
}
#[test]
fn test_on_merges_existing_meta() {
let mut base = BTreeMap::new();
base.insert("existing".to_string(), MetaValue::Int(1));
let meta = Style::on(Some(base), [("blur", MetaValue::Bool(false))]);
let map = meta.meta.as_ref().expect("meta should be present");
assert_eq!(map.get("existing"), Some(&MetaValue::Int(1)));
assert_eq!(map.get("@blur"), Some(&MetaValue::Bool(false)));
}
#[test]
fn test_normalize_valid_style() {
assert_eq!(Style::normalize(" BOLD red ON blue "), "bold red on blue");
assert_eq!(Style::normalize(""), "none");
assert_eq!(Style::normalize(" none "), "none");
}
#[test]
fn test_normalize_invalid_style_returns_trimmed_lower() {
assert_eq!(Style::normalize(" no_such_token "), "no_such_token");
assert_eq!(Style::normalize("foo bold"), "foo bold");
assert_eq!(
Style::normalize("bold on not_a_color"),
"bold on not_a_color"
);
}
#[test]
fn test_pick_first_returns_first_non_none() {
let first = Style::new().with_bold(true);
let second = Style::new().with_italic(true);
let picked = Style::pick_first(&[None, Some(first), Some(second)]);
assert_eq!(picked, first);
}
#[test]
#[should_panic(expected = "expected at least one non-None style")]
fn test_pick_first_panics_when_all_none() {
let _ = Style::pick_first(&[None, None]);
}
#[test]
fn test_background_style() {
let style = Style::new()
.with_bold(true)
.with_color(Color::Standard(1))
.with_bgcolor(Color::Standard(4));
let bg = style.background_style();
assert_eq!(bg.bgcolor, Some(Color::Standard(4)));
assert_eq!(bg.color, None);
assert_eq!(bg.bold, None);
}
#[test]
fn test_without_color() {
let style = Style::new()
.with_bold(true)
.with_color(Color::Standard(1))
.with_bgcolor(Color::Standard(4));
let nc = style.without_color();
assert_eq!(nc.color, None);
assert_eq!(nc.bgcolor, None);
assert_eq!(nc.bold, Some(true));
}
#[test]
fn test_has_transparent_background() {
assert!(Style::new().has_transparent_background());
assert!(
Style::new()
.with_bgcolor(Color::Default)
.has_transparent_background()
);
assert!(
!Style::new()
.with_bgcolor(Color::Standard(1))
.has_transparent_background()
);
}
#[test]
fn test_style_stack_new() {
let stack = StyleStack::new(Style::new().with_bold(true));
assert_eq!(stack.current().bold, Some(true));
}
#[test]
fn test_style_stack_push_pop() {
let mut stack = StyleStack::new(Style::new().with_bold(true));
stack.push(Style::new().with_italic(true));
let current = stack.current();
assert_eq!(current.bold, Some(true));
assert_eq!(current.italic, Some(true));
stack.pop();
let current = stack.current();
assert_eq!(current.bold, Some(true));
assert_eq!(current.italic, None);
}
#[test]
fn test_style_stack_multiple_push() {
let mut stack = StyleStack::new(Style::new());
stack.push(Style::new().with_bold(true));
stack.push(Style::new().with_italic(true));
stack.push(Style::new().with_underline(true));
let current = stack.current();
assert_eq!(current.bold, Some(true));
assert_eq!(current.italic, Some(true));
assert_eq!(current.underline, Some(true));
stack.pop();
let current = stack.current();
assert_eq!(current.bold, Some(true));
assert_eq!(current.italic, Some(true));
assert_eq!(current.underline, None);
}
#[test]
fn test_html_style_overline() {
let style = Style::new().with_overline(true);
let css = style.get_html_style();
assert!(css.contains("text-decoration: overline"));
}
#[test]
fn test_html_style_underline_strike_overline_combined() {
let style = Style::new()
.with_underline(true)
.with_strike(true)
.with_overline(true);
let css = style.get_html_style();
assert!(css.contains("text-decoration: underline line-through overline"));
assert_eq!(css.matches("text-decoration").count(), 1);
}
}