From d749ee847f350d4ec8434fe925d1046d693060e7 Mon Sep 17 00:00:00 2001 From: Joe Savona Date: Sat, 7 Nov 2026 01:18:46 +0000 Subject: [PATCH] Separate UI tree preparation from line rendering (#60022) ## What changed Add `UiTree::prepare` and `PreparedUiTree` to prepare styled inline text and row structure independently of viewport width. Compute minimum and preferred widths for text and nested rows, including gaps, and retain authored line breaks when filtering zero-width children. Render the first line from the prepared tree while retaining overflow ellipses and hyperlink clipping. ## Testing Add a preparation test covering row width measurements across styled text, nested rows, gaps, empty children, combining marks, and non-breaking spaces. GitOrigin-RevId: 5f63366c0568a01cb55686d9f14c1b3fac90946f --- codex-rs/tui/src/ui_tree/render.rs | 183 ++++++------------ codex-rs/tui/src/ui_tree/render/prepare.rs | 157 +++++++++++++++ .../tui/src/ui_tree/render/prepare_tests.rs | 29 +++ codex-rs/tui/src/ui_tree/render/text.rs | 2 + 4 files changed, 249 insertions(+), 122 deletions(-) create mode 100644 codex-rs/tui/src/ui_tree/render/prepare.rs create mode 100644 codex-rs/tui/src/ui_tree/render/prepare_tests.rs diff --git a/codex-rs/tui/src/ui_tree/render.rs b/codex-rs/tui/src/ui_tree/render.rs index 697e52291c..1459402d7a 100644 --- a/codex-rs/tui/src/ui_tree/render.rs +++ b/codex-rs/tui/src/ui_tree/render.rs @@ -1,13 +1,14 @@ //! Render the visible prefix of a horizontal display tree into one terminal line. +mod prepare; mod text; -use self::text::InlineText; -use self::text::Paint; +use self::prepare::NodeKind; +use self::prepare::PreparedNode; + use super::MAX_TEXT_BYTES; use super::TextProps; use super::TextWeight; -use super::UiNode; use super::UiTree; use super::theme::RenderTheme; use crate::line_truncation::truncate_line_with_ellipsis; @@ -23,22 +24,29 @@ use vtparse::CollectingVTActor; use vtparse::VTAction; use vtparse::VTParser; +pub(crate) struct PreparedUiTree<'a>(Option>); + impl UiTree { - /// Render one row. On overflow, fit an unlinked ellipsis inside the supplied width. + pub(crate) fn prepare(&self, theme: &Theme) -> PreparedUiTree<'_> { + let theme = RenderTheme::new(theme); + PreparedUiTree(self.0.as_ref().and_then(|node| { + PreparedNode::from_node(node, Style::default(), /*href*/ None, &theme) + })) + } + pub(crate) fn render_line(&self, width: u16, theme: &Theme) -> HyperlinkLine { - let width = usize::from(width); + self.prepare(theme).render_line(width) + } +} + +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(); }; - let output = LineBuilder::from_node( - root, - width, - Style::default(), - /*href*/ None, - &RenderTheme::new(theme), - ); - let mut line = output.line; - if output.used > width { + 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); } @@ -46,122 +54,53 @@ impl UiTree { } } -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; +fn first_line(node: &PreparedNode<'_>, width: usize) -> HyperlinkLine { + let mut line = HyperlinkLine::default(); + match &node.kind { + NodeKind::Text(text) => { + if let Some(first) = text.lines.first() { + text::paint_chunks(first, &mut line, /*start*/ 0); } - UiNode::Row { props, children } => { - self.saw_row = true; - self.flush_runs(); - if let Some(color) = &props.background { - style = style.bg(color.resolve(theme)); + } + NodeKind::Row { + children, + gap, + paint, + } => { + 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; } - 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); + 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); } - self.used += gap; + 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; + 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; + } } - 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); - } - } - - 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); - } - // 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); } } + line } 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 new file mode 100644 index 0000000000..0b2959e1d0 --- /dev/null +++ b/codex-rs/tui/src/ui_tree/render/prepare.rs @@ -0,0 +1,157 @@ +//! Prepare inline content and measure each row child before viewport layout. + +use super::text::InlineText; +use super::text::Paint; +use super::text::PreparedText; +use crate::ui_tree::UiNode; +use crate::ui_tree::theme::RenderTheme; +use ratatui::style::Style; + +pub(super) struct PreparedNode<'a> { + pub(super) kind: NodeKind<'a>, + pub(super) widths: Widths, +} + +pub(super) struct Widths { + pub(super) minimum: u64, + pub(super) preferred: u64, +} + +pub(super) enum NodeKind<'a> { + Text(PreparedText<'a>), + Row { + children: Vec>, + gap: u64, + paint: Paint<'a>, + }, +} + +impl<'a> PreparedNode<'a> { + pub(super) fn from_node( + node: &'a UiNode, + style: Style, + href: Option<&'a str>, + theme: &RenderTheme<'_>, + ) -> Option { + let mut items = Vec::new(); + let mut inline = InlineText::default(); + Self::collect_inline(&mut items, &mut inline, node, style, href, theme); + items.extend(Self::text(inline)); + if items.len() <= 1 { + items.pop() + } else { + Self::row(items, /*gap*/ 0, Paint { style, href }) + } + } + + fn text(inline: InlineText<'a>) -> Option { + let text = inline.finish(); + if text.lines.is_empty() { + return None; + } + let mut widths = Widths { + minimum: 0, + preferred: 0, + }; + for line in &text.lines { + let mut width = 0; + for chunk in line { + let cells = chunk.width_cells as u64; + widths.minimum = widths.minimum.max(cells); + width += cells + u64::from(chunk.trailing_space.is_some()); + } + widths.preferred = widths.preferred.max(width); + } + Some(Self { + kind: NodeKind::Text(text), + widths, + }) + } + + fn row(mut children: Vec, gap: u64, paint: Paint<'a>) -> Option { + children.retain(|child| { + child.widths.preferred > 0 + || match &child.kind { + NodeKind::Row { .. } => true, + NodeKind::Text(text) => text.has_authored_break, + } + }); + if children.is_empty() { + return None; + } + 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)), + preferred: children + .iter() + .fold(gaps, |w, c| w.saturating_add(c.widths.preferred)), + }; + Some(Self { + widths, + kind: NodeKind::Row { + children, + gap, + paint, + }, + }) + } + + fn collect_inline( + items: &mut Vec, + inline: &mut InlineText<'a>, + node: &'a UiNode, + mut style: Style, + mut href: Option<&'a str>, + theme: &RenderTheme<'_>, + ) { + let children = match node { + UiNode::String(raw) => { + inline.push_leaf(raw, Paint { style, href }); + return; + } + UiNode::Row { props, children } => { + items.extend(Self::text(std::mem::take(inline))); + if let Some(color) = &props.background { + style = style.bg(color.resolve(theme)); + } + let children = children + .iter() + .flatten() + .filter_map(|node| Self::from_node(node, style, href, theme)) + .collect(); + items.extend(Self::row(children, props.gap, Paint { style, href })); + 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() { + Self::collect_inline(items, inline, node, style, href, theme); + } + } +} + +#[cfg(test)] +#[path = "prepare_tests.rs"] +mod tests; diff --git a/codex-rs/tui/src/ui_tree/render/prepare_tests.rs b/codex-rs/tui/src/ui_tree/render/prepare_tests.rs new file mode 100644 index 0000000000..440cc671c6 --- /dev/null +++ b/codex-rs/tui/src/ui_tree/render/prepare_tests.rs @@ -0,0 +1,29 @@ +#![cfg(test)] + +use super::PreparedNode; +use crate::ui_tree::UiNode; +use crate::ui_tree::theme::RenderTheme; +use pretty_assertions::assert_eq; +use ratatui::style::Style; +use serde_json::json; +use syntect::highlighting::Theme; + +#[test] +fn a_row_measures_whole_children_and_intervening_gaps() { + let node: UiNode = serde_json::from_value(json!({ + "type": "row", "props": { "gap": 2 }, "children": [ + {"type":"text","children":[{"type":"bold","children":["bu"]},"ild ready"]}, // codespell:ignore bu + null, "", "\u{301}\n", {"type":"row","props":{"gap":1},"children":["δΈ­","PR\u{a0}2"]} + ] + })) + .unwrap(); + let theme = Theme::default(); + let node = PreparedNode::from_node( + &node, + Style::default(), + /*href*/ None, + &RenderTheme::new(&theme), + ) + .unwrap(); + assert_eq!((node.widths.minimum, node.widths.preferred), (16, 22)); +} diff --git a/codex-rs/tui/src/ui_tree/render/text.rs b/codex-rs/tui/src/ui_tree/render/text.rs index ce4d0d9e6b..292c32bd53 100644 --- a/codex-rs/tui/src/ui_tree/render/text.rs +++ b/codex-rs/tui/src/ui_tree/render/text.rs @@ -42,6 +42,7 @@ pub(super) struct Chunk<'a> { #[derive(Default)] pub(super) struct PreparedText<'a> { + pub(super) has_authored_break: bool, pub(super) lines: Vec>>, } @@ -83,6 +84,7 @@ impl<'a> InlineText<'a> { } pub(super) fn finish(mut self) -> PreparedText<'a> { + self.text.has_authored_break = !self.text.lines.is_empty(); if !self.line.is_empty() { self.text.lines.push(chunks(self.line)); }