mirror of
https://github.com/openai/codex.git
synced 2026-04-04 22:41:48 +03:00
Compare commits
2 Commits
main
...
starr/osc8
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
03f6f04b75 | ||
|
|
b03833ac71 |
@@ -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}…"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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"));
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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();
|
||||
|
||||
@@ -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;
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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());
|
||||
}
|
||||
|
||||
@@ -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;;)
|
||||
|
||||
———
|
||||
|
||||
|
||||
@@ -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);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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>();
|
||||
|
||||
455
codex-rs/tui/src/terminal_wrappers.rs
Normal file
455
codex-rs/tui/src/terminal_wrappers.rs
Normal 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}')
|
||||
}
|
||||
@@ -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));
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user