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
This commit is contained in:
@@ -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<std::cell::Cell<Option<i32>>> =
|
||||
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<std::cell::Cell<Option<i32>>>,
|
||||
terminal_ref: NodeRef<html::Div>,
|
||||
scroll_top: RwSignal<i32>,
|
||||
programmatic_scroll_top: RwSignal<i32>,
|
||||
view_mode: RwSignal<ViewMode>,
|
||||
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::<dyn FnMut()>::wrap(Box::new(move || {
|
||||
let Some(element) = terminal_ref.get_untracked() else {
|
||||
return;
|
||||
};
|
||||
let Some(el) = element.dyn_ref::<HtmlElement>() 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<dyn FnMut()>);
|
||||
|
||||
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<std::cell::Cell<Option<i32>>>) {
|
||||
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,
|
||||
|
||||
Reference in New Issue
Block a user