Skip to content

Commit f2bb3a8

Browse files
feat: support gradients
1 parent f37cb6e commit f2bb3a8

File tree

1 file changed

+234
-27
lines changed

1 file changed

+234
-27
lines changed

src/utils.rs

Lines changed: 234 additions & 27 deletions
Original file line numberDiff line numberDiff line change
@@ -114,6 +114,45 @@ impl Color {
114114
}
115115
}
116116

117+
/// A Color used in a gradient
118+
#[derive(Copy, Clone, Debug, PartialEq, Eq)]
119+
pub struct ColorGradient {
120+
red: u8,
121+
green: u8,
122+
blue: u8,
123+
}
124+
125+
impl ColorGradient {
126+
fn new(red: u8, green: u8, blue: u8) -> Self {
127+
ColorGradient {
128+
red: red.min(255),
129+
green: green.min(255),
130+
blue: blue.min(255),
131+
}
132+
}
133+
134+
fn from_hex(hex: &str) -> Self {
135+
// Remove '#' if present
136+
let s = hex.trim_start_matches('#');
137+
138+
// 6 char hex
139+
let channels: Vec<u8> = (0..5)
140+
.step_by(2)
141+
.map(|i| u8::from_str_radix(&s[i..i + 2], 16).unwrap_or_default())
142+
.collect();
143+
144+
ColorGradient::new(channels[0], channels[1], channels[2])
145+
}
146+
147+
pub(crate) fn interpolate(&self, other: &ColorGradient, t: f32) -> ColorGradient {
148+
ColorGradient {
149+
red: (self.red as f32 + (other.red as f32 - self.red as f32) * t) as u8,
150+
green: (self.green as f32 + (other.green as f32 - self.green as f32) * t) as u8,
151+
blue: (self.blue as f32 + (other.blue as f32 - self.blue as f32) * t) as u8,
152+
}
153+
}
154+
}
155+
117156
/// A terminal style attribute.
118157
#[derive(Copy, Clone, Debug, PartialEq, Eq, Ord, PartialOrd)]
119158
pub enum Attribute {
@@ -160,6 +199,8 @@ pub struct Style {
160199
bg: Option<Color>,
161200
fg_bright: bool,
162201
bg_bright: bool,
202+
fg_gradient: Vec<ColorGradient>,
203+
bg_gradient: Vec<ColorGradient>,
163204
attrs: BTreeSet<Attribute>,
164205
force: Option<bool>,
165206
for_stderr: bool,
@@ -179,6 +220,8 @@ impl Style {
179220
bg: None,
180221
fg_bright: false,
181222
bg_bright: false,
223+
fg_gradient: vec![],
224+
bg_gradient: vec![],
182225
attrs: BTreeSet::new(),
183226
force: None,
184227
for_stderr: false,
@@ -222,6 +265,19 @@ impl Style {
222265
"reverse" => rv.reverse(),
223266
"hidden" => rv.hidden(),
224267
"strikethrough" => rv.strikethrough(),
268+
gradient if gradient.starts_with("gradient_") => {
269+
for hex_color in gradient[9..].split('_') {
270+
rv = rv.gradient(ColorGradient::from_hex(hex_color));
271+
}
272+
rv
273+
}
274+
on_gradient if on_gradient.starts_with("on_gradient_") => {
275+
for hex_color in on_gradient[12..].split('_') {
276+
rv = rv.on_gradient(ColorGradient::from_hex(hex_color));
277+
}
278+
rv
279+
}
280+
225281
on_c if on_c.starts_with("on_") => {
226282
if let Ok(n) = on_c[3..].parse::<u8>() {
227283
rv.on_color256(n)
@@ -295,6 +351,22 @@ impl Style {
295351
self
296352
}
297353

354+
/// Add a gradient color to the text gradient
355+
/// Overrides basic foreground colors
356+
#[inline]
357+
pub fn gradient(mut self, color: ColorGradient) -> Self {
358+
self.fg_gradient.push(color);
359+
self
360+
}
361+
362+
/// Add a gradient color to the text on_gradient
363+
/// Overrides basic background colors
364+
#[inline]
365+
pub fn on_gradient(mut self, color: ColorGradient) -> Self {
366+
self.bg_gradient.push(color);
367+
self
368+
}
369+
298370
#[inline]
299371
pub const fn black(self) -> Self {
300372
self.fg(Color::Black)
@@ -486,6 +558,18 @@ impl<D> StyledObject<D> {
486558
self
487559
}
488560

561+
#[inline]
562+
pub fn gradient(mut self, color: ColorGradient) -> StyledObject<D> {
563+
self.style = self.style.gradient(color);
564+
self
565+
}
566+
567+
#[inline]
568+
pub fn on_gradient(mut self, color: ColorGradient) -> StyledObject<D> {
569+
self.style = self.style.on_gradient(color);
570+
self
571+
}
572+
489573
/// Adds a attr.
490574
#[inline]
491575
pub fn attr(mut self, attr: Attribute) -> StyledObject<D> {
@@ -618,7 +702,7 @@ impl<D> StyledObject<D> {
618702
}
619703

620704
macro_rules! impl_fmt {
621-
($name:ident) => {
705+
($name:ident,$format_char:expr) => {
622706
impl<D: fmt::$name> fmt::$name for StyledObject<D> {
623707
fn fmt(&self, f: &mut fmt::Formatter) -> fmt::Result {
624708
let mut reset = false;
@@ -630,32 +714,49 @@ macro_rules! impl_fmt {
630714
false => colors_enabled(),
631715
})
632716
{
633-
if let Some(fg) = self.style.fg {
634-
if fg.is_color256() {
635-
write!(f, "\x1b[38;5;{}m", fg.ansi_num())?;
636-
} else if self.style.fg_bright {
637-
write!(f, "\x1b[38;5;{}m", fg.ansi_num() + 8)?;
638-
} else {
639-
write!(f, "\x1b[{}m", fg.ansi_num() + 30)?;
717+
if self.style.fg_gradient.is_empty() {
718+
if let Some(fg) = self.style.fg {
719+
if fg.is_color256() {
720+
write!(f, "\x1b[38;5;{}m", fg.ansi_num())?;
721+
} else if self.style.fg_bright {
722+
write!(f, "\x1b[38;5;{}m", fg.ansi_num() + 8)?;
723+
} else {
724+
write!(f, "\x1b[{}m", fg.ansi_num() + 30)?;
725+
}
726+
reset = true;
640727
}
641-
reset = true;
642728
}
643-
if let Some(bg) = self.style.bg {
644-
if bg.is_color256() {
645-
write!(f, "\x1b[48;5;{}m", bg.ansi_num())?;
646-
} else if self.style.bg_bright {
647-
write!(f, "\x1b[48;5;{}m", bg.ansi_num() + 8)?;
648-
} else {
649-
write!(f, "\x1b[{}m", bg.ansi_num() + 40)?;
729+
if self.style.bg_gradient.is_empty() {
730+
if let Some(bg) = self.style.bg {
731+
if bg.is_color256() {
732+
write!(f, "\x1b[48;5;{}m", bg.ansi_num())?;
733+
} else if self.style.bg_bright {
734+
write!(f, "\x1b[48;5;{}m", bg.ansi_num() + 8)?;
735+
} else {
736+
write!(f, "\x1b[{}m", bg.ansi_num() + 40)?;
737+
}
738+
reset = true;
650739
}
651-
reset = true;
652740
}
653741
for attr in &self.style.attrs {
654742
write!(f, "\x1b[{}m", attr.ansi_num())?;
655743
reset = true;
656744
}
657745
}
658-
fmt::$name::fmt(&self.val, f)?;
746+
// Get the underlying value
747+
let mut buf: String = format!($format_char, &self.val);
748+
749+
if (!self.style.fg_gradient.is_empty()) {
750+
buf = apply_gradient_impl(&buf, &self.style.fg_gradient, false, true);
751+
reset = true;
752+
}
753+
754+
if (!self.style.bg_gradient.is_empty()) {
755+
buf = apply_gradient_impl(&buf, &self.style.bg_gradient, false, false);
756+
reset = true;
757+
}
758+
759+
write!(f, "{}", buf)?;
659760
if reset {
660761
write!(f, "\x1b[0m")?;
661762
}
@@ -665,15 +766,15 @@ macro_rules! impl_fmt {
665766
};
666767
}
667768

668-
impl_fmt!(Binary);
669-
impl_fmt!(Debug);
670-
impl_fmt!(Display);
671-
impl_fmt!(LowerExp);
672-
impl_fmt!(LowerHex);
673-
impl_fmt!(Octal);
674-
impl_fmt!(Pointer);
675-
impl_fmt!(UpperExp);
676-
impl_fmt!(UpperHex);
769+
impl_fmt!(Binary, "{:b}");
770+
impl_fmt!(Debug, "{:?}");
771+
impl_fmt!(Display, "{}");
772+
impl_fmt!(LowerExp, "{:e}");
773+
impl_fmt!(LowerHex, "{:x}");
774+
impl_fmt!(Octal, "{:o}");
775+
impl_fmt!(Pointer, "{:p}");
776+
impl_fmt!(UpperExp, "{:E}");
777+
impl_fmt!(UpperHex, "{:X}");
677778

678779
/// "Intelligent" emoji formatter.
679780
///
@@ -732,6 +833,112 @@ fn char_width(c: char) -> usize {
732833
}
733834
}
734835

836+
/// Returns the number of visible characters, ignoring style related chars
837+
fn count_visible_chars(text: &str) -> usize {
838+
let mut total_visible = 0;
839+
840+
let mut chars = text.chars();
841+
while let Some(c) = chars.next() {
842+
if c == '\x1b' {
843+
while let Some(next) = chars.next() {
844+
if next == 'm' {
845+
break;
846+
}
847+
}
848+
continue;
849+
}
850+
if c != '\n' {
851+
total_visible += 1;
852+
}
853+
}
854+
855+
total_visible
856+
}
857+
858+
/// Applies the gradient over a string
859+
fn apply_gradient_impl(
860+
text: &str,
861+
gradients: &Vec<ColorGradient>,
862+
block: bool,
863+
foreground: bool,
864+
) -> String {
865+
let ascii_number = if foreground { 3 } else { 4 };
866+
let skip_sequence = format!("[{}8;2;", ascii_number);
867+
868+
let mut visible_chars = 0;
869+
let total_visible = count_visible_chars(text);
870+
// The pre-allocation is an estimate, chances are high a re-allocation will occur
871+
// But it still less than without the estimate
872+
// Since gradients are on the form `\x1b[{}8;2;{};{};{}m` for a single color, the estimation is 16 chars per visible char
873+
let mut result = String::with_capacity(16 * total_visible);
874+
875+
// Second pass: apply gradient
876+
let mut chars = text.chars().peekable();
877+
while let Some(c) = chars.next() {
878+
if c == '\x1b' {
879+
let mut seq = String::from(c);
880+
while let Some(&next) = chars.peek() {
881+
seq.push(next);
882+
chars.next();
883+
if next == 'm' {
884+
break;
885+
}
886+
}
887+
888+
// Only skip foreground/background color sequences
889+
if !seq.contains(&skip_sequence) {
890+
result.push_str(&seq);
891+
}
892+
continue;
893+
}
894+
895+
if c == '\n' {
896+
result.push(c);
897+
if block {
898+
visible_chars = 0;
899+
}
900+
continue;
901+
}
902+
903+
let progress = if total_visible > 1 {
904+
visible_chars as f32 / (total_visible - 1) as f32
905+
} else {
906+
0.0
907+
};
908+
909+
// Find which gradient to use, along the progress for an individual gradient interpolation
910+
let gradient_range =
911+
map_range((0f32, 1f32), (0f32, (gradients.len() - 1) as f32), progress);
912+
let mut current_gradient_index: usize = gradient_range as usize;
913+
let mut gradient_progress = gradient_range - current_gradient_index as f32;
914+
915+
if current_gradient_index == gradients.len() - 1 && current_gradient_index > 0 {
916+
// Edge case at the end of the gradient, don't continue looping
917+
current_gradient_index -= 1;
918+
gradient_progress = 1.0f32
919+
}
920+
921+
let color = gradients[current_gradient_index].interpolate(
922+
&gradients[(current_gradient_index + 1).min(gradients.len() - 1)],
923+
gradient_progress,
924+
);
925+
926+
result.push_str(&format!(
927+
"\x1b[{}8;2;{};{};{}m",
928+
ascii_number, color.red, color.green, color.blue
929+
));
930+
result.push(c);
931+
932+
visible_chars += 1;
933+
}
934+
result
935+
}
936+
937+
/// Map one range to another
938+
fn map_range(from_range: (f32, f32), to_range: (f32, f32), s: f32) -> f32 {
939+
to_range.0 + (s - from_range.0) * (to_range.1 - to_range.0) / (from_range.1 - from_range.0)
940+
}
941+
735942
/// Truncates a string to a certain number of characters.
736943
///
737944
/// This ensures that escape codes are not screwed up in the process.

0 commit comments

Comments
 (0)