From d1056fbbbffa6d2f0b6ed0b671622a3727ae24b7 Mon Sep 17 00:00:00 2001 From: Valentin Haudiquet Date: Sun, 20 Sep 2026 00:15:37 +0200 Subject: [PATCH] ui: ellipsize fake-terminal pane lines wider than the terminal Overflowing log lines in the pkh deb / pkh build rolling pane used to wrap onto a second line, corrupting the pane layout. Truncate them to the terminal width (minus the pane prefix) by display width and append an ellipsis instead; lines are left whole when the terminal size is unknown. --- Cargo.toml | 1 + src/ui/deb.rs | 150 ++++++++++++++++++++++++++++++++++++++++++++++++-- 2 files changed, 146 insertions(+), 5 deletions(-) diff --git a/Cargo.toml b/Cargo.toml index 48e8ce6..5612c60 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -34,6 +34,7 @@ ssh2 = "0.9.5" gpgme = "0.11" serde_yaml = "0.9" lazy_static = "1.4.0" +unicode-width = "0.2" [dev-dependencies] test-log = "0.2.19" diff --git a/src/ui/deb.rs b/src/ui/deb.rs index f01d998..4bbfb01 100644 --- a/src/ui/deb.rs +++ b/src/ui/deb.rs @@ -19,6 +19,7 @@ use std::time::{Duration, Instant}; use crossterm::{cursor, execute, style::Stylize, terminal::Clear, terminal::ClearType}; use directories::ProjectDirs; use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; +use unicode_width::{UnicodeWidthChar, UnicodeWidthStr}; use crate::context::{LineSink, Stream}; use crate::logfmt::{Action, Classifier, GenericClassifier}; @@ -411,19 +412,65 @@ fn push_line(shared: &Shared, st: &mut Pipeline, kind: Kind, text: String) { } } -/// Render the pane content with per-kind colors +/// Render the pane content with per-kind colors, ellipsizing lines that are +/// wider than the terminal so they do not overflow onto a wrapped line fn render_pane(lines: &VecDeque<(Kind, String)>) -> String { + let max_width = terminal_width().map(|w| w.saturating_sub(PANE_PREFIX_WIDTH)); + render_pane_with_width(lines, max_width) +} + +/// [`render_pane`] with the available pane width injected (in display +/// columns); `None` means the terminal size is unknown and lines are kept whole +fn render_pane_with_width(lines: &VecDeque<(Kind, String)>, max_width: Option) -> String { lines .iter() - .map(|(kind, text)| match kind { - Kind::Normal => format!(" │ {text}"), - Kind::Warning => format!(" │ {}", text.as_str().yellow()), - Kind::Error => format!(" │ {}", text.as_str().red()), + .map(|(kind, text)| { + let text = match max_width { + Some(width) => ellipsize(text, width), + None => text.clone(), + }; + match kind { + Kind::Normal => format!(" │ {text}"), + Kind::Warning => format!(" │ {}", text.as_str().yellow()), + Kind::Error => format!(" │ {}", text.as_str().red()), + } }) .collect::>() .join("\n") } +/// Display width of the ` │ ` prefix rendered before each pane line +const PANE_PREFIX_WIDTH: usize = 4; + +/// Width of the terminal in columns, or `None` when it cannot be determined +fn terminal_width() -> Option { + crossterm::terminal::size() + .ok() + .map(|(cols, _)| cols as usize) +} + +/// Ellipsize `text` to at most `max_width` display columns, keeping its head +/// and appending `…` when it does not fit +fn ellipsize(text: &str, max_width: usize) -> String { + if UnicodeWidthStr::width(text) <= max_width { + return text.to_string(); + } + // Reserve one column for the ellipsis itself + let budget = max_width.saturating_sub(1); + let mut out = String::new(); + let mut width = 0; + for ch in text.chars() { + let w = UnicodeWidthChar::width(ch).unwrap_or(0); + if width + w > budget { + break; + } + out.push(ch); + width += w; + } + out.push('…'); + out +} + /// Status bar style while no determinate progress is known /// /// The target lives on the first line and the current phase/message on its own @@ -516,3 +563,96 @@ extern "C" fn on_sigint(_sig: libc::c_int) { libc::_exit(130); } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn ellipsize_keeps_short_lines() { + assert_eq!(ellipsize("short", 10), "short"); + assert_eq!(ellipsize("exactly10!", 10), "exactly10!"); + } + + #[test] + fn ellipsize_truncates_long_lines_to_the_width_budget() { + let out = ellipsize("a very long build line that overflows", 20); + assert_eq!(UnicodeWidthStr::width(out.as_str()), 20); + assert!(out.ends_with('…')); + assert!(out.starts_with("a very long build")); + } + + #[test] + fn ellipsize_never_exceeds_the_budget_with_wide_characters() { + let out = ellipsize("wíth émojis 🎉 and 文字 mixing", 12); + assert!(UnicodeWidthStr::width(out.as_str()) <= 12); + assert!(out.ends_with('…')); + } + + #[test] + fn ellipsize_degenerate_width_still_terminates() { + assert_eq!(ellipsize("overflowing", 0), "…"); + assert_eq!(ellipsize("overflowing", 1), "…"); + } + + #[test] + fn pane_lines_are_ellipsized_but_keep_their_prefix_and_color() { + let mut lines = VecDeque::new(); + lines.push_back(( + Kind::Normal, + "gcc -DHAVE_CONFIG_H -I. -I.. -g -O2 -c hello.c".to_string(), + )); + lines.push_back(( + Kind::Error, + "an error much too long for the pane".to_string(), + )); + + let rendered = render_pane_with_width(&lines, Some(20)); + + let rendered = rendered.lines().collect::>(); + assert_eq!(rendered.len(), 2); + // The injected budget is the text width; every rendered line stays + // within the simulated terminal width (prefix + budget) + for line in &rendered { + let plain = strip_ansi(line); + assert!( + UnicodeWidthStr::width(plain.as_str()) <= 20 + PANE_PREFIX_WIDTH, + "{plain}" + ); + } + assert!(strip_ansi(rendered[0]).starts_with(" │ gcc -DHAVE_CONFIG_H")); + // The error keeps its color wrapping around the ellipsized text + assert!(rendered[1].contains('\x1b'), "{rendered:?}"); + assert!(strip_ansi(rendered[1]).starts_with(" │ an error much too l…")); + } + + #[test] + fn pane_lines_are_kept_whole_without_a_known_terminal_size() { + let mut lines = VecDeque::new(); + lines.push_back(( + Kind::Normal, + "a line that would overflow a narrow pane".to_string(), + )); + + let rendered = render_pane_with_width(&lines, None); + assert!(rendered.contains("a line that would overflow a narrow pane")); + } + + /// Best-effort ANSI escape stripper, enough for the assertions above + fn strip_ansi(line: &str) -> String { + let mut out = String::new(); + let mut chars = line.chars(); + while let Some(ch) = chars.next() { + if ch == '\x1b' { + for esc in chars.by_ref() { + if esc.is_ascii_alphabetic() { + break; + } + } + } else { + out.push(ch); + } + } + out + } +}