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)); }