use crate::runtime::{KeyCode, KeyEvent, Step};
#[must_use]
pub fn step_enabled(
len: usize,
from: usize,
direction: Step,
disabled: impl Fn(usize) -> bool,
) -> usize {
let next = match direction {
Step::Forward => (from.saturating_add(1)..len).find(|&i| !disabled(i)),
Step::Backward => (0..from.min(len)).rev().find(|&i| !disabled(i)),
};
next.unwrap_or(from)
}
#[must_use]
pub fn first_enabled(len: usize, disabled: impl Fn(usize) -> bool) -> Option<usize> {
(0..len).find(|&i| !disabled(i))
}
#[must_use]
pub fn last_enabled(len: usize, disabled: impl Fn(usize) -> bool) -> Option<usize> {
(0..len).rev().find(|&i| !disabled(i))
}
#[must_use]
pub fn page_enabled(
len: usize,
from: usize,
direction: Step,
page_size: usize,
disabled: impl Fn(usize) -> bool,
) -> usize {
if len == 0 {
return from;
}
let from = from.min(len - 1);
let target = match direction {
Step::Forward => from.saturating_add(page_size).min(len - 1),
Step::Backward => from.saturating_sub(page_size),
};
match direction {
Step::Forward => (target..len)
.find(|&index| !disabled(index))
.or_else(|| last_enabled(len, disabled))
.unwrap_or(from),
Step::Backward => (0..=target)
.rev()
.find(|&index| !disabled(index))
.or_else(|| first_enabled(len, disabled))
.unwrap_or(from),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum NavOutcome {
Move(usize),
Stay,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
enum NavMove {
Step(Step),
Edge(Step),
Page(Step),
HalfPage(Step),
}
fn nav_move(key: KeyEvent) -> Option<NavMove> {
if key.modifiers.alt || key.modifiers.shift {
return None;
}
if key.modifiers.ctrl {
return match key.code {
KeyCode::Char('n') => Some(NavMove::Step(Step::Forward)),
KeyCode::Char('p') => Some(NavMove::Step(Step::Backward)),
KeyCode::Char('d') => Some(NavMove::HalfPage(Step::Forward)),
KeyCode::Char('u') => Some(NavMove::HalfPage(Step::Backward)),
_ => None,
};
}
match key.code {
KeyCode::Up | KeyCode::Char('k') => Some(NavMove::Step(Step::Backward)),
KeyCode::Down | KeyCode::Char('j') => Some(NavMove::Step(Step::Forward)),
KeyCode::Home => Some(NavMove::Edge(Step::Backward)),
KeyCode::End => Some(NavMove::Edge(Step::Forward)),
KeyCode::PageUp => Some(NavMove::Page(Step::Backward)),
KeyCode::PageDown => Some(NavMove::Page(Step::Forward)),
_ => None,
}
}
#[must_use]
pub fn is_step_key(key: KeyEvent) -> bool {
matches!(nav_move(key), Some(NavMove::Step(_)))
}
#[must_use]
pub fn has_reserved_modifier(key: KeyEvent) -> bool {
key.modifiers.any()
}
#[must_use]
pub fn nav_key_target(
key: KeyEvent,
len: usize,
cursor: Option<usize>,
page_size: usize,
disabled: impl Fn(usize) -> bool,
) -> Option<NavOutcome> {
let movement = nav_move(key)?;
let Some(cursor) = cursor else {
return first_enabled(len, &disabled).map(NavOutcome::Move);
};
let half = (page_size / 2).max(1);
let target = match movement {
NavMove::Step(direction) => step_enabled(len, cursor, direction, &disabled),
NavMove::Edge(Step::Backward) => first_enabled(len, &disabled)?,
NavMove::Edge(Step::Forward) => last_enabled(len, &disabled)?,
NavMove::Page(direction) => page_enabled(len, cursor, direction, page_size, &disabled),
NavMove::HalfPage(direction) => page_enabled(len, cursor, direction, half, &disabled),
};
Some(if target == cursor {
NavOutcome::Stay
} else {
NavOutcome::Move(target)
})
}
#[must_use]
pub fn cursor_visible_offset(
len: usize,
viewport_height: usize,
requested: usize,
cursor: Option<usize>,
) -> usize {
let offset = clamp_scroll_offset(len, viewport_height, requested);
let Some(cursor) = cursor else {
return offset;
};
if viewport_height == 0 {
offset
} else if cursor < offset {
cursor
} else if cursor >= offset.saturating_add(viewport_height) {
cursor.saturating_add(1).saturating_sub(viewport_height)
} else {
offset
}
}
#[must_use]
pub fn clamp_scroll_offset(len: usize, viewport_height: usize, offset: usize) -> usize {
offset.min(len.saturating_sub(viewport_height))
}
#[must_use]
pub fn wheel_offset(
len: usize,
viewport_height: usize,
current: usize,
direction: ScrollStep,
step: usize,
) -> usize {
match direction {
ScrollStep::Up => clamp_scroll_offset(len, viewport_height, current.saturating_sub(step)),
ScrollStep::Down => clamp_scroll_offset(len, viewport_height, current.saturating_add(step)),
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub enum ScrollStep {
Up,
Down,
}
#[must_use]
pub fn index_at_row(len: usize, scroll_offset: usize, local_row: usize) -> Option<usize> {
let index = scroll_offset.checked_add(local_row)?;
(index < len).then_some(index)
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn step_enabled_skips_disabled_indices() {
let disabled = |i: usize| i == 1 || i == 2;
assert_eq!(step_enabled(4, 0, Step::Forward, disabled), 3);
assert_eq!(step_enabled(4, 3, Step::Backward, disabled), 0);
}
#[test]
fn step_enabled_stays_put_at_the_edge() {
let none_disabled = |_: usize| false;
assert_eq!(step_enabled(3, 2, Step::Forward, none_disabled), 2);
assert_eq!(step_enabled(3, 0, Step::Backward, none_disabled), 0);
let all_but_one = |i: usize| i != 1;
assert_eq!(step_enabled(3, 1, Step::Forward, all_but_one), 1);
assert_eq!(step_enabled(3, 1, Step::Backward, all_but_one), 1);
}
#[test]
fn step_enabled_steps_off_a_parked_disabled_index() {
let disabled = |i: usize| i == 1;
assert_eq!(step_enabled(3, 1, Step::Forward, disabled), 2);
assert_eq!(step_enabled(3, 1, Step::Backward, disabled), 0);
}
#[test]
fn first_and_last_enabled_find_the_edges_or_none() {
let disabled = |i: usize| i == 0 || i == 3;
assert_eq!(first_enabled(4, disabled), Some(1));
assert_eq!(last_enabled(4, disabled), Some(2));
assert_eq!(first_enabled(0, |_| false), None);
assert_eq!(first_enabled(3, |_| true), None);
assert_eq!(last_enabled(3, |_| true), None);
}
#[test]
fn page_enabled_moves_by_rows_and_clamps_to_enabled_edges() {
let disabled = |i: usize| i == 0 || i == 4;
assert_eq!(page_enabled(5, 1, Step::Forward, 3, disabled), 3);
assert_eq!(page_enabled(5, 3, Step::Backward, 3, disabled), 1);
}
#[test]
fn page_enabled_clamps_out_of_range_origins_before_scanning() {
let in_range = |index: usize| {
assert!(index < 3);
false
};
assert_eq!(page_enabled(3, usize::MAX, Step::Forward, 1, in_range), 2);
assert_eq!(page_enabled(3, usize::MAX, Step::Backward, 1, in_range), 1);
}
#[test]
fn clamp_scroll_offset_caps_at_the_viewport_aware_maximum() {
assert_eq!(clamp_scroll_offset(10, 4, 0), 0);
assert_eq!(clamp_scroll_offset(10, 4, 6), 6);
assert_eq!(clamp_scroll_offset(10, 4, 99), 6);
assert_eq!(clamp_scroll_offset(10, 0, 99), 10);
}
#[test]
fn wheel_offset_clamps_at_zero_and_the_viewport_aware_maximum() {
assert_eq!(wheel_offset(10, 4, 0, ScrollStep::Up, 3), 0);
assert_eq!(wheel_offset(10, 4, 5, ScrollStep::Down, 3), 6);
assert_eq!(wheel_offset(10, 4, 2, ScrollStep::Down, 3), 5);
assert_eq!(wheel_offset(10, 4, 2, ScrollStep::Up, 3), 0);
}
#[test]
fn wheel_offset_with_no_viewport_height_clamps_to_the_last_item() {
assert_eq!(wheel_offset(10, 0, 0, ScrollStep::Down, 100), 10);
}
#[test]
fn wheel_offset_saturates_before_clamping() {
assert_eq!(wheel_offset(10, 4, usize::MAX, ScrollStep::Down, 1), 6);
}
#[test]
fn wheel_offset_clamps_an_out_of_range_current_offset() {
assert_eq!(wheel_offset(10, 4, 99, ScrollStep::Up, 1), 6);
}
#[test]
fn index_at_row_respects_scroll_offset_and_item_count() {
assert_eq!(index_at_row(10, 0, 0), Some(0));
assert_eq!(index_at_row(10, 4, 2), Some(6));
assert_eq!(index_at_row(10, 8, 2), None);
}
#[test]
fn index_at_row_returns_none_on_overflow() {
assert_eq!(index_at_row(usize::MAX, usize::MAX, 1), None);
}
#[test]
fn nav_key_target_maps_each_navigation_key() {
let none = |_: usize| false;
assert_eq!(
nav_key_target(KeyCode::Down.into(), 5, Some(1), 2, none),
Some(NavOutcome::Move(2))
);
assert_eq!(
nav_key_target(KeyCode::Up.into(), 5, Some(1), 2, none),
Some(NavOutcome::Move(0))
);
assert_eq!(
nav_key_target(KeyCode::Home.into(), 5, Some(3), 2, none),
Some(NavOutcome::Move(0))
);
assert_eq!(
nav_key_target(KeyCode::End.into(), 5, Some(3), 2, none),
Some(NavOutcome::Move(4))
);
assert_eq!(
nav_key_target(KeyCode::PageDown.into(), 5, Some(0), 2, none),
Some(NavOutcome::Move(2))
);
assert_eq!(
nav_key_target(KeyCode::PageUp.into(), 5, Some(4), 2, none),
Some(NavOutcome::Move(2))
);
}
#[test]
fn nav_key_target_with_nowhere_new_to_go_stays() {
let none = |_: usize| false;
assert_eq!(
nav_key_target(KeyCode::Up.into(), 3, Some(0), 1, none),
Some(NavOutcome::Stay)
);
assert_eq!(
nav_key_target(KeyCode::Home.into(), 3, Some(0), 1, none),
Some(NavOutcome::Stay)
);
}
#[test]
fn nav_key_target_without_a_cursor_lands_on_the_first_enabled_item() {
let disabled = |i: usize| i == 0;
assert_eq!(
nav_key_target(KeyCode::Down.into(), 3, None, 1, disabled),
Some(NavOutcome::Move(1))
);
assert_eq!(
nav_key_target(KeyCode::End.into(), 3, None, 1, disabled),
Some(NavOutcome::Move(1))
);
}
#[test]
fn nav_key_target_ignores_commit_keys_and_all_disabled_lists() {
assert_eq!(
nav_key_target(KeyCode::Enter.into(), 3, Some(0), 1, |_| false),
None
);
assert_eq!(
nav_key_target(KeyCode::Down.into(), 3, None, 1, |_| true),
None
);
assert_eq!(
nav_key_target(KeyCode::Home.into(), 3, Some(1), 1, |_| true),
None
);
}
#[test]
fn cursor_visible_offset_pulls_the_viewport_to_the_cursor() {
assert_eq!(cursor_visible_offset(10, 4, 0, None), 0);
assert_eq!(cursor_visible_offset(10, 4, 5, Some(2)), 2);
assert_eq!(cursor_visible_offset(10, 4, 0, Some(6)), 3);
assert_eq!(cursor_visible_offset(10, 4, 2, Some(3)), 2);
assert_eq!(cursor_visible_offset(10, 4, 99, None), 6);
}
#[test]
fn cursor_visible_offset_with_no_viewport_keeps_the_clamped_offset() {
assert_eq!(cursor_visible_offset(10, 0, 3, Some(7)), 3);
}
}