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(Rect::new(0, 0, 0, 0), &mut buf, 100, 10, 0, &ScrollbarStyle::default());
158 assert_eq!(buf, blank);
159 }
160
161 #[test]
162 fn render_draws_a_thumb_when_content_overflows() {
163 let area = Rect::new(0, 0, 1, 10);
164 let mut buf = Buffer::empty(area);
165 render_scrollbar(area, &mut buf, 100, 10, 0, &ScrollbarStyle::default());
166 // At least one cell must now carry the thumb glyph.
167 let painted: String = (0..area.height)
168 .map(|y| buf[(0, y)].symbol().to_string())
169 .collect();
170 assert!(
171 painted.contains('\u{2588}'),
172 "a scrollable panel must paint a thumb: {painted:?}"
173 );
174 }
175}