render: OSC 8 hyperlinks with hover/click and a URL hint mode

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I7b39adae426d3fc5b7dfe1437eb10e976a6a6964
This commit is contained in:
raf 2026-06-25 13:48:20 +03:00
commit 2161d7250f
No known key found for this signature in database
GPG key ID: 29D95B64378DB4BF
6 changed files with 457 additions and 11 deletions

View file

@ -2,6 +2,7 @@
//! operations the VT parser drives.
use std::collections::VecDeque;
use std::num::NonZeroU16;
use unicode_width::UnicodeWidthChar;
@ -117,6 +118,8 @@ pub struct Cell {
/// the common case; the renderer stacks each over the base glyph. This is
/// the grapheme cluster a future shaper (HarfBuzz) would consume.
pub combining: Option<Box<str>>,
/// OSC 8 hyperlink: a 1-based index into the grid's link table, or `None`.
pub link: Option<NonZeroU16>,
}
impl Default for Cell {
@ -129,6 +132,7 @@ impl Default for Cell {
underline: Underline::None,
underline_color: Color::Default,
combining: None,
link: None,
}
}
}
@ -139,6 +143,67 @@ struct Cursor {
y: usize,
}
/// A URL detected in the visible viewport, with the `(row, col)` of its first
/// character in viewport coordinates.
#[derive(Clone, PartialEq, Eq, Debug)]
pub struct UrlHit {
pub url: String,
pub row: usize,
pub col: usize,
}
/// Whether `c` may appear inside a URL (excludes whitespace, controls, and the
/// delimiters that conventionally bound a URL in flowing text).
fn is_url_char(c: char) -> bool {
!c.is_whitespace()
&& !c.is_control()
&& !matches!(c, '<' | '>' | '"' | '`' | '{' | '}' | '|' | '\\' | '^')
}
/// Find `scheme://…` URLs in `chars`, returning `(start, end)` index ranges.
/// The scheme is a run of `[A-Za-z][A-Za-z0-9+.-]*` before `://`; the body runs
/// to the first non-URL character, with trailing sentence punctuation trimmed.
fn find_urls(chars: &[char]) -> Vec<(usize, usize)> {
let mut out = Vec::new();
let mut i = 0;
while i + 2 < chars.len() {
if chars[i] == ':' && chars[i + 1] == '/' && chars[i + 2] == '/' {
// Backtrack over the scheme.
let mut start = i;
while start > 0 {
let c = chars[start - 1];
if c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.') {
start -= 1;
} else {
break;
}
}
if start < i && chars[start].is_ascii_alphabetic() {
let mut end = i + 3;
while end < chars.len() && is_url_char(chars[end]) {
end += 1;
}
// Trim trailing punctuation that is usually sentence-level.
while end > i + 3
&& matches!(
chars[end - 1],
'.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '\'' | '"'
)
{
end -= 1;
}
if end > i + 3 {
out.push((start, end));
i = end;
continue;
}
}
}
i += 1;
}
out
}
/// Shell-integration prompt mark on a line (OSC 133): the start of a prompt,
/// the start of typed command input, the start of command output, or the line
/// where the command finished.
@ -252,6 +317,8 @@ pub struct Grid {
/// Position of the last printed base cell, so a following zero-width
/// combining mark can attach to it.
last_base: Option<(usize, usize)>,
/// OSC 8 hyperlink URIs; a cell's `link` is a 1-based index into this.
links: Vec<Box<str>>,
}
fn default_tabs(cols: usize) -> Vec<bool> {
@ -306,9 +373,47 @@ impl Grid {
word_delimiters: WORD_DELIMITERS.to_string(),
scrollback_cap: SCROLLBACK_CAP,
last_base: None,
links: Vec::new(),
}
}
/// Set (or clear) the active OSC 8 hyperlink applied to printed cells. An
/// empty/`None` URI ends the current link.
pub fn set_link(&mut self, uri: Option<&str>) {
self.pen.link = match uri {
Some(uri) if !uri.is_empty() => Some(self.intern_link(uri)),
_ => None,
};
}
/// Intern a hyperlink URI, returning its 1-based id (deduplicated; the table
/// is capped so a pathological stream cannot grow it without bound).
fn intern_link(&mut self, uri: &str) -> NonZeroU16 {
if let Some(i) = self.links.iter().position(|u| u.as_ref() == uri) {
return NonZeroU16::new(i as u16 + 1).expect("index + 1 is non-zero");
}
// u16::MAX distinct links is far past any real document; reuse the last
// slot once saturated rather than overflow the id space.
if self.links.len() < usize::from(u16::MAX) - 1 {
self.links.push(uri.into());
} else {
*self.links.last_mut().expect("table is non-empty when full") = uri.into();
}
NonZeroU16::new(self.links.len() as u16).expect("len after push is non-zero")
}
/// The URI for a hyperlink id, if it is still in the table.
pub fn link_uri(&self, id: NonZeroU16) -> Option<&str> {
self.links
.get(usize::from(id.get()) - 1)
.map(|s| s.as_ref())
}
/// The hyperlink id of the cell at an absolute `(row, col)`, if any.
pub fn link_at(&self, abs_row: usize, col: usize) -> Option<NonZeroU16> {
self.abs_row(abs_row).get(col).and_then(|c| c.link)
}
/// Override the word-delimiter set; `None` keeps the built-in default.
pub fn set_word_delimiters(&mut self, delims: Option<String>) {
if let Some(d) = delims {
@ -1015,6 +1120,49 @@ impl Grid {
self.scrollback.len() - self.view_offset + y
}
/// The line at an absolute row (scrollback first, then the live screen).
fn line_at_abs(&self, abs: usize) -> &Line {
if abs < self.scrollback.len() {
&self.scrollback[abs]
} else {
&self.lines[abs - self.scrollback.len()]
}
}
/// Detect plain-text URLs across the visible viewport, returning each with
/// the viewport `(row, col)` of its first character. Soft-wrapped rows are
/// joined so a URL split across a wrap is found whole; hard line breaks end
/// a URL.
pub fn visible_urls(&self) -> Vec<UrlHit> {
let mut chars: Vec<char> = Vec::new();
let mut pos: Vec<(usize, usize)> = Vec::new();
for y in 0..self.rows {
let line = self.line_at_abs(self.view_to_abs(y));
for (x, cell) in line.cells.iter().enumerate() {
if cell.flags.contains(Flags::WIDE_CONT) {
continue;
}
chars.push(cell.c);
pos.push((y, x));
}
if !line.wrapped {
chars.push('\n'); // a hard break terminates any URL
pos.push((y, usize::MAX));
}
}
find_urls(&chars)
.into_iter()
.map(|(s, e)| {
let (row, col) = pos[s];
UrlHit {
url: chars[s..e].iter().collect(),
row,
col,
}
})
.collect()
}
/// Cells of an absolute row (scrollback first, then the live screen).
fn abs_row(&self, row: usize) -> &[Cell] {
if row < self.scrollback.len() {
@ -1508,6 +1656,30 @@ mod tests {
assert_eq!(g.selection_text().as_deref(), Some("e\u{0301}x"));
}
#[test]
fn detects_urls_trimming_trailing_punctuation() {
let mut g = Grid::new(60, 2);
for c in "see https://example.com/p?q=1, ok".chars() {
g.print(c);
}
let hits = g.visible_urls();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].url, "https://example.com/p?q=1");
assert_eq!((hits[0].row, hits[0].col), (0, 4));
}
#[test]
fn detects_url_across_a_soft_wrap() {
// 20 cols: the URL wraps, but autowrap sets the wrapped flag so it rejoins.
let mut g = Grid::new(20, 3);
for c in "x https://example.com/averylongpath".chars() {
g.print(c);
}
let hits = g.visible_urls();
assert_eq!(hits.len(), 1);
assert_eq!(hits[0].url, "https://example.com/averylongpath");
}
#[test]
fn prompt_mark_survives_reflow() {
let mut g = Grid::new(10, 4);