pub mod color;
pub mod macros;
use core::fmt;
pub use self::color::Color;
use self::color::{Layer, to_ansi_string};
use crate::terminal::{ColorLevel, TerminalApp, get_cached_level, get_terminal_app};
pub struct StyledRef<'a, T: fmt::Display + ?Sized> {
style: Style,
value: &'a T,
}
impl<'a, T: fmt::Display + ?Sized> fmt::Display for StyledRef<'a, T> {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
if self.style.is_plain() || get_cached_level() == ColorLevel::None {
return fmt::Display::fmt(self.value, f);
}
if let Some(color) = self.style.fg {
to_ansi_string(f, color, Layer::Foreground)?;
}
if let Some(color) = self.style.bg {
to_ansi_string(f, color, Layer::Background)?;
}
for attr_escape in self.style.active_attributes() {
f.write_str(attr_escape)?;
}
if self.style.strikethrough && get_terminal_app() != TerminalApp::AppleTerminal {
f.write_str("\x1b[9m")?;
}
if self.style.strikethrough && get_terminal_app() == TerminalApp::AppleTerminal {
write_apple_strikethrough_fallback(f, self.value)?;
} else {
fmt::Display::fmt(self.value, f)?;
}
f.write_str("\x1b[0m")?;
Ok(())
}
}
fn write_apple_strikethrough_fallback<T: fmt::Display + ?Sized>(
f: &mut fmt::Formatter<'_>,
value: &T,
) -> fmt::Result {
struct StrikethroughWriter<'a, 'b> {
formatter: &'a mut fmt::Formatter<'b>,
in_ansi: bool,
}
impl fmt::Write for StrikethroughWriter<'_, '_> {
fn write_str(&mut self, s: &str) -> fmt::Result {
for c in s.chars() {
self.formatter.write_char(c)?;
if c == '\x1b' {
self.in_ansi = true;
} else if c == 'm' && self.in_ansi {
self.in_ansi = false;
continue;
}
if !c.is_control() && !self.in_ansi {
self.formatter.write_char('\u{0336}')?;
}
}
Ok(())
}
}
use std::fmt::Write;
let mut writer = StrikethroughWriter {
formatter: f,
in_ansi: false,
};
write!(writer, "{}", value)
}
#[derive(Debug, PartialEq, Eq, Default, Copy, Clone)]
pub struct Style {
fg: Option<Color>,
bg: Option<Color>,
bold: bool,
underline: bool,
italic: bool,
strikethrough: bool,
dim: bool,
invert: bool,
}
impl Style {
pub fn new() -> Self {
Self::default()
}
pub(crate) fn is_plain(&self) -> bool {
let is_fg = self.fg.is_some() && (self.fg != Some(Color::None));
let is_bg = self.bg.is_some() && (self.bg != Some(Color::None));
!(is_fg
|| is_bg
|| self.bold
|| self.underline
|| self.italic
|| self.strikethrough
|| self.dim
|| self.invert)
}
pub(crate) fn active_attributes(&self) -> impl Iterator<Item = &'static str> {
[
(self.bold, "\x1b[1m"),
(self.dim, "\x1b[2m"),
(self.italic, "\x1b[3m"),
(self.underline, "\x1b[4m"),
(self.invert, "\x1b[7m"),
]
.into_iter()
.filter_map(|(active, escape)| active.then_some(escape))
}
pub fn fg(mut self, color: Color) -> Self {
self.fg = Some(color);
self
}
pub fn bg(mut self, color: Color) -> Self {
self.bg = Some(color);
self
}
pub fn bold(mut self) -> Self {
self.bold = true;
self
}
pub fn underline(mut self) -> Self {
self.underline = true;
self
}
pub fn italic(mut self) -> Self {
self.italic = true;
self
}
pub fn strikethrough(mut self) -> Self {
self.strikethrough = true;
self
}
pub fn dim(mut self) -> Self {
self.dim = true;
self
}
pub fn invert(mut self) -> Self {
self.invert = true;
self
}
pub fn apply<'a, T: fmt::Display + ?Sized>(&self, value: &'a T) -> StyledRef<'a, T> {
StyledRef {
style: *self,
value,
}
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::style::color::to_ansi_string_for_test;
use crate::terminal::TerminalApp;
use crate::test_utils::MockTerminalGuard;
#[test]
fn new_returns_default_style() {
assert_eq!(
Style::new(),
Style {
fg: None,
bg: None,
bold: false,
underline: false,
italic: false,
strikethrough: false,
dim: false,
invert: false
}
);
}
#[test]
fn fg_sets_foreground_color() {
let style = Style::new().fg(Color::Red);
assert_eq!(style.fg, Some(Color::Red));
assert_eq!(style.bg, None);
assert!(!style.bold);
}
#[test]
fn bg_sets_background_color() {
let style = Style::new().bg(Color::Blue);
assert_eq!(style.fg, None);
assert_eq!(style.bg, Some(Color::Blue));
assert!(!style.bold);
}
#[test]
fn fg_and_bg_can_be_chained() {
let style = Style::new().fg(Color::Red).bg(Color::Blue);
assert_eq!(style.fg, Some(Color::Red));
assert_eq!(style.bg, Some(Color::Blue));
}
#[test]
fn apply_without_style_returns_plain_text() {
let style = Style::new();
assert_eq!(format!("{}", style.apply("hello")), "hello");
}
#[test]
fn apply_with_foreground_wraps_text_with_ansi_reset() {
let _guard = MockTerminalGuard::acquire(TerminalApp::Unknown, ColorLevel::TrueColor);
let style = Style::new().fg(Color::Red);
assert_eq!(
format!("{}", style.apply("hello")),
format!(
"{}hello\x1b[0m",
to_ansi_string_for_test(Color::Red, Layer::Foreground)
)
);
}
#[test]
fn downsampling_truecolor_remains_unchanged() {
let _guard = MockTerminalGuard::acquire(TerminalApp::Unknown, ColorLevel::TrueColor);
let style = Style::new().fg(Color::Rgb(255, 128, 0));
let result = format!("{}", style.apply("test"));
assert_eq!(result, "\x1b[38;2;255;128;0mtest\x1b[0m");
}
#[test]
fn downsampling_rgb_to_ansi256_palette() {
let _guard = MockTerminalGuard::acquire(TerminalApp::Unknown, ColorLevel::Ansi256);
let style = Style::new().fg(Color::Rgb(255, 0, 0));
let result = format!("{}", style.apply("test"));
assert_eq!(result, "\x1b[38;5;196mtest\x1b[0m");
}
#[test]
fn downsampling_rgb_to_basic_ansi16_bucket() {
let _guard = MockTerminalGuard::acquire(TerminalApp::Unknown, ColorLevel::Basic);
let style = Style::new().fg(Color::Rgb(255, 0, 0));
let result = format!("{}", style.apply("test"));
assert_eq!(result, "\x1b[31mtest\x1b[0m");
}
#[test]
fn downsampling_ansi256_to_basic_ansi16_bucket() {
let _guard = MockTerminalGuard::acquire(TerminalApp::Unknown, ColorLevel::Basic);
let style = Style::new().fg(Color::Ansi256(196));
let result = format!("{}", style.apply("test"));
assert_eq!(result, "\x1b[31mtest\x1b[0m");
}
#[test]
fn downsampling_strips_all_formatting_when_level_is_none() {
let _guard = MockTerminalGuard::acquire(TerminalApp::Unknown, ColorLevel::None);
let style = Style::new()
.fg(Color::Rgb(100, 200, 255))
.bg(Color::Ansi256(45))
.bold()
.underline()
.italic();
let result = format!("{}", style.apply("hello"));
assert_eq!(result, "hello");
}
#[test]
fn downsampling_keeps_basic_attributes_even_if_colors_are_none() {
let _guard = MockTerminalGuard::acquire(TerminalApp::Unknown, ColorLevel::Basic);
let style = Style::new().fg(Color::None).bold();
let result = format!("{}", style.apply("text"));
assert_eq!(result, "\x1b[1mtext\x1b[0m");
}
#[test]
fn downsampling_handles_background_conversions_uniformly() {
let _guard = MockTerminalGuard::acquire(TerminalApp::Unknown, ColorLevel::Basic);
let style = Style::new().bg(Color::Rgb(0, 0, 255)); let result = format!("{}", style.apply("bg"));
assert_eq!(result, "\x1b[44mbg\x1b[0m");
}
#[test]
fn apply_with_background_wraps_text_with_ansi_reset() {
let _guard = MockTerminalGuard::acquire(TerminalApp::Unknown, ColorLevel::TrueColor);
let style = Style::new().bg(Color::Blue);
assert_eq!(
format!("{}", style.apply("hello")),
format!(
"{}hello\x1b[0m",
to_ansi_string_for_test(Color::Blue, Layer::Background)
)
);
}
#[test]
fn bold_sets_bold_to_true() {
let style = Style::new().bold();
assert!(style.bold);
assert_eq!(style.fg, None);
assert_eq!(style.bg, None);
}
#[test]
fn bold_can_be_chained_with_fg_and_bg() {
let style = Style::new().fg(Color::Red).bg(Color::Blue).bold();
assert_eq!(style.fg, Some(Color::Red));
assert_eq!(style.bg, Some(Color::Blue));
assert!(style.bold);
}
#[test]
fn apply_with_bold_wraps_text_with_bold_ansi_and_reset() {
let _guard = MockTerminalGuard::acquire(TerminalApp::Unknown, ColorLevel::TrueColor);
let style = Style::new().bold();
assert_eq!(format!("{}", style.apply("hello")), "\x1b[1mhello\x1b[0m");
}
#[test]
fn apply_with_foreground_background_and_bold_orders_bold_after_colors() {
let _guard = MockTerminalGuard::acquire(TerminalApp::Unknown, ColorLevel::TrueColor);
let style = Style::new().fg(Color::Red).bg(Color::Blue).bold();
assert_eq!(
format!("{}", style.apply("hello")),
format!(
"{}{}\x1b[1mhello\x1b[0m",
to_ansi_string_for_test(Color::Red, Layer::Foreground),
to_ansi_string_for_test(Color::Blue, Layer::Background),
)
);
}
#[test]
fn underline_sets_underline_to_true() {
let style = Style::new().underline();
assert!(style.underline);
}
#[test]
fn italic_sets_italic_to_true() {
let style = Style::new().italic();
assert!(style.italic);
}
#[test]
fn strikethrough_sets_strikethrough_to_true() {
let style = Style::new().strikethrough();
assert!(style.strikethrough);
}
#[test]
fn dim_sets_dim_to_true() {
let style = Style::new().dim();
assert!(style.dim);
}
#[test]
fn invert_sets_invert_to_true() {
let style = Style::new().invert();
assert!(style.invert);
}
#[test]
fn apply_with_text_styles_wraps_text_with_ansi_and_reset() {
let _guard = MockTerminalGuard::acquire(TerminalApp::Unknown, ColorLevel::TrueColor);
let style = Style::new()
.dim()
.italic()
.underline()
.invert()
.strikethrough();
assert_eq!(
format!("{}", style.apply("hello")),
"\x1b[2m\x1b[3m\x1b[4m\x1b[7m\x1b[9mhello\x1b[0m"
);
}
#[test]
fn all_styles_can_be_chained_with_fg_and_bg() {
let _guard = MockTerminalGuard::acquire(TerminalApp::Unknown, ColorLevel::TrueColor);
let style = Style::new()
.fg(Color::Red)
.bg(Color::Blue)
.bold()
.dim()
.italic()
.underline()
.invert()
.strikethrough();
assert_eq!(
format!("{}", style.apply("hello")),
format!(
"{}{}\x1b[1m\x1b[2m\x1b[3m\x1b[4m\x1b[7m\x1b[9mhello\x1b[0m",
to_ansi_string_for_test(Color::Red, Layer::Foreground),
to_ansi_string_for_test(Color::Blue, Layer::Background),
)
);
}
#[test]
fn apply_with_strikethrough_uses_unicode_fallback_on_apple_terminal() {
let _guard = MockTerminalGuard::acquire(TerminalApp::AppleTerminal, ColorLevel::Ansi256);
assert_eq!(get_terminal_app(), TerminalApp::AppleTerminal);
assert_eq!(get_cached_level(), ColorLevel::Ansi256);
let style = Style::new().strikethrough();
let result = format!("{}", style.apply("abc"));
assert!(result.contains('\u{0336}'));
assert!(!result.contains("\x1b[9m"));
}
#[test]
fn apply_with_strikethrough_uses_ansi_escape_on_standard_terminals() {
let _guard = MockTerminalGuard::acquire(TerminalApp::Unknown, ColorLevel::Ansi256);
assert_eq!(get_terminal_app(), TerminalApp::Unknown);
assert_eq!(get_cached_level(), ColorLevel::Ansi256);
let style = Style::new().strikethrough();
let result = format!("{}", style.apply("abc"));
assert!(result.contains("\x1b[9m"));
assert!(!result.contains('\u{0336}'));
}
#[test]
fn apply_with_strikethrough_preserves_nested_ansi_escape_sequences_in_apple_terminal() {
let _guard = MockTerminalGuard::acquire(TerminalApp::AppleTerminal, ColorLevel::TrueColor);
assert_eq!(get_terminal_app(), TerminalApp::AppleTerminal);
assert_eq!(get_cached_level(), ColorLevel::TrueColor);
let colored_text = "\x1b[31mtest\x1b[0m";
let style = Style::new().strikethrough();
let result = format!("{}", style.apply(colored_text));
assert!(result.contains("\x1b[31m"));
assert!(result.contains("\x1b[0m"));
assert!(result.contains("t\u{0336}e\u{0336}s\u{0336}t\u{0336}"));
}
#[test]
fn apply_with_color_none_does_not_append_ansi_reset() {
let _guard = MockTerminalGuard::acquire(TerminalApp::Unknown, ColorLevel::TrueColor);
let style = Style::new().fg(Color::None).bg(Color::None);
assert_eq!(format!("{}", style.apply("hello")), "hello");
}
}