brewery
notashelf /
fb590c1645035cc2537f826f025ac7ea12f5e514

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git
src/grid/links.rsRust136 lines4.9 KB
1
use std::num::NonZeroU16;
2
3
use super::*;
4
5
/// A URL detected in the visible viewport, with the `(row, col)` of its first
6
/// character in viewport coordinates.
7
#[derive(Clone, PartialEq, Eq, Debug)]
8
pub struct UrlHit {
9
    pub url: String,
10
    pub row: usize,
11
    pub col: usize,
12
}
13
14
/// Whether `c` may appear inside a URL (excludes whitespace, controls, and the
15
/// delimiters that conventionally bound a URL in flowing text).
16
fn is_url_char(c: char) -> bool {
17
    !c.is_whitespace()
18
        && !c.is_control()
19
        && !matches!(c, '<' | '>' | '"' | '`' | '{' | '}' | '|' | '\\' | '^')
20
}
21
22
/// Find `scheme://…` URLs in `chars`, returning `(start, end)` index ranges.
23
/// The scheme is a run of `[A-Za-z][A-Za-z0-9+.-]*` before `://`; the body runs
24
/// to the first non-URL character, with trailing sentence punctuation trimmed.
25
fn find_urls(chars: &[char]) -> Vec<(usize, usize)> {
26
    let mut out = Vec::new();
27
    let mut i = 0;
28
    while i + 2 < chars.len() {
29
        if chars[i] == ':' && chars[i + 1] == '/' && chars[i + 2] == '/' {
30
            // Backtrack over the scheme.
31
            let mut start = i;
32
            while start > 0 {
33
                let c = chars[start - 1];
34
                if c.is_ascii_alphanumeric() || matches!(c, '+' | '-' | '.') {
35
                    start -= 1;
36
                } else {
37
                    break;
38
                }
39
            }
40
            if start < i && chars[start].is_ascii_alphabetic() {
41
                let mut end = i + 3;
42
                while end < chars.len() && is_url_char(chars[end]) {
43
                    end += 1;
44
                }
45
                // Trim trailing punctuation that is usually sentence-level.
46
                while end > i + 3
47
                    && matches!(
48
                        chars[end - 1],
49
                        '.' | ',' | ';' | ':' | '!' | '?' | ')' | ']' | '\'' | '"'
50
                    )
51
                {
52
                    end -= 1;
53
                }
54
                if end > i + 3 {
55
                    out.push((start, end));
56
                    i = end;
57
                    continue;
58
                }
59
            }
60
        }
61
        i += 1;
62
    }
63
    out
64
}
65
66
impl Grid {
67
    /// Set (or clear) the active OSC 8 hyperlink applied to printed cells. An
68
    /// empty/`None` URI ends the current link.
69
    pub fn set_link(&mut self, uri: Option<&str>) {
70
        self.pen.link = match uri {
71
            Some(uri) if !uri.is_empty() => Some(self.intern_link(uri)),
72
            _ => None,
73
        };
74
    }
75
76
    /// Intern a hyperlink URI, returning its 1-based id (deduplicated; the table
77
    /// is capped so a pathological stream cannot grow it without bound).
78
    fn intern_link(&mut self, uri: &str) -> NonZeroU16 {
79
        if let Some(i) = self.links.iter().position(|u| u.as_ref() == uri) {
80
            return NonZeroU16::new(i as u16 + 1).expect("index + 1 is non-zero");
81
        }
82
        // u16::MAX distinct links is far past any real document; reuse the last
83
        // slot once saturated rather than overflow the id space.
84
        if self.links.len() < usize::from(u16::MAX) - 1 {
85
            self.links.push(uri.into());
86
        } else {
87
            *self.links.last_mut().expect("table is non-empty when full") = uri.into();
88
        }
89
        NonZeroU16::new(self.links.len() as u16).expect("len after push is non-zero")
90
    }
91
92
    /// The URI for a hyperlink id, if it is still in the table.
93
    pub fn link_uri(&self, id: NonZeroU16) -> Option<&str> {
94
        self.links
95
            .get(usize::from(id.get()) - 1)
96
            .map(|s| s.as_ref())
97
    }
98
99
    /// The hyperlink id of the cell at an absolute `(row, col)`, if any.
100
    pub fn link_at(&self, abs_row: usize, col: usize) -> Option<NonZeroU16> {
101
        self.abs_row(abs_row).get(col).and_then(|c| c.link)
102
    }
103
    /// Detect plain-text URLs across the visible viewport, returning each with
104
    /// the viewport `(row, col)` of its first character. Soft-wrapped rows are
105
    /// joined so a URL split across a wrap is found whole; hard line breaks end
106
    /// a URL.
107
    pub fn visible_urls(&self) -> Vec<UrlHit> {
108
        let mut chars: Vec<char> = Vec::new();
109
        let mut pos: Vec<(usize, usize)> = Vec::new();
110
        for y in 0..self.rows {
111
            let line = self.line_at_abs(self.view_to_abs(y));
112
            for (x, cell) in line.cells.iter().enumerate() {
113
                if cell.flags.contains(Flags::WIDE_CONT) {
114
                    continue;
115
                }
116
                chars.push(cell.c);
117
                pos.push((y, x));
118
            }
119
            if !line.wrapped {
120
                chars.push('\n'); // a hard break terminates any URL
121
                pos.push((y, usize::MAX));
122
            }
123
        }
124
        find_urls(&chars)
125
            .into_iter()
126
            .map(|(s, e)| {
127
                let (row, col) = pos[s];
128
                UrlHit {
129
                    url: chars[s..e].iter().collect(),
130
                    row,
131
                    col,
132
                }
133
            })
134
            .collect()
135
    }
136
}