forked from NotAShelf/beer
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:
parent
72ec651ff1
commit
2161d7250f
6 changed files with 457 additions and 11 deletions
172
src/grid.rs
172
src/grid.rs
|
|
@ -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);
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue