1use crate::color::{ColorDepth, Rgb};
4use crate::geometry::{Rect, Size, clamp_u16};
5use crate::icons::GlyphMode;
6use crate::style::CellStyle;
7use crate::text;
8use crate::widget::{MeasureCx, PaintCx, Widget};
9
10const PIXEL_ROWS: usize = 5;
12
13fn glyph(c: char) -> [&'static str; PIXEL_ROWS] {
16 match c.to_ascii_uppercase() {
17 '0' => ["###", "#.#", "#.#", "#.#", "###"],
18 '1' => [".#.", "##.", ".#.", ".#.", "###"],
19 '2' => ["###", "..#", "###", "#..", "###"],
20 '3' => ["###", "..#", ".##", "..#", "###"],
21 '4' => ["#.#", "#.#", "###", "..#", "..#"],
22 '5' => ["###", "#..", "###", "..#", "###"],
23 '6' => ["###", "#..", "###", "#.#", "###"],
24 '7' => ["###", "..#", "..#", "..#", "..#"],
25 '8' => ["###", "#.#", "###", "#.#", "###"],
26 '9' => ["###", "#.#", "###", "..#", "###"],
27 ':' => [".", "#", ".", "#", "."],
28 '.' => [".", ".", ".", ".", "#"],
29 '%' => ["#.#", "..#", ".#.", "#..", "#.#"],
30 '-' => ["...", "...", "###", "...", "..."],
31 'A' => [".#.", "#.#", "###", "#.#", "#.#"],
32 'B' => ["##.", "#.#", "##.", "#.#", "##."],
33 'C' => [".##", "#..", "#..", "#..", ".##"],
34 'D' => ["##.", "#.#", "#.#", "#.#", "##."],
35 'E' => ["###", "#..", "##.", "#..", "###"],
36 'F' => ["###", "#..", "##.", "#..", "#.."],
37 'G' => [".##", "#..", "#.#", "#.#", ".##"],
38 'H' => ["#.#", "#.#", "###", "#.#", "#.#"],
39 'I' => ["###", ".#.", ".#.", ".#.", "###"],
40 'J' => ["..#", "..#", "..#", "#.#", ".#."],
41 'K' => ["#.#", "#.#", "##.", "#.#", "#.#"],
42 'L' => ["#..", "#..", "#..", "#..", "###"],
43 'M' => ["#...#", "##.##", "#.#.#", "#...#", "#...#"],
44 'N' => ["#..#", "##.#", "#.##", "#..#", "#..#"],
45 'O' => [".#.", "#.#", "#.#", "#.#", ".#."],
46 'P' => ["##.", "#.#", "##.", "#..", "#.."],
47 'Q' => [".#.", "#.#", "#.#", "##.", ".##"],
48 'R' => ["##.", "#.#", "##.", "#.#", "#.#"],
49 'S' => [".##", "#..", ".#.", "..#", "##."],
50 'T' => ["###", ".#.", ".#.", ".#.", ".#."],
51 'U' => ["#.#", "#.#", "#.#", "#.#", "###"],
52 'V' => ["#.#", "#.#", "#.#", "#.#", ".#."],
53 'W' => ["#...#", "#...#", "#.#.#", "##.##", "#...#"],
54 'X' => ["#.#", "#.#", ".#.", "#.#", "#.#"],
55 'Y' => ["#.#", "#.#", ".#.", ".#.", ".#."],
56 'Z' => ["###", "..#", ".#.", "#..", "###"],
57 _ => ["..", "..", "..", "..", ".."],
58 }
59}
60
61#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum Gradient {
64 Columns,
66 Rows,
68}
69
70#[derive(Debug, Clone, PartialEq, Eq)]
84pub struct BigText {
85 text: String,
86 variant: Option<String>,
87 gradient: Option<(String, Gradient)>,
88}
89
90impl BigText {
91 #[must_use]
93 pub fn new(text: impl Into<String>) -> Self {
94 Self { text: text.into(), variant: None, gradient: None }
95 }
96
97 #[must_use]
99 pub fn variant(mut self, variant: impl Into<String>) -> Self {
100 self.variant = Some(variant.into());
101 self
102 }
103
104 #[must_use]
111 pub fn gradient(mut self, to: impl Into<String>, direction: Gradient) -> Self {
112 self.gradient = Some((to.into(), direction));
113 self
114 }
115
116 fn big_width(&self) -> u16 {
117 let glyphs = self
119 .text
120 .chars()
121 .map(|c| clamp_u16(i32::try_from(glyph(c)[0].len()).unwrap_or(0)))
122 .fold(0, u16::saturating_add);
123 let gaps = clamp_u16(i32::try_from(self.text.chars().count()).unwrap_or(i32::MAX) - 1);
124 glyphs.saturating_add(gaps)
125 }
126
127 fn blend(&self, cx: &PaintCx<'_>) -> Option<(Rgb, Gradient)> {
135 let (token, direction) = self.gradient.as_ref()?;
136 if cx.env().depth() == ColorDepth::Ansi16 {
137 return None;
138 }
139 Some((cx.env().theme().color(token)?, *direction))
140 }
141}
142
143fn big_rows(mode: GlyphMode) -> u16 {
145 if mode == GlyphMode::Ascii { 5 } else { 3 }
146}
147
148impl<Msg: 'static> Widget<Msg> for BigText {
149 fn measure(&self, cx: &mut MeasureCx<'_>, available: Size) -> Size {
150 let rows = big_rows(cx.env().glyph_mode());
151 let big = Size::new(self.big_width(), rows);
152 if big.width <= available.width && big.height <= available.height {
153 big
154 } else {
155 Size::new(text::width(&self.text), 1).min(available)
156 }
157 }
158
159 fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
160 if area.is_empty() {
161 return;
162 }
163 let mut style = cx.style("big-text", self.variant.as_deref(), &[]).text();
164 style.bg = None;
165 let color = style.fg.unwrap_or_else(|| cx.color("text"));
166 let mode = cx.env().glyph_mode();
167 if self.big_width() > area.width || big_rows(mode) > area.height {
168 let shown = text::truncate(&self.text, area.width).into_owned();
169 cx.text(area.x, area.y, &shown, CellStyle::fg(color).with_bold(true), area.width);
170 return;
171 }
172 let rows = big_rows(mode);
173 let blend = self.blend(cx);
174 let tone = |cell_x: i32, cell_row: u16| match blend {
176 None => color,
177 Some((end, Gradient::Columns)) => color.mix(end, share(clamp_u16(cell_x - area.x), self.big_width())),
178 Some((end, Gradient::Rows)) => color.mix(end, share(cell_row, rows)),
179 };
180 let mut x = area.x;
181 for c in self.text.chars() {
182 let pixels = glyph(c);
183 let width = clamp_u16(i32::try_from(pixels[0].len()).unwrap_or(0));
184 for column in 0..usize::from(width) {
185 let lit = |row: usize| pixels.get(row).is_some_and(|line| line.as_bytes()[column] == b'#');
186 let cell_x = x + i32::try_from(column).unwrap_or(0);
187 if mode == GlyphMode::Ascii {
188 for row in 0..PIXEL_ROWS {
189 if lit(row) {
190 let row = clamp_u16(i32::try_from(row).unwrap_or(0));
191 cx.clear(Rect::new(cell_x, area.y + i32::from(row), 1, 1), tone(cell_x, row));
192 }
193 }
194 continue;
195 }
196 for cell_row in 0..3 {
197 let symbol = match (lit(cell_row * 2), lit(cell_row * 2 + 1)) {
198 (true, true) => "█",
199 (true, false) => "▀",
200 (false, true) => "▄",
201 (false, false) => continue,
202 };
203 let row = clamp_u16(i32::try_from(cell_row).unwrap_or(0));
204 let y = area.y + i32::from(row);
205 cx.text(cell_x, y, symbol, CellStyle::fg(tone(cell_x, row)), 1);
206 }
207 }
208 x += i32::from(width) + 1;
209 }
210 }
211}
212
213fn share(step: u16, steps: u16) -> f32 {
215 match steps {
216 0 | 1 => 0.0,
217 steps => f32::from(step.min(steps - 1)) / f32::from(steps - 1),
219 }
220}
221
222#[cfg(test)]
223mod tests {
224 use ratatui_core::style::Color;
225
226 use super::*;
227 use crate::runtime::{App, Command, Harness};
228 use crate::widget::View;
229
230 struct Demo(&'static str);
231
232 impl App for Demo {
233 type Msg = ();
234 fn update(&mut self, _: ()) -> Command<()> {
235 Command::none()
236 }
237 fn view(&self, ui: &mut View<'_, ()>) {
238 ui.add(BigText::new(self.0).variant("accent"));
239 }
240 }
241
242 struct Blended {
244 text: &'static str,
245 to: &'static str,
246 direction: Gradient,
247 }
248
249 impl App for Blended {
250 type Msg = ();
251 fn update(&mut self, _: ()) -> Command<()> {
252 Command::none()
253 }
254 fn view(&self, ui: &mut View<'_, ()>) {
255 ui.add(BigText::new(self.text).variant("accent").gradient(self.to, self.direction));
256 }
257 }
258
259 fn blended(text: &'static str, direction: Gradient) -> Harness<Blended> {
260 Harness::new(Blended { text, to: "info", direction }, 20, 5)
261 }
262
263 #[test]
264 fn digits_and_colon_in_half_blocks() {
265 let h = Harness::new(Demo("12:05"), 20, 3);
266 assert_eq!(h.screen(), "▄█ ▀▀█ ▄ █▀█ █▀▀\n █ █▀▀ ▄ █ █ ▀▀█\n▀▀▀ ▀▀▀ ▀▀▀ ▀▀▀\n");
267 assert_eq!(h.fg(0, 0), h.env().theme().color("accent"));
268 }
269
270 #[test]
271 fn letters_and_percent() {
272 let h = Harness::new(Demo("OK 9%"), 20, 3);
273 assert_eq!(h.screen(), "▄▀▄ █ █ █▀█ ▀ █\n█ █ █▀▄ ▀▀█ ▄▀\n ▀ ▀ ▀ ▀▀▀ ▀ ▀\n");
274 }
275
276 #[test]
277 fn ascii_uses_coloured_cells_five_rows_tall() {
278 let mut h = Harness::new(Demo("7"), 6, 5);
279 h.set_glyph_mode(GlyphMode::Ascii);
280 assert_eq!(h.screen(), "\n\n\n\n\n");
281 let accent = h.env().theme().color("accent");
282 assert_eq!(h.bg(0, 0), accent);
283 assert_eq!(h.bg(2, 4), accent);
284 assert_ne!(h.bg(0, 4), accent);
285 }
286
287 #[test]
288 fn falls_back_to_plain_bold_text_when_small() {
289 let h = Harness::new(Demo("12:05"), 20, 2);
290 assert_eq!(h.screen(), "12:05\n\n");
291 assert!(h.is_bold(0, 0));
292 }
293
294 #[test]
295 fn text_wider_than_any_screen_falls_back_without_overflowing() {
296 let h = Harness::new(Demo("12:05".repeat(4_000).leak()), 8, 3);
297 assert_eq!(h.screen(), "12:0512…\n\n\n");
298 }
299
300 fn ends<A: App>(h: &Harness<A>) -> (Rgb, Rgb) {
302 let theme = h.env().theme();
303 (theme.color("accent").expect("token"), theme.color("info").expect("token"))
304 }
305
306 #[test]
307 fn the_flat_colour_is_the_default() {
308 let h = Harness::new(Demo("OK"), 20, 3);
309 let (accent, info) = ends(&h);
310 for x in [0, 4, 6] {
312 assert_eq!(h.fg(x, 0), Some(accent), "column {x} keeps the flat colour");
313 }
314 assert_ne!(accent, info, "the test would say nothing if the ends were the same colour");
315 }
316
317 #[test]
318 fn a_column_gradient_blends_from_left_to_right() {
319 let h = blended("OK", Gradient::Columns);
320 let (accent, info) = ends(&h);
321 assert_eq!(h.screen(), "▄▀▄ █ █\n█ █ █▀▄\n ▀ ▀ ▀\n\n\n");
322 assert_eq!(h.fg(0, 0), Some(accent), "the first column is the near end");
323 assert_eq!(h.fg(4, 0), Some(accent.mix(info, 4.0 / 6.0)), "and every column a step further");
324 assert_eq!(h.fg(6, 0), Some(info), "the last column is the far end");
325 assert_eq!(h.fg(0, 1), h.fg(0, 0), "a column is one tone from top to bottom");
326 }
327
328 #[test]
329 fn a_row_gradient_blends_from_top_to_bottom() {
330 let h = blended("OK", Gradient::Rows);
331 let (accent, info) = ends(&h);
332 assert_eq!(h.fg(4, 0), Some(accent), "the first row is the near end");
334 assert_eq!(h.fg(4, 1), Some(accent.mix(info, 0.5)), "the middle row is halfway");
335 assert_eq!(h.fg(4, 2), Some(info), "the last row is the far end");
336 assert_eq!(h.fg(6, 0), h.fg(4, 0), "a row is one tone from left to right");
337 }
338
339 #[test]
340 fn ascii_blends_over_its_five_rows_of_cells() {
341 let mut h = blended("OK", Gradient::Rows);
342 h.set_glyph_mode(GlyphMode::Ascii);
343 let (accent, info) = ends(&h);
344 assert_eq!(h.bg(4, 0), Some(accent));
345 assert_eq!(h.bg(4, 2), Some(accent.mix(info, 0.5)), "five rows of cells, so five steps");
346 assert_eq!(h.bg(4, 4), Some(info));
347
348 let mut columns = blended("OK", Gradient::Columns);
349 columns.set_glyph_mode(GlyphMode::Ascii);
350 assert_eq!(columns.bg(0, 1), Some(accent));
351 assert_eq!(columns.bg(6, 0), Some(info), "and the same ends across the columns");
352 }
353
354 #[test]
355 fn nerd_font_glyphs_blend_like_unicode_ones() {
356 let mut h = blended("OK", Gradient::Columns);
357 let unicode: Vec<Option<Rgb>> = (0..7).map(|x| h.fg(x, 0)).collect();
358 h.set_glyph_mode(GlyphMode::Nerd);
359 let nerd: Vec<Option<Rgb>> = (0..7).map(|x| h.fg(x, 0)).collect();
360 assert_eq!(unicode, nerd);
361 }
362
363 #[test]
364 fn sixteen_colours_fall_back_to_the_flat_colour() {
365 let mut h = blended("OK", Gradient::Columns);
366 h.set_depth(ColorDepth::Ansi16);
367 let (accent, _) = ends(&h);
368 let flat = Color::Indexed(accent.to_ansi16());
369 for (x, y) in [(0, 0), (4, 0), (6, 0), (4, 1), (4, 2)] {
370 assert_eq!(h.buffer()[(x, y)].fg, flat, "cell {x},{y} is the flat colour");
371 }
372 let mut deeper = blended("OK", Gradient::Columns);
373 deeper.set_depth(ColorDepth::Ansi256);
374 assert_ne!(
375 deeper.buffer()[(0, 0)].fg,
376 deeper.buffer()[(6, 0)].fg,
377 "the 256-colour palette still shows the blend"
378 );
379 }
380
381 #[test]
382 fn a_colour_the_theme_does_not_know_stays_flat() {
383 let h = Harness::new(Blended { text: "OK", to: "sunset", direction: Gradient::Columns }, 20, 5);
384 let (accent, _) = ends(&h);
385 assert_eq!(h.fg(0, 0), Some(accent));
386 assert_eq!(h.fg(6, 0), Some(accent));
387 }
388
389 #[test]
390 fn the_plain_fallback_keeps_the_flat_colour() {
391 let h = Harness::new(Blended { text: "OK", to: "info", direction: Gradient::Columns }, 20, 2);
392 let (accent, _) = ends(&h);
393 assert_eq!(h.screen(), "OK\n\n");
394 assert!(h.is_bold(0, 0));
395 assert_eq!(h.fg(0, 0), Some(accent));
396 assert_eq!(h.fg(1, 0), Some(accent), "a blend over two cells would read as a mistake");
397 }
398
399 #[test]
400 fn one_glyph_wide_text_and_every_theme_keep_both_ends() {
401 for theme in ["monochrome", "nordic", "amber", "iris"] {
402 let mut h = blended("OK", Gradient::Columns);
403 h.set_theme(theme);
404 let (accent, info) = ends(&h);
405 assert_eq!(h.fg(0, 0), Some(accent), "{theme}");
406 assert_eq!(h.fg(6, 0), Some(info), "{theme}");
407
408 let mut single = blended("7", Gradient::Columns);
409 single.set_theme(theme);
410 assert_eq!(single.fg(0, 0), Some(accent), "{theme}: three columns still blend");
411 assert_eq!(single.fg(2, 0), Some(info), "{theme}");
412 }
413 }
414
415 #[test]
416 fn share_of_a_single_step_is_the_near_end() {
417 assert_eq!(share(0, 0), 0.0);
418 assert_eq!(share(0, 1), 0.0);
419 assert_eq!(share(3, 3), 1.0, "a step past the end stays at the far end");
420 assert_eq!(share(1, 3), 0.5);
421 }
422}