mandible 0.9.1

mandible: a universal, interactive TUI reference for CLI tools. `mandible git` opens an explorable tree of every command, subcommand, and flag git has, with descriptions.
1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
307
308
309
310
311
312
313
314
315
316
317
318
319
320
321
322
323
324
325
326
327
328
329
330
331
332
333
334
335
336
337
338
339
340
341
342
343
344
345
346
347
348
349
350
351
352
353
354
355
356
357
358
359
360
361
362
363
364
365
366
367
368
369
370
371
372
373
374
375
376
377
378
379
380
381
382
383
384
385
386
387
388
389
390
391
392
393
394
395
396
397
398
399
400
401
402
403
404
405
406
407
408
409
410
411
412
413
414
415
416
417
418
419
420
421
422
423
424
425
426
//! `mandible mandible` — the about screen.
//!
//! Running the tool on its own name shows this instead of extracting
//! mandible's own `--help`. Self-introspection isn't lost: `mandible
//! --doctor mandible` still runs the real pipeline against the binary,
//! which is the diagnostic form anyone actually wants for that purpose.
//!
//! Everything here is drawn from Cargo metadata at compile time, so the
//! version and repository can't drift from what was actually built. There
//! is deliberately no hand-maintained fact that a release could
//! invalidate — an about screen that lies about its own version is worse
//! than no about screen.

/// ANSI SGR wrapper that becomes a no-op when color is disabled, so
/// `NO_COLOR` and piped output stay clean (spec §9.2).
fn paint(text: &str, code: &str, color: bool) -> String {
    if color {
        format!("\x1b[{code}m{text}\x1b[0m")
    } else {
        text.to_string()
    }
}

/// The wordmark, one entry per letter, each a block of equal-height rows.
///
/// `█`/`▒` rather than a font: block elements are ordinary Unicode and
/// present in effectively every monospace font, unlike the private-use
/// glyphs a Nerd Font would need (see `mandible_tui::glyphs`). In a
/// non-UTF-8 terminal the banner is skipped entirely rather than drawn as
/// tofu — an about screen is exactly the place that can afford to say less.
const LETTERS: &[&[&str]] = &[
    &[
        "                ",
        "                ",
        " █████████████  ",
        "▒▒███▒▒███▒▒███ ",
        " ▒███ ▒███ ▒███ ",
        " ▒███ ▒███ ▒███ ",
        " █████▒███ █████",
        "▒▒▒▒▒ ▒▒▒ ▒▒▒▒▒ ",
    ],
    &[
        "          ",
        "          ",
        "  ██████  ",
        " ▒▒▒▒▒███ ",
        "  ███████ ",
        " ███▒▒███ ",
        "▒▒████████",
        " ▒▒▒▒▒▒▒▒ ",
    ],
    &[
        "           ",
        "           ",
        " ████████  ",
        "▒▒███▒▒███ ",
        " ▒███ ▒███ ",
        " ▒███ ▒███ ",
        " ████ █████",
        "▒▒▒▒ ▒▒▒▒▒ ",
    ],
    &[
        "     █████",
        "    ▒▒███ ",
        "  ███████ ",
        " ███▒▒███ ",
        "▒███ ▒███ ",
        "▒███ ▒███ ",
        "▒▒████████",
        " ▒▒▒▒▒▒▒▒ ",
    ],
    &[
        "  ███ ",
        " ▒▒▒  ",
        " ████ ",
        "▒▒███ ",
        " ▒███ ",
        " ▒███ ",
        " █████",
        "▒▒▒▒▒ ",
    ],
    &[
        " █████    ",
        "▒▒███     ",
        " ▒███████ ",
        " ▒███▒▒███",
        " ▒███ ▒███",
        " ▒███ ▒███",
        " ████████ ",
        "▒▒▒▒▒▒▒▒  ",
    ],
    &[
        " ████ ",
        "▒▒███ ",
        " ▒███ ",
        " ▒███ ",
        " ▒███ ",
        " ▒███ ",
        " █████",
        "▒▒▒▒▒ ",
    ],
    &[
        "         ",
        "         ",
        "  ██████ ",
        " ███▒▒███",
        "▒███████ ",
        "▒███▒▒▒  ",
        "▒▒██████ ",
        " ▒▒▒▒▒▒  ",
    ],
];

