| 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 | } |