diff --git a/crates/turborepo-ui/src/tui/app.rs b/crates/turborepo-ui/src/tui/app.rs index e17d1288d5806..0e7efa3c81dcd 100644 --- a/crates/turborepo-ui/src/tui/app.rs +++ b/crates/turborepo-ui/src/tui/app.rs @@ -32,6 +32,7 @@ use super::{ }; use crate::{ tui::{ + scroll::ScrollMomentum, task::{Task, TasksByStatus}, term_output::TerminalOutput, }, @@ -60,6 +61,7 @@ pub struct App { done: bool, preferences: PreferenceLoader, scrollback_len: u64, + scroll_momentum: ScrollMomentum, } impl App { @@ -115,6 +117,7 @@ impl App { is_task_selection_pinned: preferences.active_task().is_some(), preferences, scrollback_len, + scroll_momentum: ScrollMomentum::new(), } } @@ -204,8 +207,21 @@ impl App { } #[tracing::instrument(skip_all)] - pub fn scroll_terminal_output(&mut self, direction: Direction) -> Result<(), Error> { - self.get_full_task_mut()?.scroll(direction)?; + pub fn scroll_terminal_output( + &mut self, + direction: Direction, + use_momentum: bool, + ) -> Result<(), Error> { + let lines = if use_momentum { + self.scroll_momentum.on_scroll_event(direction) + } else { + self.scroll_momentum.reset(); + 1 + }; + + if lines > 0 { + self.get_full_task_mut()?.scroll_by(direction, lines)?; + } Ok(()) } @@ -851,26 +867,36 @@ fn update( } Event::ScrollUp => { app.is_task_selection_pinned = true; - app.scroll_terminal_output(Direction::Up)?; + app.scroll_momentum.reset(); + app.scroll_terminal_output(Direction::Up, false)? } Event::ScrollDown => { app.is_task_selection_pinned = true; - app.scroll_terminal_output(Direction::Down)?; + app.scroll_momentum.reset(); + app.scroll_terminal_output(Direction::Down, false)?; + } + Event::ScrollWithMomentum(direction) => { + app.is_task_selection_pinned = true; + app.scroll_terminal_output(direction, true)?; } Event::PageUp => { app.is_task_selection_pinned = true; + app.scroll_momentum.reset(); app.scroll_terminal_output_by_page(Direction::Up)?; } Event::PageDown => { app.is_task_selection_pinned = true; + app.scroll_momentum.reset(); app.scroll_terminal_output_by_page(Direction::Down)?; } Event::JumpToLogsTop => { app.is_task_selection_pinned = true; + app.scroll_momentum.reset(); app.jump_to_logs_top()?; } Event::JumpToLogsBottom => { app.is_task_selection_pinned = true; + app.scroll_momentum.reset(); app.jump_to_logs_bottom()?; } Event::EnterInteractive => { diff --git a/crates/turborepo-ui/src/tui/event.rs b/crates/turborepo-ui/src/tui/event.rs index c90065ed50d90..fd7425bb43db9 100644 --- a/crates/turborepo-ui/src/tui/event.rs +++ b/crates/turborepo-ui/src/tui/event.rs @@ -29,6 +29,7 @@ pub enum Event { Down, ScrollUp, ScrollDown, + ScrollWithMomentum(Direction), PageUp, PageDown, JumpToLogsTop, @@ -68,7 +69,7 @@ pub enum Event { SearchBackspace, } -#[derive(Copy, Clone)] +#[derive(Copy, Clone, PartialEq, Eq)] pub enum Direction { Up, Down, diff --git a/crates/turborepo-ui/src/tui/input.rs b/crates/turborepo-ui/src/tui/input.rs index d3a4c3e6d174a..9d9518edb6acb 100644 --- a/crates/turborepo-ui/src/tui/input.rs +++ b/crates/turborepo-ui/src/tui/input.rs @@ -37,8 +37,12 @@ impl InputOptions<'_> { match event { crossterm::event::Event::Key(k) => translate_key_event(self, k), crossterm::event::Event::Mouse(m) => match m.kind { - crossterm::event::MouseEventKind::ScrollDown => Some(Event::ScrollDown), - crossterm::event::MouseEventKind::ScrollUp => Some(Event::ScrollUp), + crossterm::event::MouseEventKind::ScrollDown => { + Some(Event::ScrollWithMomentum(Direction::Down)) + } + crossterm::event::MouseEventKind::ScrollUp => { + Some(Event::ScrollWithMomentum(Direction::Up)) + } crossterm::event::MouseEventKind::Down(crossterm::event::MouseButton::Left) | crossterm::event::MouseEventKind::Drag(crossterm::event::MouseButton::Left) => { Some(Event::Mouse(m)) diff --git a/crates/turborepo-ui/src/tui/mod.rs b/crates/turborepo-ui/src/tui/mod.rs index 508f18e15f039..3c2649966d393 100644 --- a/crates/turborepo-ui/src/tui/mod.rs +++ b/crates/turborepo-ui/src/tui/mod.rs @@ -7,6 +7,7 @@ mod input; mod pane; mod popup; mod preferences; +pub mod scroll; mod search; mod size; mod spinner; diff --git a/crates/turborepo-ui/src/tui/scroll.rs b/crates/turborepo-ui/src/tui/scroll.rs new file mode 100644 index 0000000000000..f403d96ce2d1b --- /dev/null +++ b/crates/turborepo-ui/src/tui/scroll.rs @@ -0,0 +1,111 @@ +use std::time::{Duration, Instant}; + +use crate::tui::event::Direction; + +/// The maximum number of lines that can be scrolled per event. +/// Increase for a higher top speed; decrease for a lower top speed. +const MAX_VELOCITY: f32 = 12.0; // max lines per event + +/// The minimum number of lines to scroll per event (when not accelerating). +/// Usually leave at 1.0 for single-line scrolls. +const MIN_VELOCITY: f32 = 1.0; + +/// How much the scroll velocity increases per qualifying event. +/// Increase for faster acceleration (reaches top speed quicker, feels +/// snappier). Decrease for slower, smoother acceleration (takes longer to reach +/// top speed). +const ACCELERATION: f32 = 0.3; + +/// How long (in ms) between scrolls before momentum resets. +/// Increase to allow longer pauses between scrolls while keeping momentum. +/// Decrease to require faster, more continuous scrolling to maintain momentum. +const DECAY_TIME: Duration = Duration::from_millis(350); + +/// How long (in ms) between scrolls before events are ignored +/// Increase to allow longer pauses between scrolls to trigger throttling +/// Decrease to require faster, more continuous scrolling to trigger throttling +const THROTTLE_TIME: Duration = Duration::from_millis(50); + +/// Only process 1 out of every N scroll events (throttling). +/// Increase to make scrolling less sensitive to high-frequency mouse wheels +/// (e.g. trackpads). Decrease to process more events (smoother, but may be +/// too fast on some input devices). +const THROTTLE_FACTOR: u8 = 3; + +/// Tracks and computes momentum-based scrolling. +pub struct ScrollMomentum { + velocity: f32, + last_event: Option, + last_direction: Option, + throttle_counter: u8, +} + +impl Default for ScrollMomentum { + fn default() -> Self { + Self::new() + } +} + +impl ScrollMomentum { + /// Create a new ScrollMomentum tracker. + pub fn new() -> Self { + Self { + velocity: 0.0, + last_event: None, + last_direction: None, + throttle_counter: 0, + } + } + + /// Call this on every scroll event (mouse wheel, key, etc). + /// Returns the number of lines to scroll for this event. + pub fn on_scroll_event(&mut self, direction: Direction) -> usize { + let now = Instant::now(); + let last_event = self.last_event.replace(now); + let last_direction = self.last_direction.replace(direction); + + let has_direction_changed = last_direction.is_some_and(|last| last != direction); + let is_scrolling_quickly = + last_event.is_some_and(|last| now.duration_since(last) < DECAY_TIME); + let is_throttling = last_event.is_some_and(|last| now.duration_since(last) < THROTTLE_TIME); + + if is_throttling { + self.throttle_counter = (self.throttle_counter + 1) % THROTTLE_FACTOR; + let should_throttle = self.throttle_counter != 0; + if should_throttle { + return 0; + } + } else { + self.throttle_counter = 0; + } + + if has_direction_changed { + self.velocity = MIN_VELOCITY; + } else if is_scrolling_quickly { + self.velocity = (self.velocity + ACCELERATION).min(MAX_VELOCITY); + } else { + self.velocity = MIN_VELOCITY; + } + + self.velocity.round().clamp(MIN_VELOCITY, MAX_VELOCITY) as usize + } + + /// Reset the momentum (e.g. on focus loss or scroll stop) + pub fn reset(&mut self) { + self.velocity = 0.0; + self.last_event = None; + self.last_direction = None; + self.throttle_counter = 0; + } +} + +#[cfg(test)] +mod test { + use super::*; + + #[test] + fn first_event_scrolls() { + let mut scroll = ScrollMomentum::new(); + assert_eq!(scroll.on_scroll_event(Direction::Up), 1); + } +}