clone
ssh://git@git.notashelf.dev:33/notashelf/beer.gitCommit 2161d7250fa0
tarballunverified6 files changed+457-11
@@ -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.@@ -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<String, String>,@@ -73,6 +74,22 @@ pub struct ShellIntegration { pub pipe_command: Vec<String>, } +/// `[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<String>,+}++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.@@ -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,7 +373,45 @@ 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.@@ -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() {@@ -1509,6 +1657,30 @@ mod tests { } #[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); g.set_prompt_mark(PromptKind::OutputStart); // marks line 0@@ -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<NonZeroU16>, } #[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.@@ -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).@@ -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<std::path::PathBuf>) -> 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<std::path::PathBuf>) -> 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<NonZeroU16>,+ /// 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<u8>, /// Last cell a motion report was emitted for, to suppress duplicates.@@ -496,6 +510,13 @@ struct App { flash_timer: Option<RegistrationToken>, /// 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<UrlHit>,+ url_labels: Vec<String>,+ /// 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<NonZeroU16> {+ 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<CursorShape> { } } +/// 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<String> {+ 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<u8> { 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); } }