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