Compare commits

...

2 Commits

Author SHA1 Message Date
starr-openai
2a5a3cef8c Generalize TUI terminal-wrapper layout handling
The failure mode here is that layout code was treating OSC-8 escape bytes as part of visible text. That is the wrong abstraction for width, wrapping, and truncation: those paths need to operate on visible cells while preserving zero-width wrapper bytes atomically around the sliced text.

Introduce a generic full-span wrapper shape for that split, use it in width/wrap/truncate, accept both BEL and ST terminators on input, preserve opener params when rewrapping slices, and add edge-case unit coverage for malformed wrappers and grapheme-safe truncation.

Co-authored-by: Codex <noreply@openai.com>
2026-03-20 10:15:24 -07:00
starr-openai
69920b84f9 Make TUI size handling OSC-8-aware
Add shared OSC-8 helpers and make low-level wrapping and truncation count visible text width, preserving wrapped/truncated hyperlinks across ratatui spans.

Co-authored-by: Codex <noreply@openai.com>
2026-03-20 10:15:24 -07:00
12 changed files with 1192 additions and 108 deletions

View File

@@ -43,7 +43,8 @@ use ratatui::layout::Size;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
use crate::terminal_wrappers::visible_width;
/// Returns the display width of a cell symbol, ignoring OSC escape sequences.
///
@@ -54,28 +55,7 @@ use unicode_width::UnicodeWidthStr;
/// This function strips them first so that only visible characters contribute
/// to the width.
fn display_width(s: &str) -> usize {
// Fast path: no escape sequences present.
if !s.contains('\x1B') {
return s.width();
}
// Strip OSC sequences: ESC ] ... BEL
let mut visible = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(ch) = chars.next() {
if ch == '\x1B' && chars.clone().next() == Some(']') {
// Consume the ']' and everything up to and including BEL.
chars.next(); // skip ']'
for c in chars.by_ref() {
if c == '\x07' {
break;
}
}
continue;
}
visible.push(ch);
}
visible.width()
visible_width(s)
}
#[derive(Debug, Hash)]
@@ -703,6 +683,22 @@ mod tests {
use ratatui::layout::Rect;
use ratatui::style::Style;
#[test]
fn display_width_ignores_bel_terminated_osc8_wrapper() {
assert_eq!(
display_width("\u{1b}]8;;https://example.com\u{7}docs\u{1b}]8;;\u{7}"),
4
);
}
#[test]
fn display_width_ignores_st_terminated_osc8_wrapper() {
assert_eq!(
display_width("\u{1b}]8;;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\"),
4
);
}
#[test]
fn diff_buffers_does_not_emit_clear_to_end_for_full_width_row() {
let area = Rect::new(0, 0, 3, 2);

View File

@@ -112,6 +112,7 @@ mod model_migration;
mod multi_agents;
mod notifications;
pub mod onboarding;
mod osc8;
mod oss_selection;
mod pager_overlay;
pub mod public_widgets;
@@ -128,6 +129,7 @@ mod streaming;
mod style;
mod terminal_palette;
mod terminal_title;
mod terminal_wrappers;
mod text_formatting;
mod theme_picker;
mod tooltips;

View File

@@ -1,11 +1,15 @@
use ratatui::text::Line;
use ratatui::text::Span;
use unicode_width::UnicodeWidthChar;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::terminal_wrappers::parse_zero_width_terminal_wrapper;
use crate::terminal_wrappers::strip_zero_width_terminal_wrappers;
use crate::terminal_wrappers::visible_width as wrapped_visible_width;
pub(crate) fn line_width(line: &Line<'_>) -> usize {
line.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.map(|span| visible_width(span.content.as_ref()))
.sum()
}
@@ -23,7 +27,7 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
let mut spans_out: Vec<Span<'static>> = Vec::with_capacity(spans.len());
for span in spans {
let span_width = UnicodeWidthStr::width(span.content.as_ref());
let span_width = visible_width(span.content.as_ref());
if span_width == 0 {
spans_out.push(span);
@@ -42,18 +46,31 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
let style = span.style;
let text = span.content.as_ref();
let parsed_wrapper = parse_zero_width_terminal_wrapper(text);
let visible_text = parsed_wrapper.map_or_else(
|| strip_zero_width_terminal_wrappers(text),
|wrapper| wrapper.text.to_string(),
);
// Truncate by visible grapheme clusters, not scalar values. This keeps
// multi-codepoint emoji intact and lets zero-width wrappers stay
// attached to the truncated visible prefix.
let mut end_idx = 0usize;
for (idx, ch) in text.char_indices() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + ch_width > max_width {
for grapheme in UnicodeSegmentation::graphemes(visible_text.as_str(), true) {
let grapheme_width = UnicodeWidthStr::width(grapheme);
if used + grapheme_width > max_width {
break;
}
end_idx = idx + ch.len_utf8();
used += ch_width;
end_idx += grapheme.len();
used += grapheme_width;
}
if end_idx > 0 {
spans_out.push(Span::styled(text[..end_idx].to_string(), style));
let truncated_text = &visible_text[..end_idx];
let content = parsed_wrapper.map_or_else(
|| truncated_text.to_string(),
|wrapper| format!("{}{}{}", wrapper.prefix, truncated_text, wrapper.suffix),
);
spans_out.push(Span::styled(content, style));
}
break;
@@ -66,6 +83,10 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
}
}
fn visible_width(text: &str) -> usize {
wrapped_visible_width(text)
}
/// Truncate a styled line to `max_width` and append an ellipsis on overflow.
///
/// Intended for short UI rows. This preserves a fast no-overflow path (width
@@ -98,3 +119,128 @@ pub(crate) fn truncate_line_with_ellipsis_if_overflow(
spans,
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use crate::osc8::osc8_hyperlink;
use super::*;
#[test]
fn line_width_counts_osc8_wrapped_text_as_visible_text_only() {
let line = Line::from(vec![
"See ".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "docs")).underlined(),
]);
assert_eq!(line_width(&line), 8);
}
#[test]
fn truncate_line_to_width_preserves_osc8_wrapped_prefix() {
let line = Line::from(vec![
"See ".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "docs")).underlined(),
]);
let truncated = truncate_line_to_width(line, 6);
let expected = Line::from(vec![
"See ".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "do")).underlined(),
]);
assert_eq!(truncated, expected);
}
#[test]
fn truncate_line_to_width_preserves_osc8_between_ascii_spans() {
let line = Line::from(vec![
"A".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "BC"))
.cyan()
.underlined(),
"DE".into(),
]);
let truncated = truncate_line_to_width(line, 4);
let expected = Line::from(vec![
"A".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "BC"))
.cyan()
.underlined(),
"D".into(),
]);
assert_eq!(truncated, expected);
}
#[test]
fn truncate_line_with_ellipsis_if_overflow_preserves_osc8_wrapped_prefix() {
let line = Line::from(vec![
"See ".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "docs")).underlined(),
]);
let truncated = truncate_line_with_ellipsis_if_overflow(line, 7);
let expected = Line::from(vec![
"See ".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "do")).underlined(),
"".underlined(),
]);
assert_eq!(truncated, expected);
}
#[test]
fn truncate_line_to_width_preserves_st_terminated_wrapper_with_params() {
let wrapped = "\u{1b}]8;id=abc;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\";
let line = Line::from(vec!["See ".into(), Span::from(wrapped).cyan().underlined()]);
let truncated = truncate_line_to_width(line, 6);
let expected = Line::from(vec![
"See ".into(),
Span::from("\u{1b}]8;id=abc;https://example.com\u{1b}\\do\u{1b}]8;;\u{1b}\\")
.cyan()
.underlined(),
]);
assert_eq!(truncated, expected);
}
#[test]
fn truncate_line_to_width_cuts_by_grapheme_not_scalar_value() {
let line = Line::from(vec![
Span::from(osc8_hyperlink(
"https://example.com/docs",
"👨\u{200d}👩\u{200d}👧\u{200d}👦x",
))
.underlined(),
]);
let truncated = truncate_line_to_width(line, 2);
let expected = Line::from(vec![
Span::from(osc8_hyperlink(
"https://example.com/docs",
"👨\u{200d}👩\u{200d}👧\u{200d}👦",
))
.underlined(),
]);
assert_eq!(truncated, expected);
}
#[test]
fn truncate_line_to_width_preserves_malformed_unterminated_wrapper_verbatim_until_limit() {
let malformed = "See \u{1b}]8;;https://example.com\u{7}docs";
let line = Line::from(malformed);
let truncated = truncate_line_to_width(line, 7);
assert_eq!(truncated, Line::from("See \u{1b}]"));
}
}

