use retroglyph_core::event::{Event, KeyCode};
#[derive(Clone, Copy, Debug, Default, PartialEq, Eq)]
pub struct MenuState {
selected: Option<usize>,
offset: usize,
}
impl MenuState {
#[must_use]
pub const fn new() -> Self {
Self {
selected: None,
offset: 0,
}
}
#[must_use]
pub const fn is_open(&self) -> bool {
self.selected.is_some()
}
#[must_use]
pub const fn selected(&self) -> Option<usize> {
self.selected
}
#[must_use]
pub const fn offset(&self) -> usize {
self.offset
}
pub const fn set_offset(&mut self, offset: usize) {
self.offset = offset;
}
pub const fn select(&mut self, index: Option<usize>) {
match index {
Some(_) => self.selected = index,
None => self.close(),
}
}
pub const fn close(&mut self) {
self.selected = None;
self.offset = 0;
}
pub fn open(&mut self, len: usize, selectable: impl Fn(usize) -> bool) {
if let Some(first) = first_selectable(len, &selectable) {
self.selected = Some(first);
self.offset = 0;
}
}
pub fn toggle(&mut self, len: usize, selectable: impl Fn(usize) -> bool) {
if self.is_open() {
self.close();
} else {
self.open(len, selectable);
}
}
pub fn select_next(&mut self, len: usize, selectable: impl Fn(usize) -> bool) {
if self.selected.is_none() {
return;
}
self.selected = step(self.selected, 1, len, &selectable);
}
pub fn select_previous(&mut self, len: usize, selectable: impl Fn(usize) -> bool) {
if self.selected.is_none() {
return;
}
self.selected = step(self.selected, -1, len, &selectable);
}
pub const fn ensure_visible(&mut self, visible_height: usize) {
let Some(selected) = self.selected else {
return;
};
if visible_height == 0 {
return;
}
if selected < self.offset {
self.offset = selected;
} else if selected >= self.offset.saturating_add(visible_height) {
self.offset = selected.saturating_add(1).saturating_sub(visible_height);
}
}
pub fn scroll_by(&mut self, delta: i32) {
let next = i64::from(delta).saturating_add(i64::try_from(self.offset).unwrap_or(i64::MAX));
self.offset = next.max(0).try_into().unwrap_or(usize::MAX);
}
pub fn handle_event(
&mut self,
event: &Event,
len: usize,
selectable: impl Fn(usize) -> bool,
) -> Option<usize> {
if !self.is_open() {
return None;
}
let Event::Key(key) = event else {
return None;
};
if !key.is_down() {
return None;
}
match key.code {
KeyCode::Up => {
self.select_previous(len, selectable);
None
}
KeyCode::Down => {
self.select_next(len, selectable);
None
}
KeyCode::Enter => {
let index = self.selected.filter(|&i| selectable(i))?;
self.close();
Some(index)
}
KeyCode::Escape => {
self.close();
None
}
_ => None,
}
}
}
fn first_selectable(len: usize, selectable: &impl Fn(usize) -> bool) -> Option<usize> {
(0..len).find(|&i| selectable(i))
}
fn last_selectable(len: usize, selectable: &impl Fn(usize) -> bool) -> Option<usize> {
(0..len).rev().find(|&i| selectable(i))
}
fn step(
current: Option<usize>,
delta: i32,
len: usize,
selectable: &impl Fn(usize) -> bool,
) -> Option<usize> {
if len == 0 {
return None;
}
let Some(start) = current else {
return if delta > 0 {
first_selectable(len, selectable)
} else {
last_selectable(len, selectable)
};
};
let Ok(len) = i32::try_from(len) else {
return current; };
let mut i = i32::try_from(start).unwrap_or(0);
loop {
i += delta;
if i < 0 || i >= len {
return current; }
#[allow(clippy::cast_sign_loss)]
let idx = i as usize;
if selectable(idx) {
return Some(idx);
}
}
}
#[cfg(test)]
mod tests {
use super::*;
fn selectable_at(indices: &'static [usize]) -> impl Fn(usize) -> bool {
move |i| indices.contains(&i)
}
#[test]
fn starts_closed_with_nothing_selected() {
let s = MenuState::new();
assert!(!s.is_open());
assert_eq!(s.selected(), None);
assert_eq!(s.offset(), 0);
}
#[test]
fn open_skips_a_leading_separator() {
let mut s = MenuState::new();
s.open(3, selectable_at(&[1, 2]));
assert!(s.is_open());
assert_eq!(s.selected(), Some(1));
}
#[test]
fn an_empty_menu_never_opens() {
let mut s = MenuState::new();
s.open(3, selectable_at(&[])); assert!(!s.is_open());
assert_eq!(s.selected(), None);
}
#[test]
fn toggle_opens_then_closes() {
let mut s = MenuState::new();
s.toggle(2, selectable_at(&[0, 1]));
assert!(s.is_open());
s.toggle(2, selectable_at(&[0, 1]));
assert!(!s.is_open());
assert_eq!(s.selected(), None);
}
#[test]
fn select_next_and_previous_skip_separators() {
let mut s = MenuState::new();
let selectable = selectable_at(&[0, 2]);
s.open(3, &selectable);
assert_eq!(s.selected(), Some(0));
s.select_next(3, &selectable);
assert_eq!(s.selected(), Some(2)); s.select_next(3, &selectable);
assert_eq!(s.selected(), Some(2)); s.select_previous(3, &selectable);
assert_eq!(s.selected(), Some(0));
s.select_previous(3, &selectable);
assert_eq!(s.selected(), Some(0)); }
#[test]
fn label_rows_are_never_selected_by_navigation() {
let mut s = MenuState::new();
let selectable = selectable_at(&[1, 3]);
s.open(4, &selectable);
assert_eq!(s.selected(), Some(1));
s.select_next(4, &selectable);
assert_eq!(s.selected(), Some(3));
}
#[test]
fn close_clears_selection_and_offset() {
let mut s = MenuState::new();
s.open(2, selectable_at(&[0, 1]));
s.set_offset(5);
s.close();
assert!(!s.is_open());
assert_eq!(s.selected(), None);
assert_eq!(s.offset(), 0);
}
#[test]
fn ensure_visible_scrolls_down_to_reveal_a_later_selection() {
let mut s = MenuState::new();
s.open(20, selectable_at(&[10]));
s.ensure_visible(4);
assert_eq!(s.offset(), 7); }
#[test]
fn scroll_by_is_independent_of_the_selection() {
let mut s = MenuState::new();
s.open(5, selectable_at(&[0]));
s.scroll_by(2);
assert_eq!(s.offset(), 2);
assert_eq!(s.selected(), Some(0)); s.scroll_by(-5);
assert_eq!(s.offset(), 0); }
#[test]
fn escape_closes_without_activating() {
use retroglyph_core::event::{KeyEvent, KeyModifiers};
let mut s = MenuState::new();
s.open(2, selectable_at(&[0, 1]));
let event = Event::Key(KeyEvent::new(KeyCode::Escape, KeyModifiers::NONE));
let activated = s.handle_event(&event, 2, selectable_at(&[0, 1]));
assert_eq!(activated, None);
assert!(!s.is_open());
}
#[test]
fn enter_activates_the_selection_and_closes() {
use retroglyph_core::event::{KeyEvent, KeyModifiers};
let mut s = MenuState::new();
s.open(2, selectable_at(&[0, 1]));
s.select_next(2, selectable_at(&[0, 1]));
let event = Event::Key(KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE));
let activated = s.handle_event(&event, 2, selectable_at(&[0, 1]));
assert_eq!(activated, Some(1));
assert!(!s.is_open()); }
#[test]
fn arrow_keys_navigate_while_open() {
use retroglyph_core::event::{KeyEvent, KeyModifiers};
let mut s = MenuState::new();
s.open(3, selectable_at(&[0, 1, 2]));
let down = Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
s.handle_event(&down, 3, selectable_at(&[0, 1, 2]));
assert_eq!(s.selected(), Some(1));
}
#[test]
fn events_are_ignored_while_closed() {
use retroglyph_core::event::{KeyEvent, KeyModifiers};
let mut s = MenuState::new();
let down = Event::Key(KeyEvent::new(KeyCode::Down, KeyModifiers::NONE));
assert_eq!(s.handle_event(&down, 3, selectable_at(&[0, 1, 2])), None);
assert_eq!(s.selected(), None);
}
#[test]
fn select_next_on_a_closed_menu_is_a_no_op() {
let mut s = MenuState::new();
s.select_next(3, selectable_at(&[0, 1, 2]));
assert!(!s.is_open());
assert_eq!(s.selected(), None);
}
#[test]
fn select_previous_on_a_closed_menu_is_a_no_op() {
let mut s = MenuState::new();
s.select_previous(3, selectable_at(&[0, 1, 2]));
assert!(!s.is_open());
assert_eq!(s.selected(), None);
}
#[test]
fn select_while_closed_does_not_report_a_selection() {
let mut s = MenuState::new();
assert!(!s.is_open());
s.select(Some(2));
assert!(s.is_open());
assert_eq!(s.selected(), Some(2));
}
#[test]
fn select_none_closes() {
let mut s = MenuState::new();
s.open(2, selectable_at(&[0, 1]));
s.set_offset(5);
s.select(None);
assert!(!s.is_open());
assert_eq!(s.selected(), None);
assert_eq!(s.offset(), 0);
}
}