mirror of
https://github.com/openai/codex.git
synced 2026-04-14 11:31:42 +03:00
Compare commits
4 Commits
dev/shaqay
...
starr/tui-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
74ec45df64 | ||
|
|
aa0ab63b14 | ||
|
|
2a9ce23940 | ||
|
|
cca76f843e |
@@ -110,6 +110,7 @@ mod model_migration;
|
||||
mod multi_agents;
|
||||
mod notifications;
|
||||
pub mod onboarding;
|
||||
mod osc8;
|
||||
mod oss_selection;
|
||||
mod pager_overlay;
|
||||
pub mod public_widgets;
|
||||
|
||||
@@ -3,9 +3,13 @@ use ratatui::text::Span;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use crate::osc8::parse_osc8_hyperlink;
|
||||
use crate::osc8::strip_osc8_hyperlinks;
|
||||
|
||||
pub(crate) fn line_width(line: &Line<'_>) -> usize {
|
||||
line.iter()
|
||||
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
|
||||
.map(|span| visible_width(span.content.as_ref()))
|
||||
.sum()
|
||||
}
|
||||
|
||||
@@ -23,7 +27,7 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
|
||||
let mut spans_out: Vec<Span<'static>> = Vec::with_capacity(spans.len());
|
||||
|
||||
for span in spans {
|
||||
let span_width = UnicodeWidthStr::width(span.content.as_ref());
|
||||
let span_width = visible_width(span.content.as_ref());
|
||||
|
||||
if span_width == 0 {
|
||||
spans_out.push(span);
|
||||
@@ -42,8 +46,11 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
|
||||
|
||||
let style = span.style;
|
||||
let text = span.content.as_ref();
|
||||
let parsed_link = parse_osc8_hyperlink(text);
|
||||
let visible_text =
|
||||
parsed_link.map_or_else(|| strip_osc8_hyperlinks(text), |link| link.text.to_string());
|
||||
let mut end_idx = 0usize;
|
||||
for (idx, ch) in text.char_indices() {
|
||||
for (idx, ch) in visible_text.char_indices() {
|
||||
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||
if used + ch_width > max_width {
|
||||
break;
|
||||
@@ -53,7 +60,12 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
|
||||
}
|
||||
|
||||
if end_idx > 0 {
|
||||
spans_out.push(Span::styled(text[..end_idx].to_string(), style));
|
||||
let truncated_text = &visible_text[..end_idx];
|
||||
let content = parsed_link.map_or_else(
|
||||
|| truncated_text.to_string(),
|
||||
|link| osc8_hyperlink(link.destination, truncated_text),
|
||||
);
|
||||
spans_out.push(Span::styled(content, style));
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -66,6 +78,13 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
|
||||
}
|
||||
}
|
||||
|
||||
fn visible_width(text: &str) -> usize {
|
||||
parse_osc8_hyperlink(text).map_or_else(
|
||||
|| UnicodeWidthStr::width(strip_osc8_hyperlinks(text).as_str()),
|
||||
|link| UnicodeWidthStr::width(link.text),
|
||||
)
|
||||
}
|
||||
|
||||
/// Truncate a styled line to `max_width` and append an ellipsis on overflow.
|
||||
///
|
||||
/// Intended for short UI rows. This preserves a fast no-overflow path (width
|
||||
@@ -98,3 +117,56 @@ pub(crate) fn truncate_line_with_ellipsis_if_overflow(
|
||||
spans,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn line_width_counts_osc8_wrapped_text_as_visible_text_only() {
|
||||
let line = Line::from(vec![
|
||||
"See ".into(),
|
||||
Span::from(osc8_hyperlink("https://example.com/docs", "docs")).underlined(),
|
||||
]);
|
||||
|
||||
assert_eq!(line_width(&line), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_line_to_width_preserves_osc8_wrapped_prefix() {
|
||||
let line = Line::from(vec![
|
||||
"See ".into(),
|
||||
Span::from(osc8_hyperlink("https://example.com/docs", "docs")).underlined(),
|
||||
]);
|
||||
|
||||
let truncated = truncate_line_to_width(line, 6);
|
||||
|
||||
let expected = Line::from(vec![
|
||||
"See ".into(),
|
||||
Span::from(osc8_hyperlink("https://example.com/docs", "do")).underlined(),
|
||||
]);
|
||||
assert_eq!(truncated, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_line_with_ellipsis_if_overflow_preserves_osc8_wrapped_prefix() {
|
||||
let line = Line::from(vec![
|
||||
"See ".into(),
|
||||
Span::from(osc8_hyperlink("https://example.com/docs", "docs")).underlined(),
|
||||
]);
|
||||
|
||||
let truncated = truncate_line_with_ellipsis_if_overflow(line, 7);
|
||||
|
||||
let expected = Line::from(vec![
|
||||
"See ".into(),
|
||||
Span::from(osc8_hyperlink("https://example.com/docs", "do")).underlined(),
|
||||
"…".underlined(),
|
||||
]);
|
||||
assert_eq!(truncated, expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! transcripts show the real file target (including normalized location suffixes) and can shorten
|
||||
//! absolute paths relative to a known working directory.
|
||||
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use crate::render::highlight::highlight_code_to_lines;
|
||||
use crate::render::line_utils::line_to_static;
|
||||
use crate::wrapping::RtOptions;
|
||||
@@ -42,7 +43,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 +65,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(),
|
||||
}
|
||||
}
|
||||
@@ -272,7 +275,12 @@ where
|
||||
Tag::Emphasis => self.push_inline_style(self.styles.emphasis),
|
||||
Tag::Strong => self.push_inline_style(self.styles.strong),
|
||||
Tag::Strikethrough => self.push_inline_style(self.styles.strikethrough),
|
||||
Tag::Link { dest_url, .. } => self.push_link(dest_url.to_string()),
|
||||
Tag::Link { dest_url, .. } => {
|
||||
self.push_link(dest_url.to_string());
|
||||
if self.remote_link_destination().is_some() {
|
||||
self.push_inline_style(self.styles.link_label);
|
||||
}
|
||||
}
|
||||
Tag::HtmlBlock
|
||||
| Tag::FootnoteDefinition(_)
|
||||
| Tag::Table(_)
|
||||
@@ -407,7 +415,7 @@ where
|
||||
if i > 0 {
|
||||
self.push_line(Line::default());
|
||||
}
|
||||
let content = line.to_string();
|
||||
let content = self.maybe_wrap_remote_link_text(line);
|
||||
let span = Span::styled(
|
||||
content,
|
||||
self.inline_styles.last().copied().unwrap_or_default(),
|
||||
@@ -426,7 +434,13 @@ where
|
||||
self.push_line(Line::default());
|
||||
self.pending_marker_line = false;
|
||||
}
|
||||
let span = Span::from(code.into_string()).style(self.styles.code);
|
||||
let style = self
|
||||
.inline_styles
|
||||
.last()
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.patch(self.styles.code);
|
||||
let span = Span::from(self.maybe_wrap_remote_link_text(code.as_ref())).style(style);
|
||||
self.push_span(span);
|
||||
}
|
||||
|
||||
@@ -445,7 +459,7 @@ where
|
||||
self.push_line(Line::default());
|
||||
}
|
||||
let style = self.inline_styles.last().copied().unwrap_or_default();
|
||||
self.push_span(Span::styled(line.to_string(), style));
|
||||
self.push_span(Span::styled(self.maybe_wrap_remote_link_text(line), style));
|
||||
}
|
||||
self.needs_newline = !inline;
|
||||
}
|
||||
@@ -596,8 +610,12 @@ where
|
||||
fn pop_link(&mut self) {
|
||||
if let Some(link) = self.link.take() {
|
||||
if link.show_destination {
|
||||
self.pop_inline_style();
|
||||
self.push_span(" (".into());
|
||||
self.push_span(Span::styled(link.destination, self.styles.link));
|
||||
self.push_span(Span::styled(
|
||||
osc8_hyperlink(&link.destination, &link.destination),
|
||||
self.styles.link_destination,
|
||||
));
|
||||
self.push_span(")".into());
|
||||
} else if let Some(local_target_display) = link.local_target_display {
|
||||
if self.pending_marker_line {
|
||||
@@ -617,6 +635,20 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_link_destination(&self) -> Option<&str> {
|
||||
self.link
|
||||
.as_ref()
|
||||
.filter(|link| link.show_destination)
|
||||
.map(|link| link.destination.as_str())
|
||||
}
|
||||
|
||||
fn maybe_wrap_remote_link_text(&self, text: &str) -> String {
|
||||
self.remote_link_destination().map_or_else(
|
||||
|| text.to_string(),
|
||||
|destination| osc8_hyperlink(destination, text),
|
||||
)
|
||||
}
|
||||
|
||||
fn suppressing_local_link_label(&self) -> bool {
|
||||
self.link
|
||||
.as_ref()
|
||||
|
||||
@@ -9,6 +9,8 @@ use crate::markdown_render::COLON_LOCATION_SUFFIX_RE;
|
||||
use crate::markdown_render::HASH_LOCATION_SUFFIX_RE;
|
||||
use crate::markdown_render::render_markdown_text;
|
||||
use crate::markdown_render::render_markdown_text_with_width_and_cwd;
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use crate::osc8::strip_osc8_hyperlinks;
|
||||
use insta::assert_snapshot;
|
||||
|
||||
fn render_markdown_text_for_cwd(input: &str, cwd: &Path) -> Text<'static> {
|
||||
@@ -651,9 +653,12 @@ fn strong_emphasis() {
|
||||
fn link() {
|
||||
let text = render_markdown_text("[Link](https://example.com)");
|
||||
let expected = Text::from(Line::from_iter([
|
||||
"Link".into(),
|
||||
osc8_hyperlink("https://example.com", "Link")
|
||||
.underlined(),
|
||||
" (".into(),
|
||||
"https://example.com".cyan().underlined(),
|
||||
osc8_hyperlink("https://example.com", "https://example.com")
|
||||
.cyan()
|
||||
.underlined(),
|
||||
")".into(),
|
||||
]));
|
||||
assert_eq!(text, expected);
|
||||
@@ -776,17 +781,44 @@ fn file_link_uses_target_path_for_hash_range() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_link_shows_destination() {
|
||||
fn url_link_renders_clickable_label_with_destination() {
|
||||
let text = render_markdown_text("[docs](https://example.com/docs)");
|
||||
let expected = Text::from(Line::from_iter([
|
||||
"docs".into(),
|
||||
osc8_hyperlink("https://example.com/docs", "docs")
|
||||
.underlined(),
|
||||
" (".into(),
|
||||
"https://example.com/docs".cyan().underlined(),
|
||||
osc8_hyperlink("https://example.com/docs", "https://example.com/docs")
|
||||
.cyan()
|
||||
.underlined(),
|
||||
")".into(),
|
||||
]));
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_link_with_inline_code_is_clickable() {
|
||||
let text = render_markdown_text("[`docs`](https://example.com/docs)");
|
||||
let expected = Text::from(Line::from_iter([
|
||||
osc8_hyperlink("https://example.com/docs", "docs")
|
||||
.cyan()
|
||||
.underlined(),
|
||||
" (".into(),
|
||||
osc8_hyperlink("https://example.com/docs", "https://example.com/docs")
|
||||
.cyan()
|
||||
.underlined(),
|
||||
")".into(),
|
||||
]));
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_link_sanitizes_control_chars() {
|
||||
assert_eq!(
|
||||
osc8_hyperlink("https://example.com/\u{1b}]8;;\u{07}injected", "unsafe"),
|
||||
"\u{1b}]8;;https://example.com/]8;;injected\u{7}unsafe\u{1b}]8;;\u{7}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_render_file_link_snapshot() {
|
||||
let text = render_markdown_text_for_cwd(
|
||||
@@ -797,10 +829,12 @@ fn markdown_render_file_link_snapshot() {
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
let line = l
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.clone())
|
||||
.collect::<String>()
|
||||
.collect::<String>();
|
||||
strip_osc8_hyperlinks(&line)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
@@ -819,10 +853,12 @@ fn unordered_list_local_file_link_stays_inline_with_following_text() {
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
let rendered = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
.collect::<String>();
|
||||
strip_osc8_hyperlinks(&rendered)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
@@ -1161,10 +1197,12 @@ URL with parentheses: [link](https://example.com/path_(with)_parens).
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
let line = l
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.clone())
|
||||
.collect::<String>()
|
||||
.collect::<String>();
|
||||
strip_osc8_hyperlinks(&line)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
83
codex-rs/tui/src/osc8.rs
Normal file
83
codex-rs/tui/src/osc8.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ParsedOsc8<'a> {
|
||||
pub(crate) destination: &'a str,
|
||||
pub(crate) text: &'a str,
|
||||
}
|
||||
|
||||
const OSC8_OPEN_PREFIX: &str = "\u{1b}]8;;";
|
||||
const OSC8_CLOSE: &str = "\u{1b}]8;;\u{7}";
|
||||
|
||||
pub(crate) fn sanitize_osc8_url(destination: &str) -> String {
|
||||
destination
|
||||
.chars()
|
||||
.filter(|&c| c != '\x1B' && c != '\x07')
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn osc8_hyperlink<S: AsRef<str>>(destination: &str, text: S) -> String {
|
||||
let safe_destination = sanitize_osc8_url(destination);
|
||||
if safe_destination.is_empty() {
|
||||
return text.as_ref().to_string();
|
||||
}
|
||||
|
||||
format!(
|
||||
"{OSC8_OPEN_PREFIX}{safe_destination}\u{7}{}{OSC8_CLOSE}",
|
||||
text.as_ref()
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_osc8_hyperlink(text: &str) -> Option<ParsedOsc8<'_>> {
|
||||
let after_open = text.strip_prefix(OSC8_OPEN_PREFIX)?;
|
||||
let url_end = after_open.find('\x07')?;
|
||||
let destination = &after_open[..url_end];
|
||||
let after_destination = &after_open[url_end + 1..];
|
||||
let label = after_destination.strip_suffix(OSC8_CLOSE)?;
|
||||
Some(ParsedOsc8 {
|
||||
destination,
|
||||
text: label,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn strip_osc8_hyperlinks(text: &str) -> String {
|
||||
let mut remaining = text;
|
||||
let mut rendered = String::new();
|
||||
|
||||
while let Some(open_pos) = remaining.find(OSC8_OPEN_PREFIX) {
|
||||
rendered.push_str(&remaining[..open_pos]);
|
||||
let after_open = &remaining[open_pos + OSC8_OPEN_PREFIX.len()..];
|
||||
let Some(url_end) = after_open.find('\x07') else {
|
||||
rendered.push_str(&remaining[open_pos..]);
|
||||
return rendered;
|
||||
};
|
||||
let after_url = &after_open[url_end + 1..];
|
||||
let Some(close_pos) = after_url.find(OSC8_CLOSE) else {
|
||||
rendered.push_str(&remaining[open_pos..]);
|
||||
return rendered;
|
||||
};
|
||||
rendered.push_str(&after_url[..close_pos]);
|
||||
remaining = &after_url[close_pos + OSC8_CLOSE.len()..];
|
||||
}
|
||||
|
||||
rendered.push_str(remaining);
|
||||
rendered
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn parses_wrapped_text() {
|
||||
let wrapped = osc8_hyperlink("https://example.com", "docs");
|
||||
let parsed = parse_osc8_hyperlink(&wrapped).expect("expected osc8 span");
|
||||
assert_eq!(parsed.destination, "https://example.com");
|
||||
assert_eq!(parsed.text, "docs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_wrapped_text() {
|
||||
let wrapped = format!("See {}", osc8_hyperlink("https://example.com", "docs"));
|
||||
assert_eq!(strip_osc8_hyperlinks(&wrapped), "See docs");
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ use std::borrow::Cow;
|
||||
use std::ops::Range;
|
||||
use textwrap::Options;
|
||||
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use crate::osc8::parse_osc8_hyperlink;
|
||||
use crate::osc8::strip_osc8_hyperlinks;
|
||||
use crate::render::line_utils::push_owned_lines;
|
||||
|
||||
/// Returns byte-ranges into `text` for each wrapped line, including
|
||||
@@ -177,12 +180,7 @@ fn map_owned_wrapped_line_to_range(
|
||||
///
|
||||
/// Concatenates all span contents and delegates to [`text_contains_url_like`].
|
||||
pub(crate) fn line_contains_url_like(line: &Line<'_>) -> bool {
|
||||
let text: String = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect();
|
||||
text_contains_url_like(&text)
|
||||
text_contains_url_like(&visible_line_text(line))
|
||||
}
|
||||
|
||||
/// Returns `true` if `line` contains both a URL-like token and at least one
|
||||
@@ -191,12 +189,15 @@ pub(crate) fn line_contains_url_like(line: &Line<'_>) -> bool {
|
||||
/// Decorative marker tokens (for example list prefixes like `-`, `1.`, `|`,
|
||||
/// `│`) are ignored for the non-URL side of this check.
|
||||
pub(crate) fn line_has_mixed_url_and_non_url_tokens(line: &Line<'_>) -> bool {
|
||||
let text: String = line
|
||||
.spans
|
||||
text_has_mixed_url_and_non_url_tokens(&visible_line_text(line))
|
||||
}
|
||||
|
||||
fn visible_line_text(line: &Line<'_>) -> String {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect();
|
||||
text_has_mixed_url_and_non_url_tokens(&text)
|
||||
.map(|span| strip_osc8_hyperlinks(span.content.as_ref()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
/// Returns `true` if any whitespace-delimited token in `text` looks like a URL.
|
||||
@@ -644,11 +645,16 @@ where
|
||||
let mut span_bounds = Vec::new();
|
||||
let mut acc = 0usize;
|
||||
for s in &line.spans {
|
||||
let text = s.content.as_ref();
|
||||
let parsed = parse_osc8_hyperlink(s.content.as_ref());
|
||||
let text = parsed.map_or_else(|| s.content.as_ref(), |link| link.text);
|
||||
let start = acc;
|
||||
flat.push_str(text);
|
||||
acc += text.len();
|
||||
span_bounds.push((start..acc, s.style));
|
||||
span_bounds.push(SpanBound {
|
||||
range: start..acc,
|
||||
style: s.style,
|
||||
osc8_destination: parsed.map(|link| link.destination),
|
||||
});
|
||||
}
|
||||
|
||||
let rt_opts: RtOptions<'a> = width_or_options.into();
|
||||
@@ -841,15 +847,15 @@ where
|
||||
|
||||
fn slice_line_spans<'a>(
|
||||
original: &'a Line<'a>,
|
||||
span_bounds: &[(Range<usize>, ratatui::style::Style)],
|
||||
span_bounds: &[SpanBound<'a>],
|
||||
range: &Range<usize>,
|
||||
) -> Line<'a> {
|
||||
let start_byte = range.start;
|
||||
let end_byte = range.end;
|
||||
let mut acc: Vec<Span<'a>> = Vec::new();
|
||||
for (i, (range, style)) in span_bounds.iter().enumerate() {
|
||||
let s = range.start;
|
||||
let e = range.end;
|
||||
for (i, bound) in span_bounds.iter().enumerate() {
|
||||
let s = bound.range.start;
|
||||
let e = bound.range.end;
|
||||
if e <= start_byte {
|
||||
continue;
|
||||
}
|
||||
@@ -861,11 +867,15 @@ fn slice_line_spans<'a>(
|
||||
if seg_end > seg_start {
|
||||
let local_start = seg_start - s;
|
||||
let local_end = seg_end - s;
|
||||
let content = original.spans[i].content.as_ref();
|
||||
let slice = &content[local_start..local_end];
|
||||
let slice = slice_span_content(
|
||||
original.spans[i].content.as_ref(),
|
||||
bound,
|
||||
local_start,
|
||||
local_end,
|
||||
);
|
||||
acc.push(Span {
|
||||
style: *style,
|
||||
content: std::borrow::Cow::Borrowed(slice),
|
||||
style: bound.style,
|
||||
content: slice,
|
||||
});
|
||||
}
|
||||
if e >= end_byte {
|
||||
@@ -879,9 +889,40 @@ fn slice_line_spans<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct SpanBound<'a> {
|
||||
range: Range<usize>,
|
||||
style: ratatui::style::Style,
|
||||
osc8_destination: Option<&'a str>,
|
||||
}
|
||||
|
||||
fn slice_span_content<'a>(
|
||||
content: &'a str,
|
||||
bound: &SpanBound<'a>,
|
||||
local_start: usize,
|
||||
local_end: usize,
|
||||
) -> Cow<'a, str> {
|
||||
if let Some(destination) = bound.osc8_destination {
|
||||
if let Some(parsed) = parse_osc8_hyperlink(content) {
|
||||
Cow::Owned(osc8_hyperlink(
|
||||
destination,
|
||||
&parsed.text[local_start..local_end],
|
||||
))
|
||||
} else {
|
||||
Cow::Borrowed(&content[local_start..local_end])
|
||||
}
|
||||
} else {
|
||||
Cow::Borrowed(&content[local_start..local_end])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::osc8::ParsedOsc8;
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use crate::osc8::parse_osc8_hyperlink;
|
||||
use crate::osc8::strip_osc8_hyperlinks;
|
||||
use itertools::Itertools as _;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Color;
|
||||
@@ -965,6 +1006,34 @@ mod tests {
|
||||
assert_eq!(concat_line(&out[0]), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc8_wrapped_span_wraps_by_visible_text() {
|
||||
let url = "https://example.com/docs";
|
||||
let line = Line::from(vec![osc8_hyperlink(url, "abcdefghij").cyan().underlined()]);
|
||||
let out = word_wrap_line(&line, 5);
|
||||
assert_eq!(out.len(), 2);
|
||||
|
||||
let first = concat_line(&out[0]);
|
||||
let second = concat_line(&out[1]);
|
||||
|
||||
assert_eq!(strip_osc8_hyperlinks(&first), "abcde");
|
||||
assert_eq!(strip_osc8_hyperlinks(&second), "fghij");
|
||||
assert_eq!(
|
||||
parse_osc8_hyperlink(&first).expect("first line should stay hyperlinked"),
|
||||
ParsedOsc8 {
|
||||
destination: url,
|
||||
text: "abcde",
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_osc8_hyperlink(&second).expect("second line should stay hyperlinked"),
|
||||
ParsedOsc8 {
|
||||
destination: url,
|
||||
text: "fghij",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_spaces_preserved_on_first_line() {
|
||||
let line = Line::from(" hello");
|
||||
|
||||
@@ -124,6 +124,7 @@ mod model_migration;
|
||||
mod multi_agents;
|
||||
mod notifications;
|
||||
pub mod onboarding;
|
||||
mod osc8;
|
||||
mod oss_selection;
|
||||
mod pager_overlay;
|
||||
pub mod public_widgets;
|
||||
|
||||
@@ -3,9 +3,13 @@ use ratatui::text::Span;
|
||||
use unicode_width::UnicodeWidthChar;
|
||||
use unicode_width::UnicodeWidthStr;
|
||||
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use crate::osc8::parse_osc8_hyperlink;
|
||||
use crate::osc8::strip_osc8_hyperlinks;
|
||||
|
||||
pub(crate) fn line_width(line: &Line<'_>) -> usize {
|
||||
line.iter()
|
||||
.map(|span| UnicodeWidthStr::width(span.content.as_ref()))
|
||||
.map(|span| visible_width(span.content.as_ref()))
|
||||
.sum()
|
||||
}
|
||||
|
||||
@@ -23,7 +27,7 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
|
||||
let mut spans_out: Vec<Span<'static>> = Vec::with_capacity(spans.len());
|
||||
|
||||
for span in spans {
|
||||
let span_width = UnicodeWidthStr::width(span.content.as_ref());
|
||||
let span_width = visible_width(span.content.as_ref());
|
||||
|
||||
if span_width == 0 {
|
||||
spans_out.push(span);
|
||||
@@ -42,8 +46,11 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
|
||||
|
||||
let style = span.style;
|
||||
let text = span.content.as_ref();
|
||||
let parsed_link = parse_osc8_hyperlink(text);
|
||||
let visible_text =
|
||||
parsed_link.map_or_else(|| strip_osc8_hyperlinks(text), |link| link.text.to_string());
|
||||
let mut end_idx = 0usize;
|
||||
for (idx, ch) in text.char_indices() {
|
||||
for (idx, ch) in visible_text.char_indices() {
|
||||
let ch_width = UnicodeWidthChar::width(ch).unwrap_or(0);
|
||||
if used + ch_width > max_width {
|
||||
break;
|
||||
@@ -53,7 +60,12 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
|
||||
}
|
||||
|
||||
if end_idx > 0 {
|
||||
spans_out.push(Span::styled(text[..end_idx].to_string(), style));
|
||||
let truncated_text = &visible_text[..end_idx];
|
||||
let content = parsed_link.map_or_else(
|
||||
|| truncated_text.to_string(),
|
||||
|link| osc8_hyperlink(link.destination, truncated_text),
|
||||
);
|
||||
spans_out.push(Span::styled(content, style));
|
||||
}
|
||||
|
||||
break;
|
||||
@@ -66,6 +78,13 @@ pub(crate) fn truncate_line_to_width(line: Line<'static>, max_width: usize) -> L
|
||||
}
|
||||
}
|
||||
|
||||
fn visible_width(text: &str) -> usize {
|
||||
parse_osc8_hyperlink(text).map_or_else(
|
||||
|| UnicodeWidthStr::width(strip_osc8_hyperlinks(text).as_str()),
|
||||
|link| UnicodeWidthStr::width(link.text),
|
||||
)
|
||||
}
|
||||
|
||||
/// Truncate a styled line to `max_width` and append an ellipsis on overflow.
|
||||
///
|
||||
/// Intended for short UI rows. This preserves a fast no-overflow path (width
|
||||
@@ -98,3 +117,56 @@ pub(crate) fn truncate_line_with_ellipsis_if_overflow(
|
||||
spans,
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Stylize;
|
||||
use ratatui::text::Line;
|
||||
use ratatui::text::Span;
|
||||
|
||||
use super::*;
|
||||
|
||||
#[test]
|
||||
fn line_width_counts_osc8_wrapped_text_as_visible_text_only() {
|
||||
let line = Line::from(vec![
|
||||
"See ".into(),
|
||||
Span::from(osc8_hyperlink("https://example.com/docs", "docs")).underlined(),
|
||||
]);
|
||||
|
||||
assert_eq!(line_width(&line), 8);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_line_to_width_preserves_osc8_wrapped_prefix() {
|
||||
let line = Line::from(vec![
|
||||
"See ".into(),
|
||||
Span::from(osc8_hyperlink("https://example.com/docs", "docs")).underlined(),
|
||||
]);
|
||||
|
||||
let truncated = truncate_line_to_width(line, 6);
|
||||
|
||||
let expected = Line::from(vec![
|
||||
"See ".into(),
|
||||
Span::from(osc8_hyperlink("https://example.com/docs", "do")).underlined(),
|
||||
]);
|
||||
assert_eq!(truncated, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn truncate_line_with_ellipsis_if_overflow_preserves_osc8_wrapped_prefix() {
|
||||
let line = Line::from(vec![
|
||||
"See ".into(),
|
||||
Span::from(osc8_hyperlink("https://example.com/docs", "docs")).underlined(),
|
||||
]);
|
||||
|
||||
let truncated = truncate_line_with_ellipsis_if_overflow(line, 7);
|
||||
|
||||
let expected = Line::from(vec![
|
||||
"See ".into(),
|
||||
Span::from(osc8_hyperlink("https://example.com/docs", "do")).underlined(),
|
||||
"…".underlined(),
|
||||
]);
|
||||
assert_eq!(truncated, expected);
|
||||
}
|
||||
}
|
||||
|
||||
@@ -5,6 +5,7 @@
|
||||
//! transcripts show the real file target (including normalized location suffixes) and can shorten
|
||||
//! absolute paths relative to a known working directory.
|
||||
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use crate::render::highlight::highlight_code_to_lines;
|
||||
use crate::render::line_utils::line_to_static;
|
||||
use crate::wrapping::RtOptions;
|
||||
@@ -42,7 +43,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 +65,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(),
|
||||
}
|
||||
}
|
||||
@@ -272,7 +275,12 @@ where
|
||||
Tag::Emphasis => self.push_inline_style(self.styles.emphasis),
|
||||
Tag::Strong => self.push_inline_style(self.styles.strong),
|
||||
Tag::Strikethrough => self.push_inline_style(self.styles.strikethrough),
|
||||
Tag::Link { dest_url, .. } => self.push_link(dest_url.to_string()),
|
||||
Tag::Link { dest_url, .. } => {
|
||||
self.push_link(dest_url.to_string());
|
||||
if self.remote_link_destination().is_some() {
|
||||
self.push_inline_style(self.styles.link_label);
|
||||
}
|
||||
}
|
||||
Tag::HtmlBlock
|
||||
| Tag::FootnoteDefinition(_)
|
||||
| Tag::Table(_)
|
||||
@@ -407,7 +415,7 @@ where
|
||||
if i > 0 {
|
||||
self.push_line(Line::default());
|
||||
}
|
||||
let content = line.to_string();
|
||||
let content = self.maybe_wrap_remote_link_text(line);
|
||||
let span = Span::styled(
|
||||
content,
|
||||
self.inline_styles.last().copied().unwrap_or_default(),
|
||||
@@ -426,7 +434,13 @@ where
|
||||
self.push_line(Line::default());
|
||||
self.pending_marker_line = false;
|
||||
}
|
||||
let span = Span::from(code.into_string()).style(self.styles.code);
|
||||
let style = self
|
||||
.inline_styles
|
||||
.last()
|
||||
.copied()
|
||||
.unwrap_or_default()
|
||||
.patch(self.styles.code);
|
||||
let span = Span::from(self.maybe_wrap_remote_link_text(code.as_ref())).style(style);
|
||||
self.push_span(span);
|
||||
}
|
||||
|
||||
@@ -445,7 +459,7 @@ where
|
||||
self.push_line(Line::default());
|
||||
}
|
||||
let style = self.inline_styles.last().copied().unwrap_or_default();
|
||||
self.push_span(Span::styled(line.to_string(), style));
|
||||
self.push_span(Span::styled(self.maybe_wrap_remote_link_text(line), style));
|
||||
}
|
||||
self.needs_newline = !inline;
|
||||
}
|
||||
@@ -596,8 +610,12 @@ where
|
||||
fn pop_link(&mut self) {
|
||||
if let Some(link) = self.link.take() {
|
||||
if link.show_destination {
|
||||
self.pop_inline_style();
|
||||
self.push_span(" (".into());
|
||||
self.push_span(Span::styled(link.destination, self.styles.link));
|
||||
self.push_span(Span::styled(
|
||||
osc8_hyperlink(&link.destination, &link.destination),
|
||||
self.styles.link_destination,
|
||||
));
|
||||
self.push_span(")".into());
|
||||
} else if let Some(local_target_display) = link.local_target_display {
|
||||
if self.pending_marker_line {
|
||||
@@ -617,6 +635,20 @@ where
|
||||
}
|
||||
}
|
||||
|
||||
fn remote_link_destination(&self) -> Option<&str> {
|
||||
self.link
|
||||
.as_ref()
|
||||
.filter(|link| link.show_destination)
|
||||
.map(|link| link.destination.as_str())
|
||||
}
|
||||
|
||||
fn maybe_wrap_remote_link_text(&self, text: &str) -> String {
|
||||
self.remote_link_destination().map_or_else(
|
||||
|| text.to_string(),
|
||||
|destination| osc8_hyperlink(destination, text),
|
||||
)
|
||||
}
|
||||
|
||||
fn suppressing_local_link_label(&self) -> bool {
|
||||
self.link
|
||||
.as_ref()
|
||||
|
||||
@@ -9,6 +9,8 @@ use crate::markdown_render::COLON_LOCATION_SUFFIX_RE;
|
||||
use crate::markdown_render::HASH_LOCATION_SUFFIX_RE;
|
||||
use crate::markdown_render::render_markdown_text;
|
||||
use crate::markdown_render::render_markdown_text_with_width_and_cwd;
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use crate::osc8::strip_osc8_hyperlinks;
|
||||
use insta::assert_snapshot;
|
||||
|
||||
fn render_markdown_text_for_cwd(input: &str, cwd: &Path) -> Text<'static> {
|
||||
@@ -651,9 +653,12 @@ fn strong_emphasis() {
|
||||
fn link() {
|
||||
let text = render_markdown_text("[Link](https://example.com)");
|
||||
let expected = Text::from(Line::from_iter([
|
||||
"Link".into(),
|
||||
osc8_hyperlink("https://example.com", "Link")
|
||||
.underlined(),
|
||||
" (".into(),
|
||||
"https://example.com".cyan().underlined(),
|
||||
osc8_hyperlink("https://example.com", "https://example.com")
|
||||
.cyan()
|
||||
.underlined(),
|
||||
")".into(),
|
||||
]));
|
||||
assert_eq!(text, expected);
|
||||
@@ -776,17 +781,44 @@ fn file_link_uses_target_path_for_hash_range() {
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_link_shows_destination() {
|
||||
fn url_link_renders_clickable_label_with_destination() {
|
||||
let text = render_markdown_text("[docs](https://example.com/docs)");
|
||||
let expected = Text::from(Line::from_iter([
|
||||
"docs".into(),
|
||||
osc8_hyperlink("https://example.com/docs", "docs")
|
||||
.underlined(),
|
||||
" (".into(),
|
||||
"https://example.com/docs".cyan().underlined(),
|
||||
osc8_hyperlink("https://example.com/docs", "https://example.com/docs")
|
||||
.cyan()
|
||||
.underlined(),
|
||||
")".into(),
|
||||
]));
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_link_with_inline_code_is_clickable() {
|
||||
let text = render_markdown_text("[`docs`](https://example.com/docs)");
|
||||
let expected = Text::from(Line::from_iter([
|
||||
osc8_hyperlink("https://example.com/docs", "docs")
|
||||
.cyan()
|
||||
.underlined(),
|
||||
" (".into(),
|
||||
osc8_hyperlink("https://example.com/docs", "https://example.com/docs")
|
||||
.cyan()
|
||||
.underlined(),
|
||||
")".into(),
|
||||
]));
|
||||
assert_eq!(text, expected);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn url_link_sanitizes_control_chars() {
|
||||
assert_eq!(
|
||||
osc8_hyperlink("https://example.com/\u{1b}]8;;\u{07}injected", "unsafe"),
|
||||
"\u{1b}]8;;https://example.com/]8;;injected\u{7}unsafe\u{1b}]8;;\u{7}"
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn markdown_render_file_link_snapshot() {
|
||||
let text = render_markdown_text_for_cwd(
|
||||
@@ -797,10 +829,12 @@ fn markdown_render_file_link_snapshot() {
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
let line = l
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.clone())
|
||||
.collect::<String>()
|
||||
.collect::<String>();
|
||||
strip_osc8_hyperlinks(&line)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
@@ -819,10 +853,12 @@ fn unordered_list_local_file_link_stays_inline_with_following_text() {
|
||||
.lines
|
||||
.iter()
|
||||
.map(|line| {
|
||||
line.spans
|
||||
let rendered = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect::<String>()
|
||||
.collect::<String>();
|
||||
strip_osc8_hyperlinks(&rendered)
|
||||
})
|
||||
.collect::<Vec<_>>();
|
||||
assert_eq!(
|
||||
@@ -1161,10 +1197,12 @@ URL with parentheses: [link](https://example.com/path_(with)_parens).
|
||||
.lines
|
||||
.iter()
|
||||
.map(|l| {
|
||||
l.spans
|
||||
let line = l
|
||||
.spans
|
||||
.iter()
|
||||
.map(|s| s.content.clone())
|
||||
.collect::<String>()
|
||||
.collect::<String>();
|
||||
strip_osc8_hyperlinks(&line)
|
||||
})
|
||||
.collect::<Vec<_>>()
|
||||
.join("\n");
|
||||
|
||||
83
codex-rs/tui_app_server/src/osc8.rs
Normal file
83
codex-rs/tui_app_server/src/osc8.rs
Normal file
@@ -0,0 +1,83 @@
|
||||
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
|
||||
pub(crate) struct ParsedOsc8<'a> {
|
||||
pub(crate) destination: &'a str,
|
||||
pub(crate) text: &'a str,
|
||||
}
|
||||
|
||||
const OSC8_OPEN_PREFIX: &str = "\u{1b}]8;;";
|
||||
const OSC8_CLOSE: &str = "\u{1b}]8;;\u{7}";
|
||||
|
||||
pub(crate) fn sanitize_osc8_url(destination: &str) -> String {
|
||||
destination
|
||||
.chars()
|
||||
.filter(|&c| c != '\x1B' && c != '\x07')
|
||||
.collect()
|
||||
}
|
||||
|
||||
pub(crate) fn osc8_hyperlink<S: AsRef<str>>(destination: &str, text: S) -> String {
|
||||
let safe_destination = sanitize_osc8_url(destination);
|
||||
if safe_destination.is_empty() {
|
||||
return text.as_ref().to_string();
|
||||
}
|
||||
|
||||
format!(
|
||||
"{OSC8_OPEN_PREFIX}{safe_destination}\u{7}{}{OSC8_CLOSE}",
|
||||
text.as_ref()
|
||||
)
|
||||
}
|
||||
|
||||
pub(crate) fn parse_osc8_hyperlink(text: &str) -> Option<ParsedOsc8<'_>> {
|
||||
let after_open = text.strip_prefix(OSC8_OPEN_PREFIX)?;
|
||||
let url_end = after_open.find('\x07')?;
|
||||
let destination = &after_open[..url_end];
|
||||
let after_destination = &after_open[url_end + 1..];
|
||||
let label = after_destination.strip_suffix(OSC8_CLOSE)?;
|
||||
Some(ParsedOsc8 {
|
||||
destination,
|
||||
text: label,
|
||||
})
|
||||
}
|
||||
|
||||
pub(crate) fn strip_osc8_hyperlinks(text: &str) -> String {
|
||||
let mut remaining = text;
|
||||
let mut rendered = String::new();
|
||||
|
||||
while let Some(open_pos) = remaining.find(OSC8_OPEN_PREFIX) {
|
||||
rendered.push_str(&remaining[..open_pos]);
|
||||
let after_open = &remaining[open_pos + OSC8_OPEN_PREFIX.len()..];
|
||||
let Some(url_end) = after_open.find('\x07') else {
|
||||
rendered.push_str(&remaining[open_pos..]);
|
||||
return rendered;
|
||||
};
|
||||
let after_url = &after_open[url_end + 1..];
|
||||
let Some(close_pos) = after_url.find(OSC8_CLOSE) else {
|
||||
rendered.push_str(&remaining[open_pos..]);
|
||||
return rendered;
|
||||
};
|
||||
rendered.push_str(&after_url[..close_pos]);
|
||||
remaining = &after_url[close_pos + OSC8_CLOSE.len()..];
|
||||
}
|
||||
|
||||
rendered.push_str(remaining);
|
||||
rendered
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use pretty_assertions::assert_eq;
|
||||
|
||||
#[test]
|
||||
fn parses_wrapped_text() {
|
||||
let wrapped = osc8_hyperlink("https://example.com", "docs");
|
||||
let parsed = parse_osc8_hyperlink(&wrapped).expect("expected osc8 span");
|
||||
assert_eq!(parsed.destination, "https://example.com");
|
||||
assert_eq!(parsed.text, "docs");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn strips_wrapped_text() {
|
||||
let wrapped = format!("See {}", osc8_hyperlink("https://example.com", "docs"));
|
||||
assert_eq!(strip_osc8_hyperlinks(&wrapped), "See docs");
|
||||
}
|
||||
}
|
||||
@@ -32,6 +32,9 @@ use std::borrow::Cow;
|
||||
use std::ops::Range;
|
||||
use textwrap::Options;
|
||||
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use crate::osc8::parse_osc8_hyperlink;
|
||||
use crate::osc8::strip_osc8_hyperlinks;
|
||||
use crate::render::line_utils::push_owned_lines;
|
||||
|
||||
/// Returns byte-ranges into `text` for each wrapped line, including
|
||||
@@ -177,12 +180,7 @@ fn map_owned_wrapped_line_to_range(
|
||||
///
|
||||
/// Concatenates all span contents and delegates to [`text_contains_url_like`].
|
||||
pub(crate) fn line_contains_url_like(line: &Line<'_>) -> bool {
|
||||
let text: String = line
|
||||
.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect();
|
||||
text_contains_url_like(&text)
|
||||
text_contains_url_like(&visible_line_text(line))
|
||||
}
|
||||
|
||||
/// Returns `true` if `line` contains both a URL-like token and at least one
|
||||
@@ -191,12 +189,15 @@ pub(crate) fn line_contains_url_like(line: &Line<'_>) -> bool {
|
||||
/// Decorative marker tokens (for example list prefixes like `-`, `1.`, `|`,
|
||||
/// `│`) are ignored for the non-URL side of this check.
|
||||
pub(crate) fn line_has_mixed_url_and_non_url_tokens(line: &Line<'_>) -> bool {
|
||||
let text: String = line
|
||||
.spans
|
||||
text_has_mixed_url_and_non_url_tokens(&visible_line_text(line))
|
||||
}
|
||||
|
||||
fn visible_line_text(line: &Line<'_>) -> String {
|
||||
line.spans
|
||||
.iter()
|
||||
.map(|span| span.content.as_ref())
|
||||
.collect();
|
||||
text_has_mixed_url_and_non_url_tokens(&text)
|
||||
.map(|span| strip_osc8_hyperlinks(span.content.as_ref()))
|
||||
.collect::<Vec<_>>()
|
||||
.join("")
|
||||
}
|
||||
|
||||
/// Returns `true` if any whitespace-delimited token in `text` looks like a URL.
|
||||
@@ -644,11 +645,16 @@ where
|
||||
let mut span_bounds = Vec::new();
|
||||
let mut acc = 0usize;
|
||||
for s in &line.spans {
|
||||
let text = s.content.as_ref();
|
||||
let parsed = parse_osc8_hyperlink(s.content.as_ref());
|
||||
let text = parsed.map_or_else(|| s.content.as_ref(), |link| link.text);
|
||||
let start = acc;
|
||||
flat.push_str(text);
|
||||
acc += text.len();
|
||||
span_bounds.push((start..acc, s.style));
|
||||
span_bounds.push(SpanBound {
|
||||
range: start..acc,
|
||||
style: s.style,
|
||||
osc8_destination: parsed.map(|link| link.destination),
|
||||
});
|
||||
}
|
||||
|
||||
let rt_opts: RtOptions<'a> = width_or_options.into();
|
||||
@@ -841,15 +847,15 @@ where
|
||||
|
||||
fn slice_line_spans<'a>(
|
||||
original: &'a Line<'a>,
|
||||
span_bounds: &[(Range<usize>, ratatui::style::Style)],
|
||||
span_bounds: &[SpanBound<'a>],
|
||||
range: &Range<usize>,
|
||||
) -> Line<'a> {
|
||||
let start_byte = range.start;
|
||||
let end_byte = range.end;
|
||||
let mut acc: Vec<Span<'a>> = Vec::new();
|
||||
for (i, (range, style)) in span_bounds.iter().enumerate() {
|
||||
let s = range.start;
|
||||
let e = range.end;
|
||||
for (i, bound) in span_bounds.iter().enumerate() {
|
||||
let s = bound.range.start;
|
||||
let e = bound.range.end;
|
||||
if e <= start_byte {
|
||||
continue;
|
||||
}
|
||||
@@ -861,11 +867,15 @@ fn slice_line_spans<'a>(
|
||||
if seg_end > seg_start {
|
||||
let local_start = seg_start - s;
|
||||
let local_end = seg_end - s;
|
||||
let content = original.spans[i].content.as_ref();
|
||||
let slice = &content[local_start..local_end];
|
||||
let slice = slice_span_content(
|
||||
original.spans[i].content.as_ref(),
|
||||
bound,
|
||||
local_start,
|
||||
local_end,
|
||||
);
|
||||
acc.push(Span {
|
||||
style: *style,
|
||||
content: std::borrow::Cow::Borrowed(slice),
|
||||
style: bound.style,
|
||||
content: slice,
|
||||
});
|
||||
}
|
||||
if e >= end_byte {
|
||||
@@ -879,9 +889,40 @@ fn slice_line_spans<'a>(
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug)]
|
||||
struct SpanBound<'a> {
|
||||
range: Range<usize>,
|
||||
style: ratatui::style::Style,
|
||||
osc8_destination: Option<&'a str>,
|
||||
}
|
||||
|
||||
fn slice_span_content<'a>(
|
||||
content: &'a str,
|
||||
bound: &SpanBound<'a>,
|
||||
local_start: usize,
|
||||
local_end: usize,
|
||||
) -> Cow<'a, str> {
|
||||
if let Some(destination) = bound.osc8_destination {
|
||||
if let Some(parsed) = parse_osc8_hyperlink(content) {
|
||||
Cow::Owned(osc8_hyperlink(
|
||||
destination,
|
||||
&parsed.text[local_start..local_end],
|
||||
))
|
||||
} else {
|
||||
Cow::Borrowed(&content[local_start..local_end])
|
||||
}
|
||||
} else {
|
||||
Cow::Borrowed(&content[local_start..local_end])
|
||||
}
|
||||
}
|
||||
|
||||
#[cfg(test)]
|
||||
mod tests {
|
||||
use super::*;
|
||||
use crate::osc8::ParsedOsc8;
|
||||
use crate::osc8::osc8_hyperlink;
|
||||
use crate::osc8::parse_osc8_hyperlink;
|
||||
use crate::osc8::strip_osc8_hyperlinks;
|
||||
use itertools::Itertools as _;
|
||||
use pretty_assertions::assert_eq;
|
||||
use ratatui::style::Color;
|
||||
@@ -965,6 +1006,34 @@ mod tests {
|
||||
assert_eq!(concat_line(&out[0]), "");
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn osc8_wrapped_span_wraps_by_visible_text() {
|
||||
let url = "https://example.com/docs";
|
||||
let line = Line::from(vec![osc8_hyperlink(url, "abcdefghij").cyan().underlined()]);
|
||||
let out = word_wrap_line(&line, 5);
|
||||
assert_eq!(out.len(), 2);
|
||||
|
||||
let first = concat_line(&out[0]);
|
||||
let second = concat_line(&out[1]);
|
||||
|
||||
assert_eq!(strip_osc8_hyperlinks(&first), "abcde");
|
||||
assert_eq!(strip_osc8_hyperlinks(&second), "fghij");
|
||||
assert_eq!(
|
||||
parse_osc8_hyperlink(&first).expect("first line should stay hyperlinked"),
|
||||
ParsedOsc8 {
|
||||
destination: url,
|
||||
text: "abcde",
|
||||
}
|
||||
);
|
||||
assert_eq!(
|
||||
parse_osc8_hyperlink(&second).expect("second line should stay hyperlinked"),
|
||||
ParsedOsc8 {
|
||||
destination: url,
|
||||
text: "fghij",
|
||||
}
|
||||
);
|
||||
}
|
||||
|
||||
#[test]
|
||||
fn leading_spaces_preserved_on_first_line() {
|
||||
let line = Line::from(" hello");
|
||||
|
||||
Reference in New Issue
Block a user