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            "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    if let Some(term_font) = &info.terminal_font {
255        print_line("Terminal Font", term_font);
256    }
257    print_line("Users", &info.users.to_string());
258    if let Some(pkgs) = info.packages {
259        if pkgs > 0 {
260            print_line("Packages", &pkgs.to_string());
261        }
262    }
263
264    // Setup logo representation
265    enum ActiveLogo {
266        Lines(Vec<String>),
267        Kitty(Vec<u8>),
268        Iterm2(Vec<u8>),
269        Sixel(Vec<u8>),
270        None,
271    }
272
273    let mut active_logo = ActiveLogo::None;
274
275    if show_logo {
276        let distro_hint = _config.logo.clone().or_else(logo::detect_distro);
277        let user_logo = if let Some(config_dir) = dirs::config_dir() {
278            let p = config_dir.join("retch").join("logo.png");
279            if p.exists() {
280                Some(p)
281            } else {
282                None
283            }
284        } else {
285            None
286        };
287
288        if cli.ascii_logo {
289            active_logo = ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
290        } else if _config.chafa.unwrap_or(false) || cli.chafa_logo {
291            let mut resolved = false;
292            if logo::chafa_available() {
293                if let Some(path) = &user_logo {
294                    if let Some(lines) = logo::get_chafa_logo_lines(path) {
295                        active_logo = ActiveLogo::Lines(lines);
296                        resolved = true;
297                    }
298                } else if let Some(distro) = &distro_hint {
299                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
300                        let temp_path = std::env::temp_dir()
301                            .join(format!("retch_logo_{}.png", std::process::id()));
302                        if std::fs::write(&temp_path, bytes).is_ok() {
303                            if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
304                                active_logo = ActiveLogo::Lines(lines);
305                                resolved = true;
306                            }
307                            let _ = std::fs::remove_file(&temp_path);
308                        }
309                    }
310                }
311            }
312            if !resolved {
313                active_logo =
314                    ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
315            }
316        } else {
317            let mut resolved = false;
318
319            // Kitty
320            #[cfg(feature = "graphics")]
321            if !resolved && logo::supports_kitty() {
322                if let Some(path) = &user_logo {
323                    if let Ok(bytes) = std::fs::read(path) {
324                        active_logo = ActiveLogo::Kitty(bytes);
325                        resolved = true;
326                    }
327                } else if let Some(distro) = &distro_hint {
328                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
329                        active_logo = ActiveLogo::Kitty(bytes.to_vec());
330                        resolved = true;
331                    }
332                }
333            }
334
335            // iTerm2
336            #[cfg(feature = "graphics")]
337            if !resolved && logo::supports_iterm2() {
338                if let Some(path) = &user_logo {
339                    if let Ok(bytes) = std::fs::read(path) {
340                        active_logo = ActiveLogo::Iterm2(bytes);
341                        resolved = true;
342                    }
343                } else if let Some(distro) = &distro_hint {
344                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
345                        active_logo = ActiveLogo::Iterm2(bytes.to_vec());
346                        resolved = true;
347                    }
348                }
349            }
350
351            // Sixel
352            #[cfg(feature = "graphics")]
353            if !resolved && logo::supports_sixel() {
354                if let Some(path) = &user_logo {
355                    if let Ok(bytes) = std::fs::read(path) {
356                        active_logo = ActiveLogo::Sixel(bytes);
357                        resolved = true;
358                    }
359                } else if let Some(distro) = &distro_hint {
360                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
361                        active_logo = ActiveLogo::Sixel(bytes.to_vec());
362                        resolved = true;
363                    }
364                }
365            }
366
367            // Chafa
368            if !resolved && logo::chafa_available() {
369                if let Some(path) = &user_logo {
370                    if let Some(lines) = logo::get_chafa_logo_lines(path) {
371                        active_logo = ActiveLogo::Lines(lines);
372                        resolved = true;
373                    }
374                } else if let Some(distro) = &distro_hint {
375                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
376                        // Write temp logo and read lines via chafa
377                        let temp_path = std::env::temp_dir()
378                            .join(format!("retch_logo_{}.png", std::process::id()));
379                        if std::fs::write(&temp_path, bytes).is_ok() {
380                            if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
381                                active_logo = ActiveLogo::Lines(lines);
382                                resolved = true;
383                            }
384                            let _ = std::fs::remove_file(&temp_path);
385                        }
386                    }
387                }
388            }
389
390            // Fallback to ASCII lines
391            if !resolved {
392                active_logo =
393                    ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
394            }
395        }
396    }
397
398    // Helper to strip ANSI codes and calculate visible length
399    let visible_len = |s: &str| -> usize {
400        let mut count = 0;
401        let mut in_esc = false;
402        for c in s.chars() {
403            if c == '\x1b' {
404                in_esc = true;
405            } else if in_esc {
406                if c.is_ascii_alphabetic() {
407                    in_esc = false;
408                }
409            } else {
410                count += 1;
411            }
412        }
413        count
414    };
415
416    let max_text_width = info_lines
417        .iter()
418        .map(|line| visible_len(line))
419        .max()
420        .unwrap_or(0);
421    let text_column_width = std::cmp::max(max_text_width + 4, 45);
422
423    let max_logo_width = match &active_logo {
424        ActiveLogo::Lines(logo_lines) => logo_lines
425            .iter()
426            .map(|line| visible_len(line))
427            .max()
428            .unwrap_or(0),
429        ActiveLogo::Kitty(_) | ActiveLogo::Iterm2(_) | ActiveLogo::Sixel(_) => 40,
430        ActiveLogo::None => 0,
431    };
432
433    let side_by_side =
434        show_logo && term_width >= 95 && term_width >= (text_column_width + max_logo_width);
435
436    println!(); // leading newline
437
438    if side_by_side {
439        match active_logo {
440            ActiveLogo::Lines(logo_lines) => {
441                let max_lines = std::cmp::max(info_lines.len(), logo_lines.len());
442                for i in 0..max_lines {
443                    let info_line = info_lines.get(i).cloned().unwrap_or_default();
444                    let logo_line = logo_lines.get(i).cloned().unwrap_or_default();
445                    let vis_len = visible_len(&info_line);
446                    let padding = if vis_len < text_column_width {
447                        " ".repeat(text_column_width - vis_len)
448                    } else {
449                        String::new()
450                    };
451                    println!("{}{}{}", info_line, padding, logo_line);
452                }
453            }
454            ActiveLogo::Kitty(bytes) => {
455                // Print text lines
456                for line in &info_lines {
457                    println!("{}", line);
458                }
459                // Position and render
460                let num_lines = info_lines.len();
461                print!("\x1b7"); // DEC save cursor
462                if num_lines > 0 {
463                    print!("\x1b[{}A", num_lines); // Move up
464                }
465                print!("\x1b[{}C", text_column_width); // Move right
466                logo::print_graphical_logo(&bytes);
467                print!("\x1b8"); // DEC restore cursor
468            }
469            ActiveLogo::Iterm2(bytes) => {
470                // Print text lines
471                for line in &info_lines {
472                    println!("{}", line);
473                }
474                // Position and render
475                let num_lines = info_lines.len();
476                print!("\x1b7"); // DEC save cursor
477                if num_lines > 0 {
478                    print!("\x1b[{}A", num_lines); // Move up
479                }
480                print!("\x1b[{}C", text_column_width); // Move right
481                logo::print_iterm2_logo(&bytes);
482                print!("\x1b8"); // DEC restore cursor
483            }
484            ActiveLogo::Sixel(bytes) => {
485                // Print text lines
486                for line in &info_lines {
487                    println!("{}", line);
488                }
489                // Position and render
490                let num_lines = info_lines.len();
491                print!("\x1b7"); // DEC save cursor
492                if num_lines > 0 {
493                    print!("\x1b[{}A", num_lines); // Move up
494                }
495                print!("\x1b[{}C", text_column_width); // Move right
496                logo::print_sixel_logo(&bytes);
497                print!("\x1b8"); // DEC restore cursor
498            }
499            ActiveLogo::None => {
500                for line in &info_lines {
501                    println!("{}", line);
502                }
503            }
504        }
505    } else {
506        // Narrow or no-logo fallback: print logo, then print data
507        match active_logo {
508            ActiveLogo::Lines(logo_lines) => {
509                for line in logo_lines {
510                    println!("{}", line);
511                }
512                println!();
513            }
514            ActiveLogo::Kitty(bytes) => {
515                logo::print_graphical_logo(&bytes);
516                println!();
517            }
518            ActiveLogo::Iterm2(bytes) => {
519                logo::print_iterm2_logo(&bytes);
520                println!();
521            }
522            ActiveLogo::Sixel(bytes) => {
523                logo::print_sixel_logo(&bytes);
524                println!();
525            }
526            ActiveLogo::None => {}
527        }
528        for line in &info_lines {
529            println!("{}", line);
530        }
531    }
532
533    Ok(())
534}
535
536/// Formats a raw uptime string (in seconds) into a human-readable duration.
537///
538/// Example: "45224s" -> "12h 33m 44s"
539fn format_uptime(uptime: &str) -> String {
540    // Parse the uptime string (e.g. "45224s")
541    let seconds: u64 = uptime.trim_end_matches('s').parse().unwrap_or(0);
542
543    let years = seconds / (365 * 24 * 3600);
544    let days = (seconds % (365 * 24 * 3600)) / (24 * 3600);
545    let hours = (seconds % (24 * 3600)) / 3600;
546    let minutes = (seconds % 3600) / 60;
547    let secs = seconds % 60;
548
549    let mut parts = Vec::new();
550    if years > 0 {
551        parts.push(format!("{}y", years));
552    }
553    if days > 0 {
554        parts.push(format!("{}d", days));
555    }
556    if hours > 0 {
557        parts.push(format!("{}h", hours));
558    }
559    if minutes > 0 {
560        parts.push(format!("{}m", minutes));
561    }
562    if secs > 0 || parts.is_empty() {
563        parts.push(format!("{}s", secs));
564    }
565
566    parts.join(" ")
567}
568
569#[cfg(test)]
570mod tests {
571    use super::*;
572
573    #[test]
574    fn test_format_uptime() {
575        assert_eq!(format_uptime("60s"), "1m");
576        assert_eq!(format_uptime("3600s"), "1h");
577        assert_eq!(format_uptime("3661s"), "1h 1m 1s");
578        assert_eq!(format_uptime("86400s"), "1d");
579        assert_eq!(format_uptime("90061s"), "1d 1h 1m 1s");
580        assert_eq!(format_uptime("31536000s"), "1y");
581        assert_eq!(format_uptime("31626061s"), "1y 1d 1h 1m 1s");
582        assert_eq!(format_uptime("0s"), "0s");
583    }
584}