diff options
| author | akbash <akbash@herdr.dev> | 2026-07-06 23:59:59 +0300 |
|---|---|---|
| committer | GitHub <noreply@github.com> | 2026-07-06 23:59:59 +0300 |
| commit | 4f22e9a73ba4d9029468e078235c5f5ce629d179 (patch) | |
| tree | 49b56a29305192c8270aa43e87bb5f1d86d00233 /src | |
| parent | 56edcbcba83b8ff7f6f69ee4231f163e97df3346 (diff) | |
fix: preserve wrapped pane links (#1099)
refs #1098
Co-authored-by: akbash-bot <300245827+akbash-bot@users.noreply.github.com>
Co-authored-by: Can Celik <ogulcancelik@gmail.com>
Diffstat (limited to 'src')
| -rw-r--r-- | src/app/actions.rs | 136 | ||||
| -rw-r--r-- | src/app/input/terminal.rs | 144 | ||||
| -rw-r--r-- | src/server/headless.rs | 43 | ||||
| -rw-r--r-- | src/server/render_stream.rs | 148 |
4 files changed, 453 insertions, 18 deletions
diff --git a/src/app/actions.rs b/src/app/actions.rs index 019b6692..52ebbbb7 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -1999,15 +1999,23 @@ impl AppState { } let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id); - let row_selection = Selection::range( + let visible_selection = Selection::line_range( pane_id, - viewport_row, - 0, + Selection::absolute_row_for_viewport(0, metrics), + Selection::absolute_row_for_viewport(info.inner_rect.height.saturating_sub(1), metrics), info.inner_rect.width.saturating_sub(1), - metrics, ); - let row_text = rt.extract_selection(&row_selection)?; - url_at_column(&row_text, col).map(str::to_owned) + let visible_text = rt.extract_selection(&visible_selection)?; + let logical_cell = + logical_cell_for_visible_cell(&visible_text, info.inner_rect.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 fn copy_selection(&mut self, terminal_runtimes: &crate::terminal::TerminalRuntimeRegistry) { @@ -2088,12 +2096,126 @@ fn word_bounds_at_column(row: &str, col: u16) -> Option<(u16, u16)> { 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_span_at_column(&cells, clicked_idx)?; + 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)?) } +pub(crate) fn visible_url_spans(row: &str) -> Vec<(u16, u16, &str)> { + let cells = text_cells(row); + url_spans(&cells) + .into_iter() + .filter_map(|span| { + let start_byte = byte_index_for_cell(row, span.start); + let end_byte = byte_index_after_cell(row, span.end); + let url = row.get(start_byte..end_byte).and_then(safe_web_url)?; + let (start_col, end_col) = span.columns(&cells); + Some((start_col, end_col, url)) + }) + .collect() +} + +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 = UnicodeWidthChar::width(ch).unwrap_or(0) as u16; + 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 = UnicodeWidthChar::width(cell.ch).unwrap_or(0) as u16; + 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) + } + }) +} + fn token_span_at_column(cells: &[TextCell], clicked_idx: usize) -> Option<CellSpan> { if is_word_separator(cells[clicked_idx].ch) { return None; diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index 282fa263..c9abbbc2 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -561,6 +561,150 @@ mod tests { } #[tokio::test] + async fn pane_cell_url_resolver_finds_soft_wrapped_url() { + let (_app, info) = app_with_screen_bytes(b""); + let prefix = "https://example.com/"; + let padding = "a".repeat(info.inner_rect.width as usize - prefix.len()); + let url = format!("{prefix}{padding}tail"); + let (app, _info) = app_with_screen_bytes(url.as_bytes()); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + + assert_eq!( + app.state + .url_at_pane_cell(&app.terminal_runtimes, pane_id, 1, 1) + .as_deref(), + Some(url.as_str()) + ); + } + + #[tokio::test] + async fn pane_cell_url_resolver_does_not_shift_after_zero_width_mark() { + let url = "https://example.com/mark"; + let screen = format!("e\u{301} {url}"); + let (app, _info) = app_with_screen_bytes(screen.as_bytes()); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + + assert_eq!( + app.state + .url_at_pane_cell(&app.terminal_runtimes, pane_id, 0, 2) + .as_deref(), + Some(url) + ); + } + + #[tokio::test] + async fn pane_cell_url_resolver_handles_hard_newline_after_full_row() { + let (_app, info) = app_with_screen_bytes(b""); + let full_row = "x".repeat(info.inner_rect.width as usize); + let url = "https://example.com/next"; + let screen = format!("{full_row}\n{url}"); + let (app, _info) = app_with_screen_bytes(screen.as_bytes()); + let pane_id = app.state.workspaces[0].tabs[0].root_pane; + + assert_eq!( + app.state + .url_at_pane_cell(&app.terminal_runtimes, pane_id, 1, 1) + .as_deref(), + None + ); + assert_eq!( + app.state + .url_at_pane_cell(&app.terminal_runtimes, pane_id, 2, 1) + .as_deref(), + Some(url) + ); + } + + #[tokio::test] + async fn render_stream_synthesizes_soft_wrapped_url_hyperlinks() { + let (_app, info) = app_with_screen_bytes(b""); + let prefix = "https://example.com/"; + let padding = "b".repeat(info.inner_rect.width as usize - prefix.len()); + let url = format!("{prefix}{padding}tail"); + let (app, info) = app_with_screen_bytes(url.as_bytes()); + + let links = crate::server::render_stream::visible_hyperlinks( + &app.state, + &app.terminal_runtimes, + None, + ); + + assert!(links.iter().any(|((x, y), symbol, uri)| { + *x == info.inner_rect.x + 1 + && *y == info.inner_rect.y + 1 + && symbol == "a" + && uri == &url + })); + } + + #[tokio::test] + async fn render_stream_does_not_shift_url_hyperlinks_after_zero_width_mark() { + let url = "https://example.com/mark"; + let screen = format!("e\u{301} {url}"); + let (app, info) = app_with_screen_bytes(screen.as_bytes()); + + let links = crate::server::render_stream::visible_hyperlinks( + &app.state, + &app.terminal_runtimes, + None, + ); + + assert!(links.iter().any(|((x, y), symbol, uri)| { + *x == info.inner_rect.x + 2 && *y == info.inner_rect.y && symbol == "h" && uri == url + })); + } + + #[tokio::test] + async fn render_stream_handles_hard_newline_after_full_row() { + let (_app, info) = app_with_screen_bytes(b""); + let full_row = "x".repeat(info.inner_rect.width as usize); + let url = "https://example.com/next"; + let screen = format!("{full_row}\n{url}"); + let (app, info) = app_with_screen_bytes(screen.as_bytes()); + let links = crate::server::render_stream::visible_hyperlinks( + &app.state, + &app.terminal_runtimes, + None, + ); + + assert!(links.iter().any(|((x, y), symbol, uri)| { + *x == info.inner_rect.x + 1 + && *y == info.inner_rect.y + 2 + && symbol == "t" + && uri == url + })); + } + + #[tokio::test] + async fn render_stream_filters_plain_url_when_final_cell_differs() { + let line = "see https://example.com/hidden"; + let (mut app, _info) = app_with_screen_bytes(line.as_bytes()); + let (mut buffer, _cursor) = + crate::server::render_stream::render_virtual_with_runtime_registry( + &mut app.state, + &app.terminal_runtimes, + ratatui::layout::Rect::new(0, 0, 106, 20), + false, + crate::kitty_graphics::HostCellSize::default(), + ); + let info = app.state.view.pane_infos[0].clone(); + let target_x = info.inner_rect.x + line.find("https").expect("url start") as u16; + let target_y = info.inner_rect.y; + buffer[(target_x, target_y)] + .set_style(ratatui::style::Style::default().fg(ratatui::style::Color::Red)); + + let links = crate::server::render_stream::visible_hyperlinks( + &app.state, + &app.terminal_runtimes, + Some(&buffer), + ); + + assert!(!links + .iter() + .any(|((x, y), _, uri)| *x == target_x && *y == target_y && uri == &line[4..])); + } + + #[tokio::test] async fn pane_cell_url_resolver_prefers_osc8_hyperlink() { let (app, _info) = app_with_screen_bytes( b"\x1b]8;;https://example.com/hidden-target\x1b\\label\x1b]8;;\x1b\\", diff --git a/src/server/headless.rs b/src/server/headless.rs index e84dfc59..04320ff2 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -163,6 +163,13 @@ fn dirty_patch_intersects_hyperlinks( false } +fn dirty_patch_contains_plain_url_candidate(patch: &crate::pane::TerminalDirtyPatch) -> bool { + patch.rows.iter().any(|(_, row_cells)| { + let row: String = row_cells.iter().map(|cell| cell.symbol.as_str()).collect(); + !crate::app::actions::visible_url_spans(&row).is_empty() + }) +} + // --------------------------------------------------------------------------- // Constants // --------------------------------------------------------------------------- @@ -3196,6 +3203,9 @@ impl HeadlessServer { if dirty_patch_intersects_hyperlinks(&frame, info.inner_rect, &patch) { retained_fallback!("hyperlink_intersection"); } + if dirty_patch_contains_plain_url_candidate(&patch) { + retained_fallback!("plain_url_candidate"); + } if !apply_terminal_dirty_patch(&mut frame, info.inner_rect, patch) { retained_fallback!("patch_apply_failed"); } @@ -3372,6 +3382,7 @@ impl HeadlessServer { let hyperlinks = crate::server::render_stream::visible_hyperlinks( &self.app.state, &self.app.terminal_runtimes, + Some(&buffer), ); crate::render_prof::duration_since( "full_render.visible_hyperlinks", @@ -7513,6 +7524,38 @@ next_tab = "" } #[tokio::test] + async fn retained_pty_update_declines_when_dirty_row_creates_plain_url() { + let (mut server, client_rx, pane_id) = retained_test_server(b"plain"); + server.render_and_stream(); + let _ = client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("initial frame"); + + let runtime = server + .app + .state + .runtime_for_pane_in_workspace(&server.app.terminal_runtimes, 0, pane_id) + .expect("runtime"); + runtime.test_process_pty_bytes(b"\rhttps://example.com/new"); + + assert!(!server.render_retained_pty_update_and_stream()); + assert!(client_rx.recv_timeout(Duration::from_millis(50)).is_err()); + + server.render_and_stream(); + let full = read_server_frame( + client_rx + .recv_timeout(Duration::from_millis(100)) + .expect("full frame after plain URL"), + ); + assert!( + full.hyperlinks + .iter() + .any(|uri| uri == "https://example.com/new"), + "full render should synthesize plain URL hyperlink metadata" + ); + } + + #[tokio::test] async fn retained_pty_update_allows_kitty_enabled_empty_graphics_cache() { let (mut server, client_rx, pane_id) = retained_test_server(b"aaaa"); server.app.state.kitty_graphics_enabled = true; diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index 58f7a456..4368bde6 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -1,5 +1,7 @@ //! Virtual rendering helpers for headless client frame streaming. +use std::collections::HashSet; + use ratatui::backend::{Backend, ClearType, TestBackend, WindowSize}; use ratatui::layout::{Position, Rect, Size}; @@ -7,7 +9,8 @@ use crate::app::state::AppState; use crate::app::Mode; use crate::protocol::render_ansi::{BlitEncoder, EncodedBlit}; use crate::protocol::{CursorState, FrameData, RenderEncoding, ServerMessage, TerminalFrame}; -use crate::terminal::TerminalRuntimeRegistry; +use crate::selection::Selection; +use crate::terminal::{TerminalRuntime, TerminalRuntimeRegistry}; /// Per-client render baseline for the negotiated render encoding. pub(crate) enum ClientRenderState { @@ -360,28 +363,151 @@ pub(crate) fn render_terminal_virtual( pub(crate) fn visible_hyperlinks( app_state: &AppState, terminal_runtimes: &TerminalRuntimeRegistry, + final_buffer: Option<&ratatui::buffer::Buffer>, ) -> Vec<((u16, u16), String, String)> { let Some(ws_idx) = app_state.active else { return Vec::new(); }; - let Some(tab) = app_state - .workspaces - .get(ws_idx) - .and_then(crate::workspace::Workspace::active_tab) - else { + if app_state.workspaces.get(ws_idx).is_none() { return Vec::new(); - }; + } let mut links = Vec::new(); + let mut occupied = HashSet::new(); for info in &app_state.view.pane_infos { - if let Some(runtime) = tab - .terminal_id(info.id) - .and_then(|terminal_id| terminal_runtimes.get(terminal_id)) + if let Some(runtime) = + app_state.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) { - links.extend(runtime.visible_hyperlinks(info.inner_rect)); + for link in runtime.visible_hyperlinks(info.inner_rect) { + occupied.insert(link.0); + links.push(link); + } + let metrics = app_state.pane_scroll_metrics(terminal_runtimes, info.id); + for link in plain_url_hyperlinks_for_pane( + runtime, + info.id, + info.inner_rect, + metrics, + final_buffer, + ) { + if occupied.insert(link.0) { + links.push(link); + } + } + } + } + links +} + +fn plain_url_hyperlinks_for_pane( + runtime: &TerminalRuntime, + pane_id: crate::layout::PaneId, + area: Rect, + metrics: Option<crate::pane::ScrollMetrics>, + final_buffer: Option<&ratatui::buffer::Buffer>, +) -> Vec<((u16, u16), String, String)> { + if area.width == 0 || area.height == 0 { + return Vec::new(); + } + + let selection = Selection::line_range( + pane_id, + Selection::absolute_row_for_viewport(0, metrics), + Selection::absolute_row_for_viewport(area.height.saturating_sub(1), metrics), + area.width.saturating_sub(1), + ); + let Some(text) = runtime.extract_selection(&selection) else { + return Vec::new(); + }; + let mut links = Vec::new(); + let cells = crate::app::actions::visible_text_cells(&text, area.width); + let mut line_start = 0; + for (byte_idx, ch) in text.char_indices() { + if ch == '\n' { + push_plain_url_line_links( + &mut links, + &text[line_start..byte_idx], + line_start, + &cells, + area, + ); + line_start = byte_idx + ch.len_utf8(); } } + push_plain_url_line_links(&mut links, &text[line_start..], line_start, &cells, area); + if links.is_empty() { + return links; + } + let pane_buffer = final_buffer.and_then(|_| render_runtime_buffer(runtime, area)); links + .into_iter() + .filter(|((x, y), _, _)| { + pane_cell_is_visible( + x.saturating_sub(area.x), + y.saturating_sub(area.y), + *x, + *y, + pane_buffer.as_ref(), + final_buffer, + ) + }) + .collect() +} + +fn render_runtime_buffer(runtime: &TerminalRuntime, area: Rect) -> Option<ratatui::buffer::Buffer> { + let backend = TestBackend::new(area.width, area.height); + let mut terminal = ratatui::Terminal::new(backend).ok()?; + terminal + .draw(|frame| { + runtime.render(frame, Rect::new(0, 0, area.width, area.height), false); + }) + .ok()?; + Some(terminal.backend().buffer().clone()) +} + +fn push_plain_url_line_links( + links: &mut Vec<((u16, u16), String, String)>, + line: &str, + line_start: usize, + cells: &[crate::app::actions::VisibleTextCell], + area: Rect, +) { + for (start_col, end_col, url) in crate::app::actions::visible_url_spans(line) { + for cell in cells.iter().filter(|cell| { + cell.byte_index >= line_start + && cell.byte_index < line_start + line.len() + && cell.logical_col >= start_col + && cell.logical_col <= end_col + && cell.screen_row < area.height + }) { + let x = area.x.saturating_add(cell.screen_col); + let y = area.y.saturating_add(cell.screen_row); + links.push(((x, y), cell.ch.to_string(), url.to_owned())); + } + } +} + +fn pane_cell_is_visible( + pane_x: u16, + pane_y: u16, + frame_x: u16, + frame_y: u16, + pane_buffer: Option<&ratatui::buffer::Buffer>, + final_buffer: Option<&ratatui::buffer::Buffer>, +) -> bool { + let (Some(pane_buffer), Some(final_buffer)) = (pane_buffer, final_buffer) else { + return true; + }; + let Some(pane_cell) = pane_buffer.cell((pane_x, pane_y)) else { + return false; + }; + let Some(final_cell) = final_buffer.cell((frame_x, frame_y)) else { + return false; + }; + pane_cell.symbol() == final_cell.symbol() + && pane_cell.fg == final_cell.fg + && pane_cell.bg == final_cell.bg + && pane_cell.modifier == final_cell.modifier } pub(crate) fn focused_terminal_cursor( |
