use super::theme;
use ratatui::Frame;
use ratatui::layout::Rect;
use ratatui::style::Style;
use ratatui::widgets::{Scrollbar, ScrollbarOrientation, ScrollbarState};
pub(super) const THUMB: &str = "┃";
pub(super) fn draw(
frame: &mut Frame,
track: Rect,
total: usize,
visible: usize,
offset: usize,
) -> Option<Rect> {
if total <= visible || visible == 0 || track.height == 0 || track.width == 0 {
return None;
}
let max_offset = total - visible;
let mut state = ScrollbarState::new(max_offset + 1)
.viewport_content_length(visible)
.position(offset.min(max_offset));
let bar = Scrollbar::new(ScrollbarOrientation::VerticalRight)
.begin_symbol(None)
.end_symbol(None)
.track_symbol(None)
.thumb_symbol(THUMB)
.thumb_style(Style::default().fg(theme::colors().accent));
frame.render_stateful_widget(bar, track, &mut state);
Some(track)
}
pub(super) fn position_at(track: Rect, row: u16, last: usize) -> usize {
let span = track.height.saturating_sub(1) as usize;
let at = row
.saturating_sub(track.y)
.min(track.height.saturating_sub(1)) as usize;
if span == 0 {
return 0;
}
(at * last + span / 2) / span
}
pub(super) fn hit(track: Option<Rect>, col: u16, row: u16) -> Option<Rect> {
track.filter(|t| t.contains(ratatui::layout::Position::new(col, row)))
}
pub(super) fn right_border(outer: Rect, from: u16, rows: u16) -> Rect {
let top = from.max(outer.y + 1);
let bottom = (from + rows).min(outer.bottom().saturating_sub(1));
Rect {
x: outer.right().saturating_sub(1),
y: top,
width: 1,
height: bottom.saturating_sub(top),
}
}
pub(super) fn on_border(
frame: &mut Frame,
outer: Rect,
total: usize,
visible: usize,
offset: usize,
) -> Option<Rect> {
let track = right_border(outer, outer.y + 1, outer.height.saturating_sub(2));
draw(frame, track, total, visible, offset)
}
#[cfg(test)]
pub(super) mod tests {
use super::*;
use ratatui::Terminal;
use ratatui::backend::TestBackend;
use ratatui::buffer::Buffer;
use ratatui::widgets::Block;
pub(crate) fn thumb_cells(buf: &Buffer) -> usize {
buf.content().iter().filter(|c| c.symbol() == THUMB).count()
}
fn drawn(total: usize, offset: usize) -> Buffer {
let mut terminal = Terminal::new(TestBackend::new(20, 12)).expect("backend");
terminal
.draw(|frame| {
let outer = frame.area();
frame.render_widget(Block::bordered(), outer);
let _ = on_border(frame, outer, total, 10, offset);
})
.expect("draw");
terminal.backend().buffer().clone()
}
#[test]
fn a_click_on_the_track_asks_for_its_share_of_the_content() {
let track = Rect::new(19, 1, 1, 10);
assert_eq!(position_at(track, 1, 99), 0);
assert_eq!(position_at(track, 10, 99), 99);
assert_eq!(position_at(track, 30, 99), 99);
assert_eq!(position_at(track, 0, 99), 0);
let mid = position_at(track, 5, 99);
assert!((40..=50).contains(&mid), "{mid}");
assert_eq!(position_at(Rect::new(0, 4, 1, 1), 4, 7), 0);
assert!(hit(Some(track), 19, 3).is_some());
assert!(hit(Some(track), 18, 3).is_none());
assert!(hit(None, 19, 3).is_none());
}
#[test]
fn nothing_is_drawn_when_it_fits() {
assert_eq!(thumb_cells(&drawn(10, 0)), 0);
assert_eq!(thumb_cells(&drawn(3, 0)), 0);
}
#[test]
fn the_thumb_is_the_share_shown_and_moves_with_the_view() {
let top = drawn(20, 0);
assert_eq!(thumb_cells(&top), 5);
assert_eq!(top[(19, 1)].symbol(), THUMB);
assert_eq!(top[(19, 0)].symbol(), "┐");
let bottom = drawn(20, 10);
assert_eq!(thumb_cells(&bottom), 5);
assert_eq!(bottom[(19, 10)].symbol(), THUMB);
assert_eq!(bottom[(19, 11)].symbol(), "┘");
assert_eq!(bottom[(19, 1)].symbol(), "│");
}
}