84
codex-rs/tui/src/osc8.rs Normal file
View File

@@ -0,0 +1,84 @@
#[cfg(test)]
use crate::terminal_wrappers::parse_zero_width_terminal_wrapper;
#[cfg(test)]
use crate::terminal_wrappers::strip_zero_width_terminal_wrappers;
#[cfg(test)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ParsedOsc8<'a> {
pub(crate) destination: &'a str,
pub(crate) text: &'a str,
}
const OSC8_OPEN_PREFIX: &str = "\u{1b}]8;;";
#[cfg(test)]
const OSC8_PREFIX: &str = "\u{1b}]8;";
const OSC8_CLOSE: &str = "\u{1b}]8;;\u{7}";
pub(crate) fn sanitize_osc8_url(destination: &str) -> String {
destination
.chars()
.filter(|&c| c != '\x1B' && c != '\x07')
.collect()
}
pub(crate) fn osc8_hyperlink<S: AsRef<str>>(destination: &str, text: S) -> String {
let safe_destination = sanitize_osc8_url(destination);
if safe_destination.is_empty() {
return text.as_ref().to_string();
}
format!(
"{OSC8_OPEN_PREFIX}{safe_destination}\u{7}{}{OSC8_CLOSE}",
text.as_ref()
)
}
#[cfg(test)]
pub(crate) fn parse_osc8_hyperlink(text: &str) -> Option<ParsedOsc8<'_>> {
let wrapped = parse_zero_width_terminal_wrapper(text)?;
let opener_payload = wrapped.prefix.strip_prefix(OSC8_PREFIX)?;
let params_end = opener_payload.find(';')?;
let after_params = &opener_payload[params_end + 1..];
let destination = after_params
.strip_suffix('\x07')
.or_else(|| after_params.strip_suffix("\x1b\\"))?;
Some(ParsedOsc8 {
destination,
text: wrapped.text,
})
}
#[cfg(test)]
pub(crate) fn strip_osc8_hyperlinks(text: &str) -> String {
strip_zero_width_terminal_wrappers(text)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn parses_wrapped_text() {
let wrapped = osc8_hyperlink("https://example.com", "docs");
let parsed = parse_osc8_hyperlink(&wrapped).expect("expected osc8 span");
assert_eq!(parsed.destination, "https://example.com");
assert_eq!(parsed.text, "docs");
}
#[test]
fn strips_wrapped_text() {
let wrapped = format!("See {}", osc8_hyperlink("https://example.com", "docs"));
assert_eq!(strip_osc8_hyperlinks(&wrapped), "See docs");
}
#[test]
fn parses_st_terminated_wrapped_text_with_params() {
let wrapped = "\u{1b}]8;id=abc;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\";
let parsed = parse_osc8_hyperlink(wrapped).expect("expected osc8 span");
assert_eq!(parsed.destination, "https://example.com");
assert_eq!(parsed.text, "docs");
}
}

View File

