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