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(user) = &info.current_user {
122        print_line("User", user);
123    }
124    print_line("Arch", &info.arch);
125    print_line("CPU", &format!("{} ({})", info.cpu, info.cpu_core_info));
126    if let Some(freq) = &info.cpu_freq {
127        print_line("CPU Freq", freq);
128    }
129    if let Some(cache) = &info.cpu_cache {
130        print_line("CPU Cache", cache);
131    }
132    if let Some(usage) = &info.cpu_usage {
133        print_line("CPU Usage", usage);
134    }
135    if let Some(motherboard) = &info.motherboard {
136        print_line("Motherboard", motherboard);
137    }
138    if let Some(bios) = &info.bios {
139        print_line("BIOS", bios);
140    }
141    if should_show("GPU") {
142        for gpu in &info.gpu {
143            print_line("GPU", gpu);
144        }
145    }
146    if should_show("Display") {
147        for display in &info.displays {
148            print_line("Display", display);
149        }
150    }
151    if let Some(audio) = &info.audio {
152        print_line("Audio", audio);
153    }
154    if should_show("Camera") {
155        for cam in &info.camera {
156            print_line("Camera", cam);
157        }
158    }
159    if should_show("Gamepad") {
160        for gp in &info.gamepad {
161            print_line("Gamepad", gp);
162        }
163    }
164    print_line("Memory", &info.memory);
165    if let Some(phys_mem) = &info.physical_memory {
166        print_line("Phys Mem", phys_mem);
167    }
168    print_line("Swap", &info.swap);
169    print_line("Procs", &info.processes.to_string());
170    if let Some(load) = &info.load_avg {
171        print_line("Load", load);
172    }
173
174    if should_show("Disk") {
175        for disk in &info.disks {
176            print_line("Disk", disk);
177        }
178    }
179
180    if should_show("Phys Disk") {
181        for disk in &info.physical_disks {
182            print_line("Phys Disk", disk);
183        }
184    }
185
186    if should_show("Temp") {
187        for temp in &info.temps {
188            print_line("Temp", temp);
189        }
190    }
191
192    if should_show("Net") {
193        if cli.long {
194            for net in &info.networks {
195                if let Some(ref active) = info.active_interface {
196                    if net.contains(active) {
197                        print_line("Net", &net.bright_blue().to_string());
198                    }
199                }
200            }
201            for net in &info.networks {
202                if let Some(ref active) = info.active_interface {
203                    if net.contains(active) {
204                        continue;
205                    }
206                }
207                print_line("Net", net);
208            }
209        } else {
210            let mut printed = false;
211            if let Some(ref active) = info.active_interface {
212                for net in &info.networks {
213                    if net.contains(active) {
214                        print_line("Net", net);
215                        printed = true;
216                        break;
217                    }
218                }
219            }
220            if !printed {
221                for net in &info.networks {
222                    if net.contains("[Up]") {
223                        print_line("Net", net);
224                        break;
225                    }
226                }
227            }
228        }
229    }
230
231    if let Some(ip) = &info.public_ip {
232        print_line("Public IP", ip);
233    }
234
235    if let Some(wifi) = &info.wifi {
236        print_line("Wi-Fi", wifi);
237    }
238
239    if let Some(bt) = &info.bluetooth {
240        print_line("Bluetooth", bt);
241    }
242
243    // Uptime: human duration first, then ISO boot time with timezone
244    let uptime_str = format_uptime(&info.uptime);
245    let boot_display = format!("{} since {}", uptime_str, info.boot_time);
246    print_line("Uptime", &boot_display);
247
248    if let Some(bat) = &info.battery {
249        print_line("Battery", bat);
250    }
251
252    if let Some(shell) = &info.shell {
253        print_line("Shell", shell);
254    }
255    if let Some(term) = &info.terminal {
256        print_line("Terminal", term);
257    }
258    if let Some(de) = &info.desktop {
259        print_line("Desktop", de);
260    }
261    if let Some(ui_theme) = &info.ui_theme {
262        print_line("Theme", ui_theme);
263    }
264    if let Some(icons) = &info.icons {
265        print_line("Icons", icons);
266    }
267    if let Some(cursor) = &info.cursor {
268        print_line("Cursor", cursor);
269    }
270    if let Some(font) = &info.font {
271        print_line("Font", font);
272    }
273    if let Some(term_font) = &info.terminal_font {
274        print_line("Terminal Font", term_font);
275    }
276    print_line("Users", &info.users.to_string());
277    if let Some(pkgs) = info.packages {
278        if pkgs > 0 {
279            print_line("Packages", &pkgs.to_string());
280        }
281    }
282
283    // Setup logo representation
284    enum ActiveLogo {
285        Lines(Vec<String>),
286        Kitty(Vec<u8>),
287        Iterm2(Vec<u8>),
288        Sixel(Vec<u8>),
289        None,
290    }
291
292    let mut active_logo = ActiveLogo::None;
293
294    if show_logo {
295        let distro_hint = _config.logo.clone().or_else(logo::detect_distro);
296        let user_logo = if let Some(config_dir) = dirs::config_dir() {
297            let p = config_dir.join("retch").join("logo.png");
298            if p.exists() {
299                Some(p)
300            } else {
301                None
302            }
303        } else {
304            None
305        };
306
307        if cli.ascii_logo {
308            active_logo = ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
309        } else if _config.chafa.unwrap_or(false) || cli.chafa_logo {
310            let mut resolved = false;
311            if logo::chafa_available() {
312                if let Some(path) = &user_logo {
313                    if let Some(lines) = logo::get_chafa_logo_lines(path) {
314                        active_logo = ActiveLogo::Lines(lines);
315                        resolved = true;
316                    }
317                } else if let Some(distro) = &distro_hint {
318                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
319                        let temp_path = std::env::temp_dir()
320                            .join(format!("retch_logo_{}.png", std::process::id()));
321                        if std::fs::write(&temp_path, bytes).is_ok() {
322                            if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
323                                active_logo = ActiveLogo::Lines(lines);
324                                resolved = true;
325                            }
326                            let _ = std::fs::remove_file(&temp_path);
327                        }
328                    }
329                }
330            }
331            if !resolved {
332                active_logo =
333                    ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
334            }
335        } else {
336            let mut resolved = false;
337
338            // Kitty
339            #[cfg(feature = "graphics")]
340            if !resolved && logo::supports_kitty() {
341                if let Some(path) = &user_logo {
342                    if let Ok(bytes) = std::fs::read(path) {
343                        active_logo = ActiveLogo::Kitty(bytes);
344                        resolved = true;
345                    }
346                } else if let Some(distro) = &distro_hint {
347                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
348                        active_logo = ActiveLogo::Kitty(bytes.to_vec());
349                        resolved = true;
350                    }
351                }
352            }
353
354            // iTerm2
355            #[cfg(feature = "graphics")]
356            if !resolved && logo::supports_iterm2() {
357                if let Some(path) = &user_logo {
358                    if let Ok(bytes) = std::fs::read(path) {
359                        active_logo = ActiveLogo::Iterm2(bytes);
360                        resolved = true;
361                    }
362                } else if let Some(distro) = &distro_hint {
363                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
364                        active_logo = ActiveLogo::Iterm2(bytes.to_vec());
365                        resolved = true;
366                    }
367                }
368            }
369
370            // Sixel
371            #[cfg(feature = "graphics")]
372            if !resolved && logo::supports_sixel() {
373                if let Some(path) = &user_logo {
374                    if let Ok(bytes) = std::fs::read(path) {
375                        active_logo = ActiveLogo::Sixel(bytes);
376                        resolved = true;
377                    }
378                } else if let Some(distro) = &distro_hint {
379                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
380                        active_logo = ActiveLogo::Sixel(bytes.to_vec());
381                        resolved = true;
382                    }
383                }
384            }
385
386            // Chafa
387            if !resolved && logo::chafa_available() {
388                if let Some(path) = &user_logo {
389                    if let Some(lines) = logo::get_chafa_logo_lines(path) {
390                        active_logo = ActiveLogo::Lines(lines);
391                        resolved = true;
392                    }
393                } else if let Some(distro) = &distro_hint {
394                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
395                        // Write temp logo and read lines via chafa
396                        let temp_path = std::env::temp_dir()
397                            .join(format!("retch_logo_{}.png", std::process::id()));
398                        if std::fs::write(&temp_path, bytes).is_ok() {
399                            if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
400                                active_logo = ActiveLogo::Lines(lines);
401                                resolved = true;
402                            }
403                            let _ = std::fs::remove_file(&temp_path);
404                        }
405                    }
406                }
407            }
408
409            // Fallback to ASCII lines
410            if !resolved {
411                active_logo =
412                    ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
413            }
414        }
415    }
416
417    // Helper to strip ANSI codes and calculate visible length
418    let visible_len = |s: &str| -> usize {
419        let mut count = 0;
420        let mut in_esc = false;
421        for c in s.chars() {
422            if c == '\x1b' {
423                in_esc = true;
424            } else if in_esc {
425                if c.is_ascii_alphabetic() {
426                    in_esc = false;
427                }
428            } else {
429                count += 1;
430            }
431        }
432        count
433    };
434
435    let max_text_width = info_lines
436        .iter()
437        .map(|line| visible_len(line))
438        .max()
439        .unwrap_or(0);
440    let text_column_width = std::cmp::max(max_text_width + 4, 45);
441
442    let max_logo_width = match &active_logo {
443        ActiveLogo::Lines(logo_lines) => logo_lines
444            .iter()
445            .map(|line| visible_len(line))
446            .max()
447            .unwrap_or(0),
448        ActiveLogo::Kitty(_) | ActiveLogo::Iterm2(_) | ActiveLogo::Sixel(_) => 40,
449        ActiveLogo::None => 0,
450    };
451
452    let side_by_side =
453        show_logo && term_width >= 95 && term_width >= (text_column_width + max_logo_width);
454
455    println!(); // leading newline
456
457    if side_by_side {
458        match active_logo {
459            ActiveLogo::Lines(logo_lines) => {
460                let max_lines = std::cmp::max(info_lines.len(), logo_lines.len());
461                for i in 0..max_lines {
462                    let info_line = info_lines.get(i).cloned().unwrap_or_default();
463                    let logo_line = logo_lines.get(i).cloned().unwrap_or_default();
464                    let vis_len = visible_len(&info_line);
465                    let padding = if vis_len < text_column_width {
466                        " ".repeat(text_column_width - vis_len)
467                    } else {
468                        String::new()
469                    };
470                    println!("{}{}{}", info_line, padding, logo_line);
471                }
472            }
473            ActiveLogo::Kitty(bytes) => {
474                // Print text lines
475                for line in &info_lines {
476                    println!("{}", line);
477                }
478                // Position and render
479                let num_lines = info_lines.len();
480                print!("\x1b7"); // DEC save cursor
481                if num_lines > 0 {
482                    print!("\x1b[{}A", num_lines); // Move up
483                }
484                print!("\x1b[{}C", text_column_width); // Move right
485                logo::print_graphical_logo(&bytes);
486                print!("\x1b8"); // DEC restore cursor
487            }
488            ActiveLogo::Iterm2(bytes) => {
489                // Print text lines
490                for line in &info_lines {
491                    println!("{}", line);
492                }
493                // Position and render
494                let num_lines = info_lines.len();
495                print!("\x1b7"); // DEC save cursor
496                if num_lines > 0 {
497                    print!("\x1b[{}A", num_lines); // Move up
498                }
499                print!("\x1b[{}C", text_column_width); // Move right
500                logo::print_iterm2_logo(&bytes);
501                print!("\x1b8"); // DEC restore cursor
502            }
503            ActiveLogo::Sixel(bytes) => {
504                // Print text lines
505                for line in &info_lines {
506                    println!("{}", line);
507                }
508                // Position and render
509                let num_lines = info_lines.len();
510                print!("\x1b7"); // DEC save cursor
511                if num_lines > 0 {
512                    print!("\x1b[{}A", num_lines); // Move up
513                }
514                print!("\x1b[{}C", text_column_width); // Move right
515                logo::print_sixel_logo(&bytes);
516                print!("\x1b8"); // DEC restore cursor
517            }
518            ActiveLogo::None => {
519                for line in &info_lines {
520                    println!("{}", line);
521                }
522            }
523        }
524    } else {
525        // Narrow or no-logo fallback: print logo, then print data
526        match active_logo {
527            ActiveLogo::Lines(logo_lines) => {
528                for line in logo_lines {
529                    println!("{}", line);
530                }
531                println!();
532            }
533            ActiveLogo::Kitty(bytes) => {
534                logo::print_graphical_logo(&bytes);
535                println!();
536            }
537            ActiveLogo::Iterm2(bytes) => {
538                logo::print_iterm2_logo(&bytes);
539                println!();
540            }
541            ActiveLogo::Sixel(bytes) => {
542                logo::print_sixel_logo(&bytes);
543                println!();
544            }
545            ActiveLogo::None => {}
546        }
547        for line in &info_lines {
548            println!("{}", line);
549        }
550    }
551
552    Ok(())
553}
554
555/// Formats a raw uptime string (in seconds) into a human-readable duration.
556///
557/// Example: "45224s" -> "12h 33m 44s"
558fn format_uptime(uptime: &str) -> String {
559    // Parse the uptime string (e.g. "45224s")
560    let seconds: u64 = uptime.trim_end_matches('s').parse().unwrap_or(0);
561
562    let years = seconds / (365 * 24 * 3600);
563    let days = (seconds % (365 * 24 * 3600)) / (24 * 3600);
564    let hours = (seconds % (24 * 3600)) / 3600;
565    let minutes = (seconds % 3600) / 60;
566    let secs = seconds % 60;
567
568    let mut parts = Vec::new();
569    if years > 0 {
570        parts.push(format!("{}y", years));
571    }
572    if days > 0 {
573        parts.push(format!("{}d", days));
574    }
575    if hours > 0 {
576        parts.push(format!("{}h", hours));
577    }
578    if minutes > 0 {
579        parts.push(format!("{}m", minutes));
580    }
581    if secs > 0 || parts.is_empty() {
582        parts.push(format!("{}s", secs));
583    }
584
585    parts.join(" ")
586}
587
588#[cfg(test)]
589mod tests {
590    use super::*;
591
592    #[test]
593    fn test_format_uptime() {
594        assert_eq!(format_uptime("60s"), "1m");
595        assert_eq!(format_uptime("3600s"), "1h");
596        assert_eq!(format_uptime("3661s"), "1h 1m 1s");
597        assert_eq!(format_uptime("86400s"), "1d");
598        assert_eq!(format_uptime("90061s"), "1d 1h 1m 1s");
599        assert_eq!(format_uptime("31536000s"), "1y");
600        assert_eq!(format_uptime("31626061s"), "1y 1d 1h 1m 1s");
601        assert_eq!(format_uptime("0s"), "0s");
602    }
603}