render: OSC 8 hyperlinks with hover/click and a URL hint mode

Signed-off-by: NotAShelf <raf@notashelf.dev>
Change-Id: I7b39adae426d3fc5b7dfe1437eb10e976a6a6964
This commit is contained in:
raf 2026-06-25 13:48:20 +03:00
commit 2161d7250f
No known key found for this signature in database
GPG key ID: 29D95B64378DB4BF
6 changed files with 457 additions and 11 deletions

View file

@ -24,6 +24,7 @@ pub enum Action {
JumpPromptUp, JumpPromptUp,
JumpPromptDown, JumpPromptDown,
PipeCommandOutput, PipeCommandOutput,
UrlMode,
} }
impl Action { impl Action {
@ -45,6 +46,7 @@ impl Action {
"jump-prompt-up" => Self::JumpPromptUp, "jump-prompt-up" => Self::JumpPromptUp,
"jump-prompt-down" => Self::JumpPromptDown, "jump-prompt-down" => Self::JumpPromptDown,
"pipe-command-output" => Self::PipeCommandOutput, "pipe-command-output" => Self::PipeCommandOutput,
"url-mode" => Self::UrlMode,
_ => return None, _ => return None,
}) })
} }
@ -182,6 +184,7 @@ const DEFAULT_BINDINGS: &[(&str, &str)] = &[
("Ctrl+Shift+N", "new-window"), ("Ctrl+Shift+N", "new-window"),
("Ctrl+Shift+Up", "jump-prompt-up"), ("Ctrl+Shift+Up", "jump-prompt-up"),
("Ctrl+Shift+Down", "jump-prompt-down"), ("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. /// Map a key token to a keysym: a single character, or a named special key.

View file

@ -18,6 +18,7 @@ pub struct Config {
pub bell: Bell, pub bell: Bell,
pub mouse: Mouse, pub mouse: Mouse,
pub shell_integration: ShellIntegration, pub shell_integration: ShellIntegration,
pub url: Url,
/// Chord → action, e.g. `"Ctrl+Shift+C" = "copy"`. Merged over the defaults; /// Chord → action, e.g. `"Ctrl+Shift+C" = "copy"`. Merged over the defaults;
/// a value of `"none"` unbinds. /// a value of `"none"` unbinds.
pub key_bindings: std::collections::HashMap<String, String>, pub key_bindings: std::collections::HashMap<String, String>,
@ -73,6 +74,22 @@ pub struct ShellIntegration {
pub pipe_command: Vec<String>, 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. /// `[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 /// Each value is an X11 colour spec (`#rrggbb` or `rgb:rr/gg/bb`); unset entries
/// keep the built-in default. /// keep the built-in default.

View file

@ -2,6 +2,7 @@
//! operations the VT parser drives. //! operations the VT parser drives.
use std::collections::VecDeque; use std::collections::VecDeque;
use std::num::NonZeroU16;
use unicode_width::UnicodeWidthChar; 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 common case; the renderer stacks each over the base glyph. This is
/// the grapheme cluster a future shaper (HarfBuzz) would consume. /// the grapheme cluster a future shaper (HarfBuzz) would consume.
pub combining: Option<Box<str>>, 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 { impl Default for Cell {
@ -129,6 +132,7 @@ impl Default for Cell {
underline: Underline::None, underline: Underline::None,
underline_color: Color::Default, underline_color: Color::Default,
combining: None, combining: None,
link: None,
} }
} }
} }
@ -139,6 +143,67 @@ struct Cursor {
y: usize, 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, /// 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 /// the start of typed command input, the start of command output, or the line
/// where the command finished. /// where the command finished.
@ -252,6 +317,8 @@ pub struct Grid {
/// Position of the last printed base cell, so a following zero-width /// Position of the last printed base cell, so a following zero-width
/// combining mark can attach to it. /// combining mark can attach to it.
last_base: Option<(usize, usize)>, 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> { fn default_tabs(cols: usize) -> Vec<bool> {
@ -306,9 +373,47 @@ impl Grid {
word_delimiters: WORD_DELIMITERS.to_string(), word_delimiters: WORD_DELIMITERS.to_string(),
scrollback_cap: SCROLLBACK_CAP, scrollback_cap: SCROLLBACK_CAP,
last_base: None, 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. /// Override the word-delimiter set; `None` keeps the built-in default.
pub fn set_word_delimiters(&mut self, delims: Option<String>) { pub fn set_word_delimiters(&mut self, delims: Option<String>) {
if let Some(d) = delims { if let Some(d) = delims {
@ -1015,6 +1120,49 @@ impl Grid {
self.scrollback.len() - self.view_offset + y 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). /// Cells of an absolute row (scrollback first, then the live screen).
fn abs_row(&self, row: usize) -> &[Cell] { fn abs_row(&self, row: usize) -> &[Cell] {
if row < self.scrollback.len() { if row < self.scrollback.len() {
@ -1508,6 +1656,30 @@ mod tests {
assert_eq!(g.selection_text().as_deref(), Some("e\u{0301}x")); 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] #[test]
fn prompt_mark_survives_reflow() { fn prompt_mark_survives_reflow() {
let mut g = Grid::new(10, 4); let mut g = Grid::new(10, 4);

View file

@ -5,6 +5,8 @@
//! then glyphs - so a wide glyph that overflows its cell is not clipped by the //! then glyphs - so a wide glyph that overflows its cell is not clipped by the
//! neighbouring cell's background fill. //! neighbouring cell's background fill.
use std::num::NonZeroU16;
use crate::font::{CellMetrics, Fonts, GlyphData, Style}; use crate::font::{CellMetrics, Fonts, GlyphData, Style};
use crate::grid::{Cell, CursorShape, Flags, Grid, Underline}; use crate::grid::{Cell, CursorShape, Flags, Grid, Underline};
use crate::theme::{Plane, Rgb, Theme}; use crate::theme::{Plane, Rgb, Theme};
@ -94,6 +96,8 @@ pub struct Frame<'a> {
pub theme: &'a Theme, pub theme: &'a Theme,
pub focused: bool, pub focused: bool,
pub blink_on: bool, pub blink_on: bool,
/// Hyperlink currently under the pointer; its cells get a hover underline.
pub hovered_link: Option<NonZeroU16>,
} }
#[derive(Debug)] #[derive(Debug)]
@ -217,6 +221,10 @@ impl Renderer {
} }
} }
draw_decorations(&mut canvas, cell, theme, origin_x, row_top, m, fg); 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. // 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 /// 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 /// row `row`, over whatever was there. The preedit sits on the selection
/// background and is underlined so it reads as uncommitted, in-flight text. /// background and is underlined so it reads as uncommitted, in-flight text.

View file

@ -680,6 +680,17 @@ impl Perform for Term {
self.grid.set_prompt_mark(kind); 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). // OSC 4: set/query palette entries (pairs of index;spec).
Some(&n) if n == b"4" => self.osc_palette(params, bell), Some(&n) if n == b"4" => self.osc_palette(params, bell),
// OSC 104: reset palette (all, or the listed indices). // OSC 104: reset palette (all, or the listed indices).

View file

@ -5,6 +5,7 @@
use std::fs::File; use std::fs::File;
use std::io::{Read as _, Write as _}; use std::io::{Read as _, Write as _};
use std::num::NonZeroU16;
use std::os::fd::OwnedFd; use std::os::fd::OwnedFd;
use std::os::unix::process::ExitStatusExt; use std::os::unix::process::ExitStatusExt;
use std::process::ExitCode; use std::process::ExitCode;
@ -19,7 +20,7 @@ use calloop_wayland_source::WaylandSource;
use crate::config::Config; use crate::config::Config;
use crate::font::Fonts; 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::pty::Pty;
use crate::render::Renderer; use crate::render::Renderer;
use crate::vt::Term; use crate::vt::Term;
@ -273,6 +274,9 @@ pub fn run(config: Config, config_path: Option<std::path::PathBuf>) -> anyhow::R
clipboard: String::new(), clipboard: String::new(),
primary_clip: String::new(), primary_clip: String::new(),
selecting: false, selecting: false,
hovered_link: None,
pointer_enter_serial: 0,
press_cell: None,
pressed_button: None, pressed_button: None,
last_report_cell: None, last_report_cell: None,
autoscroll: 0, autoscroll: 0,
@ -302,6 +306,10 @@ pub fn run(config: Config, config_path: Option<std::path::PathBuf>) -> anyhow::R
flashing: false, flashing: false,
flash_timer: None, flash_timer: None,
searching: false, searching: false,
url_mode: false,
url_hits: Vec::new(),
url_labels: Vec::new(),
url_input: String::new(),
focused: true, focused: true,
exit: false, exit: false,
exit_code: ExitCode::SUCCESS, exit_code: ExitCode::SUCCESS,
@ -448,6 +456,12 @@ struct App {
primary_clip: String, primary_clip: String,
/// A left-button drag is in progress. /// A left-button drag is in progress.
selecting: bool, 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. /// Button base code held down while mouse reporting, for drag reports.
pressed_button: Option<u8>, pressed_button: Option<u8>,
/// Last cell a motion report was emitted for, to suppress duplicates. /// Last cell a motion report was emitted for, to suppress duplicates.
@ -496,6 +510,13 @@ struct App {
flash_timer: Option<RegistrationToken>, flash_timer: Option<RegistrationToken>,
/// Whether incremental search mode is active (the query lives in the grid). /// Whether incremental search mode is active (the query lives in the grid).
searching: bool, 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). /// Whether the toplevel currently has keyboard focus (drives the cursor).
focused: bool, focused: bool,
exit: bool, exit: bool,
@ -572,7 +593,11 @@ impl App {
/// text bindings, else the byte encoding sent to the shell (which snaps the /// text bindings, else the byte encoding sent to the shell (which snaps the
/// viewport back to the live screen). /// viewport back to the live screen).
fn handle_key(&mut self, event: &KeyEvent) { 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 { if self.searching {
self.search_key(event); self.search_key(event);
return; return;
@ -642,6 +667,69 @@ impl App {
Action::JumpPromptUp => self.jump_prompt(true), Action::JumpPromptUp => self.jump_prompt(true),
Action::JumpPromptDown => self.jump_prompt(false), Action::JumpPromptDown => self.jump_prompt(false),
Action::PipeCommandOutput => self.pipe_command_output(), 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; 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 /// Scale a logical pixel length to physical (buffer) pixels at the current
/// fractional scale, rounding to nearest. /// fractional scale, rounding to nearest.
fn to_phys(&self, v: u32) -> u32 { 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. /// them, damage just those rows, and commit with a frame-callback request.
fn present(&mut self) { fn present(&mut self) {
self.needs_draw = false; 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 // Render into a buffer sized in physical pixels (logical × scale); the
// viewport presents it back at the logical surface size. // viewport presents it back at the logical surface size.
let (w, h) = self.phys_dims(); let (w, h) = self.phys_dims();
@ -1623,6 +1785,7 @@ impl App {
theme, theme,
focused, focused,
blink_on, blink_on,
hovered_link: self.hovered_link,
}; };
if fresh { if fresh {
self.renderer.clear(canvas, dims, theme); self.renderer.clear(canvas, dims, theme);
@ -1644,6 +1807,15 @@ impl App {
.render_preedit(canvas, dims, theme, y, *col, text); .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; self.frames[idx].rows = cur;
let surface = self.window.wl_surface(); 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. /// Map a Wayland button code to the terminal mouse base code, if reportable.
fn button_code(button: u32) -> Option<u8> { fn button_code(button: u32) -> Option<u8> {
match button { match button {
@ -1976,14 +2173,8 @@ impl PointerHandler for App {
match &event.kind { match &event.kind {
PointerEventKind::Enter { serial } => { PointerEventKind::Enter { serial } => {
self.pointer_pos = event.position; self.pointer_pos = event.position;
let device = self self.pointer_enter_serial = *serial;
.seats self.update_hover(pointer);
.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_drag(); self.pointer_drag();
} }
PointerEventKind::Motion { .. } => { PointerEventKind::Motion { .. } => {
@ -1991,6 +2182,9 @@ impl PointerHandler for App {
if self.try_report_motion() { if self.try_report_motion() {
continue; continue;
} }
if !self.selecting {
self.update_hover(pointer);
}
self.pointer_drag(); self.pointer_drag();
} }
PointerEventKind::Press { PointerEventKind::Press {
@ -2008,7 +2202,10 @@ impl PointerHandler for App {
continue; continue;
} }
match *button { 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(), BTN_MIDDLE => self.paste_primary(),
_ => {} _ => {}
} }
@ -2025,6 +2222,7 @@ impl PointerHandler for App {
continue; continue;
} }
if *button == BTN_LEFT { if *button == BTN_LEFT {
self.maybe_open_clicked_link();
self.pointer_release(qh); self.pointer_release(qh);
} }
} }