diff --git a/src/app/actions.rs b/src/app/actions.rs index 12c0df87..5c263e7e 100644 --- a/src/app/actions.rs +++ b/src/app/actions.rs @@ -2122,20 +2122,6 @@ pub(crate) fn url_at_column(row: &str, col: u16) -> Option<&str> { 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 { let mut spans = Vec::new(); let mut start = 0; diff --git a/src/app/input/terminal.rs b/src/app/input/terminal.rs index c9abbbc2..a8e364ba 100644 --- a/src/app/input/terminal.rs +++ b/src/app/input/terminal.rs @@ -616,92 +616,70 @@ mod tests { } #[tokio::test] - async fn render_stream_synthesizes_soft_wrapped_url_hyperlinks() { + async fn render_stream_does_not_synthesize_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 (app, _info) = app_with_screen_bytes(url.as_bytes()); - let links = crate::server::render_stream::visible_hyperlinks( - &app.state, - &app.terminal_runtimes, - None, - ); + let links = + crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes); - assert!(links.iter().any(|((x, y), symbol, uri)| { - *x == info.inner_rect.x + 1 - && *y == info.inner_rect.y + 1 - && symbol == "a" - && uri == &url - })); + assert!(links.is_empty()); } #[tokio::test] - async fn render_stream_does_not_shift_url_hyperlinks_after_zero_width_mark() { + async fn render_stream_does_not_synthesize_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 (app, _info) = app_with_screen_bytes(screen.as_bytes()); - let links = crate::server::render_stream::visible_hyperlinks( - &app.state, - &app.terminal_runtimes, - None, - ); + let links = + crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes); - assert!(links.iter().any(|((x, y), symbol, uri)| { - *x == info.inner_rect.x + 2 && *y == info.inner_rect.y && symbol == "h" && uri == url - })); + assert!(links.is_empty()); } #[tokio::test] - async fn render_stream_handles_hard_newline_after_full_row() { + async fn render_stream_does_not_synthesize_hard_newline_plain_url_hyperlinks() { 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, - ); + let (app, _info) = app_with_screen_bytes(screen.as_bytes()); + let links = + crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes); - assert!(links.iter().any(|((x, y), symbol, uri)| { - *x == info.inner_rect.x + 1 - && *y == info.inner_rect.y + 2 - && symbol == "t" - && uri == url - })); + assert!(links.is_empty()); } #[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, + async fn render_stream_exports_osc8_hyperlink_metadata() { + let uri = "https://example.com/target"; + let (mut app, _info) = + app_with_screen_bytes(format!("\x1b]8;;{uri}\x1b\\label\x1b]8;;\x1b\\").as_bytes()); + let (buffer, cursor) = crate::server::render_stream::render_virtual_with_runtime_registry( + &mut app.state, &app.terminal_runtimes, - Some(&buffer), + ratatui::layout::Rect::new(0, 0, 106, 20), + false, + crate::kitty_graphics::HostCellSize::default(), ); - - assert!(!links + let links = + crate::server::render_stream::visible_hyperlinks(&app.state, &app.terminal_runtimes); + let frame = crate::protocol::FrameData::from_ratatui_buffer_with_hyperlinks( + &buffer, cursor, &links, + ); + let ((x, y), symbol, _) = links .iter() - .any(|((x, y), _, uri)| *x == target_x && *y == target_y && uri == &line[4..])); + .find(|(_, symbol, link_uri)| symbol == "l" && link_uri == uri) + .expect("OSC 8 link cell"); + let linked_cell_index = usize::from(*y) * usize::from(frame.width) + usize::from(*x); + + assert_eq!(frame.hyperlinks, vec![uri.to_owned()]); + assert_eq!(symbol, "l"); + assert_eq!(frame.cells[linked_cell_index].hyperlink, Some(0)); } #[tokio::test] diff --git a/src/server/headless.rs b/src/server/headless.rs index 57561947..e6d2b833 100644 --- a/src/server/headless.rs +++ b/src/server/headless.rs @@ -163,13 +163,6 @@ 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 // --------------------------------------------------------------------------- @@ -3203,9 +3196,6 @@ 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"); } @@ -3382,7 +3372,6 @@ 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", @@ -7524,7 +7513,7 @@ next_tab = "" } #[tokio::test] - async fn retained_pty_update_declines_when_dirty_row_creates_plain_url() { + async fn retained_pty_update_allows_dirty_row_that_creates_plain_url() { let (mut server, client_rx, pane_id) = retained_test_server(b"plain"); server.render_and_stream(); let _ = client_rx @@ -7538,20 +7527,15 @@ next_tab = "" .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( + assert!(server.render_retained_pty_update_and_stream()); + let patched = read_server_frame( client_rx .recv_timeout(Duration::from_millis(100)) - .expect("full frame after plain URL"), + .expect("retained frame after plain URL"), ); assert!( - full.hyperlinks - .iter() - .any(|uri| uri == "https://example.com/new"), - "full render should synthesize plain URL hyperlink metadata" + patched.hyperlinks.is_empty(), + "retained render should not synthesize plain URL hyperlink metadata" ); } diff --git a/src/server/render_stream.rs b/src/server/render_stream.rs index 4368bde6..d21dea56 100644 --- a/src/server/render_stream.rs +++ b/src/server/render_stream.rs @@ -1,7 +1,5 @@ //! 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}; @@ -9,8 +7,7 @@ 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::selection::Selection; -use crate::terminal::{TerminalRuntime, TerminalRuntimeRegistry}; +use crate::terminal::TerminalRuntimeRegistry; /// Per-client render baseline for the negotiated render encoding. pub(crate) enum ClientRenderState { @@ -363,7 +360,6 @@ 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(); @@ -373,143 +369,16 @@ pub(crate) fn visible_hyperlinks( } let mut links = Vec::new(); - let mut occupied = HashSet::new(); for info in &app_state.view.pane_infos { if let Some(runtime) = app_state.runtime_for_pane_in_workspace(terminal_runtimes, ws_idx, info.id) { - 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.extend(runtime.visible_hyperlinks(info.inner_rect)); } } links } -fn plain_url_hyperlinks_for_pane( - runtime: &TerminalRuntime, - pane_id: crate::layout::PaneId, - area: Rect, - metrics: Option, - 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 { - 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( app_state: &AppState, terminal_runtimes: &TerminalRuntimeRegistry,