Skip to main content

fresh/view/ui/
scrollbar.rs

1//! Reusable scrollbar widget for lists and content areas
2//!
3//! This module provides a scrollbar that can be used with any scrollable content,
4//! not just the editor buffer. It's extracted from the split_rendering module
5//! to enable reuse in file browsers, popups, and other scrollable UI elements.
6
7use ratatui::layout::Rect;
8use ratatui::style::{Color, Style};
9use ratatui::widgets::Paragraph;
10use ratatui::Frame;
11
12/// State needed to render and interact with a scrollbar
13#[derive(Debug, Clone, Copy)]
14pub struct ScrollbarState {
15    /// Total number of items/lines
16    pub total_items: usize,
17    /// Number of items visible in the viewport
18    pub visible_items: usize,
19    /// Current scroll offset (first visible item index)
20    pub scroll_offset: usize,
21}
22
23impl ScrollbarState {
24    /// Create a new scrollbar state
25    pub fn new(total_items: usize, visible_items: usize, scroll_offset: usize) -> Self {
26        Self {
27            total_items,
28            visible_items,
29            scroll_offset,
30        }
31    }
32
33    /// Calculate thumb position and size for a given track height
34    ///
35    /// Returns (thumb_start, thumb_size) in rows
36    pub fn thumb_geometry(&self, track_height: usize) -> (usize, usize) {
37        if track_height == 0 || self.total_items == 0 {
38            return (0, 0);
39        }
40
41        // Calculate the maximum scroll position
42        let max_scroll = self.total_items.saturating_sub(self.visible_items);
43
44        // When content fits entirely in viewport, fill the entire scrollbar
45        if max_scroll == 0 {
46            return (0, track_height);
47        }
48
49        // Calculate thumb size based on viewport ratio
50        let thumb_size_raw = ((self.visible_items as f64 / self.total_items as f64)
51            * track_height as f64)
52            .ceil() as usize;
53
54        // Cap thumb size: minimum 1, maximum 80% of track height
55        let max_thumb_size = (track_height as f64 * 0.8).floor() as usize;
56        let thumb_size = thumb_size_raw.max(1).min(max_thumb_size).min(track_height);
57
58        // Calculate thumb position using linear mapping
59        let scroll_ratio = self.scroll_offset.min(max_scroll) as f64 / max_scroll as f64;
60        let max_thumb_start = track_height.saturating_sub(thumb_size);
61        let thumb_start = (scroll_ratio * max_thumb_start as f64) as usize;
62
63        (thumb_start, thumb_size)
64    }
65
66    /// Convert a click position on the track to a scroll offset
67    ///
68    /// # Arguments
69    /// * `track_height` - Height of the scrollbar track in rows
70    /// * `click_row` - Row within the track that was clicked (0-indexed)
71    ///
72    /// # Returns
73    /// The scroll offset that would position the thumb at the click location
74    pub fn click_to_offset(&self, track_height: usize, click_row: usize) -> usize {
75        if track_height == 0 || self.total_items == 0 {
76            return 0;
77        }
78
79        let max_scroll = self.total_items.saturating_sub(self.visible_items);
80        if max_scroll == 0 {
81            return 0;
82        }
83
84        // Map click position to scroll offset
85        let click_ratio = click_row as f64 / track_height as f64;
86        let offset = (click_ratio * max_scroll as f64) as usize;
87
88        offset.min(max_scroll)
89    }
90
91    /// Check if a row is within the thumb area
92    pub fn is_thumb_row(&self, track_height: usize, row: usize) -> bool {
93        let (thumb_start, thumb_size) = self.thumb_geometry(track_height);
94        row >= thumb_start && row < thumb_start + thumb_size
95    }
96
97    /// Inverse of [`thumb_geometry`]: compute the scroll offset that
98    /// places the thumb's top at (or as close as possible to)
99    /// `target_thumb_top`. Use this — not `click_to_offset` — when a
100    /// caller needs the thumb to land at a specific row on the track
101    /// (e.g. press on the track to recentre the thumb under the cursor):
102    /// `click_to_offset` divides by `track_height` rather than the actual
103    /// `max_thumb_top`, so its result drifts above the intended row by a
104    /// factor of `thumb_size / track_height`.
105    pub fn offset_for_thumb_top(&self, track_height: usize, target_thumb_top: usize) -> usize {
106        let max_scroll = self.total_items.saturating_sub(self.visible_items);
107        if track_height == 0 || max_scroll == 0 {
108            return 0;
109        }
110        let (_, thumb_size) = self.thumb_geometry(track_height);
111        let max_thumb_top = track_height.saturating_sub(thumb_size);
112        if max_thumb_top == 0 {
113            return 0;
114        }
115        let clamped = target_thumb_top.min(max_thumb_top);
116        let ratio = clamped as f64 / max_thumb_top as f64;
117        ((ratio * max_scroll as f64).round() as usize).min(max_scroll)
118    }
119
120    /// Compute the scroll offset for a drag that preserves the cursor's
121    /// position within the thumb.
122    ///
123    /// When the user presses on the thumb itself, the thumb shouldn't jump
124    /// so its top aligns with the cursor — the cursor should stay pinned to
125    /// the same spot on the thumb. Callers capture the press position
126    /// (`drag_start_row`) and the scroll offset at that moment
127    /// (`drag_start_offset`), then call this on every subsequent drag event.
128    ///
129    /// # Arguments
130    /// * `track_height` — height of the scrollbar track in rows
131    /// * `drag_start_row` — track-relative row where the drag started
132    /// * `drag_start_offset` — scroll offset at the time of the press
133    /// * `current_row` — track-relative row of the cursor now
134    pub fn drag_to_offset(
135        &self,
136        track_height: usize,
137        drag_start_row: usize,
138        drag_start_offset: usize,
139        current_row: usize,
140    ) -> usize {
141        let max_scroll = self.total_items.saturating_sub(self.visible_items);
142        if track_height == 0 || max_scroll == 0 {
143            return drag_start_offset.min(max_scroll);
144        }
145
146        // Compute by cursor delta so the round-trip through thumb_geometry
147        // can't drift the offset on a zero-movement drag — pressing on the
148        // thumb without moving must leave the viewport untouched.
149        let delta_rows = current_row as i64 - drag_start_row as i64;
150        if delta_rows == 0 {
151            return drag_start_offset.min(max_scroll);
152        }
153
154        // Thumb geometry the thumb had at drag start. `max_thumb_top` is
155        // the denominator that maps thumb rows to scroll offsets.
156        let start = Self::new(self.total_items, self.visible_items, drag_start_offset);
157        let (_, thumb_size) = start.thumb_geometry(track_height);
158        let max_thumb_top = track_height.saturating_sub(thumb_size);
159        if max_thumb_top == 0 {
160            return drag_start_offset.min(max_scroll);
161        }
162
163        // delta_rows on the track ↦ delta_rows × (max_scroll / max_thumb_top).
164        let offset_delta = delta_rows as f64 * (max_scroll as f64 / max_thumb_top as f64);
165        let new_offset = (drag_start_offset as f64 + offset_delta).round();
166        new_offset.clamp(0.0, max_scroll as f64) as usize
167    }
168}
169
170/// In-flight scrollbar drag state captured on press. `start_row` is in
171/// track coordinates (0 = top of the track).
172#[derive(Debug, Clone, Copy)]
173pub struct ScrollbarDrag {
174    pub start_row: usize,
175    pub start_offset: usize,
176}
177
178/// Shared press/drag/release state for a modal scrollbar. Owners hold one
179/// of these alongside their scroll state and forward mouse events to it
180/// via [`press`](Self::press), [`drag`](Self::drag), [`release`](Self::release).
181///
182/// All three methods return `Some(new_offset)` when the caller should
183/// update its scroll position, or `None` when the event isn't ours to
184/// handle (press outside the track, drag without a prior press, etc.).
185#[derive(Debug, Clone, Copy, Default)]
186pub struct ScrollbarMouse {
187    pub drag: Option<ScrollbarDrag>,
188}
189
190impl ScrollbarMouse {
191    /// Handle a left-button press. Returns `Some(new_offset)` when the
192    /// press lands inside `track`. A press on the thumb captures the
193    /// anchor without moving the viewport; a press on the track outside
194    /// the thumb recentres the thumb on the cursor before capturing.
195    pub fn press(
196        &mut self,
197        state: ScrollbarState,
198        track: Rect,
199        col: u16,
200        row: u16,
201    ) -> Option<usize> {
202        if !super::point_in_rect(track, col, row) {
203            return None;
204        }
205        let track_height = track.height as usize;
206        let click_row = (row.saturating_sub(track.y) as usize).min(track_height);
207
208        let new_offset = if state.is_thumb_row(track_height, click_row) {
209            state.scroll_offset
210        } else {
211            let (_, thumb_size) = state.thumb_geometry(track_height);
212            let aim_top = click_row.saturating_sub(thumb_size / 2);
213            state.offset_for_thumb_top(track_height, aim_top)
214        };
215
216        self.drag = Some(ScrollbarDrag {
217            start_row: click_row,
218            start_offset: new_offset,
219        });
220        Some(new_offset)
221    }
222
223    /// Handle a left-button drag. Returns `Some(new_offset)` if a drag is
224    /// active (i.e. there was a prior `press`), preserving the cursor's
225    /// position within the thumb.
226    pub fn drag(&mut self, state: ScrollbarState, track: Rect, row: u16) -> Option<usize> {
227        let drag = self.drag?;
228        let track_height = track.height as usize;
229        let current_row = (row.saturating_sub(track.y) as usize).min(track_height);
230        Some(state.drag_to_offset(track_height, drag.start_row, drag.start_offset, current_row))
231    }
232
233    /// Handle a left-button release. Ends any active drag.
234    pub fn release(&mut self) {
235        self.drag = None;
236    }
237}
238
239/// Colors for the scrollbar
240#[derive(Debug, Clone, Copy)]
241pub struct ScrollbarColors {
242    pub track: Color,
243    pub thumb: Color,
244}
245
246impl Default for ScrollbarColors {
247    fn default() -> Self {
248        Self {
249            track: Color::DarkGray,
250            thumb: Color::Gray,
251        }
252    }
253}
254
255impl ScrollbarColors {
256    /// Colors for an active/focused scrollbar
257    pub fn active() -> Self {
258        Self {
259            track: Color::DarkGray,
260            thumb: Color::Gray,
261        }
262    }
263
264    /// Colors for an inactive/unfocused scrollbar
265    pub fn inactive() -> Self {
266        Self {
267            track: Color::Black,
268            thumb: Color::DarkGray,
269        }
270    }
271
272    /// Create from theme colors
273    pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
274        Self {
275            track: theme.scrollbar_track_fg,
276            thumb: theme.scrollbar_thumb_fg,
277        }
278    }
279
280    /// Create from theme colors with hover
281    pub fn from_theme_hover(theme: &crate::view::theme::Theme) -> Self {
282        Self {
283            track: theme.scrollbar_track_hover_fg,
284            thumb: theme.scrollbar_thumb_hover_fg,
285        }
286    }
287}
288
289/// Render a vertical scrollbar
290///
291/// # Arguments
292/// * `frame` - The ratatui frame to render to
293/// * `area` - A 1-column wide rectangle for the scrollbar
294/// * `state` - The scrollbar state (total items, visible items, offset)
295/// * `colors` - Colors for track and thumb
296///
297/// # Returns
298/// (thumb_start, thumb_end) in row coordinates relative to the area
299pub fn render_scrollbar(
300    frame: &mut Frame,
301    area: Rect,
302    state: &ScrollbarState,
303    colors: &ScrollbarColors,
304) -> (usize, usize) {
305    let height = area.height as usize;
306    if height == 0 || area.width == 0 {
307        return (0, 0);
308    }
309
310    let (thumb_start, thumb_size) = state.thumb_geometry(height);
311    let thumb_end = thumb_start + thumb_size;
312
313    // Render as background fills to avoid gaps with box-drawing glyphs in some terminals.
314    for row in 0..height {
315        let cell_area = Rect::new(area.x, area.y + row as u16, 1, 1);
316
317        let style = if row >= thumb_start && row < thumb_end {
318            Style::default().bg(colors.thumb)
319        } else {
320            Style::default().bg(colors.track)
321        };
322
323        let paragraph = Paragraph::new(" ").style(style);
324        frame.render_widget(paragraph, cell_area);
325    }
326
327    (thumb_start, thumb_end)
328}
329
330/// Render a scrollbar with mouse hover highlight
331///
332/// Same as `render_scrollbar` but highlights the thumb if hovered
333pub fn render_scrollbar_with_hover(
334    frame: &mut Frame,
335    area: Rect,
336    state: &ScrollbarState,
337    colors: &ScrollbarColors,
338    is_thumb_hovered: bool,
339) -> (usize, usize) {
340    let height = area.height as usize;
341    if height == 0 || area.width == 0 {
342        return (0, 0);
343    }
344
345    let (thumb_start, thumb_size) = state.thumb_geometry(height);
346    let thumb_end = thumb_start + thumb_size;
347
348    // Highlight thumb when hovered
349    let thumb_color = if is_thumb_hovered {
350        Color::White
351    } else {
352        colors.thumb
353    };
354
355    for row in 0..height {
356        let cell_area = Rect::new(area.x, area.y + row as u16, 1, 1);
357
358        let style = if row >= thumb_start && row < thumb_end {
359            Style::default().bg(thumb_color)
360        } else {
361            Style::default().bg(colors.track)
362        };
363
364        let paragraph = Paragraph::new(" ").style(style);
365        frame.render_widget(paragraph, cell_area);
366    }
367
368    (thumb_start, thumb_end)
369}
370
371#[cfg(test)]
372mod tests {
373    use super::*;
374
375    #[test]
376    fn test_thumb_geometry_full_content_visible() {
377        // When all content fits in viewport, thumb fills entire track
378        let state = ScrollbarState::new(10, 20, 0); // 10 items, 20 visible
379        let (start, size) = state.thumb_geometry(10);
380        assert_eq!(start, 0);
381        assert_eq!(size, 10); // Fills entire track
382    }
383
384    #[test]
385    fn test_thumb_geometry_at_top() {
386        let state = ScrollbarState::new(100, 20, 0);
387        let (start, _size) = state.thumb_geometry(10);
388        assert_eq!(start, 0);
389    }
390
391    #[test]
392    fn test_thumb_geometry_at_bottom() {
393        let state = ScrollbarState::new(100, 20, 80); // Scrolled to max
394        let (start, size) = state.thumb_geometry(10);
395        assert_eq!(start + size, 10); // Thumb should be at bottom
396    }
397
398    #[test]
399    fn test_thumb_geometry_middle() {
400        let state = ScrollbarState::new(100, 20, 40); // Halfway
401        let (start, size) = state.thumb_geometry(10);
402        // Thumb should be roughly in the middle
403        assert!(start > 0);
404        assert!(start + size < 10);
405    }
406
407    #[test]
408    fn test_click_to_offset_top() {
409        let state = ScrollbarState::new(100, 20, 0);
410        let offset = state.click_to_offset(10, 0);
411        assert_eq!(offset, 0);
412    }
413
414    #[test]
415    fn test_click_to_offset_bottom() {
416        let state = ScrollbarState::new(100, 20, 0);
417        let offset = state.click_to_offset(10, 10);
418        assert_eq!(offset, 80); // max scroll
419    }
420
421    #[test]
422    fn test_click_to_offset_middle() {
423        let state = ScrollbarState::new(100, 20, 0);
424        let offset = state.click_to_offset(10, 5);
425        assert_eq!(offset, 40); // Half of max scroll (80)
426    }
427
428    #[test]
429    fn test_is_thumb_row() {
430        let state = ScrollbarState::new(100, 20, 0);
431        let (start, size) = state.thumb_geometry(10);
432
433        // Rows in thumb area should return true
434        for row in start..(start + size) {
435            assert!(state.is_thumb_row(10, row));
436        }
437
438        // Rows outside should return false (if any)
439        if start > 0 {
440            assert!(!state.is_thumb_row(10, 0));
441        }
442    }
443
444    #[test]
445    fn test_drag_to_offset_no_movement_keeps_offset() {
446        // Press on the thumb and don't move — offset must stay put.
447        let state = ScrollbarState::new(100, 20, 40);
448        let track = 20;
449        let (thumb_top, _) = state.thumb_geometry(track);
450        // Click in the middle of the thumb.
451        let click_row = thumb_top + 1;
452        let new_offset = state.drag_to_offset(track, click_row, 40, click_row);
453        assert_eq!(new_offset, 40);
454    }
455
456    #[test]
457    fn test_drag_to_offset_press_anywhere_on_thumb_no_jump() {
458        // Pressing on a non-top row of the thumb must not jump the
459        // viewport — the cursor stays pinned to that thumb position.
460        let state = ScrollbarState::new(200, 50, 75);
461        let track = 20;
462        let (thumb_top, thumb_size) = state.thumb_geometry(track);
463        assert!(thumb_size >= 2, "test needs thumb at least 2 rows tall");
464        for row_in_thumb in thumb_top..(thumb_top + thumb_size) {
465            let new_offset = state.drag_to_offset(track, row_in_thumb, 75, row_in_thumb);
466            assert_eq!(
467                new_offset, 75,
468                "press at thumb row {row_in_thumb} should not move the viewport"
469            );
470        }
471    }
472
473    #[test]
474    fn test_drag_to_offset_follows_cursor_down() {
475        // Press at the top of the thumb when scrolled to the start, then
476        // drag the cursor down — the offset must move down accordingly.
477        let state = ScrollbarState::new(100, 20, 0);
478        let track = 20;
479        let (thumb_top, _) = state.thumb_geometry(track);
480        let start_row = thumb_top;
481        let down_row = start_row + 5;
482        let dragged = state.drag_to_offset(track, start_row, 0, down_row);
483        assert!(
484            dragged > 0,
485            "drag down should increase offset, got {dragged}"
486        );
487    }
488
489    #[test]
490    fn test_drag_to_offset_clamps_at_bottom() {
491        let state = ScrollbarState::new(100, 20, 0);
492        let track = 20;
493        let dragged = state.drag_to_offset(track, 0, 0, 1000);
494        let max_scroll = 100 - 20;
495        assert_eq!(dragged, max_scroll);
496    }
497
498    #[test]
499    fn test_drag_to_offset_no_overflow_when_fits() {
500        // Content shorter than viewport — drag is a no-op.
501        let state = ScrollbarState::new(10, 20, 0);
502        assert_eq!(state.drag_to_offset(20, 0, 0, 5), 0);
503    }
504
505    #[test]
506    fn test_offset_for_thumb_top_round_trip() {
507        // For every reachable thumb row, `offset_for_thumb_top` must
508        // produce an offset whose rendered thumb top matches that row —
509        // i.e. it really is the inverse of `thumb_geometry`.
510        let cases = [
511            (200_usize, 50_usize, 20_usize),
512            (1000, 30, 25),
513            (50, 10, 15),
514        ];
515        for (total, visible, track) in cases {
516            let probe = ScrollbarState::new(total, visible, 0);
517            let (_, thumb_size) = probe.thumb_geometry(track);
518            let max_thumb_top = track.saturating_sub(thumb_size);
519            for target in 0..=max_thumb_top {
520                let offset = probe.offset_for_thumb_top(track, target);
521                let placed = ScrollbarState::new(total, visible, offset);
522                let (got_top, _) = placed.thumb_geometry(track);
523                assert!(
524                    got_top.abs_diff(target) <= 1,
525                    "thumb landed at {got_top}, expected {target} (total={total} visible={visible} track={track})"
526                );
527            }
528        }
529    }
530
531    #[test]
532    fn test_offset_for_thumb_top_clamps_to_max() {
533        let state = ScrollbarState::new(200, 50, 0);
534        let track = 20;
535        let (_, thumb_size) = state.thumb_geometry(track);
536        let max_thumb_top = track - thumb_size;
537        // Asking for a row past the bottom must clamp to max_scroll, not
538        // wrap or overshoot.
539        assert_eq!(
540            state.offset_for_thumb_top(track, max_thumb_top + 100),
541            200 - 50
542        );
543    }
544
545    fn track_rect(height: u16) -> Rect {
546        Rect::new(50, 10, 1, height)
547    }
548
549    #[test]
550    fn test_mouse_press_outside_track_returns_none() {
551        let mut mouse = ScrollbarMouse::default();
552        let state = ScrollbarState::new(200, 50, 75);
553        let track = track_rect(20);
554        // x outside
555        assert_eq!(mouse.press(state, track, 0, 15), None);
556        // y above
557        assert_eq!(mouse.press(state, track, 50, 0), None);
558        // y below (track is rows 10..30)
559        assert_eq!(mouse.press(state, track, 50, 30), None);
560        assert!(mouse.drag.is_none());
561    }
562
563    #[test]
564    fn test_mouse_press_on_thumb_does_not_jump() {
565        let mut mouse = ScrollbarMouse::default();
566        let state = ScrollbarState::new(200, 50, 75);
567        let track = track_rect(20);
568        let (thumb_top, _) = state.thumb_geometry(track.height as usize);
569        let press_screen_row = track.y + thumb_top as u16 + 1;
570        let returned = mouse.press(state, track, track.x, press_screen_row);
571        assert_eq!(returned, Some(75), "press on thumb must not move offset");
572        let drag = mouse.drag.expect("anchor captured");
573        assert_eq!(drag.start_offset, 75);
574    }
575
576    #[test]
577    fn test_mouse_press_on_track_recenters_thumb() {
578        let mut mouse = ScrollbarMouse::default();
579        let state = ScrollbarState::new(200, 50, 0); // thumb at top
580        let track = track_rect(20);
581        // Click way down the track (outside the thumb).
582        let returned = mouse.press(state, track, track.x, track.y + 18).unwrap();
583        // The new offset should place the thumb so its centre is near
584        // row 18: that means the new thumb_top is at 18 - thumb_size/2.
585        let placed = ScrollbarState::new(200, 50, returned);
586        let (got_top, thumb_size) = placed.thumb_geometry(track.height as usize);
587        let want_top = (18_usize).saturating_sub(thumb_size / 2);
588        assert!(
589            got_top.abs_diff(want_top) <= 1,
590            "thumb landed at {got_top}, expected ~{want_top}"
591        );
592    }
593
594    #[test]
595    fn test_mouse_drag_without_press_returns_none() {
596        let mut mouse = ScrollbarMouse::default();
597        let state = ScrollbarState::new(200, 50, 0);
598        assert_eq!(mouse.drag(state, track_rect(20), 15), None);
599    }
600
601    #[test]
602    fn test_mouse_drag_after_press_follows_cursor() {
603        let mut mouse = ScrollbarMouse::default();
604        let state = ScrollbarState::new(200, 50, 0);
605        let track = track_rect(20);
606        // Press at the thumb top.
607        let _ = mouse.press(state, track, track.x, track.y);
608        // Drag down a few rows.
609        let new_offset = mouse.drag(state, track, track.y + 5).unwrap();
610        assert!(new_offset > 0, "drag down should increase offset");
611    }
612
613    #[test]
614    fn test_mouse_release_clears_drag() {
615        let mut mouse = ScrollbarMouse::default();
616        let state = ScrollbarState::new(200, 50, 0);
617        let track = track_rect(20);
618        let _ = mouse.press(state, track, track.x, track.y);
619        assert!(mouse.drag.is_some());
620        mouse.release();
621        assert!(mouse.drag.is_none());
622    }
623}