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