Skip to content

Commit d67a6c3

Browse files
feat(tui): momentum-based scrolling (#10420)
### Description #10415 Adds momentum-based scrolling to the TUI for a smoother and more natural scroll experience. ### Testing Instructions 1. Run a long-running task that prints many lines, e.g.: ```bashs perl -E '$i=1; for (1..1500) { say "[$i] " . "=" x int(rand(100) + 1); $i++ }' && read ``` 2. Use the TUI to scroll through the output and observe the new scrolling behavior. --------- Co-authored-by: Chris Olszewski <[email protected]> Co-authored-by: Chris Olszewski <[email protected]>
1 parent a227a75 commit d67a6c3

File tree

5 files changed

+150
-7
lines changed

5 files changed

+150
-7
lines changed

crates/turborepo-ui/src/tui/app.rs

+30-4
Original file line numberDiff line numberDiff line change
@@ -32,6 +32,7 @@ use super::{
3232
};
3333
use crate::{
3434
tui::{
35+
scroll::ScrollMomentum,
3536
task::{Task, TasksByStatus},
3637
term_output::TerminalOutput,
3738
},
@@ -60,6 +61,7 @@ pub struct App<W> {
6061
done: bool,
6162
preferences: PreferenceLoader,
6263
scrollback_len: u64,
64+
scroll_momentum: ScrollMomentum,
6365
}
6466

6567
impl<W> App<W> {
@@ -115,6 +117,7 @@ impl<W> App<W> {
115117
is_task_selection_pinned: preferences.active_task().is_some(),
116118
preferences,
117119
scrollback_len,
120+
scroll_momentum: ScrollMomentum::new(),
118121
}
119122
}
120123

@@ -204,8 +207,21 @@ impl<W> App<W> {
204207
}
205208

206209
#[tracing::instrument(skip_all)]
207-
pub fn scroll_terminal_output(&mut self, direction: Direction) -> Result<(), Error> {
208-
self.get_full_task_mut()?.scroll(direction)?;
210+
pub fn scroll_terminal_output(
211+
&mut self,
212+
direction: Direction,
213+
use_momentum: bool,
214+
) -> Result<(), Error> {
215+
let lines = if use_momentum {
216+
self.scroll_momentum.on_scroll_event(direction)
217+
} else {
218+
self.scroll_momentum.reset();
219+
1
220+
};
221+
222+
if lines > 0 {
223+
self.get_full_task_mut()?.scroll_by(direction, lines)?;
224+
}
209225
Ok(())
210226
}
211227

@@ -851,26 +867,36 @@ fn update(
851867
}
852868
Event::ScrollUp => {
853869
app.is_task_selection_pinned = true;
854-
app.scroll_terminal_output(Direction::Up)?;
870+
app.scroll_momentum.reset();
871+
app.scroll_terminal_output(Direction::Up, false)?
855872
}
856873
Event::ScrollDown => {
857874
app.is_task_selection_pinned = true;
858-
app.scroll_terminal_output(Direction::Down)?;
875+
app.scroll_momentum.reset();
876+
app.scroll_terminal_output(Direction::Down, false)?;
877+
}
878+
Event::ScrollWithMomentum(direction) => {
879+
app.is_task_selection_pinned = true;
880+
app.scroll_terminal_output(direction, true)?;
859881
}
860882
Event::PageUp => {
861883
app.is_task_selection_pinned = true;
884+
app.scroll_momentum.reset();
862885
app.scroll_terminal_output_by_page(Direction::Up)?;
863886
}
864887
Event::PageDown => {
865888
app.is_task_selection_pinned = true;
889+
app.scroll_momentum.reset();
866890
app.scroll_terminal_output_by_page(Direction::Down)?;
867891
}
868892
Event::JumpToLogsTop => {
869893
app.is_task_selection_pinned = true;
894+
app.scroll_momentum.reset();
870895
app.jump_to_logs_top()?;
871896
}
872897
Event::JumpToLogsBottom => {
873898
app.is_task_selection_pinned = true;
899+
app.scroll_momentum.reset();
874900
app.jump_to_logs_bottom()?;
875901
}
876902
Event::EnterInteractive => {

crates/turborepo-ui/src/tui/event.rs

+2-1
Original file line numberDiff line numberDiff line change
@@ -29,6 +29,7 @@ pub enum Event {
2929
Down,
3030
ScrollUp,
3131
ScrollDown,
32+
ScrollWithMomentum(Direction),
3233
PageUp,
3334
PageDown,
3435
JumpToLogsTop,
@@ -68,7 +69,7 @@ pub enum Event {
6869
SearchBackspace,
6970
}
7071

71-
#[derive(Copy, Clone)]
72+
#[derive(Copy, Clone, PartialEq, Eq)]
7273
pub enum Direction {
7374
Up,
7475
Down,

crates/turborepo-ui/src/tui/input.rs

+6-2
Original file line numberDiff line numberDiff line change
@@ -37,8 +37,12 @@ impl InputOptions<'_> {
3737
match event {
3838
crossterm::event::Event::Key(k) => translate_key_event(self, k),
3939
crossterm::event::Event::Mouse(m) => match m.kind {
40-
crossterm::event::MouseEventKind::ScrollDown => Some(Event::ScrollDown),
41-
crossterm::event::MouseEventKind::ScrollUp => Some(Event::ScrollUp),
40+
crossterm::event::MouseEventKind::ScrollDown => {
41+
Some(Event::ScrollWithMomentum(Direction::Down))
42+
}
43+
crossterm::event::MouseEventKind::ScrollUp => {
44+
Some(Event::ScrollWithMomentum(Direction::Up))
45+
}
4246
crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left)
4347
| crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left) => {
4448
Some(Event::Mouse(m))

crates/turborepo-ui/src/tui/mod.rs

+1
Original file line numberDiff line numberDiff line change
@@ -7,6 +7,7 @@ mod input;
77
mod pane;
88
mod popup;
99
mod preferences;
10+
pub mod scroll;
1011
mod search;
1112
mod size;
1213
mod spinner;

crates/turborepo-ui/src/tui/scroll.rs

+111
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,111 @@
1+
use std::time::{Duration, Instant};
2+
3+
use crate::tui::event::Direction;
4+
5+
/// The maximum number of lines that can be scrolled per event.
6+
/// Increase for a higher top speed; decrease for a lower top speed.
7+
const MAX_VELOCITY: f32 = 12.0; // max lines per event
8+
9+
/// The minimum number of lines to scroll per event (when not accelerating).
10+
/// Usually leave at 1.0 for single-line scrolls.
11+
const MIN_VELOCITY: f32 = 1.0;
12+
13+
/// How much the scroll velocity increases per qualifying event.
14+
/// Increase for faster acceleration (reaches top speed quicker, feels
15+
/// snappier). Decrease for slower, smoother acceleration (takes longer to reach
16+
/// top speed).
17+
const ACCELERATION: f32 = 0.3;
18+
19+
/// How long (in ms) between scrolls before momentum resets.
20+
/// Increase to allow longer pauses between scrolls while keeping momentum.
21+
/// Decrease to require faster, more continuous scrolling to maintain momentum.
22+
const DECAY_TIME: Duration = Duration::from_millis(350);
23+
24+
/// How long (in ms) between scrolls before events are ignored
25+
/// Increase to allow longer pauses between scrolls to trigger throttling
26+
/// Decrease to require faster, more continuous scrolling to trigger throttling
27+
const THROTTLE_TIME: Duration = Duration::from_millis(50);
28+
29+
/// Only process 1 out of every N scroll events (throttling).
30+
/// Increase to make scrolling less sensitive to high-frequency mouse wheels
31+
/// (e.g. trackpads). Decrease to process more events (smoother, but may be
32+
/// too fast on some input devices).
33+
const THROTTLE_FACTOR: u8 = 3;
34+
35+
/// Tracks and computes momentum-based scrolling.
36+
pub struct ScrollMomentum {
37+
velocity: f32,
38+
last_event: Option<Instant>,
39+
last_direction: Option<Direction>,
40+
throttle_counter: u8,
41+
}
42+
43+
impl Default for ScrollMomentum {
44+
fn default() -> Self {
45+
Self::new()
46+
}
47+
}
48+
49+
impl ScrollMomentum {
50+
/// Create a new ScrollMomentum tracker.
51+
pub fn new() -> Self {
52+
Self {
53+
velocity: 0.0,
54+
last_event: None,
55+
last_direction: None,
56+
throttle_counter: 0,
57+
}
58+
}
59+
60+
/// Call this on every scroll event (mouse wheel, key, etc).
61+
/// Returns the number of lines to scroll for this event.
62+
pub fn on_scroll_event(&mut self, direction: Direction) -> usize {
63+
let now = Instant::now();
64+
let last_event = self.last_event.replace(now);
65+
let last_direction = self.last_direction.replace(direction);
66+
67+
let has_direction_changed = last_direction.is_some_and(|last| last != direction);
68+
let is_scrolling_quickly =
69+
last_event.is_some_and(|last| now.duration_since(last) < DECAY_TIME);
70+
let is_throttling = last_event.is_some_and(|last| now.duration_since(last) < THROTTLE_TIME);
71+
72+
if is_throttling {
73+
self.throttle_counter = (self.throttle_counter + 1) % THROTTLE_FACTOR;
74+
let should_throttle = self.throttle_counter != 0;
75+
if should_throttle {
76+
return 0;
77+
}
78+
} else {
79+
self.throttle_counter = 0;
80+
}
81+
82+
if has_direction_changed {
83+
self.velocity = MIN_VELOCITY;
84+
} else if is_scrolling_quickly {
85+
self.velocity = (self.velocity + ACCELERATION).min(MAX_VELOCITY);
86+
} else {
87+
self.velocity = MIN_VELOCITY;
88+
}
89+
90+
self.velocity.round().clamp(MIN_VELOCITY, MAX_VELOCITY) as usize
91+
}
92+
93+
/// Reset the momentum (e.g. on focus loss or scroll stop)
94+
pub fn reset(&mut self) {
95+
self.velocity = 0.0;
96+
self.last_event = None;
97+
self.last_direction = None;
98+
self.throttle_counter = 0;
99+
}
100+
}
101+
102+
#[cfg(test)]
103+
mod test {
104+
use super::*;
105+
106+
#[test]
107+
fn first_event_scrolls() {
108+
let mut scroll = ScrollMomentum::new();
109+
assert_eq!(scroll.on_scroll_event(Direction::Up), 1);
110+
}
111+
}

0 commit comments

Comments
 (0)