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:
@@ -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"
|
||||
|
||||
+142
-2
@@ -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<usize>) -> String {
|
||||
lines
|
||||
.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::Warning => format!(" │ {}", text.as_str().yellow()),
|
||||
Kind::Error => format!(" │ {}", text.as_str().red()),
|
||||
}
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.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
|
||||
///
|
||||
/// 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::<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
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user