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, the width the info
44/// lines beside the logo wrap to, and the column the logo itself is drawn at.
45///
46/// `text_column_width` and `logo_column` are deliberately **separate**. The first bounds how
47/// wide a beside-logo info line may grow; the second is where the logo block starts. Folding
48/// them into one value is what let the logo drift inward: the text column is clamped to 65
49/// columns, so on a wide terminal the logo was drawn at column 65 with the rest of the
50/// terminal left empty.
51struct LayoutPlan {
52    side_by_side: bool,
53    text_column_width: usize,
54    logo_column: usize,
55}
56
57/// Decide side-by-side vs. stacked layout, and the text-column width, from the geometry of
58/// the info block and the currently-selected logo.
59///
60/// Only the info lines that actually sit **beside** the logo — the first `logo_height` rows —
61/// constrain the layout. In `--long`/`--full` the widest lines (Wi-Fi, Network, Battery) fall
62/// *below* the logo, where nothing overlaps them, so they must neither widen the text column
63/// nor force a stacked layout. Basing the decision on every line (the previous behaviour) let
64/// a single 150+ char Wi-Fi line push the logo above the text on any normal-width terminal.
65///
66/// This is logo-type-agnostic: `logo_height`/`logo_width` are supplied by the caller from the
67/// active logo, so it works identically for ASCII art, Chafa (both rendered as text lines),
68/// and the graphical image protocols (Kitty/iTerm2/Sixel, whose cell footprint comes from
69/// [`logo::fit_logo_cells`] — the *same* call the emitters use to size the image, so the
70/// reserved area and the drawn area cannot disagree).
71///
72/// In side-by-side mode the logo is **flush against the right margin** (`logo_column =
73/// term_width - logo_width`), not butted up against the end of the text column. The two used
74/// to be the same number, which was only ever right by accident: before the text column was
75/// narrowed to the beside-logo lines (v0.6.8) and then clamped to 65 (v0.6.16), a long
76/// `Wi-Fi`/`Net` line inflated it far enough that the logo happened to land near the edge.
77/// Afterwards it sat at column 65 on every wide terminal, stranding the remainder.
78///
79/// Right-anchoring can never push the logo *left* of the text: `side_by_side` already
80/// requires `term_width >= text_column_width + logo_width`, so `term_width - logo_width` is
81/// at least `text_column_width`.
82///
83/// `info_widths` are the ANSI-stripped visible widths of the info lines, in render order.
84fn plan_layout(
85    info_widths: &[usize],
86    logo_height: usize,
87    logo_width: usize,
88    term_width: usize,
89    show_logo: bool,
90) -> LayoutPlan {
91    let beside_count = info_widths.len().min(logo_height);
92    let max_beside_width = info_widths[..beside_count]
93        .iter()
94        .copied()
95        .max()
96        .unwrap_or(0);
97    let text_column_width = if term_width >= 95 {
98        (term_width.saturating_sub(logo_width + 4))
99            .min(std::cmp::max(max_beside_width + 4, 45))
100            .clamp(45, 65)
101    } else {
102        std::cmp::max(max_beside_width + 4, 45)
103    };
104    let side_by_side =
105        show_logo && term_width >= 95 && term_width >= text_column_width + logo_width;
106    // Flush right. The `max(text_column_width)` floor is belt-and-braces: the `side_by_side`
107    // condition above already guarantees it, and the value is unused when stacked.
108    let logo_column = term_width.saturating_sub(logo_width).max(text_column_width);
109    LayoutPlan {
110        side_by_side,
111        text_column_width,
112        logo_column,
113    }
114}
115
116/// Strip ANSI escape sequences and return the string's width in **terminal columns**.
117///
118/// Not a character count. A CJK ideograph or a Hangul syllable occupies two columns, a
119/// combining mark occupies none, and an emoji followed by the variation selector U+FE0F
120/// (`☀️`) is two columns even though its base character alone would be one — none of which a
121/// `chars().count()` can express.
122///
123/// This matters because every layout decision in this module is denominated in columns: the
124/// padding that positions the logo, the wrap width for beside-logo lines, and the logo's own
125/// measured width. Counting characters undercounted `Media: 宇多田ヒカル - 花束を君に` by 11
126/// columns, so the line overran its column and pushed the logo out of alignment on that row.
127/// `media`/`player` (v0.8.0) read arbitrary track metadata, so non-Latin text is an ordinary
128/// input here, not an exotic one.
129///
130/// Escape handling is unchanged: `\x1b` opens a sequence that ends at the first ASCII letter,
131/// which covers the CSI (`\x1b[…m`), charset (`\x1b(B`) and private (`\x1b[?25l`) forms that
132/// `owo_colors` and `chafa` emit.
133///
134/// The visible characters are measured as one run rather than summed per character, because
135/// width is not a per-character property: variation-selector and zero-width-joiner sequences
136/// are only correct when the whole grapheme is measured together.
137pub fn visible_len(s: &str) -> usize {
138    use unicode_width::UnicodeWidthStr;
139
140    let mut visible = String::with_capacity(s.len());
141    let mut in_esc = false;
142    for c in s.chars() {
143        if c == '\x1b' {
144            in_esc = true;
145        } else if in_esc {
146            if c.is_ascii_alphabetic() {
147                in_esc = false;
148            }
149        } else {
150            visible.push(c);
151        }
152    }
153    visible.width()
154}
155
156/// Wrap a formatted info line (key: value) at logical boundaries to fit within `max_width`.
157///
158/// Continuation lines are indented to align with the start of the value portion.
159/// Prefers splitting on logical delimiters (e.g. `, `, ` - `) over arbitrary space boundaries,
160/// keeping atomic pairs (like `RX: ... TX: ...`) on the same line.
161pub fn wrap_info_line(line: &str, max_width: usize) -> Vec<String> {
162    let vis_len = visible_len(line);
163    if vis_len <= max_width || max_width < 20 {
164        return vec![line.to_string()];
165    }
166
167    let prefix_len = if let Some(idx) = line.find(':') {
168        let prefix_sub = &line[..=idx];
169        let extra_space = if line[idx + 1..].starts_with(' ') {
170            1
171        } else {
172            0
173        };
174        visible_len(prefix_sub) + extra_space
175    } else {
176        4
177    };
178
179    let indent = " ".repeat(prefix_len.min(max_width / 2));
180
181    // Try logical splitting by comma (", ") if present
182    if line.contains(", ") {
183        let parts: Vec<&str> = line.split(", ").collect();
184        let mut lines = Vec::new();
185        let mut current = String::new();
186
187        for (i, part) in parts.iter().enumerate() {
188            let item = if i == 0 {
189                part.to_string()
190            } else {
191                format!(", {}", part)
192            };
193            let item_vis = visible_len(&item);
194
195            if current.is_empty() || visible_len(&current) + item_vis <= max_width {
196                current.push_str(&item);
197            } else {
198                // Keep the separator we split on. Dropping it changed the *data*, not just
199                // its appearance: `American Megatrends International, LLC.` wrapped to
200                // `…International` / `LLC.`, which reads as two values rather than one
201                // company name. The comma stays on the preceding line, as in prose.
202                lines.push(format!("{current},"));
203                current = format!("{}{}", indent, part);
204            }
205        }
206        if !current.is_empty() {
207            lines.push(current);
208        }
209        if lines.iter().all(|l| visible_len(l) <= max_width + 10) {
210            return carry_sgr_across_lines(lines);
211        }
212    }
213
214    // Whitespace splitting fallback: group RX/TX headers with their values
215    let raw_words: Vec<&str> = line.split_whitespace().collect();
216    let mut words: Vec<String> = Vec::new();
217    let mut idx = 0;
218    while idx < raw_words.len() {
219        if raw_words[idx] == "RX:"
220            && idx + 3 < raw_words.len()
221            && raw_words.iter().skip(idx).any(|&w| w == "TX:")
222        {
223            let rx_tx = format!(
224                "{} {} {} {} {} {}",
225                raw_words[idx],
226                raw_words[idx + 1],
227                raw_words[idx + 2],
228                raw_words[idx + 3],
229                raw_words.get(idx + 4).copied().unwrap_or(""),
230                raw_words.get(idx + 5).copied().unwrap_or("")
231            );
232            words.push(rx_tx.trim().to_string());
233            idx += if idx + 5 < raw_words.len() { 6 } else { 4 };
234            continue;
235        }
236        words.push(raw_words[idx].to_string());
237        idx += 1;
238    }
239
240    let mut lines = Vec::new();
241    let mut current = String::new();
242
243    for word in words {
244        let word_vis = visible_len(&word);
245        if current.is_empty() {
246            current.push_str(&word);
247        } else if visible_len(&current) + 1 + word_vis <= max_width {
248            current.push(' ');
249            current.push_str(&word);
250        } else {
251            lines.push(current);
252            current = format!("{}{}", indent, word);
253        }
254    }
255    if !current.is_empty() {
256        lines.push(current);
257    }
258
259    if lines.is_empty() {
260        vec![line.to_string()]
261    } else {
262        // No separator to retain here — this branch breaks on whitespace, and a space at a
263        // line break needs no visible marker the way a comma does.
264        carry_sgr_across_lines(lines)
265    }
266}
267
268/// The foreground-colour SGR sequence still in effect at the end of `s`, given the sequence
269/// `entry` that was in effect when it started.
270///
271/// Only foreground colour is tracked, because that is all `Theme`/`owo_colors` emit here.
272/// A reset — `\x1b[0m` or `\x1b[39m` — clears it; any other `…m` sequence becomes the new
273/// state. Non-`m` sequences (cursor moves, chafa's `\x1b[?25l`) are ignored.
274fn active_sgr_after(s: &str, entry: Option<String>) -> Option<String> {
275    let mut active = entry;
276    let bytes = s.as_bytes();
277    let mut i = 0;
278    while i < bytes.len() {
279        if bytes[i] != 0x1b {
280            i += 1;
281            continue;
282        }
283        let start = i;
284        i += 1;
285        while i < bytes.len() && !bytes[i].is_ascii_alphabetic() {
286            i += 1;
287        }
288        if i < bytes.len() {
289            let seq = &s[start..=i];
290            if seq.ends_with('m') {
291                active = if seq == "\x1b[0m" || seq == "\x1b[39m" {
292                    None
293                } else {
294                    Some(seq.to_string())
295                };
296            }
297            i += 1;
298        }
299    }
300    active
301}
302
303/// Re-open the active colour on every continuation line, and close it at each line end.
304///
305/// Info lines are colourised **before** they are wrapped, so a value split across lines has
306/// its opening SGR on the first line and its closing `\x1b[39m` on the last: every line in
307/// between renders in the terminal's default colour. Reported against a wrapped `BIOS:` value,
308/// whose second line came out uncoloured while the first was cyan.
309///
310/// Fixing it at the wrap step rather than by colourising after wrapping is deliberate — the
311/// wrap points are chosen from *visible* width, so wrapping has to see the escapes anyway.
312///
313/// Only zero-width escape sequences are added, so [`visible_len`] of every line is unchanged
314/// and the widths the layout already computed still hold.
315fn carry_sgr_across_lines(lines: Vec<String>) -> Vec<String> {
316    let mut active: Option<String> = None;
317    let mut out = Vec::with_capacity(lines.len());
318    for line in lines {
319        let reopened = match &active {
320            Some(sgr) => format!("{sgr}{line}"),
321            None => line.clone(),
322        };
323        let end_state = active_sgr_after(&line, active.clone());
324        active = end_state.clone();
325        out.push(match end_state {
326            // Close the colour at the line end so it cannot bleed into the logo column.
327            Some(_) => format!("{reopened}\x1b[39m"),
328            None => reopened,
329        });
330    }
331    out
332}
333
334/// Split the Wi-Fi detail string into `(hardware, connection)` for two-line display.
335///
336/// The Linux `iw` path builds `"{adapter model} [{iface}] - {SSID} ({band/rate})"` — hardware
337/// and connection joined by `" - "`. Splitting on the first `" - "` puts the adapter on one
338/// line ("Wi-Fi") and the live connection on a second ("Wi-Fi Link"), so neither is the
339/// 150+ char line that used to wrap and collide with the logo. The fallback detectors
340/// (nmcli/iwgetid/macOS/Windows) return only the connection with no `" - "`, so those render
341/// as a single line (`connection` is `None`).
342fn split_wifi_line(wifi: &str) -> (&str, Option<&str>) {
343    match wifi.split_once(" - ") {
344        Some((hardware, connection)) => (hardware, Some(connection)),
345        None => (wifi, None),
346    }
347}
348
349/// Compose one row of the side-by-side layout: the info line, padded out to `logo_column`,
350/// followed by the logo line.
351///
352/// Padding goes to the **logo column** (the right margin), not merely to the end of the text
353/// column. Rows with no logo content get none at all — otherwise every line below the logo
354/// would carry ~90 trailing spaces.
355///
356/// Extracted from `display()`'s render loop deliberately. This arithmetic used to be inline
357/// there, alongside a local `visible_len` closure that shadowed the module function for the
358/// whole of `display()`; the shadow was a byte-for-byte copy of an older, character-counting
359/// implementation, so the layout silently measured characters while the module function —
360/// and its unit tests — measured columns. A free function cannot be shadowed by a local
361/// binding in another function's body, so the two can no longer diverge, and this is now
362/// directly testable without a pseudo-terminal.
363fn compose_side_by_side_row(info_line: &str, logo_line: &str, logo_column: usize) -> String {
364    let vis_len = visible_len(info_line);
365    if logo_line.is_empty() || vis_len >= logo_column {
366        return format!("{info_line}{logo_line}");
367    }
368    format!(
369        "{info_line}{}{logo_line}",
370        " ".repeat(logo_column - vis_len)
371    )
372}
373
374/// Escape prelude for [`render_graphical_side_by_side`]: reserve `logo_rows` rows with
375/// newlines, move back up to the image-top row, shift right to `logo_column`, and save the
376/// cursor (`\x1b7`).
377///
378/// The reservation is the scroll-safety mechanism: printing the newlines *first* forces any
379/// scrolling to happen before the cursor is saved, so nothing between the save and the
380/// restore can scroll. Without it, drawing the image with the cursor near the bottom margin
381/// scrolled the screen mid-draw, and `\x1b8` — which restores a *viewport-relative*
382/// position — landed on the row below the image instead of beside its top (text rendered
383/// under the logo; reproduced on Rio and kitty alike whenever the prompt sat near the
384/// bottom of a used terminal).
385///
386/// `logo_rows == 0` emits no reservation and no cursor-up (`CSI 0 A` would still move one
387/// row on real terminals).
388fn graphical_side_by_side_prelude(logo_column: usize, logo_rows: usize) -> String {
389    let mut prelude = String::new();
390    if logo_rows > 0 {
391        prelude.push_str(&"\n".repeat(logo_rows));
392        prelude.push_str(&format!("\x1b[{}A", logo_rows));
393    }
394    prelude.push_str(&format!("\x1b[{}C\x1b7", logo_column));
395    prelude
396}
397
398/// Render an image-protocol logo (Kitty/iTerm2/Sixel) beside the info text, scroll-safely.
399///
400/// The logo's rows are **reserved first** (newlines, then cursor-up — see
401/// [`graphical_side_by_side_prelude`]) so any scrolling happens up front; the image is then
402/// drawn at the top of the logo column bracketed by save/restore (`\x1b7`/`\x1b8`), which is
403/// only valid because no scroll can occur between the two. The info lines are then printed
404/// top-to-bottom at column 0, so the terminal scrolls naturally and carries the cell-anchored
405/// image with it.
406///
407/// This replaces two broken predecessors: "print all text, then `\x1b[{n}A` back up and draw"
408/// (clamped at the viewport top for tall `--long`/`--full` output, drawing the image
409/// mid-text) and the v0.6.8 unreserved save/draw/restore (correct on a fresh screen, but with
410/// the prompt near the bottom the draw scrolled the screen and the restore landed *below* the
411/// image). Residual risk: the draw can still scroll only if the image's real row count
412/// exceeds `logo_rows` — the same cell-height estimate the layout already trusts.
413fn render_graphical_side_by_side(
414    logo_column: usize,
415    info_lines: &[String],
416    logo_rows: usize,
417    draw: impl FnOnce(),
418) {
419    use std::io::Write;
420    // Reserve the logo rows (scroll now, if at all), return to the image-top row at the
421    // logo column, save, draw the image, restore, return to column 0.
422    print!("{}", graphical_side_by_side_prelude(logo_column, logo_rows));
423    draw(); // emits the image escape (and may move the cursor / print a newline)
424    print!("\x1b8\r");
425    for line in info_lines {
426        println!("{}", line);
427    }
428    // If the image is taller than the text block, advance past its bottom edge so a following
429    // shell prompt doesn't overlap it.
430    for _ in info_lines.len()..logo_rows {
431        println!();
432    }
433    let _ = std::io::stdout().flush();
434}
435
436/// Renders the collected system information to the terminal.
437///
438/// This function handles theme selection, logo rendering (including fallbacks
439/// between graphics, Chafa, and ASCII), and field filtering based on
440/// CLI flags and configuration.
441pub fn display(info: &SystemInfo, cli: &Cli, config: &Config) -> anyhow::Result<()> {
442    let _config = config;
443    let theme_name = _config.theme.as_deref().or(cli.theme.as_deref());
444    let mut theme = match theme_name {
445        Some(name) => Theme::from_name(name),
446        None => Theme::detect_system_theme(), // Default to system preference
447    };
448
449    // Apply custom theme overrides from config if present
450    if let Some(custom) = &_config.custom_theme {
451        theme = Theme::with_custom_overrides(theme, custom);
452    }
453
454    // Determine terminal width.
455    let term_size = terminal_size::terminal_size();
456    let term_width = if let Some((terminal_size::Width(w), _)) = term_size {
457        w as usize
458    } else {
459        80
460    };
461    // Use isatty() directly — terminal_size() can return Some() when a pager
462    // (e.g. bat) allocates a PTY, giving a false positive.
463    let stdout_is_tty = std::io::IsTerminal::is_terminal(&std::io::stdout());
464
465    let show_logo = should_show_logo(
466        _config.show_logo,
467        cli.no_logo,
468        cli.ascii_logo,
469        stdout_is_tty,
470    );
471
472    // Determine which fields to show. Strata allow-lists are derived from the
473    // single field registry (src/fields.rs) — the same source `main.rs` uses for
474    // collection, so display and collection can no longer drift apart. An explicit
475    // `config.fields` list bypasses the strata.
476    let allowed_fields: Option<Vec<String>> = if cli.full {
477        Some(fields::fields_for(Mode::Full))
478    } else if cli.long {
479        Some(fields::fields_for(Mode::Long))
480    } else if cli.short {
481        Some(fields::fields_for(Mode::Short))
482    } else if let Some(fields) = &_config.fields {
483        Some(fields.iter().map(|s| s.to_lowercase()).collect())
484    } else {
485        Some(fields::fields_for(Mode::Standard))
486    };
487
488    let should_show = |label: &str| -> bool {
489        match &allowed_fields {
490            Some(fields) => {
491                let norm_label = label.to_lowercase().replace(['-', '_'], " ");
492                let norm_label_no_spaces = norm_label.replace(' ', "");
493                fields.iter().any(|f| {
494                    let norm_f = f.to_lowercase().replace(['-', '_'], " ");
495                    norm_f == norm_label
496                        || norm_f.replace(' ', "") == norm_label_no_spaces
497                        // "dns" field key matches "DNS Server" display label
498                        || (norm_label == "dns server" && norm_f == "dns")
499                        // "memory" field key matches "Memory Usage" display label
500                        || (norm_label == "memory usage" && norm_f == "memory")
501                        // "Wi-Fi Link" (the connection line) maps to the "wifi" field key
502                        || (norm_label == "wi fi link" && norm_f == "wifi")
503                })
504            }
505            None => true,
506        }
507    };
508
509    // Helper for right-aligned labels
510    let label_width = 10;
511    let mut info_lines = Vec::new();
512    let mut print_line = |label: &str, value: &str| {
513        if should_show(label) {
514            info_lines.push(format!(
515                "{:>width$}{} {}",
516                theme.color_label(label),
517                theme.color_separator(":"),
518                theme.color_value(value),
519                width = label_width
520            ));
521        }
522    };
523
524    // OS / system identity
525    print_line("OS", &info.os);
526    if let Some(kernel) = &info.kernel {
527        print_line("Kernel", kernel);
528    }
529    if let Some(host) = &info.hostname {
530        print_line("Host", host);
531    }
532    if let Some(domain) = &info.domain {
533        print_line("Domain", domain);
534    }
535    if should_show("domain-search") {
536        for entry in &info.domain_search {
537            print_line("Domain Search", entry);
538        }
539    }
540    if let Some(chassis) = &info.chassis {
541        print_line("Chassis", chassis);
542    }
543    if let Some(init) = &info.init_system {
544        print_line("Init", init);
545    }
546    if let Some(locale) = &info.locale {
547        print_line("Locale", locale);
548    }
549    print_line("Arch", &info.arch);
550    // Suppress "Users: 0" — a 0 means the count couldn't be determined (e.g. the Unix
551    // uid>=1000 heuristic on a platform that keys users differently), not that nobody is
552    // logged in. Mirrors the `packages` guard below.
553    if info.users > 0 {
554        print_line("Users", &info.users.to_string());
555    }
556    if let Some(pkgs) = info.packages {
557        if pkgs > 0 {
558            print_line("Packages", &pkgs.to_string());
559        }
560    }
561    if let Some(user) = &info.current_user {
562        print_line("User", user);
563    }
564    // Uptime belongs with system identity, not hardware
565    let uptime_str = format_uptime(&info.uptime);
566    let boot_display = format!("{} since {}", uptime_str, info.boot_time);
567    print_line("Uptime", &boot_display);
568
569    // Hardware
570    print_line("CPU", &format!("{} ({})", info.cpu, info.cpu_core_info));
571    if let Some(freq) = &info.cpu_freq {
572        print_line("CPU Freq", freq);
573    }
574    if let Some(cache) = &info.cpu_cache {
575        print_line("CPU Cache", cache);
576    }
577    if let Some(usage) = &info.cpu_usage {
578        print_line("CPU Usage", usage);
579    }
580    if let Some(motherboard) = &info.motherboard {
581        print_line("Motherboard", motherboard);
582    }
583    if let Some(bios) = &info.bios {
584        print_line("BIOS", bios);
585    }
586    if let Some(bootmgr) = &info.bootmgr {
587        print_line("Bootmgr", bootmgr);
588    }
589    if let Some(tpm) = &info.tpm {
590        print_line("TPM", tpm);
591    }
592    if should_show("GPU") {
593        for gpu in &info.gpu {
594            print_line("GPU", gpu);
595        }
596    }
597    if should_show("Display") {
598        for display in &info.displays {
599            print_line("Display", display);
600        }
601    }
602    if let Some(brightness) = &info.brightness {
603        print_line("Brightness", brightness);
604    }
605    if let Some(audio) = &info.audio {
606        print_line("Audio", audio);
607    }
608    if should_show("Camera") {
609        for cam in &info.camera {
610            print_line("Camera", cam);
611        }
612    }
613    if should_show("Gamepad") {
614        for gp in &info.gamepad {
615            print_line("Gamepad", gp);
616        }
617    }
618    if should_show("Keyboard") {
619        for kb in &info.keyboard {
620            print_line("Keyboard", kb);
621        }
622    }
623    if should_show("Mouse") {
624        for m in &info.mouse {
625            print_line("Mouse", m);
626        }
627    }
628    if let Some(wifi) = &info.wifi {
629        // Split the (often 150+ char) Wi-Fi string into a hardware line and a connection line
630        // so neither wraps and collides with the logo. See `split_wifi_line`.
631        let (hardware, connection) = split_wifi_line(wifi);
632        print_line("Wi-Fi", hardware);
633        if let Some(conn) = connection {
634            print_line("Wi-Fi Link", conn);
635        }
636    }
637    if let Some(bt) = &info.bluetooth {
638        print_line("Bluetooth", bt);
639    }
640    if let Some(bat) = &info.battery {
641        print_line("Battery", bat);
642    }
643    if let Some(power) = &info.power_adapter {
644        print_line("Power Adapter", power);
645    }
646    print_line("Memory Usage", &info.memory);
647    if let Some(phys_mem) = &info.physical_memory {
648        print_line("Phys Mem", phys_mem);
649    }
650    print_line("Swap", &info.swap);
651    print_line("Procs", &info.processes.to_string());
652    if let Some(load) = &info.load_avg {
653        print_line("Load", load);
654    }
655    if should_show("Disk") {
656        for disk in &info.disks {
657            print_line("Disk", disk);
658        }
659    }
660    if should_show("Phys Disk") {
661        for disk in &info.physical_disks {
662            print_line("Phys Disk", disk);
663        }
664    }
665    if should_show("Disk IO") {
666        for io in &info.disk_io {
667            print_line("Disk IO", io);
668        }
669    }
670    if should_show("Btrfs") {
671        for vol in &info.btrfs {
672            print_line("Btrfs", vol);
673        }
674    }
675    if should_show("Zpool") {
676        for pool in &info.zpool {
677            print_line("Zpool", pool);
678        }
679    }
680    if should_show("Temp") {
681        if cli.full {
682            for temp in &info.temps {
683                print_line("Temp", temp);
684            }
685        } else {
686            for temp in consolidate_temps(&info.temps) {
687                print_line("Temp", &temp);
688            }
689        }
690    }
691
692    // Network
693    if should_show("Net") {
694        if cli.long || cli.full {
695            for net in &info.networks {
696                if let Some(ref active) = info.active_interface {
697                    if net.contains(active) {
698                        // Re-assert bright blue after the nested green "Up" /
699                        // red "Down" reset so the whole active line stays blue
700                        // (brackets and RX/TX included), not just up to "[".
701                        print_line("Net", &colorize_nested(net, ACTIVE_IFACE_PREFIX));
702                    }
703                }
704            }
705            for net in &info.networks {
706                if let Some(ref active) = info.active_interface {
707                    if net.contains(active) {
708                        continue;
709                    }
710                }
711                print_line("Net", net);
712            }
713        } else {
714            let mut printed = false;
715            if let Some(ref active) = info.active_interface {
716                for net in &info.networks {
717                    if net.contains(active) {
718                        print_line("Net", net);
719                        printed = true;
720                        break;
721                    }
722                }
723            }
724            if !printed {
725                for net in &info.networks {
726                    if net.contains("[Up]") {
727                        print_line("Net", net);
728                        break;
729                    }
730                }
731            }
732        }
733    }
734    if should_show("Net IO") {
735        for io in &info.net_io {
736            print_line("Net IO", io);
737        }
738    }
739    if let Some(ip) = &info.public_ip {
740        print_line("Public IP", ip);
741    }
742    if !info.dns.is_empty() {
743        print_line("DNS Server", &info.dns.join(", "));
744    }
745
746    // Environment
747    if let Some(shell) = &info.shell {
748        print_line("Shell", shell);
749    }
750    if let Some(editor) = &info.editor {
751        print_line("Editor", editor);
752    }
753    if let Some(term) = &info.terminal {
754        print_line("Terminal", term);
755    }
756    if let Some(ts) = &info.terminal_size {
757        print_line("Terminal Size", ts);
758    }
759    if let Some(de) = &info.desktop {
760        print_line("Desktop", de);
761    }
762    if let Some(wm) = &info.wm {
763        let duplicate = info
764            .desktop
765            .as_deref()
766            .map(|de| de.to_lowercase() == wm.to_lowercase())
767            .unwrap_or(false);
768        if !duplicate {
769            print_line("WM", wm);
770        }
771    }
772    if let Some(wm_theme) = &info.wm_theme {
773        print_line("WM Theme", wm_theme);
774    }
775    if let Some(wallpaper) = &info.wallpaper {
776        print_line("Wallpaper", wallpaper);
777    }
778    if let Some(lm) = &info.login_manager {
779        print_line("Login Manager", lm);
780    }
781    if let Some(player) = &info.player {
782        print_line("Player", player);
783    }
784    if let Some(media) = &info.media {
785        print_line("Media", media);
786    }
787    if let Some(ui_theme) = &info.ui_theme {
788        print_line("Theme", ui_theme);
789    }
790    if let Some(icons) = &info.icons {
791        print_line("Icons", icons);
792    }
793    if let Some(cursor) = &info.cursor {
794        print_line("Cursor", cursor);
795    }
796    if let Some(font) = &info.font {
797        print_line("Font", font);
798    }
799    if let Some(term_font) = &info.terminal_font {
800        print_line("Terminal Font", term_font);
801    }
802    if let Some(term_theme) = &info.terminal_theme {
803        print_line("Terminal Theme", term_theme);
804    }
805    if let Some(weather) = &info.weather {
806        print_line("Weather", weather);
807    }
808
809    // Setup logo representation
810    enum ActiveLogo {
811        Lines(Vec<String>),
812        Kitty(Vec<u8>, usize, usize), // bytes, cols, rows
813        Iterm2(Vec<u8>, usize, usize),
814        Sixel(Vec<u8>, usize, usize),
815        None,
816    }
817
818    let mut active_logo = ActiveLogo::None;
819
820    if show_logo {
821        let distro_hint = _config.logo.clone().or_else(logo::detect_distro);
822        let user_logo = if let Some(config_dir) = dirs::config_dir() {
823            let p = config_dir.join("retch").join("logo.png");
824            if p.exists() {
825                Some(p)
826            } else {
827                None
828            }
829        } else {
830            None
831        };
832
833        if cli.ascii_logo {
834            active_logo = ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
835        } else if _config.chafa.unwrap_or(false) || cli.chafa_logo {
836            let mut resolved = false;
837            if logo::chafa_available() {
838                if let Some(path) = &user_logo {
839                    if let Some(lines) = logo::get_chafa_logo_lines(path) {
840                        active_logo = ActiveLogo::Lines(lines);
841                        resolved = true;
842                    }
843                } else if let Some(distro) = &distro_hint {
844                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
845                        let temp_path = std::env::temp_dir()
846                            .join(format!("retch_logo_{}.png", std::process::id()));
847                        if std::fs::write(&temp_path, bytes).is_ok() {
848                            if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
849                                active_logo = ActiveLogo::Lines(lines);
850                                resolved = true;
851                            }
852                            let _ = std::fs::remove_file(&temp_path);
853                        }
854                    }
855                }
856            }
857            if !resolved {
858                active_logo =
859                    ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
860            }
861        } else {
862            let mut resolved = false;
863
864            // Kitty
865            #[cfg(feature = "graphics")]
866            if !resolved && logo::supports_kitty() {
867                if let Some(path) = &user_logo {
868                    if let Ok(bytes) = std::fs::read(path) {
869                        let (cols, rows) = graphical_logo_cells(&bytes);
870                        active_logo = ActiveLogo::Kitty(bytes, cols, rows);
871                        resolved = true;
872                    }
873                } else if let Some(distro) = &distro_hint {
874                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
875                        let (cols, rows) = graphical_logo_cells(bytes);
876                        active_logo = ActiveLogo::Kitty(bytes.to_vec(), cols, rows);
877                        resolved = true;
878                    }
879                }
880            }
881
882            // iTerm2
883            #[cfg(feature = "graphics")]
884            if !resolved && logo::supports_iterm2() {
885                if let Some(path) = &user_logo {
886                    if let Ok(bytes) = std::fs::read(path) {
887                        let (cols, rows) = graphical_logo_cells(&bytes);
888                        active_logo = ActiveLogo::Iterm2(bytes, cols, rows);
889                        resolved = true;
890                    }
891                } else if let Some(distro) = &distro_hint {
892                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
893                        let (cols, rows) = graphical_logo_cells(bytes);
894                        active_logo = ActiveLogo::Iterm2(bytes.to_vec(), cols, rows);
895                        resolved = true;
896                    }
897                }
898            }
899
900            // Sixel
901            #[cfg(feature = "graphics")]
902            if !resolved && logo::supports_sixel() {
903                if let Some(path) = &user_logo {
904                    if let Ok(bytes) = std::fs::read(path) {
905                        let (cols, rows) = graphical_logo_cells(&bytes);
906                        active_logo = ActiveLogo::Sixel(bytes, cols, rows);
907                        resolved = true;
908                    }
909                } else if let Some(distro) = &distro_hint {
910                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
911                        let (cols, rows) = graphical_logo_cells(bytes);
912                        active_logo = ActiveLogo::Sixel(bytes.to_vec(), cols, rows);
913                        resolved = true;
914                    }
915                }
916            }
917
918            // Chafa
919            if !resolved && logo::chafa_available() {
920                if let Some(path) = &user_logo {
921                    if let Some(lines) = logo::get_chafa_logo_lines(path) {
922                        active_logo = ActiveLogo::Lines(lines);
923                        resolved = true;
924                    }
925                } else if let Some(distro) = &distro_hint {
926                    if let Some(bytes) = logo::get_embedded_logo(Some(distro)) {
927                        // Write temp logo and read lines via chafa
928                        let temp_path = std::env::temp_dir()
929                            .join(format!("retch_logo_{}.png", std::process::id()));
930                        if std::fs::write(&temp_path, bytes).is_ok() {
931                            if let Some(lines) = logo::get_chafa_logo_lines(&temp_path) {
932                                active_logo = ActiveLogo::Lines(lines);
933                                resolved = true;
934                            }
935                            let _ = std::fs::remove_file(&temp_path);
936                        }
937                    }
938                }
939            }
940
941            // Fallback to ASCII lines
942            if !resolved {
943                active_logo =
944                    ActiveLogo::Lines(logo::get_distro_logo_lines(distro_hint.as_deref()));
945            }
946        }
947    }
948
949    // NOTE: `display()` previously defined a local `visible_len` closure here that was a
950    // byte-for-byte copy of the module-level [`visible_len`] and shadowed it for this entire
951    // function — which is where every layout decision is made. It has been removed so there
952    // is one implementation. Do not reintroduce a local helper by this name: the shadow was
953    // invisible at every call site (the calls below read identically either way), and it
954    // silently reverted this module's width handling for the layout while the module
955    // function's own unit tests kept passing.
956    let info_widths: Vec<usize> = info_lines.iter().map(|line| visible_len(line)).collect();
957
958    // Height (row count) and width of the active logo, whatever its kind. ASCII and Chafa are
959    // both `Lines`; the graphical protocols carry their pixel-derived row count and use the
960    // fixed image column width.
961    let (logo_height, max_logo_width) = match &active_logo {
962        ActiveLogo::Lines(logo_lines) => (
963            logo_lines.len(),
964            logo_lines
965                .iter()
966                .map(|line| visible_len(line))
967                .max()
968                .unwrap_or(0),
969        ),
970        ActiveLogo::Kitty(_, cols, rows)
971        | ActiveLogo::Iterm2(_, cols, rows)
972        | ActiveLogo::Sixel(_, cols, rows) => (*rows, *cols),
973        ActiveLogo::None => (0, 0),
974    };
975
976    // Only the lines beside the logo constrain placement — a long Wi-Fi/Network line below it
977    // must not force a stacked layout. See `plan_layout`.
978    let LayoutPlan {
979        side_by_side,
980        text_column_width,
981        logo_column,
982    } = plan_layout(
983        &info_widths,
984        logo_height,
985        max_logo_width,
986        term_width,
987        show_logo,
988    );
989
990    println!(); // leading newline
991
992    let formatted_info_lines: Vec<String> = if side_by_side && text_column_width > 15 {
993        let mut result = Vec::new();
994        for (i, line) in info_lines.iter().enumerate() {
995            // Beside-logo rows may use every column up to the logo, not just the text
996            // column. Those were the same number until the logo was anchored to the right
997            // margin; afterwards, wrapping at the text column left a wrapped line with the
998            // whole gap to the logo unused — a 283-column terminal wrapped `BIOS:` at 55
999            // columns with ~177 free to its right. Below-logo rows already use the full
1000            // terminal width, so this makes the two consistent.
1001            let max_w = if i < logo_height {
1002                logo_column.saturating_sub(2)
1003            } else {
1004                term_width.saturating_sub(2)
1005            };
1006            result.extend(wrap_info_line(line, max_w));
1007        }
1008        result
1009    } else {
1010        info_lines.clone()
1011    };
1012
1013    if side_by_side {
1014        match active_logo {
1015            ActiveLogo::Lines(logo_lines) => {
1016                let max_lines = std::cmp::max(formatted_info_lines.len(), logo_lines.len());
1017                for i in 0..max_lines {
1018                    let info_line = formatted_info_lines.get(i).cloned().unwrap_or_default();
1019                    let logo_line = logo_lines.get(i).cloned().unwrap_or_default();
1020                    println!(
1021                        "{}",
1022                        compose_side_by_side_row(&info_line, &logo_line, logo_column)
1023                    );
1024                }
1025            }
1026            ActiveLogo::Kitty(bytes, _, logo_rows) => {
1027                render_graphical_side_by_side(
1028                    logo_column,
1029                    &formatted_info_lines,
1030                    logo_rows,
1031                    || logo::print_graphical_logo(&bytes),
1032                );
1033            }
1034            ActiveLogo::Iterm2(bytes, _, logo_rows) => {
1035                render_graphical_side_by_side(
1036                    logo_column,
1037                    &formatted_info_lines,
1038                    logo_rows,
1039                    || logo::print_iterm2_logo(&bytes),
1040                );
1041            }
1042            ActiveLogo::Sixel(bytes, _, logo_rows) => {
1043                render_graphical_side_by_side(
1044                    logo_column,
1045                    &formatted_info_lines,
1046                    logo_rows,
1047                    || logo::print_sixel_logo(&bytes),
1048                );
1049            }
1050            ActiveLogo::None => {
1051                for line in &formatted_info_lines {
1052                    println!("{}", line);
1053                }
1054            }
1055        }
1056    } else {
1057        // Narrow or no-logo fallback: print logo, then print data
1058        match active_logo {
1059            ActiveLogo::Lines(logo_lines) => {
1060                for line in logo_lines {
1061                    println!("{}", line);
1062                }
1063                println!();
1064            }
1065            ActiveLogo::Kitty(bytes, _, _) => {
1066                logo::print_graphical_logo(&bytes);
1067                println!();
1068            }
1069            ActiveLogo::Iterm2(bytes, _, _) => {
1070                logo::print_iterm2_logo(&bytes);
1071                println!();
1072            }
1073            ActiveLogo::Sixel(bytes, _, _) => {
1074                logo::print_sixel_logo(&bytes);
1075                println!();
1076            }
1077            ActiveLogo::None => {}
1078        }
1079        for line in &info_lines {
1080            println!("{}", line);
1081        }
1082    }
1083
1084    Ok(())
1085}
1086
1087/// Returns the highest temperature per physical category from a raw sensor list.
1088///
1089/// Input strings are formatted as `"label: 83°C"`. Output is one entry per
1090/// detected category (CPU / GPU / NVMe / WiFi / Battery / System), ordered
1091/// from most to least specific. Used by `--long` mode; `--full` shows the raw list.
1092fn consolidate_temps(temps: &[String]) -> Vec<String> {
1093    fn categorize(label: &str) -> &'static str {
1094        let l = label.to_lowercase();
1095        if l.contains("cpu")
1096            || l.contains("core")
1097            || l.contains("k10temp")
1098            || l.contains("k8temp")
1099            || l.contains("coretemp")
1100            || l.contains("tctl")
1101            || l.contains("tdie")
1102            || l.contains("tccd")
1103            || l.contains("package")
1104        {
1105            "CPU"
1106        } else if l.contains("gpu")
1107            || l.contains("nouveau")
1108            || l.contains("radeon")
1109            || l.contains("amdgpu")
1110        {
1111            "GPU"
1112        } else if l.contains("nvme") || l.contains("nand") {
1113            "NVMe"
1114        } else if l.contains("ath")
1115            || l.contains("wifi")
1116            || l.contains("wireless")
1117            || l.contains("wlan")
1118            || l.contains("iwl")
1119        {
1120            "WiFi"
1121        } else if l.contains("bat") {
1122            "Battery"
1123        } else {
1124            "System"
1125        }
1126    }
1127
1128    let mut max: std::collections::HashMap<&str, f32> = std::collections::HashMap::new();
1129    for s in temps {
1130        // Parse "some label: 83°C"
1131        if let Some((label_part, val_part)) = s.rsplit_once(':') {
1132            let val_str = val_part.trim().trim_end_matches("°C");
1133            if let Ok(val) = val_str.parse::<f32>() {
1134                let cat = categorize(label_part.trim());
1135                let entry = max.entry(cat).or_insert(f32::NEG_INFINITY);
1136                if val > *entry {
1137                    *entry = val;
1138                }
1139            }
1140        }
1141    }
1142
1143    const ORDER: &[&str] = &["CPU", "GPU", "NVMe", "WiFi", "Battery", "System"];
1144    ORDER
1145        .iter()
1146        .filter_map(|cat| max.get(cat).map(|v| format!("{}: {:.0}°C", cat, v)))
1147        .collect()
1148}
1149
1150/// Formats a raw uptime string (in seconds) into a human-readable duration.
1151///
1152/// Example: "45224s" -> "12h 33m 44s"
1153fn format_uptime(uptime: &str) -> String {
1154    // Parse the uptime string (e.g. "45224s")
1155    let seconds: u64 = uptime.trim_end_matches('s').parse().unwrap_or(0);
1156
1157    let years = seconds / (365 * 24 * 3600);
1158    let days = (seconds % (365 * 24 * 3600)) / (24 * 3600);
1159    let hours = (seconds % (24 * 3600)) / 3600;
1160    let minutes = (seconds % 3600) / 60;
1161    let secs = seconds % 60;
1162
1163    let mut parts = Vec::new();
1164    if years > 0 {
1165        parts.push(format!("{}y", years));
1166    }
1167    if days > 0 {
1168        parts.push(format!("{}d", days));
1169    }
1170    if hours > 0 {
1171        parts.push(format!("{}h", hours));
1172    }
1173    if minutes > 0 {
1174        parts.push(format!("{}m", minutes));
1175    }
1176    if secs > 0 || parts.is_empty() {
1177        parts.push(format!("{}s", secs));
1178    }
1179
1180    parts.join(" ")
1181}
1182
1183/// Returns the `(columns, rows)` a graphical logo image will occupy on this terminal.
1184///
1185/// Delegates to [`logo::logo_cells_for`], which is also what the Kitty/iTerm2/Sixel emitters
1186/// use to size the image itself — so the footprint reserved by [`plan_layout`] and the
1187/// footprint actually drawn are the same numbers by construction. They used to be computed
1188/// independently (rows here from the pixel height, width hardcoded to 40, and the Kitty
1189/// escape hardcoding a third answer), which is how the logo ended up stretched *and*
1190/// mis-positioned.
1191#[cfg(feature = "graphics")]
1192fn graphical_logo_cells(bytes: &[u8]) -> (usize, usize) {
1193    let (img_w, img_h) = image::load_from_memory(bytes)
1194        .map(|img| (img.width(), img.height()))
1195        .unwrap_or((0, 0));
1196    let fit = logo::logo_cells_for(img_w, img_h);
1197    (fit.cols, fit.rows)
1198}
1199
1200#[cfg(test)]
1201mod tests {
1202    use super::*;
1203
1204    // ── should_show_logo ──────────────────────────────────────────────────────
1205
1206    #[test]
1207    fn test_show_logo_auto_requires_tty() {
1208        // Auto mode (no explicit flags): logo only on a TTY.
1209        assert!(should_show_logo(None, false, false, true));
1210        assert!(!should_show_logo(None, false, false, false));
1211    }
1212
1213    #[test]
1214    fn test_show_logo_ascii_forces_without_tty() {
1215        // --ascii-logo forces the logo even when stdout is not a TTY (pipe / CI).
1216        assert!(should_show_logo(None, false, true, false));
1217        assert!(should_show_logo(None, false, true, true));
1218    }
1219
1220    #[test]
1221    fn test_show_logo_no_logo_always_wins() {
1222        // --no-logo suppresses even when --ascii-logo is set or on a TTY.
1223        assert!(!should_show_logo(None, true, true, true));
1224        assert!(!should_show_logo(None, true, false, true));
1225    }
1226
1227    #[test]
1228    fn test_show_logo_config_disable() {
1229        // config show_logo=false suppresses in auto mode...
1230        assert!(!should_show_logo(Some(false), false, false, true));
1231        // ...but an explicit --ascii-logo still forces it on (CLI overrides config default).
1232        assert!(should_show_logo(Some(false), false, true, false));
1233    }
1234
1235    // ── visible_len ───────────────────────────────────────────────────────────
1236
1237    #[test]
1238    fn test_visible_len_strips_every_escape_form_retch_emits() {
1239        // owo_colors' SGR, its default-reset, chafa's private-mode cursor hide, and the
1240        // charset designator. `\x1b[?25l` is the one that bit a measurement harness during
1241        // this work: it is 6 characters and an SGR-only stripper leaves all of them.
1242        assert_eq!(visible_len("plain"), 5);
1243        assert_eq!(visible_len("\x1b[38;2;1;2;3mabc\x1b[39m"), 3);
1244        assert_eq!(visible_len("\x1b[?25labc"), 3);
1245        assert_eq!(visible_len("\x1b(Babc"), 3);
1246        assert_eq!(visible_len("\x1b[0m \x1b[38;2;0;0;0m\u{2582}"), 2);
1247    }
1248
1249    #[test]
1250    fn test_visible_len_counts_columns_not_characters() {
1251        // Regression: this returned a char count, so every wide glyph was undercounted by
1252        // one column. `media`/`player` (v0.8.0) surface arbitrary track metadata, so CJK and
1253        // Hangul are ordinary inputs.
1254        assert_eq!(visible_len("宇多田ヒカル"), 12); // 6 ideographs, 2 columns each
1255        assert_eq!(visible_len("아이유"), 6); // 3 Hangul syllables
1256        assert_eq!(visible_len("Media: 宇多田ヒカル - 花束を君に"), 32);
1257        assert_eq!(visible_len("Media: 아이유 - 밤편지"), 22);
1258
1259        // Combining marks add no width: "cafe" + U+0301 renders as four columns.
1260        assert_eq!(visible_len("cafe\u{301}"), 4);
1261        // Precomposed form measures the same, so the two spellings cannot disagree.
1262        assert_eq!(visible_len("café"), 4);
1263
1264        // A colour-wrapped wide value must measure the same as the bare one — the layout
1265        // sees the wrapped form.
1266        assert_eq!(
1267            visible_len("\x1b[38;2;1;2;3m宇多田\x1b[39m"),
1268            visible_len("宇多田")
1269        );
1270    }
1271
1272    #[test]
1273    fn test_visible_len_ascii_art_and_chafa_symbols_are_one_column_each() {
1274        // Every shipped logo is ASCII or narrow block-drawing, which is why the char-count
1275        // bug never showed on a logo. Pin that, so a future wide-glyph asset fails here
1276        // rather than silently overflowing the right margin.
1277        for line in logo::get_ascii_logo(Some("fedora")) {
1278            let stripped: String = strip_for_test(&line);
1279            assert_eq!(
1280                visible_len(&line),
1281                stripped.chars().count(),
1282                "fedora ASCII logo line is not one column per character: {stripped:?}"
1283            );
1284        }
1285        // Chafa's half-block/quadrant symbols are all narrow.
1286        for sym in [
1287            '\u{2580}', '\u{2584}', '\u{2588}', '\u{258c}', '\u{2596}', '\u{2582}',
1288        ] {
1289            assert_eq!(visible_len(&sym.to_string()), 1, "{sym:?} is not 1 column");
1290        }
1291    }
1292
1293    /// Test-only escape stripper, deliberately independent of [`visible_len`] so the test
1294    /// above compares two different implementations rather than one against itself.
1295    fn strip_for_test(s: &str) -> String {
1296        let mut out = String::new();
1297        let mut in_esc = false;
1298        for c in s.chars() {
1299            if c == '\x1b' {
1300                in_esc = true;
1301            } else if in_esc {
1302                if c.is_ascii_alphabetic() {
1303                    in_esc = false;
1304                }
1305            } else {
1306                out.push(c);
1307            }
1308        }
1309        out
1310    }
1311
1312    // ── wrap_info_line: separator retention and colour carry ──────────────────
1313
1314    /// The shape `Theme::color_value` produces: `<SGR>value<reset>`.
1315    const CYAN: &str = "\x1b[38;2;0;255;255m";
1316    const RESET: &str = "\x1b[39m";
1317
1318    #[test]
1319    fn test_wrap_keeps_the_comma_it_split_on() {
1320        // Regression: the comma was dropped at the break, so a wrapped
1321        // `American Megatrends International, LLC.` read as two separate values. That is a
1322        // change to the data, not to its presentation.
1323        let out = wrap_info_line(
1324            "BIOS: American Megatrends International, LLC. HN7306EAC.310 (8//20/07/0)",
1325            40,
1326        );
1327        assert!(out.len() > 1, "expected a wrap, got {out:?}");
1328        assert!(
1329            out[0].ends_with(','),
1330            "separator lost at the break: {:?}",
1331            out[0]
1332        );
1333        // And nothing is invented or dropped: rejoining recovers the original text.
1334        let rejoined: String = out
1335            .iter()
1336            .map(|l| l.trim_start().to_string())
1337            .collect::<Vec<_>>()
1338            .join(" ");
1339        assert_eq!(
1340            rejoined,
1341            "BIOS: American Megatrends International, LLC. HN7306EAC.310 (8//20/07/0)"
1342        );
1343    }
1344
1345    #[test]
1346    fn test_wrap_reopens_the_colour_on_every_continuation_line() {
1347        // Reported symptom: a wrapped BIOS value rendered its second line in the terminal
1348        // default because the opening SGR stayed on line 1 and the closing reset landed on
1349        // the last line.
1350        let line =
1351            format!("BIOS: {CYAN}American Megatrends International, LLC. HN7306EAC.310{RESET}");
1352        let out = wrap_info_line(&line, 40);
1353        assert!(out.len() > 1, "expected a wrap, got {out:?}");
1354        for (i, l) in out.iter().enumerate().skip(1) {
1355            assert!(
1356                l.contains(CYAN),
1357                "continuation line {i} has no colour: {l:?}"
1358            );
1359        }
1360        // Every line that opens a colour also closes it, so none can bleed into the logo.
1361        for l in &out {
1362            if l.contains(CYAN) {
1363                assert!(l.ends_with(RESET), "colour left open on {l:?}");
1364            }
1365        }
1366    }
1367
1368    #[test]
1369    fn test_wrap_colour_carry_does_not_change_visible_width() {
1370        // The escapes added must be zero-width, or every layout number computed from these
1371        // lines would be wrong.
1372        let plain = "BIOS: American Megatrends International, LLC. HN7306EAC.310";
1373        let coloured =
1374            format!("BIOS: {CYAN}American Megatrends International, LLC. HN7306EAC.310{RESET}");
1375        let a = wrap_info_line(plain, 40);
1376        let b = wrap_info_line(&coloured, 40);
1377        assert_eq!(a.len(), b.len());
1378        for (x, y) in a.iter().zip(b.iter()) {
1379            assert_eq!(visible_len(x), visible_len(y), "{x:?} vs {y:?}");
1380        }
1381    }
1382
1383    #[test]
1384    fn test_wrap_uncoloured_line_is_untouched_by_the_carry() {
1385        let out = wrap_info_line("Disk: aaaa, bbbb, cccc, dddd, eeee, ffff, gggg, hhhh", 24);
1386        assert!(out.len() > 1);
1387        assert!(
1388            out.iter().all(|l| !l.contains('\x1b')),
1389            "carry injected escapes into an uncoloured line: {out:?}"
1390        );
1391    }
1392
1393    #[test]
1394    fn test_active_sgr_after_tracks_open_and_reset() {
1395        assert_eq!(active_sgr_after("plain", None), None);
1396        assert_eq!(active_sgr_after(CYAN, None), Some(CYAN.to_string()));
1397        assert_eq!(active_sgr_after(&format!("{CYAN}x{RESET}"), None), None);
1398        assert_eq!(active_sgr_after("\x1b[0m", Some(CYAN.into())), None);
1399        // Carried in from the previous line and never reset here.
1400        assert_eq!(
1401            active_sgr_after("more text", Some(CYAN.into())),
1402            Some(CYAN.to_string())
1403        );
1404        // A non-`m` sequence (chafa's cursor hide) must not disturb the colour state.
1405        assert_eq!(
1406            active_sgr_after("\x1b[?25l", Some(CYAN.into())),
1407            Some(CYAN.to_string())
1408        );
1409    }
1410
1411    #[test]
1412    fn test_active_sgr_after_takes_the_last_colour_when_nested() {
1413        // The `Net` line embeds a green Up inside the value colour (v0.5.1). Whatever the
1414        // nesting, the state at end-of-line is simply the last sequence seen.
1415        let green = "\x1b[32m";
1416        let s = format!("{CYAN}[{green}Up{RESET}] RX: 1 MB");
1417        assert_eq!(active_sgr_after(&s, None), None); // last was the reset
1418        let s2 = format!("{CYAN}[{green}Up{RESET}]{CYAN} RX: 1 MB");
1419        assert_eq!(active_sgr_after(&s2, None), Some(CYAN.to_string()));
1420    }
1421
1422    // ── compose_side_by_side_row ──────────────────────────────────────────────
1423
1424    #[test]
1425    fn test_row_places_the_logo_at_the_logo_column() {
1426        let row = compose_side_by_side_row("OS: Fedora", "###", 20);
1427        assert_eq!(row, format!("OS: Fedora{}###", " ".repeat(10)));
1428        assert_eq!(visible_len(&row), 23);
1429    }
1430
1431    #[test]
1432    fn test_row_aligns_wide_characters_by_column_not_character_count() {
1433        // The regression that hid behind a shadowed `visible_len`: the layout measured
1434        // characters while the module function measured columns, so a CJK value pushed the
1435        // logo right by one column per wide glyph. Both rows below must put the logo at
1436        // exactly the same column.
1437        let latin = compose_side_by_side_row("Locale: en_US.UTF-8", "###", 40);
1438        let cjk = compose_side_by_side_row("Locale: ja_JP.宇多田ヒカル", "###", 40);
1439        assert_eq!(visible_len(&latin), 43);
1440        assert_eq!(
1441            visible_len(&cjk),
1442            43,
1443            "a wide-character info line must not shift the logo column"
1444        );
1445        // And the logo really is at column 40 in both, not merely the same total width.
1446        assert!(latin.ends_with("   ###") && cjk.ends_with("  ###"));
1447    }
1448
1449    #[test]
1450    fn test_row_without_a_logo_gets_no_trailing_padding() {
1451        // Lines below the logo would otherwise carry ~90 trailing spaces each.
1452        assert_eq!(compose_side_by_side_row("Net: eth0", "", 40), "Net: eth0");
1453    }
1454
1455    #[test]
1456    fn test_row_with_overlong_info_does_not_underflow() {
1457        // An info line wider than the logo column must not panic on the subtraction.
1458        let row = compose_side_by_side_row("x".repeat(50).as_str(), "###", 40);
1459        assert_eq!(row, format!("{}###", "x".repeat(50)));
1460    }
1461
1462    #[test]
1463    fn test_row_ignores_ansi_colour_when_measuring() {
1464        let plain = compose_side_by_side_row("abc", "###", 10);
1465        let coloured = compose_side_by_side_row("\x1b[31mabc\x1b[39m", "###", 10);
1466        assert_eq!(visible_len(&plain), visible_len(&coloured));
1467    }
1468
1469    // ── plan_layout ───────────────────────────────────────────────────────────
1470
1471    // A ~20-row logo with the widest beside-logo line = 54 (e.g. the CPU line), then a very
1472    // long Wi-Fi line (158) far below it — the real --full shape on this hardware.
1473    fn realistic_full_widths() -> Vec<usize> {
1474        let mut w = vec![40; 20]; // rows 0..20 sit beside the logo
1475        w[13] = 54; // CPU line, still beside the logo
1476        w.extend([158, 91, 79, 60, 45, 62]); // Wi-Fi/Net/Battery/etc., all BELOW the logo
1477        w
1478    }
1479
1480    #[test]
1481    fn test_layout_long_line_below_logo_stays_side_by_side() {
1482        // The 158-wide Wi-Fi line is below the 20-row logo, so it must NOT force a stack.
1483        let p = plan_layout(&realistic_full_widths(), 20, 40, 120, true);
1484        assert!(p.side_by_side);
1485        // Text column is driven by the widest BESIDE-logo line (54), not the 158 below it.
1486        assert_eq!(p.text_column_width, 58); // 54 + 4
1487    }
1488
1489    #[test]
1490    fn test_layout_old_behavior_would_have_stacked() {
1491        // Sanity: the pre-fix rule (widest of ALL lines) would need 158+4+40 = 202 cols and
1492        // stack at 120. Confirm the *new* rule does not, on the same inputs.
1493        let widths = realistic_full_widths();
1494        let old_text_col = std::cmp::max(widths.iter().copied().max().unwrap() + 4, 45);
1495        assert!(120 < old_text_col + 40); // old rule: stacked
1496        assert!(plan_layout(&widths, 20, 40, 120, true).side_by_side); // new rule: side-by-side
1497    }
1498
1499    #[test]
1500    fn test_layout_long_line_within_logo_wraps_and_stays_side_by_side() {
1501        // A 158-wide line among the first `logo_height` rows no longer breaks side-by-side layout
1502        // because text_column_width is clamped and the line is wrapped.
1503        let mut w = vec![40; 20];
1504        w[5] = 158;
1505        let p = plan_layout(&w, 20, 40, 120, true);
1506        assert!(p.side_by_side);
1507        assert_eq!(p.text_column_width, 65);
1508    }
1509
1510    #[test]
1511    fn test_layout_narrow_terminal_stacks() {
1512        assert!(!plan_layout(&[40; 30], 20, 40, 94, true).side_by_side); // < 95 hard floor
1513        assert!(!plan_layout(&[40; 30], 20, 40, 80, true).side_by_side);
1514    }
1515
1516    #[test]
1517    fn test_layout_show_logo_false_stacks() {
1518        assert!(!plan_layout(&[40; 30], 20, 40, 200, false).side_by_side);
1519    }
1520
1521    #[test]
1522    fn test_layout_column_floor_and_graphical_width() {
1523        // Tiny lines → text column floored at 45; graphical logo width (40) still applies.
1524        let p = plan_layout(&[10; 25], 20, 40, 100, true);
1525        assert!(p.side_by_side);
1526        assert_eq!(p.text_column_width, 45); // max(10+4, 45)
1527    }
1528
1529    #[test]
1530    fn test_layout_widened_logo_box_still_fits_at_the_side_by_side_threshold() {
1531        // The logo cell box grew from 28 to `logo::LOGO_MAX_COLS` (45) so wide-aspect logos get
1532        // enough rows to stay legible. That must not cost the side-by-side layout at the 95-col
1533        // threshold: the text column floors at 45, and 45 + 45 = 90 <= 95.
1534        let p = plan_layout(&[10; 25], 10, logo::LOGO_MAX_COLS, 95, true);
1535        assert!(
1536            p.side_by_side,
1537            "a full-width logo must still sit beside the text at 95 columns"
1538        );
1539        assert!(p.text_column_width + logo::LOGO_MAX_COLS <= 95);
1540
1541        // And a wide terminal is unaffected — the text column still reaches its 65 cap.
1542        let wide = plan_layout(&[120; 25], 10, logo::LOGO_MAX_COLS, 169, true);
1543        assert!(wide.side_by_side);
1544        assert_eq!(wide.text_column_width, 65);
1545    }
1546
1547    #[test]
1548    fn test_layout_logo_taller_than_text() {
1549        // Fewer info lines than logo rows: all lines are beside the logo (no panic on slice).
1550        let p = plan_layout(&[50, 30, 54], 20, 40, 120, true);
1551        assert!(p.side_by_side);
1552        assert_eq!(p.text_column_width, 58); // widest of the 3 (54) + 4
1553    }
1554
1555    #[test]
1556    fn test_layout_logo_is_flush_with_the_right_margin() {
1557        // The drift this fixes: on a wide terminal the logo used to be drawn at
1558        // `text_column_width` (capped at 65), stranding everything to its right. Measured on
1559        // arrakis at 138 columns with the 49-wide Windows ASCII logo: output stopped at
1560        // column 103, leaving 35 dead columns.
1561        let p = plan_layout(&realistic_full_widths(), 20, 49, 138, true);
1562        assert!(p.side_by_side);
1563        assert_eq!(p.text_column_width, 58); // unchanged: still driven by the beside lines
1564        assert_eq!(p.logo_column, 138 - 49); // logo now ends exactly at the right margin
1565        assert!(
1566            p.logo_column > p.text_column_width,
1567            "the pre-fix behaviour was logo_column == text_column_width"
1568        );
1569    }
1570
1571    #[test]
1572    fn test_layout_right_anchor_never_overlaps_the_text_column() {
1573        // At the 95-column threshold with a full-width logo the two columns meet exactly;
1574        // the logo must never be pulled left of where beside-logo text can reach.
1575        for term_width in 95..200 {
1576            let p = plan_layout(&[120; 25], 10, logo::LOGO_MAX_COLS, term_width, true);
1577            if p.side_by_side {
1578                assert!(
1579                    p.logo_column >= p.text_column_width,
1580                    "logo_column {} < text_column_width {} at {} cols",
1581                    p.logo_column,
1582                    p.text_column_width,
1583                    term_width
1584                );
1585                assert_eq!(p.logo_column + logo::LOGO_MAX_COLS, term_width);
1586            }
1587        }
1588    }
1589
1590    #[test]
1591    fn test_layout_logo_column_does_not_underflow_on_an_oversized_logo() {
1592        // A logo wider than the terminal stacks, and the (unused) column must not underflow.
1593        let p = plan_layout(&[40; 10], 10, 200, 100, true);
1594        assert!(!p.side_by_side);
1595        assert_eq!(p.logo_column, p.text_column_width);
1596    }
1597
1598    // ── graphical_side_by_side_prelude ────────────────────────────────────────
1599
1600    #[test]
1601    fn test_prelude_reserves_rows_before_saving_cursor() {
1602        // Regression for the below-the-logo bug (Rio/kitty, prompt at the bottom row): the
1603        // scroll-forcing reservation (newlines) and the cursor-up must both come BEFORE the
1604        // cursor save, so nothing between save and restore can scroll.
1605        let p = graphical_side_by_side_prelude(52, 3);
1606        assert_eq!(p, "\n\n\n\x1b[3A\x1b[52C\x1b7");
1607    }
1608
1609    #[test]
1610    fn test_prelude_v068_shape_only_differs_by_reservation() {
1611        // With the reservation stripped, the prelude is exactly the v0.6.8 bytes — the fresh
1612        // top-of-screen rendering (where no scroll happens) is unchanged.
1613        let p = graphical_side_by_side_prelude(45, 20);
1614        assert_eq!(
1615            p.replace(&format!("{}\x1b[20A", "\n".repeat(20)), ""),
1616            "\x1b[45C\x1b7"
1617        );
1618    }
1619
1620    #[test]
1621    fn test_prelude_zero_rows_skips_reservation_and_cursor_up() {
1622        // CSI 0 A still moves one row on real terminals, so logo_rows == 0 must emit
1623        // neither the reservation nor the cursor-up.
1624        let p = graphical_side_by_side_prelude(45, 0);
1625        assert_eq!(p, "\x1b[45C\x1b7");
1626    }
1627
1628    // ── split_wifi_line ───────────────────────────────────────────────────────
1629
1630    #[test]
1631    fn test_split_wifi_hardware_and_connection() {
1632        // The real `iw`-path shape: "{adapter} [{iface}] - {ssid} ({details})".
1633        let s = "MEDIATEK Corp. MT7925 802.11be [Filogic 360] [wlp194s0] - myssid (5.0 GHz ch36 [↓866 ↑866])";
1634        let (hw, conn) = split_wifi_line(s);
1635        assert_eq!(
1636            hw,
1637            "MEDIATEK Corp. MT7925 802.11be [Filogic 360] [wlp194s0]"
1638        );
1639        assert_eq!(conn, Some("myssid (5.0 GHz ch36 [↓866 ↑866])"));
1640    }
1641
1642    #[test]
1643    fn test_split_wifi_splits_on_first_separator() {
1644        // Only the first " - " (the hardware|connection boundary) splits; a " - " inside the
1645        // SSID/details stays with the connection.
1646        let (hw, conn) = split_wifi_line("Card X [wlan0] - Guest - 5G (5 GHz)");
1647        assert_eq!(hw, "Card X [wlan0]");
1648        assert_eq!(conn, Some("Guest - 5G (5 GHz)"));
1649    }
1650
1651    #[test]
1652    fn test_split_wifi_connection_only_fallback() {
1653        // Fallback detectors (nmcli/iwgetid/macOS/Windows) have no " - " → single line.
1654        let (hw, conn) = split_wifi_line("myssid (300 Mbps)");
1655        assert_eq!(hw, "myssid (300 Mbps)");
1656        assert_eq!(conn, None);
1657    }
1658
1659    #[test]
1660    fn test_consolidate_temps_basic() {
1661        let raw = vec![
1662            "k10temp Tctl: 83°C".to_string(),
1663            "amdgpu edge: 65°C".to_string(),
1664            "nvme Composite: 62°C".to_string(),
1665            "ath11k_hwmon temp1: 58°C".to_string(),
1666            "acpitz temp1: 77°C".to_string(),
1667        ];
1668        let result = consolidate_temps(&raw);
1669        assert_eq!(
1670            result,
1671            vec![
1672                "CPU: 83°C",
1673                "GPU: 65°C",
1674                "NVMe: 62°C",
1675                "WiFi: 58°C",
1676                "System: 77°C"
1677            ]
1678        );
1679    }
1680
1681    #[test]
1682    fn test_consolidate_temps_highest_wins() {
1683        let raw = vec![
1684            "thinkpad CPU: 83°C".to_string(),
1685            "k10temp Tctl: 79°C".to_string(),
1686            "nvme Composite: 62°C".to_string(),
1687            "nvme Sensor 1: 59°C".to_string(),
1688            "nvme Sensor 2: 56°C".to_string(),
1689        ];
1690        let result = consolidate_temps(&raw);
1691        assert!(result.contains(&"CPU: 83°C".to_string()));
1692        assert!(result.contains(&"NVMe: 62°C".to_string()));
1693        assert!(!result
1694            .iter()
1695            .any(|s| s.contains("79") || s.contains("59") || s.contains("56")));
1696    }
1697
1698    #[test]
1699    fn test_consolidate_temps_order() {
1700        let raw = vec![
1701            "acpitz: 60°C".to_string(),
1702            "nvme: 55°C".to_string(),
1703            "amdgpu edge: 65°C".to_string(),
1704            "k10temp Tctl: 80°C".to_string(),
1705        ];
1706        let result = consolidate_temps(&raw);
1707        let cpu_pos = result.iter().position(|s| s.starts_with("CPU"));
1708        let gpu_pos = result.iter().position(|s| s.starts_with("GPU"));
1709        let nvme_pos = result.iter().position(|s| s.starts_with("NVMe"));
1710        let sys_pos = result.iter().position(|s| s.starts_with("System"));
1711        assert!(cpu_pos < gpu_pos);
1712        assert!(gpu_pos < nvme_pos);
1713        assert!(nvme_pos < sys_pos);
1714    }
1715
1716    #[test]
1717    fn test_consolidate_temps_empty() {
1718        assert!(consolidate_temps(&[]).is_empty());
1719    }
1720
1721    #[test]
1722    fn test_format_uptime() {
1723        assert_eq!(format_uptime("60s"), "1m");
1724        assert_eq!(format_uptime("3600s"), "1h");
1725        assert_eq!(format_uptime("3661s"), "1h 1m 1s");
1726        assert_eq!(format_uptime("86400s"), "1d");
1727        assert_eq!(format_uptime("90061s"), "1d 1h 1m 1s");
1728        assert_eq!(format_uptime("31536000s"), "1y");
1729        assert_eq!(format_uptime("31626061s"), "1y 1d 1h 1m 1s");
1730        assert_eq!(format_uptime("0s"), "0s");
1731    }
1732
1733    #[test]
1734    fn test_wrap_info_line_short_line_unchanged() {
1735        let line = "Audio: Windows Audio (USB Audio Device)";
1736        let wrapped = wrap_info_line(line, 50);
1737        assert_eq!(wrapped, vec![line.to_string()]);
1738    }
1739
1740    #[test]
1741    fn test_wrap_info_line_wraps_and_indents() {
1742        let line = "Audio: Windows Audio (USB Audio Device, AMD High Definition Audio Device, AMD SoundWire Device)";
1743        let wrapped = wrap_info_line(line, 45);
1744        assert!(wrapped.len() > 1);
1745        assert!(wrapped[0].starts_with("Audio: Windows Audio"));
1746        assert!(wrapped[1].starts_with("       "));
1747    }
1748}