1pub const KNOWN_LOGOS: &[(&str, &str)] = &[
23 ("arch", "Arch Linux"),
24 ("debian", "Debian"),
25 ("endeavouros", "EndeavourOS"),
26 ("fedora", "Fedora"),
27 ("garuda", "Garuda Linux"),
28 ("kali", "Kali Linux"),
29 ("linuxmint", "Linux Mint"),
30 ("manjaro", "Manjaro"),
31 ("mx", "MX Linux"),
32 ("nixos", "NixOS"),
33 ("opensuse", "openSUSE"),
34 ("pop", "Pop!_OS"),
35 ("ubuntu", "Ubuntu"),
36 ("zorin", "Zorin OS"),
37 ("macos", "macOS"),
38 ("windows", "Windows"),
39];
40
41#[cfg(feature = "graphics")]
46pub fn get_embedded_logo(distro: Option<&str>) -> Option<&'static [u8]> {
47 let d = distro.map(|s| s.to_lowercase());
48 match d.as_deref() {
49 Some("arch") => Some(include_bytes!("../assets/logos/arch.png")),
50 Some("debian") => Some(include_bytes!("../assets/logos/debian.png")),
51 Some("fedora") => Some(include_bytes!("../assets/logos/fedora.png")),
52 Some("nixos") => Some(include_bytes!("../assets/logos/nixos.png")),
53 Some("ubuntu") => Some(include_bytes!("../assets/logos/ubuntu.png")),
54 Some("pop") => Some(include_bytes!("../assets/logos/pop.png")),
55 Some("manjaro") => Some(include_bytes!("../assets/logos/manjaro.png")),
56 Some("endeavouros") => Some(include_bytes!("../assets/logos/endeavouros.png")),
57 Some("opensuse") | Some("opensuse-leap") | Some("opensuse-tumbleweed") => {
58 Some(include_bytes!("../assets/logos/opensuse.png"))
59 }
60 Some("mx") => Some(include_bytes!("../assets/logos/mx.png")),
61 Some("linuxmint") => Some(include_bytes!("../assets/logos/linuxmint.png")),
62 Some("kali") => Some(include_bytes!("../assets/logos/kali.png")),
63 Some("zorin") => Some(include_bytes!("../assets/logos/zorin.png")),
64 Some("garuda") => Some(include_bytes!("../assets/logos/garuda.png")),
65 Some("macos") => Some(include_bytes!("../assets/logos/macos.png")),
66 Some("windows") => Some(include_bytes!("../assets/logos/windows.png")),
67 _ => Some(include_bytes!("../assets/logos/tux.png")),
68 }
69}
70
71#[cfg(not(feature = "graphics"))]
73pub fn get_embedded_logo(_distro: Option<&str>) -> Option<&'static [u8]> {
74 Some(include_bytes!("../assets/logos/tux.png"))
75}
76
77pub fn detect_distro() -> Option<String> {
79 #[cfg(target_os = "macos")]
80 {
81 Some("macos".to_string())
82 }
83 #[cfg(target_os = "windows")]
84 {
85 Some("windows".to_string())
86 }
87 #[cfg(not(any(target_os = "macos", target_os = "windows")))]
88 {
89 if let Ok(content) = std::fs::read_to_string("/etc/os-release") {
90 for line in content.lines() {
91 if line.starts_with("ID=") {
92 let id = line.trim_start_matches("ID=").trim_matches('"');
93 return Some(id.to_string());
94 }
95 }
96 }
97 None
98 }
99}
100
101pub fn get_ascii_logo(distro: Option<&str>) -> Vec<String> {
105 let d = distro.map(|s| s.to_lowercase());
106
107 match d.as_deref() {
108 Some("arch") => {
109 let logo = include_str!("../assets/logos/arch.txt");
110 logo.lines().map(|s| s.to_string()).collect()
111 }
112 Some("debian") => {
113 let logo = include_str!("../assets/logos/debian.txt");
114 logo.lines().map(|s| s.to_string()).collect()
115 }
116 Some("fedora") => {
117 let logo = include_str!("../assets/logos/fedora.txt");
118 logo.lines().map(|s| s.to_string()).collect()
119 }
120 Some("nixos") => {
121 let logo = include_str!("../assets/logos/nixos.txt");
122 logo.lines().map(|s| s.to_string()).collect()
123 }
124 Some("ubuntu") => {
125 let logo = include_str!("../assets/logos/ubuntu.txt");
126 logo.lines().map(|s| s.to_string()).collect()
127 }
128 Some("pop") => {
129 let logo = include_str!("../assets/logos/pop.txt");
130 logo.lines().map(|s| s.to_string()).collect()
131 }
132 Some("manjaro") => {
133 let logo = include_str!("../assets/logos/manjaro.txt");
134 logo.lines().map(|s| s.to_string()).collect()
135 }
136 Some("endeavouros") => {
137 let logo = include_str!("../assets/logos/endeavouros.txt");
138 logo.lines().map(|s| s.to_string()).collect()
139 }
140 Some("opensuse") | Some("opensuse-leap") | Some("opensuse-tumbleweed") => {
141 let logo = include_str!("../assets/logos/opensuse.txt");
142 logo.lines().map(|s| s.to_string()).collect()
143 }
144 Some("mx") => {
145 let logo = include_str!("../assets/logos/mx.txt");
146 logo.lines().map(|s| s.to_string()).collect()
147 }
148 Some("linuxmint") => {
149 let logo = include_str!("../assets/logos/linuxmint.txt");
150 logo.lines().map(|s| s.to_string()).collect()
151 }
152 Some("kali") => {
153 let logo = include_str!("../assets/logos/kali.txt");
154 logo.lines().map(|s| s.to_string()).collect()
155 }
156 Some("zorin") => {
157 let logo = include_str!("../assets/logos/zorin.txt");
158 logo.lines().map(|s| s.to_string()).collect()
159 }
160 Some("garuda") => {
161 let logo = include_str!("../assets/logos/garuda.txt");
162 logo.lines().map(|s| s.to_string()).collect()
163 }
164 Some("macos") => {
165 let logo = include_str!("../assets/logos/macos.txt");
166 logo.lines().map(|s| s.to_string()).collect()
167 }
168 Some("windows") => {
169 let logo = include_str!("../assets/logos/windows.txt");
170 logo.lines().map(|s| s.to_string()).collect()
171 }
172
173 _ => {
175 let logo = include_str!("../assets/logos/tux.txt");
176 logo.lines().map(|s| s.to_string()).collect()
177 }
178 }
179}
180
181pub fn get_distro_colors(distro: Option<&str>) -> Vec<&'static str> {
183 let d = distro.map(|s| s.to_lowercase());
184 match d.as_deref() {
185 Some("arch") => vec!["\x1b[36m", "\x1b[37m"],
186 Some("debian") => vec!["\x1b[31m", "\x1b[37m"],
187 Some("fedora") => vec!["\x1b[34m", "\x1b[37m"],
188 Some("nixos") => vec![
189 "\x1b[34m", "\x1b[36m", "\x1b[34m", "\x1b[36m", "\x1b[34m", "\x1b[36m",
190 ],
191 Some("ubuntu") => vec!["\x1b[33m", "\x1b[31m"],
192 Some("pop") => vec!["\x1b[36m", "\x1b[37m"],
193 Some("manjaro") => vec!["\x1b[32m"],
194 Some("endeavouros") => vec!["\x1b[35m", "\x1b[31m", "\x1b[34m"],
195 Some("opensuse") | Some("opensuse-leap") | Some("opensuse-tumbleweed") => {
196 vec!["\x1b[32m", "\x1b[37m"]
197 }
198 Some("mx") => vec!["\x1b[34m", "\x1b[37m"],
199 Some("linuxmint") => vec!["\x1b[32m", "\x1b[37m"],
200 Some("kali") => vec!["\x1b[34m", "\x1b[37m"],
201 Some("zorin") => vec!["\x1b[36m", "\x1b[37m"],
202 Some("garuda") => vec!["\x1b[35m", "\x1b[36m"],
203 Some("macos") => vec![
206 "\x1b[38;5;252m",
207 "\x1b[38;5;250m",
208 "\x1b[38;5;248m",
209 "\x1b[38;5;246m",
210 "\x1b[38;5;244m",
211 ],
212 Some("windows") => vec!["\x1b[36m"],
213 _ => vec!["\x1b[30m", "\x1b[37m", "\x1b[33m"], }
215}
216
217pub fn get_distro_logo_lines(distro: Option<&str>) -> Vec<String> {
219 let raw_lines = get_ascii_logo(distro);
220 let colors = get_distro_colors(distro);
221 let default_color = colors.first().copied().unwrap_or("\x1b[0m");
222
223 raw_lines
224 .into_iter()
225 .map(|line| {
226 let mut formatted = line;
227 for i in 1..=9 {
228 let color_val = colors.get(i - 1).copied().unwrap_or("\x1b[0m");
229 let placeholder = format!("${{{}}}", i);
230 formatted = formatted.replace(&placeholder, color_val);
231 let placeholder_short = format!("${}", i);
232 formatted = formatted.replace(&placeholder_short, color_val);
233 }
234 if !formatted.is_empty() {
235 format!("{}{}\x1b[0m", default_color, formatted)
236 } else {
237 formatted
238 }
239 })
240 .collect()
241}
242
243fn is_rio_terminal() -> bool {
250 if let Ok(term) = std::env::var("TERM") {
251 if term == "rio" || term.starts_with("xterm-rio") {
252 return true;
253 }
254 }
255 std::env::var("TERM_PROGRAM")
256 .map(|t| t == "rio")
257 .unwrap_or(false)
258}
259
260pub fn supports_kitty() -> bool {
262 std::env::var("TERM")
263 .map(|t| t == "xterm-kitty")
264 .unwrap_or(false)
265 || std::env::var("TERMINAL_EMULATOR")
266 .map(|t| t == "iterm-kitty" || t == "iTerm.app")
267 .unwrap_or(false)
268 || is_rio_terminal()
269}
270
271pub fn supports_iterm2() -> bool {
273 if let Ok(prog) = std::env::var("TERM_PROGRAM") {
274 if prog == "iTerm.app" || prog == "WezTerm" {
275 return true;
276 }
277 }
278 is_rio_terminal()
279}
280
281pub fn supports_sixel() -> bool {
283 if let Ok(term) = std::env::var("TERM") {
284 let term = term.to_lowercase();
285 if term.contains("sixel") || term.contains("foot") || term.contains("mlterm") {
286 return true;
287 }
288 }
289
290 if let Ok(prog) = std::env::var("TERM_PROGRAM") {
291 if prog == "WezTerm" || prog == "iTerm.app" {
292 return true;
293 }
294 }
295
296 if std::env::var("WT_SESSION").is_ok() {
297 return true;
298 }
299
300 is_rio_terminal()
301}
302
303pub const LOGO_MAX_COLS: usize = 45;
311
312pub const LOGO_MAX_ROWS: usize = 10;
314
315pub fn fit_logo_cells(
329 img_w: u32,
330 img_h: u32,
331 cell_w: usize,
332 cell_h: usize,
333 max_cols: usize,
334 max_rows: usize,
335) -> LogoFit {
336 let (max_cols, max_rows) = (max_cols.max(1), max_rows.max(1));
337
338 if img_w == 0 || img_h == 0 || cell_w == 0 || cell_h == 0 {
340 return LogoFit {
341 cols: max_cols,
342 rows: max_rows,
343 width_limited: true,
344 };
345 }
346
347 let (img_w, img_h) = (u64::from(img_w), u64::from(img_h));
348 let box_w = (max_cols * cell_w) as u64;
349 let box_h = (max_rows * cell_h) as u64;
350
351 let width_limited = box_w * img_h <= box_h * img_w;
354 let (disp_w, disp_h) = if width_limited {
362 (box_w, (box_w * img_h).div_ceil(img_w)) } else {
364 ((box_h * img_w).div_ceil(img_h), box_h) };
366
367 LogoFit {
368 cols: (disp_w as usize).div_ceil(cell_w).clamp(1, max_cols),
371 rows: (disp_h as usize).div_ceil(cell_h).clamp(1, max_rows),
372 width_limited,
373 }
374}
375
376#[derive(Debug, Clone, Copy, PartialEq, Eq)]
380pub struct LogoFit {
381 pub cols: usize,
383 pub rows: usize,
385 pub width_limited: bool,
387}
388
389pub fn kitty_placement_spec(fit: LogoFit) -> String {
401 if fit.width_limited {
402 format!("c={}", fit.cols)
403 } else {
404 format!("r={}", fit.rows)
405 }
406}
407
408pub fn terminal_cell_size_px() -> (usize, usize) {
413 #[cfg(unix)]
414 {
415 use std::mem::MaybeUninit;
416 let mut ws: libc::winsize = unsafe { MaybeUninit::zeroed().assume_init() };
417 let ret = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut ws) };
419 if ret == 0 && ws.ws_row > 0 && ws.ws_col > 0 && ws.ws_xpixel > 0 && ws.ws_ypixel > 0 {
420 return (
421 ws.ws_xpixel as usize / ws.ws_col as usize,
422 ws.ws_ypixel as usize / ws.ws_row as usize,
423 );
424 }
425 }
426 (10, 20)
427}
428
429pub fn logo_cells_for(img_w: u32, img_h: u32) -> LogoFit {
433 let (cell_w, cell_h) = terminal_cell_size_px();
434 fit_logo_cells(img_w, img_h, cell_w, cell_h, LOGO_MAX_COLS, LOGO_MAX_ROWS)
435}
436
437pub fn chafa_size_arg() -> String {
442 format!("{LOGO_MAX_COLS}x{LOGO_MAX_ROWS}")
443}
444
445static CHAFA_SUPPORTS_PROBE: std::sync::OnceLock<bool> = std::sync::OnceLock::new();
446
447pub fn chafa_supports_probe() -> bool {
449 *CHAFA_SUPPORTS_PROBE.get_or_init(|| {
450 std::process::Command::new("chafa")
451 .args(["--probe", "off", "--version"])
452 .output()
453 .map(|o| o.status.success())
454 .unwrap_or(false)
455 })
456}
457
458pub fn chafa_available() -> bool {
460 std::process::Command::new("chafa")
461 .arg("--version")
462 .output()
463 .map(|o| o.status.success())
464 .unwrap_or(false)
465}
466
467fn write_temp_logo(bytes: &[u8]) -> std::io::Result<std::path::PathBuf> {
469 let temp_path = std::env::temp_dir().join(format!("retch_logo_{}.png", std::process::id()));
470 std::fs::write(&temp_path, bytes)?;
471 Ok(temp_path)
472}
473
474pub fn print_with_chafa(path: &std::path::Path) -> bool {
479 let mut cmd = std::process::Command::new("chafa");
480 cmd.arg("--format")
481 .arg("symbols")
482 .arg("--size")
483 .arg(chafa_size_arg());
484
485 if chafa_supports_probe() {
486 cmd.arg("--probe").arg("off");
487 }
488
489 let output = cmd.arg(path).output();
490
491 match output {
492 Ok(out) if out.status.success() => {
493 print!("{}", String::from_utf8_lossy(&out.stdout));
494 true
495 }
496 Ok(out) => {
497 eprintln!("warning: chafa failed with status: {}", out.status);
498 false
499 }
500 Err(e) => {
501 eprintln!("warning: failed to execute chafa: {}", e);
502 false
503 }
504 }
505}
506
507pub fn get_chafa_logo_lines(path: &std::path::Path) -> Option<Vec<String>> {
509 let mut cmd = std::process::Command::new("chafa");
510 cmd.arg("--format")
511 .arg("symbols")
512 .arg("--size")
513 .arg(chafa_size_arg());
514
515 if chafa_supports_probe() {
516 cmd.arg("--probe").arg("off");
517 }
518
519 let output = cmd.arg(path).output().ok()?;
520 if output.status.success() {
521 let content = String::from_utf8_lossy(&output.stdout);
522 Some(content.lines().map(|s| s.to_string()).collect())
523 } else {
524 None
525 }
526}
527
528pub fn print_distro_logo(distro: Option<&str>) {
533 print_distro_logo_with_ascii(distro, false, false);
534}
535
536pub fn print_distro_logo_with_ascii(distro: Option<&str>, ascii_only: bool, chafa_only: bool) {
544 if ascii_only {
545 let art = get_distro_logo_lines(distro);
547 for line in art {
548 println!("{}", line);
549 }
550 return;
551 }
552
553 let has_chafa = chafa_available();
554
555 if !chafa_only {
556 #[cfg(feature = "graphics")]
558 let supports_kitty = supports_kitty();
559 #[cfg(feature = "graphics")]
560 let supports_iterm2 = supports_iterm2();
561 #[cfg(feature = "graphics")]
562 let supports_sixel = supports_sixel();
563
564 #[cfg(feature = "graphics")]
566 if supports_kitty {
567 if let Some(bytes) = get_embedded_logo(distro) {
568 if !bytes.is_empty() {
569 print_graphical_logo(bytes);
570 return;
571 }
572 }
573 }
574
575 #[cfg(feature = "graphics")]
577 if supports_iterm2 {
578 if let Some(bytes) = get_embedded_logo(distro) {
579 if !bytes.is_empty() {
580 print_iterm2_logo(bytes);
581 return;
582 }
583 }
584 }
585
586 #[cfg(feature = "graphics")]
588 if supports_sixel {
589 if let Some(bytes) = get_embedded_logo(distro) {
590 if !bytes.is_empty() {
591 print_sixel_logo(bytes);
592 return;
593 }
594 }
595 }
596 }
597
598 if has_chafa {
600 if let Some(bytes) = get_embedded_logo(distro) {
601 if bytes.len() > 100 {
602 if let Ok(temp_path) = write_temp_logo(bytes) {
603 if print_with_chafa(&temp_path) {
604 let _ = std::fs::remove_file(&temp_path);
605 return;
606 }
607 let _ = std::fs::remove_file(&temp_path);
608 }
609 }
610 }
611 }
612
613 let art = get_distro_logo_lines(distro);
615 for line in art {
616 println!("{}", line);
617 }
618}
619
620#[cfg(feature = "graphics")]
622pub fn print_iterm2_logo(image_data: &[u8]) {
623 use base64::Engine;
624
625 let (width, height) = image::load_from_memory(image_data)
626 .map(|img| (img.width(), img.height()))
627 .unwrap_or((0, 0));
628
629 let encoded = base64::engine::general_purpose::STANDARD.encode(image_data);
630
631 if width > 0 && height > 0 {
635 let fit = logo_cells_for(width, height);
636 print!(
637 "\x1b]1337;File=inline=1;width={};height={};preserveAspectRatio=1:{}\x07",
638 fit.cols, fit.rows, encoded
639 );
640 } else {
641 print!(
642 "\x1b]1337;File=inline=1;height={};preserveAspectRatio=1:{}\x07",
643 LOGO_MAX_ROWS, encoded
644 );
645 }
646 println!(); }
648
649#[cfg(feature = "graphics")]
651pub fn print_iterm2_logo_from_path(path: &std::path::Path) {
652 if let Ok(bytes) = std::fs::read(path) {
653 print_iterm2_logo(&bytes);
654 } else {
655 println!("[Could not read logo for iTerm2 from {}]", path.display());
656 }
657}
658
659#[cfg(not(feature = "graphics"))]
661pub fn print_iterm2_logo(_image_data: &[u8]) {
662 println!("[iTerm2 logo support requires --features graphics]");
663}
664
665#[cfg(feature = "graphics")]
667pub fn print_graphical_logo(image_data: &[u8]) {
668 use base64::Engine;
669
670 let (width, height) = image::load_from_memory(image_data)
671 .map(|img| (img.width(), img.height()))
672 .unwrap_or((0, 0));
673
674 let encoded = base64::engine::general_purpose::STANDARD.encode(image_data);
675
676 if width > 0 && height > 0 {
677 let spec = kitty_placement_spec(logo_cells_for(width, height));
682 println!(
683 "\x1b_Gf=100,s={},v={},{},a=T;{}\x1b\\",
684 width, height, spec, encoded
685 );
686 } else {
687 println!("\x1b_Gf=100,a=T;{}", encoded);
688 }
689}
690
691#[cfg(feature = "graphics")]
693pub fn print_sixel_logo(image_data: &[u8]) {
694 if let Ok(img) = image::load_from_memory(image_data) {
695 let fit = logo_cells_for(img.width(), img.height());
699 let (cell_w, cell_h) = terminal_cell_size_px();
700 let resized = img.resize(
701 (fit.cols * cell_w) as u32,
702 (fit.rows * cell_h) as u32,
703 image::imageops::FilterType::Triangle,
704 );
705 let rgba = resized.to_rgba8();
706 let (width, height) = rgba.dimensions();
707 print_sixel_rgba(rgba.as_raw(), width, height);
708 }
709}
710
711#[cfg(feature = "graphics")]
713pub fn print_sixel_rgba(rgba: &[u8], width: u32, height: u32) {
714 use icy_sixel::SixelImage;
715
716 match SixelImage::try_from_rgba(rgba.to_vec(), width as usize, height as usize) {
717 Ok(sixel_img) => match sixel_img.encode() {
718 Ok(sixel_str) => {
719 print!("{}", sixel_str);
720 }
721 Err(e) => eprintln!("[Sixel Encoding Error: {}]", e),
722 },
723 Err(e) => eprintln!("[Sixel Creation Error: {}]", e),
724 }
725}
726
727#[cfg(not(feature = "graphics"))]
729pub fn print_graphical_logo(_image_data: &[u8]) {
730 println!("[Graphical logo support requires --features graphics]");
731}
732
733#[cfg(not(feature = "graphics"))]
735pub fn print_sixel_logo(_image_data: &[u8]) {
736 println!("[Sixel logo support requires --features graphics]");
737}
738
739#[cfg(feature = "graphics")]
741pub fn print_graphical_logo_from_path(path: &std::path::Path) {
742 use image::ImageFormat;
743 match image::open(path) {
744 Ok(img) => {
745 let resized = img.resize(128, 128, image::imageops::FilterType::Lanczos3);
746 let mut png_data = Vec::new();
747 if resized
748 .write_to(&mut std::io::Cursor::new(&mut png_data), ImageFormat::Png)
749 .is_ok()
750 {
751 print_graphical_logo(&png_data);
752 } else {
753 println!("[Failed to encode logo as PNG]");
754 }
755 }
756 Err(_) => {
757 println!("[Could not load graphical logo from {}]", path.display());
758 }
759 }
760}
761
762#[cfg(feature = "graphics")]
764pub fn print_sixel_logo_from_path(path: &std::path::Path) {
765 match image::open(path) {
766 Ok(img) => {
767 let resized = img.resize(128, 128, image::imageops::FilterType::Lanczos3);
768 let rgba = resized.to_rgba8();
769 let (width, height) = rgba.dimensions();
770 print_sixel_rgba(rgba.as_raw(), width, height);
771 }
772 Err(_) => {
773 println!("[Could not load logo for Sixel from {}]", path.display());
774 }
775 }
776}
777
778#[cfg(test)]
779mod tests {
780 use super::*;
781
782 #[test]
783 fn known_logos_match_the_shipped_logo_files() {
784 let dir = std::path::Path::new(env!("CARGO_MANIFEST_DIR")).join("assets/logos");
787 let mut shipped: Vec<String> = std::fs::read_dir(&dir)
788 .expect("assets/logos exists")
789 .filter_map(|e| {
790 let name = e.ok()?.file_name().into_string().ok()?;
791 name.strip_suffix(".txt").map(str::to_string)
792 })
793 .filter(|id| id != "tux")
794 .collect();
795 shipped.sort();
796 let mut listed: Vec<String> = KNOWN_LOGOS.iter().map(|(id, _)| id.to_string()).collect();
797 listed.sort();
798 assert_eq!(listed, shipped, "KNOWN_LOGOS vs assets/logos/*.txt");
799 }
800
801 #[test]
802 fn every_known_logo_renders_its_own_art_not_tux() {
803 let tux = get_ascii_logo(None);
804 for (id, _) in KNOWN_LOGOS {
805 assert_ne!(get_ascii_logo(Some(id)), tux, "{id} falls back to Tux");
806 }
807 }
808
809 #[test]
810 fn test_get_ascii_logo_arch() {
811 let logo = get_ascii_logo(Some("arch"));
812 assert!(!logo.is_empty());
813 assert!(logo[0].contains("`"));
814 }
815
816 #[test]
817 fn test_get_ascii_logo_unknown() {
818 let logo = get_ascii_logo(Some("unknown_distro"));
819 assert!(!logo.is_empty());
820 assert!(logo
822 .iter()
823 .any(|line| line.contains("o${2}_${3}o") || line.contains("o_o")));
824 }
825
826 #[test]
827 fn test_get_ascii_logo_none() {
828 let logo = get_ascii_logo(None);
829 assert!(!logo.is_empty());
830 }
831
832 static ENV_LOCK: std::sync::Mutex<()> = std::sync::Mutex::new(());
833
834 struct EnvGuard {
835 _mutex_guard: std::sync::MutexGuard<'static, ()>,
836 old_vars: std::collections::HashMap<&'static str, Option<String>>,
837 }
838
839 impl EnvGuard {
840 fn new(vars_to_mock: &[&'static str]) -> Self {
841 let guard = ENV_LOCK.lock().unwrap();
842 let mut old_vars = std::collections::HashMap::new();
843 for var in vars_to_mock {
844 old_vars.insert(*var, std::env::var(var).ok());
845 }
846 EnvGuard {
847 _mutex_guard: guard,
848 old_vars,
849 }
850 }
851 }
852
853 impl Drop for EnvGuard {
854 fn drop(&mut self) {
855 for (var, value) in &self.old_vars {
856 if let Some(val) = value {
857 std::env::set_var(var, val);
858 } else {
859 std::env::remove_var(var);
860 }
861 }
862 }
863 }
864
865 #[test]
866 fn test_supports_kitty_heuristics() {
867 let _guard = EnvGuard::new(&["TERM", "TERMINAL_EMULATOR", "TERM_PROGRAM"]);
868
869 std::env::set_var("TERM", "xterm-kitty");
871 std::env::remove_var("TERMINAL_EMULATOR");
872 std::env::remove_var("TERM_PROGRAM");
873 assert!(supports_kitty());
874
875 std::env::remove_var("TERM");
877 std::env::set_var("TERMINAL_EMULATOR", "iterm-kitty");
878 assert!(supports_kitty());
879
880 std::env::set_var("TERMINAL_EMULATOR", "iTerm.app");
882 assert!(supports_kitty());
883
884 std::env::remove_var("TERMINAL_EMULATOR");
886 std::env::set_var("TERM_PROGRAM", "rio");
887 assert!(supports_kitty());
888
889 std::env::remove_var("TERM_PROGRAM");
891 assert!(!supports_kitty());
892 }
893
894 #[test]
895 fn test_supports_iterm2_heuristics() {
896 let _guard = EnvGuard::new(&["TERM", "TERM_PROGRAM"]);
900 std::env::remove_var("TERM");
901
902 std::env::set_var("TERM_PROGRAM", "iTerm.app");
904 assert!(supports_iterm2());
905
906 std::env::set_var("TERM_PROGRAM", "WezTerm");
908 assert!(supports_iterm2());
909
910 std::env::set_var("TERM_PROGRAM", "rio");
912 assert!(supports_iterm2());
913
914 std::env::set_var("TERM_PROGRAM", "Apple_Terminal");
916 assert!(!supports_iterm2());
917
918 std::env::remove_var("TERM_PROGRAM");
920 assert!(!supports_iterm2());
921 }
922
923 #[test]
924 fn test_supports_sixel_heuristics() {
925 let _guard = EnvGuard::new(&["TERM", "TERM_PROGRAM", "WT_SESSION"]);
926
927 std::env::remove_var("TERM");
929 std::env::remove_var("TERM_PROGRAM");
930 std::env::remove_var("WT_SESSION");
931 assert!(!supports_sixel());
932
933 std::env::set_var("TERM", "xterm-sixel");
935 assert!(supports_sixel());
936
937 std::env::set_var("TERM", "foot");
939 assert!(supports_sixel());
940
941 std::env::set_var("TERM", "MLTerm");
943 assert!(supports_sixel());
944
945 std::env::remove_var("TERM");
947 std::env::set_var("TERM_PROGRAM", "WezTerm");
948 assert!(supports_sixel());
949
950 std::env::set_var("TERM_PROGRAM", "iTerm.app");
952 assert!(supports_sixel());
953
954 std::env::set_var("TERM_PROGRAM", "rio");
956 assert!(supports_sixel());
957
958 std::env::remove_var("TERM_PROGRAM");
960 std::env::set_var("WT_SESSION", "active");
961 assert!(supports_sixel());
962 }
963
964 #[test]
965 fn test_get_embedded_logo() {
966 let logo = get_embedded_logo(Some("arch"));
967 assert!(logo.is_some());
968 let logo = get_embedded_logo(Some("pop"));
969 assert!(logo.is_some());
970 let logo = get_embedded_logo(Some("manjaro"));
971 assert!(logo.is_some());
972 let logo = get_embedded_logo(Some("endeavouros"));
973 assert!(logo.is_some());
974 let logo = get_embedded_logo(Some("opensuse"));
975 assert!(logo.is_some());
976 let logo = get_embedded_logo(Some("opensuse-leap"));
977 assert!(logo.is_some());
978 let logo = get_embedded_logo(Some("opensuse-tumbleweed"));
979 assert!(logo.is_some());
980 let logo = get_embedded_logo(Some("mx"));
981 assert!(logo.is_some());
982 let logo = get_embedded_logo(Some("linuxmint"));
983 assert!(logo.is_some());
984 let logo = get_embedded_logo(Some("kali"));
985 assert!(logo.is_some());
986 let logo = get_embedded_logo(Some("zorin"));
987 assert!(logo.is_some());
988 let logo = get_embedded_logo(Some("garuda"));
989 assert!(logo.is_some());
990 let logo = get_embedded_logo(Some("macos"));
991 assert!(logo.is_some());
992 let logo = get_embedded_logo(Some("windows"));
993 assert!(logo.is_some());
994 let logo = get_embedded_logo(None);
995 assert!(logo.is_some());
996 }
997
998 #[test]
999 fn test_get_ascii_logo_new_distros() {
1000 let pop = get_ascii_logo(Some("pop"));
1001 assert!(!pop.is_empty());
1002 assert!(pop.iter().any(|line| line.contains("767")));
1003
1004 let manjaro = get_ascii_logo(Some("manjaro"));
1005 assert!(!manjaro.is_empty());
1006 assert!(manjaro.iter().any(|line| line.contains("████████")));
1007
1008 let endeavouros = get_ascii_logo(Some("endeavouros"));
1009 assert!(!endeavouros.is_empty());
1010 assert!(endeavouros.iter().any(|line| line.contains("ssso")));
1011
1012 let opensuse = get_ascii_logo(Some("opensuse"));
1013 assert!(!opensuse.is_empty());
1014 assert!(opensuse.iter().any(|line| line.contains("O0000Ok")));
1015
1016 let macos = get_ascii_logo(Some("macos"));
1017 assert!(!macos.is_empty());
1018 assert!(macos
1019 .iter()
1020 .any(|line| line.contains("cKMMMMMMMMMMNWMMMMMMMMMM0")));
1021
1022 let windows = get_ascii_logo(Some("windows"));
1023 assert!(!windows.is_empty());
1024 assert!(windows
1025 .iter()
1026 .any(|line| line.contains("AEEEtttt::::ztF") || line.contains("tt:::tt333EE3")));
1027
1028 let mx = get_ascii_logo(Some("mx"));
1029 assert!(!mx.is_empty());
1030 assert!(mx
1031 .iter()
1032 .any(|line| line.contains("MMMMMMMMMMMMMMMMMMMMMMMMMMMMMMNMMMMMMMMM")));
1033
1034 let linuxmint = get_ascii_logo(Some("linuxmint"));
1035 assert!(!linuxmint.is_empty());
1036 assert!(linuxmint.iter().any(|line| line.contains("oOOOOOOOOOOo")));
1037
1038 let kali = get_ascii_logo(Some("kali"));
1039 assert!(!kali.is_empty());
1040 assert!(kali.iter().any(|line| line.contains(":ccc")));
1041
1042 let zorin = get_ascii_logo(Some("zorin"));
1043 assert!(!zorin.is_empty());
1044 assert!(zorin
1045 .iter()
1046 .any(|line| line.contains("osssssssssssssssssssso")));
1047
1048 let garuda = get_ascii_logo(Some("garuda"));
1049 assert!(!garuda.is_empty());
1050 assert!(garuda.iter().any(|line| line.contains("888:8898898")));
1051 }
1052
1053 #[test]
1056 fn test_rio_detected_from_term_when_term_program_is_absent() {
1057 let _guard = EnvGuard::new(&["TERM", "TERMINAL_EMULATOR", "TERM_PROGRAM"]);
1060 std::env::remove_var("TERM_PROGRAM");
1061 std::env::remove_var("TERMINAL_EMULATOR");
1062 std::env::set_var("TERM", "xterm-rio");
1063
1064 assert!(is_rio_terminal());
1065 assert!(supports_kitty());
1066 assert!(supports_iterm2());
1067 assert!(supports_sixel());
1068 }
1069
1070 #[test]
1071 fn test_rio_still_detected_from_term_program() {
1072 let _guard = EnvGuard::new(&["TERM", "TERMINAL_EMULATOR", "TERM_PROGRAM"]);
1074 std::env::remove_var("TERMINAL_EMULATOR");
1075 std::env::set_var("TERM", "xterm-256color");
1076 std::env::set_var("TERM_PROGRAM", "rio");
1077
1078 assert!(is_rio_terminal());
1079 assert!(supports_kitty());
1080 }
1081
1082 #[test]
1083 fn test_non_rio_term_is_not_matched() {
1084 let _guard = EnvGuard::new(&["TERM", "TERMINAL_EMULATOR", "TERM_PROGRAM"]);
1086 std::env::remove_var("TERM_PROGRAM");
1087 std::env::remove_var("TERMINAL_EMULATOR");
1088 for term in ["xterm-256color", "screen", "linux", "rioja"] {
1089 std::env::set_var("TERM", term);
1090 assert!(!is_rio_terminal(), "{term} should not be detected as Rio");
1091 }
1092 }
1093
1094 #[test]
1097 fn test_fit_logo_cells_preserves_aspect_for_wide_image() {
1098 let fit = fit_logo_cells(384, 108, 10, 20, 45, 10);
1102 assert_eq!(fit.cols, 45);
1103 assert_eq!(fit.rows, 7);
1104 assert!(fit.width_limited);
1105 assert_eq!(kitty_placement_spec(fit), "c=45");
1107 }
1108
1109 #[test]
1110 fn test_fit_logo_cells_preserves_aspect_for_tall_image() {
1111 let fit = fit_logo_cells(291, 384, 10, 20, 45, 10);
1113 assert_eq!(fit.rows, 10);
1114 assert!(
1115 fit.cols < 45,
1116 "tall image should not fill the width, got {}",
1117 fit.cols
1118 );
1119 assert!(!fit.width_limited);
1120 assert_eq!(kitty_placement_spec(fit), "r=10");
1121 }
1122
1123 #[test]
1124 fn test_fit_logo_cells_accounts_for_non_square_cells() {
1125 let fit = fit_logo_cells(256, 256, 10, 20, 45, 10);
1128 assert_eq!((fit.cols, fit.rows), (20, 10));
1129 let sq = fit_logo_cells(256, 256, 10, 10, 45, 10);
1130 assert_eq!((sq.cols, sq.rows), (10, 10));
1131 }
1132
1133 #[test]
1134 fn test_fit_logo_cells_never_exceeds_the_box() {
1135 for (w, h) in [(384, 108), (291, 384), (256, 256), (4000, 3), (3, 4000)] {
1137 let fit = fit_logo_cells(w, h, 10, 20, 45, 10);
1138 assert!((1..=45).contains(&fit.cols), "{w}x{h} -> {} cols", fit.cols);
1139 assert!((1..=10).contains(&fit.rows), "{w}x{h} -> {} rows", fit.rows);
1140 }
1141 }
1142
1143 const SHIPPED_ASSET_DIMENSIONS: &[(u32, u32)] = &[
1149 (384, 117), (291, 384), (384, 384), (384, 108), (256, 256), (256, 150), (313, 384), (256, 232), (384, 121), (384, 163), (384, 135), (256, 222), ];
1162
1163 #[test]
1164 fn test_fit_logo_cells_reservation_is_never_smaller_than_the_drawn_image() {
1165 for &(w, h) in SHIPPED_ASSET_DIMENSIONS {
1174 for (cell_w, cell_h) in [(10usize, 20usize), (7, 15), (22, 51), (9, 18)] {
1175 let fit = fit_logo_cells(w, h, cell_w, cell_h, LOGO_MAX_COLS, LOGO_MAX_ROWS);
1176 let (box_w, box_h) = (
1177 (LOGO_MAX_COLS * cell_w) as u64,
1178 (LOGO_MAX_ROWS * cell_h) as u64,
1179 );
1180 let (w, h) = (u64::from(w), u64::from(h));
1181 let (res_w, res_h) = ((fit.cols * cell_w) as u64, (fit.rows * cell_h) as u64);
1182 if fit.width_limited {
1183 assert!(
1185 res_w >= box_w,
1186 "{w}x{h} cells {cell_w}x{cell_h}: width short"
1187 );
1188 assert!(
1192 res_h * w >= box_w * h,
1193 "{w}x{h} cells {cell_w}x{cell_h}: reserved {res_h}px < drawn {:.2}px",
1194 (box_w * h) as f64 / w as f64
1195 );
1196 } else {
1197 assert!(
1199 res_h >= box_h,
1200 "{w}x{h} cells {cell_w}x{cell_h}: height short"
1201 );
1202 assert!(
1203 res_w * h >= box_h * w,
1204 "{w}x{h} cells {cell_w}x{cell_h}: reserved {res_w}px < drawn {:.2}px",
1205 (box_h * w) as f64 / h as f64
1206 );
1207 }
1208 }
1209 }
1210 }
1211
1212 #[test]
1213 fn test_fit_logo_cells_handles_degenerate_input() {
1214 for fit in [
1217 fit_logo_cells(0, 0, 10, 20, 45, 10),
1218 fit_logo_cells(384, 108, 0, 20, 45, 10),
1219 fit_logo_cells(384, 108, 10, 0, 45, 10),
1220 ] {
1221 assert_eq!((fit.cols, fit.rows), (45, 10));
1222 }
1223 }
1224
1225 #[test]
1226 fn test_chafa_size_arg_matches_the_shared_box() {
1227 assert_eq!(chafa_size_arg(), format!("{LOGO_MAX_COLS}x{LOGO_MAX_ROWS}"));
1229 assert_eq!(chafa_size_arg(), "45x10");
1230 }
1231}