Skip to main content

qcode/ui/
logo.rs

1//! The QCode wordmark, drawn from block elements.
2//!
3//! The drawing belongs to this application rather than to the framework: it is one picture of one
4//! name, not a component another application could reuse.
5
6use qframe::geometry::{Rect, Size};
7use qframe::icons::GlyphMode;
8use qframe::style::CellStyle;
9use qframe::text;
10use qframe::widget::{MeasureCx, PaintCx, Widget};
11
12/// The wordmark, one string per row. Every character is one cell wide, so a column of the drawing
13/// is a column of the screen.
14const ART: [&str; ROWS as usize] = [
15    "   ▄████████",
16    " ▄███▀▀▀▀███",
17    "███▀     ███  ██████  ██████  █████▄  ██████",
18    "███      ███  ██      ██  ██  ██  ██  ██▄▄▄▄",
19    "███▄▄▄▄▄  ▀█  ██      ██  ██  ██  ██  ██▀▀▀▀",
20    "█████████▄    ██████  ██████  █████▀  ██████",
21];
22
23/// Rows the drawing takes.
24const ROWS: u16 = 6;
25
26/// The form the drawing gives way to. The product name is a name rather than a phrase: it reads
27/// the same in every language and so is not part of the language files.
28const PLAIN: &str = "QCode";
29
30/// Rows the logo keeps free beneath itself: the tagline and the six menu rows. The menu is the
31/// screen and the logo is its decoration, so on a short terminal the logo shrinks to the name and
32/// then goes altogether rather than pushing a row of the menu off the bottom.
33const ROWS_BELOW: u16 = 7;
34
35/// Theme colour the wordmark is painted in. A logo is one accent, not a palette.
36const COLOR: &str = "accent";
37
38/// The QCode wordmark.
39///
40/// Drawn from block elements where the terminal has them. ASCII has no block elements and a shape
41/// spelled out of `#` or `=` would be a text ornament, so in that mode the lit cells are painted
42/// in the accent colour instead, which rounds every half block up to a whole cell.
43///
44/// The drawing gives way before the menu does: without the room for it the name is written
45/// plainly, and without the room for that either nothing is drawn.
46///
47/// Give the logo [`Logo::WIDTH`] cells of width. It centres whichever form it draws inside the
48/// area it is given, so a narrower area still holds the name in the middle.
49#[derive(Debug, Clone, Copy, PartialEq, Eq, Default)]
50pub struct Logo;
51
52impl Logo {
53    /// Cells the drawing takes across, and the width the logo is laid out with.
54    pub const WIDTH: u16 = 44;
55
56    /// The wordmark.
57    #[must_use]
58    pub fn new() -> Self {
59        Self
60    }
61}
62
63impl<Msg: 'static> Widget<Msg> for Logo {
64    fn measure(&self, _cx: &mut MeasureCx<'_>, available: Size) -> Size {
65        let width = available.width.min(Self::WIDTH);
66        // What is left once the tagline and the menu have their rows.
67        let spare = available.height.saturating_sub(ROWS_BELOW);
68        if width >= Self::WIDTH && spare >= ROWS {
69            Size::new(width, ROWS)
70        } else if width >= text::width(PLAIN) && spare >= 1 {
71            Size::new(width, 1)
72        } else {
73            Size::new(0, 0)
74        }
75    }
76
77    fn paint(&self, cx: &mut PaintCx<'_>, area: Rect) {
78        if area.is_empty() {
79            return;
80        }
81        let color = cx.color(COLOR);
82        if area.width < Self::WIDTH || area.height < ROWS {
83            let name = area.centered(Size::new(text::width(PLAIN), 1));
84            cx.text(name.x, name.y, PLAIN, CellStyle::fg(color).with_bold(true), area.width);
85            return;
86        }
87        let drawing = area.centered(Size::new(Self::WIDTH, ROWS));
88        let painted = cx.env().glyph_mode() == GlyphMode::Ascii;
89        for (row, line) in ART.iter().enumerate() {
90            let y = drawing.y + i32::try_from(row).unwrap_or(0);
91            for (column, glyph) in line.chars().enumerate() {
92                if glyph == ' ' {
93                    continue;
94                }
95                let x = drawing.x + i32::try_from(column).unwrap_or(0);
96                if painted {
97                    cx.clear(Rect::new(x, y, 1, 1), color);
98                } else {
99                    let mut buffer = [0u8; 4];
100                    cx.text(x, y, glyph.encode_utf8(&mut buffer), CellStyle::fg(color), 1);
101                }
102            }
103        }
104    }
105}
106
107#[cfg(test)]
108mod tests {
109    use qframe::prelude::*;
110    use qframe::runtime::Harness;
111
112    use super::*;
113
114    /// The logo with the room it keeps free below it, laid out the way the home screen lays it
115    /// out: a fixed width, so the drawing is never measured against a narrower column.
116    struct Demo;
117
118    impl App for Demo {
119        type Msg = ();
120
121        fn update(&mut self, (): ()) -> Command<()> {
122            Command::none()
123        }
124
125        fn view(&self, ui: &mut View<'_, ()>) {
126            ui.add(Logo::new()).width(Length::Cells(Logo::WIDTH));
127        }
128    }
129
130    fn logo(width: u16, height: u16) -> Harness<Demo> {
131        let mut harness = Harness::new(Demo, width, height);
132        harness.set_glyph_mode(GlyphMode::Unicode).render();
133        harness
134    }
135
136    /// The shortest screen the drawing appears on.
137    const TALL: u16 = ROWS + ROWS_BELOW;
138
139    #[test]
140    fn every_glyph_takes_exactly_one_cell() {
141        for line in ART {
142            for glyph in line.chars() {
143                let mut buffer = [0u8; 4];
144                let glyph = glyph.encode_utf8(&mut buffer);
145                assert_eq!(text::width(glyph), 1, "`{glyph}` is not one cell wide");
146            }
147        }
148    }
149
150    #[test]
151    fn the_rows_keep_the_widths_they_were_drawn_with() {
152        let widths: Vec<u16> = ART.iter().map(|line| text::width(line)).collect();
153        assert_eq!(widths, vec![12, 12, Logo::WIDTH, Logo::WIDTH, Logo::WIDTH, Logo::WIDTH]);
154    }
155
156    #[test]
157    fn unicode_draws_the_wordmark_in_the_accent_colour() {
158        let harness = logo(Logo::WIDTH, TALL);
159        let screen = harness.screen();
160        let drawn: Vec<&str> = screen.lines().take(usize::from(ROWS)).map(str::trim_end).collect();
161        assert_eq!(drawn, ART, "{screen}");
162        assert_eq!(harness.fg(3, 0), harness.env().theme().color(COLOR));
163    }
164
165    #[test]
166    fn nerd_font_draws_the_same_wordmark() {
167        let mut harness = logo(Logo::WIDTH, TALL);
168        let unicode = harness.screen();
169        harness.set_glyph_mode(GlyphMode::Nerd).render();
170        assert_eq!(harness.screen(), unicode);
171    }
172
173    #[test]
174    fn ascii_paints_the_shape_in_cells_of_colour() {
175        let mut harness = logo(Logo::WIDTH, TALL);
176        harness.set_glyph_mode(GlyphMode::Ascii).render();
177        let screen = harness.screen();
178        assert!(screen.is_ascii(), "{screen}");
179        assert!(screen.trim().is_empty(), "the shape is colour, not characters:\n{screen}");
180        let accent = harness.env().theme().color(COLOR);
181        assert_eq!(harness.bg(3, 0), accent, "the first lit cell of the top row");
182        assert_ne!(harness.bg(0, 0), accent, "the indent of the top row stays empty");
183        // `▀` is half a cell in the other modes and the whole of one here.
184        assert_eq!(harness.bg(10, 4), accent, "a half block rounds up to a whole cell");
185        assert_ne!(harness.bg(8, 4), accent, "the gap beside it stays empty");
186    }
187
188    #[test]
189    fn a_narrow_screen_writes_the_name_plainly_and_centred() {
190        let harness = logo(Logo::WIDTH - 1, TALL);
191        assert_eq!(harness.screen().lines().next(), Some("                   QCode"));
192    }
193
194    #[test]
195    fn a_short_screen_writes_the_name_plainly() {
196        let harness = logo(Logo::WIDTH, TALL - 1);
197        assert_eq!(harness.screen().lines().next().map(str::trim), Some(PLAIN));
198    }
199
200    #[test]
201    fn a_screen_with_no_room_to_spare_drops_the_logo() {
202        let harness = logo(Logo::WIDTH, ROWS_BELOW);
203        assert!(harness.screen().trim().is_empty(), "{}", harness.screen());
204    }
205
206    #[test]
207    fn a_screen_too_narrow_even_for_the_name_drops_the_logo() {
208        let harness = logo(text::width(PLAIN) - 1, TALL);
209        assert!(harness.screen().trim().is_empty(), "{}", harness.screen());
210    }
211
212    #[test]
213    fn ascii_never_leaves_a_block_element_behind() {
214        let mut harness = logo(Logo::WIDTH - 1, TALL);
215        harness.set_glyph_mode(GlyphMode::Ascii).render();
216        assert!(harness.screen().is_ascii(), "{}", harness.screen());
217    }
218}