Add multiline layout and row wrapping to UI trees (#60026)

## What changed

Render prepared UI trees within a viewport's width and height, preserving authored line breaks and wrapping text while retaining styles and hyperlink ranges.

- Add `wrap: "wrap"` for rows to move children onto additional lines; keep `nowrap` as the default.
- Allocate space to row children while reserving later siblings' minimum widths.
- Mark horizontal clipping and vertical overflow with an unlinked ellipsis, including in single-line rendering.
- Respect word hyphens across style boundaries and nonbreaking spaces when wrapping.

## Testing

Add regression tests for nested row wrapping, sibling width allocation, hyperlink offsets and clipping, and word boundaries. Add a buffer snapshot covering authored line breaks, collapsed whitespace, styles, and links.

GitOrigin-RevId: fdfbdee92808c0a8f55e2fdf11db95d68a76d9a5
This commit is contained in:
Joe Savona
2026-11-07 01:49:05 +00:00
committed by copyberry
parent d749ee847f
commit 8e7ccd8472
7 changed files with 436 additions and 70 deletions
+4
View File
@@ -58,6 +58,10 @@ pub(super) fn optional_token<'de, D: Deserializer<'de>, T: DeserializeOwned>(
.transpose()
}
pub(super) fn token<'de, D: Deserializer<'de>, T: DeserializeOwned>(d: D) -> Result<T, D::Error> {
T::deserialize(String::deserialize(d)?.into_deserializer())
}
pub(super) fn gap<'de, D: Deserializer<'de>>(de: D) -> Result<u64, D::Error> {
let gap = u64::deserialize(de)?;
if gap > MAX_GAP {
+115 -43
View File
@@ -1,10 +1,12 @@
//! Render the visible prefix of a horizontal display tree into one terminal line.
//! Lay out prepared text and nested rows within a terminal viewport.
mod prepare;
mod text;
use self::prepare::NodeKind;
use self::prepare::PreparedNode;
use self::text::Paint;
use super::RowWrap;
use super::MAX_TEXT_BYTES;
use super::TextProps;
@@ -14,6 +16,7 @@ use super::theme::RenderTheme;
use crate::line_truncation::truncate_line_with_ellipsis;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::TerminalHyperlink;
use ratatui::layout::Size;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Span;
@@ -41,66 +44,135 @@ impl UiTree {
impl PreparedUiTree<'_> {
pub(crate) fn render_line(&self, width: u16) -> HyperlinkLine {
let Some(root) = self.0.as_ref().filter(|_| width > 0) else {
return HyperlinkLine::default();
self.render_lines(Size::new(width, /*height*/ 1))
.pop()
.unwrap_or_default()
}
/// Fit the tree to the viewport, marking clipped rows and vertical overflow with an unlinked ellipsis.
pub(crate) fn render_lines(&self, size: Size) -> Vec<HyperlinkLine> {
let Some(item) = &self.0 else {
return Vec::new();
};
let width = usize::from(width);
let mut line = first_line(root, width);
if line.width() > width {
line.line = truncate_line_with_ellipsis(line.line, width);
line.clip_hyperlinks_to_width(line.width() - 1);
if size.width == 0 || size.height == 0 {
return Vec::new();
}
line
let width = usize::from(size.width);
let height = usize::from(size.height);
// The extra row distinguishes an exact fit from vertical overflow.
let mut lines = layout_node(item, width, height + 1);
let more = lines.len() > height;
lines.truncate(height);
for (index, line) in lines.iter_mut().enumerate() {
if line.width() > width || (more && index + 1 == height) {
line.line = truncate_line_with_ellipsis(std::mem::take(&mut line.line), width);
line.clip_hyperlinks_to_width(line.width() - 1);
}
}
lines
}
}
fn first_line(node: &PreparedNode<'_>, width: usize) -> HyperlinkLine {
let mut line = HyperlinkLine::default();
fn layout_node(node: &PreparedNode<'_>, width: usize, limit: usize) -> Vec<HyperlinkLine> {
match &node.kind {
NodeKind::Text(text) => {
if let Some(first) = text.lines.first() {
text::paint_chunks(first, &mut line, /*start*/ 0);
}
}
NodeKind::Text(text) => text.layout(width, limit),
NodeKind::Row {
children,
gap,
wrap,
paint,
} => {
let gap = (*gap).min((width + 1) as u64) as usize;
let mut lines = Vec::new();
let mut start = 0;
let mut used = 0;
for child in children {
let fragment = first_line(child, width - used);
let child_width = fragment.width();
if child_width == 0 {
continue;
}
if used > 0 {
let gap = (*gap).min((width + 1 - used) as u64) as usize;
if gap != 0 {
line.line
.push_span(Span::styled(" ".repeat(gap), paint.style));
if let Some(href) = paint.href {
push_link(&mut line, used..used + gap, href);
}
used += gap;
for (i, child) in children.iter().enumerate() {
let preferred = child.widths.preferred.min((width + 1) as u64) as usize;
if *wrap == RowWrap::Wrap && i > start && used + gap + preferred > width {
lines.extend(layout_band(
&children[start..i],
width,
limit - lines.len(),
gap,
*paint,
));
if lines.len() == limit {
return lines;
}
start = i;
used = 0;
}
line.line.spans.extend(fragment.line.spans);
for link in fragment.hyperlinks {
push_link(
&mut line,
used + link.columns.start..used + link.columns.end,
&link.destination,
);
}
used += child_width;
if used > width {
break;
if i > start {
used += gap;
}
used += preferred;
}
lines.extend(layout_band(
&children[start..],
width,
limit - lines.len(),
gap,
*paint,
));
lines
}
}
line
}
fn layout_band(
children: &[PreparedNode<'_>],
width: usize,
limit: usize,
gap: usize,
paint: Paint<'_>,
) -> Vec<HyperlinkLine> {
let bounded = |cells: u64| cells.min((width + 1) as u64) as usize;
let mut reserved = children
.iter()
.map(|c| bounded(c.widths.minimum))
.sum::<usize>()
+ gap * children.len().saturating_sub(1);
let mut offset = 0;
let mut rows: Vec<(HyperlinkLine, usize)> = Vec::new();
for child in children {
let minimum = bounded(child.widths.minimum);
reserved -= minimum;
let available = bounded(child.widths.preferred)
.min(width.saturating_sub(offset + reserved))
.max(minimum);
let fragments = layout_node(child, available.clamp(1, width), limit);
if rows.len() < fragments.len() {
rows.resize_with(fragments.len(), Default::default);
}
for ((line, end), fragment) in rows.iter_mut().zip(fragments) {
// One extra column records overflow without materializing a provider-sized gap.
let start = offset.min(width + 1);
if start > *end {
line.line
.push_span(Span::styled(" ".repeat(start - *end), paint.style));
if let Some(href) = paint.href {
push_link(line, *end..start, href);
}
*end = start;
}
if offset > width {
continue;
}
let start = *end;
*end += fragment.width();
line.line.spans.extend(fragment.line.spans);
for link in fragment.hyperlinks {
push_link(
line,
start + link.columns.start..start + link.columns.end,
&link.destination,
);
}
}
offset += available + gap;
reserved = reserved.saturating_sub(gap);
}
rows.into_iter().map(|(line, _)| line).collect()
}
fn sanitize_leaf(raw: &str) -> Cow<'_, str> {
+26 -6
View File
@@ -3,6 +3,7 @@
use super::text::InlineText;
use super::text::Paint;
use super::text::PreparedText;
use crate::ui_tree::RowWrap;
use crate::ui_tree::UiNode;
use crate::ui_tree::theme::RenderTheme;
use ratatui::style::Style;
@@ -22,6 +23,7 @@ pub(super) enum NodeKind<'a> {
Row {
children: Vec<PreparedNode<'a>>,
gap: u64,
wrap: RowWrap,
paint: Paint<'a>,
},
}
@@ -40,7 +42,12 @@ impl<'a> PreparedNode<'a> {
if items.len() <= 1 {
items.pop()
} else {
Self::row(items, /*gap*/ 0, Paint { style, href })
Self::row(
items,
/*gap*/ 0,
RowWrap::Nowrap,
Paint { style, href },
)
}
}
@@ -68,7 +75,7 @@ impl<'a> PreparedNode<'a> {
})
}
fn row(mut children: Vec<Self>, gap: u64, paint: Paint<'a>) -> Option<Self> {
fn row(mut children: Vec<Self>, gap: u64, wrap: RowWrap, paint: Paint<'a>) -> Option<Self> {
children.retain(|child| {
child.widths.preferred > 0
|| match &child.kind {
@@ -81,9 +88,16 @@ impl<'a> PreparedNode<'a> {
}
let gaps = gap.saturating_mul(children.len() as u64 - 1);
let widths = Widths {
minimum: children
.iter()
.fold(gaps, |w, c| w.saturating_add(c.widths.minimum)),
minimum: match wrap {
RowWrap::Nowrap => children
.iter()
.fold(gaps, |w, c| w.saturating_add(c.widths.minimum)),
RowWrap::Wrap => children
.iter()
.map(|c| c.widths.minimum)
.max()
.unwrap_or_default(),
},
preferred: children
.iter()
.fold(gaps, |w, c| w.saturating_add(c.widths.preferred)),
@@ -93,6 +107,7 @@ impl<'a> PreparedNode<'a> {
kind: NodeKind::Row {
children,
gap,
wrap,
paint,
},
})
@@ -121,7 +136,12 @@ impl<'a> PreparedNode<'a> {
.flatten()
.filter_map(|node| Self::from_node(node, style, href, theme))
.collect();
items.extend(Self::row(children, props.gap, Paint { style, href }));
items.extend(Self::row(
children,
props.gap,
props.wrap,
Paint { style, href },
));
return;
}
UiNode::Text { props, children } => {
+55 -17
View File
@@ -8,6 +8,8 @@ use ratatui::style::Style;
use ratatui::text::Span;
use std::borrow::Cow;
use std::ops::Range;
use textwrap::core::Fragment;
use textwrap::wrap_algorithms::wrap_first_fit;
use unicode_linebreak::linebreaks;
use unicode_linebreak::split_at_safe;
use unicode_segmentation::GraphemeCursor;
@@ -215,22 +217,34 @@ fn chunks(runs: Vec<Run<'_>>) -> Vec<Chunk<'_>> {
let mut out = Vec::new();
let mut chunk = Chunk::default();
let mut offset = 0;
let mut previous = None;
let mut word_hyphen = false;
for run in runs {
let mut start = 0;
for (i, _) in run.text.grapheme_indices(true) {
while breaks.next_if(|b| *b < offset + i).is_some() {}
if breaks.next_if_eq(&(offset + i)).is_none() {
continue;
for (i, grapheme) in run.text.grapheme_indices(true) {
let position = offset + i;
while breaks.next_if(|b| *b < position).is_some() {}
// Match textwrap's default hyphen splitter, even across style boundaries.
let can_break = match previous {
Some('-') => word_hyphen && grapheme.starts_with(char::is_alphanumeric),
Some('\u{ad}') => false,
_ => breaks.peek() == Some(&position),
};
if can_break {
chunk.push(&run, start..i);
if chunk.runs.last().is_some_and(|r| r.text == " ") {
chunk.trailing_space = chunk.runs.pop().map(|r| r.paint);
chunk.width_cells -= 1;
}
if !chunk.runs.is_empty() {
out.push(std::mem::take(&mut chunk));
}
start = i;
}
chunk.push(&run, start..i);
if chunk.runs.last().is_some_and(|r| r.text == " ") {
chunk.trailing_space = chunk.runs.pop().map(|r| r.paint);
chunk.width_cells -= 1;
for ch in grapheme.chars() {
word_hyphen = ch == '-' && previous.is_some_and(char::is_alphanumeric);
previous = Some(ch);
}
if !chunk.runs.is_empty() {
out.push(std::mem::take(&mut chunk));
}
start = i;
}
chunk.push(&run, start..run.text.len());
offset += run.text.len();
@@ -251,8 +265,32 @@ impl<'a> Chunk<'a> {
}
}
pub(super) fn paint_chunks(chunks: &[Chunk<'_>], line: &mut HyperlinkLine, start: usize) -> usize {
let mut col = start;
impl Fragment for Chunk<'_> {
fn width(&self) -> f64 {
self.width_cells as f64
}
fn whitespace_width(&self) -> f64 {
f64::from(self.trailing_space.is_some())
}
fn penalty_width(&self) -> f64 {
0.0
}
}
impl PreparedText<'_> {
pub(super) fn layout(&self, width: usize, limit: usize) -> Vec<HyperlinkLine> {
self.lines
.iter()
.flat_map(|line| wrap_first_fit(line, &[width as f64]))
.take(limit)
.map(paint_chunks)
.collect()
}
}
fn paint_chunks(chunks: &[Chunk<'_>]) -> HyperlinkLine {
let mut line = HyperlinkLine::default();
let mut col = 0;
for (index, chunk) in chunks.iter().enumerate() {
for run in &chunk.runs {
let span = Span::styled(run.text.to_string(), run.paint.style);
@@ -260,7 +298,7 @@ pub(super) fn paint_chunks(chunks: &[Chunk<'_>], line: &mut HyperlinkLine, start
if let Some(url) = run.paint.href
&& end > col
{
push_link(line, col..end, url);
push_link(&mut line, col..end, url);
}
line.line.push_span(span);
col = end;
@@ -270,12 +308,12 @@ pub(super) fn paint_chunks(chunks: &[Chunk<'_>], line: &mut HyperlinkLine, start
{
line.line.push_span(Span::styled(" ", paint.style));
if let Some(url) = paint.href {
push_link(line, col..col + 1, url);
push_link(&mut line, col..col + 1, url);
}
col += 1;
}
}
col - start
line
}
#[cfg(test)]