Files
ghostty-rs/crates/ghostty-renderer/src/link.rs
T
ddidderr 871c301713 feat(renderer): port the generic renderer, renderer thread and state
Ports renderer/{generic,cell,row,link,State,Overlay,image,Thread}.zig:
- generic.rs builds GPU cell buffers from terminal RenderState each
  frame: backgrounds, shaped text runs with glyph atlases and nerd font
  constraints, all cursor styles, decorations via sprites, selection and
  search colors, min-contrast, bold/faint/blink handling, preedit,
  padding extension, background image, custom shader uniforms, link
  hover underlines, scrollbar and health reporting.
- The renderer thread updates CPU-side state only and asks the host to
  redraw; the app runtime calls draw_frame from the GLArea render signal
  (the GL context lives on the GTK thread, as in Ghostty).
- ghostty_renderer::state::{State, SharedState} is the canonical shared
  renderer state (termio's interim copy will switch to it).

Deviations: overlay uses a direct RGBA rasterizer instead of z2d; bg
images decode with png/jpeg-decoder instead of wuffs; zero-length link
matches advance by one char (Zig would loop forever).

Kitty image rendering is implemented in image::State but not yet called
from generic::update_kitty (raced with the terminal-side wiring).

Test Plan:
- cargo test -p ghostty-renderer (69 unit + 9 headless EGL tests that
  render a Terminal through the full pipeline and check pixels)
2026-10-11 23:09:52 +02:00

286 lines
9.3 KiB
Rust

//! Link matching for the renderer (`renderer/link.zig`).
use std::collections::HashSet;
use ghostty_input::Mods;
use ghostty_input::link::{Highlight, Link as InputLink};
use ghostty_terminal::point::Coordinate;
use ghostty_terminal::render::RenderState;
use onig::{Regex, Region, SearchOptions};
#[derive(Debug, thiserror::Error)]
pub enum Error {
/// A configured link regex failed to compile.
#[error("invalid link regex: {0}")]
Regex(#[from] onig::Error),
/// The render state contains an invalid codepoint so it can't be
/// converted to a string.
#[error("render state contains an invalid codepoint")]
InvalidString,
}
/// The link configuration needed for renderers.
pub struct Link {
/// The regular expression to match the link against.
pub regex: Regex,
/// The situations in which the link should be highlighted.
pub highlight: Highlight,
}
/// A set of links. This provides a higher level API for renderers
/// to match against a viewport and determine if cells are part of
/// a link.
#[derive(Default)]
pub struct Set {
pub links: Vec<Link>,
}
impl Set {
/// Returns the set of links from the configuration.
pub fn from_config(config: &[InputLink]) -> Result<Set, Error> {
let mut links = Vec::with_capacity(config.len());
for link in config {
links.push(Link {
regex: Regex::new(&link.regex)?,
highlight: link.highlight,
});
}
Ok(Set { links })
}
/// Fills `result` with the matches from regex link matches.
pub fn render_cell_map(
&self,
result: &mut HashSet<Coordinate>,
render_state: &RenderState,
mouse_viewport: Option<Coordinate>,
mouse_mods: Mods,
) -> Result<(), Error> {
// Fast path, not very likely since we have default links.
if self.links.is_empty() {
return Ok(());
}
// Convert our render state to a string + byte map.
let mut str = String::new();
let mut map: Vec<Coordinate> = Vec::new();
render_state
.string(&mut str, Some(&mut map))
.map_err(|_| Error::InvalidString)?;
// Go through each link and see if we have any matches.
for link in &self.links {
// Determine if our highlight conditions are met. We use a
// match here instead of an if so that we get a compile
// error if any other conditions are added.
match link.highlight {
Highlight::Always => {}
Highlight::AlwaysMods(v) => {
if !mouse_mods.equal(v) {
continue;
}
}
// We check the hover points later.
Highlight::Hover => {
if mouse_viewport.is_none() {
continue;
}
}
Highlight::HoverMods(v) => {
if mouse_viewport.is_none() {
continue;
}
if !mouse_mods.equal(v) {
continue;
}
}
}
let mut offset: usize = 0;
while offset < str.len() {
let haystack = &str[offset..];
let mut region = Region::new();
let found = link.regex.search_with_options(
haystack,
0,
haystack.len(),
SearchOptions::SEARCH_OPTION_NONE,
Some(&mut region),
);
// A mismatch (or any search failure such as hitting the
// retry limit) ends the search for this link.
let Some((offset_start, offset_end)) = found.and(region.pos(0)) else {
break;
};
// We have a match!
let start = offset + offset_start;
let end = offset + offset_end;
// Increment our offset by the number of bytes in the match.
// We do this after handling the match (Zig: `defer`). A
// zero-length match advances by one character so we always
// make progress (Zig would loop forever here).
let next_offset = if end > offset {
end
} else {
let step = str[offset..].chars().next().map_or(1, char::len_utf8);
offset + step
};
let hovered = match link.highlight {
Highlight::Always | Highlight::AlwaysMods(_) => true,
Highlight::Hover | Highlight::HoverMods(_) => match mouse_viewport {
Some(vp) => map[start..end].contains(&vp),
None => false,
},
};
if hovered {
// Record the match
for pt in &map[start..end] {
result.insert(*pt);
}
}
offset = next_offset;
}
}
Ok(())
}
}
#[cfg(test)]
mod tests {
use super::*;
use ghostty_input::link::Action;
use ghostty_terminal::terminal::{Options, Terminal};
fn link(regex: &str, highlight: Highlight) -> InputLink {
InputLink {
regex: regex.to_string(),
action: Action::Open,
highlight,
}
}
fn state() -> RenderState {
let mut t = Terminal::new(Options::new(5, 3)).unwrap();
{
let mut s = t.vt_stream();
s.next_slice(b"1ABCD2EFGH\r\n3IJKL").unwrap();
}
let mut state = RenderState::new();
state.update(&mut t).unwrap();
state
}
fn c(x: u16, y: u32) -> Coordinate {
Coordinate { x, y }
}
#[test]
fn render_cell_map() {
let state = state();
let set = Set::from_config(&[link("AB", Highlight::Always), link("EF", Highlight::Always)])
.unwrap();
// Get our matches
let mut result = HashSet::new();
set.render_cell_map(&mut result, &state, None, Mods::default())
.unwrap();
assert!(!result.contains(&c(0, 0)));
assert!(result.contains(&c(1, 0)));
assert!(result.contains(&c(2, 0)));
assert!(!result.contains(&c(3, 0)));
assert!(result.contains(&c(1, 1)));
assert!(!result.contains(&c(1, 2)));
}
#[test]
fn render_cell_map_hover_links() {
let state = state();
let set = Set::from_config(&[link("AB", Highlight::Hover), link("EF", Highlight::Always)])
.unwrap();
// Not hovering over the first link
{
let mut result = HashSet::new();
set.render_cell_map(&mut result, &state, None, Mods::default())
.unwrap();
assert!(!result.contains(&c(0, 0)));
assert!(!result.contains(&c(1, 0)));
assert!(!result.contains(&c(2, 0)));
assert!(!result.contains(&c(3, 0)));
assert!(result.contains(&c(1, 1)));
assert!(!result.contains(&c(1, 2)));
}
// Hovering over the first link
{
let mut result = HashSet::new();
set.render_cell_map(&mut result, &state, Some(c(1, 0)), Mods::default())
.unwrap();
assert!(!result.contains(&c(0, 0)));
assert!(result.contains(&c(1, 0)));
assert!(result.contains(&c(2, 0)));
assert!(!result.contains(&c(3, 0)));
assert!(result.contains(&c(1, 1)));
assert!(!result.contains(&c(1, 2)));
}
}
#[test]
fn render_cell_map_mods_no_match() {
let state = state();
let set = Set::from_config(&[
link("AB", Highlight::Always),
link(
"EF",
Highlight::AlwaysMods(Mods {
ctrl: true,
..Mods::default()
}),
),
])
.unwrap();
let mut result = HashSet::new();
set.render_cell_map(&mut result, &state, None, Mods::default())
.unwrap();
assert!(!result.contains(&c(0, 0)));
assert!(result.contains(&c(1, 0)));
assert!(result.contains(&c(2, 0)));
assert!(!result.contains(&c(3, 0)));
assert!(!result.contains(&c(1, 1)));
assert!(!result.contains(&c(1, 2)));
}
#[test]
fn default_url_regex_matches() {
let mut t = Terminal::new(Options::new(30, 2)).unwrap();
{
let mut s = t.vt_stream();
s.next_slice(b"see https://example.com now").unwrap();
}
let mut state = RenderState::new();
state.update(&mut t).unwrap();
let set = Set::from_config(&[link(ghostty_config::url::REGEX, Highlight::Always)]).unwrap();
let mut result = HashSet::new();
set.render_cell_map(&mut result, &state, None, Mods::default())
.unwrap();
assert!(!result.contains(&c(0, 0)));
assert!(result.contains(&c(4, 0)));
assert!(result.contains(&c(20, 0)));
assert!(!result.contains(&c(24, 0)));
}
#[test]
fn invalid_regex_is_error() {
assert!(Set::from_config(&[link("(", Highlight::Always)]).is_err());
}
}