From b4e22539998f373f38f4191ea5c9e0bfe1a19bc8 Mon Sep 17 00:00:00 2001 From: Ken Tobias <634380+l1a@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:29:11 -0700 Subject: [PATCH 1/4] fix(display): reduce logo height and wrap long info lines Assisted-By: Gemini 3.6 Flash --- NOTES.md | 2 + crates/sysinfo/src/audio.rs | 6 +- src/display.rs | 145 ++++++++++++++++++++++++++++++++---- src/logo.rs | 4 +- 4 files changed, 138 insertions(+), 19 deletions(-) diff --git a/NOTES.md b/NOTES.md index 9119a524..bb1a8366 100644 --- a/NOTES.md +++ b/NOTES.md @@ -97,6 +97,8 @@ The `retch-sysinfo` crate can be used independently as a library for cross-platf --- ## Current State (v0.6.16) +- **v0.6.16 — Graphic logo size reduction and controlled info line wrapping** (`src/display.rs`, `src/logo.rs`, `crates/sysinfo/src/audio.rs`). + Reduced Chafa logo height to `34x12` (matching standard 12-line ASCII logo heights). Filtered synthetic kernel streaming audio endpoints on Windows (`Microsoft Streaming ...`, `Microsoft Trusted Audio ...`) so `Audio:` reports real hardware devices. Updated `plan_layout` in `display.rs` to clamp `text_column_width` (max 65) and implemented `wrap_info_line` word-wrapping for info lines exceeding the text column width. Long lines wrap into indented continuation lines (aligned to the value column) rather than breaking side-by-side layout or overflowing the terminal edge. - **v0.6.16 — Cross-platform Justfile recipes on Windows** (`Justfile`, `scripts/install_completions.py`, `scripts/install_man.py`, `scripts/build_man.py`). Converted `man`, `install-man`, and `install-completions` recipes in `Justfile` to Python helper scripts, eliminating bash shebang escaping bugs (`No such file or directory` due to unescaped Windows backslashes in `justfile_directory()`), missing `.exe` extensions on Windows binary targets, and POSIX `install` utility dependencies. `just install`, `just install-man`, `just install-completions`, and `just man` now execute 100% natively on Windows PowerShell, CMD, and Unix shells without requiring `Git\usr\bin` on PATH. Also consolidated Dependabot #182 dependency bumps. `retch-sysinfo` → `0.1.52`; `retch-cli` → `0.6.16`. Patch bump. - **4 Rust crates** (`cargo-dependencies` group, #182), all patch-level and lockfile-only — diff --git a/crates/sysinfo/src/audio.rs b/crates/sysinfo/src/audio.rs index 17ab7ffc..538298f9 100644 --- a/crates/sysinfo/src/audio.rs +++ b/crates/sysinfo/src/audio.rs @@ -61,7 +61,11 @@ pub fn detect_audio(sys: &sysinfo::System) -> Option { win_reg::get_reg_string(win_reg::HKEY_LOCAL_MACHINE, &subkey, "DriverDesc") { let name = name.trim().to_string(); - if !name.is_empty() && !devices.contains(&name) { + let lower = name.to_lowercase(); + let is_synthetic = lower.starts_with("microsoft streaming") + || lower.starts_with("microsoft trusted audio") + || lower == "microsoft audio stack"; + if !name.is_empty() && !is_synthetic && !devices.contains(&name) { devices.push(name); } } diff --git a/src/display.rs b/src/display.rs index 4b97d3f0..b30fdf16 100644 --- a/src/display.rs +++ b/src/display.rs @@ -75,7 +75,13 @@ fn plan_layout( .copied() .max() .unwrap_or(0); - let text_column_width = std::cmp::max(max_beside_width + 4, 45); + let text_column_width = if term_width >= 95 { + (term_width.saturating_sub(logo_width + 4)) + .min(std::cmp::max(max_beside_width + 4, 45)) + .clamp(45, 65) + } else { + std::cmp::max(max_beside_width + 4, 45) + }; let side_by_side = show_logo && term_width >= 95 && term_width >= text_column_width + logo_width; LayoutPlan { @@ -84,6 +90,76 @@ fn plan_layout( } } +/// Helper to strip ANSI escape sequences and calculate visible string length. +pub fn visible_len(s: &str) -> usize { + let mut count = 0; + let mut in_esc = false; + for c in s.chars() { + if c == '\x1b' { + in_esc = true; + } else if in_esc { + if c.is_ascii_alphabetic() { + in_esc = false; + } + } else { + count += 1; + } + } + count +} + +/// Wrap a formatted info line (key: value) at word boundaries to fit within `max_width`. +/// +/// Continuation lines are indented to align with the start of the value portion. +pub fn wrap_info_line(line: &str, max_width: usize) -> Vec { + let vis_len = visible_len(line); + if vis_len <= max_width || max_width < 20 { + return vec![line.to_string()]; + } + + let prefix_len = if let Some(idx) = line.find(':') { + let prefix_sub = &line[..=idx]; + let extra_space = if line[idx + 1..].starts_with(' ') { + 1 + } else { + 0 + }; + visible_len(prefix_sub) + extra_space + } else { + 4 + }; + + let indent = " ".repeat(prefix_len.min(max_width / 2)); + let mut lines = Vec::new(); + let mut current = String::new(); + let mut current_vis = 0; + + for word in line.split_whitespace() { + let word_vis = visible_len(word); + if current.is_empty() { + current.push_str(word); + current_vis = word_vis; + } else if current_vis + 1 + word_vis <= max_width { + current.push(' '); + current.push_str(word); + current_vis += 1 + word_vis; + } else { + lines.push(current); + current = format!("{}{}", indent, word); + current_vis = visible_len(&indent) + word_vis; + } + } + if !current.is_empty() { + lines.push(current); + } + + if lines.is_empty() { + vec![line.to_string()] + } else { + lines + } +} + /// Split the Wi-Fi detail string into `(hardware, connection)` for two-line display. /// /// The Linux `iw` path builds `"{adapter model} [{iface}] - {SSID} ({band/rate})"` — hardware @@ -690,12 +766,21 @@ pub fn display(info: &SystemInfo, cli: &Cli, config: &Config) -> anyhow::Result< println!(); // leading newline + let formatted_info_lines: Vec = if side_by_side && text_column_width > 15 { + info_lines + .iter() + .flat_map(|line| wrap_info_line(line, text_column_width.saturating_sub(2))) + .collect() + } else { + info_lines.clone() + }; + if side_by_side { match active_logo { ActiveLogo::Lines(logo_lines) => { - let max_lines = std::cmp::max(info_lines.len(), logo_lines.len()); + let max_lines = std::cmp::max(formatted_info_lines.len(), logo_lines.len()); for i in 0..max_lines { - let info_line = info_lines.get(i).cloned().unwrap_or_default(); + let info_line = formatted_info_lines.get(i).cloned().unwrap_or_default(); let logo_line = logo_lines.get(i).cloned().unwrap_or_default(); let vis_len = visible_len(&info_line); let padding = if vis_len < text_column_width { @@ -707,22 +792,31 @@ pub fn display(info: &SystemInfo, cli: &Cli, config: &Config) -> anyhow::Result< } } ActiveLogo::Kitty(bytes, logo_rows) => { - render_graphical_side_by_side(text_column_width, &info_lines, logo_rows, || { - logo::print_graphical_logo(&bytes) - }); + render_graphical_side_by_side( + text_column_width, + &formatted_info_lines, + logo_rows, + || logo::print_graphical_logo(&bytes), + ); } ActiveLogo::Iterm2(bytes, logo_rows) => { - render_graphical_side_by_side(text_column_width, &info_lines, logo_rows, || { - logo::print_iterm2_logo(&bytes) - }); + render_graphical_side_by_side( + text_column_width, + &formatted_info_lines, + logo_rows, + || logo::print_iterm2_logo(&bytes), + ); } ActiveLogo::Sixel(bytes, logo_rows) => { - render_graphical_side_by_side(text_column_width, &info_lines, logo_rows, || { - logo::print_sixel_logo(&bytes) - }); + render_graphical_side_by_side( + text_column_width, + &formatted_info_lines, + logo_rows, + || logo::print_sixel_logo(&bytes), + ); } ActiveLogo::None => { - for line in &info_lines { + for line in &formatted_info_lines { println!("{}", line); } } @@ -947,11 +1041,14 @@ mod tests { } #[test] - fn test_layout_long_line_within_logo_forces_stack() { - // A 158-wide line among the first `logo_height` rows WOULD overlap the logo → stack. + fn test_layout_long_line_within_logo_wraps_and_stays_side_by_side() { + // A 158-wide line among the first `logo_height` rows no longer breaks side-by-side layout + // because text_column_width is clamped and the line is wrapped. let mut w = vec![40; 20]; w[5] = 158; - assert!(!plan_layout(&w, 20, 40, 120, true).side_by_side); + let p = plan_layout(&w, 20, 40, 120, true); + assert!(p.side_by_side); + assert_eq!(p.text_column_width, 65); } #[test] @@ -1115,4 +1212,20 @@ mod tests { assert_eq!(format_uptime("31626061s"), "1y 1d 1h 1m 1s"); assert_eq!(format_uptime("0s"), "0s"); } + + #[test] + fn test_wrap_info_line_short_line_unchanged() { + let line = "Audio: Windows Audio (USB Audio Device)"; + let wrapped = wrap_info_line(line, 50); + assert_eq!(wrapped, vec![line.to_string()]); + } + + #[test] + fn test_wrap_info_line_wraps_and_indents() { + let line = "Audio: Windows Audio (USB Audio Device, AMD High Definition Audio Device, AMD SoundWire Device)"; + let wrapped = wrap_info_line(line, 45); + assert!(wrapped.len() > 1); + assert!(wrapped[0].starts_with("Audio: Windows Audio")); + assert!(wrapped[1].starts_with(" ")); + } } diff --git a/src/logo.rs b/src/logo.rs index 9152d2ca..1e2eaa18 100644 --- a/src/logo.rs +++ b/src/logo.rs @@ -298,7 +298,7 @@ pub fn print_with_chafa(path: &std::path::Path) -> bool { cmd.arg("--format") .arg("symbols") .arg("--size") - .arg("40x20"); + .arg("34x12"); if chafa_supports_probe() { cmd.arg("--probe").arg("off"); @@ -328,7 +328,7 @@ pub fn get_chafa_logo_lines(path: &std::path::Path) -> Option> { cmd.arg("--format") .arg("symbols") .arg("--size") - .arg("40x20"); + .arg("34x12"); if chafa_supports_probe() { cmd.arg("--probe").arg("off"); From 0ed737e9e9a95656f9025a6cd345e9da3e6eaa38 Mon Sep 17 00:00:00 2001 From: Ken Tobias <634380+l1a@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:35:59 -0700 Subject: [PATCH 2/4] fix(display): constrain graphic logo height and wrap below-logo lines to full terminal width Assisted-By: Gemini 3.6 Flash --- src/display.rs | 93 +++++++++++++++++++++++++++++++++++++++++--------- src/logo.rs | 14 +++++--- 2 files changed, 86 insertions(+), 21 deletions(-) diff --git a/src/display.rs b/src/display.rs index b30fdf16..2054f8b3 100644 --- a/src/display.rs +++ b/src/display.rs @@ -108,9 +108,11 @@ pub fn visible_len(s: &str) -> usize { count } -/// Wrap a formatted info line (key: value) at word boundaries to fit within `max_width`. +/// Wrap a formatted info line (key: value) at logical boundaries to fit within `max_width`. /// /// Continuation lines are indented to align with the start of the value portion. +/// Prefers splitting on logical delimiters (e.g. `, `, ` - `) over arbitrary space boundaries, +/// keeping atomic pairs (like `RX: ... TX: ...`) on the same line. pub fn wrap_info_line(line: &str, max_width: usize) -> Vec { let vis_len = visible_len(line); if vis_len <= max_width || max_width < 20 { @@ -130,23 +132,75 @@ pub fn wrap_info_line(line: &str, max_width: usize) -> Vec { }; let indent = " ".repeat(prefix_len.min(max_width / 2)); + + // Try logical splitting by comma (", ") if present + if line.contains(", ") { + let parts: Vec<&str> = line.split(", ").collect(); + let mut lines = Vec::new(); + let mut current = String::new(); + + for (i, part) in parts.iter().enumerate() { + let item = if i == 0 { + part.to_string() + } else { + format!(", {}", part) + }; + let item_vis = visible_len(&item); + + if current.is_empty() || visible_len(¤t) + item_vis <= max_width { + current.push_str(&item); + } else { + lines.push(current); + current = format!("{}{}", indent, part); + } + } + if !current.is_empty() { + lines.push(current); + } + if lines.iter().all(|l| visible_len(l) <= max_width + 10) { + return lines; + } + } + + // Whitespace splitting fallback: group RX/TX headers with their values + let raw_words: Vec<&str> = line.split_whitespace().collect(); + let mut words: Vec = Vec::new(); + let mut idx = 0; + while idx < raw_words.len() { + if raw_words[idx] == "RX:" + && idx + 3 < raw_words.len() + && raw_words.iter().skip(idx).any(|&w| w == "TX:") + { + let rx_tx = format!( + "{} {} {} {} {} {}", + raw_words[idx], + raw_words[idx + 1], + raw_words[idx + 2], + raw_words[idx + 3], + raw_words.get(idx + 4).copied().unwrap_or(""), + raw_words.get(idx + 5).copied().unwrap_or("") + ); + words.push(rx_tx.trim().to_string()); + idx += if idx + 5 < raw_words.len() { 6 } else { 4 }; + continue; + } + words.push(raw_words[idx].to_string()); + idx += 1; + } + let mut lines = Vec::new(); let mut current = String::new(); - let mut current_vis = 0; - for word in line.split_whitespace() { - let word_vis = visible_len(word); + for word in words { + let word_vis = visible_len(&word); if current.is_empty() { - current.push_str(word); - current_vis = word_vis; - } else if current_vis + 1 + word_vis <= max_width { + current.push_str(&word); + } else if visible_len(¤t) + 1 + word_vis <= max_width { current.push(' '); - current.push_str(word); - current_vis += 1 + word_vis; + current.push_str(&word); } else { lines.push(current); current = format!("{}{}", indent, word); - current_vis = visible_len(&indent) + word_vis; } } if !current.is_empty() { @@ -767,10 +821,16 @@ pub fn display(info: &SystemInfo, cli: &Cli, config: &Config) -> anyhow::Result< println!(); // leading newline let formatted_info_lines: Vec = if side_by_side && text_column_width > 15 { - info_lines - .iter() - .flat_map(|line| wrap_info_line(line, text_column_width.saturating_sub(2))) - .collect() + let mut result = Vec::new(); + for (i, line) in info_lines.iter().enumerate() { + let max_w = if i < logo_height { + text_column_width.saturating_sub(2) + } else { + term_width.saturating_sub(2) + }; + result.extend(wrap_info_line(line, max_w)); + } + result } else { info_lines.clone() }; @@ -956,9 +1016,10 @@ fn format_uptime(uptime: &str) -> String { fn graphical_logo_height_lines(bytes: &[u8]) -> usize { let img_h = image::load_from_memory(bytes) .map(|img| img.height() as usize) - .unwrap_or(384); + .unwrap_or(200); let cell_h = terminal_cell_height_px(); - img_h.div_ceil(cell_h) + let rows = img_h.div_ceil(cell_h); + rows.min(10) } /// Returns the terminal cell height in pixels via TIOCGWINSZ, or 20 as fallback. diff --git a/src/logo.rs b/src/logo.rs index 1e2eaa18..17d7778c 100644 --- a/src/logo.rs +++ b/src/logo.rs @@ -298,7 +298,7 @@ pub fn print_with_chafa(path: &std::path::Path) -> bool { cmd.arg("--format") .arg("symbols") .arg("--size") - .arg("34x12"); + .arg("28x10"); if chafa_supports_probe() { cmd.arg("--probe").arg("off"); @@ -328,7 +328,7 @@ pub fn get_chafa_logo_lines(path: &std::path::Path) -> Option> { cmd.arg("--format") .arg("symbols") .arg("--size") - .arg("34x12"); + .arg("28x10"); if chafa_supports_probe() { cmd.arg("--probe").arg("off"); @@ -437,7 +437,7 @@ pub fn print_iterm2_logo(image_data: &[u8]) { use base64::Engine; let encoded = base64::engine::general_purpose::STANDARD.encode(image_data); print!( - "\x1b]1337;File=inline=1;preserveAspectRatio=1:{}\x07", + "\x1b]1337;File=inline=1;height=10;preserveAspectRatio=1:{}\x07", encoded ); println!(); // iTerm2 typically needs a newline after the logo @@ -471,7 +471,10 @@ pub fn print_graphical_logo(image_data: &[u8]) { let encoded = base64::engine::general_purpose::STANDARD.encode(image_data); if width > 0 && height > 0 { - println!("\x1b_Gf=100,s={},v={},a=T;{}\x1b\\", width, height, encoded); + println!( + "\x1b_Gf=100,s={},v={},c=26,r=10,a=T;{}\x1b\\", + width, height, encoded + ); } else { println!("\x1b_Gf=100,a=T;{}", encoded); } @@ -481,7 +484,8 @@ pub fn print_graphical_logo(image_data: &[u8]) { #[cfg(feature = "graphics")] pub fn print_sixel_logo(image_data: &[u8]) { if let Ok(img) = image::load_from_memory(image_data) { - let rgba = img.to_rgba8(); + let resized = img.resize(240, 200, image::imageops::FilterType::Triangle); + let rgba = resized.to_rgba8(); let (width, height) = rgba.dimensions(); print_sixel_rgba(rgba.as_raw(), width, height); } From 03a1aac93e8961770fdc338e2b5a66574cd697e0 Mon Sep 17 00:00:00 2001 From: Ken Tobias <634380+l1a@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:49:15 -0700 Subject: [PATCH 3/4] fix(sysinfo): normalize and deduplicate Windows audio device names Assisted-By: Gemini 3.6 Flash --- crates/sysinfo/src/audio.rs | 74 +++++++++++++++++++++++++++++++++---- 1 file changed, 67 insertions(+), 7 deletions(-) diff --git a/crates/sysinfo/src/audio.rs b/crates/sysinfo/src/audio.rs index 538298f9..e904fbbd 100644 --- a/crates/sysinfo/src/audio.rs +++ b/crates/sysinfo/src/audio.rs @@ -60,13 +60,10 @@ pub fn detect_audio(sys: &sysinfo::System) -> Option { if let Some(name) = win_reg::get_reg_string(win_reg::HKEY_LOCAL_MACHINE, &subkey, "DriverDesc") { - let name = name.trim().to_string(); - let lower = name.to_lowercase(); - let is_synthetic = lower.starts_with("microsoft streaming") - || lower.starts_with("microsoft trusted audio") - || lower == "microsoft audio stack"; - if !name.is_empty() && !is_synthetic && !devices.contains(&name) { - devices.push(name); + if let Some(clean_name) = normalize_win_audio_device(name.trim()) { + if !devices.contains(&clean_name) { + devices.push(clean_name); + } } } } @@ -137,6 +134,41 @@ pub fn parse_asound_cards(content: &str, asound_dir: &str) -> Vec { devices } +/// Filter out synthetic software proxy audio drivers and normalize root hardware controller names on Windows. +#[allow(dead_code)] +pub fn normalize_win_audio_device(name: &str) -> Option { + let lower = name.to_lowercase(); + if lower.is_empty() + || lower.starts_with("microsoft ") + || lower.contains("streaming") + || lower.contains("trusted audio") + || lower.contains("a2dp") + || lower.contains("render audio") + || lower.contains("capture audio") + || lower.contains("uaj ") + || lower.contains("speaker device") + || lower.contains("microphone device") + { + return None; + } + if lower.contains("soundwire") { + return Some("AMD SoundWire Audio".to_string()); + } + if lower.contains("amd high definition audio") || lower == "amd audio device" { + return Some("AMD High Definition Audio".to_string()); + } + if lower.contains("realtek") { + return Some("Realtek High Definition Audio".to_string()); + } + if lower.contains("nvidia") { + return Some("NVIDIA High Definition Audio".to_string()); + } + if lower.contains("intel") { + return Some("Intel Smart Sound Technology".to_string()); + } + Some(name.to_string()) +} + #[cfg(test)] mod tests { use super::*; @@ -154,4 +186,32 @@ mod tests { ] ); } + + #[test] + fn test_normalize_win_audio_device_filters_synthetic_and_normalizes() { + assert_eq!( + normalize_win_audio_device("Microsoft Streaming Service Proxy"), + None + ); + assert_eq!( + normalize_win_audio_device("Microsoft Bluetooth A2dp Source"), + None + ); + assert_eq!( + normalize_win_audio_device("AMD SoundWire Audio Streaming Speaker Device"), + None + ); + assert_eq!( + normalize_win_audio_device("AMD SoundWire Audio Streaming Device"), + Some("AMD SoundWire Audio".to_string()) + ); + assert_eq!( + normalize_win_audio_device("USB Audio Device"), + Some("USB Audio Device".to_string()) + ); + assert_eq!( + normalize_win_audio_device("AMD High Definition Audio Device"), + Some("AMD High Definition Audio".to_string()) + ); + } } From e9d004554b00592e8ffd43212d94bd95f161aa27 Mon Sep 17 00:00:00 2001 From: Ken Tobias <634380+l1a@users.noreply.github.com> Date: Mon, 10 Aug 2026 13:54:19 -0700 Subject: [PATCH 4/4] fix(sysinfo): evaluate soundwire before streaming filter in normalize_win_audio_device Assisted-By: Gemini 3.6 Flash --- crates/sysinfo/src/audio.rs | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/crates/sysinfo/src/audio.rs b/crates/sysinfo/src/audio.rs index e904fbbd..ea59b7e2 100644 --- a/crates/sysinfo/src/audio.rs +++ b/crates/sysinfo/src/audio.rs @@ -140,7 +140,6 @@ pub fn normalize_win_audio_device(name: &str) -> Option { let lower = name.to_lowercase(); if lower.is_empty() || lower.starts_with("microsoft ") - || lower.contains("streaming") || lower.contains("trusted audio") || lower.contains("a2dp") || lower.contains("render audio") @@ -166,6 +165,9 @@ pub fn normalize_win_audio_device(name: &str) -> Option { if lower.contains("intel") { return Some("Intel Smart Sound Technology".to_string()); } + if lower.contains("streaming") { + return None; + } Some(name.to_string()) }