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