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}