terminal: sustain drag autoscroll

This commit is contained in:
Stephan Buys 2025-11-07 06:47:42 +02:00 committed by Jacob Kauffmann
parent c829fb24d0
commit 4922c7f07b

View file

@ -17,6 +17,7 @@ use cosmic::{
event::{Event, Status},
keyboard::{Event as KeyEvent, Key, Modifiers},
mouse::{self, Button, Event as MouseEvent, ScrollDelta},
window::RedrawRequest,
},
iced_core::{
Border, Shell,
@ -49,6 +50,62 @@ use crate::{
mouse_reporter::MouseReporter, terminal::Metadata,
};
const AUTOSCROLL_INTERVAL: Duration = Duration::from_millis(100);
/// Drives repeated drag updates while the pointer is outside the widget.
struct DragAutoscroll {
active: bool,
pointer: Option<Point>,
last_tick: Instant,
interval: Duration,
}
impl DragAutoscroll {
fn new(interval: Duration) -> Self {
Self {
active: false,
pointer: None,
last_tick: Instant::now(),
interval,
}
}
fn start(&mut self, pointer: Point) {
self.pointer = Some(pointer);
if !self.active {
self.active = true;
self.last_tick = Instant::now();
}
}
fn update_pointer(&mut self, pointer: Point) {
if self.active {
self.pointer = Some(pointer);
}
}
fn stop(&mut self) {
self.active = false;
self.pointer = None;
}
fn is_active(&self) -> bool {
self.active
}
/// Returns the stored pointer when the next tick is due.
fn next_due(&mut self) -> Option<(Point, f32)> {
if self.active && self.last_tick.elapsed() >= self.interval {
let elapsed = self.last_tick.elapsed();
self.last_tick = Instant::now();
let ticks = (elapsed.as_secs_f32() / self.interval.as_secs_f32()).max(1.0);
self.pointer.map(|pointer| (pointer, ticks))
} else {
None
}
}
}
pub struct TerminalBox<'a, Message> {
terminal: &'a Mutex<Terminal>,
id: Option<Id>,
@ -763,8 +820,31 @@ where
shell.publish(on_window_focused());
}
}
cosmic::iced::window::Event::RedrawRequested(_) => {
if is_mouse_mode {
state.autoscroll.stop();
} else {
if let Some((pointer, multiplier)) = state.autoscroll.next_due() {
if update_buffer_drag(
state,
&mut terminal,
buffer_size,
pointer,
layout.bounds(),
self.padding,
multiplier,
) {
status = Status::Captured;
}
}
if state.autoscroll.is_active() {
shell.request_redraw(RedrawRequest::NextFrame);
}
}
}
cosmic::iced::window::Event::Unfocused => {
state.is_focused = false;
state.autoscroll.stop();
if let Some(on_window_unfocused) = &self.on_window_unfocused {
shell.publish(on_window_unfocused());
}
@ -1027,6 +1107,7 @@ where
let row = y / terminal.size().cell_height;
if is_mouse_mode {
state.autoscroll.stop();
terminal.report_mouse(event, &state.modifiers, col as u32, row as u32);
} else {
state.is_focused = true;
@ -1083,6 +1164,8 @@ where
state.click = Some((click_kind, Instant::now()));
state.dragging = Some(Dragging::Buffer {
edge_scroll_remainder: 0.0,
last_edge_direction: EdgeScrollDirection::None,
last_edge_overshoot: 0.0,
last_point: location,
last_side: side,
});
@ -1150,6 +1233,7 @@ where
}
}
Event::Mouse(MouseEvent::ButtonReleased(Button::Left)) => {
state.autoscroll.stop();
if let Some(dragging) = state.dragging.take() {
if let Dragging::Buffer {
last_point,
@ -1194,6 +1278,7 @@ where
}
}
Event::Mouse(MouseEvent::ButtonReleased(_button)) => {
state.autoscroll.stop();
if let Some(p) = cursor_position.position_in(layout.bounds()) {
let x = p.x - self.padding.left;
let y = p.y - self.padding.top;
@ -1220,8 +1305,9 @@ where
}
}
if let Some(p) = cursor_position.position() {
let x = (p.x - layout.bounds().x) - self.padding.left;
let y = (p.y - layout.bounds().y) - self.padding.top;
let bounds = layout.bounds();
let x = (p.x - bounds.x) - self.padding.left;
let y = (p.y - bounds.y) - self.padding.top;
//TODO: better calculation of position
let col = x / terminal.size().cell_width;
let row = y / terminal.size().cell_height;
@ -1236,65 +1322,45 @@ where
if is_mouse_mode {
terminal.report_mouse(event, &state.modifiers, col as u32, row as u32);
} else {
if let Some(dragging) = state.dragging.as_mut() {
match dragging {
Dragging::Buffer {
edge_scroll_remainder,
last_point,
last_side,
} => {
let size = terminal.size();
let buffer_height = buffer_size.1.unwrap_or(size.height as f32);
let max_row_index =
(size.screen_lines().saturating_sub(1)) as f32;
let max_col_index = (size.columns().saturating_sub(1)) as f32;
let (scroll_delta, adjusted_row, new_remainder) =
edge_scroll_adjustment(
y,
buffer_height,
size.cell_height,
max_row_index,
*edge_scroll_remainder,
);
*edge_scroll_remainder = new_remainder;
let col = (x / size.cell_width).clamp(0.0, max_col_index);
let row = adjusted_row.clamp(0.0, max_row_index);
if scroll_delta != 0 {
terminal.scroll(TerminalScroll::Delta(scroll_delta));
}
let location = terminal.viewport_to_point(TermPoint::new(
row as usize,
TermColumn(col as usize),
));
let side = if col.fract() < 0.5 {
TermSide::Left
} else {
TermSide::Right
};
{
let mut term = terminal.term.lock();
if let Some(selection) = &mut term.selection {
selection.update(location, side);
}
}
*last_point = location;
*last_side = side;
terminal.needs_update = true;
}
Dragging::Scrollbar {
start_y,
start_scroll,
} => {
let start_y = *start_y;
let start_scroll = *start_scroll;
let scroll_offset = terminal.with_buffer(|buffer| {
(y - start_y) / buffer.size().1.unwrap_or(1.0)
});
terminal.scroll_to(start_scroll.0 + scroll_offset);
}
}
let handled_buffer_drag = update_buffer_drag(
state,
&mut terminal,
buffer_size,
p,
bounds,
self.padding,
0.0,
);
if handled_buffer_drag {
status = Status::Captured;
} else if let Some(Dragging::Scrollbar {
start_y,
start_scroll,
}) = state.dragging.as_mut()
{
let start_y = *start_y;
let start_scroll = *start_scroll;
let scroll_offset = terminal.with_buffer(|buffer| {
(y - start_y) / buffer.size().1.unwrap_or(1.0)
});
terminal.scroll_to(start_scroll.0 + scroll_offset);
status = Status::Captured;
}
if matches!(state.dragging, Some(Dragging::Buffer { .. })) {
if cursor_position.position_in(bounds).is_some() {
state.autoscroll.stop();
} else {
if state.autoscroll.is_active() {
state.autoscroll.update_pointer(p);
} else {
state.autoscroll.start(p);
}
shell.request_redraw(RedrawRequest::NextFrame);
}
} else {
state.autoscroll.stop();
}
status = Status::Captured;
}
}
}
@ -1469,6 +1535,8 @@ enum ClickKind {
enum Dragging {
Buffer {
edge_scroll_remainder: f32,
last_edge_direction: EdgeScrollDirection,
last_edge_overshoot: f32,
last_point: TermPoint,
last_side: TermSide,
},
@ -1478,32 +1546,77 @@ enum Dragging {
},
}
#[derive(Clone, Copy, PartialEq, Eq, Debug)]
enum EdgeScrollDirection {
None,
Top,
Bottom,
}
fn edge_scroll_adjustment(
y: f32,
buffer_height: f32,
cell_height: f32,
max_row_index: f32,
scroll_remainder: f32,
) -> (i32, f32, f32) {
last_direction: EdgeScrollDirection,
last_overshoot: f32,
forced_increment: f32,
) -> (i32, f32, f32, EdgeScrollDirection, f32) {
if cell_height <= 0.0 {
return (0, 0.0, 0.0);
return (0, 0.0, 0.0, EdgeScrollDirection::None, 0.0);
}
let mut row = y / cell_height;
let mut delta = 0;
let mut remainder = scroll_remainder;
let mut direction = EdgeScrollDirection::None;
let mut overshoot = 0.0;
if y < 0.0 {
remainder = remainder.max(0.0);
remainder += (-y) / cell_height;
direction = EdgeScrollDirection::Top;
overshoot = (-y) / cell_height;
let previous_overshoot = if last_direction == direction {
last_overshoot
} else {
0.0
};
let overshoot_delta = overshoot - previous_overshoot;
let forced = if forced_increment > 0.0 {
forced_increment * overshoot.max(1.0)
} else {
0.0
};
if overshoot_delta > 0.0 || forced > 0.0 {
remainder = remainder.max(0.0) + overshoot_delta.max(0.0) + forced;
} else {
remainder = remainder.max(0.0).min(overshoot.fract());
}
delta = remainder.trunc() as i32;
remainder -= delta as f32;
row = 0.0;
} else if y > buffer_height {
remainder = remainder.min(0.0);
remainder -= (y - buffer_height) / cell_height;
delta = remainder.trunc() as i32;
remainder -= delta as f32;
direction = EdgeScrollDirection::Bottom;
overshoot = (y - buffer_height) / cell_height;
let previous_overshoot = if last_direction == direction {
last_overshoot
} else {
0.0
};
let overshoot_delta = overshoot - previous_overshoot;
let forced = if forced_increment > 0.0 {
forced_increment * overshoot.max(1.0)
} else {
0.0
};
if overshoot_delta > 0.0 || forced > 0.0 {
remainder = remainder.max(0.0) + overshoot_delta.max(0.0) + forced;
} else {
remainder = remainder.max(0.0).min(overshoot.fract());
}
let lines = remainder.trunc() as i32;
delta = -lines;
remainder -= lines as f32;
row = max_row_index;
} else {
remainder = 0.0;
@ -1511,7 +1624,77 @@ fn edge_scroll_adjustment(
row = row.clamp(0.0, max_row_index);
(delta, row, remainder)
(delta, row, remainder, direction, overshoot)
}
fn update_buffer_drag(
state: &mut State,
terminal: &mut Terminal,
buffer_size: (Option<f32>, Option<f32>),
pointer: Point,
bounds: Rectangle<f32>,
padding: Padding,
forced_increment: f32,
) -> bool {
let Some(dragging) = state.dragging.as_mut() else {
return false;
};
let Dragging::Buffer {
edge_scroll_remainder,
last_edge_direction,
last_edge_overshoot,
last_point,
last_side,
} = dragging
else {
return false;
};
let x = (pointer.x - bounds.x) - padding.left;
let y = (pointer.y - bounds.y) - padding.top;
let size = terminal.size();
let buffer_height = buffer_size.1.unwrap_or(size.height as f32);
let max_row_index = (size.screen_lines().saturating_sub(1)) as f32;
let max_col_index = (size.columns().saturating_sub(1)) as f32;
let (scroll_delta, adjusted_row, new_remainder, new_direction, new_overshoot) =
edge_scroll_adjustment(
y,
buffer_height,
size.cell_height,
max_row_index,
*edge_scroll_remainder,
*last_edge_direction,
*last_edge_overshoot,
forced_increment,
);
*edge_scroll_remainder = new_remainder;
*last_edge_direction = new_direction;
*last_edge_overshoot = new_overshoot;
let col = (x / size.cell_width).clamp(0.0, max_col_index);
let row = adjusted_row.clamp(0.0, max_row_index);
if scroll_delta != 0 {
terminal.scroll(TerminalScroll::Delta(scroll_delta));
}
let location =
terminal.viewport_to_point(TermPoint::new(row as usize, TermColumn(col as usize)));
let side = if col.fract() < 0.5 {
TermSide::Left
} else {
TermSide::Right
};
{
let mut term = terminal.term.lock();
if let Some(selection) = &mut term.selection {
selection.update(location, side);
}
}
*last_point = location;
*last_side = side;
terminal.needs_update = true;
true
}
pub struct State {
@ -1521,6 +1704,7 @@ pub struct State {
is_focused: bool,
scroll_pixels: f32,
scrollbar_rect: Cell<Rectangle<f32>>,
autoscroll: DragAutoscroll,
}
impl State {
@ -1533,6 +1717,7 @@ impl State {
is_focused: false,
scroll_pixels: 0.0,
scrollbar_rect: Cell::new(Rectangle::default()),
autoscroll: DragAutoscroll::new(AUTOSCROLL_INTERVAL),
}
}
}
@ -1609,7 +1794,7 @@ fn ss3(code: &str, modifiers: u8) -> Option<Vec<u8>> {
#[cfg(test)]
mod tests {
use super::edge_scroll_adjustment;
use super::{EdgeScrollDirection, edge_scroll_adjustment};
const BUFFER_HEIGHT: f32 = 200.0;
const CELL_HEIGHT: f32 = 20.0;
@ -1617,57 +1802,190 @@ mod tests {
#[test]
fn edge_scroll_small_top_overshoot_does_not_scroll_immediately() {
let (delta, row, remainder) =
edge_scroll_adjustment(-5.0, BUFFER_HEIGHT, CELL_HEIGHT, MAX_ROW, 0.0);
let (delta, row, remainder, direction, overshoot) = edge_scroll_adjustment(
-5.0,
BUFFER_HEIGHT,
CELL_HEIGHT,
MAX_ROW,
0.0,
EdgeScrollDirection::None,
0.0,
0.0,
);
assert_eq!(delta, 0);
assert_eq!(row, 0.0);
assert!((remainder - 0.25).abs() < f32::EPSILON);
let (delta_repeat, row_repeat, remainder_repeat, _, _) = edge_scroll_adjustment(
-5.0,
BUFFER_HEIGHT,
CELL_HEIGHT,
MAX_ROW,
remainder,
direction,
overshoot,
0.0,
);
assert_eq!(delta_repeat, 0);
assert_eq!(row_repeat, 0.0);
assert!((remainder_repeat - 0.25).abs() < f32::EPSILON);
}
#[test]
fn edge_scroll_top_accumulates_into_scroll() {
let (_, _, remainder) =
edge_scroll_adjustment(-5.0, BUFFER_HEIGHT, CELL_HEIGHT, MAX_ROW, 0.0);
let (delta, row, remainder) =
edge_scroll_adjustment(-45.0, BUFFER_HEIGHT, CELL_HEIGHT, MAX_ROW, remainder);
let (_delta, _row, mut remainder, mut direction, mut overshoot) = edge_scroll_adjustment(
-5.0,
BUFFER_HEIGHT,
CELL_HEIGHT,
MAX_ROW,
0.0,
EdgeScrollDirection::None,
0.0,
0.0,
);
let (delta, row, new_remainder, new_direction, new_overshoot) = edge_scroll_adjustment(
-45.0,
BUFFER_HEIGHT,
CELL_HEIGHT,
MAX_ROW,
remainder,
direction,
overshoot,
0.0,
);
assert_eq!(delta, 2);
assert_eq!(row, 0.0);
assert!((remainder - 0.5).abs() < f32::EPSILON);
assert!((new_remainder - 0.25).abs() < f32::EPSILON);
remainder = new_remainder;
direction = new_direction;
overshoot = new_overshoot;
// repeated event with the same overshoot should not accumulate more scroll
let (delta, _, remainder, _, _) = edge_scroll_adjustment(
-45.0,
BUFFER_HEIGHT,
CELL_HEIGHT,
MAX_ROW,
remainder,
direction,
overshoot,
0.0,
);
assert_eq!(delta, 0);
assert!((remainder - 0.25).abs() < f32::EPSILON);
}
#[test]
fn edge_scroll_inside_viewport_resets_remainder() {
let (_, _, remainder) =
edge_scroll_adjustment(-25.0, BUFFER_HEIGHT, CELL_HEIGHT, MAX_ROW, 0.0);
let (delta, row, remainder) =
edge_scroll_adjustment(60.0, BUFFER_HEIGHT, CELL_HEIGHT, MAX_ROW, remainder);
let (_delta, _row, remainder, direction, overshoot) = edge_scroll_adjustment(
-25.0,
BUFFER_HEIGHT,
CELL_HEIGHT,
MAX_ROW,
0.0,
EdgeScrollDirection::None,
0.0,
0.0,
);
let (delta, row, remainder, direction, overshoot) = edge_scroll_adjustment(
60.0,
BUFFER_HEIGHT,
CELL_HEIGHT,
MAX_ROW,
remainder,
direction,
overshoot,
0.0,
);
assert_eq!(delta, 0);
assert!((row - 3.0).abs() < f32::EPSILON);
assert_eq!(remainder, 0.0);
assert_eq!(direction, EdgeScrollDirection::None);
assert_eq!(overshoot, 0.0);
}
#[test]
fn edge_scroll_bottom_accumulates_scroll() {
let (delta, row, remainder) = edge_scroll_adjustment(
let (delta, row, remainder, direction, overshoot) = edge_scroll_adjustment(
BUFFER_HEIGHT + 1.0,
BUFFER_HEIGHT,
CELL_HEIGHT,
MAX_ROW,
0.0,
EdgeScrollDirection::None,
0.0,
0.0,
);
assert_eq!(delta, 0);
assert!((row - MAX_ROW).abs() < f32::EPSILON);
assert!((remainder + 0.05).abs() < f32::EPSILON);
assert!((remainder - 0.05).abs() < f32::EPSILON);
let (delta, row, remainder) = edge_scroll_adjustment(
let (delta, row, remainder, direction, overshoot) = edge_scroll_adjustment(
BUFFER_HEIGHT + 45.0,
BUFFER_HEIGHT,
CELL_HEIGHT,
MAX_ROW,
remainder,
direction,
overshoot,
0.0,
);
assert_eq!(delta, -2);
assert!((row - MAX_ROW).abs() < f32::EPSILON);
assert!((remainder + 0.3).abs() < f32::EPSILON);
assert!((remainder - 0.25).abs() < f32::EPSILON);
let (delta, _, remainder, _, _) = edge_scroll_adjustment(
BUFFER_HEIGHT + 45.0,
BUFFER_HEIGHT,
CELL_HEIGHT,
MAX_ROW,
remainder,
direction,
overshoot,
0.0,
);
assert_eq!(delta, 0);
assert!((remainder - 0.25).abs() < f32::EPSILON);
}
#[test]
fn edge_scroll_forced_increment_continues_scrolling() {
let (delta, _, remainder, direction, overshoot) = edge_scroll_adjustment(
-5.0,
BUFFER_HEIGHT,
CELL_HEIGHT,
MAX_ROW,
0.0,
EdgeScrollDirection::None,
0.0,
0.0,
);
assert_eq!(delta, 0);
let (delta, _, remainder, direction, overshoot) = edge_scroll_adjustment(
-5.0,
BUFFER_HEIGHT,
CELL_HEIGHT,
MAX_ROW,
remainder,
direction,
overshoot,
1.0,
);
assert_eq!(delta, 1);
assert!((remainder - 0.25).abs() < f32::EPSILON);
let (delta, _, remainder, _, _) = edge_scroll_adjustment(
-5.0,
BUFFER_HEIGHT,
CELL_HEIGHT,
MAX_ROW,
remainder,
direction,
overshoot,
1.0,
);
assert_eq!(delta, 1);
assert!((remainder - 0.25).abs() < f32::EPSILON);
}
}