Skip to main content

zsh/extensions/
banner.rs

1//! `zbanner` / `zshrs --banner` — the ZSHRS logo, a boxed summary of the
2//! builtin totals, and one live line for the daemon and this shell.
3//!
4//! Ported from ztmux's `src/extensions/banner.rs`: the same ANSI Shadow logo
5//! treatment, the same single-line box sized to its printed width, and the
6//! same "live counts, or the reason they are missing" line under it. ztmux's
7//! console prints its banner when it opens; zshrs prints nothing at startup,
8//! so this only ever runs when asked for.
9//!
10//! The daemon is the counterpart of ztmux's server socket. It is read with
11//! [`daemon_presence::check`], which connects without storing the result, so
12//! a banner drawn mid-session cannot change how the shell treats the daemon.
13//!
14//! zshrs-original — C zsh has no counterpart, so this lives under
15//! `src/extensions/` per `docs/PORT.md`.
16
17use std::io::IsTerminal;
18use std::path::Path;
19
20use crate::daemon_presence::{self, Mode};
21use crate::p10k::wizard::templates::visible_width;
22use crate::ported::zsh_h::{DISABLED, PM_UNSET, STAT_NOPRINT};
23
24/// `figlet -f "ANSI Shadow" ZSHRS` — the README banner — as (SGR colour,
25/// line) pairs, graded cyan → magenta → red the way ztmux grades its own.
26const LOGO: [(&str, &str); 6] = [
27    ("36", " ███████╗███████╗██╗  ██╗██████╗ ███████╗"),
28    ("36", " ╚══███╔╝██╔════╝██║  ██║██╔══██╗██╔════╝"),
29    ("35", "   ███╔╝ ███████╗███████║██████╔╝███████╗"),
30    ("35", "  ███╔╝  ╚════██║██╔══██║██╔══██╗╚════██║"),
31    ("31", " ███████╗███████║██║  ██║██║  ██║███████║"),
32    ("31", " ╚══════╝╚══════╝╚═╝  ╚═╝╚═╝  ╚═╝╚══════╝"),
33];
34
35/// What this shell holds right now — the half of the live line ztmux fills
36/// from its server.
37#[derive(Debug, Clone, Copy, Default, PartialEq, Eq)]
38pub struct ShellCounts {
39    pub functions: usize,
40    pub aliases: usize,
41    pub parameters: usize,
42    pub jobs: usize,
43}
44
45impl ShellCounts {
46    /// Count the live tables with the filters zsh's own `parameter` module
47    /// applies (`Src/Modules/parameter.c`): enabled functions (`$functions`,
48    /// c:470), enabled aliases, set parameters (`$parameters`, c:138), and
49    /// the jobs `$jobstates` lists (c:1429-1430).
50    pub fn read() -> Self {
51        let functions = crate::ported::hashtable::shfunctab_lock()
52            .read()
53            .map(|tab| {
54                tab.iter()
55                    .filter(|(_, f)| f.node.flags & DISABLED == 0)
56                    .count()
57            })
58            .unwrap_or(0);
59        let aliases = crate::ported::hashtable::aliastab_lock()
60            .read()
61            .map(|tab| {
62                tab.iter()
63                    .filter(|(_, a)| a.node.flags & DISABLED == 0)
64                    .count()
65            })
66            .unwrap_or(0);
67        let parameters = crate::ported::params::paramtab()
68            .read()
69            .map(|tab| {
70                tab.iter()
71                    .filter(|(_, p)| p.node.flags as u32 & PM_UNSET == 0)
72                    .count()
73            })
74            .unwrap_or(0);
75        let (jobtab, maxjob) = crate::ported::jobs::selectjobtab();
76        let jobs = (1..=maxjob)
77            .filter_map(|n| jobtab.get(n))
78            .filter(|j| j.stat != 0 && !j.procs.is_empty() && j.stat & STAT_NOPRINT == 0)
79            .count();
80        Self {
81            functions,
82            aliases,
83            parameters,
84            jobs,
85        }
86    }
87}
88
89/// Print the banner. `shell` is `None` for `zshrs --banner`, where no shell
90/// has started and the tables hold nothing of the user's, so the live line
91/// reports the daemon alone.
92pub fn print_banner(shell: Option<ShellCounts>) {
93    let color = colored();
94    for (code, line) in LOGO {
95        println!("{}", paint(line, code, color));
96    }
97
98    let summary = format!(
99        " ZSHRS // v{} // {} builtins // {} extensions ",
100        env!("CARGO_PKG_VERSION"),
101        crate::ext_builtins::compat_builtin_names().len(),
102        crate::ext_builtins::extension_builtin_names().len(),
103    );
104    for line in box_lines(&summary, color) {
105        println!("{line}");
106    }
107
108    let socket = daemon_presence::socket_path();
109    println!(
110        "{}",
111        live_line(socket.as_deref(), daemon_presence::check(), shell, color)
112    );
113}
114
115/// Colour when stdout is a terminal and `NO_COLOR` is unset.
116fn colored() -> bool {
117    std::io::stdout().is_terminal() && std::env::var_os("NO_COLOR").is_none()
118}
119
120/// Wrap `s` in SGR `code` when colouring.
121fn paint(s: &str, code: &str, color: bool) -> String {
122    if color {
123        format!("\x1b[{code}m{s}\x1b[0m")
124    } else {
125        s.to_string()
126    }
127}
128
129/// The line under the box: the daemon socket and whether anything answers
130/// on it, then this shell's counts when there is a shell to count.
131fn live_line(socket: Option<&Path>, mode: Mode, shell: Option<ShellCounts>, color: bool) -> String {
132    let socket = socket
133        .map(tilde)
134        .unwrap_or_else(|| "(no socket path)".to_string());
135    let state = match mode {
136        Mode::Present => paint("// up", "32", color),
137        Mode::Disabled => paint("// daemon disabled", "33", color),
138        Mode::Absent | Mode::Unknown => paint("// no daemon running", "31", color),
139    };
140    let mut line = format!(
141        "{}  {state}",
142        paint(&format!(" daemon {socket}"), "2", color)
143    );
144    if let Some(c) = shell {
145        for part in [
146            count(c.functions, "function", "functions"),
147            count(c.aliases, "alias", "aliases"),
148            count(c.parameters, "parameter", "parameters"),
149            count(c.jobs, "job", "jobs"),
150        ] {
151            line.push_str("  ");
152            line.push_str(&part);
153        }
154    }
155    line
156}
157
158/// `1 alias` / `2 aliases` — every count in the banner is plural-correct.
159fn count(n: usize, one: &str, many: &str) -> String {
160    format!("{n} {}", if n == 1 { one } else { many })
161}
162
163/// `$HOME/…` as `~/…`, so the path reads the way the user types it.
164fn tilde(path: &Path) -> String {
165    let shown = path.display().to_string();
166    match std::env::var("HOME") {
167        Ok(home) if !home.is_empty() && shown.starts_with(&home) => {
168            format!("~{}", &shown[home.len()..])
169        }
170        _ => shown,
171    }
172}
173
174/// The three lines of a single-line box around `line`, sized to its printed
175/// width (colour escapes excluded), so the borders stay aligned however long
176/// the version or the builtin counts get.
177fn box_lines(line: &str, color: bool) -> [String; 3] {
178    let rule = "─".repeat(visible_width(line));
179    let border = |s: &str| paint(s, "36", color);
180    [
181        border(&format!(" ┌{rule}┐")),
182        format!("{}{line}{}", border(" │"), border("│")),
183        border(&format!(" └{rule}┘")),
184    ]
185}
186
187#[cfg(test)]
188mod tests {
189    use super::*;
190
191    #[test]
192    fn banner_box_borders_match_the_content_width() {
193        // The box is measured on printed width, so neither a longer version
194        // string nor the colour escapes can push the right border out of line.
195        for content in [
196            " ZSHRS // v0.12.61 // 157 builtins // 146 extensions ",
197            " ZSHRS // v10.100.1000 // 1 builtin // 1 extension ",
198            "",
199        ] {
200            for color in [false, true] {
201                let widths: Vec<usize> = box_lines(content, color)
202                    .iter()
203                    .map(|l| visible_width(l))
204                    .collect();
205                assert_eq!(
206                    widths[0], widths[1],
207                    "top border and content disagree for {content:?} (color: {color})"
208                );
209                assert_eq!(
210                    widths[1], widths[2],
211                    "content and bottom border disagree for {content:?} (color: {color})"
212                );
213            }
214        }
215    }
216
217    #[test]
218    fn logo_lines_share_one_printed_width() {
219        let widths: Vec<usize> = LOGO.iter().map(|(_, l)| visible_width(l)).collect();
220        assert!(
221            widths.iter().all(|w| *w == widths[0]),
222            "logo lines are ragged: {widths:?}"
223        );
224    }
225
226    #[test]
227    fn live_line_says_why_the_daemon_is_missing_instead_of_claiming_it_is_up() {
228        let sock = Path::new("/tmp/zshrs-test/daemon.sock");
229        assert!(live_line(Some(sock), Mode::Absent, None, false).contains("// no daemon running"));
230        assert!(live_line(Some(sock), Mode::Disabled, None, false).contains("// daemon disabled"));
231        assert!(live_line(Some(sock), Mode::Present, None, false).contains("// up"));
232        assert!(live_line(None, Mode::Absent, None, false).contains("(no socket path)"));
233    }
234
235    #[test]
236    fn live_line_counts_are_plural_correct_and_absent_without_a_shell() {
237        let one = ShellCounts {
238            functions: 1,
239            aliases: 1,
240            parameters: 1,
241            jobs: 1,
242        };
243        let line = live_line(None, Mode::Present, Some(one), false);
244        assert!(
245            line.ends_with("1 function  1 alias  1 parameter  1 job"),
246            "{line:?}"
247        );
248        let line = live_line(None, Mode::Present, Some(ShellCounts::default()), false);
249        assert!(
250            line.ends_with("0 functions  0 aliases  0 parameters  0 jobs"),
251            "{line:?}"
252        );
253        assert!(!live_line(None, Mode::Present, None, false).contains("functions"));
254    }
255}