Speed up TUI Markdown layout (#34216)

## What changed

- Allocate Markdown table widths in bulk while preserving the existing column
  shrink priorities and balancing behavior.
- Reuse flattened styled-line data during adaptive wrapping and detect URLs
  across span boundaries.
- Remap wrapped terminal hyperlinks with a single forward scan.

## Testing

- Verify bulk table shrinking matches the previous one-cell-at-a-time results,
  including a token-heavy column with a million-character width.
- Cover URLs split across styled spans and multiple links on indented Unicode
  lines.

GitOrigin-RevId: 24a6fd6fdace6ca948e613d67c130a3a7f8d1a4e
This commit is contained in:
Charlie Marsh
2026-07-19 19:33:31 +00:00
committed by copyberry
parent 3e2f79727a
commit aa982319c2
3 changed files with 298 additions and 110 deletions
+182 -95
View File
@@ -1120,8 +1120,7 @@ where
let metrics = Self::collect_table_column_metrics(&header, &rows, column_count);
let available_width = self.available_table_width(column_count);
let widths =
self.compute_column_widths(&header, &rows, &table_state.alignments, available_width);
let widths = Self::compute_column_widths(&metrics, available_width);
let spillover_lines: Vec<HyperlinkLine> = spillover_rows
.into_iter()
.flat_map(|spillover| spillover.lines)
@@ -1236,19 +1235,15 @@ where
/// Allocate column widths for aligned, row-separated table rendering.
///
/// Each column starts at its natural (max cell content) width, then columns
/// are iteratively shrunk one character at a time until the total fits within
/// `available_width`. Token-heavy columns surrender excess width before
/// narrative prose; compact columns are preserved last. Returns `None` when
/// even the minimum width (3 chars per column) cannot fit.
/// are shrunk by priority until the total fits within `available_width`.
/// Token-heavy columns surrender excess width before narrative prose; compact
/// columns are preserved last. Returns `None` when even the minimum width
/// (3 chars per column) cannot fit.
fn compute_column_widths(
&self,
header: &[TableCell],
rows: &[Vec<TableCell>],
alignments: &[Alignment],
metrics: &[TableColumnMetrics],
available_width: Option<usize>,
) -> Option<Vec<usize>> {
let min_column_width = 3usize;
let metrics = Self::collect_table_column_metrics(header, rows, alignments.len());
let mut widths: Vec<usize> = metrics
.iter()
.map(|col| col.max_width.max(min_column_width))
@@ -1257,7 +1252,7 @@ where
let Some(max_width) = available_width else {
return Some(widths);
};
let minimum_total = alignments.len() * min_column_width;
let minimum_total = metrics.len() * min_column_width;
if max_width < minimum_total {
return None;
}
@@ -1266,41 +1261,19 @@ where
.iter()
.map(|col| Self::preferred_column_floor(col, min_column_width))
.collect();
let mut floor_total: usize = floors.iter().sum();
let floor_total: usize = floors.iter().sum();
if floor_total > max_width {
// Relax preferred floors in wrapping priority order until the hard width budget fits.
while floor_total > max_width {
let Some((idx, _)) = floors
.iter()
.enumerate()
.filter(|(_, floor)| **floor > min_column_width)
.min_by_key(|(idx, floor)| {
(
Self::column_shrink_priority(metrics[*idx].kind),
usize::MAX.saturating_sub(**floor),
)
})
else {
break;
};
floors[idx] -= 1;
floor_total -= 1;
}
}
let mut total_width: usize = widths.iter().sum();
while total_width > max_width {
let Some(idx) = Self::next_column_to_shrink(&widths, &floors, &metrics) else {
break;
};
widths[idx] -= 1;
total_width -= 1;
let minimums = vec![min_column_width; floors.len()];
Self::shrink_columns(&mut floors, &minimums, metrics, floor_total - max_width);
}
let total_width: usize = widths.iter().sum();
if total_width > max_width {
return None;
let remaining =
Self::shrink_columns(&mut widths, &floors, metrics, total_width - max_width);
if remaining > 0 {
return None;
}
}
Some(widths)
@@ -1328,14 +1301,15 @@ where
let cell = &row[column];
max_width = max_width.max(Self::cell_display_width(cell));
let plain = cell.plain_text();
body_token_width = body_token_width.max(Self::longest_token_width(&plain));
let word_count = plain.split_whitespace().count();
let mut word_count = 0usize;
for token in plain.split_whitespace() {
let token_width = token.width();
body_token_width = body_token_width.max(token_width);
long_body_token_count += usize::from(token_width >= 20);
word_count += 1;
}
if word_count > 0 {
body_token_count += word_count;
long_body_token_count += plain
.split_whitespace()
.filter(|token| token.width() >= 20)
.count();
total_words += word_count;
total_cells += 1;
total_cell_width += plain.width();
@@ -1389,36 +1363,87 @@ where
token_target.max(min_column_width).min(metrics.max_width)
}
/// Pick the next column to shrink by one character during width allocation.
/// Shrink columns in priority order, balancing slack within each priority.
///
/// Priority: TokenHeavy columns are shrunk before Narrative, then Compact.
/// Within the same kind, the column with the most slack above its floor is
/// chosen so similarly-shaped columns stay balanced.
fn next_column_to_shrink(
widths: &[usize],
/// Within the same kind, columns with the most slack above their floor are
/// shrunk first so similarly-shaped columns stay balanced. This produces the
/// same result as repeatedly shrinking one display cell, without repeatedly
/// scanning every column for long tokens.
fn shrink_columns(
widths: &mut [usize],
floors: &[usize],
metrics: &[TableColumnMetrics],
) -> Option<usize> {
widths
.iter()
.enumerate()
.filter(|(idx, width)| **width > floors[*idx])
.min_by_key(|(idx, width)| {
let slack = width.saturating_sub(floors[*idx]);
(
Self::column_shrink_priority(metrics[*idx].kind),
usize::MAX.saturating_sub(slack),
)
})
.map(|(idx, _)| idx)
}
mut amount: usize,
) -> usize {
for kind in [
TableColumnKind::TokenHeavy,
TableColumnKind::Narrative,
TableColumnKind::Compact,
] {
let slack_total = widths
.iter()
.enumerate()
.filter(|(idx, _)| metrics[*idx].kind == kind)
.map(|(idx, width)| width.saturating_sub(floors[idx]))
.sum::<usize>();
let to_remove = amount.min(slack_total);
if to_remove == 0 {
continue;
}
fn column_shrink_priority(kind: TableColumnKind) -> usize {
match kind {
TableColumnKind::TokenHeavy => 0,
TableColumnKind::Narrative => 1,
TableColumnKind::Compact => 2,
let mut low = 0usize;
let mut high = widths
.iter()
.enumerate()
.filter(|(idx, _)| metrics[*idx].kind == kind)
.map(|(idx, width)| width.saturating_sub(floors[idx]))
.max()
.unwrap_or(/*default*/ 0);
while low < high {
let cap = low + (high - low) / 2;
let removed = widths
.iter()
.enumerate()
.filter(|(idx, _)| metrics[*idx].kind == kind)
.map(|(idx, width)| width.saturating_sub(floors[idx]).saturating_sub(cap))
.sum::<usize>();
if removed > to_remove {
low = cap + 1;
} else {
high = cap;
}
}
let cap = low;
let mut removed = 0usize;
for (idx, width) in widths.iter_mut().enumerate() {
if metrics[idx].kind != kind {
continue;
}
let reduction = width.saturating_sub(floors[idx]).saturating_sub(cap);
*width -= reduction;
removed += reduction;
}
let mut remainder = to_remove - removed;
for (idx, width) in widths.iter_mut().enumerate() {
if remainder == 0 {
break;
}
if metrics[idx].kind == kind && width.saturating_sub(floors[idx]) == cap {
*width -= 1;
remainder -= 1;
}
}
amount -= to_remove;
if amount == 0 {
break;
}
}
amount
}
fn render_table_separator(
@@ -2596,38 +2621,100 @@ mod tests {
}
#[test]
fn next_column_to_shrink_prefers_token_heavy_then_narrative() {
let widths = [20usize, 20, 20];
let floors = [8usize, 8, 8];
fn bulk_column_shrink_matches_one_cell_at_a_time() {
fn priority(kind: TableColumnKind) -> usize {
match kind {
TableColumnKind::TokenHeavy => 0,
TableColumnKind::Narrative => 1,
TableColumnKind::Compact => 2,
}
}
fn shrink_one_at_a_time(
widths: &mut [usize],
floors: &[usize],
metrics: &[TableColumnMetrics],
mut amount: usize,
) -> usize {
while amount > 0 {
let Some(idx) = widths
.iter()
.enumerate()
.filter(|(idx, width)| **width > floors[*idx])
.min_by_key(|(idx, width)| {
(
priority(metrics[*idx].kind),
usize::MAX - width.saturating_sub(floors[*idx]),
)
})
.map(|(idx, _)| idx)
else {
break;
};
widths[idx] -= 1;
amount -= 1;
}
amount
}
for case in 0..64usize {
let metrics = (0..5)
.map(|idx| TableColumnMetrics {
max_width: 100,
header_token_width: 8,
body_token_width: 8,
kind: match (case + idx) % 3 {
0 => TableColumnKind::TokenHeavy,
1 => TableColumnKind::Narrative,
_ => TableColumnKind::Compact,
},
})
.collect::<Vec<_>>();
let floors = (0..5)
.map(|idx| 3 + (case * (idx + 1) + idx) % 14)
.collect::<Vec<_>>();
let initial = floors
.iter()
.enumerate()
.map(|(idx, floor)| floor + (case * (idx + 3) + idx * 7) % 41)
.collect::<Vec<_>>();
let slack = initial
.iter()
.zip(&floors)
.map(|(width, floor)| width - floor)
.sum::<usize>();
for amount in 0..=slack + 1 {
let mut expected = initial.clone();
let expected_remaining =
shrink_one_at_a_time(&mut expected, &floors, &metrics, amount);
let mut actual = initial.clone();
let actual_remaining = W::shrink_columns(&mut actual, &floors, &metrics, amount);
assert_eq!((actual, actual_remaining), (expected, expected_remaining));
}
}
}
#[test]
fn column_widths_bulk_shrink_large_token_heavy_column() {
let metrics = [
TableColumnMetrics {
max_width: 30,
header_token_width: 8,
body_token_width: 6,
kind: TableColumnKind::Narrative,
},
TableColumnMetrics {
max_width: 30,
header_token_width: 8,
body_token_width: 28,
max_width: 1_000_000,
header_token_width: 4,
body_token_width: 1_000_000,
kind: TableColumnKind::TokenHeavy,
},
TableColumnMetrics {
max_width: 30,
header_token_width: 8,
body_token_width: 6,
max_width: 8,
header_token_width: 5,
body_token_width: 8,
kind: TableColumnKind::Compact,
},
];
let idx = W::next_column_to_shrink(&widths, &floors, &metrics);
assert_eq!(idx, Some(1), "token-heavy column should shrink first");
let widths = [20usize, 8, 20];
let idx = W::next_column_to_shrink(&widths, &floors, &metrics);
assert_eq!(
idx,
Some(0),
"narrative column should shrink before compact"
W::compute_column_widths(&metrics, /*available_width*/ Some(24)),
Some(vec![16, 8])
);
}
+58 -2
View File
@@ -207,9 +207,14 @@ pub(crate) fn remap_wrapped_line(
wrapped: Vec<Line<'static>>,
) -> Vec<HyperlinkLine> {
let mut out = plain_hyperlink_lines(wrapped);
if source.hyperlinks.is_empty() {
return out;
}
let source_text = line_text(&source.line);
let mut source_byte = 0usize;
let mut source_column = 0usize;
let mut link_index = 0usize;
for (index, line) in out.iter_mut().enumerate() {
if index > 0 {
let trimmed = source_text[source_byte..].trim_start_matches(char::is_whitespace);
@@ -227,10 +232,17 @@ pub(crate) fn remap_wrapped_line(
let mut output_column = rendered[..rendered_start].width();
for ch in mapped.chars() {
let width = ch.width().unwrap_or(/*default*/ 0);
while source
.hyperlinks
.get(link_index)
.is_some_and(|link| link.columns.end <= source_column)
{
link_index += 1;
}
if let Some(link) = source
.hyperlinks
.iter()
.find(|link| link.columns.contains(&source_column))
.get(link_index)
.filter(|link| link.columns.contains(&source_column))
{
push_link_range(line, output_column..output_column + width, link);
}
@@ -636,6 +648,50 @@ mod tests {
);
}
#[test]
fn wrapping_maps_multiple_links_across_indented_unicode_lines() {
let text = "alpha 😀here middle there end";
let first_start = text.find("here").expect("first link");
let second_start = text.find("there").expect("second link");
let mut source = HyperlinkLine::new(Line::from(text));
source.hyperlinks.push(TerminalHyperlink::web(
text[..first_start].width()..text[..first_start].width() + "here".width(),
"https://example.com/first".to_string(),
));
source.hyperlinks.push(TerminalHyperlink::web(
text[..second_start].width()..text[..second_start].width() + "there".width(),
"https://example.com/second".to_string(),
));
let wrapped = remap_wrapped_line(
&source,
vec![
Line::from(" alpha 😀here"),
Line::from(" middle there end"),
],
);
assert_eq!(
wrapped,
vec![
HyperlinkLine {
line: Line::from(" alpha 😀here"),
hyperlinks: vec![TerminalHyperlink::web(
/*columns*/ 10..14,
"https://example.com/first".to_string(),
)],
},
HyperlinkLine {
line: Line::from(" middle there end"),
hyperlinks: vec![TerminalHyperlink::web(
/*columns*/ 11..16,
"https://example.com/second".to_string(),
)],
},
]
);
}
#[test]
fn buffer_hyperlinks_follow_word_wrapping() {
let destination = "https://example.com/path";
+58 -13
View File
@@ -506,14 +506,28 @@ pub(crate) fn url_preserving_wrap_options<'a>(opts: RtOptions<'a>) -> RtOptions<
/// while a genuinely overlong non-URL token can still split if needed.
#[must_use]
pub(crate) fn adaptive_wrap_line<'a>(line: &'a Line<'a>, base: RtOptions<'a>) -> Vec<Line<'a>> {
if !line_contains_url_like(line) {
return word_wrap_line(line, base);
let (flat, span_bounds) = flatten_line(line);
let mut saw_url = false;
let mut saw_non_url = false;
for token in flat.split_ascii_whitespace() {
if is_url_like_token(token) {
saw_url = true;
} else if is_substantive_non_url_token(token) {
saw_non_url = true;
}
if saw_url && saw_non_url {
break;
}
}
if line_has_mixed_url_and_non_url_tokens(line) {
mixed_url_wrap_line(line, base)
if !saw_url {
word_wrap_flattened_line(line, &flat, &span_bounds, base)
} else if saw_non_url {
mixed_url_wrap_line(line, &flat, &span_bounds, base)
} else {
word_wrap_line(line, url_preserving_wrap_options(base))
word_wrap_flattened_line(line, &flat, &span_bounds, url_preserving_wrap_options(base))
}
}
@@ -661,8 +675,15 @@ where
O: Into<RtOptions<'a>>,
{
let (flat, span_bounds) = flatten_line(line);
word_wrap_flattened_line(line, &flat, &span_bounds, width_or_options.into())
}
let rt_opts: RtOptions<'a> = width_or_options.into();
fn word_wrap_flattened_line<'a>(
line: &'a Line<'a>,
flat: &str,
span_bounds: &[(Range<usize>, ratatui::style::Style)],
rt_opts: RtOptions<'a>,
) -> Vec<Line<'a>> {
let opts = Options::new(rt_opts.width)
.line_ending(rt_opts.line_ending)
.break_words(rt_opts.break_words)
@@ -677,7 +698,7 @@ where
.width
.saturating_sub(rt_opts.initial_indent.width())
.max(1);
let initial_wrapped = wrap_ranges_trim(&flat, opts.clone().width(initial_width_available));
let initial_wrapped = wrap_ranges_trim(flat, opts.clone().width(initial_width_available));
let Some(first_line_range) = initial_wrapped.first() else {
return vec![rt_opts.initial_indent.clone()];
};
@@ -685,7 +706,7 @@ where
// Build first wrapped line with initial indent.
let mut first_line = rt_opts.initial_indent.clone().style(line.style);
{
let sliced = slice_line_spans(line, &span_bounds, first_line_range);
let sliced = slice_line_spans(line, span_bounds, first_line_range);
let mut spans = first_line.spans;
spans.append(
&mut sliced
@@ -713,7 +734,7 @@ where
}
let mut subsequent_line = rt_opts.subsequent_indent.clone().style(line.style);
let offset_range = (r.start + base)..(r.end + base);
let sliced = slice_line_spans(line, &span_bounds, &offset_range);
let sliced = slice_line_spans(line, span_bounds, &offset_range);
let mut spans = subsequent_line.spans;
spans.append(
&mut sliced
@@ -741,8 +762,12 @@ impl MixedUrlWord {
}
}
fn mixed_url_wrap_line<'a>(line: &'a Line<'a>, rt_opts: RtOptions<'a>) -> Vec<Line<'a>> {
let (flat, span_bounds) = flatten_line(line);
fn mixed_url_wrap_line<'a>(
line: &'a Line<'a>,
flat: &str,
span_bounds: &[(Range<usize>, ratatui::style::Style)],
rt_opts: RtOptions<'a>,
) -> Vec<Line<'a>> {
let initial_width_available = rt_opts
.width
.saturating_sub(rt_opts.initial_indent.width())
@@ -751,7 +776,7 @@ fn mixed_url_wrap_line<'a>(line: &'a Line<'a>, rt_opts: RtOptions<'a>) -> Vec<Li
.width
.saturating_sub(rt_opts.subsequent_indent.width())
.max(1);
let ranges = mixed_url_wrap_ranges(&flat, initial_width_available, subsequent_width_available);
let ranges = mixed_url_wrap_ranges(flat, initial_width_available, subsequent_width_available);
let mut out = Vec::new();
for (idx, range) in ranges.iter().enumerate() {
@@ -761,7 +786,7 @@ fn mixed_url_wrap_line<'a>(line: &'a Line<'a>, rt_opts: RtOptions<'a>) -> Vec<Li
rt_opts.subsequent_indent.clone()
}
.style(line.style);
let sliced = slice_line_spans(line, &span_bounds, range);
let sliced = slice_line_spans(line, span_bounds, range);
let mut spans = wrapped_line.spans;
spans.extend(
sliced
@@ -1405,6 +1430,26 @@ them."#
);
}
#[test]
fn adaptive_wrap_line_keeps_url_split_across_styled_spans_intact() {
let line = Line::from(vec![
"see ".red(),
"https://exa".cyan(),
"mple.com/path".magenta(),
" now".green(),
]);
let out = adaptive_wrap_line(&line, RtOptions::new(/*width*/ 10));
assert_eq!(
out,
vec![
Line::from("see".red()),
Line::from(vec!["https://exa".cyan(), "mple.com/path".magenta()]),
Line::from("now".green()),
]
);
}
#[test]
fn adaptive_wrap_line_mixed_line_wraps_long_non_url_token() {
let long_non_url = "a_very_long_token_without_spaces_to_force_wrapping";