/// Vertical offset per animation step. 4 is resting, 0 is the peak; the
/// values between are what make the letter glide rather than jump.
const TRAJECTORY: &[usize] = &[4, 3, 2, 1, 0, 0, 1, 2, 3, 4];
/// Ticks each letter lags behind the one before it, which is what turns
/// eight independent hops into one wave travelling along the word.
const LETTER_DELAY: usize = 2;
const LETTER_HEIGHT: usize = 8;
const LETTER_SPACING: &str = "  ";
const FPS: u64 = 20;
fn max_shift() -> usize {
    TRAJECTORY.iter().copied().max().unwrap_or(0)
}

/// One frame: each letter drawn at its own vertical offset.
fn frame(offsets: &[usize]) -> Vec<String> {
    let canvas_height = LETTER_HEIGHT + max_shift();
    (0..canvas_height)
        .map(|row| {
            let mut line = String::new();
            for (i, letter) in LETTERS.iter().enumerate() {
                let width = letter[0].chars().count();
                match row.checked_sub(offsets[i]) {
                    Some(idx) if idx < LETTER_HEIGHT => line.push_str(letter[idx]),
                    _ => line.push_str(&" ".repeat(width)),
                }
                line.push_str(LETTER_SPACING);
            }
            line.trim_end().to_string()
        })
        .collect()
}

/// Play the wave once, in place, then leave the wordmark at rest.
///
/// Deliberately finite. An about screen that animates until interrupted is
/// a program you have to escape; this is a command that prints something
/// and exits. It redraws by moving the cursor up rather than clearing the
/// screen, so it stays inline in the scrollback like any other command's
/// output instead of wiping what you were looking at.
///
/// Skipped entirely when stdout is not a terminal — piping this to a file
/// should not fill it with cursor-movement escapes — and when the terminal
/// is not in a UTF-8 locale, where the block elements would be tofu.
fn animate(code: Option<&str>) {
    use std::io::Write;

    let canvas_height = LETTER_HEIGHT + max_shift();
    let cycle = LETTERS.len() * LETTER_DELAY + TRAJECTORY.len();
    let mut out = std::io::stdout().lock();

    let _ = write!(out, "\x1b[?25l"); // hide cursor
    for tick in 0..cycle {
        let offsets: Vec<usize> = (0..LETTERS.len())
            .map(|i| {
                let local = (tick + cycle - (i * LETTER_DELAY) % cycle) % cycle;
                TRAJECTORY.get(local).copied().unwrap_or(max_shift())
            })
            .collect();

        if tick > 0 {
            let _ = write!(out, "\x1b[{canvas_height}A");
        }
        for line in frame(&offsets) {
            let _ = writeln!(
                out,
                "\x1b[2K{}",
                paint(&line, code.unwrap_or_default(), code.is_some())
            );
        }
        let _ = out.flush();
        std::thread::sleep(std::time::Duration::from_millis(1000 / FPS));
    }
    let _ = write!(out, "\x1b[?25h"); // restore cursor
    let _ = out.flush();
}

/// True when the banner can be drawn: a real terminal, in a UTF-8 locale.
fn banner_possible() -> bool {
    mandible_tui::terminal::Sink::Stdout.is_tty()
        && mandible_tui::glyphs::from_env() == mandible_tui::glyphs::UNICODE
}

