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