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