/// Print the about screen to stdout.
pub fn print() {
    let colors = mandible_core::config::load().config.colors;
    let palette = mandible_tui::style::Palette::from_env().themed(colors.theme, colors.custom);
    println!();
    if banner_possible() {
        animate(palette.sgr(mandible_core::config::Role::Accent).as_deref());
        println!();
    }
    print!("{}", text(palette));
}

/// The about screen below the wordmark: headings in bold, secondary text
/// in `palette`'s muted color, and no escape codes where it has no color.
fn text(palette: mandible_tui::style::Palette) -> String {
    use mandible_core::config::Role;
    use std::fmt::Write;
    let color = palette.color;
    let muted = palette.sgr(Role::Muted);
    let dim = muted.as_deref().unwrap_or_default();
    let bold = "1";
    let mut out = String::new();
    let _ = writeln!(
        out,
        "  {}  {}",
        paint(env!("CARGO_PKG_NAME"), bold, color),
        paint(&format!("v{}", env!("CARGO_PKG_VERSION")), dim, color),
    );
    let _ = writeln!(
        out,
        "  {}",
        paint(
            "A TUI manual for every command-line tool you have.",
            dim,
            color
        )
    );
    out.push('\n');

    // The one idea the whole architecture exists to serve. Worth stating
    // plainly on the one screen someone reads out of curiosity.
    let _ = writeln!(out, "  {}", paint("The rule:", bold, color));
    let _ = writeln!(
        out,
        "    No per-tool logic, ever. Help text isn't written by hand, it's"
    );
    let _ = writeln!(
        out,
        "    generated — so mandible learns the generators (clap, cobra, argparse,"
    );
    let _ = writeln!(
        out,
        "    click, GNU argp, and the rest), not the tools. Fixing the argparse"
    );
    let _ = writeln!(out, "    grammar improves every Python CLI ever written.");
    out.push('\n');

    let _ = writeln!(
        out,
        "  {}",
        paint("When it can't parse something:", bold, color)
    );
    let _ = writeln!(
        out,
        "    It shows you the author's own text, untouched, and says so."
    );
    let _ = writeln!(out, "    It never invents structure it didn't find.");
    out.push('\n');

    let _ = writeln!(
        out,
        "  {}   {}",
        paint("repository", dim, color),
        env!("CARGO_PKG_REPOSITORY")
    );
    let _ = writeln!(
        out,
        "  {}      {}",
        paint("license", dim, color),
        env!("CARGO_PKG_LICENSE")
    );
    out.push('\n');

    let _ = writeln!(
        out,
        "  {}",
        paint(
            "try:  mandible git    mandible docker    mandible --doctor <tool>",
            dim,
            color
        )
    );
    out.push('\n');
    out
}

#[cfg(test)]
mod tests {
    use super::*;

    /// Every letter block is the same height, so a row index means the
    /// same thing across all of them — the animation shears the wordmark
    /// apart otherwise.
    #[test]
    fn letter_blocks_are_uniform_height() {
        for (i, letter) in LETTERS.iter().enumerate() {
            assert_eq!(
                letter.len(),
                LETTER_HEIGHT,
                "letter {i} is the wrong height"
            );
        }
    }

    /// Each letter's rows are equal width, so a letter lifted out of the
    /// canvas leaves a hole exactly its own size and the ones beside it
    /// don't slide sideways mid-wave.
    #[test]
    fn each_letter_has_uniform_row_width() {
        for (i, letter) in LETTERS.iter().enumerate() {
            let w = letter[0].chars().count();
            for (r, row) in letter.iter().enumerate() {
                assert_eq!(row.chars().count(), w, "letter {i} row {r} is ragged");
            }
        }
    }

    /// At rest the whole word sits on one baseline: nothing is mid-hop
    /// before the wave starts or after it finishes.
    #[test]
    fn resting_frame_is_flat() {
        let rest = vec![max_shift(); LETTERS.len()];
        let lines = frame(&rest);
        assert_eq!(lines.len(), LETTER_HEIGHT + max_shift());
        // The rows above the baseline are empty, the rest are not.
        for line in lines.iter().take(max_shift()) {
            assert!(line.is_empty(), "expected blank lead-in, got {line:?}");
        }
        assert!(lines[max_shift()..].iter().any(|l| l.contains('█')));
    }

