Skip to main content

retch_cli/
display.rs

1// SPDX-FileCopyrightText: 2026 Ken Tobias
2// SPDX-License-Identifier: GPL-3.0-or-later
3
4//! Formatting and display logic for terminal output.
5//!
6//! Handles text rendering, layout, and image/ASCII logo rendering.
7
8use crate::cli::Cli;
9use crate::config::Config;
10use crate::fetch::SystemInfo;
11use crate::logo;
12use crate::theme::Theme;
13use owo_colors::OwoColorize;
14
15/// Renders the collected system information to the terminal.
16///
17/// This function handles theme selection, logo rendering (including fallbacks
18/// between graphics, Chafa, and ASCII), and field filtering based on
19/// CLI flags and configuration.
20pub fn display(info: &SystemInfo, cli: &Cli, config: &Config) -> anyhow::Result<()> {
21    let _config = config;
22    let theme_name = _config.theme.as_deref().or(cli.theme.as_deref());
23    let mut theme = match theme_name {
24        Some(name) => Theme::from_name(name),
25        None => Theme::detect_system_theme(), // Default to system preference
26    };
27
28    // Apply custom theme overrides from config if present
29    if let Some(custom) = &_config.custom_theme {
30        theme = Theme::with_custom_overrides(theme, custom);
31    }
32
33    // Determine terminal width
34    let term_width = if let Some((terminal_size::Width(w), _)) = terminal_size::terminal_size() {
35        w as usize
36    } else {
37        80
38    };
39
40    let show_logo = _config.show_logo.unwrap_or(true) && !cli.no_logo;
41
42    // Determine which fields to show
43    let allowed_fields: Option<Vec<String>> = if cli.long {
44        None // show everything
45    } else if cli.short {
46        Some(vec![
47            "os".to_string(),
48            "kernel".to_string(),
49            "host".to_string(),
50            "cpu".to_string(),
51            "gpu".to_string(),
52            "memory".to_string(),
53            "disk".to_string(),
54            "net".to_string(),
55        ])
56    } else if let Some(fields) = &_config.fields {
57        Some(fields.iter().map(|s| s.to_lowercase()).collect())
58    } else {
59        // Default set
60        Some(vec![
61            "os".to_string(),
62            "kernel".to_string(),
63            "host".to_string(),
64            "cpu".to_string(),
65            "cpu-cache".to_string(),
66            "cpu-usage".to_string(),
67            "motherboard".to_string(),
68            "bios".to_string(),
69            "gpu".to_string(),
70            "display".to_string(),
71            "audio".to_string(),
72            "camera".to_string(),
73            "gamepad".to_string(),
74            "memory".to_string(),
75            "phys-mem".to_string(),
76            "swap".to_string(),
77            "load".to_string(),
78            "disk".to_string(),
79            "phys-disk".to_string(),
80            "net".to_string(),
81            "uptime".to_string(),
82        ])
83    };
84
85    let should_show = |label: &str| -> bool {
86        match &allowed_fields {
87            Some(fields) => {
88                let norm_label = label.to_lowercase().replace(['-', '_'], " ");
89                let norm_label_no_spaces = norm_label.replace(' ', "");
90                fields.iter().any(|f| {
91                    let norm_f = f.to_lowercase().replace(['-', '_'], " ");
92                    norm_f == norm_label || norm_f.replace(' ', "") == norm_label_no_spaces
93                })
94            }
95            None => true,
96        }
97    };
98
99    // Helper for right-aligned labels
100    let label_width = 10;
101    let mut info_lines = Vec::new();
102    let mut print_line = |label: &str, value: &str| {
103        if should_show(label) {
104            info_lines.push(format!(
105                "{:>width$}{} {}",
106                theme.color_label(label),
107                theme.color_separator(":"),
108                theme.color_value(value),
109                width = label_width
110            ));
111        }
112    };
113
114    print_line("OS", &info.os);
115    if let Some(kernel) = &info.kernel {
116        print_line("Kernel", kernel);
117    }
118    if let Some(host) = &info.hostname {
119        print_line("Host", host);
120    }
121    if let Some(chassis) = &info.chassis {
122        print_line("Chassis", chassis);
123    }
124    if let Some(init) = &info.init_system {
125        print_line("Init", init);
126    }
127    if let Some(locale) = &info.locale {
128        print_line("Locale", locale);
129    }
130    if let Some(user) = &info.current_user {
131        print_line("User", user);
132    }
133    print_line("Arch", &info.arch);
134    print_line("CPU", &format!("{} ({})", info.cpu, info.cpu_core_info));
135    if let Some(freq) = &info.cpu_freq {
136        print_line("CPU Freq", freq);
137    }
138    if let Some(cache) = &info.cpu_cache {
139        print_line("CPU Cache", cache);
140    }
141    if let Some(usage) = &info.cpu_usage {
142        print_line("CPU Usage", usage);
143    }
144    if let Some(motherboard) = &info.motherboard {
145        print_line("Motherboard", motherboard);
146    }
147    if let Some(bios) = &info.bios {
148        print_line("BIOS", bios);
149    }
150    if let Some(bootmgr) = &info.bootmgr {
151        print_line("Bootmgr", bootmgr);
152    }
153    if should_show("GPU") {
154        for gpu in &info.gpu {
155            print_line("GPU", gpu);
156        }
157    }
158    if should_show("Display") {
159        for display in &info.displays {
160            print_line("Display", display);
161        }
162    }
163    if let Some(audio) = &info.audio {
164        print_line("Audio", audio);
165    }
166    if should_show("Camera") {
167        for cam in &info.camera {
168            print_line("Camera", cam);
169        }
170    }
171    if should_show("Gamepad") {
172        for gp in &info.gamepad {
173            print_line("Gamepad", gp);
174        }
175    }
176    print_line("Memory", &info.memory);
177    if let Some(phys_mem) = &info.physical_memory {
178        print_line("Phys Mem", phys_mem);
179    }
180    print_line("Swap", &info.swap);
181    print_line("Procs", &info.processes.to_string());
182    if let Some(load) = &info.load_avg {
183        print_line("Load", load);
184    }
185
186    if should_show("Disk") {
187        for disk in &info.disks {
188            print_line("Disk", disk);
189        }
190    }
191
192    if should_show("Phys Disk") {
193        for disk in &info.physical_disks {
194            print_line("Phys Disk", disk);
195        }
196    }
197
198    if should_show("Temp") {
199        for temp in &info.temps {
200            print_line("Temp", temp);
201        }
202    }
203
204    if should_show("Net") {
205        if cli.long {
206            for net in &info.networks {
207                if let Some(ref active) = info.active_interface {
208                    if net.contains(active) {
209                        print_line("Net", &net.bright_blue().to_string());
210                    }
211                }
212            }
213            for net in &info.networks {
214                if let Some(ref active) = info.active_interface {
215                    if net.contains(active) {
216                        continue;
217                    }
218                }
219                print_line("Net", net);
220            }
221        } else {
222            let mut printed = false;
223            if let Some(ref active) = info.active_interface {
224                for net in &info.networks {
225                    if net.contains(active) {
226                        print_line("Net", net);
227                        printed = true;
228                        break;
229                    }
230                }
231            }
232            if !printed {
233                for net in &info.networks {
234                    if net.contains("[Up]") {
235                        print_line("Net", net);
236                        break;
237                    }
238                }
239            }
240        }
241    }
242
243    if let Some(ip) = &info.public_ip {
244        print_line("Public IP", ip);
245    }
246
247    if let Some(wifi) = &info.wifi {
248        print_line("Wi-Fi", wifi);
249    }
250
251    if let Some(bt) = &info.bluetooth {
252        print_line("Bluetooth", bt);
253    }
254
255    // Uptime: human duration first, then ISO boot time with timezone
256    let uptime_str = format_uptime(&info.uptime);
257    let boot_display = format!("{} since {}", uptime_str, info.boot_time);
258    print_line("Uptime", &boot_display);
259
260    if let Some(bat) = &info.battery {
261        print_line("Battery", bat);
262    }
263
264    if let Some(shell) = &info.shell {
265        print_line("Shell", shell);
266    }
267    if let Some(editor) = &info.editor {
268        print_line("Editor", editor);
269    }
270    if let Some(term) = &info.terminal {
271        print_line("Terminal", term);
272    }
273    if let Some(de) = &info.desktop {
274        print_line("Desktop", de);
275    }
276    if let Some(ui_theme) = &info.ui_theme {
277        print_line("Theme", ui_theme);
278    }
279    if let Some(icons) = &info.icons {
280        print_line("Icons", icons);
281    }
282    if let Some(cursor) = &info.cursor {
283        print_line("Cursor", cursor);
284    }
285    if let Some(font) = &info.font {
286        print_line("Font", font);
287    }
288    if let Some(term_font) = &info.terminal_font {
289        print_line("Terminal Font", term_font);
290    }
291    print_line("Users", &info.users.to_string());
292    if let Some(pkgs) = info.packages {
293        if pkgs > 0 {
294            print_line("Packages", &pkgs.to_string());
295        }
296    }
297    if let Some(weather) = &info.weather {
298        print_line("Weather", weather);
299    }
300
301    // Setup logo representation
302    enum ActiveLogo {
303        Lines(Vec<String>),
304        Kitty(Vec<u8>),
305        Iterm2(Vec<u8>),
306        Sixel(Vec<u8>),
307        None,
308    }
309
310    let mut active_logo = ActiveLogo::None;
311
312    if show_logo {
313        let distro_hint = _config.logo.clone().or_else(logo::detect_distro);
314        let user_logo = if let Some(config_dir) = dirs::config_dir() {
315            let p = config_dir.join("retch").join("logo.png");
316            if p.exists() {
317                Some(p)
318            } else {
319                None
320            }
321        } else {
322            None
323        };
324
325        if cli.ascii_logo {
326            active_logo = ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
327        } else if _config.chafa.unwrap_or(false) || cli.chafa_logo {
328            let mut resolved = false;
329            if logo::chafa_available() {
330                if let Some(path) = &user_logo {
331                    if let Some(lines) = logo::get_chafa_logo_lines(path) {
332                        active_logo = ActiveLogo::Lines(lines);
333                        resolved = true;
334                    }
335                } else if let Some(distro) = &distro_hint {
336                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
337                        let temp_path = std::env::temp_dir()
338                            .join(format!("retch_logo_{}.png", std::process::id()));
339                        if std::fs::write(&temp_path, bytes).is_ok() {
340                            if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
341                                active_logo = ActiveLogo::Lines(lines);
342                                resolved = true;
343                            }
344                            let _ = std::fs::remove_file(&temp_path);
345                        }
346                    }
347                }
348            }
349            if !resolved {
350                active_logo =
351                    ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
352            }
353        } else {
354            let mut resolved = false;
355
356            // Kitty
357            #[cfg(feature = "graphics")]
358            if !resolved && logo::supports_kitty() {
359                if let Some(path) = &user_logo {
360                    if let Ok(bytes) = std::fs::read(path) {
361                        active_logo = ActiveLogo::Kitty(bytes);
362                        resolved = true;
363                    }
364                } else if let Some(distro) = &distro_hint {
365                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
366                        active_logo = ActiveLogo::Kitty(bytes.to_vec());
367                        resolved = true;
368                    }
369                }
370            }
371
372            // iTerm2
373            #[cfg(feature = "graphics")]
374            if !resolved && logo::supports_iterm2() {
375                if let Some(path) = &user_logo {
376                    if let Ok(bytes) = std::fs::read(path) {
377                        active_logo = ActiveLogo::Iterm2(bytes);
378                        resolved = true;
379                    }
380                } else if let Some(distro) = &distro_hint {
381                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
382                        active_logo = ActiveLogo::Iterm2(bytes.to_vec());
383                        resolved = true;
384                    }
385                }
386            }
387
388            // Sixel
389            #[cfg(feature = "graphics")]
390            if !resolved && logo::supports_sixel() {
391                if let Some(path) = &user_logo {
392                    if let Ok(bytes) = std::fs::read(path) {
393                        active_logo = ActiveLogo::Sixel(bytes);
394                        resolved = true;
395                    }
396                } else if let Some(distro) = &distro_hint {
397                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
398                        active_logo = ActiveLogo::Sixel(bytes.to_vec());
399                        resolved = true;
400                    }
401                }
402            }
403
404            // Chafa
405            if !resolved && logo::chafa_available() {
406                if let Some(path) = &user_logo {
407                    if let Some(lines) = logo::get_chafa_logo_lines(path) {
408                        active_logo = ActiveLogo::Lines(lines);
409                        resolved = true;
410                    }
411                } else if let Some(distro) = &distro_hint {
412                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
413                        // Write temp logo and read lines via chafa
414                        let temp_path = std::env::temp_dir()
415                            .join(format!("retch_logo_{}.png", std::process::id()));
416                        if std::fs::write(&temp_path, bytes).is_ok() {
417                            if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
418                                active_logo = ActiveLogo::Lines(lines);
419                                resolved = true;
420                            }
421                            let _ = std::fs::remove_file(&temp_path);
422                        }
423                    }
424                }
425            }
426
427            // Fallback to ASCII lines
428            if !resolved {
429                active_logo =
430                    ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
431            }
432        }
433    }
434
435    // Helper to strip ANSI codes and calculate visible length
436    let visible_len = |s: &str| -> usize {
437        let mut count = 0;
438        let mut in_esc = false;
439        for c in s.chars() {
440            if c == '\x1b' {
441                in_esc = true;
442            } else if in_esc {
443                if c.is_ascii_alphabetic() {
444                    in_esc = false;
445                }
446            } else {
447                count += 1;
448            }
449        }
450        count
451    };
452
453    let max_text_width = info_lines
454        .iter()
455        .map(|line| visible_len(line))
456        .max()
457        .unwrap_or(0);
458    let text_column_width = std::cmp::max(max_text_width + 4, 45);
459
460    let max_logo_width = match &active_logo {
461        ActiveLogo::Lines(logo_lines) => logo_lines
462            .iter()
463            .map(|line| visible_len(line))
464            .max()
465            .unwrap_or(0),
466        ActiveLogo::Kitty(_) | ActiveLogo::Iterm2(_) | ActiveLogo::Sixel(_) => 40,
467        ActiveLogo::None => 0,
468    };
469
470    let side_by_side =
471        show_logo && term_width >= 95 && term_width >= (text_column_width + max_logo_width);
472
473    println!(); // leading newline
474
475    if side_by_side {
476        match active_logo {
477            ActiveLogo::Lines(logo_lines) => {
478                let max_lines = std::cmp::max(info_lines.len(), logo_lines.len());
479                for i in 0..max_lines {
480                    let info_line = info_lines.get(i).cloned().unwrap_or_default();
481                    let logo_line = logo_lines.get(i).cloned().unwrap_or_default();
482                    let vis_len = visible_len(&info_line);
483                    let padding = if vis_len < text_column_width {
484                        " ".repeat(text_column_width - vis_len)
485                    } else {
486                        String::new()
487                    };
488                    println!("{}{}{}", info_line, padding, logo_line);
489                }
490            }
491            ActiveLogo::Kitty(bytes) => {
492                // Print text lines
493                for line in &info_lines {
494                    println!("{}", line);
495                }
496                // Position and render
497                let num_lines = info_lines.len();
498                print!("\x1b7"); // DEC save cursor
499                if num_lines > 0 {
500                    print!("\x1b[{}A", num_lines); // Move up
501                }
502                print!("\x1b[{}C", text_column_width); // Move right
503                logo::print_graphical_logo(&bytes);
504                print!("\x1b8"); // DEC restore cursor
505            }
506            ActiveLogo::Iterm2(bytes) => {
507                // Print text lines
508                for line in &info_lines {
509                    println!("{}", line);
510                }
511                // Position and render
512                let num_lines = info_lines.len();
513                print!("\x1b7"); // DEC save cursor
514                if num_lines > 0 {
515                    print!("\x1b[{}A", num_lines); // Move up
516                }
517                print!("\x1b[{}C", text_column_width); // Move right
518                logo::print_iterm2_logo(&bytes);
519                print!("\x1b8"); // DEC restore cursor
520            }
521            ActiveLogo::Sixel(bytes) => {
522                // Print text lines
523                for line in &info_lines {
524                    println!("{}", line);
525                }
526                // Position and render
527                let num_lines = info_lines.len();
528                print!("\x1b7"); // DEC save cursor
529                if num_lines > 0 {
530                    print!("\x1b[{}A", num_lines); // Move up
531                }
532                print!("\x1b[{}C", text_column_width); // Move right
533                logo::print_sixel_logo(&bytes);
534                print!("\x1b8"); // DEC restore cursor
535            }
536            ActiveLogo::None => {
537                for line in &info_lines {
538                    println!("{}", line);
539                }
540            }
541        }
542    } else {
543        // Narrow or no-logo fallback: print logo, then print data
544        match active_logo {
545            ActiveLogo::Lines(logo_lines) => {
546                for line in logo_lines {
547                    println!("{}", line);
548                }
549                println!();
550            }
551            ActiveLogo::Kitty(bytes) => {
552                logo::print_graphical_logo(&bytes);
553                println!();
554            }
555            ActiveLogo::Iterm2(bytes) => {
556                logo::print_iterm2_logo(&bytes);
557                println!();
558            }
559            ActiveLogo::Sixel(bytes) => {
560                logo::print_sixel_logo(&bytes);
561                println!();
562            }
563            ActiveLogo::None => {}
564        }
565        for line in &info_lines {
566            println!("{}", line);
567        }
568    }
569
570    Ok(())
571}
572
573/// Formats a raw uptime string (in seconds) into a human-readable duration.
574///
575/// Example: "45224s" -> "12h 33m 44s"
576fn format_uptime(uptime: &str) -> String {
577    // Parse the uptime string (e.g. "45224s")
578    let seconds: u64 = uptime.trim_end_matches('s').parse().unwrap_or(0);
579
580    let years = seconds / (365 * 24 * 3600);
581    let days = (seconds % (365 * 24 * 3600)) / (24 * 3600);
582    let hours = (seconds % (24 * 3600)) / 3600;
583    let minutes = (seconds % 3600) / 60;
584    let secs = seconds % 60;
585
586    let mut parts = Vec::new();
587    if years > 0 {
588        parts.push(format!("{}y", years));
589    }
590    if days > 0 {
591        parts.push(format!("{}d", days));
592    }
593    if hours > 0 {
594        parts.push(format!("{}h", hours));
595    }
596    if minutes > 0 {
597        parts.push(format!("{}m", minutes));
598    }
599    if secs > 0 || parts.is_empty() {
600        parts.push(format!("{}s", secs));
601    }
602
603    parts.join(" ")
604}
605
606#[cfg(test)]
607mod tests {
608    use super::*;
609
610    #[test]
611    fn test_format_uptime() {
612        assert_eq!(format_uptime("60s"), "1m");
613        assert_eq!(format_uptime("3600s"), "1h");
614        assert_eq!(format_uptime("3661s"), "1h 1m 1s");
615        assert_eq!(format_uptime("86400s"), "1d");
616        assert_eq!(format_uptime("90061s"), "1d 1h 1m 1s");
617        assert_eq!(format_uptime("31536000s"), "1y");
618        assert_eq!(format_uptime("31626061s"), "1y 1d 1h 1m 1s");
619        assert_eq!(format_uptime("0s"), "0s");
620    }
621}