use ratatui::widgets::ListState;
use super::keymap::{
Action, Binding, CONFIG_BACK, GO_TO_CONFIG, GO_TO_EPISODE, OPEN_PODCAST_FOLDER, QUIT,
REFRESH_FEEDS, REORDER_PODCASTS, SEARCH_EPISODES, TOGGLE_HINT_BAR,
};
use super::state::{App, Screen};
pub struct CommandEntry {
pub category: &'static str,
pub description: &'static str,
pub description_podcast_list: Option<&'static str>,
pub shortcut: Option<&'static Binding>,
pub action: Action,
pub hide_on: &'static [Screen],
}
impl CommandEntry {
pub fn shortcut_text(&self, app: &App) -> Option<String> {
let binding = self.shortcut?;
if !binding.is_live_on_screen(app) {
return None;
}
binding.shortcut_for(self.action)
}
}
const SCREENS_EXCEPT_PODCAST_LIST: &[Screen] = &[
Screen::EpisodeSelect,
Screen::Downloading,
Screen::Config,
Screen::EditPodcast,
Screen::EditPodcastSelect,
Screen::AddPodcast,
Screen::About,
];
const SCREENS_EXCEPT_EPISODE_SELECT: &[Screen] = &[
Screen::PodcastList,
Screen::Downloading,
Screen::Config,
Screen::EditPodcast,
Screen::EditPodcastSelect,
Screen::AddPodcast,
Screen::About,
];
pub static COMMANDS: &[CommandEntry] = &[
CommandEntry {
category: "podcast",
description: "refresh feeds",
description_podcast_list: None,
shortcut: Some(&REFRESH_FEEDS),
action: Action::RefreshFeeds,
hide_on: &[],
},
CommandEntry {
category: "episodes",
description: "search episodes",
description_podcast_list: None,
shortcut: Some(&SEARCH_EPISODES),
action: Action::SearchEpisodes,
hide_on: SCREENS_EXCEPT_EPISODE_SELECT,
},
CommandEntry {
category: "episodes",
description: "go to episode number",
description_podcast_list: None,
shortcut: Some(&GO_TO_EPISODE),
action: Action::JumpToEpisode,
hide_on: SCREENS_EXCEPT_EPISODE_SELECT,
},
CommandEntry {
category: "config",
description: "open config editor",
description_podcast_list: None,
shortcut: Some(&GO_TO_CONFIG),
action: Action::GoToConfig,
hide_on: &[Screen::Config, Screen::Downloading],
},
CommandEntry {
category: "podcast",
description: "go to podcast list",
description_podcast_list: None,
shortcut: Some(&CONFIG_BACK),
action: Action::GoToPodcastList,
hide_on: &[Screen::PodcastList, Screen::Downloading],
},
CommandEntry {
category: "podcast",
description: "add new podcast",
description_podcast_list: None,
shortcut: None,
action: Action::AddPodcast,
hide_on: &[Screen::AddPodcast, Screen::Downloading],
},
CommandEntry {
category: "folder",
description: "open download folder",
description_podcast_list: None,
shortcut: None,
action: Action::OpenDownloadFolder,
hide_on: &[],
},
CommandEntry {
category: "folder",
description: "open podcast folder",
description_podcast_list: Some("open selected podcast folder"),
shortcut: Some(&OPEN_PODCAST_FOLDER),
action: Action::OpenPodcastFolder,
hide_on: &[
Screen::AddPodcast,
Screen::Config,
Screen::EditPodcast,
Screen::About,
],
},
CommandEntry {
category: "repo",
description: "star repository",
description_podcast_list: None,
shortcut: None,
action: Action::OpenRepository,
hide_on: &[],
},
CommandEntry {
category: "repo",
description: "open issue",
description_podcast_list: None,
shortcut: None,
action: Action::OpenIssue,
hide_on: &[],
},
CommandEntry {
category: "podcast",
description: "move podcast up",
description_podcast_list: None,
shortcut: Some(&REORDER_PODCASTS),
action: Action::MovePodcastUp,
hide_on: SCREENS_EXCEPT_PODCAST_LIST,
},
CommandEntry {
category: "podcast",
description: "move podcast down",
description_podcast_list: None,
shortcut: Some(&REORDER_PODCASTS),
action: Action::MovePodcastDown,
hide_on: SCREENS_EXCEPT_PODCAST_LIST,
},
CommandEntry {
category: "app",
description: "toggle hint bar",
description_podcast_list: None,
shortcut: Some(&TOGGLE_HINT_BAR),
action: Action::ToggleHintBar,
hide_on: &[],
},
CommandEntry {
category: "app",
description: "open about screen",
description_podcast_list: None,
shortcut: None,
action: Action::GoToAbout,
hide_on: &[Screen::About, Screen::Downloading],
},
CommandEntry {
category: "app",
description: "quit",
description_podcast_list: None,
shortcut: Some(&QUIT),
action: Action::Quit,
hide_on: &[],
},
];
pub struct CommandPaletteState {
pub open: bool,
pub query: String,
pub list_state: ListState,
pub matches: Vec<usize>,
}
pub fn palette_command_visible(screen: Screen, cmd: &CommandEntry) -> bool {
!cmd.hide_on.contains(&screen)
}
impl App {
fn palette_command_offered(&self, cmd: &CommandEntry) -> bool {
palette_command_visible(self.screen, cmd)
&& match cmd.action {
Action::SearchEpisodes | Action::JumpToEpisode => self.episode_prompt_available(),
_ => true,
}
}
pub fn palette_open(&mut self) {
self.palette.open = true;
self.palette.query.clear();
self.palette.matches = (0..COMMANDS.len())
.filter(|&i| self.palette_command_offered(&COMMANDS[i]))
.collect();
let mut state = ListState::default();
if !self.palette.matches.is_empty() {
state.select(Some(0));
}
self.palette.list_state = state;
}
pub fn palette_close(&mut self) {
self.palette.open = false;
}
fn palette_filter(&mut self) {
let q = self.palette.query.to_lowercase();
self.palette.matches = COMMANDS
.iter()
.enumerate()
.filter(|(_, cmd)| {
if !self.palette_command_offered(cmd) {
return false;
}
let mut haystack = format!("{} {}", cmd.category, cmd.description).to_lowercase();
if let Some(alt) = cmd.description_podcast_list {
haystack.push(' ');
haystack.push_str(&alt.to_lowercase());
}
haystack.contains(&q)
})
.map(|(i, _)| i)
.collect();
if !self.palette.matches.is_empty() {
self.palette.list_state.select(Some(0));
} else {
self.palette.list_state.select(None);
}
}
pub fn palette_type(&mut self, c: char) {
self.palette.query.push(c);
self.palette_filter();
}
pub fn palette_backspace(&mut self) {
self.palette.query.pop();
self.palette_filter();
}
pub fn palette_move_up(&mut self) {
if let Some(i) = self.palette.list_state.selected()
&& i > 0
{
self.palette.list_state.select(Some(i - 1));
}
}
pub fn palette_move_down(&mut self) {
if let Some(i) = self.palette.list_state.selected()
&& i + 1 < self.palette.matches.len()
{
self.palette.list_state.select(Some(i + 1));
}
}
pub fn palette_execute(&self) -> Option<Action> {
let i = self.palette.list_state.selected()?;
let cmd_idx = *self.palette.matches.get(i)?;
Some(COMMANDS[cmd_idx].action)
}
}
#[cfg(test)]
mod tests {
use super::super::test_app_one_podcast as test_app;
use super::*;
#[test]
fn go_to_podcast_list_hidden_on_podcast_list() {
let cmd = command_for(Action::GoToPodcastList);
assert!(!palette_command_visible(Screen::PodcastList, cmd));
assert!(palette_command_visible(Screen::Config, cmd));
}
#[test]
fn open_config_editor_hidden_on_config() {
let cmd = command_for(Action::GoToConfig);
assert!(!palette_command_visible(Screen::Config, cmd));
assert!(palette_command_visible(Screen::PodcastList, cmd));
}
#[test]
fn about_screen_hidden_on_about_and_downloading() {
let cmd = command_for(Action::GoToAbout);
assert!(!palette_command_visible(Screen::About, cmd));
assert!(!palette_command_visible(Screen::Downloading, cmd));
assert!(palette_command_visible(Screen::PodcastList, cmd));
assert!(palette_command_visible(Screen::Config, cmd));
}
#[test]
fn repo_actions_are_two_and_visible_everywhere() {
let repository = command_for(Action::OpenRepository);
assert_eq!(repository.description, "star repository");
let issue = command_for(Action::OpenIssue);
assert_eq!(issue.description, "open issue");
for screen in [
Screen::PodcastList,
Screen::EpisodeSelect,
Screen::Downloading,
Screen::Config,
Screen::EditPodcast,
Screen::EditPodcastSelect,
Screen::AddPodcast,
Screen::About,
] {
assert!(palette_command_visible(screen, repository), "{screen:?}");
assert!(palette_command_visible(screen, issue), "{screen:?}");
}
}
#[test]
fn episode_commands_are_offered_only_once_episodes_are_loaded() {
let offered = |app: &mut App| {
app.palette_open();
let actions: Vec<Action> = app
.palette
.matches
.iter()
.map(|&i| COMMANDS[i].action)
.collect();
[Action::SearchEpisodes, Action::JumpToEpisode].map(|a| actions.contains(&a))
};
let mut app = test_app();
app.screen = Screen::EpisodeSelect;
app.loading_episodes = true;
assert_eq!(offered(&mut app), [false, false]);
app.loading_episodes = false;
assert_eq!(offered(&mut app), [false, false]);
app.episodes.push(crate::podcast::EpisodeInfo {
title: "Pilot".to_string(),
feed_index: 1,
pub_date: None,
description: String::new(),
});
assert_eq!(offered(&mut app), [true, true]);
}
#[test]
fn go_to_episode_names_the_digit_keys_in_the_palette() {
assert_eq!(
GO_TO_EPISODE.shortcut_for(Action::JumpToEpisode).as_deref(),
Some("#/0-9")
);
}
fn command_for(action: Action) -> &'static CommandEntry {
COMMANDS.iter().find(|c| c.action == action).unwrap()
}
#[test]
fn every_palette_shortcut_comes_from_a_binding_that_runs_that_entry() {
for cmd in COMMANDS {
if let Some(binding) = cmd.shortcut {
assert!(
binding.shortcut_for(cmd.action).is_some(),
"the shortcut binding for \"{}\" does not run its action",
cmd.description
);
}
}
}
#[test]
fn palette_shortcuts_read_as_the_keys_themselves() {
let app = test_app();
let shortcut = |action: Action| command_for(action).shortcut_text(&app);
assert_eq!(shortcut(Action::Quit).as_deref(), Some("q"));
assert_eq!(shortcut(Action::RefreshFeeds).as_deref(), Some("r"));
assert_eq!(shortcut(Action::ToggleHintBar).as_deref(), Some("? or ."));
assert_eq!(shortcut(Action::MovePodcastUp).as_deref(), Some("Shift+↑"));
assert_eq!(
shortcut(Action::MovePodcastDown).as_deref(),
Some("Shift+↓")
);
assert_eq!(shortcut(Action::AddPodcast), None);
}
#[test]
fn a_shortcut_is_named_only_on_the_screen_that_binds_it() {
let mut app = test_app();
let config = command_for(Action::GoToConfig);
let refresh = command_for(Action::RefreshFeeds);
assert_eq!(config.shortcut_text(&app).as_deref(), Some("c"));
assert_eq!(refresh.shortcut_text(&app).as_deref(), Some("r"));
app.screen = Screen::EpisodeSelect;
assert_eq!(
config.shortcut_text(&app),
None,
"c opens the config editor from the podcast list only"
);
assert_eq!(refresh.shortcut_text(&app), None);
assert_eq!(
command_for(Action::OpenPodcastFolder)
.shortcut_text(&app)
.as_deref(),
Some("o"),
"o is bound here too, so it keeps its shortcut"
);
let mut app = test_app();
app.enter_config();
assert_eq!(
command_for(Action::GoToPodcastList)
.shortcut_text(&app)
.as_deref(),
Some("Esc")
);
}
#[test]
fn the_palette_names_no_shortcut_over_a_text_field() {
let mut app = test_app();
app.enter_config();
app.config_enter_dir_edit();
for cmd in COMMANDS {
assert_eq!(
cmd.shortcut_text(&app),
None,
"\"{}\" must not name a key that would type into the field behind the palette",
cmd.description
);
}
}
#[test]
fn an_open_palette_does_not_hide_the_quit_shortcut() {
let mut app = test_app();
app.palette_open();
assert_eq!(
command_for(Action::Quit).shortcut_text(&app).as_deref(),
Some("q")
);
assert_eq!(
command_for(Action::ToggleHintBar)
.shortcut_text(&app)
.as_deref(),
Some("? or .")
);
}
#[test]
fn palette_type_and_backspace() {
let mut app = test_app();
app.palette_open();
let initial_count = app.palette.matches.len();
app.palette_type('q');
assert_eq!(app.palette.query, "q");
assert!(app.palette.matches.len() <= initial_count);
app.palette_backspace();
assert_eq!(app.palette.query, "");
assert_eq!(app.palette.matches.len(), initial_count);
}
#[test]
fn palette_execute_returns_action() {
let mut app = test_app();
app.palette_open();
let action = app.palette_execute();
assert!(action.is_some());
}
}