Preserve logical source text and styles across TUI wrapping (#46708)

## Why

Display wrapping removes whitespace and adds gutters, so rendered rows alone cannot reliably describe the original logical text. Streaming previews also need to detect source changes that leave visible rows unchanged.

## What changed

- Retain shared logical text, styles, exact byte ranges, and display margins alongside wrapped rows; remap hyperlinks using those ranges.
- Preserve source metadata through prompt, Markdown, and prefixed history rendering, including spoken artifact annotations. Clear it when split-flap animation replaces the text.
- Include source metadata when comparing streaming previews and agent or plan tails.
- Keep compound graphemes intact, preserve explicit span styles, and reuse first-fit wrapping results when safe.
- Use the lighter history prompt background for submitted messages.

## Testing

Add regression coverage for source ranges and styles across widths and gutters, distinct repeated lines, source-only streaming updates, compound graphemes, and equivalence between reused and generic wrapping results. Extend Markdown, spoken artifact, and animation tests to check retained source metadata.

GitOrigin-RevId: 9e60746642fc9a8f91c1abfe7c77eefc1fe421ab
This commit is contained in:
Eric Traut
2026-09-19 20:09:58 +00:00
committed by copyberry
parent 5ee2bdf1e0
commit fcaa035438
23 changed files with 856 additions and 123 deletions

View File

@@ -164,6 +164,8 @@ impl SplitFlapTranscriptCell {
continue;
}
// Animated glyphs and board padding no longer map to authored source bytes.
line.source = None;
let original_spans = std::mem::take(&mut line.line.spans);
line.line.style = line.line.style.patch(board_style);
for span in original_spans {

View File

@@ -90,11 +90,12 @@ fn split_flap_frames_assemble_a_transcript_from_dark_tiles() {
#[test]
fn split_flap_paints_the_entire_transcript_row_black() {
let cell = cell("GATE", MotionMode::Animated);
let lines = cell.animate_lines(
cell.inner.display_hyperlink_lines(/*width*/ 12),
/*width*/ 12,
Duration::ZERO,
);
let mut source_lines = cell.inner.display_hyperlink_lines(/*width*/ 12);
source_lines[0].source = Some(crate::terminal_hyperlinks::LogicalLineSource::from_line(
&source_lines[0].line,
));
let lines = cell.animate_lines(source_lines, /*width*/ 12, Duration::ZERO);
assert!(lines[0].source.is_none());
let area = Rect::new(
/*x*/ 0, /*y*/ 0, /*width*/ 12, /*height*/ 1,
);

View File

@@ -80,13 +80,24 @@ impl PrefixedWrappedHistoryCell {
impl HistoryCell for PrefixedWrappedHistoryCell {
fn display_lines(&self, width: u16) -> Vec<Line<'static>> {
visible_lines(self.display_hyperlink_lines(width))
}
fn display_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
if width == 0 {
return Vec::new();
}
let opts = RtOptions::new(width.max(1) as usize)
let opts = RtOptions::new(usize::from(width))
.initial_indent(self.initial_prefix.clone())
.subsequent_indent(self.subsequent_prefix.clone());
adaptive_wrap_lines(&self.text, opts)
crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines(
&plain_hyperlink_lines(self.text.lines.clone()),
opts,
)
}
fn transcript_hyperlink_lines(&self, width: u16) -> Vec<HyperlinkLine> {
self.display_hyperlink_lines(width)
}
fn raw_lines(&self) -> Vec<Line<'static>> {
@@ -173,3 +184,7 @@ impl HistoryCell for CompositeHistoryCell {
false
}
}
#[cfg(test)]
#[path = "base_tests.rs"]
mod tests;

View File

@@ -0,0 +1,95 @@
//! Prefixed rows preserve authored whitespace and exclude display gutters from source text.
use super::*;
use pretty_assertions::assert_eq;
use std::sync::Arc;
#[test]
fn prefixed_wrapping_retains_one_source_line_across_widths_and_gutters() {
let source = " let result = compute(alpha, beta, gamma); print(result);";
let styled = Line::from(vec![
" let result = ".into(),
"compute".red().bold(),
"(alpha, beta, gamma); print(result);".into(),
])
.cyan()
.italic();
let cell = PrefixedWrappedHistoryCell::new(styled, "".green(), " ");
for width in [16, 24, 48] {
let lines = cell.transcript_hyperlink_lines(width);
let first = lines
.first()
.expect("wrapped source")
.source
.as_ref()
.expect("source range");
assert_eq!(first.text.as_ref(), source);
assert_eq!(
first.styled_range(0..source.len()),
Line::from(vec![
" let result = ".cyan().italic(),
"compute".red().bold().italic(),
"(alpha, beta, gamma); print(result);".cyan().italic(),
])
);
assert_eq!(first.range.start, 0);
let restyled = lines[0].clone().style(Style::new().on_blue());
assert_eq!(
restyled
.source
.as_ref()
.unwrap()
.styled_range(0..source.len()),
Line::from(vec![
" let result = ".cyan().italic().on_blue(),
"compute".red().bold().italic().on_blue(),
"(alpha, beta, gamma); print(result);"
.cyan()
.italic()
.on_blue(),
])
);
for line in &lines {
let origin = line.source.as_ref().expect("source range");
let visible = line.line.to_string();
assert!(Arc::ptr_eq(&origin.text, &first.text));
assert_eq!(
&visible[origin.prefix_bytes..origin.prefix_bytes + origin.range.len()],
&source[origin.range.clone()],
);
assert!(line.line.width() <= usize::from(width));
}
assert_eq!(
lines
.last()
.expect("last row")
.source
.as_ref()
.expect("source")
.range
.end,
source.len()
);
}
}
#[test]
fn prefixed_wrapping_keeps_hard_lines_distinct_even_when_their_text_repeats() {
let cell = PrefixedWrappedHistoryCell::new(
Text::from(vec![
Line::from("repeated text"),
Line::from("repeated text"),
]),
"",
" ",
);
let lines = cell.transcript_hyperlink_lines(/*width*/ 40);
let first = lines[0].source.as_ref().expect("first source");
let second = lines[1].source.as_ref().expect("second source");
assert_eq!(
(first.text.as_ref(), second.text.as_ref()),
("repeated text", "repeated text")
);
assert!(!Arc::ptr_eq(&first.text, &second.text));
assert!(cell.display_hyperlink_lines(/*width*/ 0).is_empty());
}

View File

@@ -4,10 +4,12 @@
use super::markdown_render_cache::MarkdownRenderCache;
use super::*;
use crate::style::accent_color_on;
use crate::style::history_prompt_style;
use crate::terminal_hyperlinks::annotate_web_urls_in_line;
use crate::terminal_hyperlinks::remap_wrapped_line;
use crate::terminal_hyperlinks::lines_with_sources_eq;
use crate::terminal_hyperlinks::remap_source_wrapped_line;
use crate::wrapping::url_preserving_wrap_options;
use crate::wrapping::word_wrap_line;
use crate::wrapping::word_wrap_line_with_source;
use std::borrow::Cow;
#[derive(Debug)]
@@ -185,9 +187,9 @@ impl HistoryCell for UserHistoryCell {
.saturating_sub(
LIVE_PREFIX_COLS + 1, /* keep a one-column right margin for wrapping */
)
.max(1);
.max(/*other*/ 1);
let style = user_message_style();
let style = history_prompt_style();
let element_style = style.fg(accent_color_on(style.bg));
let wrapped_remote_images = if self.remote_image_urls.is_empty() {
@@ -202,60 +204,7 @@ impl HistoryCell for UserHistoryCell {
}
.filter(|lines| !lines.is_empty());
let wrapped_message = if message.is_empty() && text_elements.is_empty() {
None
} else {
let wrap_options = RtOptions::new(usize::from(wrap_width))
.wrap_algorithm(textwrap::WrapAlgorithm::FirstFit);
let mut wrapped = if text_elements.is_empty() {
let message_without_trailing_newlines = message.trim_end_matches(['\r', '\n']);
adaptive_wrap_lines(
message_without_trailing_newlines
.split('\n')
.map(|line| Line::from(line).style(style)),
wrap_options,
)
} else {
adaptive_wrap_lines(
build_user_message_lines_with_elements(
message,
text_elements,
style,
element_style,
),
wrap_options,
)
}
.into_iter()
.flat_map(|line| {
if line.width() <= usize::from(wrap_width) {
return vec![HyperlinkLine::new(line)];
}
// Terminal autowrap loses the message gutter and background. Explicitly split
// oversized URL tokens while retaining their complete OSC-8 destination.
let line = annotate_web_urls_in_line(line);
let forced_lines = word_wrap_line(
&line.line,
url_preserving_wrap_options(RtOptions::new(usize::from(wrap_width)))
.break_words(/*break_words*/ true),
)
.iter()
.map(line_to_static)
.collect();
remap_wrapped_line(&line, forced_lines)
})
.collect::<Vec<_>>();
while wrapped.last().is_some_and(|line| {
line.line
.spans
.iter()
.all(|span| span.content.trim().is_empty())
}) {
wrapped.pop();
}
(!wrapped.is_empty()).then_some(wrapped)
};
let wrapped_message = wrap_user_message(message, text_elements, style, wrap_width);
if wrapped_remote_images.is_none() && wrapped_message.is_none() {
return Vec::new();
@@ -287,6 +236,9 @@ impl HistoryCell for UserHistoryCell {
}
lines.push(HyperlinkLine::new(Line::from("").style(style)));
for source in lines.iter_mut().filter_map(|line| line.source.as_mut()) {
source.right_reserve = 1;
}
lines
}
@@ -308,6 +260,61 @@ impl HistoryCell for UserHistoryCell {
}
}
/// Wrap a prompt body before the user-message gutter and background padding are added.
fn wrap_user_message(
message: &str,
text_elements: &[TextElement],
style: Style,
wrap_width: u16,
) -> Option<Vec<HyperlinkLine>> {
let element_style = style.fg(accent_color_on(style.bg));
if message.is_empty() && text_elements.is_empty() {
return None;
}
let wrap_options =
RtOptions::new(usize::from(wrap_width)).wrap_algorithm(textwrap::WrapAlgorithm::FirstFit);
let logical_lines = if text_elements.is_empty() {
let message_without_trailing_newlines = message.trim_end_matches(['\r', '\n']);
message_without_trailing_newlines
.split('\n')
.map(|line| Line::from(line.to_owned()).style(style))
.collect()
} else {
build_user_message_lines_with_elements(message, text_elements, style, element_style)
};
let mut wrapped = crate::terminal_hyperlinks::adaptive_wrap_hyperlink_lines(
&plain_hyperlink_lines(logical_lines),
wrap_options,
)
.into_iter()
.flat_map(|mut line| {
if line.width() <= usize::from(wrap_width) {
return vec![line];
}
// Terminal autowrap loses the message gutter and background. Explicitly split
// oversized URL tokens while retaining their complete OSC-8 destination.
line.hyperlinks = annotate_web_urls_in_line(line.line.clone()).hyperlinks;
let forced_lines = word_wrap_line_with_source(
&line.line,
url_preserving_wrap_options(RtOptions::new(usize::from(wrap_width)))
.break_words(/*break_words*/ true),
);
remap_source_wrapped_line(&line, forced_lines)
})
.collect::<Vec<_>>();
while wrapped.last().is_some_and(|line| {
line.line
.spans
.iter()
.all(|span| span.content.trim().is_empty())
}) {
wrapped.pop();
}
(!wrapped.is_empty()).then_some(wrapped)
}
#[derive(Debug)]
pub(crate) struct ReasoningSummaryCell {
_header: String,
@@ -522,6 +529,10 @@ fn normalize_whitespace_only_hyperlink_lines(mut lines: Vec<HyperlinkLine>) -> V
{
line.line = Line::default().style(line.line.style);
line.hyperlinks.clear();
if let Some(source) = &mut line.source {
source.prefix_bytes = 0;
source.range.end = source.range.start;
}
}
}
lines
@@ -594,13 +605,20 @@ mod tests;
///
/// During streaming, lines that have not yet been committed to scrollback because they belong to
/// an in-progress table are displayed via this cell in the `active_cell` slot. It is replaced on
/// deltas that change the visible tail and cleared when the stream finalizes.
#[derive(Debug, Eq, PartialEq)]
/// deltas that change the visible tail or retained source, and cleared when the stream finalizes.
#[derive(Debug, Eq)]
pub(crate) struct StreamingAgentTailCell {
lines: Vec<HyperlinkLine>,
is_first_line: bool,
}
impl PartialEq for StreamingAgentTailCell {
fn eq(&self, other: &Self) -> bool {
self.is_first_line == other.is_first_line
&& lines_with_sources_eq(&self.lines, &other.lines)
}
}
impl StreamingAgentTailCell {
pub(crate) fn new(lines: Vec<HyperlinkLine>, is_first_line: bool) -> Self {
Self {

View File

@@ -234,6 +234,21 @@ fn spoken_artifacts_link_only_real_workspace_files_and_preserve_existing_urls()
span.content == "src/lib.rs:42" && span.style.add_modifier.contains(Modifier::UNDERLINED)
}));
assert_eq!(spoken.raw_lines(), vec![Line::from(markdown)]);
for width in [90, 24] {
for line in spoken.display_hyperlink_lines(width) {
let source = line.source.expect("spoken source");
assert!(
source
.styled_range(0..source.text.len())
.spans
.iter()
.any(|span| {
span.content == "src/lib.rs:42"
&& span.style.add_modifier.contains(Modifier::UNDERLINED)
})
);
}
}
insta::assert_snapshot!(
format!(
"{}\n{:?} -> <workspace>/src/lib.rs\n{:?} -> https://example.com",

View File

@@ -33,7 +33,6 @@ use crate::render::line_utils::push_owned_lines;
use crate::render::renderable::Renderable;
use crate::session_state::ThreadSessionState;
use crate::style::proposed_plan_style;
use crate::style::user_message_style;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::HyperlinkParagraph;
use crate::terminal_hyperlinks::plain_hyperlink_lines;
@@ -83,7 +82,6 @@ use codex_utils_absolute_path::AbsolutePathBuf;
#[cfg(test)]
use codex_utils_cli::format_env_display;
use ratatui::prelude::*;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::style::Style;
use ratatui::style::Styled;

View File

@@ -2,18 +2,26 @@
use super::markdown_render_cache::MarkdownRenderCache;
use super::*;
use crate::terminal_hyperlinks::lines_with_sources_eq;
/// Transient active-cell representation of the mutable tail of a proposed-plan stream.
///
/// The controller prepares the full styled plan lines because plan tails need the same header,
/// padding, and background treatment as committed `ProposedPlanStreamCell`s while remaining
/// preview-only during streaming.
#[derive(Debug, Eq, PartialEq)]
#[derive(Debug, Eq)]
pub(crate) struct StreamingPlanTailCell {
lines: Vec<HyperlinkLine>,
is_stream_continuation: bool,
}
impl PartialEq for StreamingPlanTailCell {
fn eq(&self, other: &Self) -> bool {
self.is_stream_continuation == other.is_stream_continuation
&& lines_with_sources_eq(&self.lines, &other.lines)
}
}
impl StreamingPlanTailCell {
pub(crate) fn new(lines: Vec<HyperlinkLine>, is_stream_continuation: bool) -> Self {
Self {

View File

@@ -1,5 +1,6 @@
---
source: tui/src/history_cell/tests.rs
assertion_line: 2425
expression: "snapshots.join(\"\\n\")"
---
(255, 255, 255): Buffer {
@@ -13,11 +14,11 @@ expression: "snapshots.join(\"\\n\")"
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Rgb(28, 100, 200), bg: Rgb(244, 244, 244), underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Rgb(28, 100, 200), bg: Rgb(249, 249, 249), underline: Reset, modifier: NONE,
x: 12, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 3, fg: Reset, bg: Rgb(244, 244, 244), underline: Reset, modifier: BOLD | DIM,
x: 2, y: 3, fg: Rgb(28, 100, 200), bg: Rgb(244, 244, 244), underline: Reset, modifier: NONE,
x: 12, y: 3, fg: Reset, bg: Rgb(244, 244, 244), underline: Reset, modifier: NONE,
x: 0, y: 3, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: BOLD | DIM,
x: 2, y: 3, fg: Rgb(28, 100, 200), bg: Rgb(249, 249, 249), underline: Reset, modifier: NONE,
x: 12, y: 3, fg: Reset, bg: Rgb(249, 249, 249), underline: Reset, modifier: NONE,
x: 27, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -32,11 +33,11 @@ expression: "snapshots.join(\"\\n\")"
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Rgb(99, 168, 248), bg: Rgb(46, 48, 57), underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Rgb(99, 168, 248), bg: Rgb(55, 57, 66), underline: Reset, modifier: NONE,
x: 12, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 3, fg: Reset, bg: Rgb(46, 48, 57), underline: Reset, modifier: BOLD | DIM,
x: 2, y: 3, fg: Rgb(99, 168, 248), bg: Rgb(46, 48, 57), underline: Reset, modifier: NONE,
x: 12, y: 3, fg: Reset, bg: Rgb(46, 48, 57), underline: Reset, modifier: NONE,
x: 0, y: 3, fg: Reset, bg: Rgb(55, 57, 66), underline: Reset, modifier: BOLD | DIM,
x: 2, y: 3, fg: Rgb(99, 168, 248), bg: Rgb(55, 57, 66), underline: Reset, modifier: NONE,
x: 12, y: 3, fg: Reset, bg: Rgb(55, 57, 66), underline: Reset, modifier: NONE,
x: 27, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}
@@ -51,11 +52,11 @@ expression: "snapshots.join(\"\\n\")"
],
styles: [
x: 0, y: 0, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Rgb(243, 248, 254), bg: Rgb(114, 114, 114), underline: Reset, modifier: NONE,
x: 0, y: 1, fg: Rgb(5, 9, 14), bg: Rgb(120, 120, 120), underline: Reset, modifier: NONE,
x: 12, y: 1, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
x: 0, y: 3, fg: Reset, bg: Rgb(114, 114, 114), underline: Reset, modifier: BOLD | DIM,
x: 2, y: 3, fg: Rgb(243, 248, 254), bg: Rgb(114, 114, 114), underline: Reset, modifier: NONE,
x: 12, y: 3, fg: Reset, bg: Rgb(114, 114, 114), underline: Reset, modifier: NONE,
x: 0, y: 3, fg: Reset, bg: Rgb(120, 120, 120), underline: Reset, modifier: BOLD | DIM,
x: 2, y: 3, fg: Rgb(5, 9, 14), bg: Rgb(120, 120, 120), underline: Reset, modifier: NONE,
x: 12, y: 3, fg: Reset, bg: Rgb(120, 120, 120), underline: Reset, modifier: NONE,
x: 27, y: 3, fg: Reset, bg: Reset, underline: Reset, modifier: NONE,
]
}

View File

@@ -7,12 +7,14 @@ use crate::width::display_width;
use ratatui::style::Modifier;
use ratatui::text::Span;
use std::path::Path;
use std::sync::Arc;
const MAX_ARTIFACT_CANDIDATES: usize = 16;
pub(super) fn annotate_spoken_artifacts(lines: &mut [HyperlinkLine], cwd: &Path) {
let mut inspected = 0;
for line in lines {
let mut source_patches = Vec::new();
'lines: for line in &mut *lines {
let text = line
.line
.spans
@@ -46,7 +48,7 @@ pub(super) fn annotate_spoken_artifacts(lines: &mut [HyperlinkLine], cwd: &Path)
}
inspected += 1;
if inspected > MAX_ARTIFACT_CANDIDATES {
return;
break 'lines;
}
let Some(file) = TrustedWorkspaceFile::validate(cwd, path) else {
continue;
@@ -61,6 +63,17 @@ pub(super) fn annotate_spoken_artifacts(lines: &mut [HyperlinkLine], cwd: &Path)
}
line.hyperlinks
.push(TerminalHyperlink::trusted_workspace_file(columns, file));
if let Some(source) = &line.source {
let start = byte_start.max(source.prefix_bytes);
let end = byte_end.min(source.prefix_bytes + source.range.len());
if start < end {
source_patches.push((
Arc::clone(&source.text),
source.range.start + start - source.prefix_bytes
..source.range.start + end - source.prefix_bytes,
));
}
}
let mut offset = 0;
for span in std::mem::take(&mut line.line.spans) {
@@ -91,4 +104,52 @@ pub(super) fn annotate_spoken_artifacts(lines: &mut [HyperlinkLine], cwd: &Path)
}
line.hyperlinks.sort_by_key(|link| link.columns.start);
}
// Every wrapped fragment must retain all annotations on its shared logical line.
while let Some((text, range)) = source_patches.pop() {
let mut ranges = vec![range];
source_patches.retain(|(other, range)| {
if Arc::ptr_eq(&text, other) {
ranges.push(range.clone());
false
} else {
true
}
});
let mut sources = lines
.iter_mut()
.filter_map(|line| line.source.as_mut())
.filter(|source| Arc::ptr_eq(&source.text, &text));
let Some(first) = sources.next() else {
continue;
};
let mut styles = Vec::new();
for (span, style) in first.styles.iter() {
let mut boundaries = vec![span.start, span.end];
boundaries.extend(
ranges
.iter()
.flat_map(|range| [range.start, range.end])
.filter(|offset| span.start < *offset && *offset < span.end),
);
boundaries.sort_unstable();
boundaries.dedup();
for pair in boundaries.windows(/*size*/ 2) {
let range = pair[0]..pair[1];
let style = if ranges
.iter()
.any(|patch| patch.start < range.end && range.start < patch.end)
{
style.add_modifier(Modifier::UNDERLINED)
} else {
*style
};
styles.push((range, style));
}
}
first.styles = styles.into();
for source in sources {
source.styles = Arc::clone(&first.styles);
}
}
}

View File

@@ -120,13 +120,17 @@ fn streaming_agent_tail_blank_line_uses_one_viewport_row() {
let cell = StreamingAgentTailCell::new(
vec![
HyperlinkLine::from("first"),
HyperlinkLine::from(""),
HyperlinkLine::from(" "),
HyperlinkLine::from("second"),
],
/*is_first_line*/ false,
);
let lines = cell.display_lines(/*width*/ 80);
let rendered = cell.display_hyperlink_lines(/*width*/ 80);
let source = rendered[1].source.as_ref().expect("blank row source");
assert_eq!((source.prefix_bytes, source.range.clone()), (0, 0..0));
assert_eq!(source.text.as_ref(), " ");
let lines = visible_lines(rendered);
insta::assert_snapshot!(render_lines(&lines).join("\n"), @" first
second");

View File

@@ -332,6 +332,7 @@ fn write_history_line<W: Write>(
})
.collect();
let merged_line = HyperlinkLine {
source: None,
line: Line::from(merged_spans),
hyperlinks: line.hyperlinks.clone(),
};

View File

@@ -2001,6 +2001,11 @@ where
}
} else {
let mut spans = self.current_initial_indent.clone();
let mut source =
crate::terminal_hyperlinks::LogicalLineSource::from_line(&line.line);
source.prefix_bytes = spans.iter().map(|span| span.content.len()).sum();
source.continuation_indent = self.current_subsequent_indent.clone().into();
line.source = Some(source);
let shift = Self::spans_display_width(&spans);
spans.append(&mut line.line.spans);
for hyperlink in &mut line.hyperlinks {
@@ -2180,6 +2185,7 @@ mod markdown_render_tests {
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use ratatui::style::Stylize;
use ratatui::text::Text;
fn lines_to_strings(text: &Text<'_>) -> Vec<String> {
@@ -2257,9 +2263,19 @@ mod tests {
#[test]
fn wraps_blockquotes() {
let markdown = "> block quote with content that should wrap nicely";
let rendered = render_markdown_text_with_width(markdown, Some(22));
let lines = lines_to_strings(&rendered);
let markdown = "> block quote with **content** that should wrap nicely";
let rendered =
render_markdown_lines_with_width_and_cwd(markdown, Some(22), /*cwd*/ None);
let source = rendered[0].source.as_ref().expect("blockquote source");
assert_eq!(
source.styled_range(0..source.text.len()),
Line::from(vec![
"block quote with ".green(),
"content".green().bold(),
" that should wrap nicely".green(),
])
);
let lines: Vec<_> = rendered.iter().map(|line| line.line.to_string()).collect();
assert_eq!(
lines,
vec![

View File

@@ -79,6 +79,7 @@ fn supporting_terminals_render_only_the_styled_label_and_keep_its_target() {
assert_eq!(
render(markdown, /*width*/ 80, display),
vec![HyperlinkLine {
source: None,
line: Line::from(label),
hyperlinks: vec![TerminalHyperlink::web(
0..label_width,

View File

@@ -318,6 +318,7 @@ pub(crate) fn sub_agent_activity_history_cell(item: &ThreadItem) -> Option<Plain
))
}
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn sub_agent_activity_summary(kind: SubAgentActivityKind, agent_path: &str) -> String {
match kind {
SubAgentActivityKind::Started => format!("Started `{agent_path}`"),

View File

@@ -2,6 +2,38 @@ use super::*;
use crate::terminal_hyperlinks::visible_lines;
use pretty_assertions::assert_eq;
#[test]
fn source_only_changes_refresh_the_preview_and_active_tail() {
let cwd = std::env::temp_dir();
let mut controller = StreamController::new(Some(5), &cwd, HistoryRenderMode::Rich);
controller.push("hello");
let before = controller.current_tail_lines();
// An entity preserves trailing whitespace that Markdown otherwise trims at EOF.
assert!(controller.push("&#32;"));
let after = controller.current_tail_lines();
assert_eq!(visible_lines(before.clone()), visible_lines(after.clone()));
assert_eq!(
after[0].source,
render_source(
"hello&#32;",
Some(5),
&cwd,
HistoryRenderMode::Rich,
/*inline_visualization_context*/ None,
)[0]
.source,
);
assert_ne!(
history_cell::StreamingAgentTailCell::new(before.clone(), /*is_first_line*/ true),
history_cell::StreamingAgentTailCell::new(after.clone(), /*is_first_line*/ true),
);
assert_ne!(
history_cell::StreamingPlanTailCell::new(before, /*is_stream_continuation*/ false),
history_cell::StreamingPlanTailCell::new(after, /*is_stream_continuation*/ false),
);
}
#[test]
fn unterminated_prose_reflows_and_finishes_without_duplication() {
let cwd = std::env::temp_dir();

View File

@@ -6,6 +6,7 @@ use super::render::render_source;
use crate::history_cell::HistoryRenderMode;
use crate::inline_visualization::InlineVisualizationContext;
use crate::terminal_hyperlinks::HyperlinkLine;
use crate::terminal_hyperlinks::lines_with_sources_eq;
use ratatui::text::Line;
use std::path::Path;
@@ -67,7 +68,7 @@ impl ProsePreview {
if start > 0 {
lines.insert(0, HyperlinkLine::new(Line::from("")));
}
if self.lines == lines {
if lines_with_sources_eq(&self.lines, &lines) {
return false;
}
self.lines = lines;

View File

@@ -62,7 +62,6 @@ pub fn user_message_style() -> Style {
}
/// Submitted prompts use a lighter fill than the editable composer in either theme.
#[allow(dead_code, reason = "Used by later layers of the TUI refresh stack.")]
pub(crate) fn history_prompt_style() -> Style {
let Some(background) = default_bg() else {
return Style::default();

View File

@@ -4,8 +4,10 @@
//! when text reaches a terminal buffer or scrollback writer so OSC 8 bytes never affect geometry.
mod paragraph;
mod source;
pub(crate) use paragraph::HyperlinkParagraph;
pub(crate) use source::LogicalLineSource;
use std::num::NonZeroU16;
use std::ops::Range;
@@ -31,7 +33,6 @@ use crate::render::line_utils::line_to_borrowed;
use crate::render::line_utils::line_to_static;
use crate::width::display_width;
use crate::wrapping::RtOptions;
use crate::wrapping::adaptive_wrap_line;
// Destinations are repeated in every linked buffer cell. Leave oversized URLs as plain text.
const MAX_HYPERLINK_DESTINATION_BYTES: usize = 8 * 1024;
@@ -120,7 +121,7 @@ impl TerminalHyperlink {
}
}
fn with_columns(&self, columns: Range<usize>) -> Self {
pub(crate) fn with_columns(&self, columns: Range<usize>) -> Self {
Self {
columns,
destination: self.destination.clone(),
@@ -136,10 +137,40 @@ impl TerminalHyperlink {
}
}
#[derive(Clone, Debug, Default, Eq, PartialEq)]
#[derive(Clone, Default)]
pub(crate) struct HyperlinkLine {
pub(crate) line: Line<'static>,
pub(crate) hyperlinks: Vec<TerminalHyperlink>,
pub(crate) source: Option<LogicalLineSource>,
}
// Source provenance is shared layout metadata; omit it from visual diagnostics to avoid
// repeating an entire logical line for every wrapped fragment.
impl std::fmt::Debug for HyperlinkLine {
fn fmt(&self, formatter: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
formatter
.debug_struct("HyperlinkLine")
.field("line", &self.line)
.field("hyperlinks", &self.hyperlinks)
.finish()
}
}
impl PartialEq for HyperlinkLine {
fn eq(&self, other: &Self) -> bool {
self.line == other.line && self.hyperlinks == other.hyperlinks
}
}
impl Eq for HyperlinkLine {}
/// Cache equality also tracks source whitespace hidden by display wrapping.
pub(crate) fn lines_with_sources_eq(left: &[HyperlinkLine], right: &[HyperlinkLine]) -> bool {
left == right
&& left
.iter()
.map(|line| &line.source)
.eq(right.iter().map(|line| &line.source))
}
impl HyperlinkLine {
@@ -147,6 +178,7 @@ impl HyperlinkLine {
Self {
line,
hyperlinks: Vec::new(),
source: None,
}
}
@@ -155,6 +187,7 @@ impl HyperlinkLine {
}
pub(crate) fn push_span(&mut self, span: Span<'static>, destination: Option<&str>) {
self.source = None;
let start = self.width();
let end = start + display_width(span.content.as_ref());
self.line.push_span(span);
@@ -168,6 +201,9 @@ impl HyperlinkLine {
pub(crate) fn style(mut self, style: ratatui::style::Style) -> Self {
self.line = self.line.style(style);
if let Some(source) = &mut self.source {
source.line_style = style;
}
self
}
}
@@ -220,6 +256,16 @@ pub(crate) fn prefix_hyperlink_lines(
subsequent_prefix.clone()
};
let shift = display_width(prefix.content.as_ref());
let mut source = line
.source
.take()
.unwrap_or_else(|| LogicalLineSource::from_line(&line.line));
source.prefix_bytes += prefix.content.len();
source
.continuation_indent
.spans
.insert(/*index*/ 0, subsequent_prefix.clone());
line.source = Some(source);
let mut spans = Vec::with_capacity(line.line.spans.len() + 1);
spans.push(prefix);
spans.extend(line.line.spans);
@@ -245,12 +291,14 @@ pub(crate) fn adaptive_wrap_hyperlink_lines(
.clone()
.initial_indent(options.subsequent_indent.clone())
};
out.extend(remap_wrapped_line(
line,
adaptive_wrap_line(&line.line, options)
.into_iter()
.map(|wrapped| line_to_static(&wrapped))
.collect(),
let mut source = line.clone();
source
.source
.get_or_insert_with(|| LogicalLineSource::from_line(&line.line))
.continuation_indent = options.subsequent_indent.clone();
out.extend(remap_source_wrapped_line(
&source,
crate::wrapping::adaptive_wrap_line_with_source(&line.line, options),
));
}
out
@@ -271,11 +319,67 @@ pub(crate) fn annotate_web_urls_in_line(line: Line<'static>) -> HyperlinkLine {
out
}
/// Project annotations from the exact source slices used by the existing wrapping algorithm.
pub(crate) fn remap_source_wrapped_line(
source: &HyperlinkLine,
wrapped: Vec<crate::wrapping::WrappedLine<'_>>,
) -> Vec<HyperlinkLine> {
let text = line_text(&source.line);
let mut logical = source
.source
.clone()
.unwrap_or_else(|| LogicalLineSource::from_line(&source.line));
// Wrapping bakes the row style into each span; mirror it once for all source fragments.
if logical.line_style != ratatui::style::Style::default() {
logical.styles = logical
.styles
.iter()
.map(|(range, style)| (range.clone(), logical.line_style.patch(*style)))
.collect::<Vec<_>>()
.into();
}
let mut source_byte = 0;
let mut source_column = 0;
wrapped
.into_iter()
.map(|wrapped| {
let line = line_to_static(&wrapped.line);
let displayed = line_text(&line);
let prefix_columns = display_width(&displayed[..wrapped.prefix_bytes]);
source_column += display_width(&text[source_byte..wrapped.range.start]);
let start = source_column;
let end = start + display_width(&text[wrapped.range.clone()]);
source_byte = wrapped.range.end;
source_column = end;
let hyperlinks = source
.hyperlinks
.iter()
.filter_map(|link| {
let first = link.columns.start.max(start);
let last = link.columns.end.min(end);
(first < last).then(|| {
link.with_columns(
prefix_columns + first - start..prefix_columns + last - start,
)
})
})
.collect();
HyperlinkLine {
line,
hyperlinks,
source: Some(logical.wrapped(wrapped.range, wrapped.prefix_bytes)),
}
})
.collect()
}
/// Re-attach source hyperlink ranges after visible-text wrapping has split a line.
///
/// Link text is matched in display order so a URL split across table rows retains the complete
/// destination on every rendered fragment. Whitespace inserted or removed at line boundaries is
/// ignored while matching; hyperlink destinations themselves are never reconstructed from output.
/// This legacy projection does not infer logical source provenance. New wrapping callers should
/// pass authoritative ranges to [`remap_source_wrapped_line`].
pub(crate) fn remap_wrapped_line(
source: &HyperlinkLine,
wrapped: Vec<Line<'static>>,
@@ -284,7 +388,6 @@ pub(crate) fn remap_wrapped_line(
if source.hyperlinks.is_empty() {
return out;
}
let source_text = line_text(&source.line);
let mut source_byte = 0usize;
let mut source_column = 0usize;
@@ -758,6 +861,7 @@ mod tests {
fn decorates_a_contiguous_web_link_with_one_osc8_pair() {
let destination = "https://example.com/a/very/long/path";
let line = HyperlinkLine {
source: None,
line: Line::from(destination),
hyperlinks: vec![TerminalHyperlink::web(
/*columns*/ 0..usize::from(destination.cell_width()),
@@ -823,6 +927,7 @@ mod tests {
wrapped,
vec![
HyperlinkLine {
source: None,
line: Line::from(" alpha 😀here"),
hyperlinks: vec![TerminalHyperlink::web(
/*columns*/ 10..14,
@@ -830,6 +935,7 @@ mod tests {
)],
},
HyperlinkLine {
source: None,
line: Line::from(" middle there end"),
hyperlinks: vec![TerminalHyperlink::web(
/*columns*/ 11..16,
@@ -1091,6 +1197,7 @@ mod tests {
);
link.retarget_to_trusted_file(&file_url);
let line = HyperlinkLine {
source: None,
line: Line::from("view"),
hyperlinks: vec![link],
};

View File

@@ -0,0 +1,114 @@
//! Logical text and margins retained when display wrapping removes whitespace or adds a gutter.
use ratatui::style::Style;
use ratatui::text::Line;
use ratatui::text::Span;
use std::ops::Range;
use std::sync::Arc;
/// Wrapping policy of an existing display renderer.
#[derive(Clone, Copy, Debug, Default, Eq, PartialEq)]
pub(crate) enum LineWrapPolicy {
#[default]
Word,
}
/// A displayed line's contiguous fragment of one original logical line.
///
/// Wrapped fragments share `text`. `prefix_bytes` counts synthetic display bytes before
/// the fragment, so selection never has to infer whether a gutter belongs to the source.
#[derive(Clone, Debug, Eq, PartialEq)]
pub(crate) struct LogicalLineSource {
pub(crate) text: Arc<str>,
/// Span styles share the text's byte coordinates, including whitespace omitted by wrapping.
pub(crate) styles: Arc<[(Range<usize>, Style)]>,
/// Row style, applied beneath explicit span styles just like ratatui's `Line`.
pub(crate) line_style: Style,
/// A later uniform span-style patch, such as the dim/italic reasoning treatment.
pub(crate) span_style: Style,
pub(crate) range: Range<usize>,
pub(crate) prefix_bytes: usize,
pub(crate) wrap_policy: LineWrapPolicy,
pub(crate) continuation_indent: Line<'static>,
/// Blank columns after wrapped content; they remain outside the copied source text.
pub(crate) right_reserve: u16,
}
impl LogicalLineSource {
pub(crate) fn new(text: String) -> Self {
let end = text.len();
Self {
text: text.into(),
styles: vec![(0..end, Style::default())].into(),
line_style: Style::default(),
span_style: Style::default(),
range: 0..end,
prefix_bytes: 0,
wrap_policy: LineWrapPolicy::Word,
continuation_indent: Line::default(),
right_reserve: 0,
}
}
pub(crate) fn from_line(line: &Line<'_>) -> Self {
let mut source = Self::new(
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect(),
);
let mut offset = 0;
source.line_style = line.style;
source.styles = line
.spans
.iter()
.map(|span| {
let start = offset;
offset += span.content.len();
(start..offset, span.style)
})
.collect::<Vec<_>>()
.into();
source
}
#[allow(
dead_code,
reason = "Used by the activity presentation layer of this stack."
)]
pub(crate) fn styled_range(&self, range: Range<usize>) -> Line<'static> {
Line::from(
self.styles
.iter()
.filter_map(|(style_range, style)| {
let start = style_range.start.max(range.start);
let end = style_range.end.min(range.end);
(start < end).then(|| {
Span::styled(
self.text[start..end].to_owned(),
self.line_style.patch(*style).patch(self.span_style),
)
})
})
.collect::<Vec<_>>(),
)
}
pub(super) fn wrapped(&self, displayed: Range<usize>, prefix_bytes: usize) -> Self {
let source_start = self.prefix_bytes;
let source_end = source_start + self.range.len();
let start = displayed.start.clamp(source_start, source_end);
let end = displayed.end.clamp(source_start, source_end);
Self {
text: Arc::clone(&self.text),
styles: Arc::clone(&self.styles),
line_style: self.line_style,
span_style: self.span_style,
range: self.range.start + start - source_start..self.range.start + end - source_start,
prefix_bytes: prefix_bytes + start.saturating_sub(displayed.start).min(displayed.len()),
wrap_policy: self.wrap_policy,
continuation_indent: self.continuation_indent.clone(),
right_reserve: self.right_reserve,
}
}
}

View File

@@ -15,6 +15,7 @@ fn oversized_destinations_remain_plain_text() {
// Explicit Markdown links can reach the cell marker without bare-URL detection.
let line = HyperlinkLine {
source: None,
line: Line::from("visible"),
hyperlinks: vec![TerminalHyperlink::web(/*columns*/ 0..7, oversized)],
};

View File

@@ -46,12 +46,16 @@ struct ProjectedText {
grapheme_boundaries: Vec<usize>,
}
/// Replaces halfwidth sound-mark graphemes with equally wide, textwrap-safe placeholders.
/// Replaces compound graphemes with equally wide, textwrap-safe placeholders.
///
/// Source boundaries recover original byte offsets, while grapheme boundaries keep placeholders
/// indivisible and preserve leading whitespace as a wrapping opportunity.
fn project_halfwidth_sound_marks(text: &str) -> Option<ProjectedText> {
if !text.contains(['\u{FF9E}', '\u{FF9F}']) {
fn project_complex_graphemes(text: &str) -> Option<ProjectedText> {
if !text.contains(['\u{FF9E}', '\u{FF9F}'])
&& !text
.graphemes(/*is_extended*/ true)
.any(|grapheme| grapheme.chars().count() > 1)
{
return None;
}
@@ -59,7 +63,7 @@ fn project_halfwidth_sound_marks(text: &str) -> Option<ProjectedText> {
let mut source_boundaries = vec![(0, 0)];
let mut grapheme_boundaries = vec![0];
for (source_start, grapheme) in text.grapheme_indices(/*is_extended*/ true) {
if grapheme.contains(['\u{FF9E}', '\u{FF9F}']) {
if grapheme.chars().count() > 1 || grapheme.contains(['\u{FF9E}', '\u{FF9F}']) {
let source_end = source_start + grapheme.len();
let content_start = grapheme
.find(|ch: char| !ch.is_whitespace())
@@ -72,6 +76,10 @@ fn project_halfwidth_sound_marks(text: &str) -> Option<ProjectedText> {
}
let width = display_width(content);
if width == 0 && !content.is_empty() {
projected.push_str(content);
source_boundaries.push((projected.len(), source_end));
}
let projected_start = projected.len();
for _ in 0..width / 2 {
if projected.len() > projected_start {
@@ -233,7 +241,7 @@ where
O: Into<Options<'a>>,
{
let opts = width_or_options.into();
if let Some(projected) = project_halfwidth_sound_marks(text) {
if let Some(projected) = project_complex_graphemes(text) {
return wrap_projected_ranges(&projected, &opts, /*include_trailing_spaces*/ true);
}
let mut lines: Vec<Range<usize>> = Vec::new();
@@ -279,7 +287,7 @@ where
O: Into<Options<'a>>,
{
let opts = width_or_options.into();
if let Some(projected) = project_halfwidth_sound_marks(text) {
if let Some(projected) = project_complex_graphemes(text) {
return wrap_projected_ranges(&projected, &opts, /*include_trailing_spaces*/ false);
}
let mut lines: Vec<Range<usize>> = Vec::new();
@@ -701,6 +709,24 @@ pub(crate) fn url_preserving_wrap_options<'a>(opts: RtOptions<'a>) -> RtOptions<
/// while a genuinely overlong non-URL token can still split if needed.
#[must_use]
pub(crate) fn adaptive_wrap_line<'a>(line: &'a Line<'a>, base: RtOptions<'a>) -> Vec<Line<'a>> {
adaptive_wrap_line_with_source(line, base)
.into_iter()
.map(|wrapped| wrapped.line)
.collect()
}
/// A display row and the exact source fragment used to build it.
pub(crate) struct WrappedLine<'a> {
pub(crate) line: Line<'a>,
pub(crate) range: Range<usize>,
pub(crate) prefix_bytes: usize,
}
/// Preserve wrapping's source ranges for selection and hyperlink projection.
pub(crate) fn adaptive_wrap_line_with_source<'a>(
line: &'a Line<'a>,
base: RtOptions<'a>,
) -> Vec<WrappedLine<'a>> {
let (flat, span_bounds) = flatten_line(line);
let mut saw_url = false;
let mut saw_non_url = false;
@@ -854,6 +880,20 @@ impl<'a> RtOptions<'a> {
#[must_use]
pub(crate) fn word_wrap_line<'a, O>(line: &'a Line<'a>, width_or_options: O) -> Vec<Line<'a>>
where
O: Into<RtOptions<'a>>,
{
word_wrap_line_with_source(line, width_or_options)
.into_iter()
.map(|wrapped| wrapped.line)
.collect()
}
/// Standard wrapping with the same source ranges used to slice styled spans.
pub(crate) fn word_wrap_line_with_source<'a, O>(
line: &'a Line<'a>,
width_or_options: O,
) -> Vec<WrappedLine<'a>>
where
O: Into<RtOptions<'a>>,
{
@@ -866,7 +906,7 @@ fn word_wrap_flattened_line<'a>(
flat: &str,
span_bounds: &[(Range<usize>, ratatui::style::Style)],
rt_opts: RtOptions<'a>,
) -> Vec<Line<'a>> {
) -> Vec<WrappedLine<'a>> {
let opts = Options::new(rt_opts.width)
.line_ending(rt_opts.line_ending)
.break_words(rt_opts.break_words)
@@ -874,7 +914,7 @@ fn word_wrap_flattened_line<'a>(
.word_separator(rt_opts.word_separator)
.word_splitter(rt_opts.word_splitter);
let mut out: Vec<Line<'a>> = Vec::new();
let mut out: Vec<WrappedLine<'a>> = Vec::new();
// Compute first line range with reduced width due to initial indent.
let initial_width_available = opts
@@ -883,7 +923,16 @@ fn word_wrap_flattened_line<'a>(
.max(1);
let initial_wrapped = wrap_ranges_trim(flat, opts.clone().width(initial_width_available));
let Some(first_line_range) = initial_wrapped.first() else {
return vec![rt_opts.initial_indent.clone()];
return vec![WrappedLine {
line: rt_opts.initial_indent.clone().style(line.style),
range: 0..0,
prefix_bytes: rt_opts
.initial_indent
.spans
.iter()
.map(|span| span.content.len())
.sum(),
}];
};
// Build first wrapped line with initial indent.
@@ -895,14 +944,22 @@ fn word_wrap_flattened_line<'a>(
&mut sliced
.spans
.into_iter()
.map(|s| s.patch_style(line.style))
.map(|span| Span::styled(span.content, line.style.patch(span.style)))
.collect(),
);
first_line.spans = spans;
out.push(first_line);
out.push(WrappedLine {
line: first_line,
range: first_line_range.clone(),
prefix_bytes: rt_opts
.initial_indent
.spans
.iter()
.map(|span| span.content.len())
.sum(),
});
}
// Wrap the remainder using subsequent indent width and map back to original indices.
let base = first_line_range.end;
let skip_leading_spaces = flat[base..].chars().take_while(|c| *c == ' ').count();
let base = base + skip_leading_spaces;
@@ -910,24 +967,55 @@ fn word_wrap_flattened_line<'a>(
.width
.saturating_sub(line_width(&rt_opts.subsequent_indent))
.max(1);
let remaining_wrapped = wrap_ranges_trim(&flat[base..], opts.width(subsequent_width_available));
for r in &remaining_wrapped {
if r.is_empty() {
// First-fit decisions do not depend on later rows. Reuse the full first pass when
// both indents leave the same width and the remainder starts after a complete word.
// Splitting inside a word can change hyphenation when the remainder is tokenized again.
// Custom tokenizers can also repartition the remainder, so they retain the second pass.
let remaining_wrapped = if initial_width_available == subsequent_width_available
&& matches!(rt_opts.wrap_algorithm, textwrap::WrapAlgorithm::FirstFit)
&& matches!(
rt_opts.word_separator,
WordSeparator::AsciiSpace | WordSeparator::UnicodeBreakProperties
)
&& (skip_leading_spaces > 0 || base == flat.len())
// The projected and plain text wrappers interpret control characters differently.
&& !flat.as_bytes().iter().any(u8::is_ascii_control)
&& initial_wrapped
.get(/*index*/ 1)
.map_or(base == flat.len(), |range| range.start == base)
{
initial_wrapped.into_iter().skip(/*n*/ 1).collect()
} else {
wrap_ranges_trim(&flat[base..], opts.width(subsequent_width_available))
.into_iter()
.map(|range| (range.start + base)..(range.end + base))
.collect::<Vec<_>>()
};
for offset_range in remaining_wrapped {
if offset_range.is_empty() {
continue;
}
let mut subsequent_line = rt_opts.subsequent_indent.clone().style(line.style);
let offset_range = (r.start + base)..(r.end + base);
let sliced = slice_line_spans(line, span_bounds, &offset_range);
let mut spans = subsequent_line.spans;
spans.append(
&mut sliced
.spans
.into_iter()
.map(|s| s.patch_style(line.style))
.map(|span| Span::styled(span.content, line.style.patch(span.style)))
.collect(),
);
subsequent_line.spans = spans;
out.push(subsequent_line);
out.push(WrappedLine {
line: subsequent_line,
range: offset_range,
prefix_bytes: rt_opts
.subsequent_indent
.spans
.iter()
.map(|span| span.content.len())
.sum(),
});
}
out
@@ -950,7 +1038,7 @@ fn mixed_url_wrap_line<'a>(
flat: &str,
span_bounds: &[(Range<usize>, ratatui::style::Style)],
rt_opts: RtOptions<'a>,
) -> Vec<Line<'a>> {
) -> Vec<WrappedLine<'a>> {
let initial_width_available = rt_opts
.width
.saturating_sub(line_width(&rt_opts.initial_indent))
@@ -969,20 +1057,38 @@ fn mixed_url_wrap_line<'a>(
rt_opts.subsequent_indent.clone()
}
.style(line.style);
let prefix_bytes = wrapped_line
.spans
.iter()
.map(|span| span.content.len())
.sum();
let sliced = slice_line_spans(line, span_bounds, range);
let mut spans = wrapped_line.spans;
spans.extend(
sliced
.spans
.into_iter()
.map(|span| span.patch_style(line.style)),
.map(|span| Span::styled(span.content, line.style.patch(span.style))),
);
wrapped_line.spans = spans;
out.push(wrapped_line);
out.push(WrappedLine {
line: wrapped_line,
range: range.clone(),
prefix_bytes,
});
}
if out.is_empty() {
vec![rt_opts.initial_indent.clone()]
vec![WrappedLine {
line: rt_opts.initial_indent.clone().style(line.style),
range: 0..0,
prefix_bytes: rt_opts
.initial_indent
.spans
.iter()
.map(|span| span.content.len())
.sum(),
}]
} else {
out
}
@@ -1269,6 +1375,10 @@ fn slice_line_spans<'a>(
}
}
#[cfg(test)]
#[path = "wrapping_reuse_tests.rs"]
mod reuse_tests;
#[cfg(test)]
mod tests {
use super::*;
@@ -1293,6 +1403,24 @@ mod tests {
assert_eq!(concat_line(&out[0]), "hello");
}
#[test]
fn narrow_wrap_ranges_preserve_compound_graphemes() {
assert_eq!(
wrap_ranges_trim("👩💻e\u{301}x", /*width_or_options*/ 2),
vec![0..11, 11..15],
);
for text in ["\u{301}\u{302}", " \u{301}\u{302}"] {
assert_eq!(
wrap_ranges_trim(text, /*width_or_options*/ 2),
vec![0..text.len()]
);
}
assert_eq!(
wrap_ranges("\u{301}\u{302}", /*width_or_options*/ 2),
vec![0..5],
);
}
#[test]
fn simple_unstyled_wrap_narrow_width() {
let line = Line::from("hello world");

View File

@@ -0,0 +1,114 @@
//! Compare reused first-fit rows with the generic wrapping path, including source provenance.
use super::*;
use pretty_assertions::assert_eq;
use ratatui::style::Stylize;
#[test]
fn custom_word_separator_reprocesses_the_remainder() {
let separator = WordSeparator::Custom(|text| {
let Some(space) = text.find(' ') else {
return Box::new(std::iter::once(Word::from(text)));
};
Box::new([Word::from(&text[..=space]), Word::from(&text[space + 1..])].into_iter())
});
let line = Line::from("one two three");
let rows =
word_wrap_line_with_source(&line, RtOptions::new(/*width*/ 5).word_separator(separator))
.into_iter()
.map(|row| (row.line, row.range))
.collect::<Vec<_>>();
assert_eq!(
rows,
vec![
(Line::from("one"), 0..3),
(Line::from("two"), 4..7),
(Line::from("three"), 8..13),
]
);
}
#[test]
fn first_fit_reuse_preserves_styled_rows_and_source_ranges() {
// A custom first-fit function exercises the unchanged generic path without copying
// its implementation into the test. Compare complete rows, styles and byte ranges.
let generic_first_fit = textwrap::WrapAlgorithm::Custom(|words, widths| {
textwrap::WrapAlgorithm::FirstFit.wrap(words, widths)
});
// Cover distinct split, width, indentation and separator boundaries without a random matrix.
for (text, width, initial, subsequent, break_words, separator) in [
("", 0, "", "", true, WordSeparator::AsciiSpace),
(
"hello world",
0,
"> ",
" ",
true,
WordSeparator::AsciiSpace,
),
(
"cafe\u{301} 中文 👩🏽‍💻 🇧🇷 ガ",
2,
"",
"",
true,
WordSeparator::UnicodeBreakProperties,
),
(
"a-very-long-hyphenated-word",
5,
"",
"ab",
true,
WordSeparator::AsciiSpace,
),
(
"https://example.com/a/b tail",
17,
"> ",
" ",
false,
WordSeparator::AsciiSpace,
),
(
"alpha beta\u{a0}gamma\u{200b}delta",
5,
"",
" ",
true,
WordSeparator::UnicodeBreakProperties,
),
(
"one\n two\r\nthree\tfour",
5,
"> ",
" ",
true,
WordSeparator::AsciiSpace,
),
(
"\u{1b}[31mred\u{1b}[0m tail",
80,
"",
"",
false,
WordSeparator::AsciiSpace,
),
] {
let line = Line::from(vec![text.bold(), " suffix".cyan()]).italic();
let options = RtOptions::new(width)
.initial_indent(Line::from(initial.red()))
.subsequent_indent(Line::from(subsequent.green()))
.break_words(break_words)
.word_separator(separator);
let actual = word_wrap_line_with_source(&line, options.clone())
.into_iter()
.map(|row| (row.line, row.range, row.prefix_bytes))
.collect::<Vec<_>>();
let expected = word_wrap_line_with_source(&line, options.wrap_algorithm(generic_first_fit))
.into_iter()
.map(|row| (row.line, row.range, row.prefix_bytes))
.collect::<Vec<_>>();
assert_eq!(actual, expected, "text={text:?}, width={width}");
}
}