Reuse prepared UI trees for footer rendering (#60028)

## Why

Footer width measurement and rendering each prepared the same UI tree again.

## What changed

- Prepare footer extension content once and share the owned `PreparedUiTree` through `Arc`.
- Resolve colors against the active theme at render time, caching each color resolution within a render pass.
- Share leaf text buffers across prepared chunks instead of copying their slices.

## Testing

Add a test verifying that chunks from a clean text leaf retain slices of the original buffer. Update existing UI tree rendering tests to use the owned prepared representation.

GitOrigin-RevId: daba035c291338f3e0036e9ad4f2fd26f56a3910
This commit is contained in:
Joe Savona
2026-11-07 01:51:55 +00:00
committed by copyberry
parent 66a56b17a1
commit f8df49a196
10 changed files with 252 additions and 186 deletions
@@ -5,10 +5,12 @@ use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::text::Line;
use ratatui::widgets::Widget;
use std::sync::Arc;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::highlight::with_syntax_theme;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::ui_tree::PreparedUiTree;
use crate::ui_tree::UiTree;
/// Owned content for the footer's negotiated left-hand area.
@@ -16,12 +18,14 @@ use crate::ui_tree::UiTree;
pub(crate) enum FooterContent {
Line(Line<'static>),
HyperlinkLine(HyperlinkLine),
Extension { content: UiTree },
Extension { content: Arc<PreparedUiTree> },
}
impl From<UiTree> for FooterContent {
fn from(content: UiTree) -> Self {
Self::Extension { content }
fn from(tree: UiTree) -> Self {
Self::Extension {
content: Arc::new(tree.prepare()),
}
}
}
@@ -19,7 +19,7 @@ pub(crate) const STATUS_LINE_SEPARATOR: &str = " · ";
const STATUS_LINE_COLOR_SATURATION_PERCENT: u16 = 85;
const STATUS_LINE_COLOR_BRIGHTNESS_PERCENT: u16 = 100;
#[derive(Clone, Copy, Debug, Eq, PartialEq, serde::Deserialize, serde::Serialize)]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, serde::Deserialize, serde::Serialize)]
pub(crate) enum StatusLineAccent {
#[serde(rename = "status-model")]
Model,
+1
View File
@@ -4,6 +4,7 @@ mod decode;
mod render;
mod theme;
pub(crate) use render::PreparedUiTree;
use theme::Color;
use theme::TextWeight;
+40 -35
View File
@@ -18,39 +18,34 @@ 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;
use std::borrow::Cow;
use std::ops::Range;
use syntect::highlighting::Theme;
use vtparse::CollectingVTActor;
use vtparse::VTAction;
use vtparse::VTParser;
pub(crate) struct PreparedUiTree<'a>(Option<PreparedNode<'a>>);
#[derive(Debug, PartialEq)]
pub(crate) struct PreparedUiTree(Option<PreparedNode>);
impl UiTree {
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 {
self.prepare(theme).render_line(width)
pub(crate) fn prepare(self) -> PreparedUiTree {
PreparedUiTree(
self.0
.and_then(|node| PreparedNode::from_node(node, Paint::default())),
)
}
}
impl PreparedUiTree<'_> {
pub(crate) fn render_line(&self, width: u16) -> HyperlinkLine {
self.render_lines(Size::new(width, /*height*/ 1))
impl PreparedUiTree {
pub(crate) fn render_line(&self, width: u16, theme: &Theme) -> HyperlinkLine {
self.render_lines(Size::new(width, /*height*/ 1), theme)
.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> {
pub(crate) fn render_lines(&self, size: Size, theme: &Theme) -> Vec<HyperlinkLine> {
let Some(item) = &self.0 else {
return Vec::new();
};
@@ -60,7 +55,7 @@ impl PreparedUiTree<'_> {
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 mut lines = layout_node(item, width, height + 1, &mut RenderTheme::new(theme));
let more = lines.len() > height;
lines.truncate(height);
for (index, line) in lines.iter_mut().enumerate() {
@@ -73,9 +68,14 @@ impl PreparedUiTree<'_> {
}
}
fn layout_node(node: &PreparedNode<'_>, width: usize, limit: usize) -> Vec<HyperlinkLine> {
fn layout_node(
node: &PreparedNode,
width: usize,
limit: usize,
theme: &mut RenderTheme<'_>,
) -> Vec<HyperlinkLine> {
match &node.kind {
NodeKind::Text(text) => text.layout(width, limit),
NodeKind::Text(text) => text.layout(width, limit, theme),
NodeKind::Row {
children,
gap,
@@ -94,7 +94,8 @@ fn layout_node(node: &PreparedNode<'_>, width: usize, limit: usize) -> Vec<Hyper
width,
limit - lines.len(),
gap,
*paint,
paint,
theme,
));
if lines.len() == limit {
return lines;
@@ -112,7 +113,8 @@ fn layout_node(node: &PreparedNode<'_>, width: usize, limit: usize) -> Vec<Hyper
width,
limit - lines.len(),
gap,
*paint,
paint,
theme,
));
lines
}
@@ -120,11 +122,12 @@ fn layout_node(node: &PreparedNode<'_>, width: usize, limit: usize) -> Vec<Hyper
}
fn layout_band(
children: &[PreparedNode<'_>],
children: &[PreparedNode],
width: usize,
limit: usize,
gap: usize,
paint: Paint<'_>,
paint: &Paint,
theme: &mut RenderTheme<'_>,
) -> Vec<HyperlinkLine> {
let bounded = |cells: u64| cells.min((width + 1) as u64) as usize;
let mut reserved = children
@@ -140,7 +143,7 @@ fn layout_band(
let available = bounded(child.widths.preferred)
.min(width.saturating_sub(offset + reserved))
.max(minimum);
let fragments = layout_node(child, available.clamp(1, width), limit);
let fragments = layout_node(child, available.clamp(1, width), limit, theme);
if rows.len() < fragments.len() {
rows.resize_with(fragments.len(), Default::default);
}
@@ -149,8 +152,8 @@ fn layout_band(
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_span(Span::styled(" ".repeat(start - *end), paint.style(theme)));
if let Some(href) = &paint.href {
push_link(line, *end..start, href);
}
*end = start;
@@ -175,14 +178,17 @@ fn layout_band(
rows.into_iter().map(|(line, _)| line).collect()
}
fn sanitize_leaf(raw: &str) -> Cow<'_, str> {
fn sanitize_leaf(mut raw: String) -> String {
// Built-in values can bypass the wire decoder's text limit.
let raw = &raw[..raw.floor_char_boundary(MAX_TEXT_BYTES)];
if raw.len() > MAX_TEXT_BYTES {
raw.truncate(raw.floor_char_boundary(MAX_TEXT_BYTES));
raw.shrink_to_fit();
}
if raw
.chars()
.all(|ch| !ch.is_control() || matches!(ch, '\n' | '\t'))
{
return Cow::Borrowed(raw);
return raw;
}
let mut actions = CollectingVTActor::default();
VTParser::new().parse(raw.as_bytes(), &mut actions);
@@ -209,9 +215,9 @@ fn push_link(line: &mut HyperlinkLine, columns: Range<usize>, href: &str) {
}
impl TextProps {
fn style(&self, mut style: Style, theme: &RenderTheme<'_>) -> Style {
if let Some(color) = &self.color {
style = style.fg(color.resolve(theme));
fn apply_to(&self, paint: &mut Paint) {
if let Some(color) = self.color {
paint.foreground = Some(color);
}
for (enabled, modifier) in [
(
@@ -222,11 +228,10 @@ impl TextProps {
(self.underline, Modifier::UNDERLINED),
] {
match enabled {
Some(true) => style = style.add_modifier(modifier),
Some(false) => style = style.remove_modifier(modifier),
Some(true) => paint.modifiers = paint.modifiers.add_modifier(modifier),
Some(false) => paint.modifiers = paint.modifiers.remove_modifier(modifier),
None => {}
}
}
style
}
}
+32 -48
View File
@@ -5,53 +5,44 @@ 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;
pub(super) struct PreparedNode<'a> {
pub(super) kind: NodeKind<'a>,
#[derive(Debug, PartialEq)]
pub(super) struct PreparedNode {
pub(super) kind: NodeKind,
pub(super) widths: Widths,
}
#[derive(Debug, PartialEq)]
pub(super) struct Widths {
pub(super) minimum: u64,
pub(super) preferred: u64,
}
pub(super) enum NodeKind<'a> {
Text(PreparedText<'a>),
#[derive(Debug, PartialEq)]
pub(super) enum NodeKind {
Text(PreparedText),
Row {
children: Vec<PreparedNode<'a>>,
children: Vec<PreparedNode>,
gap: u64,
wrap: RowWrap,
paint: Paint<'a>,
paint: Paint,
},
}
impl<'a> PreparedNode<'a> {
pub(super) fn from_node(
node: &'a UiNode,
style: Style,
href: Option<&'a str>,
theme: &RenderTheme<'_>,
) -> Option<Self> {
impl PreparedNode {
pub(super) fn from_node(node: UiNode, paint: Paint) -> Option<Self> {
let mut items = Vec::new();
let mut inline = InlineText::default();
Self::collect_inline(&mut items, &mut inline, node, style, href, theme);
Self::collect_inline(&mut items, &mut inline, node, paint.clone());
items.extend(Self::text(inline));
if items.len() <= 1 {
items.pop()
} else {
Self::row(
items,
/*gap*/ 0,
RowWrap::Nowrap,
Paint { style, href },
)
Self::row(items, /*gap*/ 0, RowWrap::Nowrap, paint)
}
}
fn text(inline: InlineText<'a>) -> Option<Self> {
fn text(inline: InlineText) -> Option<Self> {
let text = inline.finish();
if text.lines.is_empty() {
return None;
@@ -75,7 +66,7 @@ impl<'a> PreparedNode<'a> {
})
}
fn row(mut children: Vec<Self>, gap: u64, wrap: RowWrap, paint: Paint<'a>) -> Option<Self> {
fn row(mut children: Vec<Self>, gap: u64, wrap: RowWrap, paint: Paint) -> Option<Self> {
children.retain(|child| {
child.widths.preferred > 0
|| match &child.kind {
@@ -115,59 +106,52 @@ impl<'a> PreparedNode<'a> {
fn collect_inline(
items: &mut Vec<Self>,
inline: &mut InlineText<'a>,
node: &'a UiNode,
mut style: Style,
mut href: Option<&'a str>,
theme: &RenderTheme<'_>,
inline: &mut InlineText,
node: UiNode,
mut paint: Paint,
) {
let children = match node {
UiNode::String(raw) => {
inline.push_leaf(raw, Paint { style, href });
inline.push_leaf(raw, paint);
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));
if let Some(color) = props.background {
paint.background = Some(color);
}
let children = children
.iter()
.into_iter()
.flatten()
.filter_map(|node| Self::from_node(node, style, href, theme))
.filter_map(|node| Self::from_node(node, paint.clone()))
.collect();
items.extend(Self::row(
children,
props.gap,
props.wrap,
Paint { style, href },
));
items.extend(Self::row(children, props.gap, props.wrap, paint));
return;
}
UiNode::Text { props, children } => {
style = props.style(style, theme);
props.apply_to(&mut paint);
children
}
UiNode::Link { props, children } => {
href = Some(&props.href.0);
style = style.underlined();
paint.href = Some(props.href.0.into());
paint.modifiers = paint.modifiers.underlined();
children
}
UiNode::Bold { children } => {
style = style.bold();
paint.modifiers = paint.modifiers.bold();
children
}
UiNode::Italic { children } => {
style = style.italic();
paint.modifiers = paint.modifiers.italic();
children
}
UiNode::Underline { children } => {
style = style.underlined();
paint.modifiers = paint.modifiers.underlined();
children
}
};
for node in children.iter().flatten() {
Self::collect_inline(items, inline, node, style, href, theme);
for node in children.into_iter().flatten() {
Self::collect_inline(items, inline, node, paint.clone());
}
}
}
@@ -1,12 +1,10 @@
#![cfg(test)]
use super::Paint;
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() {
@@ -17,13 +15,6 @@ fn a_row_measures_whole_children_and_intervening_gaps() {
]
}))
.unwrap();
let theme = Theme::default();
let node = PreparedNode::from_node(
&node,
Style::default(),
/*href*/ None,
&RenderTheme::new(&theme),
)
.unwrap();
let node = PreparedNode::from_node(node, Paint::default()).unwrap();
assert_eq!((node.widths.minimum, node.widths.preferred), (16, 22));
}
+112 -56
View File
@@ -1,13 +1,16 @@
//! Prepared inline text retains styled chunks and their legal line breaks across leaves.
use super::RenderTheme;
use super::push_link;
use super::sanitize_leaf;
use crate::line_truncation::span_width;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::ui_tree::theme::Color;
use ratatui::style::Style;
use ratatui::text::Span;
use std::borrow::Cow;
use std::ops::Deref;
use std::ops::Range;
use std::sync::Arc;
use textwrap::core::Fragment;
use textwrap::wrap_algorithms::wrap_first_fit;
use unicode_linebreak::linebreaks;
@@ -16,48 +19,93 @@ use unicode_segmentation::GraphemeCursor;
use unicode_segmentation::GraphemeIncomplete;
use unicode_segmentation::UnicodeSegmentation;
#[derive(Clone, Copy, Debug)]
pub(super) struct Paint<'a> {
pub(super) style: Style,
pub(super) href: Option<&'a str>,
#[derive(Clone, Debug, Default, PartialEq)]
pub(super) struct Paint {
pub(super) modifiers: Style,
pub(super) foreground: Option<Color>,
pub(super) background: Option<Color>,
pub(super) href: Option<Arc<String>>,
}
#[derive(Debug)]
struct Run<'a> {
text: Cow<'a, str>,
paint: Paint<'a>,
}
fn slice<'a>(text: &Cow<'a, str>, range: Range<usize>) -> Cow<'a, str> {
match text {
Cow::Borrowed(raw) => Cow::Borrowed(&raw[range]),
Cow::Owned(raw) => Cow::Owned(raw[range].to_owned()),
impl Paint {
pub(super) fn style(&self, theme: &mut RenderTheme<'_>) -> Style {
let mut style = self.modifiers;
style.fg = self.foreground.map(|c| theme.resolve(c));
style.bg = self.background.map(|c| theme.resolve(c));
style
}
}
#[derive(Debug, Default)]
pub(super) struct Chunk<'a> {
runs: Vec<Run<'a>>,
#[derive(Debug, PartialEq)]
struct Run {
text: RunText,
paint: Paint,
}
// Splitting a leaf at style, whitespace or wrap boundaries shares its original buffer.
#[derive(Debug)]
enum RunText {
Slice(Arc<String>, Range<usize>),
Space,
}
impl RunText {
fn new(text: String) -> Self {
let len = text.len();
Self::Slice(Arc::new(text), 0..len)
}
fn slice(&self, range: Range<usize>) -> Self {
match self {
Self::Slice(text, bytes) => Self::Slice(
Arc::clone(text),
bytes.start + range.start..bytes.start + range.end,
),
Self::Space => Self::Space,
}
}
}
impl Deref for RunText {
type Target = str;
fn deref(&self) -> &str {
match self {
Self::Slice(text, bytes) => &text[bytes.clone()],
Self::Space => " ",
}
}
}
impl PartialEq for RunText {
fn eq(&self, other: &Self) -> bool {
**self == **other
}
}
#[derive(Debug, Default, PartialEq)]
pub(super) struct Chunk {
runs: Vec<Run>,
pub(super) width_cells: usize,
pub(super) trailing_space: Option<Paint<'a>>,
pub(super) trailing_space: Option<Paint>,
}
#[derive(Default)]
pub(super) struct PreparedText<'a> {
#[derive(Debug, Default, PartialEq)]
pub(super) struct PreparedText {
pub(super) has_authored_break: bool,
pub(super) lines: Vec<Vec<Chunk<'a>>>,
pub(super) lines: Vec<Vec<Chunk>>,
}
#[derive(Default)]
pub(super) struct InlineText<'a> {
text: PreparedText<'a>,
line: Vec<Run<'a>>,
space: Option<Paint<'a>>,
pub(super) struct InlineText {
text: PreparedText,
line: Vec<Run>,
space: Option<Paint>,
}
impl<'a> InlineText<'a> {
pub(super) fn push_leaf(&mut self, raw: &'a str, paint: Paint<'a>) {
let text = sanitize_leaf(raw);
impl InlineText {
pub(super) fn push_leaf(&mut self, raw: String, paint: Paint) {
let text = RunText::new(sanitize_leaf(raw));
let mut start = 0;
for (end, ch) in text
.match_indices([' ', '\t', '\n'])
@@ -66,26 +114,26 @@ impl<'a> InlineText<'a> {
if start < end {
if let Some(paint) = self.space.take() {
self.line.push(Run {
text: " ".into(),
text: RunText::Space,
paint,
});
}
self.line.push(Run {
text: slice(&text, start..end),
paint,
text: text.slice(start..end),
paint: paint.clone(),
});
}
if ch == "\n" {
self.text.lines.push(chunks(std::mem::take(&mut self.line)));
self.space = None;
} else if !ch.is_empty() && !self.line.is_empty() {
self.space.get_or_insert(paint);
self.space.get_or_insert_with(|| paint.clone());
}
start = end + ch.len();
}
}
pub(super) fn finish(mut self) -> PreparedText<'a> {
pub(super) fn finish(mut self) -> PreparedText {
self.text.has_authored_break = !self.text.lines.is_empty();
if !self.line.is_empty() {
self.text.lines.push(chunks(self.line));
@@ -96,7 +144,7 @@ impl<'a> InlineText<'a> {
// Most seams already fall between graphemes. Only a cluster that actually crosses a
// seam needs contiguous storage, including when it spans more than two leaves.
fn join_graphemes(runs: Vec<Run<'_>>) -> Vec<Run<'_>> {
fn join_graphemes(runs: Vec<Run>) -> Vec<Run> {
let mut runs = runs.into_iter().peekable();
let mut joined = Vec::new();
while let Some(run) = runs.next() {
@@ -130,8 +178,8 @@ fn join_graphemes(runs: Vec<Run<'_>>) -> Vec<Run<'_>> {
let prefix = run.text.len() - split;
if prefix > 0 {
joined.push(Run {
text: slice(&run.text, 0..prefix),
paint,
text: run.text.slice(0..prefix),
paint: paint.clone(),
});
}
// Resume in the growing cluster so even a long chain of combining marks
@@ -153,14 +201,14 @@ fn join_graphemes(runs: Vec<Run<'_>>) -> Vec<Run<'_>> {
}
if consumed < next.text.len() {
if consumed > 0 {
next.text = slice(&next.text, consumed..next.text.len());
next.text = next.text.slice(consumed..next.text.len());
}
break;
}
runs.next();
}
joined.push(Run {
text: Cow::Owned(cluster),
text: RunText::new(cluster),
paint,
});
}
@@ -209,7 +257,7 @@ fn break_offsets<'a>(runs: impl Iterator<Item = &'a str>) -> Vec<usize> {
breaks
}
fn chunks(runs: Vec<Run<'_>>) -> Vec<Chunk<'_>> {
fn chunks(runs: Vec<Run>) -> Vec<Chunk> {
let runs = join_graphemes(runs);
let mut breaks = break_offsets(runs.iter().map(|run| run.text.as_ref()))
.into_iter()
@@ -232,7 +280,7 @@ fn chunks(runs: Vec<Run<'_>>) -> Vec<Chunk<'_>> {
};
if can_break {
chunk.push(&run, start..i);
if chunk.runs.last().is_some_and(|r| r.text == " ") {
if chunk.runs.last().is_some_and(|r| &*r.text == " ") {
chunk.trailing_space = chunk.runs.pop().map(|r| r.paint);
chunk.width_cells -= 1;
}
@@ -255,17 +303,20 @@ fn chunks(runs: Vec<Run<'_>>) -> Vec<Chunk<'_>> {
out
}
impl<'a> Chunk<'a> {
fn push(&mut self, run: &Run<'a>, range: Range<usize>) {
impl Chunk {
fn push(&mut self, run: &Run, range: Range<usize>) {
if !range.is_empty() {
let text = slice(&run.text, range);
self.width_cells += span_width(&Span::raw(text.as_ref()));
self.runs.push(Run { text, ..*run });
let text = run.text.slice(range);
self.width_cells += span_width(&Span::raw(&*text));
self.runs.push(Run {
text,
paint: run.paint.clone(),
});
}
}
}
impl Fragment for Chunk<'_> {
impl Fragment for Chunk {
fn width(&self) -> f64 {
self.width_cells as f64
}
@@ -277,25 +328,30 @@ impl Fragment for Chunk<'_> {
}
}
impl PreparedText<'_> {
pub(super) fn layout(&self, width: usize, limit: usize) -> Vec<HyperlinkLine> {
impl PreparedText {
pub(super) fn layout(
&self,
width: usize,
limit: usize,
theme: &mut RenderTheme<'_>,
) -> Vec<HyperlinkLine> {
self.lines
.iter()
.flat_map(|line| wrap_first_fit(line, &[width as f64]))
.take(limit)
.map(paint_chunks)
.map(|chunks| paint_chunks(chunks, theme))
.collect()
}
}
fn paint_chunks(chunks: &[Chunk<'_>]) -> HyperlinkLine {
fn paint_chunks(chunks: &[Chunk], theme: &mut RenderTheme<'_>) -> 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);
let span = Span::styled(run.text.to_string(), run.paint.style(theme));
let end = col + span_width(&span);
if let Some(url) = run.paint.href
if let Some(url) = &run.paint.href
&& end > col
{
push_link(&mut line, col..end, url);
@@ -304,10 +360,10 @@ fn paint_chunks(chunks: &[Chunk<'_>]) -> HyperlinkLine {
col = end;
}
if index + 1 < chunks.len()
&& let Some(paint) = chunk.trailing_space
&& let Some(paint) = &chunk.trailing_space
{
line.line.push_span(Span::styled(" ", paint.style));
if let Some(url) = paint.href {
line.line.push_span(Span::styled(" ", paint.style(theme)));
if let Some(url) = &paint.href {
push_link(&mut line, col..col + 1, url);
}
col += 1;
@@ -14,3 +14,21 @@ fn styled_runs_have_the_same_breaks_as_contiguous_text() {
let expected: Vec<_> = linebreaks(&text).map(|(i, _)| i).collect();
assert_eq!(super::break_offsets(runs), expected[..expected.len() - 1]);
}
#[test]
fn chunks_of_a_clean_leaf_share_its_original_buffer() {
let leaf = String::from("build ready");
let start = leaf.as_ptr();
let mut inline = super::InlineText::default();
inline.push_leaf(leaf, super::Paint::default());
let prepared = inline.finish();
let runs: Vec<_> = prepared.lines[0]
.iter()
.map(|chunk| &*chunk.runs[0].text)
.collect();
assert_eq!(runs, ["build", "ready"]);
assert_eq!(
[runs[0].as_ptr(), runs[1].as_ptr()],
[start, start.wrapping_add(6)]
);
}
+16 -3
View File
@@ -9,6 +9,7 @@ use crate::render::highlight;
use crate::terminal_palette;
use crate::terminal_palette::StdoutColorLevel;
use std::cell::OnceCell;
use std::collections::HashMap;
use syntect::highlighting::Highlighter;
use syntect::highlighting::Theme;
use syntect::parsing::Scope;
@@ -16,6 +17,7 @@ use syntect::parsing::Scope;
pub(super) struct RenderTheme<'a> {
theme: &'a Theme,
highlighter: OnceCell<Highlighter<'a>>,
resolved: HashMap<Color, TerminalColor>,
}
impl<'a> RenderTheme<'a> {
@@ -23,9 +25,20 @@ impl<'a> RenderTheme<'a> {
Self {
theme,
highlighter: OnceCell::new(),
resolved: HashMap::new(),
}
}
// A color can occur on many chunks. Match its theme scopes once per paint.
pub(super) fn resolve(&mut self, color: Color) -> TerminalColor {
if let Some(&resolved) = self.resolved.get(&color) {
return resolved;
}
let resolved = color.resolve(self);
self.resolved.insert(color, resolved);
resolved
}
// Plain text and literal colors need no TextMate scope matching.
fn highlighter(&self) -> &Highlighter<'a> {
self.highlighter
@@ -48,7 +61,7 @@ fn terminal_color(color: Option<TerminalColor>, fallback: TerminalColor) -> Term
}
}
#[derive(Clone, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(rename_all = "kebab-case")]
pub(super) enum Color {
Primary,
@@ -69,7 +82,7 @@ pub(super) enum Color {
}
impl Color {
pub(super) fn resolve(&self, palette: &RenderTheme<'_>) -> TerminalColor {
fn resolve(self, palette: &RenderTheme<'_>) -> TerminalColor {
let setting = |color: Option<syntect::highlighting::Color>| {
terminal_color(
color.and_then(highlight::convert_syntect_color),
@@ -108,7 +121,7 @@ impl Color {
}
}
#[derive(Clone, Copy, Debug, Eq, PartialEq, Serialize, Deserialize)]
#[derive(Clone, Copy, Debug, Eq, Hash, PartialEq, Serialize, Deserialize)]
#[serde(try_from = "String", into = "String")]
pub(super) struct HexRgb((u8, u8, u8));
+23 -29
View File
@@ -14,14 +14,17 @@ fn parse(value: Value) -> serde_json::Result<UiTree> {
value.to_string().parse()
}
fn tree(value: Value) -> UiTree {
parse(value).unwrap()
fn tree(value: Value) -> PreparedUiTree {
parse(value).unwrap().prepare()
}
#[test]
fn elements_round_trip_and_require_explicit_row_spacing() {
let value = json!({"type":"row","props":{"gap":1},"children":[{"type":"link","props":{"href":"https://example.com/pr/42","title":"PR details"},"children":["12%"]}, null, " "]});
assert_eq!(serde_json::to_value(tree(value.clone())).unwrap(), value);
assert_eq!(
serde_json::to_value(parse(value.clone()).unwrap()).unwrap(),
value
);
for href in [
"relative/path",
"file:///tmp/example",
@@ -204,7 +207,7 @@ fn single_line_view_marks_vertical_overflow() {
#[test]
fn directly_constructed_text_keeps_a_bounded_utf8_prefix() {
let prefix = "x".repeat(MAX_TEXT_BYTES - 1);
let text = UiTree(Some(UiNode::String(format!("{prefix}界tail"))));
let text = UiTree(Some(UiNode::String(format!("{prefix}界tail")))).prepare();
assert_eq!(
text.render_line(u16::MAX, &Theme::default())
.line
@@ -227,7 +230,10 @@ fn text_and_row_share_the_color_contract() {
let value = json!({"type":"row","props":{"gap":0,"background":color},"children":[
{"type":"text","props":{"color":color,"weight":"bold","italic":true,"underline":false},"children":["ok"]}
]});
assert_eq!(serde_json::to_value(tree(value.clone())).unwrap(), value);
assert_eq!(
serde_json::to_value(parse(value.clone()).unwrap()).unwrap(),
value
);
}
for props in [
json!({"weight":"semibold"}),
@@ -488,9 +494,7 @@ fn whitespace_collapses_across_styled_leaves() {
let node = tree(json!({"type":"text","children":[
" build\t", {"type":"bold","children":[" ready "]}, "\nnext"
]}));
let lines = node
.prepare(&Theme::default())
.render_lines(Size::new(/*width*/ 20, /*height*/ 2));
let lines = node.render_lines(Size::new(/*width*/ 20, /*height*/ 2), &Theme::default());
let text: Vec<_> = lines
.into_iter()
.map(|line| line.line.to_string())
@@ -505,12 +509,12 @@ fn whitespace_collapses_across_styled_leaves() {
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"})))
parse(row(json!([1, "red"]))).unwrap(),
parse(row(json!({"gap":1,"background":"red"}))).unwrap()
);
assert_eq!(
tree(row(json!({"gap":1,"wrap":"nowrap"}))),
tree(row(json!({"gap":1})))
parse(row(json!({"gap":1,"wrap":"nowrap"}))).unwrap(),
parse(row(json!({"gap":1}))).unwrap()
);
for invalid in [json!("wrap-reverse"), json!(null), json!({"wrap":null})] {
assert!(parse(row(json!({"gap":0,"wrap":invalid}))).is_err());
@@ -530,7 +534,7 @@ fn wrapped_link_offsets_follow_each_row_and_stop_before_clipping() {
(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);
let lines = node.render_lines(size, &Theme::default());
assert_eq!(lines.len(), expected.len());
for (line, (text, columns)) in lines.into_iter().zip(expected) {
assert_eq!(line.line.to_string(), text);
@@ -555,9 +559,7 @@ fn styled_text_wraps_in_the_space_its_nowrap_row_assigns() {
{"type":"link","props":{"href":href},"children":["was green"]}
]}, "!"
]}));
let lines = node
.prepare(&Theme::default())
.render_lines(Size::new(10, 4));
let lines = node.render_lines(Size::new(10, 4), &Theme::default());
assert_eq!(
lines
.iter()
@@ -579,9 +581,7 @@ fn styled_text_wraps_in_the_space_its_nowrap_row_assigns() {
#[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));
let lines = node.render_lines(Size::new(6, 2), &Theme::default());
assert_eq!(
lines
.iter()
@@ -606,9 +606,7 @@ fn wrapping_row_moves_a_hash_and_nonbreaking_pr_label_together() {
(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 lines = node.render_lines(Size::new(width, 4), &Theme::default());
let text: Vec<_> = lines.iter().map(|line| line.line.to_string()).collect();
assert_eq!(text, expected);
assert_eq!(
@@ -629,7 +627,7 @@ fn authored_line_breaks_and_collapsed_whitespace_preserve_styles_and_links() {
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 8, /*height*/ 4,
);
let lines = node.prepare(&Theme::default()).render_lines(area.as_size());
let lines = node.render_lines(area.as_size(), &Theme::default());
assert_eq!(
lines[3].hyperlinks,
[TerminalHyperlink::web(0..6, href.to_owned())]
@@ -653,9 +651,7 @@ fn authored_line_breaks_and_collapsed_whitespace_preserve_styles_and_links() {
#[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));
let lines = node.render_lines(Size::new(10, 3), &Theme::default());
assert_eq!(
lines
.iter()
@@ -675,9 +671,7 @@ fn width_fitting_respects_word_hyphens_across_styles() {
] {
let node =
tree(json!({"type":"text","children":[{"type":"bold","children":[prefix]},suffix]}));
let lines = node
.prepare(&Theme::default())
.render_lines(Size::new(2, 3));
let lines = node.render_lines(Size::new(2, 3), &Theme::default());
assert_eq!(
lines
.iter()