Skip to main content

retch_cli/
logo.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! ASCII and graphical logo definitions and rendering.
5//!
6//! Contains embedded distro logos and logic for rendering them
7//! as text or images (e.g., Sixel, Kitty, iTerm).
8
9// Exact Fastfetch ASCII logos (intact, unmodified)
10// Source: https://github.com/fastfetch-cli/fastfetch/src/logo/ascii/
11
12// Embedded distro logos (PNG)
13// Place real logos in assets/logos/<distro>.png
14// Example: assets/logos/arch.png, assets/logos/fedora.png, assets/logos/tux.png
15
16/// Every distro logo retch ships, as `(id, display name)`, in the order `--print-logos` and
17/// `--list-distros` show them. Any other id falls back to Tux.
18///
19/// Both flags read this one list. Until v0.20.11 each kept its own copy, and
20/// `--list-distros` still lacked the five logos added after it was written. A test ties
21/// this list to `assets/logos/*.txt`, so adding a logo without an entry here fails.
22pub const KNOWN_LOGOS: &[(&str, &str)] = &[
23    ("arch", "Arch Linux"),
24    ("debian", "Debian"),
25    ("endeavouros", "EndeavourOS"),
26    ("fedora", "Fedora"),
27    ("garuda", "Garuda Linux"),
28    ("kali", "Kali Linux"),
29    ("linuxmint", "Linux Mint"),
30    ("manjaro", "Manjaro"),
31    ("mx", "MX Linux"),
32    ("nixos", "NixOS"),
33    ("opensuse", "openSUSE"),
34    ("pop", "Pop!_OS"),
35    ("ubuntu", "Ubuntu"),
36    ("zorin", "Zorin OS"),
37    ("macos", "macOS"),
38    ("windows", "Windows"),
39];
40
41/// Returns the raw PNG bytes for an embedded distro logo.
42///
43/// If the distro is not recognized, it falls back to the Tux (Linux) logo.
44/// Only available when the `graphics` feature is enabled.
45#[cfg(feature = "graphics")]
46pub fn get_embedded_logo(distro: Option<&str>) -> Option<&'static [u8]> {
47    let d = distro.map(|s| s.to_lowercase());
48    match d.as_deref() {
49        Some("arch") => Some(include_bytes!("../assets/logos/arch.png")),
50        Some("debian") => Some(include_bytes!("../assets/logos/debian.png")),
51        Some("fedora") => Some(include_bytes!("../assets/logos/fedora.png")),
52        Some("nixos") => Some(include_bytes!("../assets/logos/nixos.png")),
53        Some("ubuntu") => Some(include_bytes!("../assets/logos/ubuntu.png")),
54        Some("pop") => Some(include_bytes!("../assets/logos/pop.png")),
55        Some("manjaro") => Some(include_bytes!("../assets/logos/manjaro.png")),
56        Some("endeavouros") => Some(include_bytes!("../assets/logos/endeavouros.png")),
57        Some("opensuse") | Some("opensuse-leap") | Some("opensuse-tumbleweed") => {
58            Some(include_bytes!("../assets/logos/opensuse.png"))
59        }
60        Some("mx") => Some(include_bytes!("../assets/logos/mx.png")),
61        Some("linuxmint") => Some(include_bytes!("../assets/logos/linuxmint.png")),
62        Some("kali") => Some(include_bytes!("../assets/logos/kali.png")),
63        Some("zorin") => Some(include_bytes!("../assets/logos/zorin.png")),
64        Some("garuda") => Some(include_bytes!("../assets/logos/garuda.png")),
65        Some("macos") => Some(include_bytes!("../assets/logos/macos.png")),
66        Some("windows") => Some(include_bytes!("../assets/logos/windows.png")),
67        _ => Some(include_bytes!("../assets/logos/tux.png")),
68    }
69}
70
71/// Fallback for non-graphics build to provide Tux bytes for Chafa if needed.
72#[cfg(not(feature = "graphics"))]
73pub fn get_embedded_logo(_distro: Option<&str>) -> Option<&'static [u8]> {
74    Some(include_bytes!("../assets/logos/tux.png"))
75}
76
77/// Attempts to detect the current operating system distribution.
78pub fn detect_distro() -> Option<String> {
79    #[cfg(target_os = "macos")]
80    {
81        Some("macos".to_string())
82    }
83    #[cfg(target_os = "windows")]
84    {
85        Some("windows".to_string())
86    }
87    #[cfg(not(any(target_os = "macos", target_os = "windows")))]
88    {
89        if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
90            for line in content.lines() {
91                if line.starts_with("ID=") {
92                    let id = line.trim_start_matches("ID=").trim_matches('"');
93                    return Some(id.to_string());
94                }
95            }
96        }
97        None
98    }
99}
100
101///// Returns a list of strings representing the ASCII art for a given distro with color placeholders.
102///
103/// All ASCII logos are sourced from or compatible with Fastfetch.
104pub fn get_ascii_logo(distro: Option<&str>) -> Vec<String> {
105    let d = distro.map(|s| s.to_lowercase());
106
107    match d.as_deref() {
108        Some("arch") => {
109            let logo = include_str!("../assets/logos/arch.txt");
110            logo.lines().map(|s| s.to_string()).collect()
111        }
112        Some("debian") => {
113            let logo = include_str!("../assets/logos/debian.txt");
114            logo.lines().map(|s| s.to_string()).collect()
115        }
116        Some("fedora") => {
117            let logo = include_str!("../assets/logos/fedora.txt");
118            logo.lines().map(|s| s.to_string()).collect()
119        }
120        Some("nixos") => {
121            let logo = include_str!("../assets/logos/nixos.txt");
122            logo.lines().map(|s| s.to_string()).collect()
123        }
124        Some("ubuntu") => {
125            let logo = include_str!("../assets/logos/ubuntu.txt");
126            logo.lines().map(|s| s.to_string()).collect()
127        }
128        Some("pop") => {
129            let logo = include_str!("../assets/logos/pop.txt");
130            logo.lines().map(|s| s.to_string()).collect()
131        }
132        Some("manjaro") => {
133            let logo = include_str!("../assets/logos/manjaro.txt");
134            logo.lines().map(|s| s.to_string()).collect()
135        }
136        Some("endeavouros") => {
137            let logo = include_str!("../assets/logos/endeavouros.txt");
138            logo.lines().map(|s| s.to_string()).collect()
139        }
140        Some("opensuse") | Some("opensuse-leap") | Some("opensuse-tumbleweed") => {
141            let logo = include_str!("../assets/logos/opensuse.txt");
142            logo.lines().map(|s| s.to_string()).collect()
143        }
144        Some("mx") => {
145            let logo = include_str!("../assets/logos/mx.txt");
146            logo.lines().map(|s| s.to_string()).collect()
147        }
148        Some("linuxmint") => {
149            let logo = include_str!("../assets/logos/linuxmint.txt");
150            logo.lines().map(|s| s.to_string()).collect()
151        }
152        Some("kali") => {
153            let logo = include_str!("../assets/logos/kali.txt");
154            logo.lines().map(|s| s.to_string()).collect()
155        }
156        Some("zorin") => {
157            let logo = include_str!("../assets/logos/zorin.txt");
158            logo.lines().map(|s| s.to_string()).collect()
159        }
160        Some("garuda") => {
161            let logo = include_str!("../assets/logos/garuda.txt");
162            logo.lines().map(|s| s.to_string()).collect()
163        }
164        Some("macos") => {
165            let logo = include_str!("../assets/logos/macos.txt");
166            logo.lines().map(|s| s.to_string()).collect()
167        }
168        Some("windows") => {
169            let logo = include_str!("../assets/logos/windows.txt");
170            logo.lines().map(|s| s.to_string()).collect()
171        }
172
173        // Fallback: Tux (Linux)
174        _ => {
175            let logo = include_str!("../assets/logos/tux.txt");
176            logo.lines().map(|s| s.to_string()).collect()
177        }
178    }
179}
180
181/// Returns dynamic ANSI color arrays for a given distribution.
182pub fn get_distro_colors(distro: Option<&str>) -> Vec<&'static str> {
183    let d = distro.map(|s| s.to_lowercase());
184    match d.as_deref() {
185        Some("arch") => vec!["\x1b[36m", "\x1b[37m"],
186        Some("debian") => vec!["\x1b[31m", "\x1b[37m"],
187        Some("fedora") => vec!["\x1b[34m", "\x1b[37m"],
188        Some("nixos") => vec![
189            "\x1b[34m", "\x1b[36m", "\x1b[34m", "\x1b[36m", "\x1b[34m", "\x1b[36m",
190        ],
191        Some("ubuntu") => vec!["\x1b[33m", "\x1b[31m"],
192        Some("pop") => vec!["\x1b[36m", "\x1b[37m"],
193        Some("manjaro") => vec!["\x1b[32m"],
194        Some("endeavouros") => vec!["\x1b[35m", "\x1b[31m", "\x1b[34m"],
195        Some("opensuse") | Some("opensuse-leap") | Some("opensuse-tumbleweed") => {
196            vec!["\x1b[32m", "\x1b[37m"]
197        }
198        Some("mx") => vec!["\x1b[34m", "\x1b[37m"],
199        Some("linuxmint") => vec!["\x1b[32m", "\x1b[37m"],
200        Some("kali") => vec!["\x1b[34m", "\x1b[37m"],
201        Some("zorin") => vec!["\x1b[36m", "\x1b[37m"],
202        Some("garuda") => vec!["\x1b[35m", "\x1b[36m"],
203        // Grayscale (silver) ramp, matching the modern monochrome Apple logo rather than the
204        // legacy rainbow. 256-colour greys (light→medium) read on light and dark terminals.
205        Some("macos") => vec![
206            "\x1b[38;5;252m",
207            "\x1b[38;5;250m",
208            "\x1b[38;5;248m",
209            "\x1b[38;5;246m",
210            "\x1b[38;5;244m",
211        ],
212        Some("windows") => vec!["\x1b[36m"],
213        _ => vec!["\x1b[30m", "\x1b[37m", "\x1b[33m"], // Tux
214    }
215}
216
217/// Interpolates placeholders `${1}`...`${9}` or `$1`...`$9` with dynamic ANSI colors and appends a reset at the end.
218pub fn get_distro_logo_lines(distro: Option<&str>) -> Vec<String> {
219    let raw_lines = get_ascii_logo(distro);
220    let colors = get_distro_colors(distro);
221    let default_color = colors.first().copied().unwrap_or("\x1b[0m");
222
223    raw_lines
224        .into_iter()
225        .map(|line| {
226            let mut formatted = line;
227            for i in 1..=9 {
228                let color_val = colors.get(i - 1).copied().unwrap_or("\x1b[0m");
229                let placeholder = format!("${{{}}}", i);
230                formatted = formatted.replace(&placeholder, color_val);
231                let placeholder_short = format!("${}", i);
232                formatted = formatted.replace(&placeholder_short, color_val);
233            }
234            if !formatted.is_empty() {
235                format!("{}{}\x1b[0m", default_color, formatted)
236            } else {
237                formatted
238            }
239        })
240        .collect()
241}
242
243/// Returns true when the running terminal is Rio.
244///
245/// Checks `TERM` as well as `TERM_PROGRAM`. `TERM_PROGRAM` alone is not sufficient: it is not
246/// in sudo's default `env_keep` list, so `sudo retch` lost Rio's graphics support entirely and
247/// silently fell all the way back to Chafa. `TERM` **is** preserved by sudo (`xterm-rio` here),
248/// and the same gap affects any launcher that starts retch with a trimmed environment.
249fn is_rio_terminal() -> bool {
250    if let Ok(term) = std::env::var("TERM") {
251        if term == "rio" || term.starts_with("xterm-rio") {
252            return true;
253        }
254    }
255    std::env::var("TERM_PROGRAM")
256        .map(|t| t == "rio")
257        .unwrap_or(false)
258}
259
260/// Checks if the terminal supports the Kitty inline image protocol.
261pub fn supports_kitty() -> bool {
262    std::env::var("TERM")
263        .map(|t| t == "xterm-kitty")
264        .unwrap_or(false)
265        || std::env::var("TERMINAL_EMULATOR")
266            .map(|t| t == "iterm-kitty" || t == "iTerm.app")
267            .unwrap_or(false)
268        || is_rio_terminal()
269}
270
271/// Checks if the terminal supports the iTerm2 inline image protocol.
272pub fn supports_iterm2() -> bool {
273    if let Ok(prog) = std::env::var("TERM_PROGRAM") {
274        if prog == "iTerm.app" || prog == "WezTerm" {
275            return true;
276        }
277    }
278    is_rio_terminal()
279}
280
281/// Checks if the terminal supports Sixel graphics (heuristic based on environment).
282pub fn supports_sixel() -> bool {
283    if let Ok(term) = std::env::var("TERM") {
284        let term = term.to_lowercase();
285        if term.contains("sixel") || term.contains("foot") || term.contains("mlterm") {
286            return true;
287        }
288    }
289
290    if let Ok(prog) = std::env::var("TERM_PROGRAM") {
291        if prog == "WezTerm" || prog == "iTerm.app" {
292            return true;
293        }
294    }
295
296    if std::env::var("WT_SESSION").is_ok() {
297        return true;
298    }
299
300    is_rio_terminal()
301}
302
303/// Maximum width, in terminal columns, that a rendered logo may occupy.
304///
305/// Widened from 28 so that wide-aspect assets (the horizontal lockups — `fedora.png` is
306/// 384×108, i.e. 3.56:1 — plus arch/nixos/ubuntu/tux) get enough rows to stay legible: a
307/// logo is fitted *inside* this box preserving aspect, so a narrow box caps a wide image's
308/// height long before [`LOGO_MAX_ROWS`] does. At 28 columns the Fedora logo collapsed to 4
309/// rows of Chafa symbols and was unreadable.
310pub const LOGO_MAX_COLS: usize = 45;
311
312/// Maximum height, in terminal rows, that a rendered logo may occupy.
313pub const LOGO_MAX_ROWS: usize = 10;
314
315/// Fits an image into a cell box **preserving its aspect ratio**, returning a [`LogoFit`].
316///
317/// Both the image and the box are converted to pixels (via the terminal's cell dimensions)
318/// so the terminal's non-square cells are accounted for — a 2:1 image in 1:2 cells is 4
319/// columns per row, not 2. The result is the smallest cell rectangle that contains the
320/// scaled image, clamped to `1..=max`.
321///
322/// This is the single source of truth for a graphical logo's footprint: the same values feed
323/// the protocol escape (so the terminal does not stretch the image) and `plan_layout` (so the
324/// text column is placed against the logo's real width). Previously the Kitty path hardcoded
325/// `c=26,r=10` — which *forces* the image into that rectangle, ignoring aspect entirely, so
326/// the 3.56:1 Fedora logo was squashed into a roughly 1:1 box and rendered ~3× too tall —
327/// while the layout separately assumed a fixed 40-column width.
328pub fn fit_logo_cells(
329    img_w: u32,
330    img_h: u32,
331    cell_w: usize,
332    cell_h: usize,
333    max_cols: usize,
334    max_rows: usize,
335) -> LogoFit {
336    let (max_cols, max_rows) = (max_cols.max(1), max_rows.max(1));
337
338    // Degenerate inputs: fall back to the full box rather than dividing by zero.
339    if img_w == 0 || img_h == 0 || cell_w == 0 || cell_h == 0 {
340        return LogoFit {
341            cols: max_cols,
342            rows: max_rows,
343            width_limited: true,
344        };
345    }
346
347    let (img_w, img_h) = (u64::from(img_w), u64::from(img_h));
348    let box_w = (max_cols * cell_w) as u64;
349    let box_h = (max_rows * cell_h) as u64;
350
351    // Compare box_w/img_w against box_h/img_h without floating point: whichever ratio is
352    // smaller is the limiting dimension.
353    let width_limited = box_w * img_h <= box_h * img_w;
354    // `div_ceil` on the *pixel* division too, not just the cell division below. Truncating
355    // here first could leave the reservation up to one pixel short of what the terminal
356    // actually draws, because the truncated value can fall exactly on a cell boundary that
357    // the true value sits just above: `mx.png` (256x232) scales to 220.69 px, truncated to
358    // 220, which `div_ceil`s to 22 cells = 220 px — 0.69 px less than the image. Invisible
359    // while the logo sat mid-screen with columns to spare; against the right margin it is
360    // the difference between honouring the invariant below and merely nearly honouring it.
361    let (disp_w, disp_h) = if width_limited {
362        (box_w, (box_w * img_h).div_ceil(img_w)) // wide image: touches the sides first
363    } else {
364        ((box_h * img_w).div_ceil(img_h), box_h) // tall image: touches top and bottom first
365    };
366
367    LogoFit {
368        // `div_ceil` so the reservation is never *smaller* than what gets drawn: an extra
369        // blank row or column is harmless, an overlapping one corrupts the layout.
370        cols: (disp_w as usize).div_ceil(cell_w).clamp(1, max_cols),
371        rows: (disp_h as usize).div_ceil(cell_h).clamp(1, max_rows),
372        width_limited,
373    }
374}
375
376/// The cell footprint of a logo, plus which dimension of the box it touches first.
377///
378/// `width_limited` matters only to the Kitty emitter — see [`kitty_placement_spec`].
379#[derive(Debug, Clone, Copy, PartialEq, Eq)]
380pub struct LogoFit {
381    /// Width in terminal columns.
382    pub cols: usize,
383    /// Height in terminal rows.
384    pub rows: usize,
385    /// True when the image reaches the box's width before its height (a wide image).
386    pub width_limited: bool,
387}
388
389/// Builds the Kitty graphics-protocol placement keys (`c=` / `r=`) for a fitted logo.
390///
391/// Deliberately emits **only the limiting dimension**. Kitty derives the other from the
392/// image's real aspect ratio, whereas specifying both makes it scale each axis independently
393/// to fill the rectangle exactly — and because cells are indivisible, the rounded rectangle is
394/// never quite the image's aspect, so passing both leaves a residual stretch even when the
395/// numbers are computed correctly (measured at 9% for the Fedora logo). Passing one leaves
396/// none.
397///
398/// The reservation [`plan_layout`](crate::display) makes is `div_ceil`-rounded and therefore
399/// always covers what Kitty then draws.
400pub fn kitty_placement_spec(fit: LogoFit) -> String {
401    if fit.width_limited {
402        format!("c={}", fit.cols)
403    } else {
404        format!("r={}", fit.rows)
405    }
406}
407
408/// Returns the terminal cell size in pixels as `(width, height)` via `TIOCGWINSZ`.
409///
410/// Falls back to a 10×20 cell — the conventional default — when the terminal does not report
411/// pixel dimensions (tmux, many terminals, and any non-TTY stdout).
412pub fn terminal_cell_size_px() -> (usize, usize) {
413    #[cfg(unix)]
414    {
415        use std::mem::MaybeUninit;
416        let mut ws: libc::winsize = unsafe { MaybeUninit::zeroed().assume_init() };
417        // SAFETY: `ws` is a live, zeroed `winsize` and TIOCGWINSZ writes exactly that type.
418        let ret = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut ws) };
419        if ret == 0 && ws.ws_row > 0 && ws.ws_col > 0 && ws.ws_xpixel > 0 && ws.ws_ypixel > 0 {
420            return (
421                ws.ws_xpixel as usize / ws.ws_col as usize,
422                ws.ws_ypixel as usize / ws.ws_row as usize,
423            );
424        }
425    }
426    (10, 20)
427}
428
429/// Returns the cell footprint an image will occupy on the current terminal.
430///
431/// Thin wrapper pairing [`terminal_cell_size_px`] with the pure [`fit_logo_cells`].
432pub fn logo_cells_for(img_w: u32, img_h: u32) -> LogoFit {
433    let (cell_w, cell_h) = terminal_cell_size_px();
434    fit_logo_cells(img_w, img_h, cell_w, cell_h, LOGO_MAX_COLS, LOGO_MAX_ROWS)
435}
436
437/// The `--size WxH` argument passed to `chafa`, derived from the shared logo cell box.
438///
439/// Chafa fits the image *inside* this box preserving aspect ratio, so this is a maximum in
440/// both dimensions rather than a target — a wide logo comes back short, a tall one narrow.
441pub fn chafa_size_arg() -> String {
442    format!("{LOGO_MAX_COLS}x{LOGO_MAX_ROWS}")
443}
444
445static CHAFA_SUPPORTS_PROBE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
446
447/// Checks if the `chafa` command-line tool supports the `--probe` option.
448pub fn chafa_supports_probe() -> bool {
449    *CHAFA_SUPPORTS_PROBE.get_or_init(|| {
450        std::process::Command::new("chafa")
451            .args(["--probe", "off", "--version"])
452            .output()
453            .map(|o| o.status.success())
454            .unwrap_or(false)
455    })
456}
457
458/// Checks if the `chafa` command-line tool is available in the system path.
459pub fn chafa_available() -> bool {
460    std::process::Command::new("chafa")
461        .arg("--version")
462        .output()
463        .map(|o| o.status.success())
464        .unwrap_or(false)
465}
466
467/// Write embedded logo bytes to a temporary file and return the path.
468fn write_temp_logo(bytes: &[u8]) -> std::io::Result<std::path::PathBuf> {
469    let temp_path = std::env::temp_dir().join(format!("retch_logo_{}.png", std::process::id()));
470    std::fs::write(&temp_path, bytes)?;
471    Ok(temp_path)
472}
473
474/// Attempts to render an image using the `chafa` utility.
475///
476/// Chafa renders images using high-quality Unicode symbols, providing a
477/// good graphical fallback for many terminal emulators.
478pub fn print_with_chafa(path: &std::path::Path) -> bool {
479    let mut cmd = std::process::Command::new("chafa");
480    cmd.arg("--format")
481        .arg("symbols")
482        .arg("--size")
483        .arg(chafa_size_arg());
484
485    if chafa_supports_probe() {
486        cmd.arg("--probe").arg("off");
487    }
488
489    let output = cmd.arg(path).output();
490
491    match output {
492        Ok(out) if out.status.success() => {
493            print!("{}", String::from_utf8_lossy(&out.stdout));
494            true
495        }
496        Ok(out) => {
497            eprintln!("warning: chafa failed with status: {}", out.status);
498            false
499        }
500        Err(e) => {
501            eprintln!("warning: failed to execute chafa: {}", e);
502            false
503        }
504    }
505}
506
507/// Attempts to get Chafa output as a list of lines.
508pub fn get_chafa_logo_lines(path: &std::path::Path) -> Option<Vec<String>> {
509    let mut cmd = std::process::Command::new("chafa");
510    cmd.arg("--format")
511        .arg("symbols")
512        .arg("--size")
513        .arg(chafa_size_arg());
514
515    if chafa_supports_probe() {
516        cmd.arg("--probe").arg("off");
517    }
518
519    let output = cmd.arg(path).output().ok()?;
520    if output.status.success() {
521        let content = String::from_utf8_lossy(&output.stdout);
522        Some(content.lines().map(|s| s.to_string()).collect())
523    } else {
524        None
525    }
526}
527
528/// Print logo for a distro following the strict priority:
529/// 1. Real graphic logo (if terminal supports it and embedded logo exists)
530/// 2. Chafa high-quality symbols (if chafa is available)
531/// 3. Real Fastfetch ASCII logo (always available)
532pub fn print_distro_logo(distro: Option<&str>) {
533    print_distro_logo_with_ascii(distro, false, false);
534}
535
536/// Renders the distribution logo with options to force ASCII or Chafa mode.
537///
538/// This is the primary entry point for logo rendering, handling the entire
539/// priority chain from high-res graphics down to text-based ASCII.
540/// - `ascii_only`: skip all graphical protocols and render the ASCII art directly.
541/// - `chafa_only`: skip Kitty/iTerm2/Sixel and go straight to Chafa (falls back
542///   to ASCII if Chafa is unavailable). `ascii_only` takes precedence.
543pub fn print_distro_logo_with_ascii(distro: Option<&str>, ascii_only: bool, chafa_only: bool) {
544    if ascii_only {
545        // Force ASCII path
546        let art = get_distro_logo_lines(distro);
547        for line in art {
548            println!("{}", line);
549        }
550        return;
551    }
552
553    let has_chafa = chafa_available();
554
555    if !chafa_only {
556        // Only the image protocols below read these, and they exist only with `graphics`.
557        #[cfg(feature = "graphics")]
558        let supports_kitty = supports_kitty();
559        #[cfg(feature = "graphics")]
560        let supports_iterm2 = supports_iterm2();
561        #[cfg(feature = "graphics")]
562        let supports_sixel = supports_sixel();
563
564        // 1. Try embedded graphical logo (Kitty)
565        #[cfg(feature = "graphics")]
566        if supports_kitty {
567            if let Some(bytes) = get_embedded_logo(distro) {
568                if !bytes.is_empty() {
569                    print_graphical_logo(bytes);
570                    return;
571                }
572            }
573        }
574
575        // 2. Try embedded graphical logo (iTerm2)
576        #[cfg(feature = "graphics")]
577        if supports_iterm2 {
578            if let Some(bytes) = get_embedded_logo(distro) {
579                if !bytes.is_empty() {
580                    print_iterm2_logo(bytes);
581                    return;
582                }
583            }
584        }
585
586        // 3. Try embedded graphical logo (Sixel)
587        #[cfg(feature = "graphics")]
588        if supports_sixel {
589            if let Some(bytes) = get_embedded_logo(distro) {
590                if !bytes.is_empty() {
591                    print_sixel_logo(bytes);
592                    return;
593                }
594            }
595        }
596    }
597
598    // 4. Try chafa using embedded distro logo
599    if has_chafa {
600        if let Some(bytes) = get_embedded_logo(distro) {
601            if bytes.len() > 100 {
602                if let Ok(temp_path) = write_temp_logo(bytes) {
603                    if print_with_chafa(&temp_path) {
604                        let _ = std::fs::remove_file(&temp_path);
605                        return;
606                    }
607                    let _ = std::fs::remove_file(&temp_path);
608                }
609            }
610        }
611    }
612
613    // 5. Final fallback: Real Fastfetch ASCII logo
614    let art = get_distro_logo_lines(distro);
615    for line in art {
616        println!("{}", line);
617    }
618}
619
620/// Renders a raw image buffer using the iTerm2 inline image protocol.
621#[cfg(feature = "graphics")]
622pub fn print_iterm2_logo(image_data: &[u8]) {
623    use base64::Engine;
624
625    let (width, height) = image::load_from_memory(image_data)
626        .map(|img| (img.width(), img.height()))
627        .unwrap_or((0, 0));
628
629    let encoded = base64::engine::general_purpose::STANDARD.encode(image_data);
630
631    // `width`/`height` are in character cells here; `preserveAspectRatio=1` makes them a
632    // bounding box rather than a target, so the image is never distorted. Passing both (not
633    // just `height`) keeps the drawn footprint inside the width `plan_layout` reserved.
634    if width > 0 && height > 0 {
635        let fit = logo_cells_for(width, height);
636        print!(
637            "\x1b]1337;File=inline=1;width={};height={};preserveAspectRatio=1:{}\x07",
638            fit.cols, fit.rows, encoded
639        );
640    } else {
641        print!(
642            "\x1b]1337;File=inline=1;height={};preserveAspectRatio=1:{}\x07",
643            LOGO_MAX_ROWS, encoded
644        );
645    }
646    println!(); // iTerm2 typically needs a newline after the logo
647}
648
649/// Loads an image from a file and prints it using the iTerm2 protocol.
650#[cfg(feature = "graphics")]
651pub fn print_iterm2_logo_from_path(path: &std::path::Path) {
652    if let Ok(bytes) = std::fs::read(path) {
653        print_iterm2_logo(&bytes);
654    } else {
655        println!("[Could not read logo for iTerm2 from {}]", path.display());
656    }
657}
658
659/// Placeholder for iTerm2 logo rendering when the `graphics` feature is disabled.
660#[cfg(not(feature = "graphics"))]
661pub fn print_iterm2_logo(_image_data: &[u8]) {
662    println!("[iTerm2 logo support requires --features graphics]");
663}
664
665/// Renders a raw image buffer using the Kitty graphics protocol.
666#[cfg(feature = "graphics")]
667pub fn print_graphical_logo(image_data: &[u8]) {
668    use base64::Engine;
669
670    let (width, height) = image::load_from_memory(image_data)
671        .map(|img| (img.width(), img.height()))
672        .unwrap_or((0, 0));
673
674    let encoded = base64::engine::general_purpose::STANDARD.encode(image_data);
675
676    if width > 0 && height > 0 {
677        // Kitty *forces* the image into whatever placement rectangle it is given, so the
678        // spec carries only the limiting dimension and lets Kitty derive the other from the
679        // image's aspect ratio. The old hardcoded `c=26,r=10` is what squashed the 3.56:1
680        // Fedora logo into a roughly 1:1 box.
681        let spec = kitty_placement_spec(logo_cells_for(width, height));
682        println!(
683            "\x1b_Gf=100,s={},v={},{},a=T;{}\x1b\\",
684            width, height, spec, encoded
685        );
686    } else {
687        println!("\x1b_Gf=100,a=T;{}", encoded);
688    }
689}
690
691/// Renders a raw image buffer (e.g. PNG bytes) using the Sixel graphics protocol.
692#[cfg(feature = "graphics")]
693pub fn print_sixel_logo(image_data: &[u8]) {
694    if let Ok(img) = image::load_from_memory(image_data) {
695        // Size the sixel to the same cell box the layout reserved, in pixels. `resize` already
696        // preserves aspect ratio (it fits within the box), so this only ever shrinks the image
697        // to the footprint `plan_layout` was told about.
698        let fit = logo_cells_for(img.width(), img.height());
699        let (cell_w, cell_h) = terminal_cell_size_px();
700        let resized = img.resize(
701            (fit.cols * cell_w) as u32,
702            (fit.rows * cell_h) as u32,
703            image::imageops::FilterType::Triangle,
704        );
705        let rgba = resized.to_rgba8();
706        let (width, height) = rgba.dimensions();
707        print_sixel_rgba(rgba.as_raw(), width, height);
708    }
709}
710
711/// Renders raw RGBA pixels using the Sixel graphics protocol.
712#[cfg(feature = "graphics")]
713pub fn print_sixel_rgba(rgba: &[u8], width: u32, height: u32) {
714    use icy_sixel::SixelImage;
715
716    match SixelImage::try_from_rgba(rgba.to_vec(), width as usize, height as usize) {
717        Ok(sixel_img) => match sixel_img.encode() {
718            Ok(sixel_str) => {
719                print!("{}", sixel_str);
720            }
721            Err(e) => eprintln!("[Sixel Encoding Error: {}]", e),
722        },
723        Err(e) => eprintln!("[Sixel Creation Error: {}]", e),
724    }
725}
726
727/// Placeholder for graphical logo rendering when the `graphics` feature is disabled.
728#[cfg(not(feature = "graphics"))]
729pub fn print_graphical_logo(_image_data: &[u8]) {
730    println!("[Graphical logo support requires --features graphics]");
731}
732
733/// Placeholder for sixel logo rendering when the `graphics` feature is disabled.
734#[cfg(not(feature = "graphics"))]
735pub fn print_sixel_logo(_image_data: &[u8]) {
736    println!("[Sixel logo support requires --features graphics]");
737}
738
739/// Loads an image from a file, resizes it, and prints it using the graphics protocol.
740#[cfg(feature = "graphics")]
741pub fn print_graphical_logo_from_path(path: &std::path::Path) {
742    use image::ImageFormat;
743    match image::open(path) {
744        Ok(img) => {
745            let resized = img.resize(128, 128, image::imageops::FilterType::Lanczos3);
746            let mut png_data = Vec::new();
747            if resized
748                .write_to(&mut std::io::Cursor::new(&mut png_data), ImageFormat::Png)
749                .is_ok()
750            {
751                print_graphical_logo(&png_data);
752            } else {
753                println!("[Failed to encode logo as PNG]");
754            }
755        }
756        Err(_) => {
757            println!("[Could not load graphical logo from {}]", path.display());
758        }
759    }
760}
761
762/// Loads an image from a file, resizes it, and prints it using the Sixel protocol.
763#[cfg(feature = "graphics")]
764pub fn print_sixel_logo_from_path(path: &std::path::Path) {
765    match image::open(path) {
766        Ok(img) => {
767            let resized = img.resize(128, 128, image::imageops::FilterType::Lanczos3);
768            let rgba = resized.to_rgba8();
769            let (width, height) = rgba.dimensions();
770            print_sixel_rgba(rgba.as_raw(), width, height);
771        }
772        Err(_) => {
773            println!("[Could not load logo for Sixel from {}]", path.display());
774        }
775    }
776}
777
778#[cfg(test)]
779mod tests {
780    use super::*;
781
782    #[test]
783    fn known_logos_match_the_shipped_logo_files() {
784        // Every logo file (except the Tux fallback) must be listed, and every listed id must
785        // have a file. `--list-distros` drifted from the files once; this pins both directions.
786        let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/logos");
787        let mut shipped: Vec<String> = std::fs::read_dir(&dir)
788            .expect("assets/logos exists")
789            .filter_map(|e| {
790                let name = e.ok()?.file_name().into_string().ok()?;
791                name.strip_suffix(".txt").map(str::to_string)
792            })
793            .filter(|id| id != "tux")
794            .collect();
795        shipped.sort();
796        let mut listed: Vec<String> = KNOWN_LOGOS.iter().map(|(id, _)| id.to_string()).collect();
797        listed.sort();
798        assert_eq!(listed, shipped, "KNOWN_LOGOS vs assets/logos/*.txt");
799    }
800
801    #[test]
802    fn every_known_logo_renders_its_own_art_not_tux() {
803        let tux = get_ascii_logo(None);
804        for (id, _) in KNOWN_LOGOS {
805            assert_ne!(get_ascii_logo(Some(id)), tux, "{id} falls back to Tux");
806        }
807    }
808
809    #[test]
810    fn test_get_ascii_logo_arch() {
811        let logo = get_ascii_logo(Some("arch"));
812        assert!(!logo.is_empty());
813        assert!(logo[0].contains("`"));
814    }
815
816    #[test]
817    fn test_get_ascii_logo_unknown() {
818        let logo = get_ascii_logo(Some("unknown_distro"));
819        assert!(!logo.is_empty());
820        // Should fall back to Tux
821        assert!(logo
822            .iter()
823            .any(|line| line.contains("o${2}_${3}o") || line.contains("o_o")));
824    }
825
826    #[test]
827    fn test_get_ascii_logo_none() {
828        let logo = get_ascii_logo(None);
829        assert!(!logo.is_empty());
830    }
831
832    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
833
834    struct EnvGuard {
835        _mutex_guard: std::sync::MutexGuard<'static, ()>,
836        old_vars: std::collections::HashMap<&'static str, Option<String>>,
837    }
838
839    impl EnvGuard {
840        fn new(vars_to_mock: &[&'static str]) -> Self {
841            let guard = ENV_LOCK.lock().unwrap();
842            let mut old_vars = std::collections::HashMap::new();
843            for var in vars_to_mock {
844                old_vars.insert(*var, std::env::var(var).ok());
845            }
846            EnvGuard {
847                _mutex_guard: guard,
848                old_vars,
849            }
850        }
851    }
852
853    impl Drop for EnvGuard {
854        fn drop(&mut self) {
855            for (var, value) in &self.old_vars {
856                if let Some(val) = value {
857                    std::env::set_var(var, val);
858                } else {
859                    std::env::remove_var(var);
860                }
861            }
862        }
863    }
864
865    #[test]
866    fn test_supports_kitty_heuristics() {
867        let _guard = EnvGuard::new(&["TERM", "TERMINAL_EMULATOR", "TERM_PROGRAM"]);
868
869        // Test TERM=xterm-kitty
870        std::env::set_var("TERM", "xterm-kitty");
871        std::env::remove_var("TERMINAL_EMULATOR");
872        std::env::remove_var("TERM_PROGRAM");
873        assert!(supports_kitty());
874
875        // Test TERMINAL_EMULATOR=iterm-kitty
876        std::env::remove_var("TERM");
877        std::env::set_var("TERMINAL_EMULATOR", "iterm-kitty");
878        assert!(supports_kitty());
879
880        // Test TERMINAL_EMULATOR=iTerm.app
881        std::env::set_var("TERMINAL_EMULATOR", "iTerm.app");
882        assert!(supports_kitty());
883
884        // Test TERM_PROGRAM=rio
885        std::env::remove_var("TERMINAL_EMULATOR");
886        std::env::set_var("TERM_PROGRAM", "rio");
887        assert!(supports_kitty());
888
889        // Test clear env -> false
890        std::env::remove_var("TERM_PROGRAM");
891        assert!(!supports_kitty());
892    }
893
894    #[test]
895    fn test_supports_iterm2_heuristics() {
896        // TERM must be guarded and cleared as well as TERM_PROGRAM: `supports_iterm2` consults
897        // it via `is_rio_terminal`, so without this the *host's* TERM leaks in and the negative
898        // assertions below fail on a Rio box while passing everywhere else.
899        let _guard = EnvGuard::new(&["TERM", "TERM_PROGRAM"]);
900        std::env::remove_var("TERM");
901
902        // Test TERM_PROGRAM=iTerm.app
903        std::env::set_var("TERM_PROGRAM", "iTerm.app");
904        assert!(supports_iterm2());
905
906        // Test TERM_PROGRAM=WezTerm
907        std::env::set_var("TERM_PROGRAM", "WezTerm");
908        assert!(supports_iterm2());
909
910        // Test TERM_PROGRAM=rio
911        std::env::set_var("TERM_PROGRAM", "rio");
912        assert!(supports_iterm2());
913
914        // Test TERM_PROGRAM=Apple_Terminal
915        std::env::set_var("TERM_PROGRAM", "Apple_Terminal");
916        assert!(!supports_iterm2());
917
918        // Test clear env -> false
919        std::env::remove_var("TERM_PROGRAM");
920        assert!(!supports_iterm2());
921    }
922
923    #[test]
924    fn test_supports_sixel_heuristics() {
925        let _guard = EnvGuard::new(&["TERM", "TERM_PROGRAM", "WT_SESSION"]);
926
927        // Clear all to start fresh
928        std::env::remove_var("TERM");
929        std::env::remove_var("TERM_PROGRAM");
930        std::env::remove_var("WT_SESSION");
931        assert!(!supports_sixel());
932
933        // Test TERM=xterm-sixel
934        std::env::set_var("TERM", "xterm-sixel");
935        assert!(supports_sixel());
936
937        // Test TERM=foot
938        std::env::set_var("TERM", "foot");
939        assert!(supports_sixel());
940
941        // Test TERM=mlterm (case variations)
942        std::env::set_var("TERM", "MLTerm");
943        assert!(supports_sixel());
944
945        // Reset TERM, test TERM_PROGRAM=WezTerm
946        std::env::remove_var("TERM");
947        std::env::set_var("TERM_PROGRAM", "WezTerm");
948        assert!(supports_sixel());
949
950        // Test TERM_PROGRAM=iTerm.app
951        std::env::set_var("TERM_PROGRAM", "iTerm.app");
952        assert!(supports_sixel());
953
954        // Test TERM_PROGRAM=rio
955        std::env::set_var("TERM_PROGRAM", "rio");
956        assert!(supports_sixel());
957
958        // Reset TERM_PROGRAM, test WT_SESSION
959        std::env::remove_var("TERM_PROGRAM");
960        std::env::set_var("WT_SESSION", "active");
961        assert!(supports_sixel());
962    }
963
964    #[test]
965    fn test_get_embedded_logo() {
966        let logo = get_embedded_logo(Some("arch"));
967        assert!(logo.is_some());
968        let logo = get_embedded_logo(Some("pop"));
969        assert!(logo.is_some());
970        let logo = get_embedded_logo(Some("manjaro"));
971        assert!(logo.is_some());
972        let logo = get_embedded_logo(Some("endeavouros"));
973        assert!(logo.is_some());
974        let logo = get_embedded_logo(Some("opensuse"));
975        assert!(logo.is_some());
976        let logo = get_embedded_logo(Some("opensuse-leap"));
977        assert!(logo.is_some());
978        let logo = get_embedded_logo(Some("opensuse-tumbleweed"));
979        assert!(logo.is_some());
980        let logo = get_embedded_logo(Some("mx"));
981        assert!(logo.is_some());
982        let logo = get_embedded_logo(Some("linuxmint"));
983        assert!(logo.is_some());
984        let logo = get_embedded_logo(Some("kali"));
985        assert!(logo.is_some());
986        let logo = get_embedded_logo(Some("zorin"));
987        assert!(logo.is_some());
988        let logo = get_embedded_logo(Some("garuda"));
989        assert!(logo.is_some());
990        let logo = get_embedded_logo(Some("macos"));
991        assert!(logo.is_some());
992        let logo = get_embedded_logo(Some("windows"));
993        assert!(logo.is_some());
994        let logo = get_embedded_logo(None);
995        assert!(logo.is_some());
996    }
997
998    #[test]
999    fn test_get_ascii_logo_new_distros() {
1000        let pop = get_ascii_logo(Some("pop"));
1001        assert!(!pop.is_empty());
1002        assert!(pop.iter().any(|line| line.contains("767")));
1003
1004        let manjaro = get_ascii_logo(Some("manjaro"));
1005        assert!(!manjaro.is_empty());
1006        assert!(manjaro.iter().any(|line| line.contains("████████")));
1007
1008        let endeavouros = get_ascii_logo(Some("endeavouros"));
1009        assert!(!endeavouros.is_empty());
1010        assert!(endeavouros.iter().any(|line| line.contains("ssso")));
1011
1012        let opensuse = get_ascii_logo(Some("opensuse"));
1013        assert!(!opensuse.is_empty());
1014        assert!(opensuse.iter().any(|line| line.contains("O0000Ok")));
1015
1016        let macos = get_ascii_logo(Some("macos"));
1017        assert!(!macos.is_empty());
1018        assert!(macos
1019            .iter()
1020            .any(|line| line.contains("cKMMMMMMMMMMNWMMMMMMMMMM0")));
1021
1022        let windows = get_ascii_logo(Some("windows"));
1023        assert!(!windows.is_empty());
1024        assert!(windows
1025            .iter()
1026            .any(|line| line.contains("AEEEtttt::::ztF") || line.contains("tt:::tt333EE3")));
1027
1028        let mx = get_ascii_logo(Some("mx"));
1029        assert!(!mx.is_empty());
1030        assert!(mx
1031            .iter()
1032            .any(|line| line.contains("MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMNMMMMMMMMM")));
1033
1034        let linuxmint = get_ascii_logo(Some("linuxmint"));
1035        assert!(!linuxmint.is_empty());
1036        assert!(linuxmint.iter().any(|line| line.contains("oOOOOOOOOOOo")));
1037
1038        let kali = get_ascii_logo(Some("kali"));
1039        assert!(!kali.is_empty());
1040        assert!(kali.iter().any(|line| line.contains(":ccc")));
1041
1042        let zorin = get_ascii_logo(Some("zorin"));
1043        assert!(!zorin.is_empty());
1044        assert!(zorin
1045            .iter()
1046            .any(|line| line.contains("osssssssssssssssssssso")));
1047
1048        let garuda = get_ascii_logo(Some("garuda"));
1049        assert!(!garuda.is_empty());
1050        assert!(garuda.iter().any(|line| line.contains("888:8898898")));
1051    }
1052
1053    // ── Rio detection (TERM as well as TERM_PROGRAM) ──────────────────────────
1054
1055    #[test]
1056    fn test_rio_detected_from_term_when_term_program_is_absent() {
1057        // The sudo case: `env_reset` keeps TERM but drops TERM_PROGRAM, which used to cost
1058        // Rio all graphics support and fall through to Chafa.
1059        let _guard = EnvGuard::new(&["TERM", "TERMINAL_EMULATOR", "TERM_PROGRAM"]);
1060        std::env::remove_var("TERM_PROGRAM");
1061        std::env::remove_var("TERMINAL_EMULATOR");
1062        std::env::set_var("TERM", "xterm-rio");
1063
1064        assert!(is_rio_terminal());
1065        assert!(supports_kitty());
1066        assert!(supports_iterm2());
1067        assert!(supports_sixel());
1068    }
1069
1070    #[test]
1071    fn test_rio_still_detected_from_term_program() {
1072        // The pre-existing path must keep working when TERM says nothing useful.
1073        let _guard = EnvGuard::new(&["TERM", "TERMINAL_EMULATOR", "TERM_PROGRAM"]);
1074        std::env::remove_var("TERMINAL_EMULATOR");
1075        std::env::set_var("TERM", "xterm-256color");
1076        std::env::set_var("TERM_PROGRAM", "rio");
1077
1078        assert!(is_rio_terminal());
1079        assert!(supports_kitty());
1080    }
1081
1082    #[test]
1083    fn test_non_rio_term_is_not_matched() {
1084        // Guard against a loose substring match: these must not be taken for Rio.
1085        let _guard = EnvGuard::new(&["TERM", "TERMINAL_EMULATOR", "TERM_PROGRAM"]);
1086        std::env::remove_var("TERM_PROGRAM");
1087        std::env::remove_var("TERMINAL_EMULATOR");
1088        for term in ["xterm-256color", "screen", "linux", "rioja"] {
1089            std::env::set_var("TERM", term);
1090            assert!(!is_rio_terminal(), "{term} should not be detected as Rio");
1091        }
1092    }
1093
1094    // ── fit_logo_cells ────────────────────────────────────────────────────────
1095
1096    #[test]
1097    fn test_fit_logo_cells_preserves_aspect_for_wide_image() {
1098        // fedora.png is 384x108 (3.56:1). In 10x20px cells a 45x10 box is 450x200px, so the
1099        // image is width-limited: 450px wide -> 450*108/384 = 126px tall -> 7 rows.
1100        // The old hardcoded c=26,r=10 forced it into 260x200px, a ~3x vertical stretch.
1101        let fit = fit_logo_cells(384, 108, 10, 20, 45, 10);
1102        assert_eq!(fit.cols, 45);
1103        assert_eq!(fit.rows, 7);
1104        assert!(fit.width_limited);
1105        // A wide image is pinned by width, so Kitty is told the width and derives the height.
1106        assert_eq!(kitty_placement_spec(fit), "c=45");
1107    }
1108
1109    #[test]
1110    fn test_fit_logo_cells_preserves_aspect_for_tall_image() {
1111        // debian.png is 291x384 (0.76:1) — height-limited, so it must not claim the full width.
1112        let fit = fit_logo_cells(291, 384, 10, 20, 45, 10);
1113        assert_eq!(fit.rows, 10);
1114        assert!(
1115            fit.cols < 45,
1116            "tall image should not fill the width, got {}",
1117            fit.cols
1118        );
1119        assert!(!fit.width_limited);
1120        assert_eq!(kitty_placement_spec(fit), "r=10");
1121    }
1122
1123    #[test]
1124    fn test_fit_logo_cells_accounts_for_non_square_cells() {
1125        // A square image in 1:2 cells must come back twice as wide as it is tall, otherwise
1126        // it renders visibly squashed. Same image, square cells, stays square.
1127        let fit = fit_logo_cells(256, 256, 10, 20, 45, 10);
1128        assert_eq!((fit.cols, fit.rows), (20, 10));
1129        let sq = fit_logo_cells(256, 256, 10, 10, 45, 10);
1130        assert_eq!((sq.cols, sq.rows), (10, 10));
1131    }
1132
1133    #[test]
1134    fn test_fit_logo_cells_never_exceeds_the_box() {
1135        // Whatever the aspect, the result must fit the budget plan_layout was given.
1136        for (w, h) in [(384, 108), (291, 384), (256, 256), (4000, 3), (3, 4000)] {
1137            let fit = fit_logo_cells(w, h, 10, 20, 45, 10);
1138            assert!((1..=45).contains(&fit.cols), "{w}x{h} -> {} cols", fit.cols);
1139            assert!((1..=10).contains(&fit.rows), "{w}x{h} -> {} rows", fit.rows);
1140        }
1141    }
1142
1143    /// Every logo asset shipped in `assets/logos/`, as `(width, height)` pixels.
1144    ///
1145    /// Hardcoded rather than read from disk so the test is a pure function of the numbers —
1146    /// the aspect ratios are what matter, and a fixture that reads PNGs would fail for
1147    /// filesystem reasons rather than arithmetic ones.
1148    const SHIPPED_ASSET_DIMENSIONS: &[(u32, u32)] = &[
1149        (384, 117), // arch
1150        (291, 384), // debian
1151        (384, 384), // endeavouros, manjaro, opensuse, pop, windows
1152        (384, 108), // fedora
1153        (256, 256), // garuda, linuxmint
1154        (256, 150), // kali
1155        (313, 384), // macos
1156        (256, 232), // mx      <- 220.69 px wide: the truncation case
1157        (384, 121), // nixos
1158        (384, 163), // tux
1159        (384, 135), // ubuntu
1160        (256, 222), // zorin   <- 230.63 px wide: the other truncation case
1161    ];
1162
1163    #[test]
1164    fn test_fit_logo_cells_reservation_is_never_smaller_than_the_drawn_image() {
1165        // The invariant `fit_logo_cells` documents, asserted exactly (cross-multiplied, so
1166        // there is no floating point and no rounding of our own).
1167        //
1168        // Regression for a sub-pixel breach: the display size was computed with truncating
1169        // integer division *before* the cell count was `div_ceil`ed, so a true size sitting
1170        // just above a cell boundary truncated onto it and reserved one pixel too few.
1171        // `mx.png` and `zorin.png` both did this (0.07 cells over). It was invisible while the
1172        // logo sat mid-screen; right-anchored, the overflow is at the terminal's edge.
1173        for &(w, h) in SHIPPED_ASSET_DIMENSIONS {
1174            for (cell_w, cell_h) in [(10usize, 20usize), (7, 15), (22, 51), (9, 18)] {
1175                let fit = fit_logo_cells(w, h, cell_w, cell_h, LOGO_MAX_COLS, LOGO_MAX_ROWS);
1176                let (box_w, box_h) = (
1177                    (LOGO_MAX_COLS * cell_w) as u64,
1178                    (LOGO_MAX_ROWS * cell_h) as u64,
1179                );
1180                let (w, h) = (u64::from(w), u64::from(h));
1181                let (res_w, res_h) = ((fit.cols * cell_w) as u64, (fit.rows * cell_h) as u64);
1182                if fit.width_limited {
1183                    // Drawn size is box_w x (box_w * h / w). Assert res_h >= that, exactly.
1184                    assert!(
1185                        res_w >= box_w,
1186                        "{w}x{h} cells {cell_w}x{cell_h}: width short"
1187                    );
1188                    // Print the drawn size as a real number — reporting it with the same
1189                    // truncating division the bug is about would render the message
1190                    // self-contradicting ("reserved 220px < drawn 220px").
1191                    assert!(
1192                        res_h * w >= box_w * h,
1193                        "{w}x{h} cells {cell_w}x{cell_h}: reserved {res_h}px < drawn {:.2}px",
1194                        (box_w * h) as f64 / w as f64
1195                    );
1196                } else {
1197                    // Drawn size is (box_h * w / h) x box_h.
1198                    assert!(
1199                        res_h >= box_h,
1200                        "{w}x{h} cells {cell_w}x{cell_h}: height short"
1201                    );
1202                    assert!(
1203                        res_w * h >= box_h * w,
1204                        "{w}x{h} cells {cell_w}x{cell_h}: reserved {res_w}px < drawn {:.2}px",
1205                        (box_h * w) as f64 / h as f64
1206                    );
1207                }
1208            }
1209        }
1210    }
1211
1212    #[test]
1213    fn test_fit_logo_cells_handles_degenerate_input() {
1214        // Unreadable image dimensions or a terminal reporting zero-size cells must not panic
1215        // or divide by zero.
1216        for fit in [
1217            fit_logo_cells(0, 0, 10, 20, 45, 10),
1218            fit_logo_cells(384, 108, 0, 20, 45, 10),
1219            fit_logo_cells(384, 108, 10, 0, 45, 10),
1220        ] {
1221            assert_eq!((fit.cols, fit.rows), (45, 10));
1222        }
1223    }
1224
1225    #[test]
1226    fn test_chafa_size_arg_matches_the_shared_box() {
1227        // Chafa and the graphical protocols must budget the same footprint.
1228        assert_eq!(chafa_size_arg(), format!("{LOGO_MAX_COLS}x{LOGO_MAX_ROWS}"));
1229        assert_eq!(chafa_size_arg(), "45x10");
1230    }
1231}