diff options
| author | Can Celik <ogulcancelik@gmail.com> | 2026-09-13 23:05:30 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-09-13 23:05:30 +0300 |
| commit | f8e59221cbe5117f8f691a36affa374f5a986a9f (patch) | |
| tree | 3b2792683eff251b061fbbad7343ecffafe91aab /src/app/actions.rs | |
| parent | 59167658455442ffd2cd5b72a0fa81bcc4bcdf9e (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.rs | 360 |
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") |
