Normalize inline UI tree text across styled leaves (#60021)

## What changed

- Collapse spaces and tabs across inline leaves, trim leading and trailing whitespace, and keep rendering limited to the first line.
- Prepare Unicode line-break opportunities across style boundaries and join graphemes split across leaves. Use the first fragment's style and retain a hyperlink only when all fragments share its destination.
- Preserve row boundaries and explicit gaps, and use row gaps for status-line separators.
- Prevent zero-width fragments from adding row gaps or changing the ellipsis style.

## Testing

Add regression tests for whitespace collapse, split graphemes, hyperlink ownership, zero-width fragments, and line-break consistency between styled runs and contiguous text.

GitOrigin-RevId: 94e58808bc8469e9cd0adddb16c194b9568cfbb6
This commit is contained in:
Joe Savona
2026-11-07 01:37:46 +00:00
committed by copyberry
parent 7e980a54fe
commit 1a20f53c33
9 changed files with 528 additions and 72 deletions
+1
View File
@@ -5275,6 +5275,7 @@ dependencies = [
"tracing-appender",
"tracing-subscriber",
"two-face",
"unicode-linebreak",
"unicode-segmentation",
"unicode-width 0.2.1",
"url",
+1
View File
@@ -558,6 +558,7 @@ tree-sitter-powershell = "=0.26.4"
ts-rs = "11"
tungstenite = { version = "0.27.0", features = ["deflate", "proxy"] }
uds_windows = "1.1.0"
unicode-linebreak = "0.1.5"
unicode-normalization = "0.1.25"
unicode-segmentation = "1.12.0"
unicode-width = "0.2"
+1
View File
@@ -127,6 +127,7 @@ tracing-appender = { workspace = true }
tracing-subscriber = { workspace = true, features = ["env-filter"] }
syntect = { version = "5", default-features = false, features = ["parsing", "plist-load", "regex-onig"] }
two-face = { version = "0.5", default-features = false, features = ["syntect-onig"] }
unicode-linebreak = { workspace = true }
unicode-segmentation = { workspace = true }
unicode-width = { workspace = true }
vtparse = { workspace = true }
@@ -26,10 +26,10 @@ fn composer() -> ChatComposer {
fn extension_status_line_clips_links_to_footer_layout() {
let mut composer = composer();
let tree = r#"{
"type": "row", "props": {"gap": 0}, "children": [
"type": "row", "props": {"gap": 1}, "children": [
"plain",
{ "type": "row", "props": {"gap": 0}, "children": [
" · ",
{ "type": "row", "props": {"gap": 1}, "children": [
"·",
{ "type": "link", "props": { "href": "https://example.com/pr/42" }, "children": [
"PR #42"
] }
@@ -38,7 +38,9 @@ impl StatusLineRenderer {
let mut children = Vec::new();
for (item, text) in segments {
if !children.is_empty() {
children.push(Some(UiNode::String(STATUS_LINE_SEPARATOR.to_owned())));
children.push(Some(UiNode::String(
STATUS_LINE_SEPARATOR.trim().to_owned(),
)));
}
let text = UiNode::String(text);
let node = if item == StatusLineItem::PullRequestNumber
@@ -53,10 +55,9 @@ impl StatusLineRenderer {
};
children.push(Some(node));
}
let tree = UiNode::Row {
props: RowProps::default(),
children,
};
let mut props = RowProps::default();
props.gap = 1;
let tree = UiNode::Row { props, children };
Some(FooterContent::Extension {
content: UiTree(Some(tree)),
})
+136 -64
View File
@@ -1,5 +1,9 @@
//! Render the visible prefix of a horizontal display tree into one terminal line.
mod text;
use self::text::InlineText;
use self::text::Paint;
use super::MAX_TEXT_BYTES;
use super::TextProps;
use super::TextWeight;
@@ -13,6 +17,7 @@ use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::text::Span;
use std::borrow::Cow;
use std::ops::Range;
use syntect::highlighting::Theme;
use vtparse::CollectingVTActor;
use vtparse::VTAction;
@@ -25,8 +30,15 @@ impl UiTree {
let Some(root) = self.0.as_ref().filter(|_| width > 0) else {
return HyperlinkLine::default();
};
let mut line = node_line(root, width, Style::default(), &RenderTheme::new(theme));
if line.width() > width {
let output = LineBuilder::from_node(
root,
width,
Style::default(),
/*href*/ None,
&RenderTheme::new(theme),
);
let mut line = output.line;
if output.used > width {
line.line = truncate_line_with_ellipsis(line.line, width);
line.clip_hyperlinks_to_width(line.width() - 1);
}
@@ -34,35 +46,122 @@ impl UiTree {
}
}
fn node_line(node: &UiNode, width: usize, style: Style, theme: &RenderTheme<'_>) -> HyperlinkLine {
let (children, gap, style) = match node {
UiNode::String(raw) => {
let text = sanitize_leaf(raw);
let first = text.split('\n').next().unwrap_or_default();
let visible = first.replace('\t', " ");
return HyperlinkLine::new(Span::styled(visible, style).into());
}
UiNode::Link { props, children } => {
let mut line = row_line(children, /*gap*/ 0, width, style.underlined(), theme);
let width = line.width();
if width > 0 {
line.hyperlinks = vec![TerminalHyperlink::web(0..width, props.href.0.clone())];
struct LineBuilder<'a> {
inline: InlineText<'a>,
line: HyperlinkLine,
used: usize,
width: usize,
saw_row: bool,
}
impl<'a> LineBuilder<'a> {
fn from_node(
node: &'a UiNode,
width: usize,
style: Style,
href: Option<&'a str>,
theme: &RenderTheme<'_>,
) -> Self {
let mut output = Self {
inline: InlineText::default(),
line: HyperlinkLine::default(),
used: 0,
width,
saw_row: false,
};
output.visit(node, style, href, theme);
output.flush_runs();
output
}
fn visit(
&mut self,
node: &'a UiNode,
mut style: Style,
mut href: Option<&'a str>,
theme: &RenderTheme<'_>,
) {
let children = match node {
UiNode::String(raw) => {
self.inline.push_leaf(raw, Paint { style, href });
return;
}
return line;
UiNode::Row { props, children } => {
self.saw_row = true;
self.flush_runs();
if let Some(color) = &props.background {
style = style.bg(color.resolve(theme));
}
let start = self.used;
for child in children.iter().flatten() {
if self.used > self.width {
break;
}
let child = Self::from_node(child, self.width - self.used, style, href, theme);
if child.used == 0 {
continue;
}
if self.used > start && props.gap > 0 {
let gap = props.gap.min((self.width + 1 - self.used) as u64) as usize;
self.line
.line
.push_span(Span::styled(" ".repeat(gap), style));
if let Some(url) = href {
push_link(&mut self.line, self.used..self.used + gap, url);
}
self.used += gap;
}
self.line.line.spans.extend(child.line.line.spans);
for mut link in child.line.hyperlinks {
link.columns = self.used + link.columns.start..self.used + link.columns.end;
push_link(&mut self.line, link.columns, &link.destination);
}
self.used += child.used;
}
return;
}
UiNode::Text { props, children } => {
style = props.style(style, theme);
children
}
UiNode::Link { props, children } => {
href = Some(&props.href.0);
style = style.underlined();
children
}
UiNode::Bold { children } => {
style = style.bold();
children
}
UiNode::Italic { children } => {
style = style.italic();
children
}
UiNode::Underline { children } => {
style = style.underlined();
children
}
};
for node in children.iter().flatten() {
if self.used > self.width {
break;
}
self.visit(node, style, href, theme);
}
UiNode::Row { props, children } => {
let style = props
.background
.as_ref()
.map_or(style, |color| style.bg(color.resolve(theme)));
(children, props.gap, style)
}
fn flush_runs(&mut self) {
let start = self.used;
let first_span = self.line.line.spans.len();
let text = std::mem::take(&mut self.inline).finish();
if let Some(first) = text.lines.first() {
self.used += text::paint_chunks(first, &mut self.line, self.used);
}
UiNode::Text { props, children } => (children, 0, props.style(style, theme)),
UiNode::Bold { children } => (children, 0, style.bold()),
UiNode::Italic { children } => (children, 0, style.italic()),
UiNode::Underline { children } => (children, 0, style.underlined()),
};
row_line(children, gap, width, style, theme)
// A zero-cell fragment between row children must not style a later ellipsis.
if self.saw_row && self.used == start {
self.line.line.spans.truncate(first_span);
}
}
}
fn sanitize_leaf(raw: &str) -> Cow<'_, str> {
@@ -86,43 +185,16 @@ fn sanitize_leaf(raw: &str) -> Cow<'_, str> {
.collect()
}
fn row_line(
children: &[Option<UiNode>],
gap: u64,
width: usize,
style: Style,
theme: &RenderTheme<'_>,
) -> HyperlinkLine {
let mut result = HyperlinkLine::default();
let mut used = 0;
for child in children.iter().flatten() {
let child = node_line(child, width, style, theme);
let child_width = child.width();
if child_width == 0 {
continue;
}
if used > 0 && gap > 0 {
// One column past the viewport is sufficient to detect overflow.
let spacing = gap.min((width + 1 - used) as u64) as usize;
result
.line
.push_span(Span::styled(" ".repeat(spacing), style));
used += spacing;
}
let offset = used;
result.line.spans.extend(child.line.spans);
result
.hyperlinks
.extend(child.hyperlinks.into_iter().map(|mut link| {
link.columns = link.columns.start + offset..link.columns.end + offset;
link
}));
used += child_width;
if used > width {
break;
}
fn push_link(line: &mut HyperlinkLine, columns: Range<usize>, href: &str) {
if let Some(last) = line.hyperlinks.last_mut()
&& last.columns.end == columns.start
&& last.destination == href
{
last.columns.end = columns.end;
} else {
line.hyperlinks
.push(TerminalHyperlink::web(columns, href.to_owned()));
}
result
}
impl TextProps {
+281
View File
@@ -0,0 +1,281 @@
//! Prepared inline text retains styled chunks and their legal line breaks across leaves.
use super::push_link;
use super::sanitize_leaf;
use crate::line_truncation::span_width;
use crate::terminal_hyperlinks::HyperlinkLine;
use ratatui::style::Style;
use ratatui::text::Span;
use std::borrow::Cow;
use std::ops::Range;
use unicode_linebreak::linebreaks;
use unicode_linebreak::split_at_safe;
use unicode_segmentation::GraphemeCursor;
use unicode_segmentation::GraphemeIncomplete;
use unicode_segmentation::UnicodeSegmentation;
#[derive(Clone, Copy, Debug)]
pub(super) struct Paint<'a> {
pub(super) style: Style,
pub(super) href: Option<&'a str>,
}
#[derive(Debug)]
struct Run<'a> {
text: Cow<'a, str>,
paint: Paint<'a>,
}
fn slice<'a>(text: &Cow<'a, str>, range: Range<usize>) -> Cow<'a, str> {
match text {
Cow::Borrowed(raw) => Cow::Borrowed(&raw[range]),
Cow::Owned(raw) => Cow::Owned(raw[range].to_owned()),
}
}
#[derive(Debug, Default)]
pub(super) struct Chunk<'a> {
runs: Vec<Run<'a>>,
pub(super) width_cells: usize,
pub(super) trailing_space: Option<Paint<'a>>,
}
#[derive(Default)]
pub(super) struct PreparedText<'a> {
pub(super) lines: Vec<Vec<Chunk<'a>>>,
}
#[derive(Default)]
pub(super) struct InlineText<'a> {
text: PreparedText<'a>,
line: Vec<Run<'a>>,
space: Option<Paint<'a>>,
}
impl<'a> InlineText<'a> {
pub(super) fn push_leaf(&mut self, raw: &'a str, paint: Paint<'a>) {
let text = sanitize_leaf(raw);
let mut start = 0;
for (end, ch) in text
.match_indices([' ', '\t', '\n'])
.chain(std::iter::once((text.len(), "")))
{
if start < end {
if let Some(paint) = self.space.take() {
self.line.push(Run {
text: " ".into(),
paint,
});
}
self.line.push(Run {
text: slice(&text, start..end),
paint,
});
}
if ch == "\n" {
self.text.lines.push(chunks(std::mem::take(&mut self.line)));
self.space = None;
} else if !ch.is_empty() && !self.line.is_empty() {
self.space.get_or_insert(paint);
}
start = end + ch.len();
}
}
pub(super) fn finish(mut self) -> PreparedText<'a> {
if !self.line.is_empty() {
self.text.lines.push(chunks(self.line));
}
self.text
}
}
// Most seams already fall between graphemes. Only a cluster that actually crosses a
// seam needs contiguous storage, including when it spans more than two leaves.
fn join_graphemes(runs: Vec<Run<'_>>) -> Vec<Run<'_>> {
let mut runs = runs.into_iter().peekable();
let mut joined = Vec::new();
while let Some(run) = runs.next() {
let Some(next) = runs.peek() else {
joined.push(run);
break;
};
if run.text.as_bytes().last().is_some_and(u8::is_ascii)
&& next.text.as_bytes()[0].is_ascii()
{
joined.push(run);
continue;
}
// Reverse segmentation in unicode-segmentation 1.12 disagrees on Indic conjuncts.
#[expect(clippy::double_ended_iterator_last)]
let Some(tail) = run.text.graphemes(true).last() else {
continue;
};
let split = tail.len();
let mut cursor = GraphemeCursor::new(split, usize::MAX, true);
while let Err(GraphemeIncomplete::PreContext(_)) = cursor.is_boundary(&next.text, split) {
cursor.provide_context(tail, 0);
}
if cursor.is_boundary(&next.text, split) == Ok(true) {
joined.push(run);
continue;
}
let mut cluster = tail.to_owned();
let mut paint = run.paint;
let prefix = run.text.len() - split;
if prefix > 0 {
joined.push(Run {
text: slice(&run.text, 0..prefix),
paint,
});
}
// Resume in the growing cluster so even a long chain of combining marks
// advances once per character and Unicode lookbehind stays available.
let mut cursor = GraphemeCursor::new(0, usize::MAX, true);
let _ = cursor.next_boundary(&cluster, 0);
while let Some(next) = runs.peek_mut() {
let mut consumed = 0;
for ch in next.text.chars() {
cluster.push(ch);
if cursor.next_boundary(&cluster, 0).is_ok() {
cluster.pop();
break;
}
consumed += ch.len_utf8();
}
if consumed > 0 && paint.href != next.paint.href {
paint.href = None;
}
if consumed < next.text.len() {
if consumed > 0 {
next.text = slice(&next.text, consumed..next.text.len());
}
break;
}
runs.next();
}
joined.push(Run {
text: Cow::Owned(cluster),
paint,
});
}
joined
}
// Keep the context needed for breaks across style boundaries.
fn break_offsets<'a>(runs: impl Iterator<Item = &'a str>) -> Vec<usize> {
let mut runs = runs.peekable();
let mut breaks = Vec::new();
let mut scratch = String::new();
let mut base = 0;
while let Some(&run) = runs.peek() {
let scanned = scratch.len();
let window = if scanned == 0 {
runs.next();
run
} else {
// Match the retained context with fresh bytes to keep rescanning linear.
// Bound each read so a short prefix never pulls a large ASCII run into scratch.
while scratch.len() - scanned < scanned
&& let Some(next) = runs.peek_mut()
{
let (part, rest) = next.split_at(next.floor_char_boundary(scanned.max(128)));
scratch.push_str(part);
*next = rest;
runs.next_if(|s| s.is_empty());
}
scratch.as_str()
};
breaks.extend(
linebreaks(window)
.filter_map(|(i, _)| (i >= scanned && i < window.len()).then_some(base + i)),
);
if runs.peek().is_none() {
break;
}
let discard = split_at_safe(window).0.len();
base += discard;
if scanned == 0 {
scratch.push_str(&run[discard..]);
} else {
scratch.drain(..discard);
}
}
breaks
}
fn chunks(runs: Vec<Run<'_>>) -> Vec<Chunk<'_>> {
let runs = join_graphemes(runs);
let mut breaks = break_offsets(runs.iter().map(|run| run.text.as_ref()))
.into_iter()
.peekable();
let mut out = Vec::new();
let mut chunk = Chunk::default();
let mut offset = 0;
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;
}
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..run.text.len());
offset += run.text.len();
}
if !chunk.runs.is_empty() {
out.push(chunk);
}
out
}
impl<'a> Chunk<'a> {
fn push(&mut self, run: &Run<'a>, range: Range<usize>) {
if !range.is_empty() {
let text = slice(&run.text, range);
self.width_cells += span_width(&Span::raw(text.as_ref()));
self.runs.push(Run { text, ..*run });
}
}
}
pub(super) fn paint_chunks(chunks: &[Chunk<'_>], line: &mut HyperlinkLine, start: usize) -> usize {
let mut col = start;
for (index, chunk) in chunks.iter().enumerate() {
for run in &chunk.runs {
let span = Span::styled(run.text.to_string(), run.paint.style);
let end = col + span_width(&span);
if let Some(url) = run.paint.href
&& end > col
{
push_link(line, col..end, url);
}
line.line.push_span(span);
col = end;
}
if index + 1 < chunks.len()
&& let Some(paint) = chunk.trailing_space
{
line.line.push_span(Span::styled(" ", paint.style));
if let Some(url) = paint.href {
push_link(line, col..col + 1, url);
}
col += 1;
}
}
col - start
}
#[cfg(test)]
#[path = "text_tests.rs"]
mod tests;
@@ -0,0 +1,16 @@
#![cfg(test)]
use pretty_assertions::assert_eq;
use unicode_linebreak::linebreaks;
#[test]
fn styled_runs_have_the_same_breaks_as_contiguous_text() {
let long = "中日".repeat(200);
let runs = ["bu", "ild (", " ready)", "— ", "—"] // codespell:ignore bu
.into_iter()
.chain(std::iter::repeat_n("🇨", 79))
.chain([long.as_str(), " done"]);
let text: String = runs.clone().collect();
let expected: Vec<_> = linebreaks(&text).map(|(i, _)| i).collect();
assert_eq!(super::break_offsets(runs), expected[..expected.len() - 1]);
}
+83
View File
@@ -167,6 +167,24 @@ fn ellipsis_counts_toward_width_only_when_there_is_overflow() {
);
}
#[test]
fn zero_width_fragments_do_not_style_the_ellipsis_or_add_row_gaps() {
let row = |text| json!({"type":"row","props":{"gap":0},"children":[text]});
let mark = json!({"type":"bold","children":["\u{301}"]});
for (mut root, width) in [
(json!({"type":"text"}), 2),
(json!({"type":"row","props":{"gap":1}}), 4),
] {
root["children"] = json!([row("x"), mark, row("yz")]);
let node = tree(root.clone());
root["children"] = json!([row("x"), row("yz")]);
assert_eq!(
node.render_line(width, &Theme::default()),
tree(root).render_line(width, &Theme::default())
);
}
}
#[test]
fn first_line_skips_hidden_text_before_rendering_its_sibling() {
let multiline = format!("\u{1b}[2Jok\n{}", "x".repeat(1024));
@@ -406,3 +424,68 @@ fn terminal_commands_are_sanitized_within_each_string() {
);
insta::assert_snapshot!(line.line.to_string(), @"red label Ready");
}
#[test]
fn split_graphemes_use_the_first_style_and_require_unanimous_links() {
use crate::terminal_hyperlinks::TerminalHyperlink;
use ratatui::style::Stylize;
use ratatui::text::Span;
let url = "https://example.com/a";
let link = |href, text| json!({"type":"link","props":{"href":href},"children":[text]});
let node = tree(json!({"type":"text","children":[
{"type":"bold","children":[{"type":"row","props":{"gap":0},"children":["R"]},"e"]}, {"type":"italic","children":[]}, "\u{301}",
link(url, "👩"), link(url, "\u{200d}💻"),
link(url, "👩"), link("https://example.com/b", "\u{200d}💻"),
{"type":"bold","children":["\u{600}"]}, " x",
link(url, "तक्क"), "\u{301}"
]}));
let line = node.render_line(/*width*/ 20, &Theme::default());
assert_eq!(
line.line.spans,
[
Span::raw("R").bold(),
Span::raw("e\u{301}").bold(),
Span::raw("👩‍💻").underlined(),
Span::raw("👩‍💻").underlined(),
Span::raw("\u{600} ").bold(),
Span::raw("x"),
Span::raw("त").underlined(),
Span::raw("क्क\u{301}").underlined()
]
);
assert_eq!(
line.hyperlinks,
[
TerminalHyperlink::web(2..4, url.to_owned()),
TerminalHyperlink::web(9..10, url.to_owned())
]
);
}
#[test]
fn collapsed_space_keeps_the_first_whitespace_owners_link() {
use crate::terminal_hyperlinks::TerminalHyperlink;
let href = "https://example.com/build";
let node = tree(json!({"type":"text","children":[
"a", {"type":"link","props":{"href":href},"children":["\t"]},
"\u{1b}[31m", " b", {"type":"link","props":{"href":href},"children":[" "]},
{"type":"row","props":{"gap":0},"children":[" c "]}, " d"
]}));
let line = node.render_line(/*width*/ 10, &Theme::default());
assert_eq!(line.line.to_string(), "a bcd");
assert_eq!(
line.hyperlinks,
[TerminalHyperlink::web(1..2, href.to_owned())]
);
}
#[test]
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");
}