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/client | |
| parent | 59167658455442ffd2cd5b72a0fa81bcc4bcdf9e (diff) | |
feat: add ctrl-hover highlighting and fix wrapped links (#4074)
refs #1282
Diffstat (limited to 'src/client')
| -rw-r--r-- | src/client/shell.rs | 1 | ||||
| -rw-r--r-- | src/client/shell/actions.rs | 4 | ||||
| -rw-r--r-- | src/client/shell/composition.rs | 1 | ||||
| -rw-r--r-- | src/client/shell/input.rs | 2 | ||||
| -rw-r--r-- | src/client/shell/link_hover.rs | 352 | ||||
| -rw-r--r-- | src/client/shell/mouse.rs | 1 | ||||
| -rw-r--r-- | src/client/shell/state.rs | 7 | ||||
| -rw-r--r-- | src/client/shell/surface_patch.rs | 3 | ||||
| -rw-r--r-- | src/client/shell/tests/link_hover.rs | 410 | ||||
| -rw-r--r-- | src/client/shell/tests/mod.rs | 1 | ||||
| -rw-r--r-- | src/client/shell/worktrees.rs | 1 |
11 files changed, 783 insertions, 0 deletions
diff --git a/src/client/shell.rs b/src/client/shell.rs index 4c2fc6cd..c6c7f2ed 100644 --- a/src/client/shell.rs +++ b/src/client/shell.rs @@ -20,6 +20,7 @@ mod global_menu; mod graphics; mod input; mod input_source; +mod link_hover; mod mobile; mod mouse; mod notification_policy; diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs index 9190f5e4..95a24a54 100644 --- a/src/client/shell/actions.rs +++ b/src/client/shell/actions.rs @@ -483,6 +483,9 @@ impl ClientShellState { { return (false, Vec::new()); } + if let PendingEndpointKind::PaneLinkResolve { target } = pending.kind { + return self.complete_link_hover(target, result); + } if result.is_ok() { let timeout_key = ClientEndpointNoticeKey { boot_id: boot_id.to_owned(), @@ -528,6 +531,7 @@ impl ClientShellState { } match pending.kind { PendingEndpointKind::Generic => {} + PendingEndpointKind::PaneLinkResolve { .. } => unreachable!("handled above"), PendingEndpointKind::ProductAnnouncementDismiss { version, id } => { return match result { Ok(_) => (false, Vec::new()), diff --git a/src/client/shell/composition.rs b/src/client/shell/composition.rs index 3266e54d..8e99059b 100644 --- a/src/client/shell/composition.rs +++ b/src/client/shell/composition.rs @@ -371,6 +371,7 @@ impl ClientShellState { } frame.replace_from_ratatui_buffer_preserving_effects(&composed, cursor); } + self.render_link_hover(&mut frame); if self.mode == ClientShellMode::Copy { frame.cursor = None; if let Some(copy_mode) = self.copy_mode.as_ref() { diff --git a/src/client/shell/input.rs b/src/client/shell/input.rs index 8b5fbd83..7118819b 100644 --- a/src/client/shell/input.rs +++ b/src/client/shell/input.rs @@ -247,6 +247,7 @@ impl ClientShellState { .push(ClientMessage::ClientShellFocus { focused: true }); } RawInputEvent::OuterFocusLost => { + outcome.repaint |= self.clear_link_hover(); self.outer_focused = Some(false); self.release_input_leases(&mut outcome); outcome @@ -301,6 +302,7 @@ impl ClientShellState { key: crate::input::TerminalKey, outcome: &mut ClientShellInput, ) { + outcome.repaint |= self.clear_link_hover(); if self.copy_operation_in_flight { self.copy_input_queue.push_back(key); return; diff --git a/src/client/shell/link_hover.rs b/src/client/shell/link_hover.rs new file mode 100644 index 00000000..f35bfbc2 --- /dev/null +++ b/src/client/shell/link_hover.rs @@ -0,0 +1,352 @@ +use super::*; +use crate::api::schema::{PaneLinkActivateParams, PaneLinkRegion}; +use crate::protocol::SurfaceRect; +use crossterm::event::{KeyModifiers, MouseEvent, MouseEventKind}; + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(super) struct LinkHoverTarget { + pane_id: String, + inner_rect: Rect, + source_rect: SurfaceRect, + content_revision: u64, + offset_from_bottom: Option<u64>, + row: u16, + col: u16, +} + +impl LinkHoverTarget { + fn same_content(&self, other: &Self) -> bool { + self.pane_id == other.pane_id + && self.inner_rect == other.inner_rect + && self.source_rect == other.source_rect + && self.content_revision == other.content_revision + && self.offset_from_bottom == other.offset_from_bottom + } +} + +pub(super) struct LinkHover { + target: LinkHoverTarget, + pub(super) regions: Vec<PaneLinkRegion>, + resolved: bool, +} + +impl ClientShellState { + pub(super) fn clear_link_hover(&mut self) -> bool { + self.link_hover + .take() + .is_some_and(|hover| !hover.regions.is_empty()) + } + + fn link_hover_allowed(&self) -> bool { + self.mode == ClientShellMode::Terminal + && self.overlay.is_none() + && self.popup_terminal_id.is_none() + && self.outer_focused != Some(false) + && self.chrome_drag.is_none() + && self.pane_mouse_gesture.is_none() + } + + fn link_hover_target_current(&self, target: &LinkHoverTarget) -> bool { + self.link_hover_allowed() + && self.pane_surface.as_ref().is_some_and(|surface| { + self.snapshot.as_ref().is_some_and(|snapshot| { + surface.boot_id == snapshot.boot_id + && surface.projection_revision == snapshot.revision + }) && surface.panes.iter().any(|pane| { + pane.pane_id == target.pane_id + && pane.inner_rect == target.source_rect + && pane.content_revision == target.content_revision + && pane.scroll.map(|scroll| scroll.offset_from_bottom) + == target.offset_from_bottom + }) + }) + && self + .hits + .panes + .iter() + .any(|hit| hit.pane_id == target.pane_id && hit.inner_rect == target.inner_rect) + } + + pub(super) fn invalidate_link_hover(&mut self) { + if self + .link_hover + .as_ref() + .is_some_and(|hover| !self.link_hover_target_current(&hover.target)) + { + self.clear_link_hover(); + } + } + + pub(super) fn update_link_hover(&mut self, mouse: MouseEvent, outcome: &mut ClientShellInput) { + if mouse.kind != MouseEventKind::Moved + || !mouse.modifiers.contains(KeyModifiers::CONTROL) + || !self.link_hover_allowed() + { + outcome.repaint |= self.clear_link_hover(); + return; + } + let Some(hit) = self + .hits + .panes + .iter() + .find(|hit| contains(hit.inner_rect, (mouse.column, mouse.row))) + else { + outcome.repaint |= self.clear_link_hover(); + return; + }; + let Some(pane) = self.pane_surface.as_ref().and_then(|surface| { + surface + .panes + .iter() + .find(|pane| pane.pane_id == hit.pane_id) + }) else { + outcome.repaint |= self.clear_link_hover(); + return; + }; + let target = LinkHoverTarget { + pane_id: hit.pane_id.clone(), + inner_rect: hit.inner_rect, + source_rect: pane.inner_rect, + content_revision: pane.content_revision, + offset_from_bottom: pane.scroll.map(|scroll| scroll.offset_from_bottom), + row: mouse.row.saturating_sub(hit.inner_rect.y), + col: mouse.column.saturating_sub(hit.inner_rect.x), + }; + if !self.link_hover_target_current(&target) || !target.content_revision.is_multiple_of(2) { + outcome.repaint |= self.clear_link_hover(); + return; + } + if self.link_hover.as_ref().is_some_and(|hover| { + hover.target.same_content(&target) + && (hover.target == target + || hover + .regions + .iter() + .any(|region| region_contains(region, target.col, target.row))) + }) { + self.request_link_hover(outcome); + return; + } + outcome.repaint |= self.clear_link_hover(); + let explicit = self.explicit_link_regions(&target); + let resolved = explicit.is_some(); + let regions = explicit.unwrap_or_default(); + outcome.repaint |= !regions.is_empty(); + self.link_hover = Some(LinkHover { + target, + regions, + resolved, + }); + self.request_link_hover(outcome); + } + + fn request_link_hover(&mut self, outcome: &mut ClientShellInput) { + if self + .pending_requests + .values() + .any(|pending| matches!(pending.kind, PendingEndpointKind::PaneLinkResolve { .. })) + { + return; + } + let Some(hover) = self.link_hover.as_ref().filter(|hover| !hover.resolved) else { + return; + }; + if !self.link_hover_target_current(&hover.target) { + return; + } + let target = hover.target.clone(); + // Hover is optional and speculative: do not probe unadvertised methods or show notices. + if !self.endpoint_is_online(&self.active_endpoint_id) + || !self + .endpoints + .iter() + .find(|endpoint| endpoint.endpoint_id == self.active_endpoint_id) + .and_then(|endpoint| endpoint.methods.as_ref()) + .is_some_and(|methods| methods.iter().any(|method| method == "pane.link.resolve")) + { + if let Some(hover) = self.link_hover.as_mut() { + hover.resolved = true; + } + return; + } + self.push_endpoint_method_with_kind( + crate::api::schema::Method::PaneLinkResolve(PaneLinkActivateParams { + pane_id: target.pane_id.clone(), + viewport_row: target.row, + col: target.col, + content_revision: Some(target.content_revision), + offset_from_bottom: target.offset_from_bottom, + }), + PendingEndpointKind::PaneLinkResolve { target }, + outcome, + ); + } + + pub(super) fn complete_link_hover( + &mut self, + target: LinkHoverTarget, + result: Result<crate::api::schema::ResponseResult, ClientShellEndpointError>, + ) -> (bool, Vec<ClientShellAction>) { + let cancelled = result + .as_ref() + .err() + .is_some_and(|error| error.code.as_deref() == Some("endpoint_cancelled")); + let mut outcome = ClientShellInput::default(); + if self.link_hover_target_current(&target) + && self + .link_hover + .as_ref() + .is_some_and(|hover| hover.target == target) + { + let regions = match result { + Ok(crate::api::schema::ResponseResult::PaneLinkResolved { regions }) + if regions.len() <= usize::from(target.inner_rect.height) + && regions.iter().all(|region| { + region.row < target.inner_rect.height + && region.start_col <= region.end_col + && region.end_col < target.inner_rect.width + }) + && regions + .iter() + .any(|region| region_contains(region, target.col, target.row)) => + { + regions + } + _ => Vec::new(), + }; + outcome.repaint = !regions.is_empty(); + self.link_hover = Some(LinkHover { + target, + regions, + resolved: true, + }); + } + if !cancelled { + self.request_link_hover(&mut outcome); + } + (outcome.repaint, outcome.actions) + } + + fn explicit_link_regions(&self, target: &LinkHoverTarget) -> Option<Vec<PaneLinkRegion>> { + let surface = self.pane_surface.as_ref()?; + let rect = target.source_rect; + let cell = |col: u16, row: u16| { + let x = usize::from(rect.x) + usize::from(col); + let y = usize::from(rect.y) + usize::from(row); + if x >= usize::from(surface.frame.width) || y >= usize::from(surface.frame.height) { + return None; + } + surface + .frame + .cells + .get(y * usize::from(surface.frame.width) + x) + }; + let hyperlink_at = |col: u16, row: u16| { + let current = cell(col, row)?; + current.hyperlink.or_else(|| { + // Rendered wide-cell spacers may not carry their own OSC 8 metadata. + if col == 0 || !current.symbol.trim().is_empty() { + return None; + } + let previous = cell(col - 1, row)?; + (previous.symbol.width() == 2) + .then_some(previous.hyperlink) + .flatten() + }) + }; + let hyperlink = hyperlink_at(target.col, target.row)?; + let uri = surface.frame.hyperlinks.get(hyperlink as usize)?; + if crate::app::actions::safe_web_url(uri).is_none() { + return Some(Vec::new()); + } + let width = usize::from(rect.width); + if width == 0 { + return Some(Vec::new()); + } + let clicked = usize::from(target.row) * width + usize::from(target.col); + let mut start = clicked; + let mut end = clicked; + let matches = |index: usize| { + hyperlink_at((index % width) as u16, (index / width) as u16) == Some(hyperlink) + }; + // Frame IDs identify destinations, not OSC 8 runs. Adjacent cells with + // the same destination form one hover region, including across rows. + let mut remaining = 8191usize; + while start > 0 && matches(start - 1) { + if remaining == 0 { + return Some(Vec::new()); + } + remaining -= 1; + start -= 1; + } + while end + 1 < width * usize::from(rect.height) && matches(end + 1) { + if remaining == 0 { + return Some(Vec::new()); + } + remaining -= 1; + end += 1; + } + let regions = (start / width..=end / width) + .map(|row| PaneLinkRegion { + row: row as u16, + start_col: if row == start / width { + (start % width) as u16 + } else { + 0 + }, + end_col: if row == end / width { + (end % width) as u16 + } else { + rect.width - 1 + }, + }) + .collect(); + Some(regions) + } + + pub(super) fn link_hover_blocks_patch( + &self, + patch: &crate::protocol::PaneSurfacePatch, + ) -> bool { + self.link_hover.as_ref().is_some_and(|hover| { + !hover.regions.is_empty() + && patch + .panes + .iter() + .any(|pane| pane.pane_id == hover.target.pane_id) + }) + } + + pub(super) fn render_link_hover(&self, frame: &mut FrameData) { + let Some(hover) = self + .link_hover + .as_ref() + .filter(|hover| self.link_hover_target_current(&hover.target)) + else { + return; + }; + let rect = hover.target.inner_rect; + for region in &hover.regions { + let row = rect.y.saturating_add(region.row); + if row >= frame.height { + continue; + } + for col in region.start_col..=region.end_col { + let col = rect.x.saturating_add(col); + if col >= frame.width { + continue; + } + if let Some(cell) = frame + .cells + .get_mut(usize::from(row) * usize::from(frame.width) + usize::from(col)) + { + cell.modifier |= Modifier::UNDERLINED.bits(); + } + } + } + } +} + +fn region_contains(region: &PaneLinkRegion, col: u16, row: u16) -> bool { + region.row == row && col >= region.start_col && col <= region.end_col +} diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs index 7b4c0cb2..1ce14feb 100644 --- a/src/client/shell/mouse.rs +++ b/src/client/shell/mouse.rs @@ -652,6 +652,7 @@ impl ClientShellState { } pub(super) fn handle_mouse(&mut self, mouse: MouseEvent, outcome: &mut ClientShellInput) { + self.update_link_hover(mouse, outcome); let point = (mouse.column, mouse.row); if self.mode == ClientShellMode::Navigate && self.workspace_preview_action_blocked() diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index eede0f29..8f2aec43 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -699,6 +699,9 @@ pub(super) enum PendingEndpointKind { absolute_row: u32, generation: u64, }, + PaneLinkResolve { + target: super::link_hover::LinkHoverTarget, + }, PaneLinkActivate { pane_id: String, inner_rect: Rect, @@ -934,6 +937,7 @@ pub(crate) struct ClientShellState { pub(super) overlay: Option<ClientShellOverlay>, pub(super) previous_pane_id: Option<String>, pub(super) pane_mouse_gesture: Option<ClientPaneMouseGesture>, + pub(super) link_hover: Option<super::link_hover::LinkHover>, pub(super) url_click_consumes_until_up: bool, pub(super) replaying_url_click: bool, pub(super) selection: Option<crate::selection::Selection<String>>, @@ -1093,6 +1097,7 @@ impl ClientShellState { overlay, previous_pane_id: None, pane_mouse_gesture: None, + link_hover: None, url_click_consumes_until_up: false, replaying_url_click: false, selection: None, @@ -1284,6 +1289,7 @@ impl ClientShellState { .then_some(ClientShellOverlay::Onboarding); self.previous_pane_id = None; self.pane_mouse_gesture = None; + self.link_hover = None; self.url_click_consumes_until_up = false; self.replaying_url_click = false; self.selection = None; @@ -1811,6 +1817,7 @@ impl ClientShellState { self.graphics .set_scene(std::mem::take(&mut surface.graphics)); self.pane_surface = Some(surface); + self.invalidate_link_hover(); self.resume_mobile_switcher_if_ready(); self.reconcile_input_source(); } diff --git a/src/client/shell/surface_patch.rs b/src/client/shell/surface_patch.rs index 784fbcd8..87f947fa 100644 --- a/src/client/shell/surface_patch.rs +++ b/src/client/shell/surface_patch.rs @@ -72,6 +72,8 @@ fn fast_path_blocker( Some("client_surface_patch.fallback.notification") } else if state.copy_feedback.is_some() { Some("client_surface_patch.fallback.copy_feedback") + } else if state.link_hover_blocks_patch(patch) { + Some("client_surface_patch.fallback.link_hover") } else if state.selection.is_some() { Some("client_surface_patch.fallback.selection") } else if state.copy_mode.is_some() { @@ -240,6 +242,7 @@ impl ClientShellState { } } } + self.invalidate_link_hover(); self.reconcile_input_source(); } else { let mut next = current.clone(); diff --git a/src/client/shell/tests/link_hover.rs b/src/client/shell/tests/link_hover.rs new file mode 100644 index 00000000..f29fdec2 --- /dev/null +++ b/src/client/shell/tests/link_hover.rs @@ -0,0 +1,410 @@ +use super::*; + +fn hover_state() -> ClientShellState { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + state.set_endpoint_methods(Some(vec!["pane.link.resolve".into()])); + state.set_pane_surface(surface()); + state.compose(106, 20).unwrap(); + state +} + +fn hover_mouse(state: &ClientShellState, col: u16, row: u16) -> MouseEvent { + let pane = &state.hits.panes[0]; + MouseEvent { + kind: MouseEventKind::Moved, + column: pane.inner_rect.x + col, + row: pane.inner_rect.y + row, + modifiers: KeyModifiers::CONTROL, + } +} + +fn hover_request(outcome: &ClientShellInput) -> String { + let [ClientShellAction::Endpoint { request, .. }] = &outcome.actions[..] else { + panic!("expected one side-effect-free link resolution"); + }; + assert!(matches!( + request.method, + crate::api::schema::Method::PaneLinkResolve(_) + )); + request.id.clone() +} + +fn resolve_hover(state: &mut ClientShellState, id: &str) -> bool { + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + id, + Ok(crate::api::schema::ResponseResult::PaneLinkResolved { + regions: vec![ + crate::api::schema::PaneLinkRegion { + row: 0, + start_col: 1, + end_col: 3, + }, + crate::api::schema::PaneLinkRegion { + row: 1, + start_col: 0, + end_col: 2, + }, + ], + }), + ); + assert!( + actions.is_empty(), + "hover must never open a URL or activate a plugin" + ); + repaint +} + +#[test] +fn ctrl_hover_underlines_wrapped_segments_and_caches_the_link() { + let mut state = hover_state(); + let mouse = hover_mouse(&state, 1, 0); + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + assert!(resolve_hover(&mut state, &id)); + let frame = state.compose(106, 20).unwrap(); + let pane = &state.hits.panes[0]; + let underlined = |col, row| { + frame.cells[usize::from(pane.inner_rect.y + row) * usize::from(frame.width) + + usize::from(pane.inner_rect.x + col)] + .modifier + & Modifier::UNDERLINED.bits() + != 0 + }; + assert!(!underlined(0, 0)); + assert!(underlined(1, 0)); + assert!(underlined(3, 0)); + assert!(underlined(0, 1)); + assert!(underlined(2, 1)); + assert!(!underlined(3, 1)); + let moved = hover_mouse(&state, 2, 1); + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(moved)]); + assert!(outcome.actions.is_empty()); + assert!(!outcome.repaint); + assert!(state.selection.is_none()); +} + +#[test] +fn ctrl_hover_pending_request_does_not_block_another_endpoint_with_the_same_boot_id() { + use crate::client::endpoint::{ + ClientEndpointId, ClientEndpointStatus, ProfileId, SavedSshEndpoint, + }; + let mut state = hover_state(); + let mouse = hover_mouse(&state, 1, 0); + let old_id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + let profile = SavedSshEndpoint { + id: ProfileId::parse("0123456789abcdef0123456789abcdef").unwrap(), + label: "Other connection".into(), + target: "host".into(), + session: "default".into(), + enabled: true, + }; + let endpoint = ClientEndpointId::Ssh(profile.id.clone()); + state.set_endpoint_catalog(&[profile]); + state.set_endpoint_status(&endpoint, ClientEndpointStatus::Online); + state.set_endpoint_snapshot(&endpoint, Box::new(snapshot())); + assert!(state.activate_endpoint_projection(&endpoint)); + assert!( + state.pending_requests.is_empty(), + "endpoint switches retire old requests" + ); + state.set_endpoint_methods(Some(vec!["pane.link.resolve".into()])); + state.set_pane_surface(surface()); + state.compose(106, 20).unwrap(); + let mouse = hover_mouse(&state, 1, 0); + let new_id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + assert!( + !resolve_hover(&mut state, &old_id), + "old endpoint must not paint here" + ); + assert!(resolve_hover(&mut state, &new_id)); +} + +#[test] +fn ctrl_hover_coalesces_motion_and_ignores_old_replies() { + let mut state = hover_state(); + let first = hover_mouse(&state, 1, 0); + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(first)])); + for col in [2, 3, 0] { + let mouse = hover_mouse(&state, col, 1); + assert!(state + .handle_raw_events(vec![RawInputEvent::Mouse(mouse)]) + .actions + .is_empty()); + } + let (repaint, actions) = state.handle_endpoint_result( + "boot-1", + &id, + Ok(crate::api::schema::ResponseResult::PaneLinkResolved { regions: vec![] }), + ); + assert!(!repaint); + assert!( + matches!( + &actions[..], + [ClientShellAction::Endpoint { request, .. }] + if matches!(&request.method, crate::api::schema::Method::PaneLinkResolve(params) + if params.viewport_row == 1 && params.col == 0) + ), + "only the latest pointer position should be queried" + ); + assert!(state.link_hover.as_ref().unwrap().regions.is_empty()); +} + +#[test] +fn ctrl_hover_clears_on_release_content_change_and_focus_loss() { + for clear in 0..4 { + let mut state = hover_state(); + let mouse = hover_mouse(&state, 1, 0); + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + assert!(resolve_hover(&mut state, &id)); + match clear { + 0 => { + state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + modifiers: KeyModifiers::empty(), + ..mouse + })]); + } + 1 => { + let mut next = surface(); + next.surface_revision += 1; + next.panes[0].content_revision += 2; + state.set_pane_surface(next); + } + 2 => { + state.handle_raw_events(vec![RawInputEvent::OuterFocusLost]); + } + _ => { + state.handle_raw_events(vec![RawInputEvent::Key( + crate::input::TerminalKey::new( + KeyCode::Modifier(crossterm::event::ModifierKeyCode::LeftControl), + KeyModifiers::empty(), + ) + .with_kind(crossterm::event::KeyEventKind::Release), + )]); + } + } + assert!(state.link_hover.is_none()); + } +} + +#[test] +fn ctrl_hover_content_patch_removes_all_old_underlines() { + let mut state = hover_state(); + let mouse = hover_mouse(&state, 1, 0); + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + assert!(resolve_hover(&mut state, &id)); + state.compose(106, 20).unwrap(); + let mut pane = surface().panes.remove(0); + pane.content_revision = 2; + let patch = crate::protocol::PaneSurfacePatch { + boot_id: "boot-1".into(), + projection_revision: 1, + base_surface_revision: 1, + surface_revision: 2, + rows: vec![crate::protocol::PaneSurfacePatchRow { + x: 0, + y: 0, + cells: vec![surface().frame.cells[0].clone()], + }], + panes: vec![pane], + cursor: None, + }; + assert!(matches!( + state.apply_pane_surface_patch(patch), + ClientPaneSurfacePatchOutcome::Applied(None) + )); + assert!(state.link_hover.is_none()); + let frame = state.compose(106, 20).unwrap(); + let pane = &state.hits.panes[0]; + for row in 0..2 { + for col in 0..4 { + let idx = usize::from(pane.inner_rect.y + row) * usize::from(frame.width) + + usize::from(pane.inner_rect.x + col); + assert_eq!(frame.cells[idx].modifier & Modifier::UNDERLINED.bits(), 0); + } + } +} + +#[test] +fn ctrl_hover_preserves_fast_patches_for_other_panes() { + let mut state = hover_state(); + let mut next = surface(); + next.surface_revision = 2; + next.frame.height = 4; + next.frame.cells.extend(next.frame.cells.clone()); + let mut other = next.panes[0].clone(); + other.pane_id = "pane_2".into(); + other.rect.y = 2; + other.inner_rect.y = 2; + next.panes.push(other.clone()); + state.set_pane_surface(next); + state.compose(106, 20).unwrap(); + let mouse = hover_mouse(&state, 1, 0); + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + assert!(resolve_hover(&mut state, &id)); + state.compose(106, 20).unwrap(); + other.content_revision = 2; + let patch = crate::protocol::PaneSurfacePatch { + boot_id: "boot-1".into(), + projection_revision: 1, + base_surface_revision: 2, + surface_revision: 3, + rows: vec![crate::protocol::PaneSurfacePatchRow { + x: 0, + y: 2, + cells: vec![surface().frame.cells[0].clone()], + }], + panes: vec![other], + cursor: None, + }; + assert!(matches!( + state.apply_pane_surface_patch(patch), + ClientPaneSurfacePatchOutcome::Applied(Some(_)) + )); + assert!(!state.link_hover.as_ref().unwrap().regions.is_empty()); +} + +#[test] +fn ctrl_hover_ignores_late_reply_after_pointer_leaves() { + let mut state = hover_state(); + let mouse = hover_mouse(&state, 1, 0); + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + state.handle_raw_events(vec![RawInputEvent::Mouse(MouseEvent { + column: 0, + row: 0, + ..mouse + })]); + assert!(!resolve_hover(&mut state, &id)); + assert!(state.link_hover.is_none()); +} + +#[test] +fn ctrl_hover_is_silent_when_unsupported_or_rejected() { + let mut state = hover_state(); + state.set_endpoint_methods(Some(vec![])); + let mouse = hover_mouse(&state, 1, 0); + assert!(state + .handle_raw_events(vec![RawInputEvent::Mouse(mouse)]) + .actions + .is_empty()); + assert!(state.visible_endpoint_notice.is_none()); + state.set_endpoint_methods(Some(vec!["pane.link.resolve".into()])); + state.clear_link_hover(); + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + state.handle_endpoint_result( + "boot-1", + &id, + Err(ClientShellEndpointError { + code: Some("endpoint_timeout".into()), + message: "timeout".into(), + }), + ); + assert!(state.visible_endpoint_notice.is_none()); +} + +#[test] +fn ctrl_hover_explicit_wide_label_includes_the_spacer_cell() { + let mut state = hover_state(); + state.set_endpoint_methods(Some(vec![])); + let mut next = surface(); + next.surface_revision += 1; + next.frame.hyperlinks.push("https://example.com/".into()); + next.frame.cells[1].symbol = "界".into(); + next.frame.cells[1].hyperlink = Some(0); + next.frame.cells[2].symbol = "".into(); + state.set_pane_surface(next); + let mouse = hover_mouse(&state, 2, 0); + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]); + assert!(outcome.actions.is_empty()); + assert!(outcome.repaint); + let regions = &state.link_hover.as_ref().unwrap().regions; + assert_eq!(regions.len(), 1); + assert_eq!((regions[0].start_col, regions[0].end_col), (1, 2)); +} + +#[test] +#[ignore = "non-gating fixed-geometry hover composition profile"] +fn ctrl_hover_render_scale_profile() { + for count in [1, 15] { + let mut state = hover_state(); + let mut next = surface(); + next.surface_revision += 1; + next.frame = + FrameData::from_ratatui_buffer(&Buffer::with_lines(vec!["x".repeat(120); 30]), None); + let template = next.panes[0].clone(); + next.panes.clear(); + for index in 0..count { + let mut pane = template.clone(); + pane.pane_id = format!("pane_{index}"); + pane.inner_rect = SurfaceRect { + x: if count == 1 { 0 } else { (index % 5) * 24 }, + y: if count == 1 { 0 } else { (index / 5) * 10 }, + width: if count == 1 { 120 } else { 24 }, + height: if count == 1 { 30 } else { 10 }, + }; + pane.rect = pane.inner_rect; + next.panes.push(pane); + } + state.set_pane_surface(next); + state.compose(146, 32).unwrap(); + for active in [false, true] { + if active { + let pane = state.hits.panes.last().unwrap(); + let mouse = MouseEvent { + kind: MouseEventKind::Moved, + column: pane.inner_rect.x + 1, + row: pane.inner_rect.y, + modifiers: KeyModifiers::CONTROL, + }; + let id = hover_request(&state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)])); + assert!(resolve_hover(&mut state, &id)); + } + let mut samples = Vec::new(); + for _ in 0..200 { + let started = std::time::Instant::now(); + std::hint::black_box(state.compose(146, 32).unwrap()); + samples.push(started.elapsed().as_micros()); + } + samples.sort_unstable(); + eprintln!( + "ctrl-hover panes={count} active={active} median_us={} p95_us={}", + samples[100], samples[190] + ); + assert!( + state.pending_requests.is_empty(), + "composition must never resolve links" + ); + } + } +} + +#[test] +fn ctrl_hover_groups_contiguous_same_destination_cells_without_server_query() { + let mut state = hover_state(); + state.set_endpoint_methods(Some(vec![])); + let mut next = surface(); + next.surface_revision += 1; + next.frame.hyperlinks.push("https://example.com/".into()); + for idx in 1..7 { + next.frame.cells[idx].hyperlink = Some(0); + } + state.set_pane_surface(next); + let mouse = hover_mouse(&state, 1, 0); + let outcome = state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]); + assert!(outcome.actions.is_empty()); + assert!(outcome.repaint); + assert_eq!(state.link_hover.as_ref().unwrap().regions.len(), 2); + + let mut next = state.pane_surface.as_ref().unwrap().clone(); + next.surface_revision += 1; + next.panes[0].content_revision += 2; + next.frame.cells[3].hyperlink = None; + state.set_pane_surface(next); + state.handle_raw_events(vec![RawInputEvent::Mouse(mouse)]); + let regions = &state.link_hover.as_ref().unwrap().regions; + assert_eq!(regions.len(), 1, "an unlinked gap must stop grouping"); + assert_eq!( + (regions[0].row, regions[0].start_col, regions[0].end_col), + (0, 1, 2) + ); +} diff --git a/src/client/shell/tests/mod.rs b/src/client/shell/tests/mod.rs index a308c1f9..6633494c 100644 --- a/src/client/shell/tests/mod.rs +++ b/src/client/shell/tests/mod.rs @@ -251,6 +251,7 @@ mod endpoints; #[path = "input.rs"] mod input_domain; mod keybindings_settings; +mod link_hover; mod mobile; mod mouse_selection; mod popup_focus_projection; diff --git a/src/client/shell/worktrees.rs b/src/client/shell/worktrees.rs index 31ddbeee..d4253672 100644 --- a/src/client/shell/worktrees.rs +++ b/src/client/shell/worktrees.rs @@ -557,6 +557,7 @@ impl ClientShellState { | PendingEndpointKind::PaneScroll { .. } | PendingEndpointKind::WordSelection { .. } | PendingEndpointKind::PaneLinkActivate { .. } + | PendingEndpointKind::PaneLinkResolve { .. } | PendingEndpointKind::CopyMotion { .. } | PendingEndpointKind::CopySearch { .. }, Err(_), |
