mirror of
https://github.com/openai/codex.git
synced 2026-11-07 20:30:33 +00:00
Normalize inline UI tree text across styled leaves (#60021)
## What changed - Collapse spaces and tabs across inline leaves, trim leading and trailing whitespace, and keep rendering limited to the first line. - Prepare Unicode line-break opportunities across style boundaries and join graphemes split across leaves. Use the first fragment's style and retain a hyperlink only when all fragments share its destination. - Preserve row boundaries and explicit gaps, and use row gaps for status-line separators. - Prevent zero-width fragments from adding row gaps or changing the ellipsis style. ## Testing Add regression tests for whitespace collapse, split graphemes, hyperlink ownership, zero-width fragments, and line-break consistency between styled runs and contiguous text. GitOrigin-RevId: 94e58808bc8469e9cd0adddb16c194b9568cfbb6
This commit is contained in:
Generated
+1
@@ -5275,6 +5275,7 @@ dependencies = [
|
||||
"tracing-appender",
|
||||
"tracing-subscriber",
|
||||
"two-face",
|
||||
"unicode-linebreak",
|
||||
"unicode-segmentation",
|
||||
"unicode-width 0.2.1",
|
||||
"url",
|
||||
|
||||
@@ -558,6 +558,7 @@ tree-sitter-powershell = "=0.26.4"
|
||||
ts-rs = "11"
|
||||
tungstenite = { version = "0.27.0", features = ["deflate", "proxy"] }
|
||||
uds_windows = "1.1.0"
|
||||
unicode-linebreak = "0.1.5"
|
||||
unicode-normalization = "0.1.25"
|
||||
unicode-segmentation = "1.12.0"
|
||||
unicode-width = "0.2"
|
||||
|
||||
@@ -127,6 +127,7 @@ tracing-appender = { workspace = true }
|
||||
tracing-subscriber = { workspace = true, features = ["env-filter"] }
|
||||
syntect = { version = "5", default-features = false, features = ["parsing", "plist-load", "regex-onig"] }
|
||||
two-face = { version = "0.5", default-features = false, features = ["syntect-onig"] }
|
||||
unicode-linebreak = { workspace = true }
|
||||
unicode-segmentation = { workspace = true }
|
||||
unicode-width = { workspace = true }
|
||||
vtparse = { workspace = true }
|
||||
|
||||
@@ -26,10 +26,10 @@ fn composer() -> ChatComposer {
|
||||
fn extension_status_line_clips_links_to_footer_layout() {
|
||||
let mut composer = composer();
|
||||
let tree = r#"{
|
||||
"type": "row", "props": {"gap": 0}, "children": [
|
||||
"type": "row", "props": {"gap": 1}, "children": [
|
||||
"plain",
|
||||
{ "type": "row", "props": {"gap": 0}, "children": [
|
||||
" · ",
|
||||
{ "type": "row", "props": {"gap": 1}, "children": [
|
||||
"·",
|
||||
{ "type": "link", "props": { "href": "https://example.com/pr/42" }, "children": [
|
||||
"PR #42"
|
||||
] }
|
||||
|
||||
@@ -38,7 +38,9 @@ impl StatusLineRenderer {
|
||||
let mut children = Vec::new();
|
||||
for (item, text) in segments {
|
||||
if !children.is_empty() {
|
||||
children.push(Some(UiNode::String(STATUS_LINE_SEPARATOR.to_owned())));
|
||||
children.push(Some(UiNode::String(
|
||||
STATUS_LINE_SEPARATOR.trim().to_owned(),
|
||||
)));
|
||||
}
|
||||
let text = UiNode::String(text);
|
||||
let node = if item == StatusLineItem::PullRequestNumber
|
||||
@@ -53,10 +55,9 @@ impl StatusLineRenderer {
|
||||
};
|
||||
children.push(Some(node));
|
||||
}
|
||||
let tree = UiNode::Row {
|
||||
props: RowProps::default(),
|
||||
children,
|
||||
};
|
||||
let mut props = RowProps::default();
|
||||
props.gap = 1;
|
||||
let tree = UiNode::Row { props, children };
|
||||
Some(FooterContent::Extension {
|
||||
content: UiTree(Some(tree)),
|
||||
})
|
||||
|
||||
@@ -1,5 +1,9 @@
|
||||
//! Render the visible prefix of a horizontal display tree into one terminal line.
|
||||
|
||||
mod text;
|
||||
|
||||
use self::text::InlineText;
|
||||
use self::text::Paint;
|
||||
use super::MAX_TEXT_BYTES;
|
||||
use super::TextProps;
|
||||
use super::TextWeight;
|
||||
@@ -13,6 +17,7 @@ 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;
|
||||
@@ -25,8 +30,15 @@ impl UiTree {
|
||||
let Some(root) = self.0.as_ref().filter(|_| width > 0) else {
|
||||
return HyperlinkLine::default();
|
||||
};
|
||||
let mut line = node_line(root, width, Style::default(), &RenderTheme::new(theme));
|
||||
if line.width() > width {
|
||||
let output = LineBuilder::from_node(
|
||||
root,
|
||||
width,
|
||||
Style::default(),
|
||||
/*href*/ None,
|
||||
&RenderTheme::new(theme),
|
||||
);
|
||||
let mut line = output.line;
|
||||
if output.used > width {
|
||||
line.line = truncate_line_with_ellipsis(line.line, width);
|
||||
line.clip_hyperlinks_to_width(line.width() - 1);
|
||||
}
|
||||
@@ -34,35 +46,122 @@ impl UiTree {
|
||||
}
|
||||
}
|
||||
|
||||
fn node_line(node: &UiNode, width: usize, style: Style, theme: &RenderTheme<'_>) -> HyperlinkLine {
|
||||
let (children, gap, style) = match node {
|
||||
UiNode::String(raw) => {
|
||||
let text = sanitize_leaf(raw);
|
||||
let first = text.split('\n').next().unwrap_or_default();
|
||||
let visible = first.replace('\t', " ");
|
||||
return HyperlinkLine::new(Span::styled(visible, style).into());
|
||||
}
|
||||
UiNode::Link { props, children } => {
|
||||
let mut line = row_line(children, /*gap*/ 0, width, style.underlined(), theme);
|
||||
let width = line.width();
|
||||
if width > 0 {
|
||||
line.hyperlinks = vec![TerminalHyperlink::web(0..width, props.href.0.clone())];
|
||||
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;
|
||||
}
|
||||
return line;
|
||||
UiNode::Row { props, children } => {
|
||||
self.saw_row = true;
|
||||
self.flush_runs();
|
||||
if let Some(color) = &props.background {
|
||||
style = style.bg(color.resolve(theme));
|
||||
}
|
||||
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);
|
||||
}
|
||||
self.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;
|
||||
}
|
||||
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);
|
||||
}
|
||||
UiNode::Row { props, children } => {
|
||||
let style = props
|
||||
.background
|
||||
.as_ref()
|
||||
.map_or(style, |color| style.bg(color.resolve(theme)));
|
||||
(children, props.gap, style)
|
||||
}
|
||||
|
||||
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);
|
||||
}
|
||||
UiNode::Text { props, children } => (children, 0, props.style(style, theme)),
|
||||
UiNode::Bold { children } => (children, 0, style.bold()),
|
||||
UiNode::Italic { children } => (children, 0, style.italic()),
|
||||
UiNode::Underline { children } => (children, 0, style.underlined()),
|
||||
};
|
||||
row_line(children, gap, width, style, theme)
|
||||
// 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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn sanitize_leaf(raw: &str) -> Cow<'_, str> {
|
||||
@@ -86,43 +185,16 @@ fn sanitize_leaf(raw: &str) -> Cow<'_, str> {
|
||||
.collect()
|
||||
}
|
||||
|
||||
fn row_line(
|
||||
children: &[Option<UiNode>],
|
||||
gap: u64,
|
||||
width: usize,
|
||||
style: Style,
|
||||
theme: &RenderTheme<'_>,
|
||||
) -> HyperlinkLine {
|
||||
let mut result = HyperlinkLine::default();
|
||||
let mut used = 0;
|
||||
for child in children.iter().flatten() {
|
||||
let child = node_line(child, width, style, theme);
|
||||
let child_width = child.width();
|
||||
if child_width == 0 {
|
||||
continue;
|
||||
}
|
||||
if used > 0 && gap > 0 {
|
||||
// One column past the viewport is sufficient to detect overflow.
|
||||
let spacing = gap.min((width + 1 - used) as u64) as usize;
|
||||
result
|
||||
.line
|
||||
.push_span(Span::styled(" ".repeat(spacing), style));
|
||||
used += spacing;
|
||||
}
|
||||
let offset = used;
|
||||
result.line.spans.extend(child.line.spans);
|
||||
result
|
||||
.hyperlinks
|
||||
.extend(child.hyperlinks.into_iter().map(|mut link| {
|
||||
link.columns = link.columns.start + offset..link.columns.end + offset;
|
||||
link
|
||||
}));
|
||||
used += child_width;
|
||||
if used > width {
|
||||
break;
|
||||
}
|
||||
fn push_link(line: &mut HyperlinkLine, columns: Range<usize>, href: &str) {
|
||||
if let Some(last) = line.hyperlinks.last_mut()
|
||||
&& last.columns.end == columns.start
|
||||
&& last.destination == href
|
||||
{
|
||||
last.columns.end = columns.end;
|
||||
} else {
|
||||
line.hyperlinks
|
||||
.push(TerminalHyperlink::web(columns, href.to_owned()));
|
||||
}
|
||||
result
|
||||
}
|
||||
|
||||
impl TextProps {
|
||||
|
||||
@@ -0,0 +1,281 @@
|
||||
//! Prepared inline text retains styled chunks and their legal line breaks across leaves.
|
||||
|
||||
use super::push_link;
|
||||
use super::sanitize_leaf;
|
||||
use crate::line_truncation::span_width;
|
||||
use crate::terminal_hyperlinks::HyperlinkLine;
|
||||
use ratatui::style::Style;
|
||||
use ratatui::text::Span;
|
||||
use std::borrow::Cow;
|
||||
use std::ops::Range;
|
||||
use unicode_linebreak::linebreaks;
|
||||
use unicode_linebreak::split_at_safe;
|
||||
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(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()),
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default)]
|
||||
pub(super) struct Chunk<'a> {
|
||||
runs: Vec<Run<'a>>,
|
||||
pub(super) width_cells: usize,
|
||||
pub(super) trailing_space: Option<Paint<'a>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct PreparedText<'a> {
|
||||
pub(super) lines: Vec<Vec<Chunk<'a>>>,
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
pub(super) struct InlineText<'a> {
|
||||
text: PreparedText<'a>,
|
||||
line: Vec<Run<'a>>,
|
||||
space: Option<Paint<'a>>,
|
||||
}
|
||||
|
||||
impl<'a> InlineText<'a> {
|
||||
pub(super) fn push_leaf(&mut self, raw: &'a str, paint: Paint<'a>) {
|
||||
let text = sanitize_leaf(raw);
|
||||
let mut start = 0;
|
||||
for (end, ch) in text
|
||||
.match_indices([' ', '\t', '\n'])
|
||||
.chain(std::iter::once((text.len(), "")))
|
||||
{
|
||||
if start < end {
|
||||
if let Some(paint) = self.space.take() {
|
||||
self.line.push(Run {
|
||||
text: " ".into(),
|
||||
paint,
|
||||
});
|
||||
}
|
||||
self.line.push(Run {
|
||||
text: slice(&text, start..end),
|
||||
paint,
|
||||
});
|
||||
}
|
||||
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);
|
||||
}
|
||||
start = end + ch.len();
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn finish(mut self) -> PreparedText<'a> {
|
||||
if !self.line.is_empty() {
|
||||
self.text.lines.push(chunks(self.line));
|
||||
}
|
||||
self.text
|
||||
}
|
||||
}
|
||||
|
||||
// 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<'_>> {
|
||||
let mut runs = runs.into_iter().peekable();
|
||||
let mut joined = Vec::new();
|
||||
while let Some(run) = runs.next() {
|
||||
let Some(next) = runs.peek() else {
|
||||
joined.push(run);
|
||||
break;
|
||||
};
|
||||
if run.text.as_bytes().last().is_some_and(u8::is_ascii)
|
||||
&& next.text.as_bytes()[0].is_ascii()
|
||||
{
|
||||
joined.push(run);
|
||||
continue;
|
||||
}
|
||||
// Reverse segmentation in unicode-segmentation 1.12 disagrees on Indic conjuncts.
|
||||
#[expect(clippy::double_ended_iterator_last)]
|
||||
let Some(tail) = run.text.graphemes(true).last() else {
|
||||
continue;
|
||||
};
|
||||
let split = tail.len();
|
||||
let mut cursor = GraphemeCursor::new(split, usize::MAX, true);
|
||||
while let Err(GraphemeIncomplete::PreContext(_)) = cursor.is_boundary(&next.text, split) {
|
||||
cursor.provide_context(tail, 0);
|
||||
}
|
||||
if cursor.is_boundary(&next.text, split) == Ok(true) {
|
||||
joined.push(run);
|
||||
continue;
|
||||
}
|
||||
|
||||
let mut cluster = tail.to_owned();
|
||||
let mut paint = run.paint;
|
||||
let prefix = run.text.len() - split;
|
||||
if prefix > 0 {
|
||||
joined.push(Run {
|
||||
text: slice(&run.text, 0..prefix),
|
||||
paint,
|
||||
});
|
||||
}
|
||||
// Resume in the growing cluster so even a long chain of combining marks
|
||||
// advances once per character and Unicode lookbehind stays available.
|
||||
let mut cursor = GraphemeCursor::new(0, usize::MAX, true);
|
||||
let _ = cursor.next_boundary(&cluster, 0);
|
||||
while let Some(next) = runs.peek_mut() {
|
||||
let mut consumed = 0;
|
||||
for ch in next.text.chars() {
|
||||
cluster.push(ch);
|
||||
if cursor.next_boundary(&cluster, 0).is_ok() {
|
||||
cluster.pop();
|
||||
break;
|
||||
}
|
||||
consumed += ch.len_utf8();
|
||||
}
|
||||
if consumed > 0 && paint.href != next.paint.href {
|
||||
paint.href = None;
|
||||
}
|
||||
if consumed < next.text.len() {
|
||||
if consumed > 0 {
|
||||
next.text = slice(&next.text, consumed..next.text.len());
|
||||
}
|
||||
break;
|
||||
}
|
||||
runs.next();
|
||||
}
|
||||
joined.push(Run {
|
||||
text: Cow::Owned(cluster),
|
||||
paint,
|
||||
});
|
||||
}
|
||||
joined
|
||||
}
|
||||
|
||||
// Keep the context needed for breaks across style boundaries.
|
||||
fn break_offsets<'a>(runs: impl Iterator<Item = &'a str>) -> Vec<usize> {
|
||||
let mut runs = runs.peekable();
|
||||
let mut breaks = Vec::new();
|
||||
let mut scratch = String::new();
|
||||
let mut base = 0;
|
||||
while let Some(&run) = runs.peek() {
|
||||
let scanned = scratch.len();
|
||||
let window = if scanned == 0 {
|
||||
runs.next();
|
||||
run
|
||||
} else {
|
||||
// Match the retained context with fresh bytes to keep rescanning linear.
|
||||
// Bound each read so a short prefix never pulls a large ASCII run into scratch.
|
||||
while scratch.len() - scanned < scanned
|
||||
&& let Some(next) = runs.peek_mut()
|
||||
{
|
||||
let (part, rest) = next.split_at(next.floor_char_boundary(scanned.max(128)));
|
||||
scratch.push_str(part);
|
||||
*next = rest;
|
||||
runs.next_if(|s| s.is_empty());
|
||||
}
|
||||
scratch.as_str()
|
||||
};
|
||||
breaks.extend(
|
||||
linebreaks(window)
|
||||
.filter_map(|(i, _)| (i >= scanned && i < window.len()).then_some(base + i)),
|
||||
);
|
||||
if runs.peek().is_none() {
|
||||
break;
|
||||
}
|
||||
let discard = split_at_safe(window).0.len();
|
||||
base += discard;
|
||||
if scanned == 0 {
|
||||
scratch.push_str(&run[discard..]);
|
||||
} else {
|
||||
scratch.drain(..discard);
|
||||
}
|
||||
}
|
||||
breaks
|
||||
}
|
||||
|
||||
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()
|
||||
.peekable();
|
||||
let mut out = Vec::new();
|
||||
let mut chunk = Chunk::default();
|
||||
let mut offset = 0;
|
||||
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;
|
||||
}
|
||||
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..run.text.len());
|
||||
offset += run.text.len();
|
||||
}
|
||||
if !chunk.runs.is_empty() {
|
||||
out.push(chunk);
|
||||
}
|
||||
out
|
||||
}
|
||||
|
||||
impl<'a> Chunk<'a> {
|
||||
fn push(&mut self, run: &Run<'a>, 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 });
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn paint_chunks(chunks: &[Chunk<'_>], line: &mut HyperlinkLine, start: usize) -> usize {
|
||||
let mut col = start;
|
||||
for (index, chunk) in chunks.iter().enumerate() {
|
||||
for run in &chunk.runs {
|
||||
let span = Span::styled(run.text.to_string(), run.paint.style);
|
||||
let end = col + span_width(&span);
|
||||
if let Some(url) = run.paint.href
|
||||
&& end > col
|
||||
{
|
||||
push_link(line, col..end, url);
|
||||
}
|
||||
line.line.push_span(span);
|
||||
col = end;
|
||||
}
|
||||
if index + 1 < chunks.len()
|
||||
&& let Some(paint) = chunk.trailing_space
|
||||
{
|
||||
line.line.push_span(Span::styled(" ", paint.style));
|
||||
if let Some(url) = paint.href {
|
||||
push_link(line, col..col + 1, url);
|
||||
}
|
||||
col += 1;
|
||||
}
|
||||
}
|
||||
col - start
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
#[path = "text_tests.rs"]
|
||||
mod tests;
|
||||
@@ -0,0 +1,16 @@
|
||||
#![cfg(test)]
|
||||
|
||||
use pretty_assertions::assert_eq;
|
||||
use unicode_linebreak::linebreaks;
|
||||
|
||||
#[test]
|
||||
fn styled_runs_have_the_same_breaks_as_contiguous_text() {
|
||||
let long = "中日".repeat(200);
|
||||
let runs = ["bu", "ild (", " ready)", "— ", "—"] // codespell:ignore bu
|
||||
.into_iter()
|
||||
.chain(std::iter::repeat_n("🇨", 79))
|
||||
.chain([long.as_str(), " done"]);
|
||||
let text: String = runs.clone().collect();
|
||||
let expected: Vec<_> = linebreaks(&text).map(|(i, _)| i).collect();
|
||||
assert_eq!(super::break_offsets(runs), expected[..expected.len() - 1]);
|
||||
}
|
||||
@@ -167,6 +167,24 @@ fn ellipsis_counts_toward_width_only_when_there_is_overflow() {
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn zero_width_fragments_do_not_style_the_ellipsis_or_add_row_gaps() {
|
||||
let row = |text| json!({"type":"row","props":{"gap":0},"children":[text]});
|
||||
let mark = json!({"type":"bold","children":["\u{301}"]});
|
||||
for (mut root, width) in [
|
||||
(json!({"type":"text"}), 2),
|
||||
(json!({"type":"row","props":{"gap":1}}), 4),
|
||||
] {
|
||||
root["children"] = json!([row("x"), mark, row("yz")]);
|
||||
let node = tree(root.clone());
|
||||
root["children"] = json!([row("x"), row("yz")]);
|
||||
assert_eq!(
|
||||
node.render_line(width, &Theme::default()),
|
||||
tree(root).render_line(width, &Theme::default())
|
||||
);
|
||||
}
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn first_line_skips_hidden_text_before_rendering_its_sibling() {
|
||||
let multiline = format!("\u{1b}[2Jok\n{}", "x".repeat(1024));
|
||||
@@ -406,3 +424,68 @@ fn terminal_commands_are_sanitized_within_each_string() {
|
||||
);
|
||||
insta::assert_snapshot!(line.line.to_string(), @"red label Ready");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn split_graphemes_use_the_first_style_and_require_unanimous_links() {
|
||||
use crate::terminal_hyperlinks::TerminalHyperlink;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Span;
|
||||
|
||||
let url = "https://example.com/a";
|
||||
let link = |href, text| json!({"type":"link","props":{"href":href},"children":[text]});
|
||||
let node = tree(json!({"type":"text","children":[
|
||||
{"type":"bold","children":[{"type":"row","props":{"gap":0},"children":["R"]},"e"]}, {"type":"italic","children":[]}, "\u{301}",
|
||||
link(url, "👩"), link(url, "\u{200d}💻"),
|
||||
link(url, "👩"), link("https://example.com/b", "\u{200d}💻"),
|
||||
{"type":"bold","children":["\u{600}"]}, " x",
|
||||
link(url, "तक्क"), "\u{301}"
|
||||
]}));
|
||||
let line = node.render_line(/*width*/ 20, &Theme::default());
|
||||
assert_eq!(
|
||||
line.line.spans,
|
||||
[
|
||||
Span::raw("R").bold(),
|
||||
Span::raw("e\u{301}").bold(),
|
||||
Span::raw("👩💻").underlined(),
|
||||
Span::raw("👩💻").underlined(),
|
||||
Span::raw("\u{600} ").bold(),
|
||||
Span::raw("x"),
|
||||
Span::raw("त").underlined(),
|
||||
Span::raw("क्क\u{301}").underlined()
|
||||
]
|
||||
);
|
||||
assert_eq!(
|
||||
line.hyperlinks,
|
||||
[
|
||||
TerminalHyperlink::web(2..4, url.to_owned()),
|
||||
TerminalHyperlink::web(9..10, url.to_owned())
|
||||
]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn collapsed_space_keeps_the_first_whitespace_owners_link() {
|
||||
use crate::terminal_hyperlinks::TerminalHyperlink;
|
||||
|
||||
let href = "https://example.com/build";
|
||||
let node = tree(json!({"type":"text","children":[
|
||||
"a", {"type":"link","props":{"href":href},"children":["\t"]},
|
||||
"\u{1b}[31m", " b", {"type":"link","props":{"href":href},"children":[" "]},
|
||||
{"type":"row","props":{"gap":0},"children":[" c "]}, " d"
|
||||
]}));
|
||||
let line = node.render_line(/*width*/ 10, &Theme::default());
|
||||
assert_eq!(line.line.to_string(), "a bcd");
|
||||
assert_eq!(
|
||||
line.hyperlinks,
|
||||
[TerminalHyperlink::web(1..2, href.to_owned())]
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
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");
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user