brewery
notashelf /
2161d7250fa034ede34f5ad7682e06831985967c

beer

public

A terminal worth pouring time into

Star0tarball
clone
ssh://git@git.notashelf.dev:33/notashelf/beer.git

Commit 2161d7250fa0

tarball

NotAShelf <raf@notashelf.dev> · 2026-06-26 07:21 UTC

unverified6 files changed+457-11
render: OSC 8 hyperlinks with hover/click and a URL hint mode

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I7b39adae426d3fc5b7dfe1437eb10e976a6a6964
diff --git a/src/bindings.rs b/src/bindings.rsindex 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.rsindex 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<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.diff --git a/src/grid.rs b/src/grid.rsindex 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<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 0diff --git a/src/render.rs b/src/render.rsindex 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<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.diff --git a/src/vt.rs b/src/vt.rsindex 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.rsindex 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<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);                     }                 }