use crate::action::Action;
use crate::app::InputMode;
use crossterm::event::{self, Event, KeyCode, KeyEvent, KeyModifiers};
use std::time::Duration;
pub fn poll_action(timeout: Duration, mode: &InputMode) -> Option<Action> {
if event::poll(timeout).ok()? {
if let Event::Key(key) = event::read().ok()? {
return map_key(key, mode);
}
}
None
}
fn map_key(key: KeyEvent, mode: &InputMode) -> Option<Action> {
if key.kind != crossterm::event::KeyEventKind::Press {
return None;
}
match mode {
InputMode::Normal => map_normal(key),
InputMode::Search => map_search(key),
}
}
fn map_normal(key: KeyEvent) -> Option<Action> {
match (key.modifiers, key.code) {
(_, KeyCode::Char('q')) => Some(Action::Quit),
(_, KeyCode::Esc) => Some(Action::Quit),
(mods, KeyCode::Char('c')) if mods.contains(KeyModifiers::CONTROL) => Some(Action::Quit),
(_, KeyCode::Char('/')) => Some(Action::EnterSearch),
(_, KeyCode::F(3)) => Some(Action::EnterSearch),
(mods, KeyCode::Char('f')) if mods.contains(KeyModifiers::CONTROL) => {
Some(Action::EnterSearch)
}
(_, KeyCode::Up) | (_, KeyCode::Char('k')) => Some(Action::PrevStation),
(_, KeyCode::Down) | (_, KeyCode::Char('j')) => Some(Action::NextStation),
(_, KeyCode::Right) | (_, KeyCode::Char('l')) | (_, KeyCode::Char('d')) => {
Some(Action::StepSettingForward)
}
(_, KeyCode::Left) | (_, KeyCode::Char('a')) => Some(Action::StepSettingBackward),
(_, KeyCode::Enter) => Some(Action::PlaySelected),
(_, KeyCode::Char(' ')) => Some(Action::TogglePause),
(_, KeyCode::Char('s')) => Some(Action::Stop),
(mods, KeyCode::Char('r')) if allows_normal_shortcut_modifier(mods) => {
Some(Action::RetryStream)
}
(_, KeyCode::Char('+')) | (_, KeyCode::Char('=')) => Some(Action::VolumeUp),
(_, KeyCode::Char('-')) => Some(Action::VolumeDown),
(_, KeyCode::Char('m')) => Some(Action::ToggleMute),
(_, KeyCode::Char('f')) => Some(Action::RemoveLibrarySelection),
(_, KeyCode::Char('u')) => Some(Action::UndoRemoveLibrarySelection),
(_, KeyCode::Tab) => Some(Action::NextGenre),
(_, KeyCode::BackTab) => Some(Action::PrevGenre),
(_, KeyCode::Char('?')) | (_, KeyCode::Char('h')) => Some(Action::ToggleHelp),
(mods, KeyCode::Char('i') | KeyCode::Char('I'))
if allows_normal_shortcut_modifier(mods) =>
{
Some(Action::ToggleStationDetails)
}
(mods, KeyCode::Char('g') | KeyCode::Char('G'))
if allows_normal_shortcut_modifier(mods) =>
{
Some(Action::ToggleRecentTracks)
}
(_, KeyCode::Char('b')) => Some(Action::CycleLayout),
(_, KeyCode::Char('v')) => Some(Action::ToggleVisualizerMode),
(_, KeyCode::Char(',')) => Some(Action::ToggleSettings),
_ => None,
}
}
fn allows_normal_shortcut_modifier(modifiers: KeyModifiers) -> bool {
!modifiers.contains(KeyModifiers::CONTROL)
}
fn map_search(key: KeyEvent) -> Option<Action> {
match (key.modifiers, key.code) {
(_, KeyCode::Esc) => Some(Action::ExitSearch),
(mods, KeyCode::Enter) if mods.contains(KeyModifiers::CONTROL) => {
Some(Action::SearchAudition)
}
(_, KeyCode::Char(' ')) => Some(Action::SearchAudition),
(_, KeyCode::Enter) => Some(Action::SearchConfirm),
(_, KeyCode::Up) => Some(Action::PrevStation),
(_, KeyCode::Down) => Some(Action::NextStation),
(_, KeyCode::Backspace) => Some(Action::SearchBackspace),
(mods, KeyCode::Char('c')) if mods.contains(KeyModifiers::CONTROL) => Some(Action::Quit),
(mods, KeyCode::Char('-')) if has_search_escape_modifier(mods) => Some(Action::VolumeDown),
(mods, KeyCode::Char('+') | KeyCode::Char('=')) if has_search_escape_modifier(mods) => {
Some(Action::VolumeUp)
}
(mods, KeyCode::Char('m')) if has_search_escape_modifier(mods) => Some(Action::ToggleMute),
(_, KeyCode::Char(c)) => Some(Action::SearchInput(c)),
_ => None,
}
}
fn has_search_escape_modifier(modifiers: KeyModifiers) -> bool {
modifiers.intersects(KeyModifiers::CONTROL | KeyModifiers::ALT)
}
#[cfg(test)]
mod tests {
use super::*;
fn key(code: KeyCode) -> KeyEvent {
KeyEvent::new(code, KeyModifiers::NONE)
}
fn modified_key(code: KeyCode, modifiers: KeyModifiers) -> KeyEvent {
KeyEvent::new(code, modifiers)
}
#[test]
fn search_mode_treats_plain_f_as_text_input() {
assert_eq!(
map_key(key(KeyCode::Char('f')), &InputMode::Search),
Some(Action::SearchInput('f')),
);
}
#[test]
fn search_mode_treats_plain_a_as_text_input() {
assert_eq!(
map_key(key(KeyCode::Char('a')), &InputMode::Search),
Some(Action::SearchInput('a')),
);
}
#[test]
fn search_mode_treats_plain_u_as_text_input() {
assert_eq!(
map_key(key(KeyCode::Char('u')), &InputMode::Search),
Some(Action::SearchInput('u')),
);
}
#[test]
fn search_mode_treats_context_overlay_keys_as_text_input() {
assert_eq!(
map_key(key(KeyCode::Char('i')), &InputMode::Search),
Some(Action::SearchInput('i')),
);
assert_eq!(
map_key(key(KeyCode::Char('g')), &InputMode::Search),
Some(Action::SearchInput('g')),
);
assert_eq!(
map_key(key(KeyCode::Char('r')), &InputMode::Search),
Some(Action::SearchInput('r')),
);
}
#[test]
fn search_mode_f2_does_not_add_selected_result() {
assert_eq!(map_key(key(KeyCode::F(2)), &InputMode::Search), None,);
}
#[test]
fn search_mode_insert_does_not_add_selected_result() {
assert_eq!(map_key(key(KeyCode::Insert), &InputMode::Search), None,);
}
#[test]
fn search_mode_plain_audio_keys_remain_text_input() {
assert_eq!(
map_key(key(KeyCode::Char('m')), &InputMode::Search),
Some(Action::SearchInput('m')),
);
assert_eq!(
map_key(key(KeyCode::Char('-')), &InputMode::Search),
Some(Action::SearchInput('-')),
);
assert_eq!(
map_key(key(KeyCode::Char('=')), &InputMode::Search),
Some(Action::SearchInput('=')),
);
assert_eq!(
map_key(key(KeyCode::Char('+')), &InputMode::Search),
Some(Action::SearchInput('+')),
);
}
#[test]
fn search_mode_ctrl_audio_keys_bypass_text_input() {
assert_eq!(
map_key(
modified_key(KeyCode::Char('-'), KeyModifiers::CONTROL),
&InputMode::Search
),
Some(Action::VolumeDown),
);
assert_eq!(
map_key(
modified_key(KeyCode::Char('='), KeyModifiers::CONTROL),
&InputMode::Search
),
Some(Action::VolumeUp),
);
assert_eq!(
map_key(
modified_key(KeyCode::Char('+'), KeyModifiers::CONTROL),
&InputMode::Search
),
Some(Action::VolumeUp),
);
assert_eq!(
map_key(
modified_key(KeyCode::Char('m'), KeyModifiers::CONTROL),
&InputMode::Search
),
Some(Action::ToggleMute),
);
}
#[test]
fn search_mode_alt_audio_keys_bypass_text_input() {
assert_eq!(
map_key(
modified_key(KeyCode::Char('-'), KeyModifiers::ALT),
&InputMode::Search
),
Some(Action::VolumeDown),
);
assert_eq!(
map_key(
modified_key(KeyCode::Char('='), KeyModifiers::ALT),
&InputMode::Search
),
Some(Action::VolumeUp),
);
assert_eq!(
map_key(
modified_key(KeyCode::Char('m'), KeyModifiers::ALT),
&InputMode::Search
),
Some(Action::ToggleMute),
);
}
#[test]
fn normal_mode_search_shortcuts_enter_search() {
assert_eq!(
map_key(key(KeyCode::Char('/')), &InputMode::Normal),
Some(Action::EnterSearch),
);
assert_eq!(
map_key(key(KeyCode::F(3)), &InputMode::Normal),
Some(Action::EnterSearch),
);
assert_eq!(
map_key(
modified_key(KeyCode::Char('f'), KeyModifiers::CONTROL),
&InputMode::Normal
),
Some(Action::EnterSearch),
);
}
#[test]
fn normal_mode_right_steps_setting_forward() {
assert_eq!(
map_key(key(KeyCode::Right), &InputMode::Normal),
Some(Action::StepSettingForward),
);
}
#[test]
fn normal_mode_left_steps_setting_backward() {
assert_eq!(
map_key(key(KeyCode::Left), &InputMode::Normal),
Some(Action::StepSettingBackward),
);
}
#[test]
fn normal_mode_l_and_d_step_setting_forward() {
assert_eq!(
map_key(key(KeyCode::Char('l')), &InputMode::Normal),
Some(Action::StepSettingForward),
);
assert_eq!(
map_key(key(KeyCode::Char('d')), &InputMode::Normal),
Some(Action::StepSettingForward),
);
}
#[test]
fn normal_mode_a_steps_setting_backward() {
assert_eq!(
map_key(key(KeyCode::Char('a')), &InputMode::Normal),
Some(Action::StepSettingBackward),
);
}
#[test]
fn normal_mode_f_removes_library_selection() {
assert_eq!(
map_key(key(KeyCode::Char('f')), &InputMode::Normal),
Some(Action::RemoveLibrarySelection),
);
}
#[test]
fn normal_mode_u_undoes_library_removal() {
assert_eq!(
map_key(key(KeyCode::Char('u')), &InputMode::Normal),
Some(Action::UndoRemoveLibrarySelection),
);
}
#[test]
fn normal_mode_context_shortcuts_open_overlays_and_retry() {
assert_eq!(
map_key(key(KeyCode::Char('i')), &InputMode::Normal),
Some(Action::ToggleStationDetails),
);
assert_eq!(
map_key(key(KeyCode::Char('g')), &InputMode::Normal),
Some(Action::ToggleRecentTracks),
);
assert_eq!(
map_key(key(KeyCode::Char('r')), &InputMode::Normal),
Some(Action::RetryStream),
);
}
#[test]
fn normal_mode_context_shortcuts_tolerate_non_control_modifiers() {
assert_eq!(
map_key(
modified_key(KeyCode::Char('I'), KeyModifiers::SHIFT),
&InputMode::Normal
),
Some(Action::ToggleStationDetails),
);
assert_eq!(
map_key(
modified_key(KeyCode::Char('G'), KeyModifiers::SHIFT),
&InputMode::Normal
),
Some(Action::ToggleRecentTracks),
);
assert_eq!(
map_key(
modified_key(KeyCode::Char('r'), KeyModifiers::ALT),
&InputMode::Normal
),
Some(Action::RetryStream),
);
}
#[test]
fn normal_mode_context_shortcuts_do_not_capture_control_combos() {
assert_eq!(
map_key(
modified_key(KeyCode::Char('i'), KeyModifiers::CONTROL),
&InputMode::Normal
),
None,
);
assert_eq!(
map_key(
modified_key(KeyCode::Char('g'), KeyModifiers::CONTROL),
&InputMode::Normal
),
None,
);
assert_eq!(
map_key(
modified_key(KeyCode::Char('r'), KeyModifiers::CONTROL),
&InputMode::Normal
),
None,
);
}
#[test]
fn search_mode_space_auditions_selected_result() {
assert_eq!(
map_key(key(KeyCode::Char(' ')), &InputMode::Search),
Some(Action::SearchAudition),
);
}
#[test]
fn search_mode_ctrl_enter_auditions_selected_result_when_supported() {
assert_eq!(
map_key(
modified_key(KeyCode::Enter, KeyModifiers::CONTROL),
&InputMode::Search
),
Some(Action::SearchAudition),
);
}
#[test]
fn search_mode_enter_adds_and_plays_selected_result() {
assert_eq!(
map_key(key(KeyCode::Enter), &InputMode::Search),
Some(Action::SearchConfirm),
);
}
#[test]
fn normal_mode_removed_legacy_keys_are_unmapped() {
let removed_plain_keys = [
KeyCode::Char('t'),
KeyCode::Char('p'),
KeyCode::Char('o'),
KeyCode::Char('R'),
KeyCode::Char('M'),
KeyCode::Char('y'),
KeyCode::Char('n'),
KeyCode::Char('K'),
KeyCode::Char('T'),
KeyCode::Char('D'),
KeyCode::Delete,
];
for code in removed_plain_keys {
assert_eq!(map_key(key(code), &InputMode::Normal), None);
}
assert_eq!(
map_key(
modified_key(KeyCode::Char('r'), KeyModifiers::CONTROL),
&InputMode::Normal
),
None,
);
}
}