mirror of
https://github.com/l0ng-ai/tty7.git
synced 2026-09-21 16:02:20 +00:00
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:
@@ -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
@@ -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.
|
||||
|
||||
Reference in New Issue
Block a user