aboutsummaryrefslogtreecommitdiff
path: root/src/ghostty
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-13 23:05:30 +0300
committerGitHub <noreply@github.com>2026-09-13 23:05:30 +0300
commitf8e59221cbe5117f8f691a36affa374f5a986a9f (patch)
tree3b2792683eff251b061fbbad7343ecffafe91aab /src/ghostty
parent59167658455442ffd2cd5b72a0fa81bcc4bcdf9e (diff)
feat: add ctrl-hover highlighting and fix wrapped links (#4074)
refs #1282
Diffstat (limited to 'src/ghostty')
-rw-r--r--src/ghostty/bindings.rs9
-rw-r--r--src/ghostty/mod.rs311
2 files changed, 319 insertions, 1 deletions
diff --git a/src/ghostty/bindings.rs b/src/ghostty/bindings.rs
index 49a06ee5..bfa71825 100644
--- a/src/ghostty/bindings.rs
+++ b/src/ghostty/bindings.rs
@@ -1776,6 +1776,15 @@ unsafe extern "C" {
) -> GhosttyResult;
}
unsafe extern "C" {
+ #[doc = " Derive a word selection with a shared forward/backward cell inspection limit.\n\n Like ghostty_terminal_select_word(), but returns GHOSTTY_NO_VALUE if\n max_cells is zero or the search exhausts that budget. No partial selection\n is returned. The existing options structure and unbounded API are unchanged.\n\n @ingroup selection"]
+ pub fn ghostty_terminal_select_word_bounded(
+ terminal: GhosttyTerminal,
+ options: *const GhosttyTerminalSelectWordOptions,
+ max_cells: usize,
+ out_selection: *mut GhosttySelection,
+ ) -> GhosttyResult;
+}
+unsafe extern "C" {
#[doc = " Derive the nearest word selection snapshot between two terminal grid refs.\n\n Starting at options->start, this searches toward options->end (inclusive)\n and returns the first selectable word found using Ghostty's word-selection\n rules.\n\n This is useful for implementing double-click-and-drag selection in a UI. If\n a user double-clicks one word and drags across spaces or punctuation toward\n another word, selecting only the word directly under the current pointer can\n flicker or collapse when the pointer is between words. Instead, ask for the\n nearest word between the original click and the drag point, ask again in the\n reverse direction, and combine the two word bounds into the drag selection.\n\n @snippet c-vt-selection/src/main.c selection-word-between\n\n The returned selection is not installed as the terminal's current\n selection. It is a snapshot with the same lifetime rules as GhosttySelection.\n\n @param terminal The terminal handle (NULL returns GHOSTTY_INVALID_VALUE)\n @param options Word-between-selection options\n @param[out] out_selection On success, receives the derived selection\n @return GHOSTTY_SUCCESS on success, GHOSTTY_NO_VALUE if there is no\n selectable word content between the valid refs, or\n GHOSTTY_INVALID_VALUE if the terminal, options, refs, codepoint\n pointer, or output pointer are invalid.\n\n @ingroup selection"]
pub fn ghostty_terminal_select_word_between(
terminal: GhosttyTerminal,
diff --git a/src/ghostty/mod.rs b/src/ghostty/mod.rs
index 9ec9d324..d1748aad 100644
--- a/src/ghostty/mod.rs
+++ b/src/ghostty/mod.rs
@@ -806,6 +806,12 @@ pub fn encode_focus(event: FocusEvent) -> Result<Vec<u8>, Error> {
Ok(buffer)
}
+#[derive(Debug, PartialEq, Eq)]
+pub(crate) enum LinkTarget {
+ Uri(String),
+ Text { text: String, clicked_byte: usize },
+}
+
pub struct Terminal {
raw: ffi::GhosttyTerminal,
max_scrollback: usize,
@@ -1271,6 +1277,187 @@ impl Terminal {
grid_ref_hyperlink_uri(&grid_ref)
}
+ pub(crate) fn viewport_link_target(&self, x: u16, y: u32) -> Result<Option<LinkTarget>, Error> {
+ Ok(self
+ .viewport_link_selection(x, y)?
+ .map(|(target, _)| target))
+ }
+
+ fn viewport_link_selection(
+ &self,
+ x: u16,
+ y: u32,
+ ) -> Result<Option<(LinkTarget, Option<ffi::GhosttySelection>)>, Error> {
+ let mut clicked = self.grid_ref(ghostty_viewport_point(x, y))?;
+ if grid_ref_wide(&clicked)? == CellWide::SpacerTail {
+ clicked.x = clicked.x.saturating_sub(1);
+ }
+ if let Some(uri) = grid_ref_hyperlink_uri(&clicked)? {
+ return Ok(Some((LinkTarget::Uri(uri), None)));
+ }
+ let graphemes = grid_ref_graphemes(&clicked)?;
+ if graphemes
+ .first()
+ .copied()
+ .and_then(char::from_u32)
+ .is_none_or(char::is_whitespace)
+ {
+ return Ok(None);
+ }
+ // Unicode White_Space, matching Rust's char::is_whitespace URL boundaries.
+ const BOUNDARIES: &[u32] = &[
+ 9, 10, 11, 12, 13, 32, 0x85, 0xa0, 0x1680, 0x2000, 0x2001, 0x2002, 0x2003, 0x2004,
+ 0x2005, 0x2006, 0x2007, 0x2008, 0x2009, 0x200a, 0x2028, 0x2029, 0x202f, 0x205f, 0x3000,
+ ];
+ let options = ffi::GhosttyTerminalSelectWordOptions {
+ size: mem::size_of::<ffi::GhosttyTerminalSelectWordOptions>(),
+ ref_: clicked,
+ boundary_codepoints: BOUNDARIES.as_ptr(),
+ boundary_codepoints_len: BOUNDARIES.len(),
+ };
+ let mut selection = ffi::GhosttySelection::default();
+ // Bound work before formatting, even for an unbroken scrollback-sized token.
+ let result = unsafe {
+ ffi::ghostty_terminal_select_word_bounded(self.raw, &options, 8192, &mut selection)
+ };
+ if result == ffi::GhosttyResult_GHOSTTY_NO_VALUE {
+ return Ok(None);
+ }
+ result.into_result()?;
+ let text = self.format_selection(&selection, FormatterFormat::Plain, true, false)?;
+ let prefix = self.format_selection(
+ &ffi::GhosttySelection {
+ end: clicked,
+ ..selection
+ },
+ FormatterFormat::Plain,
+ true,
+ false,
+ )?;
+ let clicked_len: usize = graphemes
+ .into_iter()
+ .filter_map(char::from_u32)
+ .map(char::len_utf8)
+ .sum();
+ Ok(Some((
+ LinkTarget::Text {
+ text,
+ clicked_byte: prefix.len().saturating_sub(clicked_len),
+ },
+ Some(selection),
+ )))
+ }
+
+ /// Resolve only the bounded plain-text token. OSC 8 regions are resolved by
+ /// clients from frame hyperlink IDs; their full URI activation path is unchanged.
+ pub(crate) fn viewport_link_regions(
+ &self,
+ x: u16,
+ y: u32,
+ resolve: fn(&str, usize) -> Option<std::ops::Range<usize>>,
+ ) -> Result<Vec<crate::api::schema::PaneLinkRegion>, Error> {
+ let cols = self.cols()?;
+ let rows = self.rows()?;
+ if x >= cols || y >= u32::from(rows) {
+ return Ok(Vec::new());
+ }
+ let Some((LinkTarget::Text { text, clicked_byte }, Some(selection))) =
+ self.viewport_link_selection(x, y)?
+ else {
+ return Ok(Vec::new());
+ };
+ let Some(range) = resolve(&text, clicked_byte) else {
+ return Ok(Vec::new());
+ };
+ let point =
+ |grid_ref: &ffi::GhosttyGridRef, clipped: (u16, u32)| -> Result<(u16, u32), Error> {
+ let mut out = ffi::GhosttyPointCoordinate::default();
+ let result = unsafe {
+ ffi::ghostty_terminal_point_from_grid_ref(
+ self.raw,
+ grid_ref,
+ ffi::GhosttyPointTag_GHOSTTY_POINT_TAG_VIEWPORT,
+ &mut out,
+ )
+ };
+ if result == ffi::GhosttyResult_GHOSTTY_NO_VALUE {
+ return Ok(clipped);
+ }
+ result.into_result()?;
+ // C viewport coordinates can extend below the visible viewport
+ // when scrolled up. Clip those as well as NO_VALUE endpoints.
+ if out.y >= u32::from(rows) {
+ return Ok(clipped);
+ }
+ Ok((out.x, out.y))
+ };
+ // The selected token contains the visible hovered cell, so an invisible
+ // start/end necessarily lies above/below the viewport respectively.
+ let (start_col, start_row) = point(&selection.start, (0, 0))?;
+ let (end_col, end_row) = point(&selection.end, (cols - 1, u32::from(rows - 1)))?;
+ let first = self.grid_ref(ghostty_viewport_point(start_col, start_row))?;
+ let cell_len = |cell: &ffi::GhosttyGridRef| -> Result<usize, Error> {
+ if matches!(
+ grid_ref_wide(cell)?,
+ CellWide::SpacerHead | CellWide::SpacerTail
+ ) {
+ return Ok(0);
+ }
+ Ok(grid_ref_graphemes(cell)?
+ .into_iter()
+ .filter_map(char::from_u32)
+ .map(char::len_utf8)
+ .sum())
+ };
+ // Format a prefix once, not once per cell. Then walk only the selected
+ // visible cells, retaining one page/grid lookup per row.
+ let prefix = self.format_selection(
+ &ffi::GhosttySelection {
+ end: first,
+ ..selection
+ },
+ FormatterFormat::Plain,
+ true,
+ false,
+ )?;
+ let mut byte = prefix.len().saturating_sub(cell_len(&first)?);
+ let mut regions: Vec<crate::api::schema::PaneLinkRegion> = Vec::new();
+ for row in start_row..=end_row {
+ let mut cell = self.grid_ref(ghostty_viewport_point(0, row))?;
+ let left = if row == start_row { start_col } else { 0 };
+ let right = if row == end_row { end_col } else { cols - 1 };
+ for col in left..=right {
+ cell.x = col;
+ let wide = grid_ref_wide(&cell)?;
+ if matches!(wide, CellWide::SpacerHead | CellWide::SpacerTail) {
+ continue;
+ }
+ let len = cell_len(&cell)?;
+ if byte < range.end && byte + len > range.start {
+ let end = if wide == CellWide::Wide {
+ (col + 1).min(cols - 1)
+ } else {
+ col
+ };
+ if let Some(last) = regions
+ .last_mut()
+ .filter(|last| u32::from(last.row) == row && last.end_col + 1 == col)
+ {
+ last.end_col = end;
+ } else {
+ regions.push(crate::api::schema::PaneLinkRegion {
+ row: row as u16,
+ start_col: col,
+ end_col: end,
+ });
+ }
+ }
+ byte += len;
+ }
+ }
+ Ok(regions)
+ }
+
fn grid_ref(&self, point: ffi::GhosttyPoint) -> Result<ffi::GhosttyGridRef, Error> {
let mut grid_ref = ffi::GhosttyGridRef {
size: mem::size_of::<ffi::GhosttyGridRef>(),
@@ -1375,6 +1562,16 @@ impl Terminal {
end: end_ref,
rectangle,
};
+ self.format_selection(&selection, format, unwrap, trim)
+ }
+
+ fn format_selection(
+ &self,
+ selection: &ffi::GhosttySelection,
+ format: FormatterFormat,
+ unwrap: bool,
+ trim: bool,
+ ) -> Result<String, Error> {
let mut formatter: ffi::GhosttyFormatter = ptr::null_mut();
let options = ffi::GhosttyFormatterTerminalOptions {
size: mem::size_of::<ffi::GhosttyFormatterTerminalOptions>(),
@@ -1389,7 +1586,7 @@ impl Terminal {
},
..Default::default()
},
- selection: &selection,
+ selection,
};
unsafe {
ffi::ghostty_formatter_terminal_new(ptr::null(), &mut formatter, self.raw, options)
@@ -3765,6 +3962,118 @@ mod tests {
}
#[test]
+ fn link_target_includes_offscreen_wrapped_rows() {
+ let url = "https://example.com/abcdefghijklmnopqrstuv";
+ let mut terminal = Terminal::new(20, 2, 1024 * 1024).unwrap();
+ terminal.write(url.as_bytes());
+ let expected = LinkTarget::Text {
+ text: url.to_owned(),
+ clicked_byte: 20,
+ };
+ assert_eq!(terminal.viewport_link_target(0, 0).unwrap(), Some(expected));
+ terminal.scroll_viewport_row(0);
+ assert_eq!(
+ terminal.viewport_link_target(0, 0).unwrap(),
+ Some(LinkTarget::Text {
+ text: url.to_owned(),
+ clicked_byte: 0
+ })
+ );
+ }
+
+ #[test]
+ fn link_target_prefers_explicit_uri_and_does_not_join_hard_lines() {
+ let mut terminal = Terminal::new(20, 4, 1024).unwrap();
+ terminal.write(b"\x1b]8;;https://example.com/real\x1b\\label\x1b]8;;\x1b\\\r\nhttps://example.com/\r\ntail");
+ assert_eq!(
+ terminal.viewport_link_target(2, 0).unwrap(),
+ Some(LinkTarget::Uri("https://example.com/real".into()))
+ );
+ assert_eq!(
+ terminal.viewport_link_target(1, 2).unwrap(),
+ Some(LinkTarget::Text {
+ text: "tail".into(),
+ clicked_byte: 1
+ })
+ );
+ }
+
+ #[test]
+ fn link_target_stops_at_hard_line_when_clicking_last_column() {
+ let mut terminal = Terminal::new(20, 3, 1024).unwrap();
+ terminal.write(b"https://example.com/x\r\nnot-part-of-url");
+ assert_eq!(
+ terminal.viewport_link_target(19, 0).unwrap(),
+ Some(LinkTarget::Text {
+ text: "https://example.com/x".into(),
+ clicked_byte: 19
+ })
+ );
+ }
+
+ #[test]
+ fn link_target_wide_explicit_label_works_on_both_cell_halves() {
+ let mut terminal = Terminal::new(20, 2, 1024).unwrap();
+ terminal.write("\x1b]8;;https://example.com/\x1b\\路径\x1b]8;;\x1b\\".as_bytes());
+ for col in 0..4 {
+ assert_eq!(
+ terminal.viewport_link_target(col, 0).unwrap(),
+ Some(LinkTarget::Uri("https://example.com/".into()))
+ );
+ }
+ }
+
+ #[test]
+ fn link_target_selection_budget_is_shared_across_both_directions() {
+ let mut terminal = Terminal::new(20, 2, 1024).unwrap();
+ terminal.write(b" abc ");
+ let boundaries = [u32::from(b' ')];
+ let options = ffi::GhosttyTerminalSelectWordOptions {
+ size: mem::size_of::<ffi::GhosttyTerminalSelectWordOptions>(),
+ ref_: terminal.grid_ref(ghostty_viewport_point(2, 0)).unwrap(),
+ boundary_codepoints: boundaries.as_ptr(),
+ boundary_codepoints_len: boundaries.len(),
+ };
+ for budget in 0..=5 {
+ let mut selection = ffi::GhosttySelection::default();
+ let result = unsafe {
+ ffi::ghostty_terminal_select_word_bounded(
+ terminal.raw,
+ &options,
+ budget,
+ &mut selection,
+ )
+ };
+ if budget < 5 {
+ assert_eq!(
+ result,
+ ffi::GhosttyResult_GHOSTTY_NO_VALUE,
+ "budget={budget}"
+ );
+ } else {
+ result.into_result().unwrap();
+ assert_eq!(
+ terminal
+ .format_selection(&selection, FormatterFormat::Plain, true, false)
+ .unwrap(),
+ "abc"
+ );
+ }
+ }
+ }
+
+ #[test]
+ fn link_target_rejects_oversized_token_without_returning_a_prefix() {
+ let mut terminal = Terminal::new(100, 4, 16 * 1024 * 1024).unwrap();
+ let text = format!("https://example.com/{}", "a".repeat(1_000_000));
+ terminal.write(text.as_bytes());
+ let started = std::time::Instant::now();
+ let target = terminal.viewport_link_target(0, 0).unwrap();
+ eprintln!("million-byte token lookup: {:?}", started.elapsed());
+ assert!(target.is_none());
+ }
+
+ #[test]
fn terminal_read_text_viewport_unwraps_soft_wrapped_selection() {
let mut terminal = Terminal::new(5, 3, 0).unwrap();
terminal.write("1ABCD2EFGH3IJKL".as_bytes());