1
2
3
4
5
6
7
8
9
10
11
12
13
14
15
16
17
18
19
20
21
22
23
24
25
26
27
28
29
30
31
32
33
34
35
36
37
38
39
40
41
42
43
44
45
46
47
48
49
50
51
52
53
54
55
56
57
58
59
60
61
62
63
64
65
66
67
68
69
70
71
72
73
74
75
76
77
78
79
80
81
82
83
84
85
86
87
88
89
90
91
92
93
94
95
96
97
98
99
100
101
102
103
104
105
106
107
108
109
110
111
112
113
114
115
116
117
118
119
120
121
122
123
124
125
126
127
128
129
130
131
132
133
134
135
136
137
138
139
140
141
142
143
144
145
146
147
148
149
150
151
152
153
154
155
156
157
158
159
160
161
162
163
164
165
166
167
168
169
170
171
172
173
174
175
176
177
178
179
180
181
182
183
184
185
186
187
188
189
190
191
192
193
194
195
196
197
198
199
200
201
202
203
204
205
206
207
208
209
210
211
212
213
214
215
216
217
218
219
220
221
222
223
224
225
226
227
228
229
230
231
232
233
234
235
236
237
238
239
240
241
242
243
244
245
246
247
248
249
250
251
252
253
254
255
256
257
258
259
260
261
262
263
264
265
266
267
268
269
270
271
272
273
274
275
276
277
278
279
280
281
282
283
284
285
286
287
288
289
290
291
292
293
294
295
296
297
298
299
300
301
302
303
304
305
306
|
use crossterm::event::{MouseEvent, MouseEventKind};
use crate::{
app::state::{AppState, SelectionAutoscroll, SelectionAutoscrollDirection},
terminal::TerminalRuntimeRegistry,
};
impl AppState {
pub(crate) fn update_selection_cursor(
&mut self,
terminal_runtimes: &TerminalRuntimeRegistry,
pane_id: crate::layout::PaneId,
screen_col: u16,
screen_row: u16,
) {
let Some(info) = self.pane_info_by_id(pane_id).cloned() else {
return;
};
let metrics = self.pane_scroll_metrics(terminal_runtimes, pane_id);
if let Some(selection) = self.selection.as_mut() {
selection.drag(screen_col, screen_row, info.inner_rect, metrics);
}
}
fn selection_edge_scroll_lines(distance: u16) -> usize {
usize::from(distance).saturating_mul(3).clamp(3, 15)
}
pub(super) fn update_selection_drag(
&mut self,
terminal_runtimes: &TerminalRuntimeRegistry,
screen_col: u16,
screen_row: u16,
) {
let Some(pane_id) = self.selection.as_ref().map(|selection| selection.pane_id) else {
return;
};
let Some(info) = self.pane_info_by_id(pane_id).cloned() else {
return;
};
let top = info.inner_rect.y;
let bottom = info.inner_rect.y + info.inner_rect.height.saturating_sub(1);
// Only activate autoscroll when the user is actively dragging.
// An anchored click in the hot zone should not start the timer.
// Check before advancing the cursor: if already Dragging from a prior
// event, it stays true. If Anchored, the mouse must have moved away
// from the anchor cell for this to count as a real drag.
let was_dragging = self.selection.as_ref().is_some_and(|s| s.is_dragging());
let anchor_differs_from_mouse = self.selection.as_ref().is_some_and(|s| {
// Convert anchor to screen coords for comparison.
// Anchor is stored in absolute row; for a simple screen
// comparison, check whether the mouse is on a different
// cell than the anchor's screen position.
let (ar, ac) = s.anchor_screen_pos(
info.inner_rect,
self.pane_scroll_metrics(terminal_runtimes, s.pane_id),
);
ar != screen_row || ac != screen_col
});
let is_dragging = was_dragging || anchor_differs_from_mouse;
// Advance the selection cursor.
self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row);
// If the mouse is on a different cell than the anchor but drag()
// didn't transition (cursor clamped to edge == anchor), force
// Dragging so the selection becomes visible and autoscroll can run.
if is_dragging {
if let Some(sel) = self.selection.as_mut() {
if sel.is_just_click() {
sel.force_dragging();
}
}
}
if screen_row < top {
// Cursor above pane — immediate scroll + set autoscroll state
if is_dragging {
self.scroll_pane_up(
terminal_runtimes,
pane_id,
Self::selection_edge_scroll_lines(top - screen_row),
);
// Re-advance cursor after scroll so it reflects the new viewport position
self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row);
self.selection_autoscroll = Some(SelectionAutoscroll {
direction: SelectionAutoscrollDirection::Up,
last_mouse_screen_col: screen_col,
last_mouse_screen_row: screen_row,
inner_rect: info.inner_rect,
});
}
} else if screen_row > bottom {
// Cursor below pane — immediate scroll + set autoscroll state
if is_dragging {
self.scroll_pane_down(
terminal_runtimes,
pane_id,
Self::selection_edge_scroll_lines(screen_row - bottom),
);
// Re-advance cursor after scroll so it reflects the new viewport position
self.update_selection_cursor(terminal_runtimes, pane_id, screen_col, screen_row);
self.selection_autoscroll = Some(SelectionAutoscroll {
direction: SelectionAutoscrollDirection::Down,
last_mouse_screen_col: screen_col,
last_mouse_screen_row: screen_row,
inner_rect: info.inner_rect,
});
}
} else if screen_row == top {
// Hot zone: top edge row — no immediate scroll, set autoscroll state
if is_dragging {
self.selection_autoscroll = Some(SelectionAutoscroll {
direction: SelectionAutoscrollDirection::Up,
last_mouse_screen_col: screen_col,
last_mouse_screen_row: screen_row,
inner_rect: info.inner_rect,
});
} else {
self.selection_autoscroll = None;
}
} else if screen_row == bottom {
// Hot zone: bottom edge row — no immediate scroll, set autoscroll state
if is_dragging {
self.selection_autoscroll = Some(SelectionAutoscroll {
direction: SelectionAutoscrollDirection::Down,
last_mouse_screen_col: screen_col,
last_mouse_screen_row: screen_row,
inner_rect: info.inner_rect,
});
} else {
self.selection_autoscroll = None;
}
} else {
// Safe zone: inside pane, not on edge rows — clear autoscroll
self.selection_autoscroll = None;
}
}
pub(super) fn scroll_selection_with_wheel(
&mut self,
terminal_runtimes: &TerminalRuntimeRegistry,
mouse: MouseEvent,
) -> bool {
let lines_per_notch = self.mouse_scroll_lines;
let Some(selection) = self.selection.as_ref() else {
return false;
};
if !selection.is_in_progress() {
return false;
}
let pane_id = selection.pane_id;
self.focus_pane(pane_id);
match mouse.kind {
MouseEventKind::ScrollUp => {
self.scroll_pane_up(terminal_runtimes, pane_id, lines_per_notch)
}
MouseEventKind::ScrollDown => {
self.scroll_pane_down(terminal_runtimes, pane_id, lines_per_notch)
}
_ => return false,
}
self.update_selection_cursor(terminal_runtimes, pane_id, mouse.column, mouse.row);
true
}
}
#[cfg(test)]
mod autoscroll_tests {
use super::*;
use crate::layout::PaneInfo;
use crate::terminal::TerminalRuntimeRegistry;
use crate::workspace::Workspace;
use ratatui::layout::Rect;
/// Build an AppState with one workspace/pane and pane_infos populated
/// so pane_info_by_id works. Returns (state, pane_id).
fn make_state_with_pane() -> (AppState, crate::layout::PaneId) {
let mut state = AppState::test_new();
let ws = Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
state.workspaces.push(ws);
state.active = Some(0);
state.view.pane_infos.push(PaneInfo {
id: pane_id,
rect: Rect::new(0, 0, 80, 24),
inner_rect: Rect::new(0, 0, 80, 24),
scrollbar_rect: None,
borders: ratatui::widgets::Borders::NONE,
is_focused: true,
});
(state, pane_id)
}
#[test]
fn above_pane_sets_autoscroll_up() {
// Build state with pane starting at row 5 so we can drag above it
let mut state = AppState::test_new();
let ws = Workspace::test_new("test");
let pane_id = ws.tabs[0].root_pane;
state.workspaces.push(ws);
state.active = Some(0);
state.view.pane_infos.push(PaneInfo {
id: pane_id,
rect: Rect::new(0, 5, 80, 24),
inner_rect: Rect::new(0, 5, 80, 24),
scrollbar_rect: None,
borders: ratatui::widgets::Borders::NONE,
is_focused: true,
});
// Anchor at (5, 10), drag to different cell above pane
let mut sel = crate::selection::Selection::anchor(pane_id, 5, 10, None);
sel.drag(4, 5, Rect::new(0, 5, 80, 24), None);
state.selection = Some(sel);
let terminal_runtimes = TerminalRuntimeRegistry::new();
state.update_selection_drag(&terminal_runtimes, 5, 4);
let autoscroll = state.selection_autoscroll.as_ref().unwrap();
assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Up);
}
#[test]
fn top_hot_zone_sets_autoscroll_up_on_drag() {
let (mut state, pane_id) = make_state_with_pane();
// Anchor at (5, 10), drag to top edge row (row 0) — different cell
let mut sel = crate::selection::Selection::anchor(pane_id, 5, 10, None);
sel.drag(0, 0, Rect::new(0, 0, 80, 24), None);
state.selection = Some(sel);
let terminal_runtimes = TerminalRuntimeRegistry::new();
state.update_selection_drag(&terminal_runtimes, 0, 0);
let autoscroll = state.selection_autoscroll.as_ref().unwrap();
assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Up);
}
#[test]
fn top_hot_zone_clears_autoscroll_on_click() {
// An anchored click on the top edge row should NOT start autoscroll.
let (mut state, pane_id) = make_state_with_pane();
state.selection = Some(crate::selection::Selection::anchor(pane_id, 0, 0, None));
// Same-cell drag on top edge row — still anchored
let terminal_runtimes = TerminalRuntimeRegistry::new();
state.update_selection_drag(&terminal_runtimes, 0, 0);
assert!(state.selection_autoscroll.is_none());
}
#[test]
fn bottom_hot_zone_sets_autoscroll_down_on_drag() {
let (mut state, pane_id) = make_state_with_pane();
// Anchor at (0, 0), drag to bottom edge row (row 23) — different cell
let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None);
sel.drag(23, 0, Rect::new(0, 0, 80, 24), None);
state.selection = Some(sel);
let terminal_runtimes = TerminalRuntimeRegistry::new();
state.update_selection_drag(&terminal_runtimes, 0, 23);
let autoscroll = state.selection_autoscroll.as_ref().unwrap();
assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Down);
}
#[test]
fn bottom_hot_zone_clears_autoscroll_on_click() {
// An anchored click on the bottom edge row should NOT start autoscroll.
let (mut state, pane_id) = make_state_with_pane();
// Anchor at bottom edge row
state.selection = Some(crate::selection::Selection::anchor(pane_id, 23, 0, None));
// Same-cell drag — still anchored
let terminal_runtimes = TerminalRuntimeRegistry::new();
state.update_selection_drag(&terminal_runtimes, 0, 23);
assert!(state.selection_autoscroll.is_none());
}
#[test]
fn below_pane_sets_autoscroll_down_on_drag() {
let (mut state, pane_id) = make_state_with_pane();
// Anchor at (0, 0), drag to different cell below pane
let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None);
sel.drag(5, 5, Rect::new(0, 0, 80, 24), None);
state.selection = Some(sel);
// Drag cursor one row below the pane bottom
let terminal_runtimes = TerminalRuntimeRegistry::new();
state.update_selection_drag(&terminal_runtimes, 0, 24);
let autoscroll = state.selection_autoscroll.as_ref().unwrap();
assert_eq!(autoscroll.direction, SelectionAutoscrollDirection::Down);
}
#[test]
fn safe_zone_clears_autoscroll() {
let (mut state, pane_id) = make_state_with_pane();
// Anchor at (0, 0), drag to (5, 5) so it's truly dragging
let mut sel = crate::selection::Selection::anchor(pane_id, 0, 0, None);
sel.drag(5, 5, Rect::new(0, 0, 80, 24), None);
state.selection = Some(sel);
// Set autoscroll first
state.selection_autoscroll = Some(SelectionAutoscroll {
direction: SelectionAutoscrollDirection::Down,
last_mouse_screen_col: 5,
last_mouse_screen_row: 23,
inner_rect: Rect::new(0, 0, 80, 24),
});
// Move cursor into safe zone (middle of pane, not on edge rows)
let terminal_runtimes = TerminalRuntimeRegistry::new();
state.update_selection_drag(&terminal_runtimes, 5, 12);
assert!(state.selection_autoscroll.is_none());
}
}
|