Skip to main content

grr_cli/
logo.rs

1//! The `--version` banner: the grr crab, rendered as well as the
2//! terminal allows.
3//!
4//! Two renderings of one logo. On a terminal that advertises 24-bit
5//! colour, the mascot is drawn as 60x22 cells of U+2580 UPPER HALF BLOCK,
6//! each cell carrying the top pixel in the foreground and the bottom
7//! pixel in the background — two pixels of vertical detail per character
8//! row, which is the most a character cell can express. Everywhere else
9//! (pipes, CI, `TERM=dumb`, `NO_COLOR`) the same mascot degrades to the
10//! ASCII crab.
11//!
12//! Both renderings put the bare semver on the FIRST line. clap prefixes
13//! it with `grr `, and three consumers depend on that line alone:
14//! scripts/benchmark.ts parses it as the measured version and the
15//! workflow's version gate compares it against Cargo.toml;
16//! scripts/generate-demo.ts matches it against
17//! `/^grr \d+\.\d+\.\d+$/m`; the Homebrew formula greps for the version as
18//! a substring anywhere in the output. A crab-first layout would parse as
19//! "version missing" and fail the gate open forever.
20//!
21//! The pixel map below is the mascot's idle pose, cropped to its
22//! bounding box. site/scripts/generate-mascot.mjs holds the 64x64
23//! original (`buildIdleMap`) and the sRGB values in [`rgb`] mirror its
24//! PALETTE_LEGEND, so the CLI and the site speak one palette.
25
26use std::borrow::Cow;
27use std::fmt::Write as _;
28use std::io::IsTerminal;
29
30/// The mascot's idle pose, 43 rows of 60 pixels, cropped to the crab's
31/// bounding box. `.` is transparent; every other cell indexes [`rgb`].
32const CRAB: [&str; 43] = [
33    "...................oooooooooo..oooooooooo...................",
34    "...................owwwwwwhho..owwwwwwhho...................",
35    "...................owwwwwwhho..owwwwwwhho...................",
36    "...................owwwwwwhho..owwwwwwhho...................",
37    "...................ohhhhkkhho..ohhhhkkhho...................",
38    "...................ohhhhkkhho..ohhhhkkhho...................",
39    "...................ohhkkkkhho..ohhkkkkhho...................",
40    "...................ohhkkkkhho..ohhkkkkhho...................",
41    "...................ohhkkkdhho..ohhkkkdhho...................",
42    "...................ophhhhhhhoooophhhhhhho...................",
43    ".................whohhhhhhhhoppohhhhhhhho...................",
44    "...oooo.........wphooooooooooppoooooooooo............oooo...",
45    "..o.mmmoooo....wwphhhpppppppwppppppppppmooo......oooommmoo..",
46    ".o.phhhpmmo...pwwwhhhpppppppwppppppppppmmmoo.....omphhhpmoo.",
47    "o.mpppppmmo..ppwwwwwwwwwwwwwwpppppwwwwwbmmmoo....ompppppmmoo",
48    "oompppppmmo..ppwwwwwwwwwwwwwwpppppwwwwwbbmmmoo...ompppppmm.o",
49    "o.mmmmmmmbo..pppwwwwwwwwwwwwpppppppppbwwbbmmmoo..ombmmmmmmoo",
50    "oommmmmmmmo..ppppwwwwwwwwwwppppppppppbwwbbbmmmo..ommmmmmmmo.",
51    ".ommmmmmmmoooppppppppppppppppppppppppbwwbbbmmmoo.ommmmmmmmo.",
52    ".o.dmmmmmo.ooppppppppddppppppppppppppbwwmmbmmmoo.o.mmmmmmdo.",
53    "..o.omm.ooobobppppppppppmmppppppppppbbbbbbdddddoooo.omm.oo..",
54    "...ooo.o..obobbppppppppppppppppppppbbddbbbdddddoopoooo.o....",
55    ".....oo...ooobbbbppppppppppppppppbbbbbbbbbdddddodoo..oo.....",
56    "...........oobbbbbrrrrpppppppppbbbbbbbrrrrdddddooo..........",
57    "............obbbbbpprrbbbbkbbbbbkbbbbbrrdddddddo............",
58    "............ombbbbbbbbbbbbbkkkkkbbbbbbbbbbdddddo............",
59    "............oombbbbbbbbbbbbbbbbbbbbbbbbbbmddddoo............",
60    "............oommbbbbbbbbbbbbbbbbbbbbbbbbmmddddoo............",
61    ".............ommmbbbbbbbbbbbbbbbbbbbbbbmmmddddo.............",
62    ".............oommmmbbbbbbbbbbbbbbbbbbmmmmmdddoo.............",
63    "..............oommmmmbbbbbbbbbbbbbbmmmmmmmddoo..............",
64    "...............oommmmmmmmmmmmmmmmmmmmmmmmmdoo...............",
65    "................oommmmmmmmmmmmmmmmmmmmmmmmoo................",
66    ".................ooommmmmmmmmdddddddddddooooo...............",
67    "................oobooommmmmmmdddddddddooooodo...............",
68    "...............ooboo.oooommmmddddddoooooo.odoo..............",
69    "..............ooboo..oooooooooooooooodoo..oodoo.............",
70    "..............oboo....oopoo........oboo....oopoo............",
71    ".............oobo......oopo.......oobo......oopo............",
72    "............ooboo.......opoo.....ooboo.......opoo...........",
73    "........owwwwooo........ooowwowwwwooo........ooowwwwo.......",
74    "........obbbboo..........ooppobbbboo..........ooppppo.......",
75    "........oooooo............ooooooooo............oooooo.......",
76];
77
78/// The tagline: the crate's `about` line, repeated verbatim so the two
79/// can never drift.
80const TAGLINE: &str = "Google tools from the terminal, at maximum performance";
81
82/// The plain-text banner: semver, a blank line, the ASCII crab, then the
83/// tagline — concatenated at compile time, so a non-colour terminal pays
84/// no formatting cost at all.
85const ASCII_BANNER: &str = concat!(
86    env!("CARGO_PKG_VERSION"),
87    "\n\n",
88    // The mascot's idle pose: wide-set glossy eyes, small smile, chunky
89    // claws, drawn in four rows of ASCII.
90    "      _~^~^~^~_\n",
91    "  \\) / (o) (o) \\ (/\n",
92    "    '_   \\_/   _'\n",
93    "    \\  '-----'  /\n",
94    "Google tools from the terminal, at maximum performance",
95);
96
97/// The sRGB value of one palette cell, or `None` for `.` (transparent).
98///
99/// Keys and values are PALETTE_LEGEND from
100/// site/scripts/generate-mascot.mjs; every non-`.` cell in [`CRAB`] has
101/// an arm, which is what the tests pin down.
102fn rgb(cell: char) -> Option<[u8; 3]> {
103    Some(match cell {
104        'o' => [0x3B, 0x1D, 0x0B],
105        's' => [0x7C, 0x2D, 0x12],
106        'd' => [0xC2, 0x41, 0x0C],
107        'm' => [0xEA, 0x58, 0x0C],
108        'b' => [0xF9, 0x73, 0x16],
109        'p' => [0xFF, 0x8A, 0x3D],
110        'w' => [0xFF, 0xD9, 0xB0],
111        'h' => [0xFF, 0xF3, 0xE4],
112        'k' => [0x1A, 0x12, 0x0B],
113        'r' => [0xEA, 0x43, 0x35],
114        _ => return None,
115    })
116}
117
118/// The whole `--version` banner, in the richest form this terminal
119/// supports.
120///
121/// Returns [`Cow::Borrowed`] — the compile-time string, zero allocation
122/// and zero formatting work — for every invocation whose stdout is not a
123/// 24-bit-colour terminal. Only an interactive colour terminal pays to
124/// render the logo, so piping, CI and the startup benchmark never do.
125pub fn version_banner() -> Cow<'static, str> {
126    if !graphics_enabled() {
127        return Cow::Borrowed(ASCII_BANNER);
128    }
129
130    let mut banner = String::with_capacity(ASCII_BANNER.len() + CRAB.len() * 64);
131    banner.push_str(env!("CARGO_PKG_VERSION"));
132    banner.push_str("\n\n");
133    render_truecolor(&mut banner);
134    // The logo is a tall block; a blank line keeps the tagline off its
135    // last row instead of butting against it.
136    banner.push('\n');
137    banner.push_str(TAGLINE);
138    Cow::Owned(banner)
139}
140
141/// Whether this invocation may draw the colour logo.
142///
143/// `NO_COLOR` wins over everything, including the `CLICOLOR_FORCE`
144/// opt-in: the variable's entire contract is "never colour my output",
145/// and an environment that sets both has said so twice.
146///
147/// `CLICOLOR_FORCE` (anything but `0`) is the conventional way to ask for
148/// colour through a pipe — `grr --version | less -R` — and it does not
149/// disturb the semver line, which stays plain either way. Without it the
150/// logo is only drawn for a real terminal, so the ASCII crab is what
151/// scripts, CI and Homebrew see.
152fn graphics_enabled() -> bool {
153    if std::env::var_os("NO_COLOR").is_some() {
154        return false;
155    }
156    if std::env::var("CLICOLOR_FORCE").is_ok_and(|force| force != "0") {
157        return true;
158    }
159    std::io::stdout().is_terminal() && truecolor_signalled(terminal_signals())
160}
161
162/// What the environment says about the terminal's capabilities, as
163/// (TERM, COLORTERM, running under Windows Terminal).
164fn terminal_signals() -> (Option<String>, Option<String>, bool) {
165    let term = std::env::var("TERM").ok().filter(|value| !value.is_empty());
166    let colorterm = std::env::var("COLORTERM").ok().filter(|v| !v.is_empty());
167    (term, colorterm, windows_terminal())
168}
169
170/// Whether those signals amount to "24-bit colour", the plain decision
171/// split out from the environment so it can be tested directly.
172///
173/// `COLORTERM` is the portable answer and the only one iTerm2, VTE
174/// terminals, Kitty, WezTerm and recent Windows Terminal all set. `TERM`
175/// carries it too on the terminals that predate `COLORTERM`; `direct`
176/// is foot and Ghostty, `truecolor` is Kitty's own spelling. Windows
177/// Terminal additionally gets a vote from `WT_SESSION`, which it always
178/// sets — and which matters because a ConPTY host interprets ANSI without
179/// the console mode bit that legacy conhost needs.
180fn truecolor_signalled(signals: (Option<String>, Option<String>, bool)) -> bool {
181    let (term, colorterm, windows_terminal) = signals;
182    // `dumb` is the terminal explicitly saying it draws nothing — the
183    // one TERM value that overrides every other signal, including a
184    // COLORTERM the user's shell inherited from somewhere else.
185    if term.as_deref() == Some("dumb") {
186        return false;
187    }
188    if matches!(colorterm.as_deref(), Some("truecolor" | "24bit")) {
189        return true;
190    }
191    // Foot and Ghostty advertise direct addressing in TERM itself rather
192    // than setting COLORTERM.
193    if let Some(term) = term.as_deref()
194        && (term.contains("direct") || term.contains("truecolor"))
195    {
196        return true;
197    }
198    if cfg!(windows) {
199        return windows_terminal;
200    }
201    false
202}
203
204/// Whether the process is hosted by Windows Terminal, where ANSI escape
205/// sequences are interpreted by the pseudoconsole without a console mode
206/// opt-in.
207#[cfg(windows)]
208fn windows_terminal() -> bool {
209    std::env::var_os("WT_SESSION").is_some()
210}
211
212/// Not Windows Terminal, and not Windows at all: no console-mode opt-in
213/// is available, so only the portable signals in [`truecolor_signalled`]
214/// apply.
215#[cfg(not(windows))]
216fn windows_terminal() -> bool {
217    false
218}
219
220/// Draw [`CRAB`] into `out` as half-block cells with 24-bit colour.
221///
222/// Each cell shows two source rows: the foreground paints the upper
223/// pixel, the background the lower one, so a character row carries two
224/// rows of the mascot. SGR sequences are emitted only when the colour
225/// actually changes from the previous cell — a pixel-art row is mostly
226/// runs, and the run-length pass keeps the banner from being a
227/// kilobyte of nearly repeated escapes.
228fn render_truecolor(out: &mut String) {
229    let mut foreground: Option<[u8; 3]> = None;
230    let mut background: Option<[u8; 3]> = None;
231
232    for pair in CRAB.chunks(2) {
233        let top = pair[0].as_bytes();
234        let bottom = pair.get(1).map(|row| row.as_bytes());
235        for column in 0..CRAB[0].len() {
236            let top_pixel = rgb(top[column] as char);
237            let bottom_pixel = bottom.and_then(|row| rgb(row[column] as char));
238
239            if foreground != top_pixel {
240                foreground = top_pixel;
241                set_colour(out, 38, top_pixel);
242            }
243            if background != bottom_pixel {
244                background = bottom_pixel;
245                set_colour(out, 48, bottom_pixel);
246            }
247            // A transparent pixel in both halves has nothing to paint,
248            // and a space costs no escape sequence.
249            out.push(if top_pixel.is_none() && bottom_pixel.is_none() {
250                ' '
251            } else {
252                UPPER_HALF_BLOCK
253            });
254        }
255        // Reset per row so the newline itself is never coloured.
256        out.push_str(RESET);
257        out.push('\n');
258    }
259}
260
261/// Emit one SGR colour change: 24-bit foreground (`layer` 38) or
262/// background (`layer` 48), or the default-ink reset when the pixel is
263/// transparent. Escapes are written as `\u{1b}` rather than a literal
264/// control byte so the source stays pure ASCII on every platform.
265fn set_colour(out: &mut String, layer: u8, pixel: Option<[u8; 3]>) {
266    match pixel {
267        Some([red, green, blue]) => {
268            let _ = write!(out, "\u{1b}[{layer};2;{red};{green};{blue}m");
269        }
270        None => {
271            let _ = write!(out, "\u{1b}[{}m", layer + 1);
272        }
273    }
274}
275
276/// U+2580 UPPER HALF BLOCK: foreground on the top half of the cell,
277/// background on the bottom half.
278const UPPER_HALF_BLOCK: char = '\u{2580}';
279
280/// SGR 0 — back to the terminal's own default ink. Emitted at the end of
281/// every logo row so the logo cannot bleed into the tagline.
282const RESET: &str = "\u{1b}[0m";
283
284#[cfg(test)]
285mod tests {
286    use super::*;
287
288    /// Every cell the crab map actually uses must resolve to a colour,
289    /// or the logo silently loses pixels.
290    #[test]
291    fn every_pixel_cell_maps_to_a_colour() {
292        for (row_index, row) in CRAB.iter().enumerate() {
293            for (column_index, cell) in row.chars().enumerate() {
294                if cell != '.' && rgb(cell).is_none() {
295                    panic!("CRAB[{row_index}][{column_index}] = {cell:?} has no palette arm");
296                }
297            }
298        }
299    }
300
301    /// The map is a fixed-size grid: half-block rendering indexes it
302    /// column-wise and pairs rows, so a ragged map would panic or skew.
303    #[test]
304    fn the_map_is_a_rectangular_grid() {
305        // Half-block rendering pairs rows and indexes every row by column,
306        // so a ragged map would either panic or skew the logo.
307        assert!(!CRAB.is_empty());
308        let width = CRAB[0].len();
309        for row in CRAB {
310            assert_eq!(row.len(), width, "ragged crab row: {row:?}");
311        }
312        // An odd row count is expected — the crab's last pixel row is
313        // paired with nothing — but the renderer must still emit it.
314        assert_eq!(CRAB.len().div_ceil(2), 22);
315    }
316
317    /// The logo is a tall block of half blocks ending in a reset, one
318    /// line per pair of source rows.
319    #[test]
320    fn the_colour_render_draws_every_row_as_a_reset_terminated_line() {
321        let mut rendered = String::new();
322        render_truecolor(&mut rendered);
323
324        let lines: Vec<&str> = rendered.lines().collect();
325        assert_eq!(lines.len(), CRAB.len().div_ceil(2));
326        // The crab's widest row is full-width; a narrower one would mean
327        // the transparent margins were trimmed by accident.
328        let widest = lines[CRAB.len() / 2 - 1];
329        assert!(widest.contains(UPPER_HALF_BLOCK));
330        for line in lines {
331            assert!(line.ends_with(RESET), "row not reset: {line:?}");
332            assert!(line.contains(UPPER_HALF_BLOCK));
333        }
334    }
335
336    /// Colour is decided by signals alone, so each way of saying
337    /// "24-bit" is pinned independently of the machine running the test.
338    #[test]
339    fn truecolor_is_detected_from_the_documented_signals() {
340        let absent = (None, None, false);
341        assert!(!truecolor_signalled(absent));
342        assert!(truecolor_signalled((None, Some("truecolor".into()), false)));
343        assert!(truecolor_signalled((None, Some("24bit".into()), false)));
344        // Foot and Ghostty carry it in TERM; Kitty, iTerm2 and the VTE
345        // terminals carry it in COLORTERM.
346        assert!(truecolor_signalled((
347            Some("foot-direct".into()),
348            None,
349            false
350        )));
351        assert!(!truecolor_signalled((
352            Some("xterm-kitty".into()),
353            None,
354            false
355        )));
356        assert!(truecolor_signalled((
357            Some("xterm-kitty".into()),
358            Some("truecolor".into()),
359            false
360        )));
361        // An empty COLORTERM is no signal at all, and TERM=dumb overrides
362        // every other signal — it is the terminal saying it draws nothing.
363        assert!(!truecolor_signalled((None, Some(String::new()), false)));
364        assert!(!truecolor_signalled((
365            Some("dumb".into()),
366            Some("truecolor".into()),
367            false
368        )));
369    }
370
371    /// A terminal with no truecolor signal renders the ASCII crab: the
372    /// semver, the four-row mascot, the tagline.
373    #[test]
374    fn the_plain_banner_keeps_semver_first_and_the_crab_intact() {
375        let banner = version_banner();
376        let first_line = banner.lines().next().expect("banner has a first line");
377        assert_eq!(first_line, env!("CARGO_PKG_VERSION"));
378        assert!(banner.contains("(o) (o)"));
379        assert!(banner.contains("\\_/"));
380        assert!(banner.ends_with(TAGLINE));
381    }
382}