Skip to main content

qframe/widgets/
big_text.rs

1//! Big text: digits and letters drawn several rows tall, for clocks, counters and titles.
2
3use 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
10/// Pixel rows of every glyph.
11const PIXEL_ROWS: usize = 5;
12
13/// The bitmap of `c`, one string per pixel row, `#` for a lit pixel. Lowercase letters use the
14/// uppercase forms; characters without a form are drawn as a space.
15fn 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/// Which way a [`BigText`] gradient runs.
62#[derive(Debug, Clone, Copy, PartialEq, Eq)]
63pub enum Gradient {
64    /// From left to right: every column of cells takes one step of the blend.
65    Columns,
66    /// From top to bottom: every row of cells takes one step of the blend.
67    Rows,
68}
69
70/// Text drawn large from block elements: digits, `:`, `.`, `%`, `-` and the letters A to Z.
71///
72/// Each glyph is five pixels tall. With Unicode and Nerd Font glyphs two pixels share a cell
73/// through half blocks, so the text is three rows tall; ASCII mode draws one pixel per cell in
74/// the background colour, five rows tall. Glyphs are separated by one column. When the area is too
75/// small, the text is drawn at normal size in bold instead of being cut.
76///
77/// The letters take one flat colour unless [`BigText::gradient`] blends them into a second theme
78/// colour; the blend is painted, not animated ([`ShimmerText`](super::ShimmerText) is the moving
79/// one). It steps per cell, so the three glyph modes differ only in how many steps they have: a
80/// blend down the rows has three steps with Unicode and Nerd Font glyphs and five in ASCII mode.
81///
82/// Style keys: `big-text` and `big-text.<variant>` (`fg`).
83#[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    /// Big `text`, e.g. `"14:32"` or `"98%"`.
92    #[must_use]
93    pub fn new(text: impl Into<String>) -> Self {
94        Self { text: text.into(), variant: None, gradient: None }
95    }
96
97    /// Theme variant, e.g. `"accent"`.
98    #[must_use]
99    pub fn variant(mut self, variant: impl Into<String>) -> Self {
100        self.variant = Some(variant.into());
101        self
102    }
103
104    /// Blends the letters from their own colour into the theme colour `to`, running `direction`.
105    ///
106    /// `to` is a theme colour token such as `"info"` or `"accent-2"`, never a colour of its own, so
107    /// the blend changes with the theme. It falls back to the flat colour, which every glyph mode
108    /// and colour depth can draw, when the terminal has only the sixteen standard colours or when
109    /// the theme does not know `to`.
110    #[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        // Saturating: a long text is wider than any screen and falls back to plain text anyway.
118        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    /// The far end of the gradient and its direction, when one is asked for and both the terminal
128    /// and the theme can give it.
129    ///
130    /// The sixteen standard colours cannot hold a blend: every cell would round to its own palette
131    /// entry on its own and the letters would speckle instead of shading, so there the text keeps
132    /// its flat colour. A token the theme does not know falls back the same way, rather than
133    /// blending into a colour nobody chose.
134    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
143/// Rows the big form takes in `mode`.
144fn 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        // The blend runs over the whole text, so the gaps between glyphs count as steps too.
175        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
213/// Where step `step` of `steps` stands, from 0 to 1; a single step is at the start.
214fn share(step: u16, steps: u16) -> f32 {
215    match steps {
216        0 | 1 => 0.0,
217        // Cell counts are small, so the division is exact enough for a colour blend.
218        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    /// Big text with a gradient towards a theme colour.
243    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    /// The two ends of the blend in the built-in theme, and where a step of it stands.
301    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        // "OK" is seven columns wide: three for each letter and the gap between them.
311        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        // The stem of the K is lit in all three rows.
333        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}