mirror of
https://github.com/openai/codex.git
synced 2026-11-14 21:40:39 +00:00
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:
@@ -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 {
|
||||
|
||||
@@ -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> {
|
||||
|
||||
@@ -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 } => {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
Reference in New Issue
Block a user