1use ratatui::layout::Rect;
8use ratatui::style::{Color, Style};
9use ratatui::widgets::Paragraph;
10use ratatui::Frame;
11
12#[derive(Debug, Clone, Copy)]
14pub struct ScrollbarState {
15 pub total_items: usize,
17 pub visible_items: usize,
19 pub scroll_offset: usize,
21}
22
23impl ScrollbarState {
24 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 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 let max_scroll = self.total_items.saturating_sub(self.visible_items);
43
44 if max_scroll == 0 {
46 return (0, track_height);
47 }
48
49 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 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 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 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 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 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#[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 pub fn active() -> Self {
117 Self {
118 track: Color::DarkGray,
119 thumb: Color::Gray,
120 }
121 }
122
123 pub fn inactive() -> Self {
125 Self {
126 track: Color::Black,
127 thumb: Color::DarkGray,
128 }
129 }
130
131 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 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
148pub 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 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
189pub 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 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 let state = ScrollbarState::new(10, 20, 0); let (start, size) = state.thumb_geometry(10);
239 assert_eq!(start, 0);
240 assert_eq!(size, 10); }
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); let (start, size) = state.thumb_geometry(10);
254 assert_eq!(start + size, 10); }
256
257 #[test]
258 fn test_thumb_geometry_middle() {
259 let state = ScrollbarState::new(100, 20, 40); let (start, size) = state.thumb_geometry(10);
261 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); }
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); }
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 for row in start..(start + size) {
294 assert!(state.is_thumb_row(10, row));
295 }
296
297 if start > 0 {
299 assert!(!state.is_thumb_row(10, 0));
300 }
301 }
302}