use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use super::app::{ActionVerb, InputMode, KeyPress};
use super::input::map_key;
use super::textbox::TextEdit;
use crate::vocabulary::Role;
pub(super) const ENTRY_ROWS: usize = 12;
pub(super) const KEY_CELL: u16 = 12;
pub(super) const TEXT_CELL: u16 = 17;
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) enum Group {
Moving,
Looking,
Changing,
Doing,
Closing,
}
impl Group {
pub(super) const DRAWN: [Self; 4] = [Self::Moving, Self::Looking, Self::Changing, Self::Doing];
pub(super) const fn heading(self) -> &'static str {
match self {
Self::Moving => "MOVING",
Self::Looking => "LOOKING",
Self::Changing => "CHANGING",
Self::Doing => "DOING",
Self::Closing => "",
}
}
pub(super) const fn role(self) -> Role {
match self {
Self::Doing => Role::Bark,
Self::Moving | Self::Looking | Self::Changing | Self::Closing => Role::Meadow,
}
}
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(super) struct Binding {
pub keys: &'static str,
pub group: Group,
pub does: &'static str,
}
const fn binding(press: &KeyPress) -> Binding {
const fn row(keys: &'static str, group: Group, does: &'static str) -> Binding {
Binding { keys, group, does }
}
match press {
KeyPress::SelectDown | KeyPress::SelectUp => {
row("j/k \u{2193}/\u{2191}", Group::Moving, "select a row")
}
KeyPress::SelectFirst | KeyPress::SelectLast => {
row("g/G home/end", Group::Moving, "first / last")
}
KeyPress::StepDown | KeyPress::StepUp => row("J/K", Group::Moving, "next / prev sheep"),
KeyPress::PageDown | KeyPress::PageUp => row("ctrl-d/u", Group::Moving, "page the feed"),
KeyPress::TabPrev | KeyPress::TabNext => row(TAB_KEYS, Group::Moving, "environment tab"),
KeyPress::NextGroup | KeyPress::PrevGroup | KeyPress::NextField | KeyPress::PrevField => {
row("tab/S-tab", Group::Moving, "next / prev group")
}
KeyPress::Group(_) => row("1-9", Group::Moving, "jump to a group"),
KeyPress::MatchNext | KeyPress::MatchPrev => row("n/N", Group::Moving, "next / prev match"),
KeyPress::TextEdit(
TextEdit::Left | TextEdit::Right | TextEdit::WordLeft | TextEdit::WordRight,
) => row(
"\u{2190}/\u{2192} alt-b/f",
Group::Moving,
"char / word move",
),
KeyPress::TextEdit(TextEdit::Home | TextEdit::End) => {
row("ctrl-a/e", Group::Moving, "home/end in a box")
}
KeyPress::Confirm => row("\u{21b5}", Group::Moving, "open selection"),
KeyPress::Escape => row("esc", Group::Moving, "back one level"),
KeyPress::Help => row("h ?", Group::Looking, "this keymap"),
KeyPress::Bleats => row("b", Group::Looking, "feed, full screen"),
KeyPress::FoldView => row("F", Group::Looking, "gather by fold"),
KeyPress::Collapse => row("z", Group::Looking, "collapse a fold"),
KeyPress::StreamCycle => row("o", Group::Looking, "out / err / both"),
KeyPress::LevelCycle => row("m", Group::Looking, "minimum level"),
KeyPress::FollowToggle => row("f", Group::Looking, "follow the tail"),
KeyPress::WrapToggle => row("w", Group::Looking, "wrap long lines"),
KeyPress::FilterStart => row("/", Group::Looking, "filter, or match"),
KeyPress::Refresh => row("r", Group::Looking, "refresh now"),
KeyPress::Reveal => row("v", Group::Looking, "reveal for 10s"),
KeyPress::Copy => row("y", Group::Looking, "copy the value"),
KeyPress::Edit => row("e", Group::Changing, "the config pane"),
KeyPress::Cycle => row("space", Group::Changing, "cycle a value"),
KeyPress::Remove => row("d", Group::Changing, "restore default"),
KeyPress::Undo => row("u", Group::Changing, "undo the edit"),
KeyPress::Settings => row("s", Group::Changing, "shepherd settings"),
KeyPress::Secrets => row("S", Group::Changing, "secrets"),
KeyPress::ArmDelete => row("D", Group::Changing, "delete a key"),
KeyPress::Continue => row("c", Group::Changing, "leave it running"),
KeyPress::TextChar(_) => row("any char", Group::Changing, "types into a box"),
KeyPress::TextBackspace
| KeyPress::TextEdit(TextEdit::Delete)
| KeyPress::TextApply
| KeyPress::TextAbandon => row(
"\u{232b}/del \u{21b5} esc",
Group::Changing,
"erase, file, drop",
),
KeyPress::TextEdit(
TextEdit::DeleteWordBack | TextEdit::DeleteToStart | TextEdit::DeleteToEnd,
) => row("ctrl-w/u/k", Group::Changing, "cut word, to ends"),
KeyPress::Action(ActionVerb::Stop) => row("x", Group::Doing, "stop"),
KeyPress::Action(ActionVerb::Restart) => row("R", Group::Doing, "restart"),
KeyPress::Action(ActionVerb::Reload) => row("L", Group::Doing, "reload"),
KeyPress::Quit => row("q ctrl-c", Group::Closing, "quit"),
}
}
const PROBE: &[(KeyCode, KeyModifiers)] = &[
(KeyCode::Char('j'), KeyModifiers::NONE),
(KeyCode::Char('g'), KeyModifiers::NONE),
(KeyCode::Char('J'), KeyModifiers::SHIFT),
(KeyCode::Char('d'), KeyModifiers::CONTROL),
(KeyCode::Left, KeyModifiers::NONE),
(KeyCode::Char('b'), KeyModifiers::ALT),
(KeyCode::Char('a'), KeyModifiers::CONTROL),
(KeyCode::Tab, KeyModifiers::NONE),
(KeyCode::BackTab, KeyModifiers::SHIFT),
(KeyCode::Char('1'), KeyModifiers::NONE),
(KeyCode::Char('n'), KeyModifiers::NONE),
(KeyCode::Enter, KeyModifiers::NONE),
(KeyCode::Esc, KeyModifiers::NONE),
(KeyCode::Char('h'), KeyModifiers::NONE),
(KeyCode::Char('b'), KeyModifiers::NONE),
(KeyCode::Char('F'), KeyModifiers::SHIFT),
(KeyCode::Char('z'), KeyModifiers::NONE),
(KeyCode::Char('o'), KeyModifiers::NONE),
(KeyCode::Char('m'), KeyModifiers::NONE),
(KeyCode::Char('f'), KeyModifiers::NONE),
(KeyCode::Char('w'), KeyModifiers::NONE),
(KeyCode::Char('/'), KeyModifiers::NONE),
(KeyCode::Char('r'), KeyModifiers::NONE),
(KeyCode::Char('v'), KeyModifiers::NONE),
(KeyCode::Char('y'), KeyModifiers::NONE),
(KeyCode::Char('e'), KeyModifiers::NONE),
(KeyCode::Char(' '), KeyModifiers::NONE),
(KeyCode::Char('d'), KeyModifiers::NONE),
(KeyCode::Char('u'), KeyModifiers::NONE),
(KeyCode::Char('s'), KeyModifiers::NONE),
(KeyCode::Char('S'), KeyModifiers::SHIFT),
(KeyCode::Char('D'), KeyModifiers::SHIFT),
(KeyCode::Char('c'), KeyModifiers::NONE),
(KeyCode::Char('a'), KeyModifiers::NONE),
(KeyCode::Backspace, KeyModifiers::NONE),
(KeyCode::Delete, KeyModifiers::NONE),
(KeyCode::Char('w'), KeyModifiers::CONTROL),
(KeyCode::Char('x'), KeyModifiers::NONE),
(KeyCode::Char('R'), KeyModifiers::SHIFT),
(KeyCode::Char('L'), KeyModifiers::SHIFT),
(KeyCode::Char('q'), KeyModifiers::NONE),
];
fn rows_from(probe: &[(KeyCode, KeyModifiers)]) -> Vec<Binding> {
let mut out: Vec<Binding> = Vec::new();
for group in Group::DRAWN.into_iter().chain([Group::Closing]) {
for (code, modifiers) in probe {
let event = Event::Key(KeyEvent::new(*code, *modifiers));
for mode in [InputMode::Normal, InputMode::Text] {
let Some(press) = map_key(&event, mode) else {
continue;
};
let entry = binding(&press);
if entry.group == group && !out.iter().any(|seen| seen.keys == entry.keys) {
out.push(entry);
}
}
}
}
out
}
pub(super) fn rows() -> Vec<Binding> {
rows_from(PROBE)
}
pub(super) fn rows_over(tree: bool) -> Vec<Binding> {
let mut out = rows();
if tree {
for row in &mut out {
if row.keys == TAB_KEYS {
row.does = "close/open table";
}
}
}
out
}
const TAB_KEYS: &str = "\u{2190}/\u{2192}";
#[cfg(test)]
mod tests {
use crossterm::event::{Event, KeyCode, KeyEvent, KeyModifiers};
use super::*;
use crate::output::width::visible_width;
#[test]
fn every_key_map_key_binds_is_in_the_probe() {
let probed: Vec<KeyPress> = PROBE
.iter()
.filter_map(|(code, modifiers)| {
map_key(
&Event::Key(KeyEvent::new(*code, *modifiers)),
InputMode::Normal,
)
})
.chain(PROBE.iter().filter_map(|(code, modifiers)| {
map_key(
&Event::Key(KeyEvent::new(*code, *modifiers)),
InputMode::Text,
)
}))
.collect();
for (code, modifiers) in every_key() {
for mode in [InputMode::Normal, InputMode::Text] {
let Some(press) = map_key(&Event::Key(KeyEvent::new(code, modifiers)), mode) else {
continue;
};
assert!(
probed.iter().any(|seen| same_row(seen, &press)),
"{code:?} with {modifiers:?} in {mode:?} binds {press:?}, \
which no PROBE entry produces"
);
}
}
}
#[test]
fn every_probe_entry_binds_something() {
for (code, modifiers) in PROBE {
let event = Event::Key(KeyEvent::new(*code, *modifiers));
let normal = map_key(&event, InputMode::Normal);
let text = map_key(&event, InputMode::Text);
assert!(
normal.or(text).is_some(),
"{code:?} with {modifiers:?} is in PROBE and binds nothing"
);
}
}
#[test]
fn any_char_survives_losing_its_only_normal_less_entry() {
let (only_normal_less, _) = PROBE
.iter()
.enumerate()
.find(|(_, (code, modifiers))| {
let event = Event::Key(KeyEvent::new(*code, *modifiers));
map_key(&event, InputMode::Normal).is_none()
&& matches!(
map_key(&event, InputMode::Text),
Some(KeyPress::TextChar(_))
)
})
.expect("PROBE needs a letter with no Normal meaning to make this test mean anything");
let without_it: Vec<(KeyCode, KeyModifiers)> = PROBE
.iter()
.enumerate()
.filter(|(index, _)| *index != only_normal_less)
.map(|(_, entry)| *entry)
.collect();
assert!(
rows_from(&without_it)
.iter()
.any(|row| row.keys == "any char"),
"any char disappeared once its only Normal-less representative was gone, so no \
ordinary letter's own Text-mode mapping is reaching it independently"
);
}
#[test]
fn every_group_fits_its_column() {
let rows = rows();
for group in Group::DRAWN {
let count = rows.iter().filter(|row| row.group == group).count();
assert!(
count <= ENTRY_ROWS,
"{group:?} has {count} entries against {ENTRY_ROWS} rows"
);
}
}
#[test]
fn the_rows_are_deduplicated() {
let rows = rows();
let captions: Vec<&str> = rows.iter().map(|row| row.keys).collect();
assert_eq!(rows.len(), 39, "{captions:?}");
assert_eq!(
rows.iter()
.filter(|row| Group::DRAWN.contains(&row.group))
.count(),
38,
"{captions:?}"
);
for (index, row) in rows.iter().enumerate() {
assert!(
!rows[index + 1..].iter().any(|other| other.keys == row.keys),
"{} appears twice",
row.keys
);
}
}
#[test]
fn the_overlay_names_the_keys_that_shipped_not_the_ones_drawn() {
let rows = rows();
assert!(
rows.iter()
.any(|row| row.keys == "b" && row.does.contains("feed")),
"the feed is on `b`, not the design's `l`"
);
assert!(
rows.iter()
.any(|row| row.keys == "S" && row.does.contains("secrets")),
"secrets is on `S`, not the design's `g`"
);
assert!(
!rows.iter().any(|row| row.keys == "l"),
"`l` is unbound and must not appear"
);
}
#[test]
fn a_single_character_caption_names_a_bound_key() {
for row in rows() {
let mut chars = row.keys.chars();
let Some(only) = chars.next() else { continue };
if chars.next().is_some() || !only.is_ascii() {
continue;
}
assert!(
map_key(
&Event::Key(KeyEvent::new(KeyCode::Char(only), KeyModifiers::NONE)),
InputMode::Normal
)
.is_some(),
"the overlay prints `{only}` and `map_key` does not bind it"
);
}
}
#[test]
fn every_drawn_heading_is_a_word_of_its_own() {
for group in Group::DRAWN {
assert!(!group.heading().is_empty(), "{group:?} draws a bank header");
}
assert!(
Group::Closing.heading().is_empty(),
"the quit row draws no bank header, so its heading stays empty"
);
for (index, one) in Group::DRAWN.iter().enumerate() {
for other in &Group::DRAWN[index + 1..] {
assert!(
!one.heading().contains(other.heading())
&& !other.heading().contains(one.heading()),
"{one:?} and {other:?} share a heading substring"
);
}
}
}
#[test]
fn the_cells_fit_their_widths() {
for row in rows().into_iter().chain(rows_over(true)) {
assert!(
(1..=usize::from(KEY_CELL)).contains(&visible_width(row.keys)),
"the `{}` caption is {} cells against {KEY_CELL}",
row.keys,
visible_width(row.keys)
);
assert!(
(1..=usize::from(TEXT_CELL)).contains(&visible_width(row.does)),
"`{}` is {} cells against {TEXT_CELL}",
row.does,
visible_width(row.does)
);
}
}
fn every_key() -> Vec<(KeyCode, KeyModifiers)> {
let mut keys: Vec<KeyCode> = (0x20u8..=0x7E).map(|b| KeyCode::Char(b as char)).collect();
keys.extend([
KeyCode::Esc,
KeyCode::Enter,
KeyCode::Backspace,
KeyCode::Tab,
KeyCode::BackTab,
KeyCode::Up,
KeyCode::Down,
KeyCode::Left,
KeyCode::Right,
KeyCode::Home,
KeyCode::End,
KeyCode::Delete,
]);
keys.extend((1..=12).map(KeyCode::F));
keys.into_iter()
.flat_map(|code| {
[
(code, KeyModifiers::NONE),
(code, KeyModifiers::CONTROL),
(code, KeyModifiers::SHIFT),
(code, KeyModifiers::ALT),
]
})
.collect()
}
fn same_row(left: &KeyPress, right: &KeyPress) -> bool {
binding(left).keys == binding(right).keys
}
}