#[derive(Debug, Clone, Default, PartialEq, Eq)]
pub struct Viewport {
cursor: usize,
offset: usize,
rows: usize,
}
impl Viewport {
#[must_use]
pub fn new() -> Self {
Self::default()
}
#[must_use]
pub fn cursor(&self) -> usize {
self.cursor
}
#[must_use]
pub fn offset(&self) -> usize {
self.offset
}
pub fn set_rows(&mut self, rows: usize, len: usize) {
self.rows = rows;
self.ensure_visible(len);
}
pub fn move_by(&mut self, delta: isize, len: usize) {
if len == 0 {
self.cursor = 0;
self.offset = 0;
return;
}
let next = self.cursor as isize + delta;
self.cursor = next.clamp(0, len as isize - 1) as usize;
self.ensure_visible(len);
}
pub fn move_to(&mut self, index: usize, len: usize) {
if len == 0 {
self.cursor = 0;
self.offset = 0;
return;
}
self.cursor = index.min(len - 1);
self.ensure_visible(len);
}
pub fn clamp(&mut self, len: usize) {
self.move_by(0, len);
}
fn ensure_visible(&mut self, len: usize) {
if self.rows == 0 {
return;
}
if self.cursor < self.offset {
self.offset = self.cursor;
} else if self.cursor >= self.offset + self.rows {
self.offset = self.cursor + 1 - self.rows;
}
self.offset = self.offset.min(len.saturating_sub(self.rows));
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_viewport_that_does_not_know_its_height_never_scrolls() {
let mut v = Viewport::new();
v.move_by(50, 100);
assert_eq!(v.cursor(), 50);
assert_eq!(v.offset(), 0);
}
#[test]
fn moving_past_the_bottom_pulls_the_offset_so_the_cursor_is_the_last_visible_row() {
let mut v = Viewport::new();
v.set_rows(10, 100);
v.move_by(15, 100);
assert_eq!(v.cursor(), 15);
assert_eq!(v.offset(), 6, "rows 6..=15 are visible");
}
#[test]
fn moving_back_above_the_top_pulls_the_offset_to_the_cursor() {
let mut v = Viewport::new();
v.set_rows(10, 100);
v.move_by(30, 100);
v.move_by(-28, 100);
assert_eq!(v.cursor(), 2);
assert_eq!(v.offset(), 2);
}
#[test]
fn the_cursor_clamps_to_the_list_rather_than_wrapping() {
let mut v = Viewport::new();
v.set_rows(10, 100);
v.move_by(-5, 100);
assert_eq!(v.cursor(), 0);
v.move_by(500, 100);
assert_eq!(v.cursor(), 99);
assert_eq!(v.offset(), 90);
}
#[test]
fn an_empty_list_leaves_the_cursor_and_offset_at_zero() {
let mut v = Viewport::new();
v.set_rows(10, 100);
v.move_by(3, 0);
assert_eq!((v.cursor(), v.offset()), (0, 0));
}
#[test]
fn shrinking_the_list_under_the_cursor_clamps_it_back() {
let mut v = Viewport::new();
v.set_rows(10, 100);
v.move_to(45, 100);
v.clamp(20);
assert_eq!(v.cursor(), 19);
assert_eq!(v.offset(), 10);
}
#[test]
fn a_shorter_terminal_brings_the_cursor_back_into_view() {
let mut v = Viewport::new();
v.set_rows(30, 100);
v.move_to(25, 100);
assert_eq!(v.offset(), 0);
v.set_rows(10, 100);
assert_eq!(v.offset(), 16, "the cursor is still the last visible row");
}
#[test]
fn a_move_after_the_list_shrinks_catches_the_stale_offset_without_a_clamp() {
let mut v = Viewport::new();
v.set_rows(10, 100);
v.move_to(90, 100);
v.move_by(0, 20);
assert_eq!(v.offset(), 10);
assert_eq!(v.cursor(), 19);
}
}