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