diff --git a/src/bindings.rs b/src/bindings.rs index 1d8db98..cebb6de 100644 --- a/src/bindings.rs +++ b/src/bindings.rs @@ -24,6 +24,7 @@ pub enum Action { JumpPromptUp, JumpPromptDown, PipeCommandOutput, + UrlMode, } impl Action { @@ -45,6 +46,7 @@ impl Action { "jump-prompt-up" => Self::JumpPromptUp, "jump-prompt-down" => Self::JumpPromptDown, "pipe-command-output" => Self::PipeCommandOutput, + "url-mode" => Self::UrlMode, _ => return None, }) } @@ -182,6 +184,7 @@ const DEFAULT_BINDINGS: &[(&str, &str)] = &[ ("Ctrl+Shift+N", "new-window"), ("Ctrl+Shift+Up", "jump-prompt-up"), ("Ctrl+Shift+Down", "jump-prompt-down"), + ("Ctrl+Shift+O", "url-mode"), ]; /// Map a key token to a keysym: a single character, or a named special key. diff --git a/src/config.rs b/src/config.rs index 83e4204..64ab13d 100644 --- a/src/config.rs +++ b/src/config.rs @@ -18,6 +18,7 @@ pub struct Config { pub bell: Bell, pub mouse: Mouse, pub shell_integration: ShellIntegration, + pub url: Url, /// Chord → action, e.g. `"Ctrl+Shift+C" = "copy"`. Merged over the defaults; /// a value of `"none"` unbinds. pub key_bindings: std::collections::HashMap, @@ -73,6 +74,22 @@ pub struct ShellIntegration { pub pipe_command: Vec, } +/// `[url]`: opening OSC 8 hyperlinks and detected URLs. +#[derive(Debug, Clone, Deserialize)] +#[serde(default, rename_all = "kebab-case")] +pub struct Url { + /// Launcher argv the URL is appended to (e.g. `["xdg-open"]`). + pub launch: Vec, +} + +impl Default for Url { + fn default() -> Self { + Self { + launch: vec!["xdg-open".to_string()], + } + } +} + /// `[colors]`: foreground/background, the 16 base palette entries, and accents. /// Each value is an X11 colour spec (`#rrggbb` or `rgb:rr/gg/bb`); unset entries /// keep the built-in default. diff --git a/src/grid.rs b/src/grid.rs index 3f4f8b0..66124aa 100644 --- a/src/grid.rs +++ b/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>, + /// OSC 8 hyperlink: a 1-based index into the grid's link table, or `None`. + pub link: Option, } 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>, } fn default_tabs(cols: usize) -> Vec { @@ -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 { + 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) { 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 { + let mut chars: Vec = 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); diff --git a/src/render.rs b/src/render.rs index 3f7c336..e726046 100644 --- a/src/render.rs +++ b/src/render.rs @@ -5,6 +5,8 @@ //! then glyphs - so a wide glyph that overflows its cell is not clipped by the //! neighbouring cell's background fill. +use std::num::NonZeroU16; + use crate::font::{CellMetrics, Fonts, GlyphData, Style}; use crate::grid::{Cell, CursorShape, Flags, Grid, Underline}; use crate::theme::{Plane, Rgb, Theme}; @@ -94,6 +96,8 @@ pub struct Frame<'a> { pub theme: &'a Theme, pub focused: bool, pub blink_on: bool, + /// Hyperlink currently under the pointer; its cells get a hover underline. + pub hovered_link: Option, } #[derive(Debug)] @@ -217,6 +221,10 @@ impl Renderer { } } draw_decorations(&mut canvas, cell, theme, origin_x, row_top, m, fg); + // Underline an OSC 8 hyperlink while the pointer hovers over it. + if cell.link.is_some() && cell.link == frame.hovered_link { + canvas.hline(origin_x, row_top + m.height as i32 - 2, m.width, fg); + } } // The cursor belongs to the live screen; hide it while scrolled back. @@ -262,6 +270,43 @@ impl Renderer { } } + /// Draw a URL hint label (e.g. `a`, `bc`) as a highlighted tag starting at + /// viewport cell `(row, col)`, over whatever was there. + pub fn render_label( + &mut self, + pixels: &mut [u8], + dims: (usize, usize), + theme: &Theme, + row: usize, + col: usize, + text: &str, + ) { + let (width, height) = dims; + let mut canvas = Canvas { + pixels, + width, + height, + }; + let m = self.fonts.metrics(); + let (pad_x, pad_y) = self.pad; + let row_top = pad_y + row as i32 * m.height as i32; + let style = Style { + bold: true, + italic: false, + }; + let mut x = pad_x + col as i32 * m.width as i32; + for c in text.chars() { + if x as usize + m.width as usize > width { + break; + } + canvas.fill_rect(x, row_top, m.width, m.height, theme.current_match_bg); + if c != ' ' { + self.draw_glyph(&mut canvas, c, style, x, row_top, theme.bg); + } + x += m.width as i32; + } + } + /// Draw the IME preedit string inline, starting at grid cell `start_col` of /// row `row`, over whatever was there. The preedit sits on the selection /// background and is underlined so it reads as uncommitted, in-flight text. diff --git a/src/vt.rs b/src/vt.rs index d2e62ae..4e32815 100644 --- a/src/vt.rs +++ b/src/vt.rs @@ -680,6 +680,17 @@ impl Perform for Term { self.grid.set_prompt_mark(kind); } } + // OSC 8: hyperlink. `OSC 8 ; params ; URI ST`; an empty URI ends the + // link. The URI is everything after the second field, rejoined since + // a URI may itself contain ';'. + Some(&n) if n == b"8" => { + let uri_bytes = params + .get(2..) + .map(|parts| parts.join(&b';')) + .unwrap_or_default(); + let uri = std::str::from_utf8(&uri_bytes).unwrap_or(""); + self.grid.set_link((!uri.is_empty()).then_some(uri)); + } // OSC 4: set/query palette entries (pairs of index;spec). Some(&n) if n == b"4" => self.osc_palette(params, bell), // OSC 104: reset palette (all, or the listed indices). diff --git a/src/wayland.rs b/src/wayland.rs index e0c267c..90610f4 100644 --- a/src/wayland.rs +++ b/src/wayland.rs @@ -5,6 +5,7 @@ use std::fs::File; use std::io::{Read as _, Write as _}; +use std::num::NonZeroU16; use std::os::fd::OwnedFd; use std::os::unix::process::ExitStatusExt; use std::process::ExitCode; @@ -19,7 +20,7 @@ use calloop_wayland_source::WaylandSource; use crate::config::Config; use crate::font::Fonts; -use crate::grid::{Cell, CursorShape, Grid, MouseProtocol}; +use crate::grid::{Cell, CursorShape, Grid, MouseProtocol, UrlHit}; use crate::pty::Pty; use crate::render::Renderer; use crate::vt::Term; @@ -273,6 +274,9 @@ pub fn run(config: Config, config_path: Option) -> anyhow::R clipboard: String::new(), primary_clip: String::new(), selecting: false, + hovered_link: None, + pointer_enter_serial: 0, + press_cell: None, pressed_button: None, last_report_cell: None, autoscroll: 0, @@ -302,6 +306,10 @@ pub fn run(config: Config, config_path: Option) -> anyhow::R flashing: false, flash_timer: None, searching: false, + url_mode: false, + url_hits: Vec::new(), + url_labels: Vec::new(), + url_input: String::new(), focused: true, exit: false, exit_code: ExitCode::SUCCESS, @@ -448,6 +456,12 @@ struct App { primary_clip: String, /// A left-button drag is in progress. selecting: bool, + /// OSC 8 hyperlink under the pointer, underlined and opened on click. + hovered_link: Option, + /// Serial of the last pointer enter, reused to update the cursor shape. + pointer_enter_serial: u32, + /// Cell `(abs_row, col)` of the last left-press, for click-to-open links. + press_cell: Option<(usize, usize)>, /// Button base code held down while mouse reporting, for drag reports. pressed_button: Option, /// Last cell a motion report was emitted for, to suppress duplicates. @@ -496,6 +510,13 @@ struct App { flash_timer: Option, /// Whether incremental search mode is active (the query lives in the grid). searching: bool, + /// URL hint mode: detected URLs get keyboard labels to open them. + url_mode: bool, + /// Detected URLs and their hint labels (parallel), while `url_mode` is on. + url_hits: Vec, + url_labels: Vec, + /// Label characters typed so far in URL mode. + url_input: String, /// Whether the toplevel currently has keyboard focus (drives the cursor). focused: bool, exit: bool, @@ -572,7 +593,11 @@ impl App { /// text bindings, else the byte encoding sent to the shell (which snaps the /// viewport back to the live screen). fn handle_key(&mut self, event: &KeyEvent) { - // While searching, the keyboard edits the query and navigates matches. + // URL hint mode and search both capture the keyboard while active. + if self.url_mode { + self.url_key(event); + return; + } if self.searching { self.search_key(event); return; @@ -642,6 +667,69 @@ impl App { Action::JumpPromptUp => self.jump_prompt(true), Action::JumpPromptDown => self.jump_prompt(false), Action::PipeCommandOutput => self.pipe_command_output(), + Action::UrlMode => self.enter_url_mode(), + } + } + + /// Enter URL hint mode: detect the visible URLs and label them. No-op (with + /// a brief log) when there are none. + fn enter_url_mode(&mut self) { + let Some(session) = self.session.as_ref() else { + return; + }; + let hits = session.term.grid().visible_urls(); + if hits.is_empty() { + return; + } + self.url_labels = hint_labels(hits.len()); + self.url_hits = hits; + self.url_input = String::new(); + self.url_mode = true; + self.needs_draw = true; + } + + /// Leave URL hint mode, discarding any partial label input. + fn exit_url_mode(&mut self) { + self.url_mode = false; + self.url_hits.clear(); + self.url_labels.clear(); + self.url_input.clear(); + // Drop the labelled buffers so the next present repaints without labels. + self.frames.clear(); + self.needs_draw = true; + } + + /// Handle a key while URL hint mode is active: build up a label, open the + /// matching URL, or cancel. + fn url_key(&mut self, event: &KeyEvent) { + match event.keysym { + Keysym::Escape => self.exit_url_mode(), + Keysym::BackSpace => { + self.url_input.pop(); + self.needs_draw = true; + } + _ => { + let Some(text) = event.utf8.as_ref() else { + return; + }; + for c in text.chars().filter(|c| c.is_ascii_alphabetic()) { + self.url_input.push(c.to_ascii_lowercase()); + } + // Exact match opens; if no label even has this prefix, cancel. + if let Some(i) = self.url_labels.iter().position(|l| *l == self.url_input) { + let url = self.url_hits[i].url.clone(); + self.exit_url_mode(); + self.open_url(&url); + } else if !self + .url_labels + .iter() + .any(|l| l.starts_with(&self.url_input)) + { + self.exit_url_mode(); + } else { + self.needs_draw = true; + } + } } } @@ -845,6 +933,75 @@ impl App { self.needs_draw = true; } + /// The OSC 8 hyperlink id under the pointer, if any. + fn link_under_pointer(&self) -> Option { + let (row, col) = self.cell_at(self.pointer_pos.0, self.pointer_pos.1)?; + self.session.as_ref()?.term.grid().link_at(row, col) + } + + /// Recompute the hyperlink under the pointer; when it changes, repaint to + /// move the hover underline and update the pointer to a hand over a link. + fn update_hover(&mut self, pointer: &wl_pointer::WlPointer) { + let link = self.link_under_pointer(); + if link == self.hovered_link { + return; + } + self.hovered_link = link; + // The hover underline lives in every buffer's snapshot; drop the ring so + // the affected rows repaint with (or without) it. + self.frames.clear(); + self.needs_draw = true; + let shape = if link.is_some() { + Shape::Pointer + } else { + Shape::Text + }; + if let Some(device) = self + .seats + .iter() + .find(|s| s.pointer.as_ref() == Some(pointer)) + .and_then(|s| s.cursor_shape_device.as_ref()) + { + device.set_shape(self.pointer_enter_serial, shape); + } + } + + /// If the left button was pressed and released on the same hyperlinked cell + /// (a click, not a drag), open the link. + fn maybe_open_clicked_link(&mut self) { + let release = self.cell_at(self.pointer_pos.0, self.pointer_pos.1); + let Some((row, col)) = release else { return }; + if self.press_cell != Some((row, col)) { + return; + } + let uri = self + .session + .as_ref() + .and_then(|s| s.term.grid().link_at(row, col).map(|id| (s, id))) + .and_then(|(s, id)| s.term.grid().link_uri(id)) + .map(str::to_owned); + if let Some(uri) = uri { + self.open_url(&uri); + } + } + + /// Launch the configured opener (default `xdg-open`) on a URL. + fn open_url(&self, url: &str) { + let Some((program, args)) = self.config.url.launch.split_first() else { + tracing::warn!("open url: no [url] launch command configured"); + return; + }; + let mut cmd = std::process::Command::new(program); + cmd.args(args) + .arg(url) + .stdin(std::process::Stdio::null()) + .stdout(std::process::Stdio::null()) + .stderr(std::process::Stdio::null()); + if let Err(err) = cmd.spawn() { + tracing::warn!("open url {url:?}: {err}"); + } + } + /// Scale a logical pixel length to physical (buffer) pixels at the current /// fractional scale, rounding to nearest. fn to_phys(&self, v: u32) -> u32 { @@ -1518,6 +1675,11 @@ impl App { /// them, damage just those rows, and commit with a frame-callback request. fn present(&mut self) { self.needs_draw = false; + // URL hint labels overlay the grid but are not part of the row snapshot, + // so force a full redraw while the labels are showing. + if self.url_mode { + self.frames.clear(); + } // Render into a buffer sized in physical pixels (logical × scale); the // viewport presents it back at the logical surface size. let (w, h) = self.phys_dims(); @@ -1623,6 +1785,7 @@ impl App { theme, focused, blink_on, + hovered_link: self.hovered_link, }; if fresh { self.renderer.clear(canvas, dims, theme); @@ -1644,6 +1807,15 @@ impl App { .render_preedit(canvas, dims, theme, y, *col, text); } } + // Draw URL hint labels on top, narrowing to those matching the input. + if self.url_mode { + for (hit, label) in self.url_hits.iter().zip(&self.url_labels) { + if label.starts_with(&self.url_input) { + self.renderer + .render_label(canvas, dims, theme, hit.row, hit.col, label); + } + } + } self.frames[idx].rows = cur; let surface = self.window.wl_surface(); @@ -1952,6 +2124,31 @@ fn cursor_shape_from(style: Option<&str>) -> Option { } } +/// Generate `n` distinct keyboard hint labels (a, b, …, z, aa, ab, …), all the +/// same length so prefix matching is unambiguous. +fn hint_labels(n: usize) -> Vec { + const ALPHABET: &[u8] = b"abcdefghijklmnopqrstuvwxyz"; + if n == 0 { + return Vec::new(); + } + let (mut width, mut capacity) = (1usize, 26usize); + while capacity < n { + width += 1; + capacity *= 26; + } + (0..n) + .map(|i| { + let mut idx = i; + let mut chars = vec![b'a'; width]; + for slot in chars.iter_mut().rev() { + *slot = ALPHABET[idx % 26]; + idx /= 26; + } + String::from_utf8(chars).expect("ascii labels are valid utf-8") + }) + .collect() +} + /// Map a Wayland button code to the terminal mouse base code, if reportable. fn button_code(button: u32) -> Option { match button { @@ -1976,14 +2173,8 @@ impl PointerHandler for App { match &event.kind { PointerEventKind::Enter { serial } => { self.pointer_pos = event.position; - let device = self - .seats - .iter() - .find(|s| s.pointer.as_ref() == Some(pointer)) - .and_then(|s| s.cursor_shape_device.as_ref()); - if let Some(device) = device { - device.set_shape(*serial, Shape::Text); - } + self.pointer_enter_serial = *serial; + self.update_hover(pointer); self.pointer_drag(); } PointerEventKind::Motion { .. } => { @@ -1991,6 +2182,9 @@ impl PointerHandler for App { if self.try_report_motion() { continue; } + if !self.selecting { + self.update_hover(pointer); + } self.pointer_drag(); } PointerEventKind::Press { @@ -2008,7 +2202,10 @@ impl PointerHandler for App { continue; } match *button { - BTN_LEFT => self.pointer_press(*time), + BTN_LEFT => { + self.press_cell = self.cell_at(self.pointer_pos.0, self.pointer_pos.1); + self.pointer_press(*time); + } BTN_MIDDLE => self.paste_primary(), _ => {} } @@ -2025,6 +2222,7 @@ impl PointerHandler for App { continue; } if *button == BTN_LEFT { + self.maybe_open_clicked_link(); self.pointer_release(qh); } }