Skip to main content

retch_cli/
display.rs

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