@@ -0,0 +1,187 @@
use unicode_width::UnicodeWidthStr;
/// A balanced zero-width terminal wrapper around visible text.
///
/// This is deliberately narrower than "arbitrary ANSI". It models the shape we
/// need for OSC-8 hyperlinks in layout code: an opener with no display width,
/// visible text that should be measured/wrapped/truncated, and a closer with no
/// display width. Keeping the wrapper bytes separate from the visible text lets
/// us preserve them atomically when a line is split.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ParsedTerminalWrapper<'a> {
pub(crate) prefix: &'a str,
pub(crate) text: &'a str,
pub(crate) suffix: &'a str,
}
const OSC8_PREFIX: &str = "\u{1b}]8;";
const OSC8_CLOSE_BEL: &str = "\u{1b}]8;;\u{7}";
const OSC8_CLOSE_ST: &str = "\u{1b}]8;;\u{1b}\\";
const OSC_STRING_TERMINATORS: [&str; 2] = ["\u{7}", "\u{1b}\\"];
/// Parse a full-span terminal wrapper.
///
/// Today this recognizes OSC-8 hyperlinks only, but it returns a generic
/// wrapper shape so width and slicing code do not need to know about
/// hyperlink-specific fields like URL or params.
pub(crate) fn parse_zero_width_terminal_wrapper(text: &str) -> Option<ParsedTerminalWrapper<'_>> {
let after_prefix = text.strip_prefix(OSC8_PREFIX)?;
let params_end = after_prefix.find(';')?;
let after_params = &after_prefix[params_end + 1..];
let (destination_end, opener_terminator) = find_osc_string_terminator(after_params)?;
let prefix_len = OSC8_PREFIX.len() + params_end + 1 + destination_end + opener_terminator.len();
let prefix = &text[..prefix_len];
let after_opener = &text[prefix_len..];
if let Some(visible) = after_opener.strip_suffix(OSC8_CLOSE_BEL) {
return Some(ParsedTerminalWrapper {
prefix,
text: visible,
suffix: OSC8_CLOSE_BEL,
});
}
if let Some(visible) = after_opener.strip_suffix(OSC8_CLOSE_ST) {
return Some(ParsedTerminalWrapper {
prefix,
text: visible,
suffix: OSC8_CLOSE_ST,
});
}
None
}
/// Strip the zero-width wrapper bytes from any recognized wrapped runs.
///
/// Malformed or unterminated escape sequences are preserved verbatim. That
/// keeps layout helpers fail-safe: they may over-measure malformed input, but
/// they will not silently delete bytes from it.
pub(crate) fn strip_zero_width_terminal_wrappers(text: &str) -> String {
if !text.contains('\x1B') {
return text.to_string();
}
let mut remaining = text;
let mut rendered = String::with_capacity(text.len());
while let Some(open_pos) = remaining.find(OSC8_PREFIX) {
rendered.push_str(&remaining[..open_pos]);
let candidate = &remaining[open_pos..];
let Some((consumed, visible)) = consume_wrapped_prefix(candidate) else {
rendered.push_str(candidate);
return rendered;
};
rendered.push_str(visible);
remaining = &candidate[consumed..];
}
rendered.push_str(remaining);
rendered
}
/// Measure display width after removing recognized zero-width terminal wrappers.
pub(crate) fn visible_width(text: &str) -> usize {
UnicodeWidthStr::width(strip_zero_width_terminal_wrappers(text).as_str())
}
fn consume_wrapped_prefix(text: &str) -> Option<(usize, &str)> {
let after_prefix = text.strip_prefix(OSC8_PREFIX)?;
let params_end = after_prefix.find(';')?;
let after_params = &after_prefix[params_end + 1..];
let (destination_end, opener_terminator) = find_osc_string_terminator(after_params)?;
let opener_len = OSC8_PREFIX.len() + params_end + 1 + destination_end + opener_terminator.len();
let after_opener = &text[opener_len..];
let mut best: Option<(usize, &str)> = None;
for suffix in [OSC8_CLOSE_BEL, OSC8_CLOSE_ST] {
if let Some(close_pos) = after_opener.find(suffix)
&& best.is_none_or(|(best_pos, _)| close_pos < best_pos)
{
best = Some((close_pos, suffix));
}
}
let (close_pos, suffix) = best?;
Some((
opener_len + close_pos + suffix.len(),
&after_opener[..close_pos],
))
}
fn find_osc_string_terminator(text: &str) -> Option<(usize, &'static str)> {
let mut best: Option<(usize, &'static str)> = None;
for terminator in OSC_STRING_TERMINATORS {
if let Some(pos) = text.find(terminator)
&& best.is_none_or(|(best_pos, _)| pos < best_pos)
{
best = Some((pos, terminator));
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn parses_bel_terminated_wrapper() {
let wrapped = "\u{1b}]8;;https://example.com\u{7}docs\u{1b}]8;;\u{7}";
assert_eq!(
parse_zero_width_terminal_wrapper(wrapped),
Some(ParsedTerminalWrapper {
prefix: "\u{1b}]8;;https://example.com\u{7}",
text: "docs",
suffix: "\u{1b}]8;;\u{7}",
})
);
}
#[test]
fn parses_st_terminated_wrapper_with_params() {
let wrapped = "\u{1b}]8;id=abc;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\";
assert_eq!(
parse_zero_width_terminal_wrapper(wrapped),
Some(ParsedTerminalWrapper {
prefix: "\u{1b}]8;id=abc;https://example.com\u{1b}\\",
text: "docs",
suffix: "\u{1b}]8;;\u{1b}\\",
})
);
}
#[test]
fn strips_multiple_wrapped_runs_and_keeps_plain_text() {
let text = concat!(
"See ",
"\u{1b}]8;;https://a.example\u{7}alpha\u{1b}]8;;\u{7}",
" and ",
"\u{1b}]8;id=1;https://b.example\u{1b}\\beta\u{1b}]8;;\u{1b}\\",
"."
);
assert_eq!(
strip_zero_width_terminal_wrappers(text),
"See alpha and beta."
);
}
#[test]
fn preserves_malformed_unterminated_wrapper_verbatim() {
let text = "See \u{1b}]8;;https://example.com\u{7}docs";
assert_eq!(strip_zero_width_terminal_wrappers(text), text);
assert_eq!(parse_zero_width_terminal_wrapper(text), None);
}
#[test]
fn visible_width_ignores_wrapper_bytes() {
let text = "\u{1b}]8;;https://example.com\u{7}docs\u{1b}]8;;\u{7}";
assert_eq!(visible_width(text), 4);
}
}

View File

@@ -33,6 +33,8 @@ use std::ops::Range;
use textwrap::Options;
use crate::render::line_utils::push_owned_lines;
use crate::terminal_wrappers::parse_zero_width_terminal_wrapper;
use crate::terminal_wrappers::strip_zero_width_terminal_wrappers;
/// Returns byte-ranges into `text` for each wrapped line, including
/// trailing whitespace and a +1 sentinel byte. Used by the textarea
@@ -177,12 +179,7 @@ fn map_owned_wrapped_line_to_range(
///
/// Concatenates all span contents and delegates to [`text_contains_url_like`].
pub(crate) fn line_contains_url_like(line: &Line<'_>) -> bool {
let text: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
text_contains_url_like(&text)
text_contains_url_like(&visible_line_text(line))
}
/// Returns `true` if `line` contains both a URL-like token and at least one
@@ -191,12 +188,15 @@ pub(crate) fn line_contains_url_like(line: &Line<'_>) -> bool {
/// Decorative marker tokens (for example list prefixes like `-`, `1.`, `|`,
/// `│`) are ignored for the non-URL side of this check.
pub(crate) fn line_has_mixed_url_and_non_url_tokens(line: &Line<'_>) -> bool {
let text: String = line
.spans
text_has_mixed_url_and_non_url_tokens(&visible_line_text(line))
}
fn visible_line_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
text_has_mixed_url_and_non_url_tokens(&text)
.map(|span| strip_zero_width_terminal_wrappers(span.content.as_ref()))
.collect::<Vec<_>>()
.join("")
}
/// Returns `true` if any whitespace-delimited token in `text` looks like a URL.
@@ -639,16 +639,25 @@ pub(crate) fn word_wrap_line<'a, O>(line: &'a Line<'a>, width_or_options: O) ->
where
O: Into<RtOptions<'a>>,
{
// Flatten the line and record span byte ranges.
// Flatten the line to visible text and record the original span bounds.
// Zero-width terminal wrappers stay out of `flat` so textwrap sees only
// display cells, but we keep their exact prefix/suffix bytes for
// rewrapping each sliced fragment later.
let mut flat = String::new();
let mut span_bounds = Vec::new();
let mut acc = 0usize;
for s in &line.spans {
let text = s.content.as_ref();
let parsed = parse_zero_width_terminal_wrapper(s.content.as_ref());
let text = parsed.map_or_else(|| s.content.as_ref(), |wrapper| wrapper.text);
let start = acc;
flat.push_str(text);
acc += text.len();
span_bounds.push((start..acc, s.style));
span_bounds.push(SpanBound {
range: start..acc,
style: s.style,
wrapper_prefix: parsed.map(|wrapper| wrapper.prefix),
wrapper_suffix: parsed.map(|wrapper| wrapper.suffix),
});
}
let rt_opts: RtOptions<'a> = width_or_options.into();
@@ -841,15 +850,15 @@ where
fn slice_line_spans<'a>(
original: &'a Line<'a>,
span_bounds: &[(Range<usize>, ratatui::style::Style)],
span_bounds: &[SpanBound<'a>],
range: &Range<usize>,
) -> Line<'a> {
let start_byte = range.start;
let end_byte = range.end;
let mut acc: Vec<Span<'a>> = Vec::new();
for (i, (range, style)) in span_bounds.iter().enumerate() {
let s = range.start;
let e = range.end;
for (i, bound) in span_bounds.iter().enumerate() {
let s = bound.range.start;
let e = bound.range.end;
if e <= start_byte {
continue;
}
@@ -861,11 +870,15 @@ fn slice_line_spans<'a>(
if seg_end > seg_start {
let local_start = seg_start - s;
let local_end = seg_end - s;
let content = original.spans[i].content.as_ref();
let slice = &content[local_start..local_end];
let slice = slice_span_content(
original.spans[i].content.as_ref(),
bound,
local_start,
local_end,
);
acc.push(Span {
style: *style,
content: std::borrow::Cow::Borrowed(slice),
style: bound.style,
content: slice,
});
}
if e >= end_byte {
@@ -879,9 +892,44 @@ fn slice_line_spans<'a>(
}
}
#[derive(Clone, Debug)]
struct SpanBound<'a> {
range: Range<usize>,
style: ratatui::style::Style,
wrapper_prefix: Option<&'a str>,
wrapper_suffix: Option<&'a str>,
}
fn slice_span_content<'a>(
content: &'a str,
bound: &SpanBound<'a>,
local_start: usize,
local_end: usize,
) -> Cow<'a, str> {
// If the original span was wrapped in a zero-width terminal control
// sequence, re-emit that wrapper around the visible slice instead of
// cutting through the escape payload.
if let (Some(prefix), Some(suffix)) = (bound.wrapper_prefix, bound.wrapper_suffix) {
if let Some(parsed) = parse_zero_width_terminal_wrapper(content) {
Cow::Owned(format!(
"{prefix}{}{suffix}",
&parsed.text[local_start..local_end]
))
} else {
Cow::Borrowed(&content[local_start..local_end])
}
} else {
Cow::Borrowed(&content[local_start..local_end])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::osc8::osc8_hyperlink;
use crate::terminal_wrappers::ParsedTerminalWrapper;
use crate::terminal_wrappers::parse_zero_width_terminal_wrapper;
use crate::terminal_wrappers::strip_zero_width_terminal_wrappers;
use itertools::Itertools as _;
use pretty_assertions::assert_eq;
use ratatui::style::Color;
@@ -965,6 +1013,85 @@ mod tests {
assert_eq!(concat_line(&out[0]), "");
}
#[test]
fn osc8_wrapped_span_wraps_by_visible_text() {
let url = "https://example.com/docs";
let line = Line::from(vec![osc8_hyperlink(url, "abcdefghij").cyan().underlined()]);
let out = word_wrap_line(&line, 5);
assert_eq!(out.len(), 2);
let first = concat_line(&out[0]);
let second = concat_line(&out[1]);
assert_eq!(strip_zero_width_terminal_wrappers(&first), "abcde");
assert_eq!(strip_zero_width_terminal_wrappers(&second), "fghij");
assert_eq!(
parse_zero_width_terminal_wrapper(&first).expect("first line should stay hyperlinked"),
ParsedTerminalWrapper {
prefix: "\u{1b}]8;;https://example.com/docs\u{7}",
text: "abcde",
suffix: "\u{1b}]8;;\u{7}",
}
);
assert_eq!(
parse_zero_width_terminal_wrapper(&second)
.expect("second line should stay hyperlinked"),
ParsedTerminalWrapper {
prefix: "\u{1b}]8;;https://example.com/docs\u{7}",
text: "fghij",
suffix: "\u{1b}]8;;\u{7}",
}
);
}
#[test]
fn osc8_wrapper_with_params_and_st_is_preserved_across_wraps() {
let line = Line::from(vec![
"x".into(),
Span::from("\u{1b}]8;id=abc;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\")
.underlined(),
]);
let out = word_wrap_line(&line, 3);
let expected = vec![
Line::from(vec![
"x".into(),
Span::from("\u{1b}]8;id=abc;https://example.com\u{1b}\\do\u{1b}]8;;\u{1b}\\")
.underlined(),
]),
Line::from(vec![
Span::from("\u{1b}]8;id=abc;https://example.com\u{1b}\\cs\u{1b}]8;;\u{1b}\\")
.underlined(),
]),
];
assert_eq!(out, expected);
}
#[test]
fn mixed_ascii_and_osc8_wrapped_spans_preserve_ratatui_spans_across_wraps() {
let url = "https://example.com/docs";
let line = Line::from(vec![
"ab".into(),
osc8_hyperlink(url, "cdef").cyan().underlined(),
"gh".into(),
]);
let out = word_wrap_line(&line, 4);
let expected = vec![
Line::from(vec![
"ab".into(),
osc8_hyperlink(url, "cd").cyan().underlined(),
]),
Line::from(vec![
osc8_hyperlink(url, "ef").cyan().underlined(),
"gh".into(),
]),
];
assert_eq!(out, expected);
}
#[test]
fn leading_spaces_preserved_on_first_line() {
let line = Line::from(" hello");

View File

@@ -43,7 +43,8 @@ use ratatui::layout::Size;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
use crate::terminal_wrappers::visible_width;
/// Returns the display width of a cell symbol, ignoring OSC escape sequences.
///
@@ -54,28 +55,7 @@ use unicode_width::UnicodeWidthStr;
/// This function strips them first so that only visible characters contribute
/// to the width.
fn display_width(s: &str) -> usize {
// Fast path: no escape sequences present.
if !s.contains('\x1B') {
return s.width();
}
// Strip OSC sequences: ESC ] ... BEL
let mut visible = String::with_capacity(s.len());
let mut chars = s.chars();
while let Some(ch) = chars.next() {
if ch == '\x1B' && chars.clone().next() == Some(']') {
// Consume the ']' and everything up to and including BEL.
chars.next(); // skip ']'
for c in chars.by_ref() {
if c == '\x07' {
break;
}
}
continue;
}
visible.push(ch);
}
visible.width()
visible_width(s)
}
#[derive(Debug, Hash)]
@@ -703,6 +683,22 @@ mod tests {
use ratatui::layout::Rect;
use ratatui::style::Style;
#[test]
fn display_width_ignores_bel_terminated_osc8_wrapper() {
assert_eq!(
display_width("\u{1b}]8;;https://example.com\u{7}docs\u{1b}]8;;\u{7}"),
4
);
}
#[test]
fn display_width_ignores_st_terminated_osc8_wrapper() {
assert_eq!(
display_width("\u{1b}]8;;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\"),
4
);
}
#[test]
fn diff_buffers_does_not_emit_clear_to_end_for_full_width_row() {
let area = Rect::new(0, 0, 3, 2);

View File

@@ -126,6 +126,7 @@ mod model_migration;
mod multi_agents;
mod notifications;
pub mod onboarding;
mod osc8;
mod oss_selection;
mod pager_overlay;
pub mod public_widgets;
@@ -141,6 +142,7 @@ mod status_indicator_widget;
mod streaming;
mod style;
mod terminal_palette;
mod terminal_wrappers;
mod text_formatting;
mod theme_picker;
mod tooltips;

View File

@@ -1,11 +1,15 @@
use ratatui::text::Line;
use ratatui::text::Span;
use unicode_width::UnicodeWidthChar;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::terminal_wrappers::parse_zero_width_terminal_wrapper;
use crate::terminal_wrappers::strip_zero_width_terminal_wrappers;
use crate::terminal_wrappers::visible_width as wrapped_visible_width;
pub(crate) fn line_width(line: &Line<'_>) -> usize {
line.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.map(|span| visible_width(span.content.as_ref()))
.sum()
}
@@ -23,7 +27,7 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
let mut spans_out: Vec<Span<'static>> = Vec::with_capacity(spans.len());
for span in spans {
let span_width = UnicodeWidthStr::width(span.content.as_ref());
let span_width = visible_width(span.content.as_ref());
if span_width == 0 {
spans_out.push(span);
@@ -42,18 +46,31 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
let style = span.style;
let text = span.content.as_ref();
let parsed_wrapper = parse_zero_width_terminal_wrapper(text);
let visible_text = parsed_wrapper.map_or_else(
|| strip_zero_width_terminal_wrappers(text),
|wrapper| wrapper.text.to_string(),
);
// Truncate by visible grapheme clusters, not scalar values. This keeps
// multi-codepoint emoji intact and lets zero-width wrappers stay
// attached to the truncated visible prefix.
let mut end_idx = 0usize;
for (idx, ch) in text.char_indices() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + ch_width > max_width {
for grapheme in UnicodeSegmentation::graphemes(visible_text.as_str(), true) {
let grapheme_width = UnicodeWidthStr::width(grapheme);
if used + grapheme_width > max_width {
break;
}
end_idx = idx + ch.len_utf8();
used += ch_width;
end_idx += grapheme.len();
used += grapheme_width;
}
if end_idx > 0 {
spans_out.push(Span::styled(text[..end_idx].to_string(), style));
let truncated_text = &visible_text[..end_idx];
let content = parsed_wrapper.map_or_else(
|| truncated_text.to_string(),
|wrapper| format!("{}{}{}", wrapper.prefix, truncated_text, wrapper.suffix),
);
spans_out.push(Span::styled(content, style));
}
break;
@@ -66,6 +83,10 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
}
}
fn visible_width(text: &str) -> usize {
wrapped_visible_width(text)
}
/// Truncate a styled line to `max_width` and append an ellipsis on overflow.
///
/// Intended for short UI rows. This preserves a fast no-overflow path (width
@@ -98,3 +119,128 @@ pub(crate) fn truncate_line_with_ellipsis_if_overflow(
spans,
}
}
#[cfg(test)]
mod tests {
use pretty_assertions::assert_eq;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use crate::osc8::osc8_hyperlink;
use super::*;
#[test]
fn line_width_counts_osc8_wrapped_text_as_visible_text_only() {
let line = Line::from(vec![
"See ".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "docs")).underlined(),
]);
assert_eq!(line_width(&line), 8);
}
#[test]
fn truncate_line_to_width_preserves_osc8_wrapped_prefix() {
let line = Line::from(vec![
"See ".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "docs")).underlined(),
]);
let truncated = truncate_line_to_width(line, 6);
let expected = Line::from(vec![
"See ".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "do")).underlined(),
]);
assert_eq!(truncated, expected);
}
#[test]
fn truncate_line_to_width_preserves_osc8_between_ascii_spans() {
let line = Line::from(vec![
"A".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "BC"))
.cyan()
.underlined(),
"DE".into(),
]);
let truncated = truncate_line_to_width(line, 4);
let expected = Line::from(vec![
"A".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "BC"))
.cyan()
.underlined(),
"D".into(),
]);
assert_eq!(truncated, expected);
}
#[test]
fn truncate_line_with_ellipsis_if_overflow_preserves_osc8_wrapped_prefix() {
let line = Line::from(vec![
"See ".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "docs")).underlined(),
]);
let truncated = truncate_line_with_ellipsis_if_overflow(line, 7);
let expected = Line::from(vec![
"See ".into(),
Span::from(osc8_hyperlink("https://example.com/docs", "do")).underlined(),
"".underlined(),
]);
assert_eq!(truncated, expected);
}
#[test]
fn truncate_line_to_width_preserves_st_terminated_wrapper_with_params() {
let wrapped = "\u{1b}]8;id=abc;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\";
let line = Line::from(vec!["See ".into(), Span::from(wrapped).cyan().underlined()]);
let truncated = truncate_line_to_width(line, 6);
let expected = Line::from(vec![
"See ".into(),
Span::from("\u{1b}]8;id=abc;https://example.com\u{1b}\\do\u{1b}]8;;\u{1b}\\")
.cyan()
.underlined(),
]);
assert_eq!(truncated, expected);
}
#[test]
fn truncate_line_to_width_cuts_by_grapheme_not_scalar_value() {
let line = Line::from(vec![
Span::from(osc8_hyperlink(
"https://example.com/docs",
"👨\u{200d}👩\u{200d}👧\u{200d}👦x",
))
.underlined(),
]);
let truncated = truncate_line_to_width(line, 2);
let expected = Line::from(vec![
Span::from(osc8_hyperlink(
"https://example.com/docs",
"👨\u{200d}👩\u{200d}👧\u{200d}👦",
))
.underlined(),
]);
assert_eq!(truncated, expected);
}
#[test]
fn truncate_line_to_width_preserves_malformed_unterminated_wrapper_verbatim_until_limit() {
let malformed = "See \u{1b}]8;;https://example.com\u{7}docs";
let line = Line::from(malformed);
let truncated = truncate_line_to_width(line, 7);
assert_eq!(truncated, Line::from("See \u{1b}]"));
}
}

