use ratatui::crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use crate::view::{Action, Motion, Typing};
pub(super) struct Key {
pub(super) code: KeyCode,
pub(super) control: bool,
pub(super) named: &'static str,
}
const fn alone(code: KeyCode, named: &'static str) -> Key {
Key {
code,
control: false,
named,
}
}
const fn ctrl(code: char, named: &'static str) -> Key {
Key {
code: KeyCode::Char(code),
control: true,
named,
}
}
pub(super) struct Binding {
pub(super) keys: &'static [Key],
pub(super) action: Action,
pub(super) does: &'static str,
pub(super) hint: Option<&'static str>,
}
pub(super) const BINDINGS: &[Binding] = &[
Binding {
keys: &[alone(KeyCode::Enter, "Enter")],
action: Action::ShowBead,
does: "show the selected bead, or focus its pane from the bead view",
hint: None,
},
Binding {
keys: &[alone(KeyCode::Char('f'), "f")],
action: Action::Focus,
does: "focus the selected bead's pane",
hint: None,
},
Binding {
keys: &[alone(KeyCode::Char(' '), "Space")],
action: Action::ToggleFold,
does: "fold or unfold the selected node",
hint: None,
},
Binding {
keys: &[alone(KeyCode::Char('a'), "a")],
action: Action::ToggleFilter,
does: "show every tree, not only those with a live agent",
hint: Some("all"),
},
Binding {
keys: &[alone(KeyCode::Char('?'), "?")],
action: Action::ShowBindings,
does: "show these key bindings",
hint: Some("keys"),
},
Binding {
keys: &[alone(KeyCode::Char('F'), "F")],
action: Action::FocusForest,
does: "draw the selected bead as the only root, or put the forest back",
hint: None,
},
Binding {
keys: &[alone(KeyCode::Char('/'), "/")],
action: Action::Search,
does: "find part of a bead's id or title, wherever the forest draws it",
hint: Some("find"),
},
Binding {
keys: &[alone(KeyCode::Char('n'), "n")],
action: Action::NextMatch,
does: "go to the next bead matching the search",
hint: None,
},
Binding {
keys: &[alone(KeyCode::Char('N'), "N")],
action: Action::PreviousMatch,
does: "go to the one before it",
hint: None,
},
Binding {
keys: &[alone(KeyCode::Char('q'), "q"), ctrl('c', "^C")],
action: Action::Quit,
does: "quit",
hint: Some("quit"),
},
Binding {
keys: &[alone(KeyCode::Esc, "Esc")],
action: Action::Back,
does: "go back to the forest from the bead view",
hint: None,
},
Binding {
keys: &[alone(KeyCode::Tab, "Tab")],
action: Action::NextRelated,
does: "move to the next bead the shown bead names; Enter follows it",
hint: None,
},
Binding {
keys: &[ctrl('r', "^R")],
action: Action::Refresh,
does: "collect from the trackers again now",
hint: None,
},
Binding {
keys: &[alone(KeyCode::Char('e'), "e")],
action: Action::ExpandSubtree,
does: "expand the selected node and everything under it",
hint: None,
},
Binding {
keys: &[alone(KeyCode::Char('E'), "E")],
action: Action::ExpandForest,
does: "expand the whole forest",
hint: None,
},
Binding {
keys: &[alone(KeyCode::Char('c'), "c")],
action: Action::CollapseSubtree,
does: "collapse the selected node and everything under it",
hint: None,
},
Binding {
keys: &[alone(KeyCode::Char('C'), "C")],
action: Action::CollapseForest,
does: "collapse the whole forest",
hint: None,
},
Binding {
keys: &[alone(KeyCode::Char('d'), "d")],
action: Action::RestoreSubtree,
does: "restore the default folds under the selected node",
hint: None,
},
Binding {
keys: &[alone(KeyCode::Char('D'), "D")],
action: Action::RestoreDefault,
does: "restore the default folds across the whole forest",
hint: None,
},
Binding {
keys: &[alone(KeyCode::Char('y'), "y")],
action: Action::CopyId,
does: "copy the selected bead's id to the clipboard",
hint: None,
},
Binding {
keys: &[alone(KeyCode::Down, "Down"), alone(KeyCode::Char('j'), "j")],
action: Action::Move(Motion::NextRow),
does: "move down one row",
hint: None,
},
Binding {
keys: &[alone(KeyCode::Up, "Up"), alone(KeyCode::Char('k'), "k")],
action: Action::Move(Motion::PreviousRow),
does: "move up one row",
hint: None,
},
Binding {
keys: &[
alone(KeyCode::Right, "Right"),
alone(KeyCode::Char('l'), "l"),
],
action: Action::ExpandOrChild,
does: "expand, or move to the first child when it is already expanded",
hint: None,
},
Binding {
keys: &[alone(KeyCode::Left, "Left"), alone(KeyCode::Char('h'), "h")],
action: Action::CollapseOrParent,
does: "collapse, or move to the parent when it is already collapsed",
hint: None,
},
Binding {
keys: &[ctrl('d', "^D"), alone(KeyCode::PageDown, "PgDn")],
action: Action::Move(Motion::HalfScreenDown),
does: "move down half a screen",
hint: None,
},
Binding {
keys: &[ctrl('u', "^U"), alone(KeyCode::PageUp, "PgUp")],
action: Action::Move(Motion::HalfScreenUp),
does: "move up half a screen",
hint: None,
},
Binding {
keys: &[alone(KeyCode::Home, "Home"), alone(KeyCode::Char('g'), "g")],
action: Action::Move(Motion::FirstRow),
does: "move to the first row",
hint: None,
},
Binding {
keys: &[alone(KeyCode::End, "End"), alone(KeyCode::Char('G'), "G")],
action: Action::Move(Motion::LastRow),
does: "move to the last row",
hint: None,
},
];
pub(super) fn action(key: KeyEvent) -> Option<Action> {
let control = key.modifiers.contains(KeyModifiers::CONTROL);
BINDINGS
.iter()
.find(|binding| {
binding
.keys
.iter()
.any(|bound| bound.code == key.code && bound.control == control)
})
.map(|binding| binding.action)
}
pub(super) fn typing(key: KeyEvent) -> Option<Typing> {
if key.modifiers.contains(KeyModifiers::CONTROL) {
return None;
}
match key.code {
KeyCode::Char(glyph) => Some(Typing::Character(glyph)),
KeyCode::Backspace => Some(Typing::RubbedOut),
KeyCode::Enter => Some(Typing::Sought),
KeyCode::Esc => Some(Typing::Abandoned),
_ => None,
}
}
pub(super) fn bindings() -> Vec<(String, &'static str)> {
BINDINGS
.iter()
.map(|binding| {
(
binding
.keys
.iter()
.map(|key| key.named)
.collect::<Vec<_>>()
.join(", "),
binding.does,
)
})
.collect()
}
pub(super) fn key_row() -> String {
BINDINGS
.iter()
.filter_map(|binding| {
let word = binding.hint?;
Some(format!("{} {word}", binding.keys.first()?.named))
})
.collect::<Vec<_>>()
.join(" ")
}
#[cfg(test)]
pub(super) mod tests {
use super::*;
pub(in crate::tui) fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
pub(in crate::tui) fn control(code: char) -> KeyEvent {
KeyEvent::new(KeyCode::Char(code), KeyModifiers::CONTROL)
}
fn every_action() -> Vec<Action> {
let every = vec![
Action::Move(Motion::PreviousRow),
Action::Move(Motion::NextRow),
Action::Move(Motion::HalfScreenUp),
Action::Move(Motion::HalfScreenDown),
Action::Move(Motion::FirstRow),
Action::Move(Motion::LastRow),
Action::CollapseOrParent,
Action::ExpandOrChild,
Action::ToggleFold,
Action::ExpandSubtree,
Action::CollapseSubtree,
Action::RestoreSubtree,
Action::ExpandForest,
Action::CollapseForest,
Action::RestoreDefault,
Action::ToggleFilter,
Action::Focus,
Action::FocusForest,
Action::ShowBead,
Action::NextRelated,
Action::Back,
Action::CopyId,
Action::ShowBindings,
Action::Search,
Action::NextMatch,
Action::PreviousMatch,
Action::Refresh,
Action::Quit,
];
for action in &every {
match action {
Action::Move(motion) => match motion {
Motion::PreviousRow
| Motion::NextRow
| Motion::HalfScreenUp
| Motion::HalfScreenDown
| Motion::FirstRow
| Motion::LastRow => (),
},
Action::CollapseOrParent
| Action::ExpandOrChild
| Action::ToggleFold
| Action::ExpandSubtree
| Action::CollapseSubtree
| Action::RestoreSubtree
| Action::ExpandForest
| Action::CollapseForest
| Action::RestoreDefault
| Action::ToggleFilter
| Action::Focus
| Action::FocusForest
| Action::ShowBead
| Action::NextRelated
| Action::Back
| Action::CopyId
| Action::ShowBindings
| Action::Search
| Action::NextMatch
| Action::PreviousMatch
| Action::Refresh
| Action::Quit => (),
}
}
every
}
#[test]
fn every_action_has_a_key_that_asks_for_it() {
for action in every_action() {
assert!(
BINDINGS.iter().any(|binding| binding.action == action),
"{action:?} is bound to no key"
);
}
}
#[test]
fn no_key_is_named_with_anything_a_keyboard_does_not_carry() {
for binding in BINDINGS {
for bound in binding.keys {
assert!(!bound.named.is_empty(), "a key with no name");
assert!(
bound
.named
.chars()
.all(|glyph| glyph.is_ascii_graphic() || glyph == ' '),
"{:?} is not a name anyone can press",
bound.named
);
}
}
}
#[test]
fn the_row_under_the_tail_names_its_keys_as_the_mapping_does() {
let row = key_row();
for binding in BINDINGS {
let Some(word) = binding.hint else { continue };
let named = binding.keys.first().expect("a key").named;
assert!(
row.contains(&format!("{named} {word}")),
"{named} {word} missing from {row:?}"
);
}
assert!(row.contains("? keys"), "the way to the rest: {row:?}");
}
#[test]
fn the_mapping_answers_no_key_the_table_does_not_name() {
let named: Vec<&Key> = BINDINGS.iter().flat_map(|binding| binding.keys).collect();
let swept = (' '..='~')
.flat_map(|glyph| [key(KeyCode::Char(glyph)), control(glyph)])
.chain([
key(KeyCode::Up),
key(KeyCode::Down),
key(KeyCode::Left),
key(KeyCode::Right),
key(KeyCode::Enter),
key(KeyCode::Tab),
key(KeyCode::Esc),
key(KeyCode::Backspace),
key(KeyCode::Home),
key(KeyCode::End),
key(KeyCode::PageUp),
key(KeyCode::PageDown),
]);
for pressed in swept {
let control = pressed.modifiers.contains(KeyModifiers::CONTROL);
let expected = named
.iter()
.any(|bound| bound.code == pressed.code && bound.control == control);
assert_eq!(
action(pressed).is_some(),
expected,
"the table and the mapping disagree about {pressed:?}"
);
}
}
#[test]
fn every_binding_reaches_the_action_it_names() {
let bound = [
(key(KeyCode::Char('j')), Action::Move(Motion::NextRow)),
(key(KeyCode::Down), Action::Move(Motion::NextRow)),
(key(KeyCode::Char('k')), Action::Move(Motion::PreviousRow)),
(key(KeyCode::Up), Action::Move(Motion::PreviousRow)),
(key(KeyCode::Char('h')), Action::CollapseOrParent),
(key(KeyCode::Left), Action::CollapseOrParent),
(key(KeyCode::Char('l')), Action::ExpandOrChild),
(key(KeyCode::Right), Action::ExpandOrChild),
(key(KeyCode::Char('g')), Action::Move(Motion::FirstRow)),
(key(KeyCode::Home), Action::Move(Motion::FirstRow)),
(key(KeyCode::Char('G')), Action::Move(Motion::LastRow)),
(key(KeyCode::End), Action::Move(Motion::LastRow)),
(control('d'), Action::Move(Motion::HalfScreenDown)),
(control('u'), Action::Move(Motion::HalfScreenUp)),
(key(KeyCode::PageDown), Action::Move(Motion::HalfScreenDown)),
(key(KeyCode::PageUp), Action::Move(Motion::HalfScreenUp)),
(key(KeyCode::Char(' ')), Action::ToggleFold),
(key(KeyCode::Enter), Action::ShowBead),
(key(KeyCode::Char('f')), Action::Focus),
(key(KeyCode::Esc), Action::Back),
(key(KeyCode::Char('y')), Action::CopyId),
(key(KeyCode::Char('a')), Action::ToggleFilter),
(control('r'), Action::Refresh),
(key(KeyCode::Char('/')), Action::Search),
(key(KeyCode::Char('n')), Action::NextMatch),
(key(KeyCode::Char('N')), Action::PreviousMatch),
(key(KeyCode::Char('?')), Action::ShowBindings),
(key(KeyCode::Char('e')), Action::ExpandSubtree),
(key(KeyCode::Char('E')), Action::ExpandForest),
(key(KeyCode::Char('c')), Action::CollapseSubtree),
(key(KeyCode::Char('C')), Action::CollapseForest),
(key(KeyCode::Char('d')), Action::RestoreSubtree),
(key(KeyCode::Char('D')), Action::RestoreDefault),
(key(KeyCode::Char('q')), Action::Quit),
(control('c'), Action::Quit),
];
for (pressed, expected) in bound {
assert_eq!(action(pressed), Some(expected), "for {pressed:?}");
}
}
#[test]
fn every_key_of_an_id_is_a_character_of_it_while_the_prompt_is_up() {
for glyph in ('!'..='~').chain([' ']) {
assert_eq!(
typing(key(KeyCode::Char(glyph))),
Some(Typing::Character(glyph)),
"{glyph:?} is not a character of an id"
);
}
}
#[test]
fn the_prompt_answers_the_keys_that_work_a_prompt_and_leaves_control_alone() {
assert_eq!(typing(key(KeyCode::Backspace)), Some(Typing::RubbedOut));
assert_eq!(typing(key(KeyCode::Enter)), Some(Typing::Sought));
assert_eq!(typing(key(KeyCode::Esc)), Some(Typing::Abandoned));
assert_eq!(typing(key(KeyCode::Up)), None, "a motion is not typing");
assert_eq!(typing(control('c')), None, "^C is not a character of an id");
assert_eq!(action(control('c')), Some(Action::Quit));
}
#[test]
fn a_key_bound_to_nothing_asks_for_nothing() {
for pressed in [
key(KeyCode::Char('u')),
key(KeyCode::Char('r')),
key(KeyCode::Char('z')),
] {
assert_eq!(action(pressed), None, "for {pressed:?}");
}
}
#[test]
fn a_control_key_does_not_answer_for_the_letter_under_it() {
assert_ne!(action(control('c')), action(key(KeyCode::Char('c'))));
assert_ne!(action(control('d')), action(key(KeyCode::Char('d'))));
}
}