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/shell/link_hover.rs | |
| parent | 59167658455442ffd2cd5b72a0fa81bcc4bcdf9e (diff) | |
feat: add ctrl-hover highlighting and fix wrapped links (#4074)
refs #1282
Diffstat (limited to 'src/client/shell/link_hover.rs')
| -rw-r--r-- | src/client/shell/link_hover.rs | 352 |
1 files changed, 352 insertions, 0 deletions
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 +} |
