Skip to main content

bee_tui/components/
scroll.rs

1//! Shared scroll helpers for components with selectable / overflowing
2//! lists (S2 stamps, S6 peers, S9 tags). Components track a `usize`
3//! offset alongside their `selected` index; this module bundles the
4//! "keep selected visible" math + the right-edge scrollbar render.
5//!
6//! Each helper is pure where possible so unit tests can exercise the
7//! offset arithmetic without spinning up a Frame.
8
9use ratatui::{
10    Frame,
11    layout::Rect,
12    style::Style,
13    widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState},
14};
15
16use crate::theme;
17
18/// Recompute `scroll_offset` so `selected` is inside the
19/// `visible_rows` window. Pure — no Frame access.
20///
21/// - Cursor moved above the window: snap the window to the cursor.
22/// - Cursor moved below the window: scroll just enough to keep it
23///   visible (one-line bumps, not page jumps — feels smoother in
24///   practice).
25/// - List shrunk: clamp so the offset never points past the end.
26pub fn clamp_scroll(
27    selected: usize,
28    scroll_offset: usize,
29    visible_rows: usize,
30    total_rows: usize,
31) -> usize {
32    let visible_rows = visible_rows.max(1);
33    let max_offset = total_rows.saturating_sub(visible_rows);
34    let mut offset = scroll_offset;
35    if selected < offset {
36        offset = selected;
37    } else if selected >= offset + visible_rows {
38        offset = selected + 1 - visible_rows;
39    }
40    offset.min(max_offset)
41}
42
43/// Render the right-edge scrollbar over `area` if the list overflows.
44/// Silently no-ops when every row already fits — that's the common
45/// case (most operators have <8 batches / <30 peers / <5 tags).
46pub fn render_scrollbar(
47    frame: &mut Frame,
48    area: Rect,
49    scroll_offset: usize,
50    visible_rows: usize,
51    total_rows: usize,
52) {
53    if total_rows <= visible_rows {
54        return;
55    }
56    let t = theme::active();
57    let mut state = ScrollbarState::new(total_rows.saturating_sub(visible_rows))
58        .position(scroll_offset)
59        .viewport_content_length(visible_rows);
60    let scrollbar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
61        .style(Style::default().fg(t.dim))
62        .thumb_style(Style::default().fg(t.accent))
63        .begin_symbol(None)
64        .end_symbol(None);
65    frame.render_stateful_widget(scrollbar, area, &mut state);
66}
67
68#[cfg(test)]
69mod tests {
70    use super::*;
71
72    #[test]
73    fn clamp_keeps_selection_in_window_when_already_visible() {
74        // selected=3, offset=0, visible=10 — already in window.
75        assert_eq!(clamp_scroll(3, 0, 10, 50), 0);
76    }
77
78    #[test]
79    fn clamp_advances_window_when_selection_moves_below() {
80        // selected=12, visible=10 → offset = 12 + 1 - 10 = 3
81        assert_eq!(clamp_scroll(12, 0, 10, 50), 3);
82    }
83
84    #[test]
85    fn clamp_snaps_window_up_when_selection_moves_above() {
86        // selected=2, offset=10 — cursor went up; snap to it.
87        assert_eq!(clamp_scroll(2, 10, 10, 50), 2);
88    }
89
90    #[test]
91    fn clamp_holds_offset_within_max_when_list_shrinks() {
92        // total=20, visible=10 → max_offset=10. Stale offset=15
93        // must be clamped down even though the cursor isn't outside
94        // (e.g. selected=14 was valid before the list shrank to 20).
95        assert_eq!(clamp_scroll(14, 15, 10, 20), 10);
96    }
97
98    #[test]
99    fn clamp_yields_zero_when_list_fits() {
100        // visible=10, total=5 — never need to scroll.
101        assert_eq!(clamp_scroll(4, 0, 10, 5), 0);
102        assert_eq!(clamp_scroll(4, 7, 10, 5), 0); // even if offset is bogus
103    }
104
105    #[test]
106    fn clamp_handles_zero_visible_rows_without_panic() {
107        // Vertical layout can momentarily yield height=0 during
108        // resize; treat as visible_rows=1 for safety.
109        let _ = clamp_scroll(3, 0, 0, 50);
110    }
111}