diff options
Diffstat (limited to 'src')
| -rw-r--r-- | src/client/shell/actions.rs | 2 | ||||
| -rw-r--r-- | src/client/shell/copy_mode.rs | 22 | ||||
| -rw-r--r-- | src/client/shell/mouse.rs | 2 | ||||
| -rw-r--r-- | src/client/shell/state.rs | 104 | ||||
| -rw-r--r-- | src/client/shell/tests/copy.rs | 131 | ||||
| -rw-r--r-- | src/client/shell/tests/mouse_selection.rs | 39 | ||||
| -rw-r--r-- | src/client/shell/tests/popup_focus_projection.rs | 1 |
7 files changed, 194 insertions, 107 deletions
diff --git a/src/client/shell/actions.rs b/src/client/shell/actions.rs index 48dab344..12d8cc16 100644 --- a/src/client/shell/actions.rs +++ b/src/client/shell/actions.rs @@ -269,7 +269,7 @@ impl ClientShellState { .as_ref() .and_then(|surface| surface.panes.iter().find(|pane| pane.pane_id == pane_id)) .map(|pane| pane.content_revision) - // Read a manual mouse selection atomically from the live terminal. Output + // Read an explicit selection atomically from the live terminal. Output // between the displayed frame and this request must not reject the copy. .filter(|_| !live); let (anchor, cursor) = selection.ordered_cells(); diff --git a/src/client/shell/copy_mode.rs b/src/client/shell/copy_mode.rs index 9750e19e..07dc8d18 100644 --- a/src/client/shell/copy_mode.rs +++ b/src/client/shell/copy_mode.rs @@ -70,15 +70,18 @@ impl ClientShellState { self.stop_selection_autoscroll(); self.selection_highlight_clear_deadline = None; self.reset_copy_pipeline(); - let content_revision = self + let (content_revision, alternate_screen_active) = self .pane_surface .as_ref() .and_then(|surface| surface.panes.iter().find(|pane| pane.pane_id == pane_id)) - .map_or(0, |pane| pane.content_revision); + .map_or((0, false), |pane| { + (pane.content_revision, pane.alternate_screen_active) + }); self.copy_mode = Some(ClientCopyModeState { pane_id, content_revision, geometry: (hit.inner_rect.width, hit.inner_rect.height), + alternate_screen_active, cursor, offset_from_bottom: metrics.offset_from_bottom, max_offset_from_bottom: metrics.max_offset_from_bottom, @@ -814,12 +817,11 @@ impl ClientShellState { } pub(super) fn exit_copy_mode(&mut self, copy: bool, outcome: &mut ClientShellInput) { - if copy - && !self - .selection - .as_ref() - .is_some_and(crate::selection::Selection::is_visible) - { + let live_selection = self + .selection + .as_ref() + .is_some_and(crate::selection::Selection::is_visible); + if copy && !live_selection { if let Some((pane_id, text_match)) = self.copy_mode.as_ref().and_then(|copy_mode| { copy_mode .search_current @@ -843,7 +845,9 @@ impl ClientShellState { .as_ref() .is_some_and(crate::selection::Selection::is_visible) { - self.request_selection_copy(outcome, false); + // A visible explicit selection is live. If the fallback above supplied + // a search match, retain the revision that established its boundaries. + self.request_selection_copy(outcome, live_selection); } self.selection = None; self.selection_highlight_clear_deadline = None; diff --git a/src/client/shell/mouse.rs b/src/client/shell/mouse.rs index 1828d7f6..0b9326d0 100644 --- a/src/client/shell/mouse.rs +++ b/src/client/shell/mouse.rs @@ -1712,7 +1712,7 @@ impl ClientShellState { .as_mut() .is_some_and(crate::selection::Selection::finish); if copied && self.config.copy_on_select { - self.request_selection_copy(outcome, false); + self.request_selection_copy(outcome, true); self.selection = None; } else if self .selection diff --git a/src/client/shell/state.rs b/src/client/shell/state.rs index f61295c8..901c5a28 100644 --- a/src/client/shell/state.rs +++ b/src/client/shell/state.rs @@ -5,63 +5,6 @@ pub(super) const NEW_TAB_WIDTH: u16 = 3; pub(super) const WORKSPACE_HEADER_ROWS: u16 = 2; const ENDPOINT_ERROR_TIMEOUT_SECS: u64 = 5; -fn pane_surface_row<'a>( - surface: &'a PaneSurfaceFrame, - pane: &crate::protocol::PaneSurfacePane, - absolute_row: u32, -) -> Option<&'a [crate::protocol::CellData]> { - let viewport_top = pane - .scroll - .map(|scroll| { - scroll - .max_offset_from_bottom - .saturating_sub(scroll.offset_from_bottom) as u32 - }) - .unwrap_or(0); - let viewport_row = u16::try_from(absolute_row.checked_sub(viewport_top)?).ok()?; - if viewport_row >= pane.inner_rect.height { - return None; - } - let start = (usize::from(pane.inner_rect.y) + usize::from(viewport_row)) - * usize::from(surface.frame.width) - + usize::from(pane.inner_rect.x); - surface - .frame - .cells - .get(start..start + usize::from(pane.inner_rect.width)) -} - -fn selection_cells_unchanged( - selection: &crate::selection::Selection<String>, - previous_surface: &PaneSurfaceFrame, - previous_pane: &crate::protocol::PaneSurfacePane, - next_surface: &PaneSurfaceFrame, - next_pane: &crate::protocol::PaneSurfacePane, -) -> bool { - let ((start_row, start_col), (end_row, end_col)) = selection.ordered_cells(); - (start_row..=end_row).all(|row| { - let first_col = if row == start_row { start_col } else { 0 }; - let last_col = if row == end_row { - end_col - } else { - previous_pane.inner_rect.width.saturating_sub(1) - }; - pane_surface_row(previous_surface, previous_pane, row) - .zip(pane_surface_row(next_surface, next_pane, row)) - .and_then(|(previous, next)| { - previous - .get(usize::from(first_col)..=usize::from(last_col)) - .zip(next.get(usize::from(first_col)..=usize::from(last_col))) - }) - .is_some_and(|(previous, next)| { - previous - .iter() - .zip(next) - .all(|(previous, next)| previous.symbol == next.symbol) - }) - }) -} - #[derive(Debug, Clone, Copy, PartialEq, Eq)] pub(crate) enum ClientShellKeybindingSource { Local, @@ -874,6 +817,7 @@ pub(super) struct ClientCopyModeState { pub(super) pane_id: String, pub(super) content_revision: u64, pub(super) geometry: (u16, u16), + pub(super) alternate_screen_active: bool, pub(super) cursor: crate::api::schema::PaneTextPoint, pub(super) offset_from_bottom: usize, pub(super) max_offset_from_bottom: usize, @@ -1729,7 +1673,7 @@ impl ClientShellState { Some(gesture) => Some(&gesture.pane_id), None => self.selection.as_ref().map(|selection| &selection.pane_id), }; - let selection_content_changed = selection_pane.is_some_and(|pane_id| { + let selection_invalidated = selection_pane.is_some_and(|pane_id| { let Some(previous_surface) = self.pane_surface.as_ref() else { return false; }; @@ -1741,34 +1685,15 @@ impl ClientShellState { let (Some(previous), Some(next)) = (previous, next) else { return false; }; - if previous.inner_rect.width != next.inner_rect.width + previous.inner_rect.width != next.inner_rect.width || previous.inner_rect.height != next.inner_rect.height || previous.alternate_screen_active != next.alternate_screen_active - { - return true; - } - if previous.content_revision == next.content_revision { - return false; - } - match (&self.word_selection_gesture, &self.selection) { - // Word gestures cache boundaries outside the selected cells too. - (Some(_), _) => true, - (None, Some(selection)) => { - self.config.copy_on_select - && (!previous.content_revision.is_multiple_of(2) - || !next.content_revision.is_multiple_of(2) - || !selection_cells_unchanged( - selection, - previous_surface, - previous, - &surface, - next, - )) - } - (None, None) => false, - } + // Ordinary selections are live buffer ranges. Only word gestures + // cache content-dependent boundaries that output can invalidate. + || (self.word_selection_gesture.is_some() + && previous.content_revision != next.content_revision) }); - if selection_content_changed { + if selection_invalidated { self.word_selection_gesture = None; self.selection = None; self.stop_selection_autoscroll(); @@ -1795,19 +1720,22 @@ impl ClientShellState { .find(|pane| pane.pane_id == copy_mode.pane_id) { let geometry = (pane.inner_rect.width, pane.inner_rect.height); - if copy_mode.content_revision != pane.content_revision - || copy_mode.geometry != geometry - { + let coordinates_changed = copy_mode.geometry != geometry + || copy_mode.alternate_screen_active != pane.alternate_screen_active; + if copy_mode.content_revision != pane.content_revision || coordinates_changed { copy_mode.content_revision = pane.content_revision; copy_mode.geometry = geometry; - copy_mode.selection = None; + copy_mode.alternate_screen_active = pane.alternate_screen_active; + if coordinates_changed { + copy_mode.selection = None; + invalidated_copy_pane = Some(copy_mode.pane_id.clone()); + } copy_mode.search_matches.clear(); copy_mode.search_total = 0; copy_mode.search_current = None; copy_mode.search_current_global = None; copy_mode.search_generation = copy_mode.search_generation.saturating_add(1); copy_mode.copy_after_search = false; - invalidated_copy_pane = Some(copy_mode.pane_id.clone()); } if let Some(scroll) = pane.scroll { let actual_offset = diff --git a/src/client/shell/tests/copy.rs b/src/client/shell/tests/copy.rs index 8ee35f45..eda4da89 100644 --- a/src/client/shell/tests/copy.rs +++ b/src/client/shell/tests/copy.rs @@ -22,6 +22,7 @@ fn pasted_help_and_copy_queries_normalize_single_line_text() { pane_id: "pane_1".into(), content_revision: 0, geometry: (80, 24), + alternate_screen_active: false, cursor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, offset_from_bottom: 0, max_offset_from_bottom: 0, @@ -194,6 +195,7 @@ fn client_mouse_selection_highlights_and_copies_through_endpoint_extraction() { if params.pane_id == "pane_1" && params.anchor == crate::api::schema::PaneTextPoint { row: 0, col: 0 } && params.cursor == crate::api::schema::PaneTextPoint { row: 0, col: 2 } + && params.content_revision.is_none() )); let (repaint, actions) = state.handle_endpoint_result( @@ -506,7 +508,7 @@ fn keyboard_copy_mode_owns_cursor_selection_copy_and_scroll_restore() { action, ClientShellAction::Endpoint { request, .. } if matches!(&request.method, crate::api::schema::Method::PaneSelectionRead(params) - if params.content_revision == Some(0)) + if params.content_revision.is_none()) ))); assert!(copy.actions.iter().any(|action| matches!( action, @@ -520,6 +522,133 @@ fn keyboard_copy_mode_owns_cursor_selection_copy_and_scroll_restore() { } #[test] +fn keyboard_selections_survive_output_and_copy_live_ranges() { + // Character and linewise selections have distinct anchor/range projections. + for selection_key in [b"v", b"V"] { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 0, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface.clone()); + state.compose(106, 20).expect("composed frame"); + state.handle_input_bytes(b"\x02["); + state.handle_input_bytes(selection_key); + state.handle_input_bytes(b"k"); + let range = state + .selection + .as_ref() + .expect("selected range") + .ordered_cells(); + + pane_surface.surface_revision += 1; + pane_surface.panes[0].content_revision += 2; + pane_surface.frame.cells[0].symbol = "X".into(); + state.set_pane_surface(pane_surface); + assert_eq!(state.mode, ClientShellMode::Copy); + assert!(state.copy_mode.as_ref().unwrap().selection.is_some()); + assert_eq!( + state + .selection + .as_ref() + .expect("retained range") + .ordered_cells(), + range + ); + + let copied = state.handle_input_bytes(b"y"); + assert!(copied.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!(&request.method, crate::api::schema::Method::PaneSelectionRead(params) + if params.content_revision.is_none() + && (params.anchor.row, params.anchor.col) == range.0 + && (params.cursor.row, params.cursor.col) == range.1) + ))); + assert_eq!(state.mode, ClientShellMode::Terminal); + assert!(state.selection.is_none()); + assert!(state.copy_mode.is_none()); + } +} + +#[test] +fn empty_keyboard_anchor_keeps_search_fallback_revision_guard() { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 0, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface); + state.compose(106, 20).expect("composed frame"); + state.handle_input_bytes(b"\x02["); + let search = state.handle_input_bytes(b"/LIVE\r"); + let [ClientShellAction::Endpoint { request, .. }] = &search.actions[..] else { + panic!("search request"); + }; + let found = crate::api::schema::PaneTextRange { + start: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, + end: crate::api::schema::PaneTextPoint { row: 0, col: 3 }, + }; + state.handle_endpoint_result( + "boot-1", + &request.id, + Ok(copy_search_result(vec![found], Some(0))), + ); + state.handle_input_bytes(b"v"); + assert!(!state.selection.as_ref().unwrap().is_visible()); + let copy = state.handle_input_bytes(b"y"); + assert!(copy.actions.iter().any(|action| matches!( + action, + ClientShellAction::Endpoint { request, .. } + if matches!(&request.method, crate::api::schema::Method::PaneSelectionRead(params) + if params.anchor == found.start + && params.cursor == found.end + && params.content_revision == Some(0)) + ))); +} + +#[test] +fn keyboard_selection_does_not_return_after_resize_or_screen_switch() { + for screen_switch in [false, true] { + let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); + state.set_snapshot(Box::new(snapshot())); + let mut pane_surface = surface(); + pane_surface.panes[0].scroll = Some(crate::protocol::PaneSurfaceScrollMetrics { + offset_from_bottom: 0, + max_offset_from_bottom: 0, + viewport_rows: 2, + }); + state.set_pane_surface(pane_surface.clone()); + state.compose(106, 20).expect("composed frame"); + state.handle_input_bytes(b"\x02["); + state.handle_input_bytes(b"vk"); + assert!(state.selection.is_some()); + pane_surface.surface_revision += 1; + pane_surface.panes[0].content_revision += 2; + if screen_switch { + pane_surface.panes[0].alternate_screen_active = true; + } else { + pane_surface.panes[0].inner_rect.width -= 1; + } + state.set_pane_surface(pane_surface); + assert!(state.selection.is_none()); + assert!(state.copy_mode.as_ref().unwrap().selection.is_none()); + state.compose(106, 20).expect("changed frame"); + state.handle_input_bytes(b"l"); + assert!( + state.selection.is_none(), + "movement must not resurrect the old anchor" + ); + } +} + +#[test] fn keyboard_copy_mode_content_motion_is_endpoint_backed_and_stale_safe() { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); state.set_snapshot(Box::new(snapshot())); diff --git a/src/client/shell/tests/mouse_selection.rs b/src/client/shell/tests/mouse_selection.rs index ccb9b86d..ae4168c2 100644 --- a/src/client/shell/tests/mouse_selection.rs +++ b/src/client/shell/tests/mouse_selection.rs @@ -692,7 +692,7 @@ fn double_click_drag_autoscroll_keeps_absolute_word_anchor() { } #[test] -fn pane_content_updates_preserve_active_selection_only_when_selected_cells_stay_stable() { +fn pane_content_updates_preserve_live_ranges_until_geometry_or_screen_changes() { let mut state = ClientShellState::new(ClientShellConfig::from_config(&Config::default())); state.set_snapshot(Box::new(snapshot())); let surface_at = |surface_revision, content_revision, alternate_screen_active| { @@ -740,17 +740,42 @@ fn pane_content_updates_preserve_active_selection_only_when_selected_cells_stay_ assert!(selection.is_visible()); assert_eq!(selection.ordered_cells(), ((12, 0), (12, 1))); - state.selection = Some(crate::selection::Selection::absolute_anchor( - "pane_1".to_owned(), - (12, 0), - )); let mut replaced_surface = surface_at(3, 4, true); replaced_surface.frame.cells[4].symbol = "X".into(); state.set_pane_surface(replaced_surface); - assert!(state.selection.is_none()); + assert_eq!( + state.selection.as_ref().unwrap().ordered_cells(), + ((12, 0), (12, 1)) + ); + + // The selected row can leave the viewport during a drag. A later patch, + // including an in-flight content revision, must keep that absolute range. + let mut scrolled = surface_at(4, 5, true); + scrolled.panes[0] + .scroll + .as_mut() + .unwrap() + .offset_from_bottom = 2; + assert!(matches!( + state.apply_pane_surface_patch(crate::protocol::PaneSurfacePatch { + boot_id: scrolled.boot_id, + projection_revision: scrolled.projection_revision, + base_surface_revision: 3, + surface_revision: 4, + panes: scrolled.panes, + rows: vec![], + cursor: scrolled.frame.cursor, + }), + super::super::surface_patch::ClientPaneSurfacePatchOutcome::Applied(_) + )); + assert!(state.selection.as_ref().unwrap().is_in_progress()); + assert_eq!( + state.selection.as_ref().unwrap().ordered_cells(), + ((12, 0), (12, 1)) + ); for (surface_revision, content_revision, width, alternate_screen_active) in - [(4, 6, 4, false), (5, 8, 3, false), (6, 9, 3, false)] + [(5, 6, 4, false), (6, 8, 3, false)] { state.selection = Some(crate::selection::Selection::absolute_anchor( "pane_1".to_owned(), diff --git a/src/client/shell/tests/popup_focus_projection.rs b/src/client/shell/tests/popup_focus_projection.rs index aa44bf6d..25d3b334 100644 --- a/src/client/shell/tests/popup_focus_projection.rs +++ b/src/client/shell/tests/popup_focus_projection.rs @@ -107,6 +107,7 @@ fn modal_paste_target_requires_a_focused_editable_client_field() { pane_id: "pane_1".into(), content_revision: 0, geometry: (80, 24), + alternate_screen_active: false, cursor: crate::api::schema::PaneTextPoint { row: 0, col: 0 }, offset_from_bottom: 0, max_offset_from_bottom: 0, |
