From 8e7ccd8472e249efb7dc9c155e2cbc5e6db0d908 Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Sat, 7 Nov 2026 01:18:46 +0000 Subject: [PATCH] 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 --- ..._whitespace_preserve_styles_and_links.snap | 20 ++ codex-rs/tui/src/ui_tree.rs | 14 ++ codex-rs/tui/src/ui_tree/decode.rs | 4 + codex-rs/tui/src/ui_tree/render.rs | 158 ++++++++++---- codex-rs/tui/src/ui_tree/render/prepare.rs | 32 ++- codex-rs/tui/src/ui_tree/render/text.rs | 72 ++++-- codex-rs/tui/src/ui_tree_tests.rs | 206 +++++++++++++++++- 7 files changed, 436 insertions(+), 70 deletions(-) create mode 100644 codex-rs/tui/src/snapshots/codex_tui__ui_tree__tests__authored_line_breaks_and_collapsed_whitespace_preserve_styles_and_links.snap diff --git a/codex-rs/tui/src/snapshots/codex_tui__ui_tree__tests__authored_line_breaks_and_collapsed_whitespace_preserve_styles_and_links.snap b/codex-rs/tui/src/snapshots/codex_tui__ui_tree__tests__authored_line_breaks_and_collapsed_whitespace_preserve_styles_and_links.snap new file mode 100644 index 0000000000..15e9ed06c7 --- /dev/null +++ b/codex-rs/tui/src/snapshots/codex_tui__ui_tree__tests__authored_line_breaks_and_collapsed_whitespace_preserve_styles_and_links.snap @@ -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, + ] +} diff --git a/codex-rs/tui/src/ui_tree.rs b/codex-rs/tui/src/ui_tree.rs index ead40c6883..9c3c855946 100644 --- a/codex-rs/tui/src/ui_tree.rs +++ b/codex-rs/tui/src/ui_tree.rs @@ -113,6 +113,20 @@ pub(crate) struct RowProps { skip_serializing_if = "Option::is_none" )] background: Option, + #[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(value: &T) -> bool { diff --git a/codex-rs/tui/src/ui_tree/decode.rs b/codex-rs/tui/src/ui_tree/decode.rs index 01052cd1a3..e6a4681692 100644 --- a/codex-rs/tui/src/ui_tree/decode.rs +++ b/codex-rs/tui/src/ui_tree/decode.rs @@ -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::deserialize(String::deserialize(d)?.into_deserializer()) +} + pub(super) fn gap<'de, D: Deserializer<'de>>(de: D) -> Result { let gap = u64::deserialize(de)?; if gap > MAX_GAP { diff --git a/codex-rs/tui/src/ui_tree/render.rs b/codex-rs/tui/src/ui_tree/render.rs index 1459402d7a..d4b92538f2 100644 --- a/codex-rs/tui/src/ui_tree/render.rs +++ b/codex-rs/tui/src/ui_tree/render.rs @@ -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 { + 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 { 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 { + let bounded = |cells: u64| cells.min((width + 1) as u64) as usize; + let mut reserved = children + .iter() + .map(|c| bounded(c.widths.minimum)) + .sum::() + + 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> { diff --git a/codex-rs/tui/src/ui_tree/render/prepare.rs b/codex-rs/tui/src/ui_tree/render/prepare.rs index 0b2959e1d0..a9df9056f5 100644 --- a/codex-rs/tui/src/ui_tree/render/prepare.rs +++ b/codex-rs/tui/src/ui_tree/render/prepare.rs @@ -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>, 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, gap: u64, paint: Paint<'a>) -> Option { + fn row(mut children: Vec, gap: u64, wrap: RowWrap, paint: Paint<'a>) -> Option { 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 } => { diff --git a/codex-rs/tui/src/ui_tree/render/text.rs b/codex-rs/tui/src/ui_tree/render/text.rs index 292c32bd53..985f5278c3 100644 --- a/codex-rs/tui/src/ui_tree/render/text.rs +++ b/codex-rs/tui/src/ui_tree/render/text.rs @@ -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>) -> Vec> { 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 { + 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)] diff --git a/codex-rs/tui/src/ui_tree_tests.rs b/codex-rs/tui/src/ui_tree_tests.rs index 3cf7473ace..c3c78959e3 100644 --- a/codex-rs/tui/src/ui_tree_tests.rs +++ b/codex-rs/tui/src/ui_tree_tests.rs @@ -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::>(), + ["> 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::>(), + ["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::>(), + ["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::>(), + expected + ); + } }