use crossterm::event::{KeyCode, KeyEvent};
use ratada::nav::{cycle, step_clamped};
use unicode_width::UnicodeWidthStr;
pub const SLUG_HEADER: &str = "Slug";
pub const SLUG_MAX: usize = 20;
pub struct ListScroll {
pub saved: usize,
pub cursor: usize,
pub row_count: usize,
pub viewport: usize,
pub first_entry_row: Option<usize>,
}
pub fn settled_offset(scroll: &ListScroll) -> usize {
let mut offset = scroll.saved.min(scroll.row_count.saturating_sub(1));
if scroll.cursor < offset {
offset = scroll.cursor;
} else if scroll.viewport > 0 && scroll.cursor >= offset + scroll.viewport {
offset = scroll.cursor + 1 - scroll.viewport;
}
if let Some(first_entry_row) = scroll.first_entry_row
&& offset <= first_entry_row
{
offset = 0;
}
offset
}
pub fn slug_column_width(widest: usize) -> usize {
widest
.max(UnicodeWidthStr::width(SLUG_HEADER))
.min(SLUG_MAX)
}
const OVERLAY_PAGE: isize = 10;
pub fn moved_cursor(key: KeyEvent, cursor: usize, len: usize) -> Option<usize> {
match key.code {
KeyCode::Up => Some(cycle(cursor, len, -1)),
KeyCode::Down => Some(cycle(cursor, len, 1)),
KeyCode::PageUp => Some(step_clamped(cursor, len, -OVERLAY_PAGE)),
KeyCode::PageDown => Some(step_clamped(cursor, len, OVERLAY_PAGE)),
KeyCode::Home => Some(0),
KeyCode::End => Some(len.saturating_sub(1)),
_ => None,
}
}
#[cfg(test)]
mod tests {
use super::*;
fn flat(saved: usize, cursor: usize, viewport: usize) -> ListScroll {
ListScroll {
saved,
cursor,
row_count: 20,
viewport,
first_entry_row: None,
}
}
#[test]
fn the_offset_stays_put_while_the_cursor_is_still_visible() {
assert_eq!(settled_offset(&flat(4, 5, 5)), 4);
assert_eq!(settled_offset(&flat(4, 4, 5)), 4);
}
#[test]
fn the_list_scrolls_up_only_once_the_cursor_crosses_the_top_edge() {
assert_eq!(settled_offset(&flat(4, 3, 5)), 3);
}
#[test]
fn the_list_pages_down_at_the_bottom_edge() {
assert_eq!(settled_offset(&flat(4, 9, 5)), 5);
}
#[test]
fn a_grouped_list_snaps_to_the_top_rather_than_hiding_only_a_header() {
let scroll = ListScroll {
saved: 1,
cursor: 1,
row_count: 10,
viewport: 5,
first_entry_row: Some(2),
};
assert_eq!(settled_offset(&scroll), 0);
}
#[test]
fn a_flat_list_never_snaps_to_the_top() {
let scroll = ListScroll {
saved: 1,
cursor: 1,
row_count: 10,
viewport: 5,
first_entry_row: None,
};
assert_eq!(settled_offset(&scroll), 1);
}
fn press(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, crossterm::event::KeyModifiers::NONE)
}
#[test]
fn the_arrows_wrap_at_both_ends() {
assert_eq!(moved_cursor(press(KeyCode::Up), 0, 5), Some(4));
assert_eq!(moved_cursor(press(KeyCode::Down), 4, 5), Some(0));
}
#[test]
fn page_jumps_and_home_end_clamp_rather_than_wrapping() {
assert_eq!(moved_cursor(press(KeyCode::PageUp), 2, 100), Some(0));
assert_eq!(moved_cursor(press(KeyCode::PageDown), 98, 100), Some(99));
assert_eq!(moved_cursor(press(KeyCode::Home), 40, 100), Some(0));
assert_eq!(moved_cursor(press(KeyCode::End), 40, 100), Some(99));
}
#[test]
fn an_empty_list_stays_at_index_zero() {
for code in [
KeyCode::Up,
KeyCode::Down,
KeyCode::PageUp,
KeyCode::PageDown,
KeyCode::Home,
KeyCode::End,
] {
assert_eq!(moved_cursor(press(code), 0, 0), Some(0));
}
}
#[test]
fn a_non_navigation_key_is_left_to_the_caller() {
assert_eq!(moved_cursor(press(KeyCode::Char('x')), 1, 5), None);
assert_eq!(moved_cursor(press(KeyCode::Enter), 1, 5), None);
assert_eq!(moved_cursor(press(KeyCode::Esc), 1, 5), None);
}
#[test]
fn the_slug_column_is_floored_at_the_header_and_capped() {
assert_eq!(slug_column_width(0), SLUG_HEADER.len());
assert_eq!(slug_column_width(2), SLUG_HEADER.len());
assert_eq!(slug_column_width(12), 12);
assert_eq!(slug_column_width(99), SLUG_MAX);
}
}