View File

@@ -0,0 +1,84 @@
#[cfg(test)]
use crate::terminal_wrappers::parse_zero_width_terminal_wrapper;
#[cfg(test)]
use crate::terminal_wrappers::strip_zero_width_terminal_wrappers;
#[cfg(test)]
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ParsedOsc8<'a> {
pub(crate) destination: &'a str,
pub(crate) text: &'a str,
}
const OSC8_OPEN_PREFIX: &str = "\u{1b}]8;;";
#[cfg(test)]
const OSC8_PREFIX: &str = "\u{1b}]8;";
const OSC8_CLOSE: &str = "\u{1b}]8;;\u{7}";
pub(crate) fn sanitize_osc8_url(destination: &str) -> String {
destination
.chars()
.filter(|&c| c != '\x1B' && c != '\x07')
.collect()
}
pub(crate) fn osc8_hyperlink<S: AsRef<str>>(destination: &str, text: S) -> String {
let safe_destination = sanitize_osc8_url(destination);
if safe_destination.is_empty() {
return text.as_ref().to_string();
}
format!(
"{OSC8_OPEN_PREFIX}{safe_destination}\u{7}{}{OSC8_CLOSE}",
text.as_ref()
)
}
#[cfg(test)]
pub(crate) fn parse_osc8_hyperlink(text: &str) -> Option<ParsedOsc8<'_>> {
let wrapped = parse_zero_width_terminal_wrapper(text)?;
let opener_payload = wrapped.prefix.strip_prefix(OSC8_PREFIX)?;
let params_end = opener_payload.find(';')?;
let after_params = &opener_payload[params_end + 1..];
let destination = after_params
.strip_suffix('\x07')
.or_else(|| after_params.strip_suffix("\x1b\\"))?;
Some(ParsedOsc8 {
destination,
text: wrapped.text,
})
}
#[cfg(test)]
pub(crate) fn strip_osc8_hyperlinks(text: &str) -> String {
strip_zero_width_terminal_wrappers(text)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn parses_wrapped_text() {
let wrapped = osc8_hyperlink("https://example.com", "docs");
let parsed = parse_osc8_hyperlink(&wrapped).expect("expected osc8 span");
assert_eq!(parsed.destination, "https://example.com");
assert_eq!(parsed.text, "docs");
}
#[test]
fn strips_wrapped_text() {
let wrapped = format!("See {}", osc8_hyperlink("https://example.com", "docs"));
assert_eq!(strip_osc8_hyperlinks(&wrapped), "See docs");
}
#[test]
fn parses_st_terminated_wrapped_text_with_params() {
let wrapped = "\u{1b}]8;id=abc;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\";
let parsed = parse_osc8_hyperlink(wrapped).expect("expected osc8 span");
assert_eq!(parsed.destination, "https://example.com");
assert_eq!(parsed.text, "docs");
}
}

