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
|
// 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
|
// buffer coordinates (not DOM), so it survives re-renders and works across
|
||||||
// rows that have scrolled out of the rendered window.
|
// 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 cell_from_event = move |event: &MouseEvent| -> Option<(usize, usize)> {
|
||||||
let element = terminal_ref.get_untracked()?;
|
let element = terminal_ref.get_untracked()?;
|
||||||
let rect = element.get_bounding_client_rect();
|
let rect = element.get_bounding_client_rect();
|
||||||
@@ -1720,6 +1727,7 @@ pub fn TerminalPanel(
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let autoscroll_move = autoscroll_handle.clone();
|
||||||
let handle_mousemove = {
|
let handle_mousemove = {
|
||||||
let move_selection = selection;
|
let move_selection = selection;
|
||||||
let move_selecting = selecting;
|
let move_selecting = selecting;
|
||||||
@@ -1727,6 +1735,7 @@ pub fn TerminalPanel(
|
|||||||
let move_held = mouse_held_button.clone();
|
let move_held = mouse_held_button.clone();
|
||||||
let move_last = mouse_last_cell.clone();
|
let move_last = mouse_last_cell.clone();
|
||||||
let move_ws = ws_signal;
|
let move_ws = ws_signal;
|
||||||
|
let autoscroll_handle = autoscroll_move;
|
||||||
move |event: MouseEvent| {
|
move |event: MouseEvent| {
|
||||||
// App mouse motion reporting.
|
// App mouse motion reporting.
|
||||||
if !event.shift_key() {
|
if !event.shift_key() {
|
||||||
@@ -1783,15 +1792,53 @@ pub fn TerminalPanel(
|
|||||||
s.head = cell;
|
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 handle_mouseup = {
|
||||||
let up_selecting = selecting;
|
let up_selecting = selecting;
|
||||||
let up_core = core_signal;
|
let up_core = core_signal;
|
||||||
let up_held = mouse_held_button.clone();
|
let up_held = mouse_held_button.clone();
|
||||||
let up_last = mouse_last_cell.clone();
|
let up_last = mouse_last_cell.clone();
|
||||||
let up_ws = ws_signal;
|
let up_ws = ws_signal;
|
||||||
|
let autoscroll_handle = autoscroll_up;
|
||||||
move |event: MouseEvent| {
|
move |event: MouseEvent| {
|
||||||
if !event.shift_key() {
|
if !event.shift_key() {
|
||||||
let mode_enc = up_core
|
let mode_enc = up_core
|
||||||
@@ -1831,6 +1878,7 @@ pub fn TerminalPanel(
|
|||||||
|
|
||||||
// Local selection end.
|
// Local selection end.
|
||||||
up_selecting.set(false);
|
up_selecting.set(false);
|
||||||
|
stop_autoscroll(&autoscroll_handle);
|
||||||
}
|
}
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -2447,6 +2495,92 @@ fn sync_scroll_to_position(
|
|||||||
callback.forget();
|
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)]
|
#[derive(Clone, Copy)]
|
||||||
struct SearchScrollContext {
|
struct SearchScrollContext {
|
||||||
total_rows: usize,
|
total_rows: usize,
|
||||||
|
|||||||
Reference in New Issue
Block a user