forked from NotAShelf/beer
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:
parent
72ec651ff1
commit
2161d7250f
6 changed files with 457 additions and 11 deletions
|
|
@ -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.
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
172
src/grid.rs
172
src/grid.rs
|
|
@ -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);
|
||||||
|
|
|
||||||
|
|
@ -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.
|
||||||
|
|
|
||||||
11
src/vt.rs
11
src/vt.rs
|
|
@ -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).
|
||||||
|
|
|
||||||
220
src/wayland.rs
220
src/wayland.rs
|
|
@ -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);
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue