aboutsummaryrefslogtreecommitdiff
path: root/src/app/actions.rs
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/app/actions.rs
parent59167658455442ffd2cd5b72a0fa81bcc4bcdf9e (diff)
feat: add ctrl-hover highlighting and fix wrapped links (#4074)
refs #1282
Diffstat (limited to 'src/app/actions.rs')
-rw-r--r--src/app/actions.rs360
1 files changed, 194 insertions, 166 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs
index d5d13d5a..85314331 100644
--- a/src/app/actions.rs
+++ b/src/app/actions.rs
@@ -10,7 +10,6 @@ use crate::events::AppEvent;
use crate::layout::PaneId;
#[cfg(test)]
use crate::layout::{find_in_direction, NavDirection};
-use crate::selection::Selection;
use crate::terminal::{EffectiveStateChange, TerminalStateMutation};
use crate::workspace::WorkspaceGitStatus;
@@ -1093,65 +1092,13 @@ impl AppState {
}
}
-impl AppState {
- pub(crate) fn url_at_pane_surface_cell(
- &self,
- terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry,
- ws_idx: usize,
- pane_id: crate::layout::PaneId,
- viewport_row: u16,
- col: u16,
- ) -> Option<String> {
- let rt = self.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, pane_id)?;
- let (height, width) = rt.current_size();
- url_at_runtime_cell(
- rt,
- pane_id,
- ratatui::layout::Rect::new(0, 0, width, height),
- viewport_row,
- col,
- rt.scroll_metrics(),
- )
- }
-}
-
-fn url_at_runtime_cell(
- runtime: &crate::terminal::TerminalRuntime,
- pane_id: crate::layout::PaneId,
- area: ratatui::layout::Rect,
- viewport_row: u16,
- col: u16,
- metrics: Option<crate::pane::ScrollMetrics>,
-) -> Option<String> {
- if viewport_row >= area.height || col >= area.width {
- return None;
+pub(super) fn url_from_link_target(target: crate::ghostty::LinkTarget) -> Option<String> {
+ match target {
+ crate::ghostty::LinkTarget::Uri(uri) => Some(uri),
+ crate::ghostty::LinkTarget::Text { text, clicked_byte } => {
+ url_at_byte(&text, clicked_byte).map(str::to_owned)
+ }
}
- let screen_col = area.x.saturating_add(col);
- let screen_row = area.y.saturating_add(viewport_row);
- if let Some((_, _, uri)) = runtime
- .visible_hyperlinks(area)
- .into_iter()
- .find(|((x, y), _, _)| *x == screen_col && *y == screen_row)
- {
- return Some(uri);
- }
-
- let visible_selection = Selection::line_range(
- pane_id,
- crate::selection::absolute_row_for_viewport(0, metrics),
- crate::selection::absolute_row_for_viewport(area.height.saturating_sub(1), metrics),
- area.width.saturating_sub(1),
- );
- let visible_text = runtime.extract_selection(&visible_selection)?;
- let logical_cell = logical_cell_for_visible_cell(&visible_text, area.width, viewport_row, col)?;
- let line_start = visible_text[..logical_cell.byte_index]
- .rfind('\n')
- .map_or(0, |idx| idx + 1);
- let line_end = visible_text[logical_cell.byte_index..]
- .find('\n')
- .map_or(visible_text.len(), |idx| logical_cell.byte_index + idx);
- let line = visible_text.get(line_start..line_end)?;
- url_at_column(line, logical_cell.logical_col).map(str::to_owned)
}
pub(crate) fn safe_web_url(url: &str) -> Option<&str> {
@@ -1201,113 +1148,19 @@ pub(crate) fn word_bounds_at_column(row: &str, col: u16) -> Option<(u16, u16)> {
Some(span.columns(&cells))
}
-pub(crate) fn url_at_column(row: &str, col: u16) -> Option<&str> {
- let cells = text_cells(row);
- let clicked_idx = cell_index_at_column(&cells, col)?;
- let span = url_spans(&cells)
- .into_iter()
- .find(|span| span.contains(clicked_idx))?;
- let start_byte = byte_index_for_cell(row, span.start);
- let end_byte = byte_index_after_cell(row, span.end);
- safe_web_url(row.get(start_byte..end_byte)?)
+fn url_at_byte(text: &str, clicked_byte: usize) -> Option<&str> {
+ let range = url_byte_range(text, clicked_byte)?;
+ text.get(range)
}
-fn url_spans(cells: &[TextCell]) -> Vec<CellSpan> {
- let mut spans = Vec::new();
- let mut start = 0;
- while start < cells.len() {
- if starts_with_chars(&cells[start..], "http://")
- || starts_with_chars(&cells[start..], "https://")
- {
- let mut end = start;
- while end + 1 < cells.len() && !cells[end + 1].ch.is_whitespace() {
- end += 1;
- }
- if let Some(span) = trim_url_edges(cells, CellSpan { start, end }) {
- spans.push(span);
- }
- start = end + 1;
- } else {
- start += 1;
- }
- }
- spans
-}
-
-#[derive(Debug, Clone, Copy, PartialEq, Eq)]
-pub(crate) struct VisibleTextCell {
- pub(crate) byte_index: usize,
- pub(crate) ch: char,
- pub(crate) logical_col: u16,
- pub(crate) screen_row: u16,
- pub(crate) screen_col: u16,
-}
-
-pub(crate) fn visible_text_cells(text: &str, pane_width: u16) -> Vec<VisibleTextCell> {
- if pane_width == 0 {
- return Vec::new();
- }
-
- let mut cells = Vec::new();
- let mut screen_row = 0u16;
- let mut screen_col = 0u16;
- let mut logical_col = 0u16;
- let mut pending_wrap = false;
- for (byte_index, ch) in text.char_indices() {
- if ch == '\n' {
- screen_row = screen_row.saturating_add(1);
- screen_col = 0;
- logical_col = 0;
- pending_wrap = false;
- continue;
- }
- if pending_wrap {
- screen_row = screen_row.saturating_add(1);
- screen_col = 0;
- pending_wrap = false;
- }
-
- let width = u16::from(crate::ghostty::unicode_codepoint_width(ch as u32));
- cells.push(VisibleTextCell {
- byte_index,
- ch,
- logical_col,
- screen_row,
- screen_col,
- });
-
- logical_col = logical_col.saturating_add(width);
- screen_col = screen_col.saturating_add(width);
- while screen_col > pane_width {
- screen_col -= pane_width;
- screen_row = screen_row.saturating_add(1);
- }
- if width > 0 && screen_col == pane_width {
- pending_wrap = true;
- screen_col = pane_width.saturating_sub(1);
- }
- }
- cells
-}
-
-pub(crate) fn logical_cell_for_visible_cell(
- text: &str,
- pane_width: u16,
- target_row: u16,
- target_col: u16,
-) -> Option<VisibleTextCell> {
- visible_text_cells(text, pane_width)
- .into_iter()
- .find(|cell| {
- let width = u16::from(crate::ghostty::unicode_codepoint_width(cell.ch as u32));
- cell.screen_row == target_row
- && if width == 0 {
- target_col == cell.screen_col
- } else {
- target_col >= cell.screen_col
- && target_col < cell.screen_col.saturating_add(width)
- }
- })
+pub(super) fn url_byte_range(text: &str, clicked_byte: usize) -> Option<std::ops::Range<usize>> {
+ let clicked_idx = text.get(..clicked_byte)?.chars().count();
+ let cells = text_cells(text);
+ let span = url_span_at_column(&cells, clicked_idx)?;
+ let start = byte_index_for_cell(text, span.start);
+ let end = byte_index_after_cell(text, span.end);
+ safe_web_url(text.get(start..end)?)?;
+ Some(start..end)
}
fn token_span_at_column(cells: &[TextCell], clicked_idx: usize) -> Option<CellSpan> {
@@ -2314,7 +2167,7 @@ mod tests {
}
fn selected_url<'a>(row: &'a str, click: &str) -> Option<&'a str> {
- url_at_column(row, col_of(row, click))
+ url_at_byte(row, row.find(click)?)
}
fn text_in_cell_range(row: &str, start_col: u16, end_col: u16) -> String {
@@ -2506,7 +2359,182 @@ mod tests {
}
#[test]
- fn url_at_column_returns_safe_visible_url_only() {
+ fn link_resolution_regions_unicode_punctuation_and_explicit_links() {
+ let mut terminal = crate::ghostty::Terminal::new(80, 4, 1024).unwrap();
+ terminal.write("[文档](https://example.com/路径?q=a(b)), next".as_bytes());
+ let regions = terminal
+ .viewport_link_regions(7, 0, url_byte_range)
+ .unwrap();
+ assert_eq!(
+ regions
+ .iter()
+ .map(|r| (r.row, r.start_col, r.end_col))
+ .collect::<Vec<_>>(),
+ vec![(0, 7, 37)]
+ );
+ assert!(terminal
+ .viewport_link_regions(1, 0, url_byte_range)
+ .unwrap()
+ .is_empty());
+ terminal.resize(20, 4, 0, 0).unwrap();
+ assert_eq!(
+ terminal
+ .viewport_link_regions(9, 1, url_byte_range)
+ .unwrap()
+ .iter()
+ .map(|r| (r.row, r.start_col, r.end_col))
+ .collect::<Vec<_>>(),
+ vec![(0, 7, 19), (1, 0, 17)]
+ );
+ for text in [
+ "file:///tmp/a",
+ "javascript:alert(1)",
+ "\x1b]8;;https://example.com\x1b\\https://example.com\x1b]8;;\x1b\\",
+ ] {
+ let mut terminal = crate::ghostty::Terminal::new(80, 4, 1024).unwrap();
+ terminal.write(text.as_bytes());
+ assert!(terminal
+ .viewport_link_regions(0, 0, url_byte_range)
+ .unwrap()
+ .is_empty());
+ }
+ let mut terminal = crate::ghostty::Terminal::new(80, 4, 1024 * 1024).unwrap();
+ terminal.write(format!("https://example.com/{}", "a".repeat(9000)).as_bytes());
+ assert!(terminal
+ .viewport_link_regions(0, 0, url_byte_range)
+ .unwrap()
+ .is_empty());
+ }
+
+ #[test]
+ fn link_resolution_regions_keep_grapheme_byte_offsets() {
+ let mut terminal = crate::ghostty::Terminal::new(40, 3, 1024).unwrap();
+ terminal.write("e\u{301}(https://example.com/路e\u{301}),".as_bytes());
+ let expected = vec![crate::api::schema::PaneLinkRegion {
+ row: 0,
+ start_col: 2,
+ end_col: 24,
+ }];
+ for col in 2..=24 {
+ assert_eq!(
+ terminal
+ .viewport_link_regions(col, 0, url_byte_range)
+ .unwrap(),
+ expected,
+ "column {col}"
+ );
+ }
+ assert!(terminal
+ .viewport_link_regions(0, 0, url_byte_range)
+ .unwrap()
+ .is_empty());
+ assert!(terminal
+ .viewport_link_regions(25, 0, url_byte_range)
+ .unwrap()
+ .is_empty());
+ terminal.resize(21, 1, 0, 0).unwrap();
+ let regions = terminal
+ .viewport_link_regions(1, 0, url_byte_range)
+ .unwrap();
+ assert_eq!(
+ regions,
+ vec![crate::api::schema::PaneLinkRegion {
+ row: 0,
+ start_col: 0,
+ end_col: 3
+ }]
+ );
+ }
+
+ #[test]
+ fn link_resolution_regions_clip_wraps_and_wide_padding() {
+ let mut terminal = crate::ghostty::Terminal::new(21, 3, 1024).unwrap();
+ terminal.write("https://example.com/路径".as_bytes());
+ let regions = terminal
+ .viewport_link_regions(1, 1, url_byte_range)
+ .unwrap();
+ assert_eq!(
+ regions
+ .iter()
+ .map(|r| (r.row, r.start_col, r.end_col))
+ .collect::<Vec<_>>(),
+ vec![(0, 0, 19), (1, 0, 3)]
+ );
+ assert!(terminal
+ .viewport_link_regions(19, 2, url_byte_range)
+ .unwrap()
+ .is_empty());
+ let mut terminal = crate::ghostty::Terminal::new(20, 2, 1024).unwrap();
+ terminal.write(b"https://example.com/abcdefghijklmnopqrstuv");
+ assert_eq!(
+ terminal
+ .viewport_link_regions(0, 0, url_byte_range)
+ .unwrap()
+ .iter()
+ .map(|r| (r.row, r.start_col, r.end_col))
+ .collect::<Vec<_>>(),
+ vec![(0, 0, 19), (1, 0, 1)]
+ );
+ terminal.scroll_viewport_row(0);
+ assert_eq!(
+ terminal
+ .viewport_link_regions(0, 0, url_byte_range)
+ .unwrap()
+ .iter()
+ .map(|r| (r.row, r.start_col, r.end_col))
+ .collect::<Vec<_>>(),
+ vec![(0, 0, 19), (1, 0, 19)]
+ );
+ assert!(terminal
+ .viewport_link_regions(0, 2, url_byte_range)
+ .unwrap()
+ .is_empty());
+ }
+
+ #[test]
+ fn link_activation_resolves_full_url_with_either_end_offscreen() {
+ let url = "https://example.com/abcdefghijklmnopqrstuv";
+ let mut terminal = crate::ghostty::Terminal::new(20, 2, 1024 * 1024).unwrap();
+ terminal.write(url.as_bytes());
+ let target = terminal.viewport_link_target(0, 0).unwrap().unwrap();
+ assert_eq!(url_from_link_target(target).as_deref(), Some(url));
+ terminal.scroll_viewport_row(0);
+ let target = terminal.viewport_link_target(5, 1).unwrap().unwrap();
+ assert_eq!(url_from_link_target(target).as_deref(), Some(url));
+ }
+
+ #[test]
+ fn link_activation_preserves_unicode_and_click_boundaries_after_resize() {
+ let mut terminal = crate::ghostty::Terminal::new(80, 5, 1024 * 1024).unwrap();
+ terminal.write("[文档](https://example.com/路径?q=a(b)), next".as_bytes());
+ assert!(
+ url_from_link_target(terminal.viewport_link_target(1, 0).unwrap().unwrap()).is_none()
+ );
+ assert_eq!(
+ url_from_link_target(terminal.viewport_link_target(7, 0).unwrap().unwrap()).as_deref(),
+ Some("https://example.com/路径?q=a(b)")
+ );
+ terminal.resize(20, 5, 0, 0).unwrap();
+ assert_eq!(
+ url_from_link_target(terminal.viewport_link_target(9, 1).unwrap().unwrap()).as_deref(),
+ Some("https://example.com/路径?q=a(b)")
+ );
+ assert!(terminal.viewport_link_target(19, 4).unwrap().is_none());
+ }
+
+ #[test]
+ fn link_activation_skips_wide_character_wrap_padding() {
+ let url = "https://example.com/路径";
+ let mut terminal = crate::ghostty::Terminal::new(20, 3, 1024).unwrap();
+ terminal.write(url.as_bytes());
+ for col in 0..4 {
+ let target = terminal.viewport_link_target(col, 1).unwrap().unwrap();
+ assert_eq!(url_from_link_target(target).as_deref(), Some(url));
+ }
+ }
+
+ #[test]
+ fn url_at_byte_returns_safe_visible_url_only() {
assert_eq!(
selected_url("see https://example.com/a(b)c.", "example"),
Some("https://example.com/a(b)c")