use std::collections::BTreeMap;
use std::fmt;
use std::str::FromStr;
use anyhow::{bail, Result};
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
use serde::{Deserialize, Deserializer, Serialize, Serializer};
#[derive(Debug, Clone, Copy, PartialEq, Eq, PartialOrd, Ord, Deserialize, Serialize)]
#[serde(rename_all = "snake_case")]
pub(crate) enum TuiAction {
Interrupt,
Quit,
EnterSearch,
LeaveSearch,
MoveDown,
MoveUp,
PageDown,
PageUp,
Top,
Bottom,
CycleProvider,
CycleSessionKind,
CycleTimeWindow,
ToggleWarningsOnly,
PreviewScrollDown,
PreviewScrollUp,
PreviewPageDown,
PreviewPageUp,
Resume,
Help,
ClearQuery,
DeleteBackward,
DeleteForward,
DeleteWordBackward,
CursorLeft,
CursorRight,
CursorStart,
CursorEnd,
}
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) enum ActionMode {
Browse,
Search,
Both,
}
impl TuiAction {
pub(crate) const ALL: [Self; 28] = [
Self::Interrupt,
Self::Quit,
Self::EnterSearch,
Self::LeaveSearch,
Self::MoveDown,
Self::MoveUp,
Self::PageDown,
Self::PageUp,
Self::Top,
Self::Bottom,
Self::CycleProvider,
Self::CycleSessionKind,
Self::CycleTimeWindow,
Self::ToggleWarningsOnly,
Self::PreviewScrollDown,
Self::PreviewScrollUp,
Self::PreviewPageDown,
Self::PreviewPageUp,
Self::Resume,
Self::Help,
Self::ClearQuery,
Self::DeleteBackward,
Self::DeleteForward,
Self::DeleteWordBackward,
Self::CursorLeft,
Self::CursorRight,
Self::CursorStart,
Self::CursorEnd,
];
pub(crate) fn mode(self) -> ActionMode {
match self {
Self::Interrupt => ActionMode::Both,
Self::LeaveSearch
| Self::ClearQuery
| Self::DeleteBackward
| Self::DeleteForward
| Self::DeleteWordBackward
| Self::CursorLeft
| Self::CursorRight
| Self::CursorStart
| Self::CursorEnd => ActionMode::Search,
Self::Quit
| Self::EnterSearch
| Self::MoveDown
| Self::MoveUp
| Self::PageDown
| Self::PageUp
| Self::Top
| Self::Bottom
| Self::CycleProvider
| Self::CycleSessionKind
| Self::CycleTimeWindow
| Self::ToggleWarningsOnly
| Self::PreviewScrollDown
| Self::PreviewScrollUp
| Self::PreviewPageDown
| Self::PreviewPageUp
| Self::Resume
| Self::Help => ActionMode::Browse,
}
}
pub(crate) fn name(self) -> &'static str {
match self {
Self::Interrupt => "interrupt",
Self::Quit => "quit",
Self::EnterSearch => "enter_search",
Self::LeaveSearch => "leave_search",
Self::MoveDown => "move_down",
Self::MoveUp => "move_up",
Self::PageDown => "page_down",
Self::PageUp => "page_up",
Self::Top => "top",
Self::Bottom => "bottom",
Self::CycleProvider => "cycle_provider",
Self::CycleSessionKind => "cycle_session_kind",
Self::CycleTimeWindow => "cycle_time_window",
Self::ToggleWarningsOnly => "toggle_warnings_only",
Self::PreviewScrollDown => "preview_scroll_down",
Self::PreviewScrollUp => "preview_scroll_up",
Self::PreviewPageDown => "preview_page_down",
Self::PreviewPageUp => "preview_page_up",
Self::Resume => "resume",
Self::Help => "help",
Self::ClearQuery => "clear_query",
Self::DeleteBackward => "delete_backward",
Self::DeleteForward => "delete_forward",
Self::DeleteWordBackward => "delete_word_backward",
Self::CursorLeft => "cursor_left",
Self::CursorRight => "cursor_right",
Self::CursorStart => "cursor_start",
Self::CursorEnd => "cursor_end",
}
}
}
impl fmt::Display for TuiAction {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
formatter.write_str(self.name())
}
}
const NAMED_KEYS: [(&str, KeyCode); 16] = [
("enter", KeyCode::Enter),
("esc", KeyCode::Esc),
("tab", KeyCode::Tab),
("backtab", KeyCode::BackTab),
("backspace", KeyCode::Backspace),
("delete", KeyCode::Delete),
("insert", KeyCode::Insert),
("space", KeyCode::Char(' ')),
("up", KeyCode::Up),
("down", KeyCode::Down),
("left", KeyCode::Left),
("right", KeyCode::Right),
("home", KeyCode::Home),
("end", KeyCode::End),
("pageup", KeyCode::PageUp),
("pagedown", KeyCode::PageDown),
];
const HIGHEST_FUNCTION_KEY: u8 = 35;
fn function_key(name: &str) -> Option<KeyCode> {
let number = name.strip_prefix('f')?.parse::<u8>().ok()?;
(1..=HIGHEST_FUNCTION_KEY)
.contains(&number)
.then_some(KeyCode::F(number))
}
const NAMED_MODIFIERS: [(&str, KeyModifiers); 4] = [
("ctrl", KeyModifiers::CONTROL),
("alt", KeyModifiers::ALT),
("shift", KeyModifiers::SHIFT),
("super", KeyModifiers::SUPER),
];
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
pub(crate) struct KeyChord {
code: KeyCode,
modifiers: KeyModifiers,
}
impl KeyChord {
pub(crate) fn new(code: KeyCode, modifiers: KeyModifiers) -> Self {
let (code, modifiers) = Self::significant(code, modifiers);
Self { code, modifiers }
}
fn significant(code: KeyCode, modifiers: KeyModifiers) -> (KeyCode, KeyModifiers) {
let chorded = KeyModifiers::CONTROL | KeyModifiers::ALT | KeyModifiers::SUPER;
let KeyCode::Char(character) = code else {
return (code, modifiers & (chorded | KeyModifiers::SHIFT));
};
let kept = modifiers & chorded;
if kept.is_empty() {
return (code, kept);
}
(KeyCode::Char(fold_case(character)), kept)
}
pub(crate) fn matches(&self, event: &KeyEvent) -> bool {
(self.code, self.modifiers) == Self::significant(event.code, event.modifiers)
}
}
fn fold_case(character: char) -> char {
let mut lowered = character.to_lowercase();
match (lowered.next(), lowered.next()) {
(Some(single), None) => single,
_ => character,
}
}
impl fmt::Display for KeyChord {
fn fmt(&self, formatter: &mut fmt::Formatter<'_>) -> fmt::Result {
for (name, flag) in NAMED_MODIFIERS {
if self.modifiers.contains(flag) {
write!(formatter, "{name}+")?;
}
}
match NAMED_KEYS.iter().find(|(_, code)| *code == self.code) {
Some((name, _)) => formatter.write_str(name),
None => match self.code {
KeyCode::Char(character) => write!(formatter, "{character}"),
KeyCode::F(number) => write!(formatter, "f{number}"),
other => write!(formatter, "{other:?}"),
},
}
}
}
fn accepted_key_names() -> String {
let named = NAMED_KEYS
.iter()
.map(|(name, _)| *name)
.collect::<Vec<_>>()
.join(", ");
format!("a single character, f1 through f{HIGHEST_FUNCTION_KEY}, or one of: {named}")
}
impl FromStr for KeyChord {
type Err = anyhow::Error;
fn from_str(text: &str) -> Result<Self> {
let trimmed = text.trim();
if trimmed.is_empty() {
bail!(
"a key binding cannot be empty; write {}",
accepted_key_names()
);
}
let mut modifiers = KeyModifiers::NONE;
let mut rest = trimmed;
while let Some((head, tail)) = rest.split_once('+') {
if tail.is_empty() {
break;
}
let lowered = head.to_ascii_lowercase();
match NAMED_MODIFIERS
.iter()
.find(|(name, _)| *name == lowered.as_str())
{
Some((_, flag)) => modifiers |= *flag,
None => bail!(
"unknown key modifier {head:?} in {trimmed:?}; write one of ctrl, alt, \
shift, super, joined to the key with +"
),
}
rest = tail;
}
let lowered = rest.to_ascii_lowercase();
if let Some((_, code)) = NAMED_KEYS
.iter()
.find(|(name, _)| *name == lowered.as_str())
{
return Ok(Self::new(*code, modifiers));
}
if let Some(code) = function_key(&lowered) {
return Ok(Self::new(code, modifiers));
}
let mut characters = rest.chars();
match (characters.next(), characters.next()) {
(Some(character), None) => Ok(Self::new(KeyCode::Char(character), modifiers)),
_ => bail!(
"unknown key {rest:?} in {trimmed:?}; write {}",
accepted_key_names()
),
}
}
}
impl<'de> Deserialize<'de> for KeyChord {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
let text = String::deserialize(deserializer)?;
text.parse().map_err(serde::de::Error::custom)
}
}
impl Serialize for KeyChord {
fn serialize<S: Serializer>(&self, serializer: S) -> std::result::Result<S::Ok, S::Error> {
serializer.serialize_str(&self.to_string())
}
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize)]
#[serde(transparent)]
pub struct KeyBindings(BTreeMap<TuiAction, Vec<KeyChord>>);
impl KeyBindings {
pub(crate) fn chords(&self, action: TuiAction) -> &[KeyChord] {
self.0.get(&action).map_or(&[], Vec::as_slice)
}
fn bind(&mut self, action: TuiAction, chords: impl IntoIterator<Item = KeyChord>) {
let mut kept: Vec<KeyChord> = Vec::new();
for chord in chords {
if !kept.contains(&chord) {
kept.push(chord);
}
}
self.0.insert(action, kept);
}
pub(crate) fn action_for(&self, event: &KeyEvent, mode: ActionMode) -> Option<TuiAction> {
self.0
.iter()
.filter(|(action, _)| reachable(action.mode(), mode))
.find(|(_, chords)| chords.iter().any(|chord| chord.matches(event)))
.map(|(action, _)| *action)
}
pub(crate) fn validate(&self) -> Result<()> {
for mode in [ActionMode::Browse, ActionMode::Search] {
let mut seen: Vec<(KeyChord, TuiAction)> = Vec::new();
for (action, chords) in &self.0 {
if !reachable(action.mode(), mode) {
continue;
}
for chord in chords {
if let Some((_, existing)) =
seen.iter().find(|(seen_chord, _)| seen_chord == chord)
{
bail!(
"ui.keys binds {chord} to both {existing} and {action}, which are \
both reachable from the same mode; give one of them another key"
);
}
seen.push((*chord, *action));
}
}
}
if self.chords(TuiAction::Quit).is_empty() && self.chords(TuiAction::Interrupt).is_empty() {
bail!(
"ui.keys leaves both quit and interrupt unbound, so the terminal UI could not \
be exited; bind at least one of them"
);
}
Ok(())
}
}
fn reachable(action: ActionMode, mode: ActionMode) -> bool {
action == ActionMode::Both || mode == ActionMode::Both || action == mode
}
impl Default for KeyBindings {
fn default() -> Self {
let chord = |text: &str| text.parse::<KeyChord>().expect("a shipped default parses");
let mut bindings = Self(BTreeMap::new());
let mut bind = |action: TuiAction, keys: &[&str]| {
bindings.bind(action, keys.iter().map(|key| chord(key)));
};
bind(TuiAction::Interrupt, &["ctrl+c"]);
bind(TuiAction::Quit, &["q", "esc"]);
bind(TuiAction::EnterSearch, &["/"]);
bind(TuiAction::LeaveSearch, &["enter", "esc"]);
bind(TuiAction::MoveDown, &["j", "down"]);
bind(TuiAction::MoveUp, &["k", "up"]);
bind(TuiAction::PageDown, &["pagedown"]);
bind(TuiAction::PageUp, &["pageup"]);
bind(TuiAction::Top, &["g"]);
bind(TuiAction::Bottom, &["G"]);
bind(TuiAction::CycleProvider, &["p"]);
bind(TuiAction::CycleSessionKind, &["f"]);
bind(TuiAction::CycleTimeWindow, &["s"]);
bind(TuiAction::ToggleWarningsOnly, &["w"]);
bind(TuiAction::PreviewScrollDown, &["J", "shift+down"]);
bind(TuiAction::PreviewScrollUp, &["K", "shift+up"]);
bind(TuiAction::PreviewPageDown, &["ctrl+d"]);
bind(TuiAction::PreviewPageUp, &["ctrl+u"]);
bind(TuiAction::Resume, &["enter", "r"]);
bind(TuiAction::Help, &["?"]);
bind(TuiAction::ClearQuery, &["ctrl+u"]);
bind(TuiAction::DeleteBackward, &["backspace"]);
bind(TuiAction::DeleteForward, &["delete"]);
bind(TuiAction::DeleteWordBackward, &["ctrl+w"]);
bind(TuiAction::CursorLeft, &["left"]);
bind(TuiAction::CursorRight, &["right"]);
bind(TuiAction::CursorStart, &["home", "ctrl+a"]);
bind(TuiAction::CursorEnd, &["end", "ctrl+e"]);
bindings
}
}
impl<'de> Deserialize<'de> for KeyBindings {
fn deserialize<D: Deserializer<'de>>(deserializer: D) -> std::result::Result<Self, D::Error> {
let overrides = BTreeMap::<TuiAction, Vec<KeyChord>>::deserialize(deserializer)?;
let mut bindings = Self::default();
for (action, chords) in overrides {
bindings.bind(action, chords);
}
Ok(bindings)
}
}
#[cfg(test)]
mod tests {
use super::*;
#[test]
fn every_shipped_default_round_trips_through_its_written_form() {
let bindings = KeyBindings::default();
for action in TuiAction::ALL {
for chord in bindings.chords(action) {
let written = chord.to_string();
let reparsed: KeyChord = written.parse().expect("a printed chord parses");
assert_eq!(reparsed, *chord, "{action} wrote {written}");
}
}
}
#[test]
fn every_action_has_a_default_binding() {
let bindings = KeyBindings::default();
for action in TuiAction::ALL {
assert!(
!bindings.chords(action).is_empty(),
"{action} ships unbound, so nothing documents how to reach it"
);
}
}
#[test]
fn a_character_chord_ignores_the_shift_a_terminal_may_or_may_not_report() {
let chord: KeyChord = "G".parse().unwrap();
assert!(chord.matches(&KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE)));
assert!(chord.matches(&KeyEvent::new(KeyCode::Char('G'), KeyModifiers::SHIFT)));
assert!(!chord.matches(&KeyEvent::new(KeyCode::Char('G'), KeyModifiers::CONTROL)));
let tab: KeyChord = "shift+tab".parse().unwrap();
assert!(tab.matches(&KeyEvent::new(KeyCode::Tab, KeyModifiers::SHIFT)));
assert!(!tab.matches(&KeyEvent::new(KeyCode::Tab, KeyModifiers::NONE)));
}
#[test]
fn the_preview_scrolls_on_the_vertical_axis_rather_than_on_h_and_l() {
let bindings = KeyBindings::default();
let press = |code, modifiers| {
bindings.action_for(&KeyEvent::new(code, modifiers), ActionMode::Browse)
};
assert_eq!(
press(KeyCode::Char('J'), KeyModifiers::NONE),
Some(TuiAction::PreviewScrollDown)
);
assert_eq!(
press(KeyCode::Char('K'), KeyModifiers::NONE),
Some(TuiAction::PreviewScrollUp)
);
assert_eq!(
press(KeyCode::Down, KeyModifiers::SHIFT),
Some(TuiAction::PreviewScrollDown)
);
assert_eq!(
press(KeyCode::Up, KeyModifiers::SHIFT),
Some(TuiAction::PreviewScrollUp)
);
assert_eq!(
press(KeyCode::Char('j'), KeyModifiers::NONE),
Some(TuiAction::MoveDown)
);
assert_eq!(
press(KeyCode::Char('k'), KeyModifiers::NONE),
Some(TuiAction::MoveUp)
);
for code in [
KeyCode::Char('h'),
KeyCode::Char('l'),
KeyCode::Left,
KeyCode::Right,
] {
assert_eq!(
press(code, KeyModifiers::NONE),
None,
"{code:?} still reaches a command in browse mode"
);
}
}
#[test]
fn a_chorded_letter_is_not_the_bare_letter() {
let bindings = KeyBindings::default();
let quit = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::NONE);
let chorded_quit = KeyEvent::new(KeyCode::Char('q'), KeyModifiers::CONTROL);
assert_eq!(
bindings.action_for(&quit, ActionMode::Browse),
Some(TuiAction::Quit)
);
assert_eq!(bindings.action_for(&chorded_quit, ActionMode::Browse), None);
}
#[test]
fn esc_and_enter_mean_different_things_in_the_two_modes() {
let bindings = KeyBindings::default();
let esc = KeyEvent::new(KeyCode::Esc, KeyModifiers::NONE);
let enter = KeyEvent::new(KeyCode::Enter, KeyModifiers::NONE);
assert_eq!(
bindings.action_for(&esc, ActionMode::Browse),
Some(TuiAction::Quit)
);
assert_eq!(
bindings.action_for(&esc, ActionMode::Search),
Some(TuiAction::LeaveSearch)
);
assert_eq!(
bindings.action_for(&enter, ActionMode::Browse),
Some(TuiAction::Resume)
);
assert_eq!(
bindings.action_for(&enter, ActionMode::Search),
Some(TuiAction::LeaveSearch)
);
bindings.validate().expect("the shipped table is operable");
}
#[test]
fn interrupt_is_reachable_from_both_modes() {
let bindings = KeyBindings::default();
let interrupt = KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL);
for mode in [ActionMode::Browse, ActionMode::Search] {
assert_eq!(
bindings.action_for(&interrupt, mode),
Some(TuiAction::Interrupt)
);
}
}
#[test]
fn every_function_key_reads_back_the_way_it_is_written() {
for number in 1..=HIGHEST_FUNCTION_KEY {
let written = format!("f{number}");
let chord: KeyChord = written.parse().unwrap_or_else(|error| {
panic!("{written} did not parse: {error}");
});
assert!(chord.matches(&KeyEvent::new(KeyCode::F(number), KeyModifiers::NONE)));
assert_eq!(chord.to_string(), written);
}
assert!("f0".parse::<KeyChord>().is_err());
assert!(format!("f{}", u16::from(HIGHEST_FUNCTION_KEY) + 1)
.parse::<KeyChord>()
.is_err());
}
#[test]
fn an_unknown_key_name_says_what_to_write_instead() {
let error = "ctrl+nope".parse::<KeyChord>().unwrap_err().to_string();
assert!(error.contains("unknown key"), "{error}");
assert!(error.contains("single character"), "{error}");
assert!(error.contains("pagedown"), "{error}");
let modifier = "hyper+c".parse::<KeyChord>().unwrap_err().to_string();
assert!(modifier.contains("unknown key modifier"), "{modifier}");
assert!(modifier.contains("ctrl"), "{modifier}");
}
#[test]
fn a_chord_may_itself_be_the_plus_key() {
let plus: KeyChord = "+".parse().unwrap();
assert!(plus.matches(&KeyEvent::new(KeyCode::Char('+'), KeyModifiers::NONE)));
let chorded: KeyChord = "ctrl++".parse().unwrap();
assert!(chorded.matches(&KeyEvent::new(KeyCode::Char('+'), KeyModifiers::CONTROL)));
}
#[test]
fn one_chord_bound_to_two_actions_in_one_mode_is_refused() {
let mut bindings = KeyBindings::default();
bindings.bind(TuiAction::Top, ["q".parse().unwrap()]);
let error = bindings.validate().unwrap_err().to_string();
assert!(error.contains("ui.keys binds q"), "{error}");
assert!(error.contains("quit"), "{error}");
assert!(error.contains("top"), "{error}");
}
#[test]
fn a_table_with_no_way_out_is_refused() {
let mut bindings = KeyBindings::default();
bindings.bind(TuiAction::Quit, []);
bindings.bind(TuiAction::Interrupt, []);
let error = bindings.validate().unwrap_err().to_string();
assert!(error.contains("could not be exited"), "{error}");
}
#[test]
fn a_table_naming_one_action_keeps_the_other_defaults() {
let bindings: KeyBindings = toml::from_str("quit = [\"x\"]").unwrap();
assert_eq!(
bindings.chords(TuiAction::Quit),
&["x".parse::<KeyChord>().unwrap()]
);
assert_eq!(
bindings.chords(TuiAction::MoveDown),
KeyBindings::default().chords(TuiAction::MoveDown)
);
}
#[test]
fn an_action_nobody_implements_is_refused_rather_than_ignored() {
let error = toml::from_str::<KeyBindings>("teleport = [\"t\"]")
.unwrap_err()
.to_string();
assert!(error.contains("teleport"), "{error}");
}
#[test]
fn an_action_may_be_reached_by_as_many_keys_as_the_reader_writes() {
let bindings: KeyBindings =
toml::from_str("quit = [\"q\", \"esc\", \"ctrl+q\", \"f1\", \"x\"]").unwrap();
bindings.validate().expect("aliases are not a conflict");
for (code, modifiers) in [
(KeyCode::Char('q'), KeyModifiers::NONE),
(KeyCode::Esc, KeyModifiers::NONE),
(KeyCode::Char('q'), KeyModifiers::CONTROL),
(KeyCode::F(1), KeyModifiers::NONE),
(KeyCode::Char('x'), KeyModifiers::NONE),
] {
assert_eq!(
bindings.action_for(&KeyEvent::new(code, modifiers), ActionMode::Browse),
Some(TuiAction::Quit),
"{code:?} with {modifiers:?} did not reach quit"
);
}
}
#[test]
fn naming_one_key_twice_for_one_action_is_kept_once_rather_than_refused() {
let bindings: KeyBindings = toml::from_str("quit = [\"q\", \"esc\", \"q\"]").unwrap();
bindings
.validate()
.expect("one action listing a key twice still means one thing");
assert_eq!(
bindings.chords(TuiAction::Quit),
&[
"q".parse::<KeyChord>().unwrap(),
"esc".parse::<KeyChord>().unwrap()
],
"the repeat should be dropped, so the help does not print `q, esc, q`"
);
}
#[test]
fn a_modified_letter_is_the_same_chord_whichever_case_the_terminal_reports() {
let lower: KeyChord = "ctrl+c".parse().unwrap();
let upper: KeyChord = "ctrl+C".parse().unwrap();
assert_eq!(lower, upper, "ctrl+c and ctrl+C name one key press");
for chord in [lower, upper] {
assert!(chord.matches(&KeyEvent::new(KeyCode::Char('c'), KeyModifiers::CONTROL)));
assert!(chord.matches(&KeyEvent::new(
KeyCode::Char('c'),
KeyModifiers::CONTROL | KeyModifiers::SHIFT
)));
assert!(chord.matches(&KeyEvent::new(KeyCode::Char('C'), KeyModifiers::CONTROL)));
}
let top: KeyChord = "g".parse().unwrap();
assert!(!top.matches(&KeyEvent::new(KeyCode::Char('G'), KeyModifiers::NONE)));
}
#[test]
fn an_explicitly_empty_action_is_unbound() {
let bindings: KeyBindings = toml::from_str("cycle_provider = []").unwrap();
assert!(bindings.chords(TuiAction::CycleProvider).is_empty());
let provider = KeyEvent::new(KeyCode::Char('p'), KeyModifiers::NONE);
assert_eq!(bindings.action_for(&provider, ActionMode::Browse), None);
bindings
.validate()
.expect("unbinding one action is allowed");
}
}