feat(selection): auto-scroll scrollback when dragging past the pane edge (#39)

Dragging a selection above the top (or below the bottom) of the pane
used to pin at the visible edge: pos_to_cell clamps the row and
on_select_update never moves the display offset, so scrollback content
just out of view was unreachable by mouse selection.

Now a left-drag that crosses the pane's vertical edge arms a repeating
50ms task that scrolls the display and re-anchors the selection's
moving end to the edge row, so the scroll continues while the pointer
holds still past the edge (mouse-move events alone stop the moment the
hand does). Speed scales with the overshoot distance — 1 line/tick when
grazing the edge up to a capped 8 lines/tick (~160 lines/s) — and the
task stops when the pointer re-enters, the drag ends, or a newer task
supersedes it (epoch guard, so a quick leave/re-enter/leave can't
double the speed). On the alt screen scroll_display no-ops (no
scrollback), leaving TUIs untouched.

Co-authored-by: l0ng-ai <24760907+l0ng-ai@users.noreply.github.com>
This commit is contained in:
l0ng-ai
2026-07-10 10:57:54 +08:00
committed by GitHub
co-authored by l0ng-ai
parent 9b03f006b1
commit 3cd9dee454
2 changed files with 177 additions and 3 deletions
+34
View File
@@ -1246,6 +1246,11 @@ impl TerminalElement {
}
if button == MouseButton::Left {
v.on_select_update(col, row, left, cx);
// Past the top/bottom edge, keep the selection growing by
// auto-scrolling the scrollback (`pos_to_cell` clamps the
// row, so the position alone stops at the edge).
let overshoot = drag_overshoot(ev.position.y, bounds, geom.line_height);
v.select_autoscroll(overshoot, col, left, cx);
}
});
});
@@ -1580,6 +1585,20 @@ impl CellGeom {
}
}
/// Vertical overshoot of a selection drag past the pane bounds, in lines:
/// positive above the top edge (auto-scroll up into history), negative below
/// the bottom, zero while inside. Feeds `TerminalView::select_autoscroll`.
fn drag_overshoot(y: Pixels, bounds: Bounds<Pixels>, line_height: Pixels) -> f32 {
let lh = line_height.as_f32().max(1.);
if y < bounds.top() {
(bounds.top() - y).as_f32() / lh
} else if y > bounds.bottom() {
-((y - bounds.bottom()).as_f32() / lh)
} else {
0.
}
}
#[cfg(test)]
mod tests {
use super::*;
@@ -1707,6 +1726,21 @@ mod tests {
assert_eq!((c, r), (4, 2));
}
/// Drag auto-scroll only engages past the vertical edges, scaled to lines:
/// above the top is positive (into history), below the bottom negative.
#[test]
fn drag_overshoot_signed_by_edge_and_zero_inside() {
let bounds = Bounds::new(point(px(0.), px(100.)), size(px(200.), px(100.)));
// Anywhere inside (including the exact edges) → no auto-scroll.
assert_eq!(drag_overshoot(px(150.), bounds, px(10.)), 0.);
assert_eq!(drag_overshoot(px(100.), bounds, px(10.)), 0.);
assert_eq!(drag_overshoot(px(200.), bounds, px(10.)), 0.);
// 20px above the top at 10px lines → 2 lines up.
assert_eq!(drag_overshoot(px(80.), bounds, px(10.)), 2.);
// 30px below the bottom → 3 lines down.
assert_eq!(drag_overshoot(px(230.), bounds, px(10.)), -3.);
}
// ---- segment_row ----
fn cell(c: char) -> RenderCell {
+143 -3
View File
@@ -54,6 +54,18 @@ pub struct ChildExited;
impl gpui::EventEmitter<ChildExited> for TerminalView {}
/// See `TerminalView::drag_scroll`.
#[derive(Clone, Copy)]
struct DragScroll {
/// How far past the pane edge the pointer sits, in lines. Positive =
/// above the top edge (scroll up into history), negative = below.
overshoot: f32,
/// Column to keep extending the selection with at the edge row.
col: usize,
/// Cell half to anchor the selection end on.
side: Side,
}
pub struct TerminalView {
pub terminal: RemoteTerminal,
/// Daemon-assigned id of the pane this view mirrors. Persisted in the session
@@ -83,6 +95,18 @@ pub struct TerminalView {
pub cell_width: Pixels,
line_height: Pixels,
selecting: bool,
/// Auto-scroll state for a selection drag that has crossed the pane's top
/// or bottom edge; `None` while the pointer is inside. A repeating task
/// (armed by `select_autoscroll`) keeps scrolling the scrollback and
/// re-extending the selection while this is `Some`, so the scroll goes on
/// even when the pointer holds still past the edge — mouse-move events
/// alone stop the moment the hand does.
drag_scroll: Option<DragScroll>,
/// Generation counter for the auto-scroll task. Bumped every time a new
/// task is armed so a stale task from a just-cancelled edge visit kills
/// itself instead of doubling the scroll speed when the pointer leaves,
/// re-enters, and leaves the pane again within one tick.
drag_scroll_epoch: u64,
pub title: String,
/// IME pre-edit (composing) text, e.g. the pinyin shown before a Chinese
/// candidate is committed. Empty when not composing.
@@ -607,6 +631,8 @@ impl TerminalView {
cell_width: px(8.),
line_height: px(17.),
selecting: false,
drag_scroll: None,
drag_scroll_epoch: 0,
title: "tty7".to_string(),
marked_text: String::new(),
last_mouse_cell: None,
@@ -2417,9 +2443,100 @@ impl TerminalView {
cx.notify();
}
/// Drive selection auto-scroll from a drag's vertical overshoot past the
/// pane bounds, in lines (0 while the pointer is inside, positive above
/// the top edge). Called on every left-drag move: entering the edge zone
/// arms a repeating task that scrolls the scrollback and keeps extending
/// the selection at the edge row; later moves just retune its speed and
/// column, and moving back inside (or releasing) stops it.
pub fn select_autoscroll(
&mut self,
overshoot: f32,
col: usize,
left: bool,
cx: &mut Context<Self>,
) {
if !self.selecting || overshoot == 0. {
self.drag_scroll = None;
return;
}
let side = if left { Side::Left } else { Side::Right };
let was_idle = self.drag_scroll.is_none();
self.drag_scroll = Some(DragScroll {
overshoot,
col,
side,
});
if !was_idle {
// The running task reads the fresh state on its next tick.
return;
}
// First step immediately so a quick flick past the edge still moves,
// then keep stepping on a timer. The task stops itself once the state
// clears (pointer back inside, drag ended), a newer task supersedes
// it (epoch mismatch), or the view is dropped.
self.drag_scroll_epoch += 1;
let epoch = self.drag_scroll_epoch;
self.drag_scroll_tick(epoch, cx);
cx.spawn(async move |this, cx| {
loop {
cx.background_executor()
.timer(std::time::Duration::from_millis(50))
.await;
if !matches!(
this.update(cx, |view, cx| view.drag_scroll_tick(epoch, cx)),
Ok(true)
) {
break;
}
}
})
.detach();
}
/// One auto-scroll step: scroll by an amount that grows with the
/// overshoot, then re-anchor the selection's moving end to the edge row
/// it is pushing past (top row when scrolling up, bottom when down).
/// Returns whether the task should keep ticking. Scrolling clamps at the
/// history limits, so pinning the pointer past the edge at the top of
/// scrollback just idles until it moves.
fn drag_scroll_tick(&mut self, epoch: u64, cx: &mut Context<Self>) -> bool {
if epoch != self.drag_scroll_epoch {
// Superseded by a newer task: the state now belongs to it, so just
// bow out without clearing anything.
return false;
}
if !self.selecting {
self.drag_scroll = None;
}
let Some(ds) = self.drag_scroll else {
return false;
};
let mut term = self.terminal.term.lock();
let before = term.grid().display_offset();
term.scroll_display(Scroll::Delta(drag_scroll_step(ds.overshoot)));
let offset = term.grid().display_offset();
let row = if ds.overshoot > 0. {
0
} else {
term.screen_lines().saturating_sub(1)
};
let point = Point::new(Line(row as i32 - offset as i32), Column(ds.col));
if let Some(sel) = term.selection.as_mut() {
sel.update(point, ds.side);
}
drop(term);
if offset != before {
self.scroll_frac = 0.;
cx.notify();
}
true
}
pub fn on_select_end(&mut self, _cx: &mut Context<Self>) {
self.selecting = false;
self.editor_selecting = false;
self.drag_scroll = None;
}
fn on_scroll(&mut self, ev: &ScrollWheelEvent, _window: &mut Window, cx: &mut Context<Self>) {
@@ -3482,12 +3599,22 @@ fn smooth_scroll_step(offset: usize, frac: f32, delta: f32, max: usize) -> (i32,
(new_offset as i32 - offset as i32, pos - new_offset)
}
/// Lines to scroll per auto-scroll tick for a selection drag sitting
/// `overshoot` lines past the pane edge (sign = direction, positive = up into
/// history). At least one line per tick so grazing the edge still crawls;
/// farther out speeds up, capped so a wild fling stays controllable
/// (8 lines/tick at a 50ms cadence ≈ 160 lines/s).
fn drag_scroll_step(overshoot: f32) -> i32 {
let lines = overshoot.abs().ceil().clamp(1., 8.) as i32;
if overshoot < 0. { -lines } else { lines }
}
#[cfg(test)]
mod tests {
use super::{
WheelRoute, clipboard_paste_text, display_width, encode_mouse, fallback_chain,
fig_icon_emoji, fig_icon_glyph, focus_report_bytes, menu_layout, paste_bytes,
shell_escape_path, smooth_scroll_step, trim_trailing_spaces, wheel_route,
WheelRoute, clipboard_paste_text, display_width, drag_scroll_step, encode_mouse,
fallback_chain, fig_icon_emoji, fig_icon_glyph, focus_report_bytes, menu_layout,
paste_bytes, shell_escape_path, smooth_scroll_step, trim_trailing_spaces, wheel_route,
wrapped_click_index,
};
use alacritty_terminal::term::TermMode;
@@ -3755,6 +3882,19 @@ mod tests {
assert_eq!(smooth_scroll_step(0, 0.0, 2.5, 0), (0, 0.0));
}
/// Selection auto-scroll: grazing the edge crawls one line per tick,
/// farther out speeds up with the overshoot, a fling caps at 8, and the
/// sign follows the direction (positive = up into history).
#[test]
fn drag_scroll_step_scales_with_overshoot_and_caps() {
assert_eq!(drag_scroll_step(0.2), 1);
assert_eq!(drag_scroll_step(-0.2), -1);
assert_eq!(drag_scroll_step(3.5), 4);
assert_eq!(drag_scroll_step(-3.5), -4);
assert_eq!(drag_scroll_step(50.0), 8);
assert_eq!(drag_scroll_step(-50.0), -8);
}
#[test]
fn trim_trailing_spaces_strips_per_line_and_preserves_structure() {
// Trailing spaces/tabs go; interior spaces and line count stay.