Skip to main content

rich_ext/
gradient.rs

1//! Text coloured along a gradient: [`GradientText`] puts each character of
2//! a [`Text`] at its place along two or more colours, across, down or
3//! corner to corner, through [`palette::gradient`](crate::palette::gradient).
4//!
5//! ```
6//! use rich::{Color, ColorSystem, Console, Text};
7//! use rich_ext::gradient::GradientText;
8//!
9//! let stops = [Color::parse("#ff0000").unwrap(), Color::parse("#0000ff").unwrap()];
10//! let title = GradientText::new(Text::new("abc"), stops);
11//! let console = Console::builder()
12//!     .width(20)
13//!     .force_terminal(true)
14//!     .color_system(Some(ColorSystem::Truecolor))
15//!     .no_color(false)
16//!     .build();
17//! assert_eq!(
18//!     console.render_export(&title),
19//!     "\x1b[38;2;255;0;0ma\x1b[0m\x1b[38;2;128;0;128mb\x1b[0m\x1b[38;2;0;0;255mc\x1b[0m\n"
20//! );
21//! ```
22
23use rich::measure::Measurement;
24use rich::{Color, Console, ConsoleOptions, Renderable, Segment, Style, Text};
25
26use crate::palette::gradient;
27
28/// Which way a [`GradientText`]'s colours run.
29#[derive(Clone, Copy, Debug, Default, PartialEq, Eq, Hash)]
30pub enum GradientDirection {
31    /// Left to right, the same at each column on every line (the default).
32    #[default]
33    Horizontal,
34    /// Top to bottom, one colour a line.
35    Vertical,
36    /// From the top left corner to the bottom right.
37    Diagonal,
38}
39
40/// A [`Text`] whose characters are coloured along a gradient of two or
41/// more colours, spaced evenly.
42///
43/// - The text is laid out as it would be on its own (wrapped, justified,
44///   its styles kept), then each character takes the colour at its place:
45///   its column across the widest line ([`Horizontal`](GradientDirection::Horizontal)),
46///   its line ([`Vertical`](GradientDirection::Vertical)), or both
47///   ([`Diagonal`](GradientDirection::Diagonal)).
48/// - The gradient replaces the text's own colour (or its background, with
49///   [`background`](Self::background)); bold, italic and the rest stay.
50/// - It degrades by the console's colour system: each colour is the
51///   nearest the console has, so a 256-colour terminal shows steps and a
52///   16-colour one a few bands, and neighbours that come out the same are
53///   written as one run. Without colour the text is drawn as it is.
54///
55/// ```
56/// use rich::{Color, ColorSystem, Console, Text};
57/// use rich_ext::gradient::{GradientDirection, GradientText};
58///
59/// let stops = ["#ff0000", "#ffff00", "#0000ff"].map(|c| Color::parse(c).unwrap());
60/// let banner = GradientText::new(Text::new("one\ntwo\nsix"), stops)
61///     .direction(GradientDirection::Vertical);
62/// let console = Console::builder()
63///     .width(20)
64///     .force_terminal(true)
65///     .color_system(Some(ColorSystem::Standard))
66///     .no_color(false)
67///     .build();
68/// // Red, yellow and blue, a line each, on 16 colours.
69/// assert_eq!(
70///     console.render_export(&banner),
71///     "\x1b[31mone\x1b[0m\n\x1b[93mtwo\x1b[0m\n\x1b[34msix\x1b[0m\n"
72/// );
73/// let plain = Console::builder().width(20).color_system(None).build();
74/// assert_eq!(plain.render_export(&banner), "one\ntwo\nsix\n");
75/// ```
76#[derive(Clone, Debug)]
77#[must_use = "builder methods return a new value; it does nothing until it is rendered or used"]
78pub struct GradientText {
79    text: Text,
80    stops: Vec<Color>,
81    direction: GradientDirection,
82    background: bool,
83}
84
85impl GradientText {
86    /// `text` coloured along `stops` (the first at the start, the last at
87    /// the end). One stop colours it all; none leaves it as it is.
88    pub fn new(text: Text, stops: impl IntoIterator<Item = Color>) -> Self {
89        GradientText {
90            text,
91            stops: stops.into_iter().collect(),
92            direction: GradientDirection::Horizontal,
93            background: false,
94        }
95    }
96
97    /// Which way the colours run (default [`GradientDirection::Horizontal`]).
98    pub fn direction(mut self, direction: GradientDirection) -> Self {
99        self.direction = direction;
100        self
101    }
102
103    /// Colour the background rather than the characters (default off).
104    pub fn background(mut self, background: bool) -> Self {
105        self.background = background;
106        self
107    }
108
109    /// The text being coloured.
110    pub fn text(&self) -> &Text {
111        &self.text
112    }
113}
114
115/// Where `index` falls between 0 and `count - 1`, as 0 to 1.
116fn share(index: usize, count: usize) -> f64 {
117    if count < 2 {
118        0.0
119    } else {
120        index as f64 / (count - 1) as f64
121    }
122}
123
124impl Renderable for GradientText {
125    fn rich_render(&self, console: &Console, options: &ConsoleOptions) -> Vec<Segment> {
126        let system = match console.color_system() {
127            Some(system) if !console.no_color() && !self.stops.is_empty() => system,
128            _ => return self.text.rich_render(console, options),
129        };
130        let lines = console.render_lines(&self.text, options, false);
131        let widest = lines
132            .iter()
133            .map(|line| line.iter().map(Segment::cell_length).sum::<usize>())
134            .max()
135            .unwrap_or(0);
136        let rows = lines.len();
137        let mut out: Vec<Segment> = Vec::new();
138        // The last run's own style and its colour's escape codes: a
139        // character that comes out the same joins it.
140        let mut last: Option<(Option<Style>, String)> = None;
141        for (row, line) in lines.into_iter().enumerate() {
142            if row > 0 {
143                out.push(Segment::line());
144                last = None;
145            }
146            let mut col = 0;
147            for segment in line {
148                if segment.control {
149                    out.push(segment);
150                    last = None;
151                    continue;
152                }
153                for c in segment.text.chars() {
154                    let t = match self.direction {
155                        GradientDirection::Horizontal => share(col, widest),
156                        GradientDirection::Vertical => share(row, rows),
157                        GradientDirection::Diagonal => {
158                            (share(col, widest) + share(row, rows)) / 2.0
159                        }
160                    };
161                    col += rich::cells::char_cell_width(c);
162                    let colour = gradient(&self.stops, t).downgrade(system);
163                    let paint = if self.background {
164                        Style::from_color(None, Some(colour))
165                    } else {
166                        Style::from_color(Some(colour), None)
167                    };
168                    let key = (segment.style.clone(), paint.ansi_codes(system));
169                    match out.last_mut() {
170                        Some(run) if last.as_ref() == Some(&key) => run.text.push(c),
171                        _ => {
172                            let style = match &segment.style {
173                                Some(own) => own.combine(&paint),
174                                None => paint,
175                            };
176                            out.push(Segment::new(c.to_string(), Some(style)));
177                            last = Some(key);
178                        }
179                    }
180                }
181            }
182        }
183        out
184    }
185
186    fn measure(&self, console: &Console, options: &ConsoleOptions) -> Measurement {
187        self.text.measure(console, options)
188    }
189}
190
191/// The text's own words: the gradient is decoration.
192impl crate::a11y::AccessibleText for GradientText {
193    fn accessible_text(&self, _width: usize) -> String {
194        self.text.plain().to_string()
195    }
196}
197
198#[cfg(test)]
199mod tests {
200    use super::*;
201
202    #[test]
203    fn shares_run_from_the_first_to_the_last() {
204        assert_eq!(share(0, 5), 0.0);
205        assert_eq!(share(4, 5), 1.0);
206        assert_eq!(share(2, 5), 0.5);
207        assert_eq!(share(0, 1), 0.0);
208    }
209}