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/// Decide whether to render a logo at all.
16///
17/// In *auto* mode the logo is shown only when stdout is a TTY: the graphical and
18/// Chafa heuristics (and the side-by-side layout) are meaningless when output is
19/// piped or redirected, so we suppress the logo there. Two explicit overrides break
20/// that rule:
21/// - `no_logo` (from `--no-logo` or config) always wins → no logo.
22/// - `ascii_logo` (from `--ascii-logo`) forces the logo on **even without a TTY**:
23///   ASCII art is plain, pipe-safe text, so a caller (e.g. `retch --ascii-logo | cat`,
24///   or CI's `full-test` dry run) that explicitly asks for it should get it — mirroring
25///   how `--no-logo` is honored regardless of TTY. `--chafa-logo`/graphical modes are
26///   deliberately NOT forced here, since they emit terminal-specific control sequences
27///   that are only meaningful on a real terminal.
28fn should_show_logo(
29    config_show_logo: Option<bool>,
30    no_logo: bool,
31    ascii_logo: bool,
32    stdout_is_tty: bool,
33) -> bool {
34    if no_logo {
35        return false; // explicit suppression always wins
36    }
37    if ascii_logo {
38        return true; // explicit ASCII request forces the logo on, TTY or not, config or not
39    }
40    config_show_logo.unwrap_or(true) && stdout_is_tty // auto mode: default-on, but TTY-gated
41}
42
43/// Result of [`plan_layout`]: whether the logo sits beside the text, and the column the text
44/// is padded to (where the logo begins).
45struct LayoutPlan {
46    side_by_side: bool,
47    text_column_width: usize,
48}
49
50/// Decide side-by-side vs. stacked layout, and the text-column width, from the geometry of
51/// the info block and the currently-selected logo.
52///
53/// Only the info lines that actually sit **beside** the logo — the first `logo_height` rows —
54/// constrain the layout. In `--long`/`--full` the widest lines (Wi-Fi, Network, Battery) fall
55/// *below* the logo, where nothing overlaps them, so they must neither widen the text column
56/// nor force a stacked layout. Basing the decision on every line (the previous behaviour) let
57/// a single 150+ char Wi-Fi line push the logo above the text on any normal-width terminal.
58///
59/// This is logo-type-agnostic: `logo_height`/`logo_width` are supplied by the caller from the
60/// active logo, so it works identically for ASCII art, Chafa (both rendered as text lines),
61/// and the graphical image protocols (Kitty/iTerm2/Sixel, whose height is their pixel-derived
62/// row count and whose width is the fixed image column).
63///
64/// `info_widths` are the ANSI-stripped visible widths of the info lines, in render order.
65fn plan_layout(
66    info_widths: &[usize],
67    logo_height: usize,
68    logo_width: usize,
69    term_width: usize,
70    show_logo: bool,
71) -> LayoutPlan {
72    let beside_count = info_widths.len().min(logo_height);
73    let max_beside_width = info_widths[..beside_count]
74        .iter()
75        .copied()
76        .max()
77        .unwrap_or(0);
78    let text_column_width = std::cmp::max(max_beside_width + 4, 45);
79    let side_by_side =
80        show_logo && term_width >= 95 && term_width >= text_column_width + logo_width;
81    LayoutPlan {
82        side_by_side,
83        text_column_width,
84    }
85}
86
87/// Split the Wi-Fi detail string into `(hardware, connection)` for two-line display.
88///
89/// The Linux `iw` path builds `"{adapter model} [{iface}] - {SSID} ({band/rate})"` — hardware
90/// and connection joined by `" - "`. Splitting on the first `" - "` puts the adapter on one
91/// line ("Wi-Fi") and the live connection on a second ("Wi-Fi Link"), so neither is the
92/// 150+ char line that used to wrap and collide with the logo. The fallback detectors
93/// (nmcli/iwgetid/macOS/Windows) return only the connection with no `" - "`, so those render
94/// as a single line (`connection` is `None`).
95fn split_wifi_line(wifi: &str) -> (&str, Option<&str>) {
96    match wifi.split_once(" - ") {
97        Some((hardware, connection)) => (hardware, Some(connection)),
98        None => (wifi, None),
99    }
100}
101
102/// Render an image-protocol logo (Kitty/iTerm2/Sixel) beside the info text, scroll-safely.
103///
104/// The image is drawn **first**, at the top of the logo column, with the draw bracketed by
105/// save/restore (`\x1b7`/`\x1b8`) so it lands at the correct row *before* any text is printed
106/// or the screen scrolls. The info lines are then printed top-to-bottom at column 0, so the
107/// terminal scrolls naturally and carries the cell-anchored image with it.
108///
109/// This replaces the previous "print all text, then `\x1b[{n}A` back up and draw" approach,
110/// which broke for tall output (`--long`/`--full`): once the info block was taller than the
111/// viewport, the cursor-up was clamped at the top of the screen and the image was drawn in
112/// the *middle* of the text instead of beside its top rows.
113fn render_graphical_side_by_side(
114    text_column_width: usize,
115    info_lines: &[String],
116    logo_rows: usize,
117    draw: impl FnOnce(),
118) {
119    use std::io::Write;
120    // Move to the top of the logo column, save, draw the image, restore, return to column 0.
121    print!("\x1b[{}C\x1b7", text_column_width);
122    draw(); // emits the image escape (and may move the cursor / print a newline)
123    print!("\x1b8\r");
124    for line in info_lines {
125        println!("{}", line);
126    }
127    // If the image is taller than the text block, advance past its bottom edge so a following
128    // shell prompt doesn't overlap it.
129    for _ in info_lines.len()..logo_rows {
130        println!();
131    }
132    let _ = std::io::stdout().flush();
133}
134
135/// Renders the collected system information to the terminal.
136///
137/// This function handles theme selection, logo rendering (including fallbacks
138/// between graphics, Chafa, and ASCII), and field filtering based on
139/// CLI flags and configuration.
140pub fn display(info: &SystemInfo, cli: &Cli, config: &Config) -> anyhow::Result<()> {
141    let _config = config;
142    let theme_name = _config.theme.as_deref().or(cli.theme.as_deref());
143    let mut theme = match theme_name {
144        Some(name) => Theme::from_name(name),
145        None => Theme::detect_system_theme(), // Default to system preference
146    };
147
148    // Apply custom theme overrides from config if present
149    if let Some(custom) = &_config.custom_theme {
150        theme = Theme::with_custom_overrides(theme, custom);
151    }
152
153    // Determine terminal width.
154    let term_size = terminal_size::terminal_size();
155    let term_width = if let Some((terminal_size::Width(w), _)) = term_size {
156        w as usize
157    } else {
158        80
159    };
160    // Use isatty() directly — terminal_size() can return Some() when a pager
161    // (e.g. bat) allocates a PTY, giving a false positive.
162    let stdout_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
163
164    let show_logo = should_show_logo(
165        _config.show_logo,
166        cli.no_logo,
167        cli.ascii_logo,
168        stdout_is_tty,
169    );
170
171    // Determine which fields to show. Strata allow-lists are derived from the
172    // single field registry (src/fields.rs) — the same source `main.rs` uses for
173    // collection, so display and collection can no longer drift apart. An explicit
174    // `config.fields` list bypasses the strata.
175    let allowed_fields: Option<Vec<String>> = if cli.full {
176        Some(fields::fields_for(Mode::Full))
177    } else if cli.long {
178        Some(fields::fields_for(Mode::Long))
179    } else if cli.short {
180        Some(fields::fields_for(Mode::Short))
181    } else if let Some(fields) = &_config.fields {
182        Some(fields.iter().map(|s| s.to_lowercase()).collect())
183    } else {
184        Some(fields::fields_for(Mode::Standard))
185    };
186
187    let should_show = |label: &str| -> bool {
188        match &allowed_fields {
189            Some(fields) => {
190                let norm_label = label.to_lowercase().replace(['-', '_'], " ");
191                let norm_label_no_spaces = norm_label.replace(' ', "");
192                fields.iter().any(|f| {
193                    let norm_f = f.to_lowercase().replace(['-', '_'], " ");
194                    norm_f == norm_label
195                        || norm_f.replace(' ', "") == norm_label_no_spaces
196                        // "dns" field key matches "DNS Server" display label
197                        || (norm_label == "dns server" && norm_f == "dns")
198                        // "memory" field key matches "Memory Usage" display label
199                        || (norm_label == "memory usage" && norm_f == "memory")
200                        // "Wi-Fi Link" (the connection line) maps to the "wifi" field key
201                        || (norm_label == "wi fi link" && norm_f == "wifi")
202                })
203            }
204            None => true,
205        }
206    };
207
208    // Helper for right-aligned labels
209    let label_width = 10;
210    let mut info_lines = Vec::new();
211    let mut print_line = |label: &str, value: &str| {
212        if should_show(label) {
213            info_lines.push(format!(
214                "{:>width$}{} {}",
215                theme.color_label(label),
216                theme.color_separator(":"),
217                theme.color_value(value),
218                width = label_width
219            ));
220        }
221    };
222
223    // OS / system identity
224    print_line("OS", &info.os);
225    if let Some(kernel) = &info.kernel {
226        print_line("Kernel", kernel);
227    }
228    if let Some(host) = &info.hostname {
229        print_line("Host", host);
230    }
231    if let Some(domain) = &info.domain {
232        print_line("Domain", domain);
233    }
234    if should_show("domain-search") {
235        for entry in &info.domain_search {
236            print_line("Domain Search", entry);
237        }
238    }
239    if let Some(chassis) = &info.chassis {
240        print_line("Chassis", chassis);
241    }
242    if let Some(init) = &info.init_system {
243        print_line("Init", init);
244    }
245    if let Some(locale) = &info.locale {
246        print_line("Locale", locale);
247    }
248    print_line("Arch", &info.arch);
249    // Suppress "Users: 0" — a 0 means the count couldn't be determined (e.g. the Unix
250    // uid>=1000 heuristic on a platform that keys users differently), not that nobody is
251    // logged in. Mirrors the `packages` guard below.
252    if info.users > 0 {
253        print_line("Users", &info.users.to_string());
254    }
255    if let Some(pkgs) = info.packages {
256        if pkgs > 0 {
257            print_line("Packages", &pkgs.to_string());
258        }
259    }
260    if let Some(user) = &info.current_user {
261        print_line("User", user);
262    }
263    // Uptime belongs with system identity, not hardware
264    let uptime_str = format_uptime(&info.uptime);
265    let boot_display = format!("{} since {}", uptime_str, info.boot_time);
266    print_line("Uptime", &boot_display);
267
268    // Hardware
269    print_line("CPU", &format!("{} ({})", info.cpu, info.cpu_core_info));
270    if let Some(freq) = &info.cpu_freq {
271        print_line("CPU Freq", freq);
272    }
273    if let Some(cache) = &info.cpu_cache {
274        print_line("CPU Cache", cache);
275    }
276    if let Some(usage) = &info.cpu_usage {
277        print_line("CPU Usage", usage);
278    }
279    if let Some(motherboard) = &info.motherboard {
280        print_line("Motherboard", motherboard);
281    }
282    if let Some(bios) = &info.bios {
283        print_line("BIOS", bios);
284    }
285    if let Some(bootmgr) = &info.bootmgr {
286        print_line("Bootmgr", bootmgr);
287    }
288    if should_show("GPU") {
289        for gpu in &info.gpu {
290            print_line("GPU", gpu);
291        }
292    }
293    if should_show("Display") {
294        for display in &info.displays {
295            print_line("Display", display);
296        }
297    }
298    if let Some(brightness) = &info.brightness {
299        print_line("Brightness", brightness);
300    }
301    if let Some(audio) = &info.audio {
302        print_line("Audio", audio);
303    }
304    if should_show("Camera") {
305        for cam in &info.camera {
306            print_line("Camera", cam);
307        }
308    }
309    if should_show("Gamepad") {
310        for gp in &info.gamepad {
311            print_line("Gamepad", gp);
312        }
313    }
314    if let Some(wifi) = &info.wifi {
315        // Split the (often 150+ char) Wi-Fi string into a hardware line and a connection line
316        // so neither wraps and collides with the logo. See `split_wifi_line`.
317        let (hardware, connection) = split_wifi_line(wifi);
318        print_line("Wi-Fi", hardware);
319        if let Some(conn) = connection {
320            print_line("Wi-Fi Link", conn);
321        }
322    }
323    if let Some(bt) = &info.bluetooth {
324        print_line("Bluetooth", bt);
325    }
326    if let Some(bat) = &info.battery {
327        print_line("Battery", bat);
328    }
329    if let Some(power) = &info.power_adapter {
330        print_line("Power Adapter", power);
331    }
332    print_line("Memory Usage", &info.memory);
333    if let Some(phys_mem) = &info.physical_memory {
334        print_line("Phys Mem", phys_mem);
335    }
336    print_line("Swap", &info.swap);
337    print_line("Procs", &info.processes.to_string());
338    if let Some(load) = &info.load_avg {
339        print_line("Load", load);
340    }
341    if should_show("Disk") {
342        for disk in &info.disks {
343            print_line("Disk", disk);
344        }
345    }
346    if should_show("Phys Disk") {
347        for disk in &info.physical_disks {
348            print_line("Phys Disk", disk);
349        }
350    }
351    if should_show("Btrfs") {
352        for vol in &info.btrfs {
353            print_line("Btrfs", vol);
354        }
355    }
356    if should_show("Zpool") {
357        for pool in &info.zpool {
358            print_line("Zpool", pool);
359        }
360    }
361    if should_show("Temp") {
362        if cli.full {
363            for temp in &info.temps {
364                print_line("Temp", temp);
365            }
366        } else {
367            for temp in consolidate_temps(&info.temps) {
368                print_line("Temp", &temp);
369            }
370        }
371    }
372
373    // Network
374    if should_show("Net") {
375        if cli.long || cli.full {
376            for net in &info.networks {
377                if let Some(ref active) = info.active_interface {
378                    if net.contains(active) {
379                        // Re-assert bright blue after the nested green "Up" /
380                        // red "Down" reset so the whole active line stays blue
381                        // (brackets and RX/TX included), not just up to "[".
382                        print_line("Net", &colorize_nested(net, ACTIVE_IFACE_PREFIX));
383                    }
384                }
385            }
386            for net in &info.networks {
387                if let Some(ref active) = info.active_interface {
388                    if net.contains(active) {
389                        continue;
390                    }
391                }
392                print_line("Net", net);
393            }
394        } else {
395            let mut printed = false;
396            if let Some(ref active) = info.active_interface {
397                for net in &info.networks {
398                    if net.contains(active) {
399                        print_line("Net", net);
400                        printed = true;
401                        break;
402                    }
403                }
404            }
405            if !printed {
406                for net in &info.networks {
407                    if net.contains("[Up]") {
408                        print_line("Net", net);
409                        break;
410                    }
411                }
412            }
413        }
414    }
415    if let Some(ip) = &info.public_ip {
416        print_line("Public IP", ip);
417    }
418    if !info.dns.is_empty() {
419        print_line("DNS Server", &info.dns.join(", "));
420    }
421
422    // Environment
423    if let Some(shell) = &info.shell {
424        print_line("Shell", shell);
425    }
426    if let Some(editor) = &info.editor {
427        print_line("Editor", editor);
428    }
429    if let Some(term) = &info.terminal {
430        print_line("Terminal", term);
431    }
432    if let Some(ts) = &info.terminal_size {
433        print_line("Terminal Size", ts);
434    }
435    if let Some(de) = &info.desktop {
436        print_line("Desktop", de);
437    }
438    if let Some(wm) = &info.wm {
439        let duplicate = info
440            .desktop
441            .as_deref()
442            .map(|de| de.to_lowercase() == wm.to_lowercase())
443            .unwrap_or(false);
444        if !duplicate {
445            print_line("WM", wm);
446        }
447    }
448    if let Some(lm) = &info.login_manager {
449        print_line("Login Manager", lm);
450    }
451    if let Some(ui_theme) = &info.ui_theme {
452        print_line("Theme", ui_theme);
453    }
454    if let Some(icons) = &info.icons {
455        print_line("Icons", icons);
456    }
457    if let Some(cursor) = &info.cursor {
458        print_line("Cursor", cursor);
459    }
460    if let Some(font) = &info.font {
461        print_line("Font", font);
462    }
463    if let Some(term_font) = &info.terminal_font {
464        print_line("Terminal Font", term_font);
465    }
466    if let Some(weather) = &info.weather {
467        print_line("Weather", weather);
468    }
469
470    // Setup logo representation
471    enum ActiveLogo {
472        Lines(Vec<String>),
473        Kitty(Vec<u8>, usize), // bytes, height_lines
474        Iterm2(Vec<u8>, usize),
475        Sixel(Vec<u8>, usize),
476        None,
477    }
478
479    let mut active_logo = ActiveLogo::None;
480
481    if show_logo {
482        let distro_hint = _config.logo.clone().or_else(logo::detect_distro);
483        let user_logo = if let Some(config_dir) = dirs::config_dir() {
484            let p = config_dir.join("retch").join("logo.png");
485            if p.exists() {
486                Some(p)
487            } else {
488                None
489            }
490        } else {
491            None
492        };
493
494        if cli.ascii_logo {
495            active_logo = ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
496        } else if _config.chafa.unwrap_or(false) || cli.chafa_logo {
497            let mut resolved = false;
498            if logo::chafa_available() {
499                if let Some(path) = &user_logo {
500                    if let Some(lines) = logo::get_chafa_logo_lines(path) {
501                        active_logo = ActiveLogo::Lines(lines);
502                        resolved = true;
503                    }
504                } else if let Some(distro) = &distro_hint {
505                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
506                        let temp_path = std::env::temp_dir()
507                            .join(format!("retch_logo_{}.png", std::process::id()));
508                        if std::fs::write(&temp_path, bytes).is_ok() {
509                            if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
510                                active_logo = ActiveLogo::Lines(lines);
511                                resolved = true;
512                            }
513                            let _ = std::fs::remove_file(&temp_path);
514                        }
515                    }
516                }
517            }
518            if !resolved {
519                active_logo =
520                    ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
521            }
522        } else {
523            let mut resolved = false;
524
525            // Kitty
526            #[cfg(feature = "graphics")]
527            if !resolved && logo::supports_kitty() {
528                if let Some(path) = &user_logo {
529                    if let Ok(bytes) = std::fs::read(path) {
530                        let h = graphical_logo_height_lines(&bytes);
531                        active_logo = ActiveLogo::Kitty(bytes, h);
532                        resolved = true;
533                    }
534                } else if let Some(distro) = &distro_hint {
535                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
536                        let h = graphical_logo_height_lines(bytes);
537                        active_logo = ActiveLogo::Kitty(bytes.to_vec(), h);
538                        resolved = true;
539                    }
540                }
541            }
542
543            // iTerm2
544            #[cfg(feature = "graphics")]
545            if !resolved && logo::supports_iterm2() {
546                if let Some(path) = &user_logo {
547                    if let Ok(bytes) = std::fs::read(path) {
548                        let h = graphical_logo_height_lines(&bytes);
549                        active_logo = ActiveLogo::Iterm2(bytes, h);
550                        resolved = true;
551                    }
552                } else if let Some(distro) = &distro_hint {
553                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
554                        let h = graphical_logo_height_lines(bytes);
555                        active_logo = ActiveLogo::Iterm2(bytes.to_vec(), h);
556                        resolved = true;
557                    }
558                }
559            }
560
561            // Sixel
562            #[cfg(feature = "graphics")]
563            if !resolved && logo::supports_sixel() {
564                if let Some(path) = &user_logo {
565                    if let Ok(bytes) = std::fs::read(path) {
566                        let h = graphical_logo_height_lines(&bytes);
567                        active_logo = ActiveLogo::Sixel(bytes, h);
568                        resolved = true;
569                    }
570                } else if let Some(distro) = &distro_hint {
571                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
572                        let h = graphical_logo_height_lines(bytes);
573                        active_logo = ActiveLogo::Sixel(bytes.to_vec(), h);
574                        resolved = true;
575                    }
576                }
577            }
578
579            // Chafa
580            if !resolved && logo::chafa_available() {
581                if let Some(path) = &user_logo {
582                    if let Some(lines) = logo::get_chafa_logo_lines(path) {
583                        active_logo = ActiveLogo::Lines(lines);
584                        resolved = true;
585                    }
586                } else if let Some(distro) = &distro_hint {
587                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
588                        // Write temp logo and read lines via chafa
589                        let temp_path = std::env::temp_dir()
590                            .join(format!("retch_logo_{}.png", std::process::id()));
591                        if std::fs::write(&temp_path, bytes).is_ok() {
592                            if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
593                                active_logo = ActiveLogo::Lines(lines);
594                                resolved = true;
595                            }
596                            let _ = std::fs::remove_file(&temp_path);
597                        }
598                    }
599                }
600            }
601
602            // Fallback to ASCII lines
603            if !resolved {
604                active_logo =
605                    ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
606            }
607        }
608    }
609
610    // Helper to strip ANSI codes and calculate visible length
611    let visible_len = |s: &str| -> usize {
612        let mut count = 0;
613        let mut in_esc = false;
614        for c in s.chars() {
615            if c == '\x1b' {
616                in_esc = true;
617            } else if in_esc {
618                if c.is_ascii_alphabetic() {
619                    in_esc = false;
620                }
621            } else {
622                count += 1;
623            }
624        }
625        count
626    };
627
628    let info_widths: Vec<usize> = info_lines.iter().map(|line| visible_len(line)).collect();
629
630    // Height (row count) and width of the active logo, whatever its kind. ASCII and Chafa are
631    // both `Lines`; the graphical protocols carry their pixel-derived row count and use the
632    // fixed image column width.
633    let (logo_height, max_logo_width) = match &active_logo {
634        ActiveLogo::Lines(logo_lines) => (
635            logo_lines.len(),
636            logo_lines
637                .iter()
638                .map(|line| visible_len(line))
639                .max()
640                .unwrap_or(0),
641        ),
642        ActiveLogo::Kitty(_, h) | ActiveLogo::Iterm2(_, h) | ActiveLogo::Sixel(_, h) => (*h, 40),
643        ActiveLogo::None => (0, 0),
644    };
645
646    // Only the lines beside the logo constrain placement — a long Wi-Fi/Network line below it
647    // must not force a stacked layout. See `plan_layout`.
648    let LayoutPlan {
649        side_by_side,
650        text_column_width,
651    } = plan_layout(
652        &info_widths,
653        logo_height,
654        max_logo_width,
655        term_width,
656        show_logo,
657    );
658
659    println!(); // leading newline
660
661    if side_by_side {
662        match active_logo {
663            ActiveLogo::Lines(logo_lines) => {
664                let max_lines = std::cmp::max(info_lines.len(), logo_lines.len());
665                for i in 0..max_lines {
666                    let info_line = info_lines.get(i).cloned().unwrap_or_default();
667                    let logo_line = logo_lines.get(i).cloned().unwrap_or_default();
668                    let vis_len = visible_len(&info_line);
669                    let padding = if vis_len < text_column_width {
670                        " ".repeat(text_column_width - vis_len)
671                    } else {
672                        String::new()
673                    };
674                    println!("{}{}{}", info_line, padding, logo_line);
675                }
676            }
677            ActiveLogo::Kitty(bytes, logo_rows) => {
678                render_graphical_side_by_side(text_column_width, &info_lines, logo_rows, || {
679                    logo::print_graphical_logo(&bytes)
680                });
681            }
682            ActiveLogo::Iterm2(bytes, logo_rows) => {
683                render_graphical_side_by_side(text_column_width, &info_lines, logo_rows, || {
684                    logo::print_iterm2_logo(&bytes)
685                });
686            }
687            ActiveLogo::Sixel(bytes, logo_rows) => {
688                render_graphical_side_by_side(text_column_width, &info_lines, logo_rows, || {
689                    logo::print_sixel_logo(&bytes)
690                });
691            }
692            ActiveLogo::None => {
693                for line in &info_lines {
694                    println!("{}", line);
695                }
696            }
697        }
698    } else {
699        // Narrow or no-logo fallback: print logo, then print data
700        match active_logo {
701            ActiveLogo::Lines(logo_lines) => {
702                for line in logo_lines {
703                    println!("{}", line);
704                }
705                println!();
706            }
707            ActiveLogo::Kitty(bytes, _) => {
708                logo::print_graphical_logo(&bytes);
709                println!();
710            }
711            ActiveLogo::Iterm2(bytes, _) => {
712                logo::print_iterm2_logo(&bytes);
713                println!();
714            }
715            ActiveLogo::Sixel(bytes, _) => {
716                logo::print_sixel_logo(&bytes);
717                println!();
718            }
719            ActiveLogo::None => {}
720        }
721        for line in &info_lines {
722            println!("{}", line);
723        }
724    }
725
726    Ok(())
727}
728
729/// Returns the highest temperature per physical category from a raw sensor list.
730///
731/// Input strings are formatted as `"label: 83°C"`. Output is one entry per
732/// detected category (CPU / GPU / NVMe / WiFi / Battery / System), ordered
733/// from most to least specific. Used by `--long` mode; `--full` shows the raw list.
734fn consolidate_temps(temps: &[String]) -> Vec<String> {
735    fn categorize(label: &str) -> &'static str {
736        let l = label.to_lowercase();
737        if l.contains("cpu")
738            || l.contains("core")
739            || l.contains("k10temp")
740            || l.contains("k8temp")
741            || l.contains("coretemp")
742            || l.contains("tctl")
743            || l.contains("tdie")
744            || l.contains("tccd")
745            || l.contains("package")
746        {
747            "CPU"
748        } else if l.contains("gpu")
749            || l.contains("nouveau")
750            || l.contains("radeon")
751            || l.contains("amdgpu")
752        {
753            "GPU"
754        } else if l.contains("nvme") || l.contains("nand") {
755            "NVMe"
756        } else if l.contains("ath")
757            || l.contains("wifi")
758            || l.contains("wireless")
759            || l.contains("wlan")
760            || l.contains("iwl")
761        {
762            "WiFi"
763        } else if l.contains("bat") {
764            "Battery"
765        } else {
766            "System"
767        }
768    }
769
770    let mut max: std::collections::HashMap<&str, f32> = std::collections::HashMap::new();
771    for s in temps {
772        // Parse "some label: 83°C"
773        if let Some((label_part, val_part)) = s.rsplit_once(':') {
774            let val_str = val_part.trim().trim_end_matches("°C");
775            if let Ok(val) = val_str.parse::<f32>() {
776                let cat = categorize(label_part.trim());
777                let entry = max.entry(cat).or_insert(f32::NEG_INFINITY);
778                if val > *entry {
779                    *entry = val;
780                }
781            }
782        }
783    }
784
785    const ORDER: &[&str] = &["CPU", "GPU", "NVMe", "WiFi", "Battery", "System"];
786    ORDER
787        .iter()
788        .filter_map(|cat| max.get(cat).map(|v| format!("{}: {:.0}°C", cat, v)))
789        .collect()
790}
791
792/// Formats a raw uptime string (in seconds) into a human-readable duration.
793///
794/// Example: "45224s" -> "12h 33m 44s"
795fn format_uptime(uptime: &str) -> String {
796    // Parse the uptime string (e.g. "45224s")
797    let seconds: u64 = uptime.trim_end_matches('s').parse().unwrap_or(0);
798
799    let years = seconds / (365 * 24 * 3600);
800    let days = (seconds % (365 * 24 * 3600)) / (24 * 3600);
801    let hours = (seconds % (24 * 3600)) / 3600;
802    let minutes = (seconds % 3600) / 60;
803    let secs = seconds % 60;
804
805    let mut parts = Vec::new();
806    if years > 0 {
807        parts.push(format!("{}y", years));
808    }
809    if days > 0 {
810        parts.push(format!("{}d", days));
811    }
812    if hours > 0 {
813        parts.push(format!("{}h", hours));
814    }
815    if minutes > 0 {
816        parts.push(format!("{}m", minutes));
817    }
818    if secs > 0 || parts.is_empty() {
819        parts.push(format!("{}s", secs));
820    }
821
822    parts.join(" ")
823}
824
825/// Returns the height in terminal rows a graphical logo image will occupy.
826///
827/// Uses TIOCGWINSZ pixel dimensions on Unix to get the real cell height.
828/// Falls back to 20px per cell when the terminal doesn't report pixel dims.
829#[cfg(feature = "graphics")]
830fn graphical_logo_height_lines(bytes: &[u8]) -> usize {
831    let img_h = image::load_from_memory(bytes)
832        .map(|img| img.height() as usize)
833        .unwrap_or(384);
834    let cell_h = terminal_cell_height_px();
835    img_h.div_ceil(cell_h)
836}
837
838/// Returns the terminal cell height in pixels via TIOCGWINSZ, or 20 as fallback.
839fn terminal_cell_height_px() -> usize {
840    #[cfg(unix)]
841    {
842        use std::mem::MaybeUninit;
843        let mut ws: libc::winsize = unsafe { MaybeUninit::zeroed().assume_init() };
844        let ret = unsafe { libc::ioctl(libc::STDOUT_FILENO, libc::TIOCGWINSZ, &mut ws) };
845        if ret == 0 && ws.ws_row > 0 && ws.ws_ypixel > 0 {
846            return ws.ws_ypixel as usize / ws.ws_row as usize;
847        }
848    }
849    20
850}
851
852#[cfg(test)]
853mod tests {
854    use super::*;
855
856    // ── should_show_logo ──────────────────────────────────────────────────────
857
858    #[test]
859    fn test_show_logo_auto_requires_tty() {
860        // Auto mode (no explicit flags): logo only on a TTY.
861        assert!(should_show_logo(None, false, false, true));
862        assert!(!should_show_logo(None, false, false, false));
863    }
864
865    #[test]
866    fn test_show_logo_ascii_forces_without_tty() {
867        // --ascii-logo forces the logo even when stdout is not a TTY (pipe / CI).
868        assert!(should_show_logo(None, false, true, false));
869        assert!(should_show_logo(None, false, true, true));
870    }
871
872    #[test]
873    fn test_show_logo_no_logo_always_wins() {
874        // --no-logo suppresses even when --ascii-logo is set or on a TTY.
875        assert!(!should_show_logo(None, true, true, true));
876        assert!(!should_show_logo(None, true, false, true));
877    }
878
879    #[test]
880    fn test_show_logo_config_disable() {
881        // config show_logo=false suppresses in auto mode...
882        assert!(!should_show_logo(Some(false), false, false, true));
883        // ...but an explicit --ascii-logo still forces it on (CLI overrides config default).
884        assert!(should_show_logo(Some(false), false, true, false));
885    }
886
887    // ── plan_layout ───────────────────────────────────────────────────────────
888
889    // A ~20-row logo with the widest beside-logo line = 54 (e.g. the CPU line), then a very
890    // long Wi-Fi line (158) far below it — the real --full shape on this hardware.
891    fn realistic_full_widths() -> Vec<usize> {
892        let mut w = vec![40; 20]; // rows 0..20 sit beside the logo
893        w[13] = 54; // CPU line, still beside the logo
894        w.extend([158, 91, 79, 60, 45, 62]); // Wi-Fi/Net/Battery/etc., all BELOW the logo
895        w
896    }
897
898    #[test]
899    fn test_layout_long_line_below_logo_stays_side_by_side() {
900        // The 158-wide Wi-Fi line is below the 20-row logo, so it must NOT force a stack.
901        let p = plan_layout(&realistic_full_widths(), 20, 40, 120, true);
902        assert!(p.side_by_side);
903        // Text column is driven by the widest BESIDE-logo line (54), not the 158 below it.
904        assert_eq!(p.text_column_width, 58); // 54 + 4
905    }
906
907    #[test]
908    fn test_layout_old_behavior_would_have_stacked() {
909        // Sanity: the pre-fix rule (widest of ALL lines) would need 158+4+40 = 202 cols and
910        // stack at 120. Confirm the *new* rule does not, on the same inputs.
911        let widths = realistic_full_widths();
912        let old_text_col = std::cmp::max(widths.iter().copied().max().unwrap() + 4, 45);
913        assert!(120 < old_text_col + 40); // old rule: stacked
914        assert!(plan_layout(&widths, 20, 40, 120, true).side_by_side); // new rule: side-by-side
915    }
916
917    #[test]
918    fn test_layout_long_line_within_logo_forces_stack() {
919        // A 158-wide line among the first `logo_height` rows WOULD overlap the logo → stack.
920        let mut w = vec![40; 20];
921        w[5] = 158;
922        assert!(!plan_layout(&w, 20, 40, 120, true).side_by_side);
923    }
924
925    #[test]
926    fn test_layout_narrow_terminal_stacks() {
927        assert!(!plan_layout(&[40; 30], 20, 40, 94, true).side_by_side); // < 95 hard floor
928        assert!(!plan_layout(&[40; 30], 20, 40, 80, true).side_by_side);
929    }
930
931    #[test]
932    fn test_layout_show_logo_false_stacks() {
933        assert!(!plan_layout(&[40; 30], 20, 40, 200, false).side_by_side);
934    }
935
936    #[test]
937    fn test_layout_column_floor_and_graphical_width() {
938        // Tiny lines → text column floored at 45; graphical logo width (40) still applies.
939        let p = plan_layout(&[10; 25], 20, 40, 100, true);
940        assert!(p.side_by_side);
941        assert_eq!(p.text_column_width, 45); // max(10+4, 45)
942    }
943
944    #[test]
945    fn test_layout_logo_taller_than_text() {
946        // Fewer info lines than logo rows: all lines are beside the logo (no panic on slice).
947        let p = plan_layout(&[50, 30, 54], 20, 40, 120, true);
948        assert!(p.side_by_side);
949        assert_eq!(p.text_column_width, 58); // widest of the 3 (54) + 4
950    }
951
952    // ── split_wifi_line ───────────────────────────────────────────────────────
953
954    #[test]
955    fn test_split_wifi_hardware_and_connection() {
956        // The real `iw`-path shape: "{adapter} [{iface}] - {ssid} ({details})".
957        let s = "MEDIATEK Corp. MT7925 802.11be [Filogic 360] [wlp194s0] - myssid (5.0 GHz ch36 [↓866 ↑866])";
958        let (hw, conn) = split_wifi_line(s);
959        assert_eq!(
960            hw,
961            "MEDIATEK Corp. MT7925 802.11be [Filogic 360] [wlp194s0]"
962        );
963        assert_eq!(conn, Some("myssid (5.0 GHz ch36 [↓866 ↑866])"));
964    }
965
966    #[test]
967    fn test_split_wifi_splits_on_first_separator() {
968        // Only the first " - " (the hardware|connection boundary) splits; a " - " inside the
969        // SSID/details stays with the connection.
970        let (hw, conn) = split_wifi_line("Card X [wlan0] - Guest - 5G (5 GHz)");
971        assert_eq!(hw, "Card X [wlan0]");
972        assert_eq!(conn, Some("Guest - 5G (5 GHz)"));
973    }
974
975    #[test]
976    fn test_split_wifi_connection_only_fallback() {
977        // Fallback detectors (nmcli/iwgetid/macOS/Windows) have no " - " → single line.
978        let (hw, conn) = split_wifi_line("myssid (300 Mbps)");
979        assert_eq!(hw, "myssid (300 Mbps)");
980        assert_eq!(conn, None);
981    }
982
983    #[test]
984    fn test_consolidate_temps_basic() {
985        let raw = vec![
986            "k10temp Tctl: 83°C".to_string(),
987            "amdgpu edge: 65°C".to_string(),
988            "nvme Composite: 62°C".to_string(),
989            "ath11k_hwmon temp1: 58°C".to_string(),
990            "acpitz temp1: 77°C".to_string(),
991        ];
992        let result = consolidate_temps(&raw);
993        assert_eq!(
994            result,
995            vec![
996                "CPU: 83°C",
997                "GPU: 65°C",
998                "NVMe: 62°C",
999                "WiFi: 58°C",
1000                "System: 77°C"
1001            ]
1002        );
1003    }
1004
1005    #[test]
1006    fn test_consolidate_temps_highest_wins() {
1007        let raw = vec![
1008            "thinkpad CPU: 83°C".to_string(),
1009            "k10temp Tctl: 79°C".to_string(),
1010            "nvme Composite: 62°C".to_string(),
1011            "nvme Sensor 1: 59°C".to_string(),
1012            "nvme Sensor 2: 56°C".to_string(),
1013        ];
1014        let result = consolidate_temps(&raw);
1015        assert!(result.contains(&"CPU: 83°C".to_string()));
1016        assert!(result.contains(&"NVMe: 62°C".to_string()));
1017        assert!(!result
1018            .iter()
1019            .any(|s| s.contains("79") || s.contains("59") || s.contains("56")));
1020    }
1021
1022    #[test]
1023    fn test_consolidate_temps_order() {
1024        let raw = vec![
1025            "acpitz: 60°C".to_string(),
1026            "nvme: 55°C".to_string(),
1027            "amdgpu edge: 65°C".to_string(),
1028            "k10temp Tctl: 80°C".to_string(),
1029        ];
1030        let result = consolidate_temps(&raw);
1031        let cpu_pos = result.iter().position(|s| s.starts_with("CPU"));
1032        let gpu_pos = result.iter().position(|s| s.starts_with("GPU"));
1033        let nvme_pos = result.iter().position(|s| s.starts_with("NVMe"));
1034        let sys_pos = result.iter().position(|s| s.starts_with("System"));
1035        assert!(cpu_pos < gpu_pos);
1036        assert!(gpu_pos < nvme_pos);
1037        assert!(nvme_pos < sys_pos);
1038    }
1039
1040    #[test]
1041    fn test_consolidate_temps_empty() {
1042        assert!(consolidate_temps(&[]).is_empty());
1043    }
1044
1045    #[test]
1046    fn test_format_uptime() {
1047        assert_eq!(format_uptime("60s"), "1m");
1048        assert_eq!(format_uptime("3600s"), "1h");
1049        assert_eq!(format_uptime("3661s"), "1h 1m 1s");
1050        assert_eq!(format_uptime("86400s"), "1d");
1051        assert_eq!(format_uptime("90061s"), "1d 1h 1m 1s");
1052        assert_eq!(format_uptime("31536000s"), "1y");
1053        assert_eq!(format_uptime("31626061s"), "1y 1d 1h 1m 1s");
1054        assert_eq!(format_uptime("0s"), "0s");
1055    }
1056}