mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +00:00
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
This commit is contained in:
@@ -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<PreparedNode<'a>>);
|
||||
|
||||
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> {
|
||||
|
||||
@@ -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<PreparedNode<'a>>,
|
||||
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<Self> {
|
||||
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<Self> {
|
||||
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<Self>, gap: u64, paint: Paint<'a>) -> Option<Self> {
|
||||
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<Self>,
|
||||
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;
|
||||
@@ -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));
|
||||
}
|
||||
@@ -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<Vec<Chunk<'a>>>,
|
||||
}
|
||||
|
||||
@@ -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));
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user