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
98/// Colors for the scrollbar
99#[derive(Debug, Clone, Copy)]
100pub struct ScrollbarColors {
101    pub track: Color,
102    pub thumb: Color,
103}
104
105impl Default for ScrollbarColors {
106    fn default() -> Self {
107        Self {
108            track: Color::DarkGray,
109            thumb: Color::Gray,
110        }
111    }
112}
113
114impl ScrollbarColors {
115    /// Colors for an active/focused scrollbar
116    pub fn active() -> Self {
117        Self {
118            track: Color::DarkGray,
119            thumb: Color::Gray,
120        }
121    }
122
123    /// Colors for an inactive/unfocused scrollbar
124    pub fn inactive() -> Self {
125        Self {
126            track: Color::Black,
127            thumb: Color::DarkGray,
128        }
129    }
130
131    /// Create from theme colors
132    pub fn from_theme(theme: &crate::view::theme::Theme) -> Self {
133        Self {
134            track: theme.scrollbar_track_fg,
135            thumb: theme.scrollbar_thumb_fg,
136        }
137    }
138
139    /// Create from theme colors with hover
140    pub fn from_theme_hover(theme: &crate::view::theme::Theme) -> Self {
141        Self {
142            track: theme.scrollbar_track_hover_fg,
143            thumb: theme.scrollbar_thumb_hover_fg,
144        }
145    }
146}
147
148/// Render a vertical scrollbar
149///
150/// # Arguments
151/// * `frame` - The ratatui frame to render to
152/// * `area` - A 1-column wide rectangle for the scrollbar
153/// * `state` - The scrollbar state (total items, visible items, offset)
154/// * `colors` - Colors for track and thumb
155///
156/// # Returns
157/// (thumb_start, thumb_end) in row coordinates relative to the area
158pub fn render_scrollbar(
159    frame: &mut Frame,
160    area: Rect,
161    state: &ScrollbarState,
162    colors: &ScrollbarColors,
163) -> (usize, usize) {
164    let height = area.height as usize;
165    if height == 0 || area.width == 0 {
166        return (0, 0);
167    }
168
169    let (thumb_start, thumb_size) = state.thumb_geometry(height);
170    let thumb_end = thumb_start + thumb_size;
171
172    // Render as background fills to avoid gaps with box-drawing glyphs in some terminals.
173    for row in 0..height {
174        let cell_area = Rect::new(area.x, area.y + row as u16, 1, 1);
175
176        let style = if row >= thumb_start && row < thumb_end {
177            Style::default().bg(colors.thumb)
178        } else {
179            Style::default().bg(colors.track)
180        };
181
182        let paragraph = Paragraph::new(" ").style(style);
183        frame.render_widget(paragraph, cell_area);
184    }
185
186    (thumb_start, thumb_end)
187}
188
189/// Render a scrollbar with mouse hover highlight
190///
191/// Same as `render_scrollbar` but highlights the thumb if hovered
192pub fn render_scrollbar_with_hover(
193    frame: &mut Frame,
194    area: Rect,
195    state: &ScrollbarState,
196    colors: &ScrollbarColors,
197    is_thumb_hovered: bool,
198) -> (usize, usize) {
199    let height = area.height as usize;
200    if height == 0 || area.width == 0 {
201        return (0, 0);
202    }
203
204    let (thumb_start, thumb_size) = state.thumb_geometry(height);
205    let thumb_end = thumb_start + thumb_size;
206
207    // Highlight thumb when hovered
208    let thumb_color = if is_thumb_hovered {
209        Color::White
210    } else {
211        colors.thumb
212    };
213
214    for row in 0..height {
215        let cell_area = Rect::new(area.x, area.y + row as u16, 1, 1);
216
217        let style = if row >= thumb_start && row < thumb_end {
218            Style::default().bg(thumb_color)
219        } else {
220            Style::default().bg(colors.track)
221        };
222
223        let paragraph = Paragraph::new(" ").style(style);
224        frame.render_widget(paragraph, cell_area);
225    }
226
227    (thumb_start, thumb_end)
228}
229
230#[cfg(test)]
231mod tests {
232    use super::*;
233
234    #[test]
235    fn test_thumb_geometry_full_content_visible() {
236        // When all content fits in viewport, thumb fills entire track
237        let state = ScrollbarState::new(10, 20, 0); // 10 items, 20 visible
238        let (start, size) = state.thumb_geometry(10);
239        assert_eq!(start, 0);
240        assert_eq!(size, 10); // Fills entire track
241    }
242
243    #[test]
244    fn test_thumb_geometry_at_top() {
245        let state = ScrollbarState::new(100, 20, 0);
246        let (start, _size) = state.thumb_geometry(10);
247        assert_eq!(start, 0);
248    }
249
250    #[test]
251    fn test_thumb_geometry_at_bottom() {
252        let state = ScrollbarState::new(100, 20, 80); // Scrolled to max
253        let (start, size) = state.thumb_geometry(10);
254        assert_eq!(start + size, 10); // Thumb should be at bottom
255    }
256
257    #[test]
258    fn test_thumb_geometry_middle() {
259        let state = ScrollbarState::new(100, 20, 40); // Halfway
260        let (start, size) = state.thumb_geometry(10);
261        // Thumb should be roughly in the middle
262        assert!(start > 0);
263        assert!(start + size < 10);
264    }
265
266    #[test]
267    fn test_click_to_offset_top() {
268        let state = ScrollbarState::new(100, 20, 0);
269        let offset = state.click_to_offset(10, 0);
270        assert_eq!(offset, 0);
271    }
272
273    #[test]
274    fn test_click_to_offset_bottom() {
275        let state = ScrollbarState::new(100, 20, 0);
276        let offset = state.click_to_offset(10, 10);
277        assert_eq!(offset, 80); // max scroll
278    }
279
280    #[test]
281    fn test_click_to_offset_middle() {
282        let state = ScrollbarState::new(100, 20, 0);
283        let offset = state.click_to_offset(10, 5);
284        assert_eq!(offset, 40); // Half of max scroll (80)
285    }
286
287    #[test]
288    fn test_is_thumb_row() {
289        let state = ScrollbarState::new(100, 20, 0);
290        let (start, size) = state.thumb_geometry(10);
291
292        // Rows in thumb area should return true
293        for row in start..(start + size) {
294            assert!(state.is_thumb_row(10, row));
295        }
296
297        // Rows outside should return false (if any)
298        if start > 0 {
299            assert!(!state.is_thumb_row(10, 0));
300        }
301    }
302}