From 8d73d6eabc476e2ba4bf4dedf0fc33fa3257be93 Mon Sep 17 00:00:00 2001 From: zhangheng Date: Thu, 25 Jun 2026 12:10:05 +0800 Subject: [PATCH] feat(terminal): auto-scroll viewport during selection drag near edges When the user drags a text selection and the cursor reaches within 40px of the top or bottom edge of the terminal, the viewport auto-scrolls at a speed proportional to the distance from the edge (up to 8 px/frame). The selection head extends as the view scrolls, enabling cross-page selection without manually scrolling first. - Add start_autoscroll / stop_autoscroll helpers using setInterval (~60fps) - Detect edge proximity in handle_mousemove after updating selection head - Stop auto-scroll on mouseup or when cursor leaves edge zone - Switch view_mode to History/Live based on scroll position --- app/src/terminal/component.rs | 134 ++++++++++++++++++++++++++++++++++ 1 file changed, 134 insertions(+) diff --git a/app/src/terminal/component.rs b/app/src/terminal/component.rs index 4e89746..5de7f91 100644 --- a/app/src/terminal/component.rs +++ b/app/src/terminal/component.rs @@ -1627,6 +1627,13 @@ pub fn TerminalPanel( // Drag to select a range of terminal text. Selection is tracked in logical // buffer coordinates (not DOM), so it survives re-renders and works across // rows that have scrolled out of the rendered window. + + // Auto-scroll state: when the user drags a selection near the top or + // bottom edge of the terminal, we auto-scroll the viewport and extend the + // selection. `None` means not auto-scrolling. + let autoscroll_handle: Rc>> = + Rc::new(std::cell::Cell::new(None)); + let cell_from_event = move |event: &MouseEvent| -> Option<(usize, usize)> { let element = terminal_ref.get_untracked()?; let rect = element.get_bounding_client_rect(); @@ -1720,6 +1727,7 @@ pub fn TerminalPanel( } }; + let autoscroll_move = autoscroll_handle.clone(); let handle_mousemove = { let move_selection = selection; let move_selecting = selecting; @@ -1727,6 +1735,7 @@ pub fn TerminalPanel( let move_held = mouse_held_button.clone(); let move_last = mouse_last_cell.clone(); let move_ws = ws_signal; + let autoscroll_handle = autoscroll_move; move |event: MouseEvent| { // App mouse motion reporting. if !event.shift_key() { @@ -1783,15 +1792,53 @@ pub fn TerminalPanel( s.head = cell; } }); + + // Auto-scroll when dragging near the top or bottom edge. + let Some(element) = terminal_ref.get_untracked() else { + return; + }; + let rect = element.get_bounding_client_rect(); + let local_y = f64::from(event.client_y()) - rect.top(); + let height = rect.height(); + let edge_zone = 40.0_f64; // px from top/bottom to trigger auto-scroll + + if local_y < edge_zone { + // Near top edge: scroll up, speed increases closer to edge. + let speed = ((edge_zone - local_y) / edge_zone * 8.0).max(1.0); + start_autoscroll( + &autoscroll_handle, + terminal_ref, + scroll_top, + programmatic_scroll_top, + view_mode, + -speed, + ); + } else if local_y > height - edge_zone { + // Near bottom edge: scroll down. + let speed = ((local_y - (height - edge_zone)) / edge_zone * 8.0).max(1.0); + start_autoscroll( + &autoscroll_handle, + terminal_ref, + scroll_top, + programmatic_scroll_top, + view_mode, + speed, + ); + } else { + // Not near any edge — stop auto-scrolling. + stop_autoscroll(&autoscroll_handle); + } } }; + let autoscroll_up = autoscroll_handle.clone(); let handle_mouseup = { let up_selecting = selecting; let up_core = core_signal; let up_held = mouse_held_button.clone(); let up_last = mouse_last_cell.clone(); let up_ws = ws_signal; + let autoscroll_handle = autoscroll_up; move |event: MouseEvent| { if !event.shift_key() { let mode_enc = up_core @@ -1831,6 +1878,7 @@ pub fn TerminalPanel( // Local selection end. up_selecting.set(false); + stop_autoscroll(&autoscroll_handle); } }; @@ -2447,6 +2495,92 @@ fn sync_scroll_to_position( callback.forget(); } +// -- Auto-scroll during selection drag -------------------------------- + + +/// Start or continue auto-scrolling the terminal viewport while the user +/// drags a selection near an edge. `delta_px` is negative for upward +/// scroll (top edge) and positive for downward (bottom edge). +fn start_autoscroll( + handle: &Rc>>, + terminal_ref: NodeRef, + scroll_top: RwSignal, + programmatic_scroll_top: RwSignal, + view_mode: RwSignal, + delta_px: f64, +) { + use wasm_bindgen::JsCast; + use web_sys::window; + + // If already auto-scrolling, skip re-creation. + if handle.get().is_some() { + return; + } + + let Some(window) = window() else { + return; + }; + + let delta_px = delta_px.round() as i32; + if delta_px == 0 { + return; + } + + let handle_cell = handle.clone(); + + let callback = Closure::::wrap(Box::new(move || { + let Some(element) = terminal_ref.get_untracked() else { + return; + }; + let Some(el) = element.dyn_ref::() else { + return; + }; + + let new_scroll_top = el.scroll_top() + delta_px; + // Clamp to valid range. + let max_scroll = el.scroll_height() - el.client_height(); + let clamped = new_scroll_top.clamp(0, max_scroll); + el.set_scroll_top(clamped); + programmatic_scroll_top.set(clamped); + + // Switch to history mode when scrolling away from the bottom. + let at_bottom = clamped >= max_scroll.saturating_sub(2); + if !at_bottom { + let offset = super::scrollback::scroll_top_to_row( + clamped, + // line_height_px — we don't have it here, but view_mode + // with an approximate offset is fine for auto-scroll; + // the next render tick will correct it. + 18.0, + ); + view_mode.set(ViewMode::History { scroll_offset: offset }); + } else { + view_mode.set(ViewMode::Live); + } + + scroll_top.set(clamped); + }) as Box); + + let Ok(interval_id) = window.set_interval_with_callback_and_timeout_and_arguments_0( + callback.as_ref().unchecked_ref(), + 16, // ~60 fps + ) else { + return; + }; + + handle_cell.set(Some(interval_id)); + // Leak the closure so it stays alive for the lifetime of the interval. + callback.forget(); +} + +/// Stop any active auto-scroll interval. +fn stop_autoscroll(handle: &Rc>>) { + if let Some(interval_id) = handle.take() + && let Some(window) = web_sys::window() + { + window.clear_interval_with_handle(interval_id); + } +} #[derive(Clone, Copy)] struct SearchScrollContext { total_rows: usize,