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
83 changes: 68 additions & 15 deletions crates/jp_term/src/width.rs
Original file line number Diff line number Diff line change
Expand Up @@ -30,20 +30,35 @@ pub fn max_line_width(rendered: &str) -> usize {
rendered.lines().map(display_width).max().unwrap_or(0)
}

/// How many clusters past the budget to keep measuring for a ligature that
/// brings the prefix back under it.
///
/// Every width-collapsing rule in `unicode-width` reduces a run of two or three
/// clusters, so a prefix that has overshot recovers within a cluster or two if
/// it recovers at all.
/// Probing a bounded distance keeps a long input from turning the scan
/// quadratic.
const MAX_LIGATURE_PROBES: usize = 8;

/// Truncate `s` to at most `max_width` display columns, appending `…` when
/// cut.
///
/// The ellipsis is counted against the budget, so the result never exceeds
/// `max_width` columns.
/// A `max_width` of `0` yields an empty string.
/// Input that already fits is returned unchanged, which includes zero-column
/// input under a `max_width` of `0`; anything wider than a `max_width` of `0`
/// yields an empty string.
///
/// The cut falls on a grapheme cluster boundary, so an emoji ZWJ or modifier
/// sequence is either kept whole or dropped whole rather than left with a
/// trailing joiner.
///
/// `s` is expected to carry no ANSI escapes: the budget is spent per cluster,
/// `s` is expected to carry no ANSI escapes: the budget is spent on the text,
/// so escape bytes would consume it and could be split mid-sequence.
/// Truncate before styling, not after.
///
/// Runs in time proportional to the input, with the whole-string measurements
/// needed for ligatures confined to the few clusters around the cut.
#[must_use]
pub fn truncate_to_width(s: &str, max_width: usize) -> String {
if display_width(s) <= max_width {
Expand All @@ -55,23 +70,61 @@ pub fn truncate_to_width(s: &str, max_width: usize) -> String {

// Reserve one column for the ellipsis.
let budget = max_width - 1;
let mut width = 0;
let mut out = String::new();

// Measured per cluster rather than per scalar: the scalars of a ZWJ emoji
// sequence sum to twice the width the sequence renders in, which would
// spend the budget at twice the true rate and could cut between a base
// character and its joiner.
for cluster in s.graphemes(true) {
let w = UnicodeWidthStr::width(cluster);
if width + w > budget {
let (end, _) = longest_fitting_prefix(s, budget);
let mut out = s[..end].to_string();
out.push('…');
out
}

/// Byte offset just past the longest prefix of `s` that fits `budget` columns,
/// and the number of whole-string measurements taken to find it.
///
/// The count is returned so tests can pin it: it has to depend on `budget`, not
/// on the size of `s`, and measuring every candidate prefix instead turns a
/// long input quadratic.
fn longest_fitting_prefix(s: &str, budget: usize) -> (usize, usize) {
// The running sum of cluster widths is an upper bound on the true width of
// the prefix: the interactions that make a string narrower than its parts
// (ZWJ emoji sequences, Arabic Lam-Alef, Tifinagh joiners) span cluster
// boundaries, while the ones that make it wider (a quotation mark plus
// U+FE01) stay inside a single cluster and are caught by measuring the
// cluster whole. So a prefix that fits under the sum certainly fits, and
// that costs O(1) per cluster.
//
// Only once the sum passes the budget does the exact width matter, and then
// it takes a whole-string measurement, because a longer prefix can render
// narrower than a shorter one: a Tifinagh consonant joiner costs a column on
// its own and none once the consonant after it completes the ligature.
let mut end = 0;
let mut sum = 0;
let mut probes = 0;
let mut measurements = 0;

for (offset, cluster) in s.grapheme_indices(true) {
let candidate = offset + cluster.len();
sum += UnicodeWidthStr::width(cluster);

if sum <= budget {
end = candidate;
continue;
}

if probes == MAX_LIGATURE_PROBES {
break;
}
width += w;
out.push_str(cluster);
probes += 1;
measurements += 1;

if UnicodeWidthStr::width(&s[..candidate]) <= budget {
end = candidate;
// A ligature closed and brought the prefix back under budget; allow
// a fresh run of probes for the next one.
probes = 0;
}
}
out.push('…');
out

(end, measurements)
}

#[cfg(test)]
Expand Down
137 changes: 137 additions & 0 deletions crates/jp_term/src/width_tests.rs
Original file line number Diff line number Diff line change
Expand Up @@ -19,6 +19,14 @@ fn truncate_to_width_minimal_budgets() {
assert_eq!(truncate_to_width("hello", 0), "");
}

#[test]
fn truncate_to_width_keeps_zero_width_input_at_a_zero_budget() {
// A zero-column string already fits a zero-column budget, so it survives
// untouched. Only input that doesn't fit is dropped for the ellipsis.
assert_eq!(truncate_to_width("\u{200D}", 0), "\u{200D}");
assert_eq!(truncate_to_width("a", 0), "");
}

#[test]
fn truncate_to_width_counts_columns_not_bytes() {
// Each emoji is four bytes wide but two columns, so a 10-column budget
Expand Down Expand Up @@ -92,6 +100,135 @@ fn truncate_to_width_never_leaves_a_dangling_joiner() {
}
}

/// Arabic Lam followed by Alef: two grapheme clusters that ligate into a single
/// column.
const LAM_ALEF: &str = "\u{0644}\u{0627}";

#[test]
fn lam_alef_ligates_narrower_than_its_clusters() {
// The premise the truncator depends on. If this ever changes upstream, the
// test below stops testing what it claims to.
assert_eq!(display_width(LAM_ALEF), 1);
let cluster_sum: usize = LAM_ALEF.graphemes(true).map(display_width).sum();
assert_eq!(cluster_sum, 2);
}

#[test]
fn truncate_to_width_measures_prefixes_not_single_clusters() {
// Four ligatures at one column each. A 3-column budget leaves 2 for text,
// which is exactly two ligatures. Summing cluster widths would have spent
// the whole budget on the first ligature and kept one.
let s = LAM_ALEF.repeat(4);
assert_eq!(display_width(&s), 4);
assert_eq!(
truncate_to_width(&s, 3),
format!("{}\u{2026}", LAM_ALEF.repeat(2))
);
}

/// Tifinagh consonant, consonant joiner, consonant: a ligature that renders in
/// one column.
const TIFINAGH_LIGATURE: &str = "\u{2D4F}\u{2D7F}\u{2D3E}";

#[test]
fn tifinagh_ligature_narrows_once_completed() {
// Prefix width is not monotonic, which is the premise the full boundary
// scan depends on: the joiner extends the first consonant's cluster and
// costs a column there, and completing the ligature drops the total to one.
assert_eq!(TIFINAGH_LIGATURE.graphemes(true).collect::<Vec<_>>(), [
"\u{2D4F}\u{2D7F}",
"\u{2D3E}"
]);
assert_eq!(display_width("\u{2D4F}\u{2D7F}"), 2);
assert_eq!(display_width(TIFINAGH_LIGATURE), 1);
}

#[test]
fn truncate_to_width_keeps_the_longest_fitting_prefix() {
// The ligature renders in one column, so it plus the ellipsis fills the
// 2-column budget exactly. Stopping at the first prefix that overshoots
// would have rejected the two-column consonant-plus-joiner prefix and
// returned the ellipsis alone.
let s = format!("{TIFINAGH_LIGATURE}xx");
assert_eq!(display_width(&s), 3);
assert_eq!(
truncate_to_width(&s, 2),
format!("{TIFINAGH_LIGATURE}\u{2026}")
);
}

/// Sum of the display widths of each grapheme cluster in `s`.
fn cluster_width_sum(s: &str) -> usize {
s.graphemes(true).map(display_width).sum()
}

#[test]
fn cluster_width_sum_is_an_upper_bound_on_display_width() {
// The invariant the cheap path in `truncate_to_width` rests on: summing
// cluster widths can over-count (ligatures) but never under-count, so a
// prefix that fits under the sum certainly fits. A widening interaction
// sits inside one cluster (U+2018 plus U+FE01 renders in two columns), where
// measuring the cluster whole already accounts for it.
for s in [
LAM_ALEF,
TIFINAGH_LIGATURE,
ZWJ_EMOJI,
"\u{2018}\u{FE01}",
"\u{2018}\u{FE00}",
"plain ascii",
"\u{65E5}\u{672C}\u{8A9E}",
] {
assert!(
display_width(s) <= cluster_width_sum(s),
"cluster sum under-counts {s:?}: {} > {}",
display_width(s),
cluster_width_sum(s)
);
}
}

#[test]
fn measurement_count_follows_the_budget_not_the_input_size() {
// Whole-string measurement is the expensive step, so its count has to depend
// on the budget rather than on how long the title is. Conversation titles are
// user-editable and uncapped, and `conversation ls` truncates one per row.
// Measuring every candidate prefix took 76s for the 100k input below, against
// 7ms once the count is bounded.
let (_, short) = longest_fitting_prefix(&"x".repeat(500), 9);
let (_, long) = longest_fitting_prefix(&"x".repeat(100_000), 9);

assert_eq!(short, long, "measurement count grew with the input");
assert!(
long <= MAX_LIGATURE_PROBES,
"{long} measurements for a 9-column cut"
);
}

#[test]
fn measurement_count_stays_bounded_across_ligatures() {
// Ligatures are why the exact measurement exists at all, so the bound has to
// hold for an input made entirely of them, where every probe finds a prefix
// that fits and starts a fresh run.
let (_, short) = longest_fitting_prefix(&LAM_ALEF.repeat(250), 2);
let (_, long) = longest_fitting_prefix(&LAM_ALEF.repeat(50_000), 2);

assert_eq!(short, long, "measurement count grew with the input");
}

#[test]
fn truncate_to_width_cuts_long_input_the_same_way_as_short() {
// The bounded measurement must not change where the cut lands: these are the
// 100k-scale counterparts of the ASCII and Lam-Alef cases above.
assert_eq!(
truncate_to_width(&"x".repeat(100_000), 10),
"xxxxxxxxx\u{2026}"
);
assert_eq!(
truncate_to_width(&LAM_ALEF.repeat(50_000), 3),
format!("{}\u{2026}", LAM_ALEF.repeat(2))
);
}

#[test]
fn display_width_counts_wide_characters_as_two_columns() {
assert_eq!(display_width("日本語"), 6);
Expand Down
Loading