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    let (disp_w, disp_h) = if width_limited {
330        (box_w, box_w * img_h / img_w) // wide image: touches the sides first
331    } else {
332        (box_h * img_w / img_h, box_h) // tall image: touches top and bottom first
333    };
334
335    LogoFit {
336        // `div_ceil` so the reservation is never *smaller* than what gets drawn: an extra
337        // blank row or column is harmless, an overlapping one corrupts the layout.
338        cols: (disp_w as usize).div_ceil(cell_w).clamp(1, max_cols),
339        rows: (disp_h as usize).div_ceil(cell_h).clamp(1, max_rows),
340        width_limited,
341    }
342}
343
344/// The cell footprint of a logo, plus which dimension of the box it touches first.
345///
346/// `width_limited` matters only to the Kitty emitter — see [`kitty_placement_spec`].
347#[derive(Debug, Clone, Copy, PartialEq, Eq)]
348pub struct LogoFit {
349    /// Width in terminal columns.
350    pub cols: usize,
351    /// Height in terminal rows.
352    pub rows: usize,
353    /// True when the image reaches the box's width before its height (a wide image).
354    pub width_limited: bool,
355}
356
357/// Builds the Kitty graphics-protocol placement keys (`c=` / `r=`) for a fitted logo.
358///
359/// Deliberately emits **only the limiting dimension**. Kitty derives the other from the
360/// image's real aspect ratio, whereas specifying both makes it scale each axis independently
361/// to fill the rectangle exactly — and because cells are indivisible, the rounded rectangle is
362/// never quite the image's aspect, so passing both leaves a residual stretch even when the
363/// numbers are computed correctly (measured at 9% for the Fedora logo). Passing one leaves
364/// none.
365///
366/// The reservation [`plan_layout`](crate::display) makes is `div_ceil`-rounded and therefore
367/// always covers what Kitty then draws.
368pub fn kitty_placement_spec(fit: LogoFit) -> String {
369    if fit.width_limited {
370        format!("c={}", fit.cols)
371    } else {
372        format!("r={}", fit.rows)
373    }
374}
375
376/// Returns the terminal cell size in pixels as `(width, height)` via `TIOCGWINSZ`.
377///
378/// Falls back to a 10×20 cell — the conventional default — when the terminal does not report
379/// pixel dimensions (tmux, many terminals, and any non-TTY stdout).
380pub fn terminal_cell_size_px() -> (usize, usize) {
381    #[cfg(unix)]
382    {
383        use std::mem::MaybeUninit;
384        let mut ws: libc::winsize = unsafe { MaybeUninit::zeroed().assume_init() };
385        // SAFETY: `ws` is a live, zeroed `winsize` and TIOCGWINSZ writes exactly that type.
386        let ret = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut ws) };
387        if ret == 0 && ws.ws_row > 0 && ws.ws_col > 0 && ws.ws_xpixel > 0 && ws.ws_ypixel > 0 {
388            return (
389                ws.ws_xpixel as usize / ws.ws_col as usize,
390                ws.ws_ypixel as usize / ws.ws_row as usize,
391            );
392        }
393    }
394    (10, 20)
395}
396
397/// Returns the cell footprint an image will occupy on the current terminal.
398///
399/// Thin wrapper pairing [`terminal_cell_size_px`] with the pure [`fit_logo_cells`].
400pub fn logo_cells_for(img_w: u32, img_h: u32) -> LogoFit {
401    let (cell_w, cell_h) = terminal_cell_size_px();
402    fit_logo_cells(img_w, img_h, cell_w, cell_h, LOGO_MAX_COLS, LOGO_MAX_ROWS)
403}
404
405/// The `--size WxH` argument passed to `chafa`, derived from the shared logo cell box.
406///
407/// Chafa fits the image *inside* this box preserving aspect ratio, so this is a maximum in
408/// both dimensions rather than a target — a wide logo comes back short, a tall one narrow.
409pub fn chafa_size_arg() -> String {
410    format!("{LOGO_MAX_COLS}x{LOGO_MAX_ROWS}")
411}
412
413static CHAFA_SUPPORTS_PROBE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
414
415/// Checks if the `chafa` command-line tool supports the `--probe` option.
416pub fn chafa_supports_probe() -> bool {
417    *CHAFA_SUPPORTS_PROBE.get_or_init(|| {
418        std::process::Command::new("chafa")
419            .args(["--probe", "off", "--version"])
420            .output()
421            .map(|o| o.status.success())
422            .unwrap_or(false)
423    })
424}
425
426/// Checks if the `chafa` command-line tool is available in the system path.
427pub fn chafa_available() -> bool {
428    std::process::Command::new("chafa")
429        .arg("--version")
430        .output()
431        .map(|o| o.status.success())
432        .unwrap_or(false)
433}
434
435/// Write embedded logo bytes to a temporary file and return the path.
436fn write_temp_logo(bytes: &[u8]) -> std::io::Result<std::path::PathBuf> {
437    let temp_path = std::env::temp_dir().join(format!("retch_logo_{}.png", std::process::id()));
438    std::fs::write(&temp_path, bytes)?;
439    Ok(temp_path)
440}
441
442/// Attempts to render an image using the `chafa` utility.
443///
444/// Chafa renders images using high-quality Unicode symbols, providing a
445/// good graphical fallback for many terminal emulators.
446pub fn print_with_chafa(path: &std::path::Path) -> bool {
447    let mut cmd = std::process::Command::new("chafa");
448    cmd.arg("--format")
449        .arg("symbols")
450        .arg("--size")
451        .arg(chafa_size_arg());
452
453    if chafa_supports_probe() {
454        cmd.arg("--probe").arg("off");
455    }
456
457    let output = cmd.arg(path).output();
458
459    match output {
460        Ok(out) if out.status.success() => {
461            print!("{}", String::from_utf8_lossy(&out.stdout));
462            true
463        }
464        Ok(out) => {
465            eprintln!("warning: chafa failed with status: {}", out.status);
466            false
467        }
468        Err(e) => {
469            eprintln!("warning: failed to execute chafa: {}", e);
470            false
471        }
472    }
473}
474
475/// Attempts to get Chafa output as a list of lines.
476pub fn get_chafa_logo_lines(path: &std::path::Path) -> Option<Vec<String>> {
477    let mut cmd = std::process::Command::new("chafa");
478    cmd.arg("--format")
479        .arg("symbols")
480        .arg("--size")
481        .arg(chafa_size_arg());
482
483    if chafa_supports_probe() {
484        cmd.arg("--probe").arg("off");
485    }
486
487    let output = cmd.arg(path).output().ok()?;
488    if output.status.success() {
489        let content = String::from_utf8_lossy(&output.stdout);
490        Some(content.lines().map(|s| s.to_string()).collect())
491    } else {
492        None
493    }
494}
495
496/// Print logo for a distro following the strict priority:
497/// 1. Real graphic logo (if terminal supports it and embedded logo exists)
498/// 2. Chafa high-quality symbols (if chafa is available)
499/// 3. Real Fastfetch ASCII logo (always available)
500pub fn print_distro_logo(distro: Option<&str>) {
501    print_distro_logo_with_ascii(distro, false, false);
502}
503
504/// Renders the distribution logo with options to force ASCII or Chafa mode.
505///
506/// This is the primary entry point for logo rendering, handling the entire
507/// priority chain from high-res graphics down to text-based ASCII.
508/// - `ascii_only`: skip all graphical protocols and render the ASCII art directly.
509/// - `chafa_only`: skip Kitty/iTerm2/Sixel and go straight to Chafa (falls back
510///   to ASCII if Chafa is unavailable). `ascii_only` takes precedence.
511pub fn print_distro_logo_with_ascii(distro: Option<&str>, ascii_only: bool, chafa_only: bool) {
512    if ascii_only {
513        // Force ASCII path
514        let art = get_distro_logo_lines(distro);
515        for line in art {
516            println!("{}", line);
517        }
518        return;
519    }
520
521    let has_chafa = chafa_available();
522
523    if !chafa_only {
524        let supports_kitty = supports_kitty();
525        let supports_iterm2 = supports_iterm2();
526        let supports_sixel = supports_sixel();
527
528        // 1. Try embedded graphical logo (Kitty)
529        #[cfg(feature = "graphics")]
530        if supports_kitty {
531            if let Some(bytes) = get_embedded_logo(distro) {
532                if !bytes.is_empty() {
533                    print_graphical_logo(bytes);
534                    return;
535                }
536            }
537        }
538
539        // 2. Try embedded graphical logo (iTerm2)
540        #[cfg(feature = "graphics")]
541        if supports_iterm2 {
542            if let Some(bytes) = get_embedded_logo(distro) {
543                if !bytes.is_empty() {
544                    print_iterm2_logo(bytes);
545                    return;
546                }
547            }
548        }
549
550        // 3. Try embedded graphical logo (Sixel)
551        #[cfg(feature = "graphics")]
552        if supports_sixel {
553            if let Some(bytes) = get_embedded_logo(distro) {
554                if !bytes.is_empty() {
555                    print_sixel_logo(bytes);
556                    return;
557                }
558            }
559        }
560    }
561
562    // 4. Try chafa using embedded distro logo
563    if has_chafa {
564        if let Some(bytes) = get_embedded_logo(distro) {
565            if bytes.len() > 100 {
566                if let Ok(temp_path) = write_temp_logo(bytes) {
567                    if print_with_chafa(&temp_path) {
568                        let _ = std::fs::remove_file(&temp_path);
569                        return;
570                    }
571                    let _ = std::fs::remove_file(&temp_path);
572                }
573            }
574        }
575    }
576
577    // 5. Final fallback: Real Fastfetch ASCII logo
578    let art = get_distro_logo_lines(distro);
579    for line in art {
580        println!("{}", line);
581    }
582}
583
584/// Renders a raw image buffer using the iTerm2 inline image protocol.
585#[cfg(feature = "graphics")]
586pub fn print_iterm2_logo(image_data: &[u8]) {
587    use base64::Engine;
588
589    let (width, height) = image::load_from_memory(image_data)
590        .map(|img| (img.width(), img.height()))
591        .unwrap_or((0, 0));
592
593    let encoded = base64::engine::general_purpose::STANDARD.encode(image_data);
594
595    // `width`/`height` are in character cells here; `preserveAspectRatio=1` makes them a
596    // bounding box rather than a target, so the image is never distorted. Passing both (not
597    // just `height`) keeps the drawn footprint inside the width `plan_layout` reserved.
598    if width > 0 && height > 0 {
599        let fit = logo_cells_for(width, height);
600        print!(
601            "\x1b]1337;File=inline=1;width={};height={};preserveAspectRatio=1:{}\x07",
602            fit.cols, fit.rows, encoded
603        );
604    } else {
605        print!(
606            "\x1b]1337;File=inline=1;height={};preserveAspectRatio=1:{}\x07",
607            LOGO_MAX_ROWS, encoded
608        );
609    }
610    println!(); // iTerm2 typically needs a newline after the logo
611}
612
613/// Loads an image from a file and prints it using the iTerm2 protocol.
614#[cfg(feature = "graphics")]
615pub fn print_iterm2_logo_from_path(path: &std::path::Path) {
616    if let Ok(bytes) = std::fs::read(path) {
617        print_iterm2_logo(&bytes);
618    } else {
619        println!("[Could not read logo for iTerm2 from {}]", path.display());
620    }
621}
622
623/// Placeholder for iTerm2 logo rendering when the `graphics` feature is disabled.
624#[cfg(not(feature = "graphics"))]
625pub fn print_iterm2_logo(_image_data: &[u8]) {
626    println!("[iTerm2 logo support requires --features graphics]");
627}
628
629/// Renders a raw image buffer using the Kitty graphics protocol.
630#[cfg(feature = "graphics")]
631pub fn print_graphical_logo(image_data: &[u8]) {
632    use base64::Engine;
633
634    let (width, height) = image::load_from_memory(image_data)
635        .map(|img| (img.width(), img.height()))
636        .unwrap_or((0, 0));
637
638    let encoded = base64::engine::general_purpose::STANDARD.encode(image_data);
639
640    if width > 0 && height > 0 {
641        // Kitty *forces* the image into whatever placement rectangle it is given, so the
642        // spec carries only the limiting dimension and lets Kitty derive the other from the
643        // image's aspect ratio. The old hardcoded `c=26,r=10` is what squashed the 3.56:1
644        // Fedora logo into a roughly 1:1 box.
645        let spec = kitty_placement_spec(logo_cells_for(width, height));
646        println!(
647            "\x1b_Gf=100,s={},v={},{},a=T;{}\x1b\\",
648            width, height, spec, encoded
649        );
650    } else {
651        println!("\x1b_Gf=100,a=T;{}", encoded);
652    }
653}
654
655/// Renders a raw image buffer (e.g. PNG bytes) using the Sixel graphics protocol.
656#[cfg(feature = "graphics")]
657pub fn print_sixel_logo(image_data: &[u8]) {
658    if let Ok(img) = image::load_from_memory(image_data) {
659        // Size the sixel to the same cell box the layout reserved, in pixels. `resize` already
660        // preserves aspect ratio (it fits within the box), so this only ever shrinks the image
661        // to the footprint `plan_layout` was told about.
662        let fit = logo_cells_for(img.width(), img.height());
663        let (cell_w, cell_h) = terminal_cell_size_px();
664        let resized = img.resize(
665            (fit.cols * cell_w) as u32,
666            (fit.rows * cell_h) as u32,
667            image::imageops::FilterType::Triangle,
668        );
669        let rgba = resized.to_rgba8();
670        let (width, height) = rgba.dimensions();
671        print_sixel_rgba(rgba.as_raw(), width, height);
672    }
673}
674
675/// Renders raw RGBA pixels using the Sixel graphics protocol.
676#[cfg(feature = "graphics")]
677pub fn print_sixel_rgba(rgba: &[u8], width: u32, height: u32) {
678    use icy_sixel::SixelImage;
679
680    match SixelImage::try_from_rgba(rgba.to_vec(), width as usize, height as usize) {
681        Ok(sixel_img) => match sixel_img.encode() {
682            Ok(sixel_str) => {
683                print!("{}", sixel_str);
684            }
685            Err(e) => eprintln!("[Sixel Encoding Error: {}]", e),
686        },
687        Err(e) => eprintln!("[Sixel Creation Error: {}]", e),
688    }
689}
690
691/// Placeholder for graphical logo rendering when the `graphics` feature is disabled.
692#[cfg(not(feature = "graphics"))]
693pub fn print_graphical_logo(_image_data: &[u8]) {
694    println!("[Graphical logo support requires --features graphics]");
695}
696
697/// Placeholder for sixel logo rendering when the `graphics` feature is disabled.
698#[cfg(not(feature = "graphics"))]
699pub fn print_sixel_logo(_image_data: &[u8]) {
700    println!("[Sixel logo support requires --features graphics]");
701}
702
703/// Loads an image from a file, resizes it, and prints it using the graphics protocol.
704#[cfg(feature = "graphics")]
705pub fn print_graphical_logo_from_path(path: &std::path::Path) {
706    use image::ImageFormat;
707    match image::open(path) {
708        Ok(img) => {
709            let resized = img.resize(128, 128, image::imageops::FilterType::Lanczos3);
710            let mut png_data = Vec::new();
711            if resized
712                .write_to(&mut std::io::Cursor::new(&mut png_data), ImageFormat::Png)
713                .is_ok()
714            {
715                print_graphical_logo(&png_data);
716            } else {
717                println!("[Failed to encode logo as PNG]");
718            }
719        }
720        Err(_) => {
721            println!("[Could not load graphical logo from {}]", path.display());
722        }
723    }
724}
725
726/// Loads an image from a file, resizes it, and prints it using the Sixel protocol.
727#[cfg(feature = "graphics")]
728pub fn print_sixel_logo_from_path(path: &std::path::Path) {
729    match image::open(path) {
730        Ok(img) => {
731            let resized = img.resize(128, 128, image::imageops::FilterType::Lanczos3);
732            let rgba = resized.to_rgba8();
733            let (width, height) = rgba.dimensions();
734            print_sixel_rgba(rgba.as_raw(), width, height);
735        }
736        Err(_) => {
737            println!("[Could not load logo for Sixel from {}]", path.display());
738        }
739    }
740}
741
742#[cfg(test)]
743mod tests {
744    use super::*;
745
746    #[test]
747    fn test_get_ascii_logo_arch() {
748        let logo = get_ascii_logo(Some("arch"));
749        assert!(!logo.is_empty());
750        assert!(logo[0].contains("`"));
751    }
752
753    #[test]
754    fn test_get_ascii_logo_unknown() {
755        let logo = get_ascii_logo(Some("unknown_distro"));
756        assert!(!logo.is_empty());
757        // Should fall back to Tux
758        assert!(logo
759            .iter()
760            .any(|line| line.contains("o${2}_${3}o") || line.contains("o_o")));
761    }
762
763    #[test]
764    fn test_get_ascii_logo_none() {
765        let logo = get_ascii_logo(None);
766        assert!(!logo.is_empty());
767    }
768
769    static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
770
771    struct EnvGuard {
772        _mutex_guard: std::sync::MutexGuard<'static, ()>,
773        old_vars: std::collections::HashMap<&'static str, Option<String>>,
774    }
775
776    impl EnvGuard {
777        fn new(vars_to_mock: &[&'static str]) -> Self {
778            let guard = ENV_LOCK.lock().unwrap();
779            let mut old_vars = std::collections::HashMap::new();
780            for var in vars_to_mock {
781                old_vars.insert(*var, std::env::var(var).ok());
782            }
783            EnvGuard {
784                _mutex_guard: guard,
785                old_vars,
786            }
787        }
788    }
789
790    impl Drop for EnvGuard {
791        fn drop(&mut self) {
792            for (var, value) in &self.old_vars {
793                if let Some(val) = value {
794                    std::env::set_var(var, val);
795                } else {
796                    std::env::remove_var(var);
797                }
798            }
799        }
800    }
801
802    #[test]
803    fn test_supports_kitty_heuristics() {
804        let _guard = EnvGuard::new(&["TERM", "TERMINAL_EMULATOR", "TERM_PROGRAM"]);
805
806        // Test TERM=xterm-kitty
807        std::env::set_var("TERM", "xterm-kitty");
808        std::env::remove_var("TERMINAL_EMULATOR");
809        std::env::remove_var("TERM_PROGRAM");
810        assert!(supports_kitty());
811
812        // Test TERMINAL_EMULATOR=iterm-kitty
813        std::env::remove_var("TERM");
814        std::env::set_var("TERMINAL_EMULATOR", "iterm-kitty");
815        assert!(supports_kitty());
816
817        // Test TERMINAL_EMULATOR=iTerm.app
818        std::env::set_var("TERMINAL_EMULATOR", "iTerm.app");
819        assert!(supports_kitty());
820
821        // Test TERM_PROGRAM=rio
822        std::env::remove_var("TERMINAL_EMULATOR");
823        std::env::set_var("TERM_PROGRAM", "rio");
824        assert!(supports_kitty());
825
826        // Test clear env -> false
827        std::env::remove_var("TERM_PROGRAM");
828        assert!(!supports_kitty());
829    }
830
831    #[test]
832    fn test_supports_iterm2_heuristics() {
833        // TERM must be guarded and cleared as well as TERM_PROGRAM: `supports_iterm2` consults
834        // it via `is_rio_terminal`, so without this the *host's* TERM leaks in and the negative
835        // assertions below fail on a Rio box while passing everywhere else.
836        let _guard = EnvGuard::new(&["TERM", "TERM_PROGRAM"]);
837        std::env::remove_var("TERM");
838
839        // Test TERM_PROGRAM=iTerm.app
840        std::env::set_var("TERM_PROGRAM", "iTerm.app");
841        assert!(supports_iterm2());
842
843        // Test TERM_PROGRAM=WezTerm
844        std::env::set_var("TERM_PROGRAM", "WezTerm");
845        assert!(supports_iterm2());
846
847        // Test TERM_PROGRAM=rio
848        std::env::set_var("TERM_PROGRAM", "rio");
849        assert!(supports_iterm2());
850
851        // Test TERM_PROGRAM=Apple_Terminal
852        std::env::set_var("TERM_PROGRAM", "Apple_Terminal");
853        assert!(!supports_iterm2());
854
855        // Test clear env -> false
856        std::env::remove_var("TERM_PROGRAM");
857        assert!(!supports_iterm2());
858    }
859
860    #[test]
861    fn test_supports_sixel_heuristics() {
862        let _guard = EnvGuard::new(&["TERM", "TERM_PROGRAM", "WT_SESSION"]);
863
864        // Clear all to start fresh
865        std::env::remove_var("TERM");
866        std::env::remove_var("TERM_PROGRAM");
867        std::env::remove_var("WT_SESSION");
868        assert!(!supports_sixel());
869
870        // Test TERM=xterm-sixel
871        std::env::set_var("TERM", "xterm-sixel");
872        assert!(supports_sixel());
873
874        // Test TERM=foot
875        std::env::set_var("TERM", "foot");
876        assert!(supports_sixel());
877
878        // Test TERM=mlterm (case variations)
879        std::env::set_var("TERM", "MLTerm");
880        assert!(supports_sixel());
881
882        // Reset TERM, test TERM_PROGRAM=WezTerm
883        std::env::remove_var("TERM");
884        std::env::set_var("TERM_PROGRAM", "WezTerm");
885        assert!(supports_sixel());
886
887        // Test TERM_PROGRAM=iTerm.app
888        std::env::set_var("TERM_PROGRAM", "iTerm.app");
889        assert!(supports_sixel());
890
891        // Test TERM_PROGRAM=rio
892        std::env::set_var("TERM_PROGRAM", "rio");
893        assert!(supports_sixel());
894
895        // Reset TERM_PROGRAM, test WT_SESSION
896        std::env::remove_var("TERM_PROGRAM");
897        std::env::set_var("WT_SESSION", "active");
898        assert!(supports_sixel());
899    }
900
901    #[test]
902    fn test_get_embedded_logo() {
903        let logo = get_embedded_logo(Some("arch"));
904        assert!(logo.is_some());
905        let logo = get_embedded_logo(Some("pop"));
906        assert!(logo.is_some());
907        let logo = get_embedded_logo(Some("manjaro"));
908        assert!(logo.is_some());
909        let logo = get_embedded_logo(Some("endeavouros"));
910        assert!(logo.is_some());
911        let logo = get_embedded_logo(Some("opensuse"));
912        assert!(logo.is_some());
913        let logo = get_embedded_logo(Some("opensuse-leap"));
914        assert!(logo.is_some());
915        let logo = get_embedded_logo(Some("opensuse-tumbleweed"));
916        assert!(logo.is_some());
917        let logo = get_embedded_logo(Some("mx"));
918        assert!(logo.is_some());
919        let logo = get_embedded_logo(Some("linuxmint"));
920        assert!(logo.is_some());
921        let logo = get_embedded_logo(Some("kali"));
922        assert!(logo.is_some());
923        let logo = get_embedded_logo(Some("zorin"));
924        assert!(logo.is_some());
925        let logo = get_embedded_logo(Some("garuda"));
926        assert!(logo.is_some());
927        let logo = get_embedded_logo(Some("macos"));
928        assert!(logo.is_some());
929        let logo = get_embedded_logo(Some("windows"));
930        assert!(logo.is_some());
931        let logo = get_embedded_logo(None);
932        assert!(logo.is_some());
933    }
934
935    #[test]
936    fn test_get_ascii_logo_new_distros() {
937        let pop = get_ascii_logo(Some("pop"));
938        assert!(!pop.is_empty());
939        assert!(pop.iter().any(|line| line.contains("767")));
940
941        let manjaro = get_ascii_logo(Some("manjaro"));
942        assert!(!manjaro.is_empty());
943        assert!(manjaro.iter().any(|line| line.contains("████████")));
944
945        let endeavouros = get_ascii_logo(Some("endeavouros"));
946        assert!(!endeavouros.is_empty());
947        assert!(endeavouros.iter().any(|line| line.contains("ssso")));
948
949        let opensuse = get_ascii_logo(Some("opensuse"));
950        assert!(!opensuse.is_empty());
951        assert!(opensuse.iter().any(|line| line.contains("O0000Ok")));
952
953        let macos = get_ascii_logo(Some("macos"));
954        assert!(!macos.is_empty());
955        assert!(macos
956            .iter()
957            .any(|line| line.contains("cKMMMMMMMMMMNWMMMMMMMMMM0")));
958
959        let windows = get_ascii_logo(Some("windows"));
960        assert!(!windows.is_empty());
961        assert!(windows
962            .iter()
963            .any(|line| line.contains("AEEEtttt::::ztF") || line.contains("tt:::tt333EE3")));
964
965        let mx = get_ascii_logo(Some("mx"));
966        assert!(!mx.is_empty());
967        assert!(mx
968            .iter()
969            .any(|line| line.contains("MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMNMMMMMMMMM")));
970
971        let linuxmint = get_ascii_logo(Some("linuxmint"));
972        assert!(!linuxmint.is_empty());
973        assert!(linuxmint.iter().any(|line| line.contains("oOOOOOOOOOOo")));
974
975        let kali = get_ascii_logo(Some("kali"));
976        assert!(!kali.is_empty());
977        assert!(kali.iter().any(|line| line.contains(":ccc")));
978
979        let zorin = get_ascii_logo(Some("zorin"));
980        assert!(!zorin.is_empty());
981        assert!(zorin
982            .iter()
983            .any(|line| line.contains("osssssssssssssssssssso")));
984
985        let garuda = get_ascii_logo(Some("garuda"));
986        assert!(!garuda.is_empty());
987        assert!(garuda.iter().any(|line| line.contains("888:8898898")));
988    }
989
990    // ── Rio detection (TERM as well as TERM_PROGRAM) ──────────────────────────
991
992    #[test]
993    fn test_rio_detected_from_term_when_term_program_is_absent() {
994        // The sudo case: `env_reset` keeps TERM but drops TERM_PROGRAM, which used to cost
995        // Rio all graphics support and fall through to Chafa.
996        let _guard = EnvGuard::new(&["TERM", "TERMINAL_EMULATOR", "TERM_PROGRAM"]);
997        std::env::remove_var("TERM_PROGRAM");
998        std::env::remove_var("TERMINAL_EMULATOR");
999        std::env::set_var("TERM", "xterm-rio");
1000
1001        assert!(is_rio_terminal());
1002        assert!(supports_kitty());
1003        assert!(supports_iterm2());
1004        assert!(supports_sixel());
1005    }
1006
1007    #[test]
1008    fn test_rio_still_detected_from_term_program() {
1009        // The pre-existing path must keep working when TERM says nothing useful.
1010        let _guard = EnvGuard::new(&["TERM", "TERMINAL_EMULATOR", "TERM_PROGRAM"]);
1011        std::env::remove_var("TERMINAL_EMULATOR");
1012        std::env::set_var("TERM", "xterm-256color");
1013        std::env::set_var("TERM_PROGRAM", "rio");
1014
1015        assert!(is_rio_terminal());
1016        assert!(supports_kitty());
1017    }
1018
1019    #[test]
1020    fn test_non_rio_term_is_not_matched() {
1021        // Guard against a loose substring match: these must not be taken for Rio.
1022        let _guard = EnvGuard::new(&["TERM", "TERMINAL_EMULATOR", "TERM_PROGRAM"]);
1023        std::env::remove_var("TERM_PROGRAM");
1024        std::env::remove_var("TERMINAL_EMULATOR");
1025        for term in ["xterm-256color", "screen", "linux", "rioja"] {
1026            std::env::set_var("TERM", term);
1027            assert!(!is_rio_terminal(), "{term} should not be detected as Rio");
1028        }
1029    }
1030
1031    // ── fit_logo_cells ────────────────────────────────────────────────────────
1032
1033    #[test]
1034    fn test_fit_logo_cells_preserves_aspect_for_wide_image() {
1035        // fedora.png is 384x108 (3.56:1). In 10x20px cells a 45x10 box is 450x200px, so the
1036        // image is width-limited: 450px wide -> 450*108/384 = 126px tall -> 7 rows.
1037        // The old hardcoded c=26,r=10 forced it into 260x200px, a ~3x vertical stretch.
1038        let fit = fit_logo_cells(384, 108, 10, 20, 45, 10);
1039        assert_eq!(fit.cols, 45);
1040        assert_eq!(fit.rows, 7);
1041        assert!(fit.width_limited);
1042        // A wide image is pinned by width, so Kitty is told the width and derives the height.
1043        assert_eq!(kitty_placement_spec(fit), "c=45");
1044    }
1045
1046    #[test]
1047    fn test_fit_logo_cells_preserves_aspect_for_tall_image() {
1048        // debian.png is 291x384 (0.76:1) — height-limited, so it must not claim the full width.
1049        let fit = fit_logo_cells(291, 384, 10, 20, 45, 10);
1050        assert_eq!(fit.rows, 10);
1051        assert!(
1052            fit.cols < 45,
1053            "tall image should not fill the width, got {}",
1054            fit.cols
1055        );
1056        assert!(!fit.width_limited);
1057        assert_eq!(kitty_placement_spec(fit), "r=10");
1058    }
1059
1060    #[test]
1061    fn test_fit_logo_cells_accounts_for_non_square_cells() {
1062        // A square image in 1:2 cells must come back twice as wide as it is tall, otherwise
1063        // it renders visibly squashed. Same image, square cells, stays square.
1064        let fit = fit_logo_cells(256, 256, 10, 20, 45, 10);
1065        assert_eq!((fit.cols, fit.rows), (20, 10));
1066        let sq = fit_logo_cells(256, 256, 10, 10, 45, 10);
1067        assert_eq!((sq.cols, sq.rows), (10, 10));
1068    }
1069
1070    #[test]
1071    fn test_fit_logo_cells_never_exceeds_the_box() {
1072        // Whatever the aspect, the result must fit the budget plan_layout was given.
1073        for (w, h) in [(384, 108), (291, 384), (256, 256), (4000, 3), (3, 4000)] {
1074            let fit = fit_logo_cells(w, h, 10, 20, 45, 10);
1075            assert!((1..=45).contains(&fit.cols), "{w}x{h} -> {} cols", fit.cols);
1076            assert!((1..=10).contains(&fit.rows), "{w}x{h} -> {} rows", fit.rows);
1077        }
1078    }
1079
1080    #[test]
1081    fn test_fit_logo_cells_handles_degenerate_input() {
1082        // Unreadable image dimensions or a terminal reporting zero-size cells must not panic
1083        // or divide by zero.
1084        for fit in [
1085            fit_logo_cells(0, 0, 10, 20, 45, 10),
1086            fit_logo_cells(384, 108, 0, 20, 45, 10),
1087            fit_logo_cells(384, 108, 10, 0, 45, 10),
1088        ] {
1089            assert_eq!((fit.cols, fit.rows), (45, 10));
1090        }
1091    }
1092
1093    #[test]
1094    fn test_chafa_size_arg_matches_the_shared_box() {
1095        // Chafa and the graphical protocols must budget the same footprint.
1096        assert_eq!(chafa_size_arg(), format!("{LOGO_MAX_COLS}x{LOGO_MAX_ROWS}"));
1097        assert_eq!(chafa_size_arg(), "45x10");
1098    }
1099}