Skip to main content

tui_panel_select/
scrollbar.rs

1//! Optional vertical scrollbar helpers, gated behind the `scrollbar` feature.
2//!
3//! Two pieces, both pure and panel-agnostic (they work for a
4//! [`MultiSelectPanel`](crate::MultiSelectPanel), a
5//! [`SelectablePanel`](crate::SelectablePanel), or any home-grown scrollable
6//! content):
7//!
8//! - [`scroll_for_track_row`] maps a clicked/dragged terminal row within a
9//!   scrollbar track to a scroll offset, so a click or drag anywhere in the
10//!   track jumps/scrolls proportionally — the way a native scrollbar behaves.
11//! - [`render_scrollbar`] draws a ratatui [`Scrollbar`] into a track column,
12//!   sizing and positioning the thumb from `total`/`capacity`/`start` and
13//!   no-op-ing when everything already fits.
14//!
15//! Panels that own their scroll offset (`MultiSelectPanel`) expose thin
16//! convenience wrappers ([`MultiSelectPanel::scroll_to_track_row`] and
17//! [`MultiSelectPanel::render_scrollbar`]) that plumb their own geometry into
18//! these functions; callers that keep scroll externally can use the free
19//! functions directly.
20
21use ratatui::buffer::Buffer;
22use ratatui::layout::Rect;
23use ratatui::style::Style;
24use ratatui::widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget};
25
26/// Visual styling for [`render_scrollbar`]. Start from
27/// [`ScrollbarStyle::default`] (a right-hand vertical bar with a `│` track and
28/// a solid `█` thumb, both unstyled) and override what you want — most callers
29/// only set `track_style`/`thumb_style` to their theme's dim/accent colours.
30#[derive(Clone, Debug)]
31pub struct ScrollbarStyle {
32    /// Which edge the bar sits on and which way it runs.
33    pub orientation: ScrollbarOrientation,
34    /// The glyph drawn along the unfilled track (`None` leaves the cells as-is).
35    pub track_symbol: Option<String>,
36    /// The glyph drawn for the thumb (the draggable filled portion).
37    pub thumb_symbol: String,
38    /// Optional cap glyph at the track's start (arrow/corner); usually `None`.
39    pub begin_symbol: Option<String>,
40    /// Optional cap glyph at the track's end; usually `None`.
41    pub end_symbol: Option<String>,
42    /// Style (typically a dim foreground colour) for the track.
43    pub track_style: Style,
44    /// Style (typically an accent foreground colour) for the thumb.
45    pub thumb_style: Style,
46}
47
48impl Default for ScrollbarStyle {
49    fn default() -> Self {
50        Self {
51            orientation: ScrollbarOrientation::VerticalRight,
52            track_symbol: Some("\u{2502}".to_string()),
53            thumb_symbol: "\u{2588}".to_string(),
54            begin_symbol: None,
55            end_symbol: None,
56            track_style: Style::default(),
57            thumb_style: Style::default(),
58        }
59    }
60}
61
62/// Map a clicked/dragged terminal `row` within a vertical scrollbar `track`
63/// to a scroll offset in `0..=max_scroll`, clamped to the track's own bounds
64/// (so a click above the track reads as the top and below it as the bottom).
65/// Returns `0` when there's nothing to scroll (an empty track or
66/// `max_scroll == 0`).
67///
68/// ```
69/// use ratatui::layout::Rect;
70/// use tui_panel_select::scrollbar::scroll_for_track_row;
71/// // An 11-row track at y=0, content that can scroll up to 100 rows.
72/// let track = Rect::new(40, 0, 1, 11);
73/// assert_eq!(scroll_for_track_row(track, 0, 100), 0); // top
74/// assert_eq!(scroll_for_track_row(track, 10, 100), 100); // bottom
75/// assert_eq!(scroll_for_track_row(track, 5, 100), 50); // middle
76/// ```
77pub fn scroll_for_track_row(track: Rect, row: u16, max_scroll: u16) -> u16 {
78    if track.height == 0 || max_scroll == 0 {
79        return 0;
80    }
81    let track_len = track.height.saturating_sub(1).max(1) as f64;
82    let rel = row
83        .saturating_sub(track.y)
84        .min(track.height.saturating_sub(1)) as f64;
85    (((rel / track_len) * max_scroll as f64).round() as u16).min(max_scroll)
86}
87
88/// Render a vertical scrollbar into `area`, sizing the thumb from `total`
89/// content rows, the visible `capacity` (rows that fit at once) and the
90/// current `start` scroll offset. A no-op when the area is empty or the
91/// content already fits (`total <= capacity`), so callers can call it
92/// unconditionally.
93pub fn render_scrollbar(
94    area: Rect,
95    buf: &mut Buffer,
96    total: usize,
97    capacity: usize,
98    start: usize,
99    style: &ScrollbarStyle,
100) {
101    if area.width == 0 || area.height == 0 || total <= capacity {
102        return;
103    }
104    let mut state = ScrollbarState::new(total - capacity).position(start);
105    let bar = Scrollbar::new(style.orientation.clone())
106        .begin_symbol(style.begin_symbol.as_deref())
107        .end_symbol(style.end_symbol.as_deref())
108        .track_symbol(style.track_symbol.as_deref())
109        .thumb_symbol(style.thumb_symbol.as_str())
110        .style(style.track_style)
111        .thumb_style(style.thumb_style);
112    StatefulWidget::render(bar, area, buf, &mut state);
113}
114
115#[cfg(test)]
116mod tests {
117    use super::*;
118
119    fn track() -> Rect {
120        // A one-column-wide, 11-row track anchored below the top border.
121        Rect::new(40, 2, 1, 11)
122    }
123
124    #[test]
125    fn track_row_maps_endpoints_and_midpoint() {
126        let t = track();
127        // Top of the track -> no scroll; bottom -> full scroll.
128        assert_eq!(scroll_for_track_row(t, t.y, 100), 0);
129        assert_eq!(scroll_for_track_row(t, t.y + t.height - 1, 100), 100);
130        // Middle row (5 of 10 steps) -> half.
131        assert_eq!(scroll_for_track_row(t, t.y + 5, 100), 50);
132    }
133
134    #[test]
135    fn track_row_clamps_out_of_bounds_rows() {
136        let t = track();
137        // Above the track reads as the top, far below as the bottom.
138        assert_eq!(scroll_for_track_row(t, 0, 100), 0);
139        assert_eq!(scroll_for_track_row(t, 500, 100), 100);
140    }
141
142    #[test]
143    fn track_row_is_zero_when_nothing_to_scroll() {
144        assert_eq!(scroll_for_track_row(track(), 5, 0), 0);
145        assert_eq!(scroll_for_track_row(Rect::new(0, 0, 1, 0), 5, 100), 0);
146    }
147
148    #[test]
149    fn render_is_a_no_op_when_content_fits_or_area_empty() {
150        let area = Rect::new(0, 0, 1, 10);
151        let mut buf = Buffer::empty(area);
152        // Fits: total <= capacity -> nothing drawn (buffer stays blank).
153        render_scrollbar(area, &mut buf, 10, 10, 0, &ScrollbarStyle::default());
154        let blank = Buffer::empty(area);
155        assert_eq!(buf, blank);
156        // Empty area -> also a no-op.
157        render_scrollbar(
158            Rect::new(0, 0, 0, 0),
159            &mut buf,
160            100,
161            10,
162            0,
163            &ScrollbarStyle::default(),
164        );
165        assert_eq!(buf, blank);
166    }
167
168    #[test]
169    fn render_draws_a_thumb_when_content_overflows() {
170        let area = Rect::new(0, 0, 1, 10);
171        let mut buf = Buffer::empty(area);
172        render_scrollbar(area, &mut buf, 100, 10, 0, &ScrollbarStyle::default());
173        // At least one cell must now carry the thumb glyph.
174        let painted: String = (0..area.height)
175            .map(|y| buf[(0, y)].symbol().to_string())
176            .collect();
177        assert!(
178            painted.contains('\u{2588}'),
179            "a scrollable panel must paint a thumb: {painted:?}"
180        );
181    }
182}