View File

@@ -0,0 +1,187 @@
use unicode_width::UnicodeWidthStr;
/// A balanced zero-width terminal wrapper around visible text.
///
/// This is deliberately narrower than "arbitrary ANSI". It models the shape we
/// need for OSC-8 hyperlinks in layout code: an opener with no display width,
/// visible text that should be measured/wrapped/truncated, and a closer with no
/// display width. Keeping the wrapper bytes separate from the visible text lets
/// us preserve them atomically when a line is split.
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub(crate) struct ParsedTerminalWrapper<'a> {
pub(crate) prefix: &'a str,
pub(crate) text: &'a str,
pub(crate) suffix: &'a str,
}
const OSC8_PREFIX: &str = "\u{1b}]8;";
const OSC8_CLOSE_BEL: &str = "\u{1b}]8;;\u{7}";
const OSC8_CLOSE_ST: &str = "\u{1b}]8;;\u{1b}\\";
const OSC_STRING_TERMINATORS: [&str; 2] = ["\u{7}", "\u{1b}\\"];
/// Parse a full-span terminal wrapper.
///
/// Today this recognizes OSC-8 hyperlinks only, but it returns a generic
/// wrapper shape so width and slicing code do not need to know about
/// hyperlink-specific fields like URL or params.
pub(crate) fn parse_zero_width_terminal_wrapper(text: &str) -> Option<ParsedTerminalWrapper<'_>> {
let after_prefix = text.strip_prefix(OSC8_PREFIX)?;
let params_end = after_prefix.find(';')?;
let after_params = &after_prefix[params_end + 1..];
let (destination_end, opener_terminator) = find_osc_string_terminator(after_params)?;
let prefix_len = OSC8_PREFIX.len() + params_end + 1 + destination_end + opener_terminator.len();
let prefix = &text[..prefix_len];
let after_opener = &text[prefix_len..];
if let Some(visible) = after_opener.strip_suffix(OSC8_CLOSE_BEL) {
return Some(ParsedTerminalWrapper {
prefix,
text: visible,
suffix: OSC8_CLOSE_BEL,
});
}
if let Some(visible) = after_opener.strip_suffix(OSC8_CLOSE_ST) {
return Some(ParsedTerminalWrapper {
prefix,
text: visible,
suffix: OSC8_CLOSE_ST,
});
}
None
}
/// Strip the zero-width wrapper bytes from any recognized wrapped runs.
///
/// Malformed or unterminated escape sequences are preserved verbatim. That
/// keeps layout helpers fail-safe: they may over-measure malformed input, but
/// they will not silently delete bytes from it.
pub(crate) fn strip_zero_width_terminal_wrappers(text: &str) -> String {
if !text.contains('\x1B') {
return text.to_string();
}
let mut remaining = text;
let mut rendered = String::with_capacity(text.len());
while let Some(open_pos) = remaining.find(OSC8_PREFIX) {
rendered.push_str(&remaining[..open_pos]);
let candidate = &remaining[open_pos..];
let Some((consumed, visible)) = consume_wrapped_prefix(candidate) else {
rendered.push_str(candidate);
return rendered;
};
rendered.push_str(visible);
remaining = &candidate[consumed..];
}
rendered.push_str(remaining);
rendered
}
/// Measure display width after removing recognized zero-width terminal wrappers.
pub(crate) fn visible_width(text: &str) -> usize {
UnicodeWidthStr::width(strip_zero_width_terminal_wrappers(text).as_str())
}
fn consume_wrapped_prefix(text: &str) -> Option<(usize, &str)> {
let after_prefix = text.strip_prefix(OSC8_PREFIX)?;
let params_end = after_prefix.find(';')?;
let after_params = &after_prefix[params_end + 1..];
let (destination_end, opener_terminator) = find_osc_string_terminator(after_params)?;
let opener_len = OSC8_PREFIX.len() + params_end + 1 + destination_end + opener_terminator.len();
let after_opener = &text[opener_len..];
let mut best: Option<(usize, &str)> = None;
for suffix in [OSC8_CLOSE_BEL, OSC8_CLOSE_ST] {
if let Some(close_pos) = after_opener.find(suffix)
&& best.is_none_or(|(best_pos, _)| close_pos < best_pos)
{
best = Some((close_pos, suffix));
}
}
let (close_pos, suffix) = best?;
Some((
opener_len + close_pos + suffix.len(),
&after_opener[..close_pos],
))
}
fn find_osc_string_terminator(text: &str) -> Option<(usize, &'static str)> {
let mut best: Option<(usize, &'static str)> = None;
for terminator in OSC_STRING_TERMINATORS {
if let Some(pos) = text.find(terminator)
&& best.is_none_or(|(best_pos, _)| pos < best_pos)
{
best = Some((pos, terminator));
}
}
best
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
#[test]
fn parses_bel_terminated_wrapper() {
let wrapped = "\u{1b}]8;;https://example.com\u{7}docs\u{1b}]8;;\u{7}";
assert_eq!(
parse_zero_width_terminal_wrapper(wrapped),
Some(ParsedTerminalWrapper {
prefix: "\u{1b}]8;;https://example.com\u{7}",
text: "docs",
suffix: "\u{1b}]8;;\u{7}",
})
);
}
#[test]
fn parses_st_terminated_wrapper_with_params() {
let wrapped = "\u{1b}]8;id=abc;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\";
assert_eq!(
parse_zero_width_terminal_wrapper(wrapped),
Some(ParsedTerminalWrapper {
prefix: "\u{1b}]8;id=abc;https://example.com\u{1b}\\",
text: "docs",
suffix: "\u{1b}]8;;\u{1b}\\",
})
);
}
#[test]
fn strips_multiple_wrapped_runs_and_keeps_plain_text() {
let text = concat!(
"See ",
"\u{1b}]8;;https://a.example\u{7}alpha\u{1b}]8;;\u{7}",
" and ",
"\u{1b}]8;id=1;https://b.example\u{1b}\\beta\u{1b}]8;;\u{1b}\\",
"."
);
assert_eq!(
strip_zero_width_terminal_wrappers(text),
"See alpha and beta."
);
}
#[test]
fn preserves_malformed_unterminated_wrapper_verbatim() {
let text = "See \u{1b}]8;;https://example.com\u{7}docs";
assert_eq!(strip_zero_width_terminal_wrappers(text), text);
assert_eq!(parse_zero_width_terminal_wrapper(text), None);
}
#[test]
fn visible_width_ignores_wrapper_bytes() {
let text = "\u{1b}]8;;https://example.com\u{7}docs\u{1b}]8;;\u{7}";
assert_eq!(visible_width(text), 4);
}
}

View File

@@ -33,6 +33,8 @@ use std::ops::Range;
use textwrap::Options;
use crate::render::line_utils::push_owned_lines;
use crate::terminal_wrappers::parse_zero_width_terminal_wrapper;
use crate::terminal_wrappers::strip_zero_width_terminal_wrappers;
/// Returns byte-ranges into `text` for each wrapped line, including
/// trailing whitespace and a +1 sentinel byte. Used by the textarea
@@ -177,12 +179,7 @@ fn map_owned_wrapped_line_to_range(
///
/// Concatenates all span contents and delegates to [`text_contains_url_like`].
pub(crate) fn line_contains_url_like(line: &Line<'_>) -> bool {
let text: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
text_contains_url_like(&text)
text_contains_url_like(&visible_line_text(line))
}
/// Returns `true` if `line` contains both a URL-like token and at least one
@@ -191,12 +188,15 @@ pub(crate) fn line_contains_url_like(line: &Line<'_>) -> bool {
/// Decorative marker tokens (for example list prefixes like `-`, `1.`, `|`,
/// `│`) are ignored for the non-URL side of this check.
pub(crate) fn line_has_mixed_url_and_non_url_tokens(line: &Line<'_>) -> bool {
let text: String = line
.spans
text_has_mixed_url_and_non_url_tokens(&visible_line_text(line))
}
fn visible_line_text(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect();
text_has_mixed_url_and_non_url_tokens(&text)
.map(|span| strip_zero_width_terminal_wrappers(span.content.as_ref()))
.collect::<Vec<_>>()
.join("")
}
/// Returns `true` if any whitespace-delimited token in `text` looks like a URL.
@@ -639,16 +639,25 @@ pub(crate) fn word_wrap_line<'a, O>(line: &'a Line<'a>, width_or_options: O) ->
where
O: Into<RtOptions<'a>>,
{
// Flatten the line and record span byte ranges.
// Flatten the line to visible text and record the original span bounds.
// Zero-width terminal wrappers stay out of `flat` so textwrap sees only
// display cells, but we keep their exact prefix/suffix bytes for
// rewrapping each sliced fragment later.
let mut flat = String::new();
let mut span_bounds = Vec::new();
let mut acc = 0usize;
for s in &line.spans {
let text = s.content.as_ref();
let parsed = parse_zero_width_terminal_wrapper(s.content.as_ref());
let text = parsed.map_or_else(|| s.content.as_ref(), |wrapper| wrapper.text);
let start = acc;
flat.push_str(text);
acc += text.len();
span_bounds.push((start..acc, s.style));
span_bounds.push(SpanBound {
range: start..acc,
style: s.style,
wrapper_prefix: parsed.map(|wrapper| wrapper.prefix),
wrapper_suffix: parsed.map(|wrapper| wrapper.suffix),
});
}
let rt_opts: RtOptions<'a> = width_or_options.into();
@@ -841,15 +850,15 @@ where
fn slice_line_spans<'a>(
original: &'a Line<'a>,
span_bounds: &[(Range<usize>, ratatui::style::Style)],
span_bounds: &[SpanBound<'a>],
range: &Range<usize>,
) -> Line<'a> {
let start_byte = range.start;
let end_byte = range.end;
let mut acc: Vec<Span<'a>> = Vec::new();
for (i, (range, style)) in span_bounds.iter().enumerate() {
let s = range.start;
let e = range.end;
for (i, bound) in span_bounds.iter().enumerate() {
let s = bound.range.start;
let e = bound.range.end;
if e <= start_byte {
continue;
}
@@ -861,11 +870,15 @@ fn slice_line_spans<'a>(
if seg_end > seg_start {
let local_start = seg_start - s;
let local_end = seg_end - s;
let content = original.spans[i].content.as_ref();
let slice = &content[local_start..local_end];
let slice = slice_span_content(
original.spans[i].content.as_ref(),
bound,
local_start,
local_end,
);
acc.push(Span {
style: *style,
content: std::borrow::Cow::Borrowed(slice),
style: bound.style,
content: slice,
});
}
if e >= end_byte {
@@ -879,9 +892,44 @@ fn slice_line_spans<'a>(
}
}
#[derive(Clone, Debug)]
struct SpanBound<'a> {
range: Range<usize>,
style: ratatui::style::Style,
wrapper_prefix: Option<&'a str>,
wrapper_suffix: Option<&'a str>,
}
fn slice_span_content<'a>(
content: &'a str,
bound: &SpanBound<'a>,
local_start: usize,
local_end: usize,
) -> Cow<'a, str> {
// If the original span was wrapped in a zero-width terminal control
// sequence, re-emit that wrapper around the visible slice instead of
// cutting through the escape payload.
if let (Some(prefix), Some(suffix)) = (bound.wrapper_prefix, bound.wrapper_suffix) {
if let Some(parsed) = parse_zero_width_terminal_wrapper(content) {
Cow::Owned(format!(
"{prefix}{}{suffix}",
&parsed.text[local_start..local_end]
))
} else {
Cow::Borrowed(&content[local_start..local_end])
}
} else {
Cow::Borrowed(&content[local_start..local_end])
}
}
#[cfg(test)]
mod tests {
use super::*;
use crate::osc8::osc8_hyperlink;
use crate::terminal_wrappers::ParsedTerminalWrapper;
use crate::terminal_wrappers::parse_zero_width_terminal_wrapper;
use crate::terminal_wrappers::strip_zero_width_terminal_wrappers;
use itertools::Itertools as _;
use pretty_assertions::assert_eq;
use ratatui::style::Color;
@@ -965,6 +1013,85 @@ mod tests {
assert_eq!(concat_line(&out[0]), "");
}
#[test]
fn osc8_wrapped_span_wraps_by_visible_text() {
let url = "https://example.com/docs";
let line = Line::from(vec![osc8_hyperlink(url, "abcdefghij").cyan().underlined()]);
let out = word_wrap_line(&line, 5);
assert_eq!(out.len(), 2);
let first = concat_line(&out[0]);
let second = concat_line(&out[1]);
assert_eq!(strip_zero_width_terminal_wrappers(&first), "abcde");
assert_eq!(strip_zero_width_terminal_wrappers(&second), "fghij");
assert_eq!(
parse_zero_width_terminal_wrapper(&first).expect("first line should stay hyperlinked"),
ParsedTerminalWrapper {
prefix: "\u{1b}]8;;https://example.com/docs\u{7}",
text: "abcde",
suffix: "\u{1b}]8;;\u{7}",
}
);
assert_eq!(
parse_zero_width_terminal_wrapper(&second)
.expect("second line should stay hyperlinked"),
ParsedTerminalWrapper {
prefix: "\u{1b}]8;;https://example.com/docs\u{7}",
text: "fghij",
suffix: "\u{1b}]8;;\u{7}",
}
);
}
#[test]
fn osc8_wrapper_with_params_and_st_is_preserved_across_wraps() {
let line = Line::from(vec![
"x".into(),
Span::from("\u{1b}]8;id=abc;https://example.com\u{1b}\\docs\u{1b}]8;;\u{1b}\\")
.underlined(),
]);
let out = word_wrap_line(&line, 3);
let expected = vec![
Line::from(vec![
"x".into(),
Span::from("\u{1b}]8;id=abc;https://example.com\u{1b}\\do\u{1b}]8;;\u{1b}\\")
.underlined(),
]),
Line::from(vec![
Span::from("\u{1b}]8;id=abc;https://example.com\u{1b}\\cs\u{1b}]8;;\u{1b}\\")
.underlined(),
]),
];
assert_eq!(out, expected);
}
#[test]
fn mixed_ascii_and_osc8_wrapped_spans_preserve_ratatui_spans_across_wraps() {
let url = "https://example.com/docs";
let line = Line::from(vec![
"ab".into(),
osc8_hyperlink(url, "cdef").cyan().underlined(),
"gh".into(),
]);
let out = word_wrap_line(&line, 4);
let expected = vec![
Line::from(vec![
"ab".into(),
osc8_hyperlink(url, "cd").cyan().underlined(),
]),
Line::from(vec![
osc8_hyperlink(url, "ef").cyan().underlined(),
"gh".into(),
]),
];
assert_eq!(out, expected);
}
#[test]
fn leading_spaces_preserved_on_first_line() {
let line = Line::from(" hello");