    /// A lifted letter really does move up: at the peak of its arc it puts
    /// ink on a row that is blank when the word is at rest.
    ///
    /// Uses `d` (index 3) because its own first row carries ink. `m` and
    /// several others begin with blank rows — lifting those moves the
    /// blanks, and the test would prove nothing.
    #[test]
    fn a_lifted_letter_reaches_higher_than_the_baseline() {
        const D: usize = 3;
        assert!(
            LETTERS[D][0].contains('█'),
            "this test needs a letter with ink on its first row"
        );

        let resting = frame(&vec![max_shift(); LETTERS.len()]);
        assert!(resting[0].is_empty(), "top row should be blank at rest");

        let mut offsets = vec![max_shift(); LETTERS.len()];
        offsets[D] = 0;
        let lifted = frame(&offsets);
        assert!(
            lifted[0].contains('█'),
            "top row should carry the lifted letter: {:?}",
            lifted[0]
        );
    }

    #[test]
    fn paint_is_a_no_op_without_color() {
        assert_eq!(paint("hi", "1", false), "hi");
    }

    #[test]
    fn paint_wraps_and_resets_with_color() {
        let painted = paint("hi", "1", true);
        assert!(painted.starts_with("\x1b[1m"));
        assert!(painted.ends_with("\x1b[0m"));
    }

    /// The secondary text takes the theme's muted color and the headings
    /// stay bold, in every theme.
    #[test]
    fn the_text_follows_the_theme_and_headings_stay_bold() {
        use mandible_core::config::{ColorIndex, Role, Roles, Theme};
        use mandible_tui::style::Palette;
        let version = format!("v{}", env!("CARGO_PKG_VERSION"));
        let mut mine = Roles::DEFAULT;
        mine.set(Role::Muted, ColorIndex(200));
        for (theme, custom, code) in [
            (Theme::Default, Roles::DEFAULT, "90"),
            (Theme::Light, Roles::DEFAULT, "38;5;237"),
            (Theme::NordStyle, Roles::DEFAULT, "38;5;102"),
            (Theme::HighContrast, Roles::DEFAULT, "38;5;248"),
            (Theme::Custom, mine, "38;5;200"),
        ] {
            let out = text(Palette::extended().themed(theme, custom));
            assert!(
                out.contains(&format!("\x1b[{code}m{version}\x1b[0m")),
                "{theme:?}: {out:?}"
            );
            assert!(out.contains("\x1b[1mThe rule:\x1b[0m"), "{theme:?}");
            assert!(!out.contains("\x1b[2m"), "no SGR 2 dim is left");
        }
    }

    /// `none` and `NO_COLOR` print the text with no escape code at all.
    #[test]
    fn no_theme_and_no_color_print_plain_text() {
        use mandible_core::config::{Roles, Theme};
        use mandible_tui::style::Palette;
        for palette in [
            Palette::extended().themed(Theme::None, Roles::DEFAULT),
            Palette::plain().themed(Theme::Light, Roles::DEFAULT),
        ] {
            let out = text(palette);
            assert!(!out.contains('\x1b'), "{out:?}");
            assert!(out.contains("The rule:"));
        }
    }

    /// Where only sixteen colors exist a numbered muted color is the
    /// default theme's `bright-black`.
    #[test]
    fn a_numbered_color_falls_back_at_sixteen_colors() {
        use mandible_core::config::{Roles, Theme};
        use mandible_tui::style::Palette;
        let out = text(Palette::basic().themed(Theme::NordStyle, Roles::DEFAULT));
        assert!(out.contains("\x1b[90mv"), "{out:?}");
    }
}