Skip to main content

rpi_cli/
brand.rs

1//! The startup brand lockup: the three-bar mark, the `rpi` wordmark, and the
2//! Rust crab standing in for the language name.
3//!
4//! The layout exists as a module rather than inline in the welcome header for
5//! one reason: **width must be unambiguous.** The lockup this replaced drew a
6//! crab above a box containing `π`. `π` is East-Asian *Ambiguous* — width 1 in
7//! most locales and width 2 under a CJK locale — so the box borders walked out
8//! of alignment depending on the user's system. The mark here is built from `█`,
9//! which is single-width everywhere; the crab is the only wide glyph, so it is
10//! the one thing the layout has to account for, and a test pins its width.
11
12use rpi_tui::{bold as tui_bold, theme as current_theme, Component};
13
14use crate::args::is_truthy_env_flag;
15
16/// Set to `1`/`true`/`yes` to render the lockup without the crab.
17pub const NO_EMOJI_ENV: &str = "RPI_NO_EMOJI";
18
19/// The Rust mascot, standing where the word "rust" used to be.
20///
21/// Emoji render correctly in every terminal we target, but the rest of the UI
22/// deliberately avoids them (see the tool status indicator comment in
23/// `rpi-tui`), so this one is opt-out rather than mandatory.
24pub const CRAB: &str = "🦀";
25
26/// Separator between `rpi` and the crab.
27const SEPARATOR: &str = "·";
28
29/// Pure form of the opt-out check, so the logic is testable without mutating the
30/// process environment (which is global state and races with parallel tests).
31pub(crate) fn crab_enabled_from(value: Option<&str>) -> bool {
32    !is_truthy_env_flag(value)
33}
34
35fn crab_enabled() -> bool {
36    crab_enabled_from(std::env::var(NO_EMOJI_ENV).ok().as_deref())
37}
38
39/// The five rows of the mark and wordmark. `crab` adds the mascot after the
40/// wordmark; without it the wordmark stands alone.
41fn render_lockup(crab: bool) -> Vec<String> {
42    let c = current_theme().colors;
43    let bar = c.dim.fg("██");
44    let bar_accent = c.accent.fg("██");
45
46    // Name plus the language mark. Without the crab there is nothing to
47    // separate, so the separator goes with it rather than dangling.
48    let name = if crab {
49        format!(
50            "{} {} {CRAB}",
51            c.accent.fg(&tui_bold("rpi")),
52            c.muted.fg(SEPARATOR)
53        )
54    } else {
55        c.accent.fg(&tui_bold("rpi"))
56    };
57
58    vec![
59        format!("      {bar_accent}"),
60        format!("      {bar_accent}"),
61        format!("   {bar} {bar_accent}"),
62        format!("   {bar} {bar_accent} {bar}"),
63        format!("   {bar} {bar_accent} {bar}   {name}"),
64    ]
65}
66
67/// The startup lockup. Add one to the welcome header.
68pub struct BrandLockup;
69
70impl BrandLockup {
71    pub fn new() -> Self {
72        Self
73    }
74}
75
76impl Default for BrandLockup {
77    fn default() -> Self {
78        Self::new()
79    }
80}
81
82impl Component for BrandLockup {
83    fn render(&self, _width: usize) -> Vec<String> {
84        render_lockup(crab_enabled())
85    }
86
87    fn invalidate(&self) {
88        // Nothing is cached.
89    }
90
91    fn as_any(&self) -> &dyn std::any::Any {
92        self
93    }
94}
95
96#[cfg(test)]
97mod tests {
98    use super::*;
99    use rpi_tui::ansi::{strip_ansi, visible_width};
100
101    fn plain(crab: bool) -> Vec<String> {
102        render_lockup(crab).iter().map(|r| strip_ansi(r)).collect()
103    }
104
105    #[test]
106    fn mark_rows_match_the_site_brand() {
107        let rows = plain(true);
108
109        // Three bottom-aligned bars: the tallest in the middle, one row taller
110        // than the left bar and two taller than the right one.
111        assert_eq!(rows[0], "      ██");
112        assert_eq!(rows[1], "      ██");
113        assert_eq!(rows[2], "   ██ ██");
114        assert_eq!(rows[3], "   ██ ██ ██");
115    }
116
117    #[test]
118    fn crab_stands_in_for_the_language_name() {
119        let rows = plain(true);
120
121        // The crab replaces the word "rust" rather than sitting beside it.
122        assert_eq!(rows[4], "   ██ ██ ██   rpi · 🦀");
123
124        // The crab is wider than the word it replaces, and the layout must
125        // account for exactly that much: the separator plus the emoji.
126        let without = visible_width(&plain(false)[4]);
127        assert_eq!(
128            visible_width(&rows[4]) - without,
129            visible_width(" · ") + visible_width(CRAB)
130        );
131    }
132
133    #[test]
134    fn emoji_width_is_the_one_thing_the_layout_depends_on() {
135        // If a dependency bump changed this, the lockup would silently shift.
136        assert_eq!(visible_width(CRAB), 2, "crab width assumption changed");
137    }
138
139    #[test]
140    fn disabling_drops_the_crab_and_its_separator() {
141        assert!(crab_enabled_from(None));
142        for value in ["1", "true", "yes", "TRUE", "Yes"] {
143            assert!(!crab_enabled_from(Some(value)), "{value} should disable");
144        }
145        for value in ["", "0", "false", "no", "off"] {
146            assert!(crab_enabled_from(Some(value)), "{value} should not disable");
147        }
148
149        let rows = plain(false);
150        assert_eq!(rows.len(), 5);
151        assert!(!rows[4].contains(CRAB));
152        // No dangling separator once the crab is gone.
153        assert!(!rows[4].contains(SEPARATOR), "got {:?}", rows[4]);
154        assert!(rows[4].ends_with("rpi"), "got {:?}", rows[4]);
155    }
156}