use ratatui::buffer::Buffer;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState, StatefulWidget};
#[derive(Clone, Debug)]
pub struct ScrollbarStyle {
pub orientation: ScrollbarOrientation,
pub track_symbol: Option<String>,
pub thumb_symbol: String,
pub begin_symbol: Option<String>,
pub end_symbol: Option<String>,
pub track_style: Style,
pub thumb_style: Style,
}
impl Default for ScrollbarStyle {
fn default() -> Self {
Self {
orientation: ScrollbarOrientation::VerticalRight,
track_symbol: Some("\u{2502}".to_string()),
thumb_symbol: "\u{2588}".to_string(),
begin_symbol: None,
end_symbol: None,
track_style: Style::default(),
thumb_style: Style::default(),
}
}
}
pub fn scroll_for_track_row(track: Rect, row: u16, max_scroll: u16) -> u16 {
if track.height == 0 || max_scroll == 0 {
return 0;
}
let track_len = track.height.saturating_sub(1).max(1) as f64;
let rel = row
.saturating_sub(track.y)
.min(track.height.saturating_sub(1)) as f64;
(((rel / track_len) * max_scroll as f64).round() as u16).min(max_scroll)
}
pub fn render_scrollbar(
area: Rect,
buf: &mut Buffer,
total: usize,
capacity: usize,
start: usize,
style: &ScrollbarStyle,
) {
if area.width == 0 || area.height == 0 || total <= capacity {
return;
}
let mut state = ScrollbarState::new(total - capacity).position(start);
let bar = Scrollbar::new(style.orientation.clone())
.begin_symbol(style.begin_symbol.as_deref())
.end_symbol(style.end_symbol.as_deref())
.track_symbol(style.track_symbol.as_deref())
.thumb_symbol(style.thumb_symbol.as_str())
.style(style.track_style)
.thumb_style(style.thumb_style);
StatefulWidget::render(bar, area, buf, &mut state);
}
#[cfg(test)]
mod tests {
use super::*;
fn track() -> Rect {
Rect::new(40, 2, 1, 11)
}
#[test]
fn track_row_maps_endpoints_and_midpoint() {
let t = track();
assert_eq!(scroll_for_track_row(t, t.y, 100), 0);
assert_eq!(scroll_for_track_row(t, t.y + t.height - 1, 100), 100);
assert_eq!(scroll_for_track_row(t, t.y + 5, 100), 50);
}
#[test]
fn track_row_clamps_out_of_bounds_rows() {
let t = track();
assert_eq!(scroll_for_track_row(t, 0, 100), 0);
assert_eq!(scroll_for_track_row(t, 500, 100), 100);
}
#[test]
fn track_row_is_zero_when_nothing_to_scroll() {
assert_eq!(scroll_for_track_row(track(), 5, 0), 0);
assert_eq!(scroll_for_track_row(Rect::new(0, 0, 1, 0), 5, 100), 0);
}
#[test]
fn render_is_a_no_op_when_content_fits_or_area_empty() {
let area = Rect::new(0, 0, 1, 10);
let mut buf = Buffer::empty(area);
render_scrollbar(area, &mut buf, 10, 10, 0, &ScrollbarStyle::default());
let blank = Buffer::empty(area);
assert_eq!(buf, blank);
render_scrollbar(
Rect::new(0, 0, 0, 0),
&mut buf,
100,
10,
0,
&ScrollbarStyle::default(),
);
assert_eq!(buf, blank);
}
#[test]
fn render_draws_a_thumb_when_content_overflows() {
let area = Rect::new(0, 0, 1, 10);
let mut buf = Buffer::empty(area);
render_scrollbar(area, &mut buf, 100, 10, 0, &ScrollbarStyle::default());
let painted: String = (0..area.height)
.map(|y| buf[(0, y)].symbol().to_string())
.collect();
assert!(
painted.contains('\u{2588}'),
"a scrollable panel must paint a thumb: {painted:?}"
);
}
}