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//!
12//! The mark and the wordmark also supply the terminal window/tab title (see
13//! [`window_title`]), so the tab and the startup screen cannot drift apart.
14
15use rpi_tui::{bold as tui_bold, theme as current_theme, Component};
16
17use crate::args::is_truthy_env_flag;
18
19/// Set to `1`/`true`/`yes` to render the lockup without the crab.
20pub const NO_EMOJI_ENV: &str = "RPI_NO_EMOJI";
21
22/// The Rust mascot, standing where the word "rust" used to be.
23///
24/// Emoji render correctly in every terminal we target, but the rest of the UI
25/// deliberately avoids them (see the tool status indicator comment in
26/// `rpi-tui`), so this one is opt-out rather than mandatory.
27pub const CRAB: &str = "🦀";
28
29/// Separator between `rpi` and the crab.
30const SEPARATOR: &str = "·";
31
32/// The three-bar mark as a single plain-text row: the lockup's silhouette,
33/// scaled into one row.
34///
35/// `render_lockup` colourises the bars with theme escapes and stacks the bar
36/// heights; neither survives into an OSC 2 window title, which must stay
37/// escape-free and single-row, so callers that need the mark as text (see
38/// [`window_title`]) use this instead.
39///
40/// Each bar is **one cell** wide (`██` per bar reads as three fat slabs in a
41/// tab strip) and its height is a lower-eighth block — one block glyph covers
42/// one lockup row, so the 3:5:2 profile of the startup lockup survives intact:
43/// the tallest bar is the middle one, the shortest the right one. Block
44/// elements have the same width class as the `█` this replaced, so the title
45/// still cannot drift on a CJK system the way the old `π` mark did.
46pub const MARK: &str = "▃ ▅ ▂";
47
48/// Pure form of the opt-out check, so the logic is testable without mutating the
49/// process environment (which is global state and races with parallel tests).
50pub(crate) fn crab_enabled_from(value: Option<&str>) -> bool {
51    !is_truthy_env_flag(value)
52}
53
54fn crab_enabled() -> bool {
55    crab_enabled_from(std::env::var(NO_EMOJI_ENV).ok().as_deref())
56}
57
58/// The wordmark plus the language mark, in plain text: `rpi · 🦀`, or bare
59/// `rpi` when the crab is disabled.
60fn plain_name(crab: bool) -> String {
61    if crab {
62        format!("rpi {SEPARATOR} {CRAB}")
63    } else {
64        "rpi".to_string()
65    }
66}
67
68/// The startup lockup flattened into a terminal window/tab title.
69///
70/// The tab title has one row, so it carries the mark and the wordmark from the
71/// lockup; a lower-eighth block per bar (see [`MARK`]) is what keeps the lockup's
72/// bar heights readable in that single row. `status` is the activity glyph the
73/// caller appends (`"⟳"` in flight, `"↻"` retrying, empty when idle); it goes last
74/// so a truncated tab still shows the mark and the name first. Honours
75/// [`NO_EMOJI_ENV`] exactly like the lockup, so the two never disagree.
76pub fn window_title(status: &str) -> String {
77    window_title_from(crab_enabled(), status)
78}
79
80/// Pure form of [`window_title`], so the crab opt-out is testable without
81/// mutating the process environment (globals race with parallel tests).
82fn window_title_from(crab: bool, status: &str) -> String {
83    let head = format!("{MARK} {}", plain_name(crab));
84    if status.is_empty() {
85        head
86    } else {
87        format!("{head} {status}")
88    }
89}
90
91/// The five rows of the mark and wordmark. `crab` adds the mascot after the
92/// wordmark; without it the wordmark stands alone.
93fn render_lockup(crab: bool) -> Vec<String> {
94    let c = current_theme().colors;
95    let bar = c.dim.fg("██");
96    let bar_accent = c.accent.fg("██");
97
98    // Name plus the language mark. Without the crab there is nothing to
99    // separate, so the separator goes with it rather than dangling.
100    let name = if crab {
101        format!(
102            "{} {} {CRAB}",
103            c.accent.fg(&tui_bold("rpi")),
104            c.muted.fg(SEPARATOR)
105        )
106    } else {
107        c.accent.fg(&tui_bold("rpi"))
108    };
109
110    vec![
111        format!("      {bar_accent}"),
112        format!("      {bar_accent}"),
113        format!("   {bar} {bar_accent}"),
114        format!("   {bar} {bar_accent} {bar}"),
115        format!("   {bar} {bar_accent} {bar}   {name}"),
116    ]
117}
118
119/// The startup lockup. Add one to the welcome header.
120pub struct BrandLockup;
121
122impl BrandLockup {
123    pub fn new() -> Self {
124        Self
125    }
126}
127
128impl Default for BrandLockup {
129    fn default() -> Self {
130        Self::new()
131    }
132}
133
134impl Component for BrandLockup {
135    fn render(&self, _width: usize) -> Vec<String> {
136        render_lockup(crab_enabled())
137    }
138
139    fn invalidate(&self) {
140        // Nothing is cached.
141    }
142
143    fn as_any(&self) -> &dyn std::any::Any {
144        self
145    }
146}
147
148#[cfg(test)]
149mod tests {
150    use super::*;
151    use rpi_tui::ansi::{strip_ansi, visible_width};
152
153    fn plain(crab: bool) -> Vec<String> {
154        render_lockup(crab).iter().map(|r| strip_ansi(r)).collect()
155    }
156
157    #[test]
158    fn mark_rows_match_the_site_brand() {
159        let rows = plain(true);
160
161        // Three bottom-aligned bars: the tallest in the middle, one row taller
162        // than the left bar and two taller than the right one.
163        assert_eq!(rows[0], "      ██");
164        assert_eq!(rows[1], "      ██");
165        assert_eq!(rows[2], "   ██ ██");
166        assert_eq!(rows[3], "   ██ ██ ██");
167
168        // The tab-title mark is that same silhouette in one row, so the bar
169        // profile has to match bar for bar — one eighth-block per lockup row.
170        // Anyone re-tuning the lockup has to re-tune `MARK` with it.
171        let rows: Vec<usize> = (0..3)
172            .map(|i| {
173                let col = 3 + i * 3;
174                plain(true)
175                    .iter()
176                    .filter(|r| r.chars().skip(col).take(2).collect::<String>() == "██")
177                    .count()
178            })
179            .collect();
180        assert_eq!(rows, vec![3, 5, 2], "lockup bar profile changed");
181        let title: Vec<usize> = MARK
182            .chars()
183            .filter_map(|c| "▁▂▃▄▅▆▇█".chars().position(|b| b == c).map(|h| h + 1))
184            .collect();
185        assert_eq!(title, rows, "the tab title must mirror the lockup mark");
186    }
187
188    #[test]
189    fn crab_stands_in_for_the_language_name() {
190        let rows = plain(true);
191
192        // The crab replaces the word "rust" rather than sitting beside it.
193        assert_eq!(rows[4], "   ██ ██ ██   rpi · 🦀");
194
195        // The crab is wider than the word it replaces, and the layout must
196        // account for exactly that much: the separator plus the emoji.
197        let without = visible_width(&plain(false)[4]);
198        assert_eq!(
199            visible_width(&rows[4]) - without,
200            visible_width(" · ") + visible_width(CRAB)
201        );
202    }
203
204    #[test]
205    fn title_flattens_the_lockup_and_keeps_status_last() {
206        assert_eq!(window_title_from(true, ""), "▃ ▅ ▂ rpi · 🦀");
207        assert_eq!(window_title_from(true, "⟳"), "▃ ▅ ▂ rpi · 🦀 ⟳");
208        assert_eq!(window_title_from(true, "↻"), "▃ ▅ ▂ rpi · 🦀 ↻");
209    }
210
211    #[test]
212    fn title_shares_the_lockup_opt_out() {
213        // Disabling the crab must not leave a dangling separator in the title
214        // either — the lockup and the tab title must never disagree.
215        assert_eq!(window_title_from(false, ""), "▃ ▅ ▂ rpi");
216        assert_eq!(window_title_from(false, "⟳"), "▃ ▅ ▂ rpi ⟳");
217        assert!(!window_title_from(false, "").contains(CRAB));
218    }
219
220    #[test]
221    fn title_has_no_theme_escapes_and_no_cjk_ambiguity() {
222        let title = window_title_from(true, "⟳");
223        assert_eq!(title, strip_ansi(&title), "OSC 2 titles must be plain text");
224        // The mark is three block glyphs, each one cell wide, so a truncated
225        // tab title cannot drift on a CJK system the way `π` did.
226        assert_eq!(visible_width(MARK), 5, "mark width assumption changed");
227    }
228
229    #[test]
230    fn emoji_width_is_the_one_thing_the_layout_depends_on() {
231        // If a dependency bump changed this, the lockup would silently shift.
232        assert_eq!(visible_width(CRAB), 2, "crab width assumption changed");
233    }
234
235    #[test]
236    fn disabling_drops_the_crab_and_its_separator() {
237        assert!(crab_enabled_from(None));
238        for value in ["1", "true", "yes", "TRUE", "Yes"] {
239            assert!(!crab_enabled_from(Some(value)), "{value} should disable");
240        }
241        for value in ["", "0", "false", "no", "off"] {
242            assert!(crab_enabled_from(Some(value)), "{value} should not disable");
243        }
244
245        let rows = plain(false);
246        assert_eq!(rows.len(), 5);
247        assert!(!rows[4].contains(CRAB));
248        // No dangling separator once the crab is gone.
249        assert!(!rows[4].contains(SEPARATOR), "got {:?}", rows[4]);
250        assert!(rows[4].ends_with("rpi"), "got {:?}", rows[4]);
251    }
252}