Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
2 changes: 2 additions & 0 deletions NOTES.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 —
Expand Down
72 changes: 69 additions & 3 deletions crates/sysinfo/src/audio.rs
Original file line number Diff line number Diff line change
Expand Up @@ -60,9 +60,10 @@ pub fn detect_audio(sys: &sysinfo::System) -> Option<String> {
if let Some(name) =
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) {
devices.push(name);
if let Some(clean_name) = normalize_win_audio_device(name.trim()) {
if !devices.contains(&clean_name) {
devices.push(clean_name);
}
}
}
}
Expand Down Expand Up @@ -133,6 +134,43 @@ pub fn parse_asound_cards(content: &str, asound_dir: &str) -> Vec<String> {
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<String> {
let lower = name.to_lowercase();
if lower.is_empty()
|| lower.starts_with("microsoft ")
|| 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());
}
if lower.contains("streaming") {
return None;
}
Some(name.to_string())
}

#[cfg(test)]
mod tests {
use super::*;
Expand All @@ -150,4 +188,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())
);
}
}
210 changes: 192 additions & 18 deletions src/display.rs
Original file line number Diff line number Diff line change
Expand Up @@ -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 {
Expand All @@ -84,6 +90,130 @@ 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 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<String> {
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));

// 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(&current) + 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<String> = 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();

for word in words {
let word_vis = visible_len(&word);
if current.is_empty() {
current.push_str(&word);
} else if visible_len(&current) + 1 + word_vis <= max_width {
current.push(' ');
current.push_str(&word);
} else {
lines.push(current);
current = format!("{}{}", indent, word);
}
}
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
Expand Down Expand Up @@ -690,12 +820,27 @@ pub fn display(info: &SystemInfo, cli: &Cli, config: &Config) -> anyhow::Result<

println!(); // leading newline

let formatted_info_lines: Vec<String> = if side_by_side && text_column_width > 15 {
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()
};

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 {
Expand All @@ -707,22 +852,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);
}
}
Expand Down Expand Up @@ -862,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.
Expand Down Expand Up @@ -947,11 +1102,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]
Expand Down Expand Up @@ -1115,4 +1273,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(" "));
}
}
Loading