use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use ratatui::layout::{Position, Rect};
#[derive(Clone, Copy, Debug, PartialEq, Eq)]
pub enum Target {
Menu(usize),
Row(usize),
Key(KeyEvent),
}
#[derive(Clone, Debug, Default)]
struct List {
area: Rect,
offset: usize,
heights: Vec<u16>,
}
#[derive(Clone, Debug, Default)]
pub struct Hits {
menu: Vec<(Rect, usize)>,
rows: Vec<(Rect, usize)>,
list: Option<List>,
keys: Vec<(Rect, KeyEvent)>,
measured: Vec<u16>,
main: Rect,
}
impl Hits {
pub fn clear(&mut self) {
*self = Self::default();
}
pub fn main(&mut self, area: Rect) {
self.main = area;
}
pub fn in_main(&self, column: u16, row: u16) -> bool {
self.main.contains(Position::new(column, row))
}
pub fn menu(&mut self, area: Rect, index: usize) {
self.menu.push((area, index));
}
pub fn row(&mut self, area: Rect, index: usize) {
self.rows.push((area, index));
}
pub fn measured(&mut self, heights: Vec<u16>) {
self.measured = heights;
}
pub fn list(&mut self, area: Rect, offset: usize) {
self.list = Some(List {
area,
offset,
heights: std::mem::take(&mut self.measured),
});
}
pub fn key(&mut self, area: Rect, key: KeyEvent) {
self.keys.push((area, key));
}
pub fn at(&self, column: u16, row: u16) -> Option<Target> {
let here = Position::new(column, row);
let inside = |area: &Rect| area.contains(here);
if let Some((_, key)) = self.keys.iter().find(|(area, _)| inside(area)) {
return Some(Target::Key(*key));
}
if let Some((_, index)) = self.menu.iter().find(|(area, _)| inside(area)) {
return Some(Target::Menu(*index));
}
if let Some((_, index)) = self.rows.iter().find(|(area, _)| inside(area)) {
return Some(Target::Row(*index));
}
let list = self.list.as_ref().filter(|list| inside(&list.area))?;
row_at(list.offset, &list.heights, row - list.area.y).map(Target::Row)
}
}
pub fn row_at(offset: usize, heights: &[u16], line: u16) -> Option<usize> {
let mut top = 0u16;
for (index, height) in heights.iter().enumerate().skip(offset) {
let bottom = top.saturating_add(*height);
if line < bottom {
return Some(index);
}
top = bottom;
}
None
}
pub fn key_named(name: &str) -> Option<KeyEvent> {
let plain = |code| Some(KeyEvent::new(code, KeyModifiers::NONE));
let control = |character| {
Some(KeyEvent::new(
KeyCode::Char(character),
KeyModifiers::CONTROL,
))
};
match name {
"Enter" => plain(KeyCode::Enter),
"Esc" => plain(KeyCode::Esc),
"Space" => plain(KeyCode::Char(' ')),
"Tab" => plain(KeyCode::Tab),
"Del" => plain(KeyCode::Delete),
"Up" => plain(KeyCode::Up),
"Down" => plain(KeyCode::Down),
"Page up" => plain(KeyCode::PageUp),
"Page down" => plain(KeyCode::PageDown),
"^K" => control('k'),
"Ctrl-c" => control('c'),
_ => {
let mut characters = name.chars();
match (characters.next(), characters.next()) {
(Some(character), None) if character.is_ascii_graphic() => {
plain(KeyCode::Char(character))
}
_ => None,
}
}
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn a_line_of_a_list_is_the_item_drawn_there() {
let heights = [1, 2, 3, 1];
assert_eq!(row_at(1, &heights, 0), Some(1));
assert_eq!(row_at(1, &heights, 1), Some(1));
assert_eq!(row_at(1, &heights, 2), Some(2));
assert_eq!(row_at(1, &heights, 4), Some(2));
assert_eq!(row_at(1, &heights, 5), Some(3));
assert_eq!(row_at(1, &heights, 6), None);
assert_eq!(row_at(0, &[], 0), None);
}
#[test]
fn a_click_finds_the_hint_then_the_menu_then_the_list() {
let mut hits = Hits::default();
hits.menu(Rect::new(0, 2, 20, 1), 3);
hits.measured(vec![1, 1, 2]);
hits.list(Rect::new(22, 5, 40, 10), 0);
hits.key(Rect::new(0, 20, 5, 1), key_named("Enter").unwrap());
assert_eq!(hits.at(4, 2), Some(Target::Menu(3)));
assert_eq!(hits.at(30, 5), Some(Target::Row(0)));
assert_eq!(hits.at(30, 8), Some(Target::Row(2)));
assert_eq!(hits.at(30, 9), None);
assert_eq!(
hits.at(2, 20),
Some(Target::Key(KeyEvent::new(
KeyCode::Enter,
KeyModifiers::NONE
)))
);
assert_eq!(hits.at(70, 30), None);
hits.clear();
assert_eq!(hits.at(4, 2), None);
}
#[test]
fn hints_name_the_keys_they_send() {
assert_eq!(key_named("x").unwrap().code, KeyCode::Char('x'));
assert_eq!(key_named("Space").unwrap().code, KeyCode::Char(' '));
assert_eq!(key_named("Page down").unwrap().code, KeyCode::PageDown);
let control_k = key_named("^K").unwrap();
assert_eq!(control_k.code, KeyCode::Char('k'));
assert!(control_k.modifiers.contains(KeyModifiers::CONTROL));
assert_eq!(key_named("↑↓"), None);
assert_eq!(key_named("Whatever"), None);
}
}