Skip to main content

par_term/
scroll_state.rs

1use std::time::Instant;
2
3/// State management for scrolling behavior
4#[derive(Debug)]
5pub struct ScrollState {
6    /// Current scroll position (0 = bottom, showing current content)
7    pub offset: usize,
8    /// Target scroll position for smooth animation
9    pub target_offset: usize,
10    /// Current animated scroll position (interpolated)
11    pub animated_offset: f64,
12    /// When scroll animation started
13    pub animation_start: Option<Instant>,
14    /// Whether currently dragging the scrollbar
15    pub dragging: bool,
16    /// Distance between cursor and thumb top when dragging
17    pub drag_offset: f32,
18    /// Last time scroll input happened (for autohide)
19    pub last_activity: Instant,
20}
21
22impl Default for ScrollState {
23    fn default() -> Self {
24        Self::new()
25    }
26}
27
28impl ScrollState {
29    /// Create a new scroll state
30    pub fn new() -> Self {
31        Self {
32            offset: 0,
33            target_offset: 0,
34            animated_offset: 0.0,
35            animation_start: None,
36            dragging: false,
37            drag_offset: 0.0,
38            last_activity: Instant::now(),
39        }
40    }
41
42    /// Set scroll target and initiate smooth interpolation animation.
43    /// Returns true if the target actually changed.
44    pub fn set_target(&mut self, new_offset: usize) -> bool {
45        if new_offset != self.target_offset {
46            self.target_offset = new_offset;
47            self.animation_start = Some(Instant::now());
48            self.last_activity = Instant::now();
49            true
50        } else {
51            false
52        }
53    }
54
55    /// Update smooth scroll animation via interpolation.
56    /// Returns true if the animation is still in progress.
57    pub fn update_animation(&mut self) -> bool {
58        if let Some(start_time) = self.animation_start {
59            const ANIMATION_DURATION: f64 = 0.15; // 150ms for snappy feel
60            let elapsed = start_time.elapsed().as_secs_f64();
61
62            if elapsed >= ANIMATION_DURATION {
63                // Animation finished: snap to exact target
64                self.animated_offset = self.target_offset as f64;
65                self.offset = self.target_offset;
66                self.animation_start = None;
67                return false;
68            }
69
70            // Easing: ease-out-cubic (1 - (1 - t)^3)
71            // Provides fast start and smooth deceleration
72            let t = elapsed / ANIMATION_DURATION;
73            let eased = 1.0 - (1.0 - t).powi(3);
74
75            let start = self.offset as f64;
76            let target = self.target_offset as f64;
77            self.animated_offset = start + (target - start) * eased;
78
79            // Update discrete offset for logic that requires integer rows
80            self.offset = self.animated_offset.round() as usize;
81
82            return true;
83        }
84
85        false
86    }
87
88    /// Clamp scroll offset to available scrollback length
89    pub fn clamp_to_scrollback(&mut self, max_scroll: usize) {
90        if self.offset > max_scroll {
91            self.offset = max_scroll;
92        }
93        // Also clamp target/animation if they exceed max
94        if self.target_offset > max_scroll {
95            self.target_offset = max_scroll;
96            self.animated_offset = max_scroll as f64;
97            self.animation_start = None;
98        }
99    }
100
101    /// Apply a scroll delta
102    /// Returns new target offset
103    pub fn apply_scroll(&mut self, lines: i32, max_scroll: usize) -> usize {
104        if lines > 0 {
105            // Scrolling up (into history)
106            let new_offset = self.target_offset.saturating_add(lines as usize);
107            new_offset.min(max_scroll)
108        } else if lines < 0 {
109            // Scrolling down (toward current)
110            self.target_offset
111                .saturating_sub(lines.unsigned_abs() as usize)
112        } else {
113            self.target_offset
114        }
115    }
116}
117
118#[cfg(test)]
119mod tests {
120    use super::*;
121
122    #[test]
123    fn test_scroll_state_defaults() {
124        let state = ScrollState::new();
125        assert_eq!(state.offset, 0);
126        assert_eq!(state.target_offset, 0);
127        assert!(!state.dragging);
128    }
129
130    #[test]
131    fn test_set_target() {
132        let mut state = ScrollState::new();
133        assert!(state.set_target(10));
134        assert_eq!(state.target_offset, 10);
135        assert!(state.animation_start.is_some());
136
137        // Setting same target should return false
138        assert!(!state.set_target(10));
139    }
140
141    #[test]
142    fn test_clamp_to_scrollback() {
143        let mut state = ScrollState::new();
144        state.offset = 100;
145        state.target_offset = 100;
146
147        state.clamp_to_scrollback(50);
148
149        assert_eq!(state.offset, 50);
150        assert_eq!(state.target_offset, 50);
151        assert!(state.animation_start.is_none());
152    }
153
154    #[test]
155    fn test_apply_scroll() {
156        let mut state = ScrollState::new();
157        let max_scroll = 100;
158
159        // Scroll up (positive)
160        assert_eq!(state.apply_scroll(10, max_scroll), 10);
161        state.target_offset = 10;
162
163        // Scroll down (negative)
164        assert_eq!(state.apply_scroll(-5, max_scroll), 5);
165        state.target_offset = 5;
166
167        // Cap at max
168        state.target_offset = 95;
169        assert_eq!(state.apply_scroll(10, max_scroll), 100);
170
171        // Cap at 0
172        state.target_offset = 5;
173        assert_eq!(state.apply_scroll(-10, max_scroll), 0);
174    }
175}