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_size = terminal_size::terminal_size();
35    let term_width = if let Some((terminal_size::Width(w), _)) = term_size {
36        w as usize
37    } else {
38        80
39    };
40    // Use isatty() directly — terminal_size() can return Some() when a pager
41    // (e.g. bat) allocates a PTY, giving a false positive.
42    let stdout_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
43
44    let show_logo = _config.show_logo.unwrap_or(true) && !cli.no_logo && stdout_is_tty;
45
46    // Determine which fields to show
47    let allowed_fields: Option<Vec<String>> = if cli.full {
48        Some(vec![
49            // Standard fields
50            "os".to_string(),
51            "kernel".to_string(),
52            "host".to_string(),
53            "cpu".to_string(),
54            "cpu-cache".to_string(),
55            "cpu-usage".to_string(),
56            "motherboard".to_string(),
57            "gpu".to_string(),
58            "display".to_string(),
59            "audio".to_string(),
60            "camera".to_string(),
61            "memory".to_string(),
62            "phys-mem".to_string(),
63            "swap".to_string(),
64            "load".to_string(),
65            "disk".to_string(),
66            "phys-disk".to_string(),
67            "net".to_string(),
68            "uptime".to_string(),
69            // Long fields
70            "bios".to_string(),
71            "font".to_string(),
72            "shell".to_string(),
73            "editor".to_string(),
74            "terminal".to_string(),
75            "terminal-font".to_string(),
76            "terminal-size".to_string(),
77            "desktop".to_string(),
78            "wm".to_string(),
79            "dns".to_string(),
80            "domain".to_string(),
81            "wifi".to_string(),
82            "bluetooth".to_string(),
83            "battery".to_string(),
84            "public-ip".to_string(),
85            "locale".to_string(),
86            "init".to_string(),
87            "chassis".to_string(),
88            "bootmgr".to_string(),
89            "temp".to_string(),
90            "cpu-freq".to_string(),
91            "procs".to_string(),
92            "arch".to_string(),
93            "users".to_string(),
94            "packages".to_string(),
95            // Full-only fields
96            "theme".to_string(),
97            "icons".to_string(),
98            "cursor".to_string(),
99            "gamepad".to_string(),
100            "weather".to_string(),
101            "domain-search".to_string(),
102        ])
103    } else if cli.long {
104        Some(vec![
105            // Standard fields
106            "os".to_string(),
107            "kernel".to_string(),
108            "host".to_string(),
109            "cpu".to_string(),
110            "cpu-cache".to_string(),
111            "cpu-usage".to_string(),
112            "motherboard".to_string(),
113            "gpu".to_string(),
114            "display".to_string(),
115            "audio".to_string(),
116            "camera".to_string(),
117            "memory".to_string(),
118            "phys-mem".to_string(),
119            "swap".to_string(),
120            "load".to_string(),
121            "disk".to_string(),
122            "phys-disk".to_string(),
123            "net".to_string(),
124            "uptime".to_string(),
125            // Long-only fields
126            "bios".to_string(),
127            "font".to_string(),
128            "shell".to_string(),
129            "editor".to_string(),
130            "terminal".to_string(),
131            "terminal-font".to_string(),
132            "terminal-size".to_string(),
133            "desktop".to_string(),
134            "wm".to_string(),
135            "dns".to_string(),
136            "domain".to_string(),
137            "wifi".to_string(),
138            "bluetooth".to_string(),
139            "battery".to_string(),
140            "public-ip".to_string(),
141            "locale".to_string(),
142            "init".to_string(),
143            "chassis".to_string(),
144            "bootmgr".to_string(),
145            "temp".to_string(),
146            "cpu-freq".to_string(),
147            "procs".to_string(),
148            "arch".to_string(),
149            "users".to_string(),
150            "packages".to_string(),
151        ])
152    } else if cli.short {
153        Some(vec![
154            "os".to_string(),
155            "kernel".to_string(),
156            "host".to_string(),
157            "cpu".to_string(),
158            "gpu".to_string(),
159            "memory".to_string(),
160            "disk".to_string(),
161            "net".to_string(),
162        ])
163    } else if let Some(fields) = &_config.fields {
164        Some(fields.iter().map(|s| s.to_lowercase()).collect())
165    } else {
166        // Default (standard) set
167        Some(vec![
168            "os".to_string(),
169            "kernel".to_string(),
170            "host".to_string(),
171            "cpu".to_string(),
172            "cpu-cache".to_string(),
173            "cpu-usage".to_string(),
174            "motherboard".to_string(),
175            "gpu".to_string(),
176            "display".to_string(),
177            "audio".to_string(),
178            "camera".to_string(),
179            "memory".to_string(),
180            "phys-mem".to_string(),
181            "swap".to_string(),
182            "load".to_string(),
183            "disk".to_string(),
184            "phys-disk".to_string(),
185            "net".to_string(),
186            "uptime".to_string(),
187        ])
188    };
189
190    let should_show = |label: &str| -> bool {
191        match &allowed_fields {
192            Some(fields) => {
193                let norm_label = label.to_lowercase().replace(['-', '_'], " ");
194                let norm_label_no_spaces = norm_label.replace(' ', "");
195                fields.iter().any(|f| {
196                    let norm_f = f.to_lowercase().replace(['-', '_'], " ");
197                    norm_f == norm_label
198                        || norm_f.replace(' ', "") == norm_label_no_spaces
199                        // "dns" field key matches "DNS Server" display label
200                        || (norm_label == "dns server" && norm_f == "dns")
201                })
202            }
203            None => true,
204        }
205    };
206
207    // Helper for right-aligned labels
208    let label_width = 10;
209    let mut info_lines = Vec::new();
210    let mut print_line = |label: &str, value: &str| {
211        if should_show(label) {
212            info_lines.push(format!(
213                "{:>width$}{} {}",
214                theme.color_label(label),
215                theme.color_separator(":"),
216                theme.color_value(value),
217                width = label_width
218            ));
219        }
220    };
221
222    // OS / system identity
223    print_line("OS", &info.os);
224    if let Some(kernel) = &info.kernel {
225        print_line("Kernel", kernel);
226    }
227    if let Some(host) = &info.hostname {
228        print_line("Host", host);
229    }
230    if let Some(domain) = &info.domain {
231        print_line("Domain", domain);
232    }
233    if should_show("domain-search") {
234        for entry in &info.domain_search {
235            print_line("Domain Search", entry);
236        }
237    }
238    if let Some(chassis) = &info.chassis {
239        print_line("Chassis", chassis);
240    }
241    if let Some(init) = &info.init_system {
242        print_line("Init", init);
243    }
244    if let Some(locale) = &info.locale {
245        print_line("Locale", locale);
246    }
247    print_line("Arch", &info.arch);
248    print_line("Users", &info.users.to_string());
249    if let Some(pkgs) = info.packages {
250        if pkgs > 0 {
251            print_line("Packages", &pkgs.to_string());
252        }
253    }
254    if let Some(user) = &info.current_user {
255        print_line("User", user);
256    }
257    // Uptime belongs with system identity, not hardware
258    let uptime_str = format_uptime(&info.uptime);
259    let boot_display = format!("{} since {}", uptime_str, info.boot_time);
260    print_line("Uptime", &boot_display);
261
262    // Hardware
263    print_line("CPU", &format!("{} ({})", info.cpu, info.cpu_core_info));
264    if let Some(freq) = &info.cpu_freq {
265        print_line("CPU Freq", freq);
266    }
267    if let Some(cache) = &info.cpu_cache {
268        print_line("CPU Cache", cache);
269    }
270    if let Some(usage) = &info.cpu_usage {
271        print_line("CPU Usage", usage);
272    }
273    if let Some(motherboard) = &info.motherboard {
274        print_line("Motherboard", motherboard);
275    }
276    if let Some(bios) = &info.bios {
277        print_line("BIOS", bios);
278    }
279    if let Some(bootmgr) = &info.bootmgr {
280        print_line("Bootmgr", bootmgr);
281    }
282    if should_show("GPU") {
283        for gpu in &info.gpu {
284            print_line("GPU", gpu);
285        }
286    }
287    if should_show("Display") {
288        for display in &info.displays {
289            print_line("Display", display);
290        }
291    }
292    if let Some(audio) = &info.audio {
293        print_line("Audio", audio);
294    }
295    if should_show("Camera") {
296        for cam in &info.camera {
297            print_line("Camera", cam);
298        }
299    }
300    if should_show("Gamepad") {
301        for gp in &info.gamepad {
302            print_line("Gamepad", gp);
303        }
304    }
305    if let Some(wifi) = &info.wifi {
306        print_line("Wi-Fi", wifi);
307    }
308    if let Some(bt) = &info.bluetooth {
309        print_line("Bluetooth", bt);
310    }
311    if let Some(bat) = &info.battery {
312        print_line("Battery", bat);
313    }
314    print_line("Memory", &info.memory);
315    if let Some(phys_mem) = &info.physical_memory {
316        print_line("Phys Mem", phys_mem);
317    }
318    print_line("Swap", &info.swap);
319    print_line("Procs", &info.processes.to_string());
320    if let Some(load) = &info.load_avg {
321        print_line("Load", load);
322    }
323    if should_show("Disk") {
324        for disk in &info.disks {
325            print_line("Disk", disk);
326        }
327    }
328    if should_show("Phys Disk") {
329        for disk in &info.physical_disks {
330            print_line("Phys Disk", disk);
331        }
332    }
333    if should_show("Temp") {
334        if cli.full {
335            for temp in &info.temps {
336                print_line("Temp", temp);
337            }
338        } else {
339            for temp in consolidate_temps(&info.temps) {
340                print_line("Temp", &temp);
341            }
342        }
343    }
344
345    // Network
346    if should_show("Net") {
347        if cli.long || cli.full {
348            for net in &info.networks {
349                if let Some(ref active) = info.active_interface {
350                    if net.contains(active) {
351                        print_line("Net", &net.bright_blue().to_string());
352                    }
353                }
354            }
355            for net in &info.networks {
356                if let Some(ref active) = info.active_interface {
357                    if net.contains(active) {
358                        continue;
359                    }
360                }
361                print_line("Net", net);
362            }
363        } else {
364            let mut printed = false;
365            if let Some(ref active) = info.active_interface {
366                for net in &info.networks {
367                    if net.contains(active) {
368                        print_line("Net", net);
369                        printed = true;
370                        break;
371                    }
372                }
373            }
374            if !printed {
375                for net in &info.networks {
376                    if net.contains("[Up]") {
377                        print_line("Net", net);
378                        break;
379                    }
380                }
381            }
382        }
383    }
384    if let Some(ip) = &info.public_ip {
385        print_line("Public IP", ip);
386    }
387    if !info.dns.is_empty() {
388        print_line("DNS Server", &info.dns.join(", "));
389    }
390
391    // Environment
392    if let Some(shell) = &info.shell {
393        print_line("Shell", shell);
394    }
395    if let Some(editor) = &info.editor {
396        print_line("Editor", editor);
397    }
398    if let Some(term) = &info.terminal {
399        print_line("Terminal", term);
400    }
401    if let Some(ts) = &info.terminal_size {
402        print_line("Terminal Size", ts);
403    }
404    if let Some(de) = &info.desktop {
405        print_line("Desktop", de);
406    }
407    if let Some(wm) = &info.wm {
408        let duplicate = info
409            .desktop
410            .as_deref()
411            .map(|de| de.to_lowercase() == wm.to_lowercase())
412            .unwrap_or(false);
413        if !duplicate {
414            print_line("WM", wm);
415        }
416    }
417    if let Some(ui_theme) = &info.ui_theme {
418        print_line("Theme", ui_theme);
419    }
420    if let Some(icons) = &info.icons {
421        print_line("Icons", icons);
422    }
423    if let Some(cursor) = &info.cursor {
424        print_line("Cursor", cursor);
425    }
426    if let Some(font) = &info.font {
427        print_line("Font", font);
428    }
429    if let Some(term_font) = &info.terminal_font {
430        print_line("Terminal Font", term_font);
431    }
432    if let Some(weather) = &info.weather {
433        print_line("Weather", weather);
434    }
435
436    // Setup logo representation
437    enum ActiveLogo {
438        Lines(Vec<String>),
439        Kitty(Vec<u8>, usize), // bytes, height_lines
440        Iterm2(Vec<u8>, usize),
441        Sixel(Vec<u8>, usize),
442        None,
443    }
444
445    let mut active_logo = ActiveLogo::None;
446
447    if show_logo {
448        let distro_hint = _config.logo.clone().or_else(logo::detect_distro);
449        let user_logo = if let Some(config_dir) = dirs::config_dir() {
450            let p = config_dir.join("retch").join("logo.png");
451            if p.exists() {
452                Some(p)
453            } else {
454                None
455            }
456        } else {
457            None
458        };
459
460        if cli.ascii_logo {
461            active_logo = ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
462        } else if _config.chafa.unwrap_or(false) || cli.chafa_logo {
463            let mut resolved = false;
464            if logo::chafa_available() {
465                if let Some(path) = &user_logo {
466                    if let Some(lines) = logo::get_chafa_logo_lines(path) {
467                        active_logo = ActiveLogo::Lines(lines);
468                        resolved = true;
469                    }
470                } else if let Some(distro) = &distro_hint {
471                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
472                        let temp_path = std::env::temp_dir()
473                            .join(format!("retch_logo_{}.png", std::process::id()));
474                        if std::fs::write(&temp_path, bytes).is_ok() {
475                            if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
476                                active_logo = ActiveLogo::Lines(lines);
477                                resolved = true;
478                            }
479                            let _ = std::fs::remove_file(&temp_path);
480                        }
481                    }
482                }
483            }
484            if !resolved {
485                active_logo =
486                    ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
487            }
488        } else {
489            let mut resolved = false;
490
491            // Kitty
492            #[cfg(feature = "graphics")]
493            if !resolved && logo::supports_kitty() {
494                if let Some(path) = &user_logo {
495                    if let Ok(bytes) = std::fs::read(path) {
496                        let h = graphical_logo_height_lines(&bytes);
497                        active_logo = ActiveLogo::Kitty(bytes, h);
498                        resolved = true;
499                    }
500                } else if let Some(distro) = &distro_hint {
501                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
502                        let h = graphical_logo_height_lines(bytes);
503                        active_logo = ActiveLogo::Kitty(bytes.to_vec(), h);
504                        resolved = true;
505                    }
506                }
507            }
508
509            // iTerm2
510            #[cfg(feature = "graphics")]
511            if !resolved && logo::supports_iterm2() {
512                if let Some(path) = &user_logo {
513                    if let Ok(bytes) = std::fs::read(path) {
514                        let h = graphical_logo_height_lines(&bytes);
515                        active_logo = ActiveLogo::Iterm2(bytes, h);
516                        resolved = true;
517                    }
518                } else if let Some(distro) = &distro_hint {
519                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
520                        let h = graphical_logo_height_lines(bytes);
521                        active_logo = ActiveLogo::Iterm2(bytes.to_vec(), h);
522                        resolved = true;
523                    }
524                }
525            }
526
527            // Sixel
528            #[cfg(feature = "graphics")]
529            if !resolved && logo::supports_sixel() {
530                if let Some(path) = &user_logo {
531                    if let Ok(bytes) = std::fs::read(path) {
532                        let h = graphical_logo_height_lines(&bytes);
533                        active_logo = ActiveLogo::Sixel(bytes, h);
534                        resolved = true;
535                    }
536                } else if let Some(distro) = &distro_hint {
537                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
538                        let h = graphical_logo_height_lines(bytes);
539                        active_logo = ActiveLogo::Sixel(bytes.to_vec(), h);
540                        resolved = true;
541                    }
542                }
543            }
544
545            // Chafa
546            if !resolved && logo::chafa_available() {
547                if let Some(path) = &user_logo {
548                    if let Some(lines) = logo::get_chafa_logo_lines(path) {
549                        active_logo = ActiveLogo::Lines(lines);
550                        resolved = true;
551                    }
552                } else if let Some(distro) = &distro_hint {
553                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
554                        // Write temp logo and read lines via chafa
555                        let temp_path = std::env::temp_dir()
556                            .join(format!("retch_logo_{}.png", std::process::id()));
557                        if std::fs::write(&temp_path, bytes).is_ok() {
558                            if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
559                                active_logo = ActiveLogo::Lines(lines);
560                                resolved = true;
561                            }
562                            let _ = std::fs::remove_file(&temp_path);
563                        }
564                    }
565                }
566            }
567
568            // Fallback to ASCII lines
569            if !resolved {
570                active_logo =
571                    ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
572            }
573        }
574    }
575
576    // Helper to strip ANSI codes and calculate visible length
577    let visible_len = |s: &str| -> usize {
578        let mut count = 0;
579        let mut in_esc = false;
580        for c in s.chars() {
581            if c == '\x1b' {
582                in_esc = true;
583            } else if in_esc {
584                if c.is_ascii_alphabetic() {
585                    in_esc = false;
586                }
587            } else {
588                count += 1;
589            }
590        }
591        count
592    };
593
594    let max_text_width = info_lines
595        .iter()
596        .map(|line| visible_len(line))
597        .max()
598        .unwrap_or(0);
599    let text_column_width = std::cmp::max(max_text_width + 4, 45);
600
601    let max_logo_width = match &active_logo {
602        ActiveLogo::Lines(logo_lines) => logo_lines
603            .iter()
604            .map(|line| visible_len(line))
605            .max()
606            .unwrap_or(0),
607        ActiveLogo::Kitty(_, _) | ActiveLogo::Iterm2(_, _) | ActiveLogo::Sixel(_, _) => 40,
608        ActiveLogo::None => 0,
609    };
610
611    let side_by_side =
612        show_logo && term_width >= 95 && term_width >= (text_column_width + max_logo_width);
613
614    println!(); // leading newline
615
616    if side_by_side {
617        match active_logo {
618            ActiveLogo::Lines(logo_lines) => {
619                let max_lines = std::cmp::max(info_lines.len(), logo_lines.len());
620                for i in 0..max_lines {
621                    let info_line = info_lines.get(i).cloned().unwrap_or_default();
622                    let logo_line = logo_lines.get(i).cloned().unwrap_or_default();
623                    let vis_len = visible_len(&info_line);
624                    let padding = if vis_len < text_column_width {
625                        " ".repeat(text_column_width - vis_len)
626                    } else {
627                        String::new()
628                    };
629                    println!("{}{}{}", info_line, padding, logo_line);
630                }
631            }
632            ActiveLogo::Kitty(bytes, height_lines) => {
633                for line in &info_lines {
634                    println!("{}", line);
635                }
636                let num_lines = info_lines.len();
637                print!("\x1b7"); // DEC save cursor
638                if num_lines > 0 {
639                    print!("\x1b[{}A", num_lines); // Move up
640                }
641                print!("\x1b[{}C", text_column_width); // Move right
642                logo::print_graphical_logo(&bytes);
643                print!("\x1b8"); // DEC restore cursor
644                                 // Advance past the logo's bottom edge if it extends below the text.
645                let overflow = height_lines.saturating_sub(num_lines);
646                if overflow > 0 {
647                    print!("\x1b[{}B", overflow);
648                }
649            }
650            ActiveLogo::Iterm2(bytes, height_lines) => {
651                for line in &info_lines {
652                    println!("{}", line);
653                }
654                let num_lines = info_lines.len();
655                print!("\x1b7"); // DEC save cursor
656                if num_lines > 0 {
657                    print!("\x1b[{}A", num_lines); // Move up
658                }
659                print!("\x1b[{}C", text_column_width); // Move right
660                logo::print_iterm2_logo(&bytes);
661                print!("\x1b8"); // DEC restore cursor
662                let overflow = height_lines.saturating_sub(num_lines);
663                if overflow > 0 {
664                    print!("\x1b[{}B", overflow);
665                }
666            }
667            ActiveLogo::Sixel(bytes, height_lines) => {
668                for line in &info_lines {
669                    println!("{}", line);
670                }
671                let num_lines = info_lines.len();
672                print!("\x1b7"); // DEC save cursor
673                if num_lines > 0 {
674                    print!("\x1b[{}A", num_lines); // Move up
675                }
676                print!("\x1b[{}C", text_column_width); // Move right
677                logo::print_sixel_logo(&bytes);
678                print!("\x1b8"); // DEC restore cursor
679                let overflow = height_lines.saturating_sub(num_lines);
680                if overflow > 0 {
681                    print!("\x1b[{}B", overflow);
682                }
683            }
684            ActiveLogo::None => {
685                for line in &info_lines {
686                    println!("{}", line);
687                }
688            }
689        }
690    } else {
691        // Narrow or no-logo fallback: print logo, then print data
692        match active_logo {
693            ActiveLogo::Lines(logo_lines) => {
694                for line in logo_lines {
695                    println!("{}", line);
696                }
697                println!();
698            }
699            ActiveLogo::Kitty(bytes, _) => {
700                logo::print_graphical_logo(&bytes);
701                println!();
702            }
703            ActiveLogo::Iterm2(bytes, _) => {
704                logo::print_iterm2_logo(&bytes);
705                println!();
706            }
707            ActiveLogo::Sixel(bytes, _) => {
708                logo::print_sixel_logo(&bytes);
709                println!();
710            }
711            ActiveLogo::None => {}
712        }
713        for line in &info_lines {
714            println!("{}", line);
715        }
716    }
717
718    Ok(())
719}
720
721/// Returns the highest temperature per physical category from a raw sensor list.
722///
723/// Input strings are formatted as `"label: 83°C"`. Output is one entry per
724/// detected category (CPU / GPU / NVMe / WiFi / Battery / System), ordered
725/// from most to least specific. Used by `--long` mode; `--full` shows the raw list.
726fn consolidate_temps(temps: &[String]) -> Vec<String> {
727    fn categorize(label: &str) -> &'static str {
728        let l = label.to_lowercase();
729        if l.contains("cpu")
730            || l.contains("core")
731            || l.contains("k10temp")
732            || l.contains("k8temp")
733            || l.contains("coretemp")
734            || l.contains("tctl")
735            || l.contains("tdie")
736            || l.contains("tccd")
737            || l.contains("package")
738        {
739            "CPU"
740        } else if l.contains("gpu")
741            || l.contains("nouveau")
742            || l.contains("radeon")
743            || l.contains("amdgpu")
744        {
745            "GPU"
746        } else if l.contains("nvme") || l.contains("nand") {
747            "NVMe"
748        } else if l.contains("ath")
749            || l.contains("wifi")
750            || l.contains("wireless")
751            || l.contains("wlan")
752            || l.contains("iwl")
753        {
754            "WiFi"
755        } else if l.contains("bat") {
756            "Battery"
757        } else {
758            "System"
759        }
760    }
761
762    let mut max: std::collections::HashMap<&str, f32> = std::collections::HashMap::new();
763    for s in temps {
764        // Parse "some label: 83°C"
765        if let Some((label_part, val_part)) = s.rsplit_once(':') {
766            let val_str = val_part.trim().trim_end_matches("°C");
767            if let Ok(val) = val_str.parse::<f32>() {
768                let cat = categorize(label_part.trim());
769                let entry = max.entry(cat).or_insert(f32::NEG_INFINITY);
770                if val > *entry {
771                    *entry = val;
772                }
773            }
774        }
775    }
776
777    const ORDER: &[&str] = &["CPU", "GPU", "NVMe", "WiFi", "Battery", "System"];
778    ORDER
779        .iter()
780        .filter_map(|cat| max.get(cat).map(|v| format!("{}: {:.0}°C", cat, v)))
781        .collect()
782}
783
784/// Formats a raw uptime string (in seconds) into a human-readable duration.
785///
786/// Example: "45224s" -> "12h 33m 44s"
787fn format_uptime(uptime: &str) -> String {
788    // Parse the uptime string (e.g. "45224s")
789    let seconds: u64 = uptime.trim_end_matches('s').parse().unwrap_or(0);
790
791    let years = seconds / (365 * 24 * 3600);
792    let days = (seconds % (365 * 24 * 3600)) / (24 * 3600);
793    let hours = (seconds % (24 * 3600)) / 3600;
794    let minutes = (seconds % 3600) / 60;
795    let secs = seconds % 60;
796
797    let mut parts = Vec::new();
798    if years > 0 {
799        parts.push(format!("{}y", years));
800    }
801    if days > 0 {
802        parts.push(format!("{}d", days));
803    }
804    if hours > 0 {
805        parts.push(format!("{}h", hours));
806    }
807    if minutes > 0 {
808        parts.push(format!("{}m", minutes));
809    }
810    if secs > 0 || parts.is_empty() {
811        parts.push(format!("{}s", secs));
812    }
813
814    parts.join(" ")
815}
816
817/// Returns the height in terminal rows a graphical logo image will occupy.
818///
819/// Uses TIOCGWINSZ pixel dimensions on Unix to get the real cell height.
820/// Falls back to 20px per cell when the terminal doesn't report pixel dims.
821#[cfg(feature = "graphics")]
822fn graphical_logo_height_lines(bytes: &[u8]) -> usize {
823    let img_h = image::load_from_memory(bytes)
824        .map(|img| img.height() as usize)
825        .unwrap_or(384);
826    let cell_h = terminal_cell_height_px();
827    img_h.div_ceil(cell_h)
828}
829
830/// Returns the terminal cell height in pixels via TIOCGWINSZ, or 20 as fallback.
831fn terminal_cell_height_px() -> usize {
832    #[cfg(unix)]
833    {
834        use std::mem::MaybeUninit;
835        let mut ws: libc::winsize = unsafe { MaybeUninit::zeroed().assume_init() };
836        let ret = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut ws) };
837        if ret == 0 && ws.ws_row > 0 && ws.ws_ypixel > 0 {
838            return ws.ws_ypixel as usize / ws.ws_row as usize;
839        }
840    }
841    20
842}
843
844#[cfg(test)]
845mod tests {
846    use super::*;
847
848    #[test]
849    fn test_consolidate_temps_basic() {
850        let raw = vec![
851            "k10temp Tctl: 83°C".to_string(),
852            "amdgpu edge: 65°C".to_string(),
853            "nvme Composite: 62°C".to_string(),
854            "ath11k_hwmon temp1: 58°C".to_string(),
855            "acpitz temp1: 77°C".to_string(),
856        ];
857        let result = consolidate_temps(&raw);
858        assert_eq!(
859            result,
860            vec![
861                "CPU: 83°C",
862                "GPU: 65°C",
863                "NVMe: 62°C",
864                "WiFi: 58°C",
865                "System: 77°C"
866            ]
867        );
868    }
869
870    #[test]
871    fn test_consolidate_temps_highest_wins() {
872        let raw = vec![
873            "thinkpad CPU: 83°C".to_string(),
874            "k10temp Tctl: 79°C".to_string(),
875            "nvme Composite: 62°C".to_string(),
876            "nvme Sensor 1: 59°C".to_string(),
877            "nvme Sensor 2: 56°C".to_string(),
878        ];
879        let result = consolidate_temps(&raw);
880        assert!(result.contains(&"CPU: 83°C".to_string()));
881        assert!(result.contains(&"NVMe: 62°C".to_string()));
882        assert!(!result
883            .iter()
884            .any(|s| s.contains("79") || s.contains("59") || s.contains("56")));
885    }
886
887    #[test]
888    fn test_consolidate_temps_order() {
889        let raw = vec![
890            "acpitz: 60°C".to_string(),
891            "nvme: 55°C".to_string(),
892            "amdgpu edge: 65°C".to_string(),
893            "k10temp Tctl: 80°C".to_string(),
894        ];
895        let result = consolidate_temps(&raw);
896        let cpu_pos = result.iter().position(|s| s.starts_with("CPU"));
897        let gpu_pos = result.iter().position(|s| s.starts_with("GPU"));
898        let nvme_pos = result.iter().position(|s| s.starts_with("NVMe"));
899        let sys_pos = result.iter().position(|s| s.starts_with("System"));
900        assert!(cpu_pos < gpu_pos);
901        assert!(gpu_pos < nvme_pos);
902        assert!(nvme_pos < sys_pos);
903    }
904
905    #[test]
906    fn test_consolidate_temps_empty() {
907        assert!(consolidate_temps(&[]).is_empty());
908    }
909
910    #[test]
911    fn test_format_uptime() {
912        assert_eq!(format_uptime("60s"), "1m");
913        assert_eq!(format_uptime("3600s"), "1h");
914        assert_eq!(format_uptime("3661s"), "1h 1m 1s");
915        assert_eq!(format_uptime("86400s"), "1d");
916        assert_eq!(format_uptime("90061s"), "1d 1h 1m 1s");
917        assert_eq!(format_uptime("31536000s"), "1y");
918        assert_eq!(format_uptime("31626061s"), "1y 1d 1h 1m 1s");
919        assert_eq!(format_uptime("0s"), "0s");
920    }
921}