use ratatui::layout::{Position, Rect};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct Extent {
pub content: usize,
pub view: usize,
pub offset: usize,
}
impl Extent {
fn furthest(&self) -> usize {
self.content.saturating_sub(self.view)
}
}
pub fn thumb(track: usize, extent: Extent) -> Option<(usize, usize)> {
let Extent { content, view, offset } = extent;
if track == 0 || view == 0 || content <= view {
return None;
}
let length = (track * view).div_ceil(content).clamp(1, track);
let furthest = extent.furthest();
let room = track - length;
let start = match offset.min(furthest) {
0 => 0,
at if at == furthest => room,
at => {
let start = (room * at + furthest / 2) / furthest;
if room >= 2 { start.clamp(1, room - 1) } else { start }
}
};
Some((start, length))
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub struct ScrollBar {
pub track: Rect,
pub vertical: bool,
pub extent: Extent,
}
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum BarPress {
Thumb(usize),
Page { forward: bool },
}
impl ScrollBar {
fn length(&self) -> usize {
usize::from(if self.vertical { self.track.height } else { self.track.width })
}
fn along(&self, column: u16, row: u16) -> isize {
if self.vertical { row as isize - self.track.y as isize } else { column as isize - self.track.x as isize }
}
pub fn press(&self, column: u16, row: u16) -> Option<BarPress> {
if !self.track.contains(Position::new(column, row)) {
return None;
}
let (start, length) = thumb(self.length(), self.extent)?;
let at = self.along(column, row) as usize;
Some(if at < start {
BarPress::Page { forward: false }
} else if at >= start + length {
BarPress::Page { forward: true }
} else {
BarPress::Thumb(at - start)
})
}
pub fn drag_to(&self, column: u16, row: u16, grab: usize) -> usize {
let Some((_, length)) = thumb(self.length(), self.extent) else {
return 0;
};
let room = self.length() - length;
if room == 0 {
return 0;
}
let start = (self.along(column, row) - grab as isize).clamp(0, room as isize) as usize;
let furthest = self.extent.furthest();
(start * furthest + room / 2) / room
}
}
#[cfg(test)]
mod tests {
use super::*;
fn at(content: usize, view: usize, offset: usize) -> Option<(usize, usize)> {
thumb(10, Extent { content, view, offset })
}
#[test]
fn nothing_to_scroll_is_no_bar() {
assert_eq!(at(10, 10, 0), None);
assert_eq!(at(3, 10, 0), None);
assert_eq!(thumb(0, Extent { content: 100, view: 10, offset: 0 }), None);
}
#[test]
fn the_thumb_is_the_view_against_the_whole() {
assert_eq!(at(20, 10, 0), Some((0, 5)));
assert_eq!(at(20, 10, 10), Some((5, 5)));
assert_eq!(at(100_000, 10, 0), Some((0, 1)));
}
#[test]
fn only_the_ends_of_the_content_reach_the_ends_of_the_track() {
let (content, view) = (1000, 10);
assert_eq!(at(content, view, 0).unwrap().0, 0);
assert_eq!(at(content, view, 1).unwrap().0, 1, "a line down is off the top");
assert_eq!(at(content, view, content - view - 1).unwrap().0, 8, "a line short of the end is off the bottom");
assert_eq!(at(content, view, content - view).unwrap().0, 9);
assert_eq!(at(content, view, content * 2).unwrap().0, 9);
}
fn bar(offset: usize) -> ScrollBar {
ScrollBar {
track: Rect::new(30, 5, 1, 10),
vertical: true,
extent: Extent { content: 100, view: 20, offset },
}
}
#[test]
fn a_press_takes_the_thumb_or_pages_towards_itself() {
let bar = bar(40);
let (start, length) = thumb(10, bar.extent).unwrap();
assert_eq!((start, length), (4, 2));
assert_eq!(bar.press(30, 5 + 4), Some(BarPress::Thumb(0)));
assert_eq!(bar.press(30, 5 + 5), Some(BarPress::Thumb(1)));
assert_eq!(bar.press(30, 5), Some(BarPress::Page { forward: false }));
assert_eq!(bar.press(30, 14), Some(BarPress::Page { forward: true }));
assert_eq!(bar.press(29, 9), None);
assert_eq!(bar.press(30, 4), None);
assert_eq!(bar.press(30, 15), None);
}
#[test]
fn a_dragged_thumb_follows_the_pointer_and_stops_at_the_ends() {
let bar = bar(0);
assert_eq!(bar.drag_to(30, 6, 1), 0);
assert_eq!(bar.drag_to(30, 10, 1), 40);
assert_eq!(bar.drag_to(30, 14, 1), 80);
assert_eq!(bar.drag_to(30, 40, 1), 80);
assert_eq!(bar.drag_to(30, 0, 1), 0);
assert_eq!(bar.drag_to(70, 10, 1), 40);
let moved = ScrollBar {
extent: Extent { offset: bar.drag_to(30, 10, 1), ..bar.extent },
..bar
};
assert_eq!(thumb(10, moved.extent).unwrap().0 + 1, 10 - 5);
}
}