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.
This commit is contained in:
2026-09-20 00:15:37 +02:00
parent a0e74073bf
commit d1056fbbbf
2 changed files with 146 additions and 5 deletions
+1
View File
@@ -34,6 +34,7 @@ ssh2 = "0.9.5"
gpgme = "0.11" gpgme = "0.11"
serde_yaml = "0.9" serde_yaml = "0.9"
lazy_static = "1.4.0" lazy_static = "1.4.0"
unicode-width = "0.2"
[dev-dependencies] [dev-dependencies]
test-log = "0.2.19" test-log = "0.2.19"
+142 -2
View File
@@ -19,6 +19,7 @@ use std::time::{Duration, Instant};
use crossterm::{cursor, execute, style::Stylize, terminal::Clear, terminal::ClearType}; use crossterm::{cursor, execute, style::Stylize, terminal::Clear, terminal::ClearType};
use directories::ProjectDirs; use directories::ProjectDirs;
use indicatif::{MultiProgress, ProgressBar, ProgressStyle}; use indicatif::{MultiProgress, ProgressBar, ProgressStyle};
use unicode_width::{UnicodeWidthChar, UnicodeWidthStr};
use crate::context::{LineSink, Stream}; use crate::context::{LineSink, Stream};
use crate::logfmt::{Action, Classifier, GenericClassifier}; 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 { 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<usize>) -> String {
lines lines
.iter() .iter()
.map(|(kind, text)| match kind { .map(|(kind, text)| {
let text = match max_width {
Some(width) => ellipsize(text, width),
None => text.clone(),
};
match kind {
Kind::Normal => format!("{text}"), Kind::Normal => format!("{text}"),
Kind::Warning => format!("{}", text.as_str().yellow()), Kind::Warning => format!("{}", text.as_str().yellow()),
Kind::Error => format!("{}", text.as_str().red()), Kind::Error => format!("{}", text.as_str().red()),
}
}) })
.collect::<Vec<_>>() .collect::<Vec<_>>()
.join("\n") .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<usize> {
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 /// Status bar style while no determinate progress is known
/// ///
/// The target lives on the first line and the current phase/message on its own /// 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); 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::<Vec<_>>();
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
}
}