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"
|
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"
|
||||||
|
|||||||
+145
-5
@@ -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)| {
|
||||||
Kind::Normal => format!(" │ {text}"),
|
let text = match max_width {
|
||||||
Kind::Warning => format!(" │ {}", text.as_str().yellow()),
|
Some(width) => ellipsize(text, width),
|
||||||
Kind::Error => format!(" │ {}", text.as_str().red()),
|
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<_>>()
|
.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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user