mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +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:
+20
@@ -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,
|
||||
]
|
||||
}
|
||||
@@ -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 {
|
||||
|
||||
@@ -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)]
|
||||
|
||||
@@ -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
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user