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
@@ -0,0 +1,20 @@
---
source: tui/src/ui_tree_tests.rs
expression: "crate::terminal_hyperlinks::strip_osc8(&format!(\"{buffer:?}\"))"
---
Buffer {
area: Rect { x: 0, y: 0, width: 8, height: 4 },
content: [
"ready ",
"now ",
" ",
"PR #42… ",
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: BOLD,
x: 3, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: UNDERLINED,
x: 7, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
+14
View File
@@ -113,6 +113,20 @@ pub(crate) struct RowProps {
skip_serializing_if = "Option::is_none"
)]
background: Option<Color>,
#[serde(
default,
deserialize_with = "decode::token",
skip_serializing_if = "is_default"
)]
wrap: RowWrap,
}
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "lowercase")]
enum RowWrap {
#[default]
Nowrap,
Wrap,
}
fn is_default<T: Default + PartialEq>(value: &T) -> bool {
+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)]
+202 -4
View File
@@ -1,9 +1,11 @@
#![cfg(test)]
use super::*;
use crate::terminal_hyperlinks::TerminalHyperlink;
use pretty_assertions::assert_eq;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::layout::Size;
use serde_json::Value;
use serde_json::json;
use syntect::highlighting::Theme;
@@ -186,7 +188,7 @@ fn zero_width_fragments_do_not_style_the_ellipsis_or_add_row_gaps() {
}
#[test]
fn first_line_skips_hidden_text_before_rendering_its_sibling() {
fn single_line_view_marks_vertical_overflow() {
let multiline = format!("\u{1b}[2Jok\n{}", "x".repeat(1024));
let tree = tree(json!({"type":"row","props":{"gap":0},"children":[
&multiline, "!"
@@ -195,7 +197,7 @@ fn first_line_skips_hidden_text_before_rendering_its_sibling() {
tree.render_line(/*width*/ 4, &Theme::default())
.line
.to_string(),
"ok!"
"ok …"
);
}
@@ -486,6 +488,202 @@ fn whitespace_collapses_across_styled_leaves() {
let node = tree(json!({"type":"text","children":[
" build\t", {"type":"bold","children":[" ready "]}, "\nnext"
]}));
let line = node.render_line(/*width*/ 20, &Theme::default());
insta::assert_snapshot!(line.line.to_string(), @"build ready");
let lines = node
.prepare(&Theme::default())
.render_lines(Size::new(/*width*/ 20, /*height*/ 2));
let text: Vec<_> = lines
.into_iter()
.map(|line| line.line.to_string())
.collect();
insta::assert_snapshot!(text.join("\n"), @r"
build ready
next
");
}
#[test]
fn reads_row_wrap_without_moving_existing_positional_props() {
let row = |props| json!({"type":"row","props":props,"children":["ok"]});
assert_eq!(
tree(row(json!([1, "red"]))),
tree(row(json!({"gap":1,"background":"red"})))
);
assert_eq!(
tree(row(json!({"gap":1,"wrap":"nowrap"}))),
tree(row(json!({"gap":1})))
);
for invalid in [json!("wrap-reverse"), json!(null), json!({"wrap":null})] {
assert!(parse(row(json!({"gap":0,"wrap":invalid}))).is_err());
}
}
#[test]
fn wrapped_link_offsets_follow_each_row_and_stop_before_clipping() {
let node = tree(json!({"type":"row","props":{"gap":0},"children":[
">",
{"type":"link","props":{"href":"https://example.com"},"children":[
{"type":"row","props":{"gap":0,"wrap":"wrap"},"children":["12345","67"]}
]}
]}));
for (size, expected) in [
(Size::new(6, 2), vec![(">12345", 1..6), (" 67", 1..3)]),
(Size::new(4, 2), vec![(">12…", 1..3), (" 67", 1..3)]),
(Size::new(6, 1), vec![(">1234…", 1..5)]),
] {
let lines = node.prepare(&Theme::default()).render_lines(size);
assert_eq!(lines.len(), expected.len());
for (line, (text, columns)) in lines.into_iter().zip(expected) {
assert_eq!(line.line.to_string(), text);
assert_eq!(
line.hyperlinks,
[TerminalHyperlink::web(
columns,
"https://example.com".to_owned()
)]
);
}
}
}
#[test]
fn styled_text_wraps_in_the_space_its_nowrap_row_assigns() {
let href = "https://example.com/build";
let (plain, bold) = "build ".split_at(3);
let node = tree(json!({"type":"row","props":{"gap":1},"children":[
">", {"type":"text","children":[
plain, {"type":"bold","children":[bold]},
{"type":"link","props":{"href":href},"children":["was green"]}
]}, "!"
]}));
let lines = node
.prepare(&Theme::default())
.render_lines(Size::new(10, 4));
assert_eq!(
lines
.iter()
.map(|row| row.line.to_string())
.collect::<Vec<_>>(),
["> build !", " was", " green"]
);
assert!(lines[0].hyperlinks.is_empty());
assert_eq!(
lines[1].hyperlinks,
[TerminalHyperlink::web(2..5, href.to_owned())]
);
assert_eq!(
lines[2].hyperlinks,
[TerminalHyperlink::web(2..7, href.to_owned())]
);
}
#[test]
fn shrinking_a_hyphenated_word_reserves_the_siblings_column() {
let node = tree(json!({"type":"row","props":{"gap":1},"children":["aa-bb", "!"]}));
let lines = node
.prepare(&Theme::default())
.render_lines(Size::new(6, 2));
assert_eq!(
lines
.iter()
.map(|line| line.line.to_string())
.collect::<Vec<_>>(),
["aa- !", "bb"]
);
}
#[test]
fn wrapping_row_moves_a_hash_and_nonbreaking_pr_label_together() {
let href = "https://example.com/pull/123";
let node = tree(
json!({"type":"row","props":{"gap":1,"wrap":"wrap"},"children":[
"gpt-5", {"type":"row","props":{"gap":1},"children":[
"a1b2c3d", {"type":"link","props":{"href":href},"children":["PR\u{a0}#123"]}
]}
]}),
);
for (width, expected, linked) in [
(21, vec!["gpt-5 a1b2c3d PR\u{a0}#123"], 14..21),
(20, vec!["gpt-5", "a1b2c3d PR\u{a0}#123"], 8..15),
(12, vec!["gpt-5", "a1b2c3d PR\u{a0}…"], 8..11),
] {
let lines = node
.prepare(&Theme::default())
.render_lines(Size::new(width, 4));
let text: Vec<_> = lines.iter().map(|line| line.line.to_string()).collect();
assert_eq!(text, expected);
assert_eq!(
lines.last().unwrap().hyperlinks,
[TerminalHyperlink::web(linked, href.to_owned())]
);
}
}
#[test]
fn authored_line_breaks_and_collapsed_whitespace_preserve_styles_and_links() {
let href = "https://example.com/pr/42";
let node = tree(json!({"type":"text","children":[
" ready \t", {"type":"bold","children":[" now"]}, "\r", "\n\n",
{"type":"link","props":{"href":href},"children":["PR \t", "#42\u{7}"]},
"\nmore"
]}));
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 8, /*height*/ 4,
);
let lines = node.prepare(&Theme::default()).render_lines(area.as_size());
assert_eq!(
lines[3].hyperlinks,
[TerminalHyperlink::web(0..6, href.to_owned())]
);
let mut buffer = Buffer::empty(area);
for (row, line) in area.rows().zip(lines) {
line.render_fitted(row, &mut buffer);
}
insta::assert_snapshot!(crate::terminal_hyperlinks::strip_osc8(&format!(
"{buffer:?}"
)));
assert_eq!(buffer[(6, 3)].symbol(), "…");
assert_eq!(
node.render_line(/*width*/ 20, &Theme::default())
.line
.to_string(),
"ready now…"
);
}
#[test]
fn earlier_sibling_grows_after_reserving_the_later_siblings_minimum() {
let node = tree(json!({"type":"row","props":{"gap":0},"children":["aaa a bb", "aaa a bb"]}));
let lines = node
.prepare(&Theme::default())
.render_lines(Size::new(10, 3));
assert_eq!(
lines
.iter()
.map(|line| line.line.to_string())
.collect::<Vec<_>>(),
["aaa a aaa", "bb a", " bb"]
);
}
#[test]
fn width_fitting_respects_word_hyphens_across_styles() {
for (prefix, suffix, expected) in [
("1-", "2", vec!["1-", "2"]),
("--", "help", vec!["-…"]),
("a\u{ad}", "bc", vec!["a\u{ad}…"]),
("中", "文", vec!["中", "文"]),
] {
let node =
tree(json!({"type":"text","children":[{"type":"bold","children":[prefix]},suffix]}));
let lines = node
.prepare(&Theme::default())
.render_lines(Size::new(2, 3));
assert_eq!(
lines
.iter()
.map(|line| line.line.to_string())
.collect::<Vec<_>>(),
expected
);
}
}