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 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 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 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 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 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#[derive(Debug, Clone, Copy)]
173pub struct ScrollbarDrag {
174 pub start_row: usize,
175 pub start_offset: usize,
176}
177
178#[derive(Debug, Clone, Copy, Default)]
186pub struct ScrollbarMouse {
187 pub drag: Option<ScrollbarDrag>,
188}
189
190impl ScrollbarMouse {
191 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 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 pub fn release(&mut self) {
235 self.drag = None;
236 }
237}
238
239#[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 pub fn active() -> Self {
258 Self {
259 track: Color::DarkGray,
260 thumb: Color::Gray,
261 }
262 }
263
264 pub fn inactive() -> Self {
266 Self {
267 track: Color::Black,
268 thumb: Color::DarkGray,
269 }
270 }
271
272 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 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
289pub 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 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
330pub 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 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 let state = ScrollbarState::new(10, 20, 0); let (start, size) = state.thumb_geometry(10);
380 assert_eq!(start, 0);
381 assert_eq!(size, 10); }
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); let (start, size) = state.thumb_geometry(10);
395 assert_eq!(start + size, 10); }
397
398 #[test]
399 fn test_thumb_geometry_middle() {
400 let state = ScrollbarState::new(100, 20, 40); let (start, size) = state.thumb_geometry(10);
402 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); }
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); }
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 for row in start..(start + size) {
435 assert!(state.is_thumb_row(10, row));
436 }
437
438 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 let state = ScrollbarState::new(100, 20, 40);
448 let track = 20;
449 let (thumb_top, _) = state.thumb_geometry(track);
450 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 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 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 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 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 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 assert_eq!(mouse.press(state, track, 0, 15), None);
556 assert_eq!(mouse.press(state, track, 50, 0), None);
558 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); let track = track_rect(20);
581 let returned = mouse.press(state, track, track.x, track.y + 18).unwrap();
583 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 let _ = mouse.press(state, track, track.x, track.y);
608 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}