Compare commits

...

2 Commits

Author SHA1 Message Date
starr-openai
03f6f04b75 Add plain-text TUI regression coverage 2026-04-01 21:36:28 -07:00
starr-openai
b03833ac71 Make TUI wrapping OSC8-aware 2026-04-01 20:43:26 -07:00
14 changed files with 1645 additions and 173 deletions

View File

@@ -1,12 +1,14 @@
use crate::terminal_wrappers;
use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
use ratatui::widgets::Paragraph;
use ratatui::widgets::Widget;
use std::borrow::Cow;
use unicode_width::UnicodeWidthChar;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::bottom_pane::popup_consts::standard_popup_hint_line;
@@ -427,7 +429,7 @@ impl RequestUserInputOverlay {
fn line_width(line: &Line<'_>) -> usize {
line.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.map(|span| terminal_wrappers::display_width(span.content.as_ref()))
.sum()
}
@@ -497,77 +499,68 @@ fn truncate_line_word_boundary_with_ellipsis(
if ellipsis_width >= max_width {
return Line::from(ellipsis);
}
let limit = max_width.saturating_sub(ellipsis_width);
#[derive(Clone, Copy)]
struct BreakPoint {
span_idx: usize,
byte_end: usize,
let limit = max_width.saturating_sub(ellipsis_width);
let mut visible_text = String::new();
let mut span_bounds: Vec<(std::ops::Range<usize>, Style)> =
Vec::with_capacity(line.spans.len());
let mut cursor = 0usize;
for span in &line.spans {
let span_visible_text = terminal_wrappers::strip(span.content.as_ref());
let start = cursor;
visible_text.push_str(&span_visible_text);
cursor += span_visible_text.len();
span_bounds.push((start..cursor, span.style));
}
// Track display width as we scan, along with the best "cut here" positions.
let mut used = 0usize;
let mut last_fit: Option<BreakPoint> = None;
let mut last_word_break: Option<BreakPoint> = None;
let mut overflowed = false;
'outer: for (span_idx, span) in line.spans.iter().enumerate() {
let text = span.content.as_ref();
for (byte_idx, ch) in text.char_indices() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if used.saturating_add(ch_width) > limit {
overflowed = true;
break 'outer;
}
used = used.saturating_add(ch_width);
let bp = BreakPoint {
span_idx,
byte_end: byte_idx + ch.len_utf8(),
};
last_fit = Some(bp);
if ch.is_whitespace() {
last_word_break = Some(bp);
}
let mut last_fit_end = 0usize;
let mut last_word_break_end = None;
for (byte_idx, grapheme) in visible_text.grapheme_indices(true) {
let grapheme_width = UnicodeWidthStr::width(grapheme);
if used.saturating_add(grapheme_width) > limit {
break;
}
used = used.saturating_add(grapheme_width);
last_fit_end = byte_idx + grapheme.len();
if grapheme.chars().all(char::is_whitespace) {
last_word_break_end = Some(last_fit_end);
}
}
// If we never overflowed, the original line already fits.
if !overflowed {
return line;
let mut chosen_end = last_word_break_end.unwrap_or(last_fit_end);
while chosen_end > 0 {
let Some((byte_idx, grapheme)) = visible_text[..chosen_end]
.grapheme_indices(true)
.next_back()
else {
break;
};
if !grapheme.chars().all(char::is_whitespace) {
break;
}
chosen_end = byte_idx;
}
// Prefer breaking on whitespace; otherwise fall back to the last fitting character.
let chosen_break = last_word_break.or(last_fit);
let Some(chosen_break) = chosen_break else {
if chosen_end == 0 {
return Line::from(ellipsis);
};
}
let line_style = line.style;
let mut spans_out: Vec<Span<'static>> = Vec::new();
for (idx, span) in line.spans.into_iter().enumerate() {
if idx < chosen_break.span_idx {
spans_out.push(span);
continue;
for (span, (visible_range, style)) in line.spans.into_iter().zip(span_bounds) {
if visible_range.start >= chosen_end {
break;
}
if idx == chosen_break.span_idx {
let text = span.content.into_owned();
let truncated = text[..chosen_break.byte_end].to_string();
if !truncated.is_empty() {
spans_out.push(Span::styled(truncated, span.style));
let local_end = chosen_end.min(visible_range.end) - visible_range.start;
if local_end > 0 {
let content =
terminal_wrappers::slice_visible_range(span.content.as_ref(), 0..local_end);
if !content.is_empty() {
spans_out.push(Span::styled(content, style));
}
}
break;
}
while let Some(last) = spans_out.last_mut() {
let trimmed = last
.content
.trim_end_matches(char::is_whitespace)
.to_string();
if trimmed.is_empty() {
spans_out.pop();
} else {
last.content = trimmed.into();
if visible_range.end >= chosen_end {
break;
}
}
@@ -580,3 +573,111 @@ fn truncate_line_word_boundary_with_ellipsis(
Line::from(spans_out).style(line_style)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use ratatui::style::Style;
use ratatui::style::Stylize;
fn concat_line(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}
fn osc8_bel_hyperlink(destination: &str, text: &str) -> String {
format!("\u{1b}]8;;{destination}\u{7}{text}\u{1b}]8;;\u{7}")
}
fn osc8_bel_hyperlink_with_params(params: &str, destination: &str, text: &str) -> String {
format!("\u{1b}]8;{params};{destination}\u{7}{text}\u{1b}]8;;\u{7}")
}
fn c1_osc8_hyperlink(destination: &str, text: &str) -> String {
format!("\u{9d}8;;{destination}\u{9c}{text}\u{9d}8;;\u{9c}")
}
fn csi_blue_text(text: &str) -> String {
format!("\u{1b}[34m{text}\u{1b}[0m")
}
fn c1_csi_blue_text(text: &str) -> String {
format!("\u{9b}34m{text}\u{9b}0m")
}
// The footer truncator predates OSC-8 support and should still prefer ordinary whitespace
// boundaries while preserving span style and the line-level style.
#[test]
fn truncate_line_word_boundary_with_ellipsis_preserves_plain_word_boundary_and_styles() {
let line = Line::from(vec!["abcdef ".green(), "ghij".magenta()]).style(Style::new().dim());
let truncated = truncate_line_word_boundary_with_ellipsis(line, 8);
assert_eq!(
truncated,
Line::from(vec!["abcdef".green(), "".green()]).style(Style::new().dim())
);
}
// Footer hints are ellipsized at word boundaries; this guards against counting invisible
// wrapper bytes or splitting a multi-codepoint emoji before appending the ellipsis.
#[test]
fn truncate_line_word_boundary_with_ellipsis_counts_only_visible_text_for_osc8_wrappers() {
let destination = "https://example.com/docs";
let line = Line::from(vec![osc8_bel_hyperlink(destination, "abcdef ghij").into()]);
let truncated = truncate_line_word_boundary_with_ellipsis(line, 7);
assert_eq!(
concat_line(&truncated),
format!("{}", osc8_bel_hyperlink(destination, "abcdef"))
);
}
#[test]
fn truncate_line_word_boundary_with_ellipsis_counts_only_visible_text_for_csi_wrappers() {
let line = Line::from(vec![csi_blue_text("abcdef ghij").into()]);
let truncated = truncate_line_word_boundary_with_ellipsis(line, 7);
assert_eq!(
concat_line(&truncated),
format!("{}", csi_blue_text("abcdef"))
);
}
#[test]
fn truncate_line_word_boundary_with_ellipsis_preserves_osc8_params_and_c1_wrappers() {
let docs = "https://example.com/docs";
let line = Line::from(vec![
osc8_bel_hyperlink_with_params("id=docs:target=_blank", docs, "abcdef ghij").into(),
" ".into(),
c1_osc8_hyperlink("https://example.com/logs", "tail").into(),
" ".into(),
c1_csi_blue_text("done").into(),
]);
let truncated = truncate_line_word_boundary_with_ellipsis(line, 7);
assert_eq!(
concat_line(&truncated),
format!(
"{}",
osc8_bel_hyperlink_with_params("id=docs:target=_blank", docs, "abcdef"),
)
);
}
#[test]
fn truncate_line_word_boundary_with_ellipsis_preserves_full_grapheme_clusters() {
let family = "👨\u{200d}👩\u{200d}👧\u{200d}👦";
let line = Line::from(format!("{family} docs"));
let truncated = truncate_line_word_boundary_with_ellipsis(line, 3);
assert_eq!(concat_line(&truncated), format!("{family}"));
}
}

View File

@@ -10,7 +10,7 @@ use ratatui::text::Span;
use ratatui::widgets::Block;
use ratatui::widgets::Widget;
use std::borrow::Cow;
use unicode_width::UnicodeWidthChar;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
use crate::key_hint::KeyBinding;
@@ -434,32 +434,43 @@ fn build_full_line(row: &GenericDisplayRow, desc_col: usize) -> Line<'static> {
if let Some(idxs) = row.match_indices.as_ref() {
let mut idx_iter = idxs.iter().peekable();
for (char_idx, ch) in row.name.chars().enumerate() {
let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
let next_width = used_width.saturating_add(ch_w);
let mut char_idx = 0usize;
for grapheme in row.name.graphemes(true) {
let grapheme_width = UnicodeWidthStr::width(grapheme);
let next_width = used_width.saturating_add(grapheme_width);
if next_width > name_limit {
truncated = true;
break;
}
used_width = next_width;
if idx_iter.peek().is_some_and(|next| **next == char_idx) {
let next_char_idx = char_idx + grapheme.chars().count();
let is_match = idx_iter
.peek()
.is_some_and(|next| (char_idx..next_char_idx).contains(next));
while idx_iter
.peek()
.is_some_and(|next| (char_idx..next_char_idx).contains(next))
{
idx_iter.next();
name_spans.push(ch.to_string().bold());
} else {
name_spans.push(ch.to_string().into());
}
if is_match {
name_spans.push(grapheme.to_string().bold());
} else {
name_spans.push(grapheme.to_string().into());
}
char_idx = next_char_idx;
}
} else {
for ch in row.name.chars() {
let ch_w = UnicodeWidthChar::width(ch).unwrap_or(0);
let next_width = used_width.saturating_add(ch_w);
for grapheme in row.name.graphemes(true) {
let grapheme_width = UnicodeWidthStr::width(grapheme);
let next_width = used_width.saturating_add(grapheme_width);
if next_width > name_limit {
truncated = true;
break;
}
used_width = next_width;
name_spans.push(ch.to_string().into());
name_spans.push(grapheme.to_string().into());
}
}
@@ -866,4 +877,25 @@ mod tests {
let two_col = wrap_two_column_row(&row, /*desc_col*/ 0, /*width*/ 1);
assert_eq!(two_col.len(), 0);
}
// Selection rows truncate the name column manually; this catches splitting a ZWJ emoji before
// the ellipsis when a description column is present.
#[test]
fn build_full_line_preserves_full_grapheme_clusters_before_ellipsis() {
let family = "👨\u{200d}👩\u{200d}👧\u{200d}👦";
let row = GenericDisplayRow {
name: format!("{family} docs"),
description: Some("description".to_string()),
..Default::default()
};
let line = build_full_line(&row, /*desc_col*/ 4);
let rendered = line
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert_eq!(rendered, format!("{family}… description"));
}
}

View File

@@ -24,6 +24,7 @@
use std::io;
use std::io::Write;
use crate::terminal_wrappers;
use crossterm::cursor::MoveTo;
use crossterm::queue;
use crossterm::style::Colors;
@@ -43,39 +44,10 @@ use ratatui::layout::Size;
use ratatui::style::Color;
use ratatui::style::Modifier;
use ratatui::widgets::WidgetRef;
use unicode_width::UnicodeWidthStr;
/// Returns the display width of a cell symbol, ignoring OSC escape sequences.
///
/// OSC sequences (e.g. OSC 8 hyperlinks: `\x1B]8;;URL\x07`) are terminal
/// control sequences that don't consume display columns. The standard
/// `UnicodeWidthStr::width()` method incorrectly counts the printable
/// characters inside OSC payloads (like `]`, `8`, `;`, and URL characters).
/// This function strips them first so that only visible characters contribute
/// to the width.
/// Returns the display width of a cell symbol, ignoring zero-width terminal wrappers.
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()
terminal_wrappers::display_width(s)
}
#[derive(Debug, Hash)]
@@ -703,6 +675,18 @@ mod tests {
use ratatui::layout::Rect;
use ratatui::style::Style;
fn osc8_st_hyperlink(destination: &str, text: &str) -> String {
format!("\u{1b}]8;;{destination}\u{1b}\\{text}\u{1b}]8;;\u{1b}\\")
}
fn osc8_bel_hyperlink_with_params(params: &str, destination: &str, text: &str) -> String {
format!("\u{1b}]8;{params};{destination}\u{7}{text}\u{1b}]8;;\u{7}")
}
fn c1_osc8_hyperlink(destination: &str, text: &str) -> String {
format!("\u{9d}8;;{destination}\u{9c}{text}\u{9d}8;;\u{9c}")
}
#[test]
fn diff_buffers_does_not_emit_clear_to_end_for_full_width_row() {
let area = Rect::new(0, 0, 3, 2);
@@ -748,4 +732,71 @@ mod tests {
"expected clear-to-end to start after the remaining wide char; commands: {commands:?}"
);
}
// Ratatui diffing uses this width helper to size terminal writes, so all escape bytes must
// be zero-width regardless of whether OSC is terminated by BEL or ST.
#[test]
fn display_width_counts_plain_ascii_and_wide_cells() {
assert_eq!(display_width("ab 中文"), 7);
}
#[test]
fn display_width_ignores_st_terminated_osc8_wrapper() {
assert_eq!(
display_width(&osc8_st_hyperlink("https://example.com/docs", "docs")),
4
);
}
#[test]
fn display_width_ignores_csi_sgr_escape_sequences() {
assert_eq!(display_width("\u{1b}[31mdocs\u{1b}[0m"), 4);
}
#[test]
fn display_width_ignores_osc8_params_and_replacement_open() {
let sample = format!(
"{}{}",
osc8_bel_hyperlink_with_params(
"id=docs:target=_blank",
"https://example.com/docs",
"ab"
),
osc8_bel_hyperlink_with_params("id=logs", "https://example.com/logs", "cd"),
);
assert_eq!(display_width(&sample), 4);
}
#[test]
fn display_width_ignores_c1_osc8_and_c1_csi_wrappers() {
let sample = format!(
"{} {}",
c1_osc8_hyperlink("https://example.com/docs", "docs"),
"\u{9b}31mtail\u{9b}0m",
);
assert_eq!(display_width(&sample), 9);
}
#[test]
fn display_width_ignores_unterminated_c1_osc8_payload_bytes() {
assert_eq!(display_width("\u{9d}8;;https://example.com/docs docs"), 0);
}
#[test]
fn display_width_handles_unterminated_escape_sequences_without_panicking() {
let samples = [
"\u{1b}]8;;https://example.com/docs\u{7}docs",
"\u{1b}]8;;https://example.com/docs",
"\u{1b}[31mdocs",
"docs\u{1b}[0",
"\u{9d}8;;https://example.com/docs\u{9c}docs",
"\u{9b}31mdocs",
];
for sample in samples {
let _ = display_width(sample);
}
}
}

View File

@@ -27,6 +27,7 @@ use crate::render::line_utils::push_owned_lines;
use crate::render::renderable::Renderable;
use crate::style::proposed_plan_style;
use crate::style::user_message_style;
use crate::terminal_wrappers;
#[cfg(test)]
use crate::test_support::PathBufExt;
use crate::text_formatting::format_and_truncate_tool_result;
@@ -1033,7 +1034,7 @@ fn with_border_internal(
.iter()
.map(|line| {
line.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.map(|span| terminal_wrappers::display_width(span.content.as_ref()))
.sum::<usize>()
})
.max()
@@ -1049,7 +1050,7 @@ fn with_border_internal(
for line in lines.into_iter() {
let used_width: usize = line
.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.map(|span| terminal_wrappers::display_width(span.content.as_ref()))
.sum();
let span_count = line.spans.len();
let mut spans: Vec<Span<'static>> = Vec::with_capacity(span_count + 4);
@@ -2935,6 +2936,29 @@ mod tests {
.expect("resource link content should serialize")
}
// Borders are padded from rendered cell width, not source byte length, so OSC-8 params and
// SGR escapes must not widen the box.
#[test]
fn with_border_sizes_padding_from_visible_text_not_escape_bytes() {
let docs = "\u{1b}]8;id=docs;https://example.com/docs\u{7}docs\u{1b}]8;;\u{7}";
let red = "\u{1b}[31mred\u{1b}[0m";
let lines = with_border(vec![Line::from(vec![
docs.to_string().into(),
" ".into(),
red.to_string().into(),
])]);
assert_eq!(
render_lines(&lines),
vec![
"╭──────────╮".to_string(),
format!("{docs} {red}"),
"╰──────────╯".to_string(),
]
);
}
#[test]
fn image_generation_call_renders_saved_path() {
let saved_path = "file:///tmp/generated-image.png".to_string();

View File

@@ -143,6 +143,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,10 @@
use crate::terminal_wrappers;
use ratatui::text::Line;
use ratatui::text::Span;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
pub(crate) fn line_width(line: &Line<'_>) -> usize {
line.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.map(|span| terminal_wrappers::display_width(span.content.as_ref()))
.sum()
}
@@ -23,7 +22,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 = terminal_wrappers::display_width(span.content.as_ref());
if span_width == 0 {
spans_out.push(span);
@@ -41,19 +40,9 @@ 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 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 {
break;
}
end_idx = idx + ch.len_utf8();
used += ch_width;
}
if end_idx > 0 {
spans_out.push(Span::styled(text[..end_idx].to_string(), style));
let text = terminal_wrappers::truncate_to_width(span.content.as_ref(), max_width - used);
if !text.is_empty() {
spans_out.push(Span::styled(text, style));
}
break;
@@ -98,3 +87,254 @@ pub(crate) fn truncate_line_with_ellipsis_if_overflow(
spans,
}
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use ratatui::layout::Alignment;
use ratatui::style::Style;
use ratatui::style::Stylize;
fn concat_line(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}
fn osc8_bel_hyperlink(destination: &str, text: &str) -> String {
format!("\u{1b}]8;;{destination}\u{7}{text}\u{1b}]8;;\u{7}")
}
fn osc8_st_hyperlink(destination: &str, text: &str) -> String {
format!("\u{1b}]8;;{destination}\u{1b}\\{text}\u{1b}]8;;\u{1b}\\")
}
fn osc8_bel_hyperlink_with_params(params: &str, destination: &str, text: &str) -> String {
format!("\u{1b}]8;{params};{destination}\u{7}{text}\u{1b}]8;;\u{7}")
}
fn osc8_bel_open_with_params(params: &str, destination: &str) -> String {
format!("\u{1b}]8;{params};{destination}\u{7}")
}
fn c1_osc8_hyperlink(destination: &str, text: &str) -> String {
format!("\u{9d}8;;{destination}\u{9c}{text}\u{9d}8;;\u{9c}")
}
fn csi_red_text(text: &str) -> String {
format!("\u{1b}[31m{text}\u{1b}[0m")
}
fn c1_csi_red_text(text: &str) -> String {
format!("\u{9b}31m{text}\u{9b}0m")
}
// Plain rows are still the dominant path; these checks pin the pre-wrapper sizing/truncation
// behavior so the escape parser can't regress ordinary styled text.
#[test]
fn line_width_counts_plain_ascii_and_wide_cells() {
let line = Line::from(vec!["ab".into(), " ".into(), "中文".into()]);
assert_eq!(line_width(&line), 7);
}
#[test]
fn truncate_line_to_width_preserves_plain_span_styles_and_line_metadata() {
let line = Line {
style: Style::new().dim(),
alignment: Some(Alignment::Right),
spans: vec!["ab".green(), "cdef".magenta()],
};
let truncated = truncate_line_to_width(line, 4);
assert_eq!(
truncated,
Line {
style: Style::new().dim(),
alignment: Some(Alignment::Right),
spans: vec!["ab".green(), "cd".magenta()],
}
);
}
#[test]
fn truncate_line_with_ellipsis_if_overflow_returns_original_plain_line_when_it_fits() {
let line = Line {
style: Style::new().dim(),
alignment: Some(Alignment::Center),
spans: vec!["ok".green(), " done".magenta()],
};
let truncated = truncate_line_with_ellipsis_if_overflow(line.clone(), 7);
assert_eq!(truncated, line);
}
// These rows are sliced for tight status/UI slots, so wrapper payload bytes must not count
// toward width and truncation must preserve the active link/color run around visible text.
#[test]
fn line_width_counts_only_visible_text_for_osc8_and_csi_wrappers() {
let line = Line::from(vec![
osc8_bel_hyperlink("https://example.com/docs", "docs").into(),
" ".into(),
csi_red_text("tail").into(),
]);
assert_eq!(line_width(&line), 9);
}
#[test]
fn line_width_counts_only_visible_text_for_st_terminated_osc8_wrappers() {
let line = Line::from(vec![
osc8_st_hyperlink("https://example.com/docs", "docs").into(),
]);
assert_eq!(line_width(&line), 4);
}
#[test]
fn line_width_counts_only_visible_text_for_osc8_params_and_c1_wrappers() {
let line = Line::from(vec![
osc8_bel_hyperlink_with_params(
"id=docs:target=_blank",
"https://example.com/docs",
"docs",
)
.into(),
" ".into(),
c1_osc8_hyperlink("https://example.com/logs", "log").into(),
" ".into(),
c1_csi_red_text("tail").into(),
]);
assert_eq!(line_width(&line), 13);
}
#[test]
fn line_width_ignores_unterminated_c1_osc8_payload_bytes() {
let line = Line::from("\u{9d}8;;https://example.com/docs docs");
assert_eq!(line_width(&line), 0);
}
#[test]
fn truncate_line_to_width_preserves_osc8_wrapper_around_partial_visible_text() {
let line = Line::from(vec![
"go ".into(),
osc8_bel_hyperlink("https://example.com/docs", "abcdef").into(),
]);
let truncated = truncate_line_to_width(line, 5);
assert_eq!(
concat_line(&truncated),
format!(
"go {}",
osc8_bel_hyperlink("https://example.com/docs", "ab")
)
);
}
#[test]
fn truncate_line_to_width_preserves_csi_wrapper_around_partial_visible_text() {
let line = Line::from(vec![csi_red_text("abcdef").into()]);
let truncated = truncate_line_to_width(line, 3);
assert_eq!(concat_line(&truncated), csi_red_text("abc"));
}
#[test]
fn truncate_line_to_width_preserves_full_grapheme_clusters() {
let family = "👨\u{200d}👩\u{200d}👧\u{200d}👦";
let line = Line::from(format!("{family} docs"));
let truncated = truncate_line_to_width(line, 2);
assert_eq!(concat_line(&truncated), family);
}
#[test]
fn truncate_line_to_width_preserves_adjacent_osc8_wrappers_as_distinct_runs() {
let docs = "https://example.com/docs";
let logs = "https://example.com/logs";
let line = Line::from(vec![
osc8_bel_hyperlink(docs, "ab").into(),
osc8_bel_hyperlink(logs, "cd").into(),
]);
let truncated = truncate_line_to_width(line, 3);
assert_eq!(
concat_line(&truncated),
format!(
"{}{}",
osc8_bel_hyperlink(docs, "ab"),
osc8_bel_hyperlink(logs, "c")
)
);
}
#[test]
fn truncate_line_to_width_preserves_osc8_id_params_and_nested_csi_wrappers() {
let docs = "https://example.com/docs";
let line = Line::from(vec![
format!(
"{}{}{}{}",
"\u{1b}[31m",
osc8_bel_open_with_params("id=docs:target=_blank", docs),
"abcdef",
"\u{1b}]8;;\u{7}\u{1b}[0m",
)
.into(),
]);
let truncated = truncate_line_to_width(line, 3);
assert_eq!(
concat_line(&truncated),
format!(
"\u{1b}[31m{}\u{1b}]8;;\u{7}\u{1b}[0m",
osc8_bel_open_with_params("id=docs:target=_blank", docs) + "abc"
),
);
}
#[test]
fn truncate_line_to_width_preserves_osc8_retarget_and_stray_close_state_transitions() {
let docs = "https://example.com/docs";
let logs = "https://example.com/logs";
let line = Line::from(vec![
format!(
"{}ab{}cd\u{1b}]8;;\u{7}\u{1b}]8;;\u{7}",
osc8_bel_open_with_params("id=docs", docs),
osc8_bel_open_with_params("id=logs", logs),
)
.into(),
]);
let truncated = truncate_line_to_width(line, 3);
assert_eq!(
concat_line(&truncated),
format!(
"{}ab\u{1b}]8;;\u{7}{}c\u{1b}]8;;\u{7}\u{1b}]8;;\u{7}",
osc8_bel_open_with_params("id=docs", docs),
osc8_bel_open_with_params("id=logs", logs),
),
);
}
#[test]
fn truncate_line_to_width_handles_unterminated_escape_sequences_without_panicking() {
let line = Line::from(vec![
"\u{1b}]8;;https://example.com/docs\u{7}docs".into(),
"\u{1b}[31mtail".into(),
]);
let _ = truncate_line_to_width(line, 4);
}
}

View File

@@ -1,4 +1,4 @@
use unicode_width::UnicodeWidthChar;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
/// A single visual row produced by RowBuilder.
@@ -190,13 +190,13 @@ pub fn take_prefix_by_width(text: &str, max_cols: usize) -> (String, &str, usize
}
let mut cols = 0usize;
let mut end_idx = 0usize;
for (i, ch) in text.char_indices() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if cols.saturating_add(ch_width) > max_cols {
for (i, grapheme) in text.grapheme_indices(true) {
let grapheme_width = UnicodeWidthStr::width(grapheme);
if cols.saturating_add(grapheme_width) > max_cols {
break;
}
cols += ch_width;
end_idx = i + ch.len_utf8();
cols += grapheme_width;
end_idx = i + grapheme.len();
if cols == max_cols {
break;
}
@@ -249,6 +249,20 @@ mod tests {
);
}
// Live streaming wraps incrementally, so prefix extraction must never split a ZWJ emoji and
// leave an invalid suffix for the next fragment.
#[test]
fn take_prefix_by_width_preserves_full_grapheme_clusters() {
let family = "👨\u{200d}👩\u{200d}👧\u{200d}👦";
let text = format!("{family} docs");
let (prefix, suffix, width) = take_prefix_by_width(&text, /*max_cols*/ 2);
assert_eq!(prefix, family);
assert_eq!(suffix, " docs");
assert_eq!(width, 2);
}
#[test]
fn fragmentation_invariance_long_token() {
let s = "ABCDEFGHIJKLMNOPQRSTUVWXYZ"; // 26 chars

View File

@@ -42,7 +42,8 @@ struct MarkdownStyles {
strikethrough: Style,
ordered_list_marker: Style,
unordered_list_marker: Style,
link: Style,
link_label: Style,
link_destination: Style,
blockquote: Style,
}
@@ -63,7 +64,8 @@ impl Default for MarkdownStyles {
strikethrough: Style::new().crossed_out(),
ordered_list_marker: Style::new().light_blue(),
unordered_list_marker: Style::new(),
link: Style::new().cyan().underlined(),
link_label: Style::new().underlined(),
link_destination: Style::new().cyan().underlined(),
blockquote: Style::new().green(),
}
}
@@ -129,6 +131,24 @@ fn should_render_link_destination(dest_url: &str) -> bool {
!is_local_path_like_link(dest_url)
}
fn osc8_hyperlink(destination: &str, text: &str) -> String {
if text.is_empty() {
return String::new();
}
let destination = sanitize_osc8_text(destination);
let text = sanitize_osc8_text(text);
if destination.is_empty() || text.is_empty() {
return text;
}
format!("\u{1b}]8;;{destination}\u{7}{text}\u{1b}]8;;\u{7}")
}
fn sanitize_osc8_text(text: &str) -> String {
text.chars().filter(|ch| !ch.is_control()).collect()
}
static COLON_LOCATION_SUFFIX_RE: LazyLock<Regex> =
LazyLock::new(
|| match Regex::new(r":\d+(?::\d+)?(?:[-]\d+(?::\d+)?)?$") {
@@ -570,6 +590,10 @@ where
self.indent_stack.pop();
}
fn current_inline_style(&self) -> Style {
self.inline_styles.last().copied().unwrap_or_default()
}
fn push_inline_style(&mut self, style: Style) {
let current = self.inline_styles.last().copied().unwrap_or_default();
let merged = current.patch(style);
@@ -582,13 +606,17 @@ where
fn push_link(&mut self, dest_url: String) {
let show_destination = should_render_link_destination(&dest_url);
let local_target_display = if is_local_path_like_link(&dest_url) {
render_local_link_target(&dest_url, self.cwd.as_deref())
} else {
None
};
if show_destination && local_target_display.is_none() {
self.push_inline_style(self.styles.link_label);
}
self.link = Some(LinkState {
show_destination,
local_target_display: if is_local_path_like_link(&dest_url) {
render_local_link_target(&dest_url, self.cwd.as_deref())
} else {
None
},
local_target_display,
destination: dest_url,
});
}
@@ -596,21 +624,25 @@ where
fn pop_link(&mut self) {
if let Some(link) = self.link.take() {
if link.show_destination {
self.push_span(" (".into());
self.push_span(Span::styled(link.destination, self.styles.link));
self.push_span(")".into());
if link.local_target_display.is_none() {
self.pop_inline_style();
}
let destination_style = self
.current_inline_style()
.patch(self.styles.link_destination);
self.push_span(Span::styled(" (", self.current_inline_style()));
self.push_span(Span::styled(
osc8_hyperlink(&link.destination, &link.destination),
destination_style,
));
self.push_span(Span::styled(")", self.current_inline_style()));
} else if let Some(local_target_display) = link.local_target_display {
if self.pending_marker_line {
self.push_line(Line::default());
}
// Local file links are rendered as code-like path text so the transcript shows the
// resolved target instead of arbitrary caller-provided label text.
let style = self
.inline_styles
.last()
.copied()
.unwrap_or_default()
.patch(self.styles.code);
let style = self.current_inline_style().patch(self.styles.code);
self.push_span(Span::styled(local_target_display, style));
self.line_ends_with_local_link_target = true;
}
@@ -674,7 +706,19 @@ where
self.pending_marker_line = false;
}
fn push_span(&mut self, span: Span<'static>) {
fn push_span(&mut self, mut span: Span<'static>) {
if let Some(link) = self
.link
.as_ref()
.filter(|link| link.show_destination && link.local_target_display.is_none())
&& !span.content.is_empty()
{
span = Span::styled(
osc8_hyperlink(&link.destination, span.content.as_ref()),
span.style,
);
}
if let Some(line) = self.current_line_content.as_mut() {
line.push_span(span);
} else {

View File

@@ -1,4 +1,5 @@
use pretty_assertions::assert_eq;
use ratatui::style::Style;
use ratatui::style::Stylize;
use ratatui::text::Line;
use ratatui::text::Span;
@@ -15,6 +16,22 @@ fn render_markdown_text_for_cwd(input: &str, cwd: &Path) -> Text<'static> {
render_markdown_text_with_width_and_cwd(input, /*width*/ None, Some(cwd))
}
fn osc8_bel_hyperlink(destination: &str, text: &str) -> String {
format!("\u{1b}]8;;{destination}\u{7}{text}\u{1b}]8;;\u{7}")
}
fn rendered_lines(text: &Text<'_>) -> Vec<String> {
text.lines
.iter()
.map(|line| {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
})
.collect()
}
#[test]
fn empty() {
assert_eq!(render_markdown_text(""), Text::default());
@@ -651,9 +668,15 @@ fn strong_emphasis() {
fn link() {
let text = render_markdown_text("[Link](https://example.com)");
let expected = Text::from(Line::from_iter([
"Link".into(),
Span::styled(
osc8_bel_hyperlink("https://example.com", "Link"),
Style::new().underlined(),
),
" (".into(),
"https://example.com".cyan().underlined(),
Span::styled(
osc8_bel_hyperlink("https://example.com", "https://example.com"),
Style::new().cyan().underlined(),
),
")".into(),
]));
assert_eq!(text, expected);
@@ -794,9 +817,18 @@ fn file_link_uses_target_path_for_hash_range() {
fn url_link_shows_destination() {
let text = render_markdown_text("[docs](https://example.com/docs)");
let expected = Text::from(Line::from_iter([
"docs".into(),
Span::styled(
osc8_bel_hyperlink("https://example.com/docs", "docs"),
Style::new().underlined(),
),
" (".into(),
"https://example.com/docs".cyan().underlined(),
Span::styled(
osc8_bel_hyperlink(
"https://example.com/docs",
"https://example.com/docs",
),
Style::new().cyan().underlined(),
),
")".into(),
]));
assert_eq!(text, expected);
@@ -1356,3 +1388,55 @@ fn code_block_preserves_trailing_blank_lines() {
"trailing blank line inside code fence was lost: {content:?}"
);
}
#[test]
// `pulldown-cmark` is not a transport for raw ESC/CSI bytes: it strips the ESC introducer before
// our wrapper layer can parse it. Raw escape chunking is covered in `wrapping.rs`; this test starts
// from normal Markdown link syntax and verifies the renderer's own OSC-8 output is re-sliced into
// independently closed chunks.
fn wrapped_markdown_preserves_renderer_emitted_osc8_wrappers_around_visible_chunks() {
let destination = "https://example.com/docs";
let text = render_markdown_text_with_width_and_cwd(
"[abcdef](https://example.com/docs)",
Some(3),
None,
);
assert_eq!(
rendered_lines(&text),
vec![
osc8_bel_hyperlink(destination, "ab"),
osc8_bel_hyperlink(destination, "cd"),
osc8_bel_hyperlink(destination, "ef"),
format!("({})", osc8_bel_hyperlink(destination, destination)),
]
);
}
#[test]
// Link destinations are wrapped with OSC-8 after any surrounding Markdown style is resolved, so
// nested emphasis must not drop the hyperlink wrapper or duplicate the visible URL.
fn markdown_link_destination_remains_clickable_inside_nested_styles() {
let destination = "https://example.com/docs";
let text = render_markdown_text_with_width_and_cwd(
"*[abcdef](https://example.com/docs)*",
/*width*/ None,
None,
);
let mut lines = text.lines.iter();
let line = lines.next().expect("expected one rendered line");
assert!(lines.next().is_none(), "expected one rendered line: {text:#?}");
assert_eq!(
rendered_lines(&text),
vec![format!(
"{} ({})",
osc8_bel_hyperlink(destination, "abcdef"),
osc8_bel_hyperlink(destination, destination),
)],
);
assert_eq!(line.spans[0].style, Style::new().italic().underlined());
assert_eq!(line.spans[2].style, Style::new().italic().cyan().underlined());
}

View File

@@ -1,13 +1,14 @@
---
source: tui/src/markdown_render_tests.rs
assertion_line: 1219
expression: rendered
---
# H1: Markdown Streaming Test
Intro paragraph with bold text, italic text, and inline code x=1.
Combined bold-italic both and escaped asterisks *literal*.
Auto-link: https://example.com (https://example.com) and reference link [ref][r1].
Link with title: hover me (https://example.com) and mailto mailto:test@example.com (mailto:test@example.com).
Auto-link: ]8;;https://example.comhttps://example.com]8;; (]8;;https://example.comhttps://example.com]8;;) and reference link [ref][r1].
Link with title: ]8;;https://example.comhover me]8;; (]8;;https://example.comhttps://example.com]8;;) and mailto ]8;;mailto:test@example.commailto:test@example.com]8;; (]8;;mailto:test@example.commailto:test@example.com]8;;).
Image: alt text
> Blockquote level 1
@@ -23,7 +24,7 @@ Image: alt text
1. Alt-numbered subitem
- [ ] Task: unchecked
- [x] Task: checked with link home (https://example.org)
- [x] Task: checked with link ]8;;https://example.orghome]8;; (]8;;https://example.orghttps://example.org]8;;)
———

View File

@@ -1,7 +1,7 @@
use crate::terminal_wrappers;
use ratatui::prelude::*;
use ratatui::style::Stylize;
use std::collections::BTreeSet;
use unicode_width::UnicodeWidthChar;
use unicode_width::UnicodeWidthStr;
#[derive(Debug, Clone)]
@@ -94,7 +94,7 @@ pub(crate) fn push_label(labels: &mut Vec<String>, seen: &mut BTreeSet<String>,
pub(crate) fn line_display_width(line: &Line<'static>) -> usize {
line.iter()
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
.map(|span| terminal_wrappers::display_width(span.content.as_ref()))
.sum()
}
@@ -109,7 +109,7 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
for span in line.spans {
let text = span.content.into_owned();
let style = span.style;
let span_width = UnicodeWidthStr::width(text.as_str());
let span_width = terminal_wrappers::display_width(text.as_str());
if span_width == 0 {
spans_out.push(Span::styled(text, style));
@@ -126,16 +126,7 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
continue;
}
let mut truncated = String::new();
for ch in text.chars() {
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
if used + ch_width > max_width {
break;
}
truncated.push(ch);
used += ch_width;
}
let truncated = terminal_wrappers::truncate_to_width(&text, max_width - used);
if !truncated.is_empty() {
spans_out.push(Span::styled(truncated, style));
}
@@ -145,3 +136,225 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
Line::from(spans_out)
}
#[cfg(test)]
mod tests {
use super::*;
use pretty_assertions::assert_eq;
use ratatui::style::Stylize;
fn concat_line(line: &Line<'_>) -> String {
line.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>()
}
fn osc8_bel_hyperlink(destination: &str, text: &str) -> String {
format!("\u{1b}]8;;{destination}\u{7}{text}\u{1b}]8;;\u{7}")
}
fn osc8_st_hyperlink(destination: &str, text: &str) -> String {
format!("\u{1b}]8;;{destination}\u{1b}\\{text}\u{1b}]8;;\u{1b}\\")
}
fn osc8_bel_hyperlink_with_params(params: &str, destination: &str, text: &str) -> String {
format!("\u{1b}]8;{params};{destination}\u{7}{text}\u{1b}]8;;\u{7}")
}
fn osc8_bel_open_with_params(params: &str, destination: &str) -> String {
format!("\u{1b}]8;{params};{destination}\u{7}")
}
fn c1_osc8_hyperlink(destination: &str, text: &str) -> String {
format!("\u{9d}8;;{destination}\u{9c}{text}\u{9d}8;;\u{9c}")
}
fn csi_red_text(text: &str) -> String {
format!("\u{1b}[31m{text}\u{1b}[0m")
}
fn c1_csi_red_text(text: &str) -> String {
format!("\u{9b}31m{text}\u{9b}0m")
}
// Status values are usually plain styled spans; this locks the pre-wrapper baseline for
// ordinary text so the escape-aware helper doesn't change non-link rendering.
#[test]
fn line_display_width_counts_plain_ascii_and_wide_cells() {
let line = Line::from(vec!["ab".into(), " ".into(), "中文".into()]);
assert_eq!(line_display_width(&line), 7);
}
#[test]
fn truncate_line_to_width_preserves_plain_span_styles() {
let line = Line::from(vec!["ab".green(), "cdef".magenta()]);
let truncated = truncate_line_to_width(line, 4);
assert_eq!(truncated, Line::from(vec!["ab".green(), "cd".magenta()]));
}
// Status lines reuse the same truncation contract as generic rows: preserve wrapper state,
// but size and cut by visible grapheme width only.
#[test]
fn line_display_width_counts_only_visible_text_for_osc8_and_csi_wrappers() {
let line = Line::from(vec![
osc8_bel_hyperlink("https://example.com/docs", "docs").into(),
" ".into(),
csi_red_text("tail").into(),
]);
assert_eq!(line_display_width(&line), 9);
}
#[test]
fn line_display_width_counts_only_visible_text_for_st_terminated_osc8_wrappers() {
let line = Line::from(vec![
osc8_st_hyperlink("https://example.com/docs", "docs").into(),
]);
assert_eq!(line_display_width(&line), 4);
}
#[test]
fn line_display_width_counts_only_visible_text_for_osc8_params_and_c1_wrappers() {
let line = Line::from(vec![
osc8_bel_hyperlink_with_params(
"id=docs:target=_blank",
"https://example.com/docs",
"docs",
)
.into(),
" ".into(),
c1_osc8_hyperlink("https://example.com/logs", "log").into(),
" ".into(),
c1_csi_red_text("tail").into(),
]);
assert_eq!(line_display_width(&line), 13);
}
#[test]
fn line_display_width_ignores_unterminated_c1_osc8_payload_bytes() {
let line = Line::from("\u{9d}8;;https://example.com/docs docs");
assert_eq!(line_display_width(&line), 0);
}
#[test]
fn truncate_line_to_width_preserves_osc8_wrapper_around_partial_visible_text() {
let line = Line::from(vec![
"go ".into(),
osc8_bel_hyperlink("https://example.com/docs", "abcdef").into(),
]);
let truncated = truncate_line_to_width(line, 5);
assert_eq!(
concat_line(&truncated),
format!(
"go {}",
osc8_bel_hyperlink("https://example.com/docs", "ab")
)
);
}
#[test]
fn truncate_line_to_width_preserves_csi_wrapper_around_partial_visible_text() {
let line = Line::from(vec![csi_red_text("abcdef").into()]);
let truncated = truncate_line_to_width(line, 3);
assert_eq!(concat_line(&truncated), csi_red_text("abc"));
}
#[test]
fn truncate_line_to_width_preserves_full_grapheme_clusters() {
let family = "👨\u{200d}👩\u{200d}👧\u{200d}👦";
let line = Line::from(format!("{family} docs"));
let truncated = truncate_line_to_width(line, 2);
assert_eq!(concat_line(&truncated), family);
}
#[test]
fn truncate_line_to_width_preserves_adjacent_osc8_wrappers_as_distinct_runs() {
let docs = "https://example.com/docs";
let logs = "https://example.com/logs";
let line = Line::from(vec![
osc8_bel_hyperlink(docs, "ab").into(),
osc8_bel_hyperlink(logs, "cd").into(),
]);
let truncated = truncate_line_to_width(line, 3);
assert_eq!(
concat_line(&truncated),
format!(
"{}{}",
osc8_bel_hyperlink(docs, "ab"),
osc8_bel_hyperlink(logs, "c")
)
);
}
#[test]
fn truncate_line_to_width_preserves_osc8_id_params_and_nested_csi_wrappers() {
let docs = "https://example.com/docs";
let line = Line::from(vec![
format!(
"\u{1b}[31m{}abcdef\u{1b}]8;;\u{7}\u{1b}[0m",
osc8_bel_open_with_params("id=docs:target=_blank", docs),
)
.into(),
]);
let truncated = truncate_line_to_width(line, 3);
assert_eq!(
concat_line(&truncated),
format!(
"\u{1b}[31m{}abc\u{1b}]8;;\u{7}\u{1b}[0m",
osc8_bel_open_with_params("id=docs:target=_blank", docs),
),
);
}
#[test]
fn truncate_line_to_width_preserves_osc8_retarget_and_stray_close_state_transitions() {
let docs = "https://example.com/docs";
let logs = "https://example.com/logs";
let line = Line::from(vec![
format!(
"{}ab{}cd\u{1b}]8;;\u{7}\u{1b}]8;;\u{7}",
osc8_bel_open_with_params("id=docs", docs),
osc8_bel_open_with_params("id=logs", logs),
)
.into(),
]);
let truncated = truncate_line_to_width(line, 3);
assert_eq!(
concat_line(&truncated),
format!(
"{}ab\u{1b}]8;;\u{7}{}c\u{1b}]8;;\u{7}\u{1b}]8;;\u{7}",
osc8_bel_open_with_params("id=docs", docs),
osc8_bel_open_with_params("id=logs", logs),
),
);
}
#[test]
fn truncate_line_to_width_handles_unterminated_escape_sequences_without_panicking() {
let line = Line::from(vec![
"\u{1b}]8;;https://example.com/docs\u{7}docs".into(),
"\u{1b}[31mtail".into(),
]);
let _ = truncate_line_to_width(line, 4);
}
}

View File

@@ -24,6 +24,7 @@ use crate::key_hint;
use crate::line_truncation::truncate_line_with_ellipsis_if_overflow;
use crate::render::renderable::Renderable;
use crate::shimmer::shimmer_spans;
use crate::terminal_wrappers;
use crate::text_formatting::capitalize_first;
use crate::tui::FrameRequester;
use crate::wrapping::RtOptions;
@@ -218,7 +219,8 @@ impl StatusIndicatorWidget {
if let Some(last) = out.last_mut()
&& let Some(span) = last.spans.last_mut()
{
let trimmed: String = span.content.as_ref().chars().take(max_base_len).collect();
let trimmed =
terminal_wrappers::truncate_to_width(span.content.as_ref(), max_base_len);
*span = format!("{trimmed}").dim();
}
}
@@ -428,6 +430,35 @@ mod tests {
);
}
// Status details are trimmed after wrapping; this keeps a final emoji grapheme intact before
// the overflow ellipsis.
#[test]
fn details_overflow_preserves_full_grapheme_clusters_before_ellipsis() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();
let tx = AppEventSender::new(tx_raw);
let mut w = StatusIndicatorWidget::new(
tx,
crate::tui::FrameRequester::test_dummy(),
/*animations_enabled*/ true,
);
let family = "👨\u{200d}👩\u{200d}👧\u{200d}👦";
w.update_details(
Some(format!("{family} docs")),
StatusDetailsCapitalization::Preserve,
/*max_lines*/ 1,
);
let lines = w.wrapped_details_lines(/*width*/ 7);
assert_eq!(lines.len(), 1);
let rendered = lines[0]
.spans
.iter()
.map(|span| span.content.as_ref())
.collect::<String>();
assert_eq!(rendered, format!("{DETAILS_PREFIX}{family}"));
}
#[test]
fn details_args_can_disable_capitalization_and_limit_lines() {
let (tx_raw, _rx) = unbounded_channel::<AppEvent>();

View File

@@ -0,0 +1,455 @@
use std::ops::Range;
use unicode_segmentation::UnicodeSegmentation;
use unicode_width::UnicodeWidthStr;
/// Measure display width while treating terminal control sequences as zero-width wrappers.
pub(crate) fn display_width(text: &str) -> usize {
let mut width = 0usize;
let mut parser = TokenParser::new(text);
while let Some(token) = parser.next_token() {
if let Token::Visible(grapheme) = token {
width = width.saturating_add(UnicodeWidthStr::width(grapheme));
}
}
width
}
/// Strip terminal control sequences, keeping only visible text.
pub(crate) fn strip(text: &str) -> String {
if !contains_control_intro(text) {
return text.to_string();
}
let mut visible = String::new();
let mut parser = TokenParser::new(text);
while let Some(token) = parser.next_token() {
if let Token::Visible(grapheme) = token {
visible.push_str(grapheme);
}
}
visible
}
/// Truncate by visible grapheme width while preserving wrapper open/close semantics.
pub(crate) fn truncate_to_width(text: &str, max_width: usize) -> String {
if max_width == 0 || text.is_empty() {
return String::new();
}
let mut out = String::new();
let mut wrappers = ActiveWrappers::default();
let mut used_width = 0usize;
let mut overflowed = false;
let mut parser = TokenParser::new(text);
while let Some(token) = parser.next_token() {
match token {
Token::Control(control) => {
if overflowed {
wrappers.consume_trailing_close_control(&control, &mut out);
} else {
wrappers.consume_control(&control, Some(&mut out));
}
}
Token::Visible(grapheme) => {
if overflowed {
continue;
}
let grapheme_width = UnicodeWidthStr::width(grapheme);
if used_width.saturating_add(grapheme_width) > max_width {
overflowed = true;
continue;
}
out.push_str(grapheme);
used_width = used_width.saturating_add(grapheme_width);
}
}
}
wrappers.append_closers(&mut out);
out
}
/// Slice a visible-text byte range out of `text`, rewrapping any active zero-width wrappers.
pub(crate) fn slice_visible_range(text: &str, visible_range: Range<usize>) -> String {
if visible_range.start >= visible_range.end || text.is_empty() {
return String::new();
}
let mut out = String::new();
let mut wrappers = ActiveWrappers::default();
let mut visible_cursor = 0usize;
let mut started = false;
let mut parser = TokenParser::new(text);
while let Some(token) = parser.next_token() {
match token {
Token::Control(control) => {
if visible_cursor < visible_range.start {
wrappers.consume_control(&control, None);
} else if visible_cursor < visible_range.end {
if started {
wrappers.consume_control(&control, Some(&mut out));
} else {
wrappers.consume_control(&control, None);
}
} else if started && wrappers.consume_trailing_close_control(&control, &mut out) {
continue;
} else {
break;
}
}
Token::Visible(grapheme) => {
let next_visible_cursor = visible_cursor.saturating_add(grapheme.len());
if next_visible_cursor <= visible_range.start {
visible_cursor = next_visible_cursor;
continue;
}
if visible_cursor >= visible_range.end {
break;
}
if !started {
wrappers.append_openers(&mut out);
started = true;
}
out.push_str(grapheme);
visible_cursor = next_visible_cursor;
}
}
}
if started {
wrappers.append_closers(&mut out);
}
out
}
#[derive(Debug)]
struct ParsedControl<'a> {
raw: &'a str,
kind: ControlKind,
closer: Option<String>,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum ControlKind {
Osc8Open,
Osc8Close,
SgrOpen,
SgrClose,
Other,
}
#[derive(Debug, Clone)]
struct ActiveWrapper {
kind: ActiveWrapperKind,
opener: String,
closer: String,
}
#[derive(Debug, Clone, Copy, Eq, PartialEq)]
enum ActiveWrapperKind {
Osc8,
Sgr,
}
#[derive(Debug, Default)]
struct ActiveWrappers {
stack: Vec<ActiveWrapper>,
}
impl ActiveWrappers {
fn append_openers(&self, out: &mut String) {
for wrapper in &self.stack {
out.push_str(&wrapper.opener);
}
}
fn append_closers(&self, out: &mut String) {
for wrapper in self.stack.iter().rev() {
out.push_str(&wrapper.closer);
}
}
fn consume_trailing_close_control(
&mut self,
control: &ParsedControl<'_>,
out: &mut String,
) -> bool {
match control.kind {
ControlKind::Osc8Close | ControlKind::SgrClose => {
self.consume_control(control, Some(out));
true
}
ControlKind::Osc8Open | ControlKind::SgrOpen | ControlKind::Other => false,
}
}
fn consume_control(&mut self, control: &ParsedControl<'_>, mut out: Option<&mut String>) {
match control.kind {
ControlKind::Osc8Open => {
if let Some(wrapper_index) = self
.stack
.iter()
.rposition(|wrapper| wrapper.kind == ActiveWrapperKind::Osc8)
{
let prior = self.stack.remove(wrapper_index);
if let Some(out) = out.as_mut() {
out.push_str(&prior.closer);
}
}
if let Some(out) = out.as_mut() {
out.push_str(control.raw);
}
if let Some(closer) = control.closer.clone() {
self.stack.push(ActiveWrapper {
kind: ActiveWrapperKind::Osc8,
opener: control.raw.to_string(),
closer,
});
}
}
ControlKind::Osc8Close => {
if let Some(out) = out.as_mut() {
out.push_str(control.raw);
}
if let Some(wrapper_index) = self
.stack
.iter()
.rposition(|wrapper| wrapper.kind == ActiveWrapperKind::Osc8)
{
self.stack.remove(wrapper_index);
}
}
ControlKind::SgrOpen => {
if let Some(out) = out.as_mut() {
out.push_str(control.raw);
}
if let Some(closer) = control.closer.clone() {
self.stack.push(ActiveWrapper {
kind: ActiveWrapperKind::Sgr,
opener: control.raw.to_string(),
closer,
});
}
}
ControlKind::SgrClose => {
if let Some(out) = out.as_mut() {
out.push_str(control.raw);
}
self.stack
.retain(|wrapper| wrapper.kind != ActiveWrapperKind::Sgr);
}
ControlKind::Other => {
if let Some(out) = out.as_mut() {
out.push_str(control.raw);
}
}
}
}
}
enum Token<'a> {
Control(ParsedControl<'a>),
Visible(&'a str),
}
struct TokenParser<'a> {
text: &'a str,
position: usize,
}
impl<'a> TokenParser<'a> {
fn new(text: &'a str) -> Self {
Self { text, position: 0 }
}
fn next_token(&mut self) -> Option<Token<'a>> {
if self.position >= self.text.len() {
return None;
}
if let Some((control, next_position)) = parse_control(self.text, self.position) {
self.position = next_position;
return Some(Token::Control(control));
}
let grapheme = self.text[self.position..].graphemes(true).next()?;
self.position += grapheme.len();
Some(Token::Visible(grapheme))
}
}
fn parse_control(text: &str, position: usize) -> Option<(ParsedControl<'_>, usize)> {
let tail = text.get(position..)?;
if tail.starts_with("\u{1b}]") {
return Some(parse_osc_control(text, position, "\u{1b}]", "\u{1b}\\"));
}
if tail.starts_with("\u{9d}") {
return Some(parse_osc_control(text, position, "\u{9d}", "\u{9c}"));
}
if tail.starts_with("\u{1b}[") {
return Some(parse_csi_control(text, position, "\u{1b}[", "\u{1b}[0m"));
}
if tail.starts_with("\u{9b}") {
return Some(parse_csi_control(text, position, "\u{9b}", "\u{9b}0m"));
}
if tail.starts_with("\u{1b}\\") {
return Some((
ParsedControl {
raw: &text[position..position + 2],
kind: ControlKind::Other,
closer: None,
},
position + 2,
));
}
if tail.starts_with("\u{9c}") {
return Some((
ParsedControl {
raw: &text[position..position + "\u{9c}".len()],
kind: ControlKind::Other,
closer: None,
},
position + "\u{9c}".len(),
));
}
if tail.starts_with("\u{1b}") {
return Some((
ParsedControl {
raw: &text[position..position + 1],
kind: ControlKind::Other,
closer: None,
},
position + 1,
));
}
None
}
fn parse_osc_control<'a>(
text: &'a str,
position: usize,
introducer: &str,
default_terminator: &str,
) -> (ParsedControl<'a>, usize) {
let payload_start = position + introducer.len();
let mut scan_position = payload_start;
let mut payload_end = text.len();
let mut sequence_end = text.len();
let mut terminator = default_terminator;
while scan_position < text.len() {
let tail = &text[scan_position..];
if tail.starts_with('\u{7}') {
payload_end = scan_position;
sequence_end = scan_position + '\u{7}'.len_utf8();
terminator = "\u{7}";
break;
}
if tail.starts_with("\u{9c}") {
payload_end = scan_position;
sequence_end = scan_position + "\u{9c}".len();
terminator = "\u{9c}";
break;
}
if tail.starts_with("\u{1b}\\") {
payload_end = scan_position;
sequence_end = scan_position + 2;
terminator = "\u{1b}\\";
break;
}
let Some(ch) = tail.chars().next() else {
break;
};
scan_position += ch.len_utf8();
}
let raw = &text[position..sequence_end];
let payload = &text[payload_start..payload_end];
let (kind, closer) = classify_osc_control(payload, introducer, terminator);
(ParsedControl { raw, kind, closer }, sequence_end)
}
fn classify_osc_control(
payload: &str,
introducer: &str,
terminator: &str,
) -> (ControlKind, Option<String>) {
let Some(rest) = payload.strip_prefix("8;") else {
return (ControlKind::Other, None);
};
let Some((_, destination)) = rest.split_once(';') else {
return (ControlKind::Other, None);
};
if destination.is_empty() {
(ControlKind::Osc8Close, None)
} else {
(
ControlKind::Osc8Open,
Some(format!("{introducer}8;;{terminator}")),
)
}
}
fn parse_csi_control<'a>(
text: &'a str,
position: usize,
introducer: &str,
closer: &str,
) -> (ParsedControl<'a>, usize) {
let payload_start = position + introducer.len();
let mut scan_position = payload_start;
let mut payload_end = text.len();
let mut sequence_end = text.len();
let mut final_char = None;
while scan_position < text.len() {
let Some(ch) = text[scan_position..].chars().next() else {
break;
};
if ('\u{40}'..='\u{7e}').contains(&ch) {
payload_end = scan_position;
sequence_end = scan_position + ch.len_utf8();
final_char = Some(ch);
break;
}
scan_position += ch.len_utf8();
}
let raw = &text[position..sequence_end];
let kind = if final_char == Some('m') {
let payload = &text[payload_start..payload_end];
if is_sgr_reset(payload) {
ControlKind::SgrClose
} else {
ControlKind::SgrOpen
}
} else {
ControlKind::Other
};
let closer = (kind == ControlKind::SgrOpen).then(|| closer.to_string());
(ParsedControl { raw, kind, closer }, sequence_end)
}
fn is_sgr_reset(payload: &str) -> bool {
payload
.split(';')
.all(|param| param.is_empty() || param == "0")
}
fn contains_control_intro(text: &str) -> bool {
text.contains('\u{1b}')
|| text.contains('\u{9b}')
|| text.contains('\u{9c}')
|| text.contains('\u{9d}')
}

View File

@@ -26,6 +26,7 @@
//! negatives let a URL get split. The heuristic is intentionally
//! conservative: file paths like `src/main.rs` are not matched.
use crate::terminal_wrappers;
use ratatui::text::Line;
use ratatui::text::Span;
use std::borrow::Cow;
@@ -180,7 +181,7 @@ pub(crate) fn line_contains_url_like(line: &Line<'_>) -> bool {
let text: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.map(|span| terminal_wrappers::strip(span.content.as_ref()))
.collect();
text_contains_url_like(&text)
}
@@ -194,7 +195,7 @@ pub(crate) fn line_has_mixed_url_and_non_url_tokens(line: &Line<'_>) -> bool {
let text: String = line
.spans
.iter()
.map(|span| span.content.as_ref())
.map(|span| terminal_wrappers::strip(span.content.as_ref()))
.collect();
text_has_mixed_url_and_non_url_tokens(&text)
}
@@ -644,10 +645,10 @@ where
let mut span_bounds = Vec::new();
let mut acc = 0usize;
for s in &line.spans {
let text = s.content.as_ref();
let visible_text = terminal_wrappers::strip(s.content.as_ref());
let start = acc;
flat.push_str(text);
acc += text.len();
flat.push_str(&visible_text);
acc += visible_text.len();
span_bounds.push((start..acc, s.style));
}
@@ -862,11 +863,8 @@ fn slice_line_spans<'a>(
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];
acc.push(Span {
style: *style,
content: std::borrow::Cow::Borrowed(slice),
});
let slice = terminal_wrappers::slice_visible_range(content, local_start..local_end);
acc.push(Span::styled(slice, *style));
}
if e >= end_byte {
break;
@@ -895,6 +893,34 @@ mod tests {
.collect::<String>()
}
fn osc8_bel_hyperlink(destination: &str, text: &str) -> String {
format!("\u{1b}]8;;{destination}\u{7}{text}\u{1b}]8;;\u{7}")
}
fn osc8_st_hyperlink(destination: &str, text: &str) -> String {
format!("\u{1b}]8;;{destination}\u{1b}\\{text}\u{1b}]8;;\u{1b}\\")
}
fn osc8_bel_hyperlink_with_params(params: &str, destination: &str, text: &str) -> String {
format!("\u{1b}]8;{params};{destination}\u{7}{text}\u{1b}]8;;\u{7}")
}
fn osc8_bel_open_with_params(params: &str, destination: &str) -> String {
format!("\u{1b}]8;{params};{destination}\u{7}")
}
fn c1_osc8_hyperlink(destination: &str, text: &str) -> String {
format!("\u{9d}8;;{destination}\u{9c}{text}\u{9d}8;;\u{9c}")
}
fn csi_green_text(text: &str) -> String {
format!("\u{1b}[32m{text}\u{1b}[0m")
}
fn c1_csi_green_text(text: &str) -> String {
format!("\u{9b}32m{text}\u{9b}0m")
}
#[test]
fn trivial_unstyled_no_indents_wide_width() {
let line = Line::from("hello");
@@ -1404,4 +1430,159 @@ them."#
assert_eq!(rebuilt, text);
assert!(ranges.len() > 1, "expected wrapped ranges, got: {ranges:?}");
}
// Wrapping must close/reopen each visual fragment so a hyperlink/color run never leaks into
// the next line and OSC-8 params survive chunk boundaries.
#[test]
fn adaptive_wrap_line_preserves_osc8_wrappers_across_wrapped_chunks() {
let destination = "https://example.com/docs";
let line = Line::from(vec![osc8_bel_hyperlink(destination, "abcdef").into()]);
let out = adaptive_wrap_line(&line, RtOptions::new(/*width*/ 4));
assert_eq!(
out.iter().map(concat_line).collect::<Vec<_>>(),
vec![
osc8_bel_hyperlink(destination, "abcd"),
osc8_bel_hyperlink(destination, "ef"),
]
);
}
#[test]
fn adaptive_wrap_line_preserves_csi_wrappers_across_wrapped_chunks() {
let line = Line::from(vec![csi_green_text("abcdef").into()]);
let out = adaptive_wrap_line(&line, RtOptions::new(/*width*/ 4));
assert_eq!(
out.iter().map(concat_line).collect::<Vec<_>>(),
vec![csi_green_text("abcd"), csi_green_text("ef")]
);
}
#[test]
fn adaptive_wrap_line_preserves_st_terminated_osc8_wrappers_across_wrapped_chunks() {
let destination = "https://example.com/docs";
let line = Line::from(vec![osc8_st_hyperlink(destination, "abcdef").into()]);
let out = adaptive_wrap_line(&line, RtOptions::new(/*width*/ 4));
assert_eq!(
out.iter().map(concat_line).collect::<Vec<_>>(),
vec![
osc8_st_hyperlink(destination, "abcd"),
osc8_st_hyperlink(destination, "ef"),
]
);
}
#[test]
fn adaptive_wrap_line_preserves_osc8_id_params_and_nested_csi_wrappers_across_chunks() {
let destination = "https://example.com/docs";
let line = Line::from(vec![
format!(
"\u{1b}[32m{}abcdef\u{1b}]8;;\u{7}\u{1b}[0m",
osc8_bel_open_with_params("id=docs:target=_blank", destination),
)
.into(),
]);
let out = adaptive_wrap_line(&line, RtOptions::new(/*width*/ 4));
assert_eq!(
out.iter().map(concat_line).collect::<Vec<_>>(),
vec![
format!(
"\u{1b}[32m{}abcd\u{1b}]8;;\u{7}\u{1b}[0m",
osc8_bel_open_with_params("id=docs:target=_blank", destination),
),
format!(
"\u{1b}[32m{}ef\u{1b}]8;;\u{7}\u{1b}[0m",
osc8_bel_open_with_params("id=docs:target=_blank", destination),
),
]
);
}
#[test]
fn adaptive_wrap_line_preserves_adjacent_osc8_wrappers_as_distinct_runs() {
let docs = "https://example.com/docs";
let logs = "https://example.com/logs";
let line = Line::from(vec![
osc8_bel_hyperlink(docs, "ab").into(),
osc8_bel_hyperlink(logs, "cd").into(),
]);
let out = adaptive_wrap_line(&line, RtOptions::new(/*width*/ 2));
assert_eq!(
out.iter().map(concat_line).collect::<Vec<_>>(),
vec![
osc8_bel_hyperlink(docs, "ab"),
osc8_bel_hyperlink(logs, "cd"),
]
);
}
#[test]
fn adaptive_wrap_line_preserves_c1_osc8_and_c1_csi_wrappers_across_chunks() {
let destination = "https://example.com/docs";
let line = Line::from(vec![
c1_osc8_hyperlink(destination, "abcdef").into(),
c1_csi_green_text("ghijkl").into(),
]);
let out = adaptive_wrap_line(&line, RtOptions::new(/*width*/ 4));
assert_eq!(
out.iter().map(concat_line).collect::<Vec<_>>(),
vec![
c1_osc8_hyperlink(destination, "abcd"),
format!(
"{}{}",
c1_osc8_hyperlink(destination, "ef"),
c1_csi_green_text("gh")
),
c1_csi_green_text("ijkl"),
]
);
}
#[test]
fn adaptive_wrap_line_preserves_osc8_retarget_and_stray_close_state_transitions() {
let docs = "https://example.com/docs";
let logs = "https://example.com/logs";
let line = Line::from(vec![
format!(
"{}ab{}cd\u{1b}]8;;\u{7}\u{1b}]8;;\u{7}",
osc8_bel_open_with_params("id=docs", docs),
osc8_bel_open_with_params("id=logs", logs),
)
.into(),
]);
let out = adaptive_wrap_line(&line, RtOptions::new(/*width*/ 2));
assert_eq!(
out.iter().map(concat_line).collect::<Vec<_>>(),
vec![
osc8_bel_hyperlink_with_params("id=docs", docs, "ab"),
format!(
"{}cd\u{1b}]8;;\u{7}\u{1b}]8;;\u{7}",
osc8_bel_open_with_params("id=logs", logs),
),
]
);
}
#[test]
fn adaptive_wrap_line_handles_unterminated_escape_sequences_without_panicking() {
let line = Line::from(vec![
"\u{1b}]8;;https://example.com/docs\u{7}abcdef".into(),
"\u{1b}[32mghij".into(),
]);
let _ = adaptive_wrap_line(&line, RtOptions::new(/*width*/ 4));
}
}