From bac82cba0f537297eeb5a8b6ea8ef29aab150f08 Mon Sep 17 00:00:00 2001 From: Mathias-Boulay Date: Wed, 22 Jan 2025 18:02:11 +0100 Subject: [PATCH] feat: support gradients --- src/lib.rs | 5 +- src/term.rs | 5 + src/unix_term.rs | 10 ++ src/utils.rs | 286 ++++++++++++++++++++++++++++++++++++++++++----- 4 files changed, 276 insertions(+), 30 deletions(-) diff --git a/src/lib.rs b/src/lib.rs index 2d3114b0..37df7801 100644 --- a/src/lib.rs +++ b/src/lib.rs @@ -84,8 +84,9 @@ pub use crate::term::{ }; pub use crate::utils::{ colors_enabled, colors_enabled_stderr, measure_text_width, pad_str, pad_str_with, - set_colors_enabled, set_colors_enabled_stderr, style, truncate_str, Alignment, Attribute, - Color, Emoji, Style, StyledObject, + set_colors_enabled, set_colors_enabled_stderr, set_true_colors_enabled, + set_true_colors_enabled_stderr, style, true_colors_enabled, true_colors_enabled_stderr, + truncate_str, Alignment, Attribute, Color, Emoji, Style, StyledObject, }; #[cfg(feature = "ansi-parsing")] diff --git a/src/term.rs b/src/term.rs index 88f5d964..bb7f303f 100644 --- a/src/term.rs +++ b/src/term.rs @@ -78,6 +78,11 @@ impl TermFeatures<'_> { is_a_color_terminal(self.0) } + /// Check if true colors are supported by this terminal. + pub fn true_colors_supported(&self) -> bool { + is_a_true_color_terminal(self.0) + } + /// Check if this terminal is an msys terminal. /// /// This is sometimes useful to disable features that are known to not diff --git a/src/unix_term.rs b/src/unix_term.rs index 0f7ac00d..f8b62cee 100644 --- a/src/unix_term.rs +++ b/src/unix_term.rs @@ -36,6 +36,16 @@ pub(crate) fn is_a_color_terminal(out: &Term) -> bool { } } +pub(crate) fn is_a_true_color_terminal(out: &Term) -> bool { + if !is_a_color_terminal(out) { + return false; + } + match env::var("COLORTERM") { + Ok(term) => term == "truecolor" || term == "24bit", + Err(_) => false, + } +} + fn c_result libc::c_int>(f: F) -> io::Result<()> { let res = f(); if res != 0 { diff --git a/src/utils.rs b/src/utils.rs index 378c9e65..14464a7f 100644 --- a/src/utils.rs +++ b/src/utils.rs @@ -22,10 +22,18 @@ fn default_colors_enabled(out: &Term) -> bool { || &env::var("CLICOLOR_FORCE").unwrap_or_else(|_| "0".into()) != "0" } +fn default_true_colors_enabled(out: &Term) -> bool { + out.features().true_colors_supported() +} + static STDOUT_COLORS: Lazy = Lazy::new(|| AtomicBool::new(default_colors_enabled(&Term::stdout()))); +static STDOUT_TRUE_COLORS: Lazy = + Lazy::new(|| AtomicBool::new(default_true_colors_enabled(&Term::stdout()))); static STDERR_COLORS: Lazy = Lazy::new(|| AtomicBool::new(default_colors_enabled(&Term::stderr()))); +static STDERR_TRUE_COLORS: Lazy = + Lazy::new(|| AtomicBool::new(default_true_colors_enabled(&Term::stderr()))); /// Returns `true` if colors should be enabled for stdout. /// @@ -39,6 +47,12 @@ pub fn colors_enabled() -> bool { STDOUT_COLORS.load(Ordering::Relaxed) } +/// Returns `true` if true colors should be enabled for stdout. +#[inline] +pub fn true_colors_enabled() -> bool { + STDERR_TRUE_COLORS.load(Ordering::Relaxed) +} + /// Forces colorization on or off for stdout. /// /// This overrides the default for the current process and changes the return value of the @@ -48,6 +62,15 @@ pub fn set_colors_enabled(val: bool) { STDOUT_COLORS.store(val, Ordering::Relaxed) } +/// Forces true colorization on or off for stdout. +/// +/// This overrides the default for the current process and changes the return value of the +/// `true_colors_enabled` function. +#[inline] +pub fn set_true_colors_enabled(val: bool) { + STDOUT_TRUE_COLORS.store(val, Ordering::Relaxed) +} + /// Returns `true` if colors should be enabled for stderr. /// /// This honors the [clicolors spec](http://bixense.com/clicolors/). @@ -60,15 +83,30 @@ pub fn colors_enabled_stderr() -> bool { STDERR_COLORS.load(Ordering::Relaxed) } +/// Returns `true` if true colors should be enabled for stderr. +#[inline] +pub fn true_colors_enabled_stderr() -> bool { + STDERR_TRUE_COLORS.load(Ordering::Relaxed) +} + /// Forces colorization on or off for stderr. /// /// This overrides the default for the current process and changes the return value of the -/// `colors_enabled` function. +/// `colors_enabled_stderr` function. #[inline] pub fn set_colors_enabled_stderr(val: bool) { STDERR_COLORS.store(val, Ordering::Relaxed) } +/// Forces true colorization on or off for stderr. +/// +/// This overrides the default for the current process and changes the return value of the +/// `true_colors_enabled_stderr` function. +#[inline] +pub fn set_true_colors_enabled_stderr(val: bool) { + STDERR_TRUE_COLORS.store(val, Ordering::Relaxed) +} + /// Measure the width of a string in terminal characters. pub fn measure_text_width(s: &str) -> usize { str_width(&strip_ansi_codes(s)) @@ -114,6 +152,45 @@ impl Color { } } +/// A Color used in a gradient +#[derive(Copy, Clone, Debug, PartialEq, Eq)] +pub struct GradientColor { + red: u8, + green: u8, + blue: u8, +} + +impl GradientColor { + fn new(red: u8, green: u8, blue: u8) -> Self { + GradientColor { + red: red.min(255), + green: green.min(255), + blue: blue.min(255), + } + } + + fn from_hex(hex: &str) -> Self { + // Remove '#' if present + let s = hex.trim_start_matches('#'); + + // 6 char hex + let mut channels: [u8; 3] = Default::default(); + for i in (0..5).step_by(2) { + channels[i / 2] = u8::from_str_radix(&s[i..i + 2], 16).unwrap_or_default() + } + + GradientColor::new(channels[0], channels[1], channels[2]) + } + + pub(crate) fn interpolate(&self, other: &Self, t: f32) -> Self { + GradientColor { + red: (self.red as f32 + (other.red as f32 - self.red as f32) * t) as u8, + green: (self.green as f32 + (other.green as f32 - self.green as f32) * t) as u8, + blue: (self.blue as f32 + (other.blue as f32 - self.blue as f32) * t) as u8, + } + } +} + /// A terminal style attribute. #[derive(Copy, Clone, Debug, PartialEq, Eq, Ord, PartialOrd)] pub enum Attribute { @@ -160,6 +237,8 @@ pub struct Style { bg: Option, fg_bright: bool, bg_bright: bool, + fg_gradient: Vec, + bg_gradient: Vec, attrs: BTreeSet, force: Option, for_stderr: bool, @@ -179,6 +258,8 @@ impl Style { bg: None, fg_bright: false, bg_bright: false, + fg_gradient: vec![], + bg_gradient: vec![], attrs: BTreeSet::new(), force: None, for_stderr: false, @@ -222,6 +303,19 @@ impl Style { "reverse" => rv.reverse(), "hidden" => rv.hidden(), "strikethrough" => rv.strikethrough(), + gradient if gradient.starts_with("gradient_") => { + for hex_color in gradient[9..].split('_') { + rv = rv.gradient(GradientColor::from_hex(hex_color)); + } + rv + } + on_gradient if on_gradient.starts_with("on_gradient_") => { + for hex_color in on_gradient[12..].split('_') { + rv = rv.on_gradient(GradientColor::from_hex(hex_color)); + } + rv + } + on_c if on_c.starts_with("on_") => { if let Ok(n) = on_c[3..].parse::() { rv.on_color256(n) @@ -295,6 +389,22 @@ impl Style { self } + /// Add a gradient color to the text gradient + /// Overrides basic foreground colors + #[inline] + pub fn gradient(mut self, color: GradientColor) -> Self { + self.fg_gradient.push(color); + self + } + + /// Add a gradient color to the text on_gradient + /// Overrides basic background colors + #[inline] + pub fn on_gradient(mut self, color: GradientColor) -> Self { + self.bg_gradient.push(color); + self + } + #[inline] pub const fn black(self) -> Self { self.fg(Color::Black) @@ -486,6 +596,18 @@ impl StyledObject { self } + #[inline] + pub fn gradient(mut self, color: GradientColor) -> StyledObject { + self.style = self.style.gradient(color); + self + } + + #[inline] + pub fn on_gradient(mut self, color: GradientColor) -> StyledObject { + self.style = self.style.on_gradient(color); + self + } + /// Adds a attr. #[inline] pub fn attr(mut self, attr: Attribute) -> StyledObject { @@ -618,10 +740,16 @@ impl StyledObject { } macro_rules! impl_fmt { - ($name:ident) => { + ($name:ident,$format_char:expr) => { impl fmt::$name for StyledObject { fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result { let mut reset = false; + + let use_true_colors = match self.style.for_stderr { + true => true_colors_enabled_stderr(), + false => true_colors_enabled(), + }; + if self .style .force @@ -630,32 +758,50 @@ macro_rules! impl_fmt { false => colors_enabled(), }) { - if let Some(fg) = self.style.fg { - if fg.is_color256() { - write!(f, "\x1b[38;5;{}m", fg.ansi_num())?; - } else if self.style.fg_bright { - write!(f, "\x1b[38;5;{}m", fg.ansi_num() + 8)?; - } else { - write!(f, "\x1b[{}m", fg.ansi_num() + 30)?; + if !use_true_colors || self.style.fg_gradient.is_empty() { + if let Some(fg) = self.style.fg { + if fg.is_color256() { + write!(f, "\x1b[38;5;{}m", fg.ansi_num())?; + } else if self.style.fg_bright { + write!(f, "\x1b[38;5;{}m", fg.ansi_num() + 8)?; + } else { + write!(f, "\x1b[{}m", fg.ansi_num() + 30)?; + } + reset = true; } - reset = true; } - if let Some(bg) = self.style.bg { - if bg.is_color256() { - write!(f, "\x1b[48;5;{}m", bg.ansi_num())?; - } else if self.style.bg_bright { - write!(f, "\x1b[48;5;{}m", bg.ansi_num() + 8)?; - } else { - write!(f, "\x1b[{}m", bg.ansi_num() + 40)?; + if !use_true_colors || self.style.bg_gradient.is_empty() { + if let Some(bg) = self.style.bg { + if bg.is_color256() { + write!(f, "\x1b[48;5;{}m", bg.ansi_num())?; + } else if self.style.bg_bright { + write!(f, "\x1b[48;5;{}m", bg.ansi_num() + 8)?; + } else { + write!(f, "\x1b[{}m", bg.ansi_num() + 40)?; + } + reset = true; } - reset = true; } for attr in &self.style.attrs { write!(f, "\x1b[{}m", attr.ansi_num())?; reset = true; } } - fmt::$name::fmt(&self.val, f)?; + // Get the underlying value + let mut buf = format!($format_char, &self.val); + + if use_true_colors { + if !self.style.fg_gradient.is_empty() { + buf = apply_gradient_impl(&buf, &self.style.fg_gradient, false, true); + reset = true; + } + if !self.style.bg_gradient.is_empty() { + buf = apply_gradient_impl(&buf, &self.style.bg_gradient, false, false); + reset = true; + } + } + + write!(f, "{}", buf)?; if reset { write!(f, "\x1b[0m")?; } @@ -665,15 +811,15 @@ macro_rules! impl_fmt { }; } -impl_fmt!(Binary); -impl_fmt!(Debug); -impl_fmt!(Display); -impl_fmt!(LowerExp); -impl_fmt!(LowerHex); -impl_fmt!(Octal); -impl_fmt!(Pointer); -impl_fmt!(UpperExp); -impl_fmt!(UpperHex); +impl_fmt!(Binary, "{:b}"); +impl_fmt!(Debug, "{:?}"); +impl_fmt!(Display, "{}"); +impl_fmt!(LowerExp, "{:e}"); +impl_fmt!(LowerHex, "{:x}"); +impl_fmt!(Octal, "{:o}"); +impl_fmt!(Pointer, "{:p}"); +impl_fmt!(UpperExp, "{:E}"); +impl_fmt!(UpperHex, "{:X}"); /// "Intelligent" emoji formatter. /// @@ -732,6 +878,90 @@ fn char_width(c: char) -> usize { } } +/// Applies the gradient over a string +fn apply_gradient_impl( + text: &str, + gradients: &[GradientColor], + block: bool, + foreground: bool, +) -> String { + let ascii_number = if foreground { 3 } else { 4 }; + let skip_sequence = format!("[{}8;2;", ascii_number); + + let mut visible_chars = 0; + let total_visible = measure_text_width(text); + // The pre-allocation is an estimate, chances are high a re-allocation will occur + // But it still less than without the estimate + // Since gradients are on the form `\x1b[{}8;2;{};{};{}m` for a single color, the estimation is 16 chars per visible char + let mut result = String::with_capacity(16 * total_visible); + + // Second pass: apply gradient + let mut chars = text.chars().peekable(); + while let Some(c) = chars.next() { + if c == '\x1b' { + let mut seq = String::from(c); + while let Some(&next) = chars.peek() { + seq.push(next); + chars.next(); + if next == 'm' { + break; + } + } + + // Only skip foreground/background color sequences + if !seq.contains(&skip_sequence) { + result.push_str(&seq); + } + continue; + } + + if c == '\n' { + result.push(c); + if block { + visible_chars = 0; + } + continue; + } + + let progress = if total_visible > 1 { + visible_chars as f32 / (total_visible - 1) as f32 + } else { + 0.0 + }; + + // Find which gradient to use, along the progress for an individual gradient interpolation + let gradient_range = + map_range((0f32, 1f32), (0f32, (gradients.len() - 1) as f32), progress); + let mut current_gradient_index: usize = gradient_range as usize; + let mut gradient_progress = gradient_range - current_gradient_index as f32; + + if current_gradient_index == gradients.len() - 1 && current_gradient_index > 0 { + // Edge case at the end of the gradient, don't continue looping + current_gradient_index -= 1; + gradient_progress = 1.0f32 + } + + let color = gradients[current_gradient_index].interpolate( + &gradients[(current_gradient_index + 1).min(gradients.len() - 1)], + gradient_progress, + ); + + result.push_str(&format!( + "\x1b[{}8;2;{};{};{}m", + ascii_number, color.red, color.green, color.blue + )); + result.push(c); + + visible_chars += 1; + } + result +} + +/// Map one range to another +fn map_range(from_range: (f32, f32), to_range: (f32, f32), s: f32) -> f32 { + to_range.0 + (s - from_range.0) * (to_range.1 - to_range.0) / (from_range.1 - from_range.0) +} + /// Truncates a string to a certain number of characters. /// /// This ensures that escape codes are not screwed up in the process.