aboutsummaryrefslogtreecommitdiff
path: root/src/client/shell/tests
diff options
context:
space:
mode:
authorCan Celik <ogulcancelik@gmail.com>2026-09-13 23:05:30 +0300
committerGitHub <noreply@github.com>2026-09-13 23:05:30 +0300
commitf8e59221cbe5117f8f691a36affa374f5a986a9f (patch)
tree3b2792683eff251b061fbbad7343ecffafe91aab /src/client/shell/tests
parent59167658455442ffd2cd5b72a0fa81bcc4bcdf9e (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.rs410
-rw-r--r--src/client/shell/tests/mod.rs1
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;