feat: Refactor all keybinding tips into a dynamically changing menu that can be invoked via '?' [#32]

This commit is contained in:
2025-08-12 16:27:34 -06:00
parent 1f4870d082
commit 00ab0f27f7
64 changed files with 1627 additions and 903 deletions
+177 -3
View File
@@ -6,13 +6,20 @@ mod tests {
use rstest::rstest;
use tokio_util::sync::CancellationToken;
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::app::context_clues::SERVARR_CONTEXT_CLUES;
use crate::app::key_binding::{KeyBinding, DEFAULT_KEYBINDINGS};
use crate::app::radarr::radarr_context_clues::{
LIBRARY_CONTEXT_CLUES, MOVIE_DETAILS_CONTEXT_CLUES,
};
use crate::app::App;
use crate::event::Key;
use crate::handlers::handle_events;
use crate::handlers::{handle_clear_errors, handle_prompt_toggle};
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use crate::handlers::{handle_events, populate_keymapping_table};
use crate::models::servarr_data::radarr::radarr_data::{ActiveRadarrBlock, RadarrData};
use crate::models::servarr_data::sonarr::sonarr_data::ActiveSonarrBlock;
use crate::models::servarr_data::ActiveKeybindingBlock;
use crate::models::servarr_models::KeybindingItem;
use crate::models::stateful_table::StatefulTable;
use crate::models::HorizontallyScrollableText;
use crate::models::Route;
@@ -82,6 +89,82 @@ mod tests {
assert!(app.cancellation_token.is_cancelled());
}
#[test]
fn test_handle_populate_keybindings_table_on_help_button_press() {
let mut app = App::test_default();
let expected_keybinding_items = Vec::from(SERVARR_CONTEXT_CLUES)
.iter()
.map(|(key, desc)| context_clue_to_keybinding_item(key, desc))
.collect::<Vec<_>>();
app.push_navigation_stack(ActiveKeybindingBlock::Help.into());
handle_events(DEFAULT_KEYBINDINGS.help.key, &mut app);
assert!(app.keymapping_table.is_some());
assert_eq!(
expected_keybinding_items,
app.keymapping_table.unwrap().items
);
}
#[test]
fn test_handle_ignore_help_button_when_ignore_special_keys_for_textbox_input_is_true() {
let mut app = App::test_default();
app.ignore_special_keys_for_textbox_input = true;
app.push_navigation_stack(ActiveRadarrBlock::default().into());
handle_events(DEFAULT_KEYBINDINGS.help.key, &mut app);
assert!(app.keymapping_table.is_none());
}
#[test]
fn test_handle_empties_keybindings_table_on_help_button_press_when_keybindings_table_is_already_populated(
) {
let mut app = App::test_default();
let keybinding_items = Vec::from(SERVARR_CONTEXT_CLUES)
.iter()
.map(|(key, desc)| context_clue_to_keybinding_item(key, desc))
.collect::<Vec<_>>();
let mut stateful_table = StatefulTable::default();
stateful_table.set_items(keybinding_items);
app.keymapping_table = Some(stateful_table);
app.push_navigation_stack(ActiveRadarrBlock::default().into());
handle_events(DEFAULT_KEYBINDINGS.help.key, &mut app);
assert!(app.keymapping_table.is_none());
}
#[test]
fn test_handle_shows_keymapping_popup_when_keymapping_table_is_populated() {
let mut app = App::test_default();
let keybinding_items = Vec::from(SERVARR_CONTEXT_CLUES)
.iter()
.map(|(key, desc)| context_clue_to_keybinding_item(key, desc))
.collect::<Vec<_>>();
let mut stateful_table = StatefulTable::default();
stateful_table.set_items(keybinding_items);
app.keymapping_table = Some(stateful_table);
app.push_navigation_stack(ActiveRadarrBlock::default().into());
let expected_selection = KeybindingItem {
key: SERVARR_CONTEXT_CLUES[1].0.key.to_string(),
alt_key: SERVARR_CONTEXT_CLUES[1]
.0
.alt
.map_or(String::new(), |k| k.to_string()),
desc: SERVARR_CONTEXT_CLUES[1].1.to_string(),
};
handle_events(DEFAULT_KEYBINDINGS.down.key, &mut app);
assert!(app.keymapping_table.is_some());
assert_eq!(
&expected_selection,
app.keymapping_table.unwrap().current_selection()
);
}
#[rstest]
fn test_handle_prompt_toggle_left_right_radarr(#[values(Key::Left, Key::Right)] key: Key) {
let mut app = App::test_default();
@@ -113,4 +196,95 @@ mod tests {
assert!(!app.data.sonarr_data.prompt_confirm);
}
#[test]
fn test_populate_keymapping_table_global_options() {
let expected_keybinding_items = Vec::from(SERVARR_CONTEXT_CLUES)
.iter()
.map(|(key, desc)| {
let (key, alt_key) = if key.alt.is_some() {
(key.key.to_string(), key.alt.as_ref().unwrap().to_string())
} else {
(key.key.to_string(), String::new())
};
KeybindingItem {
key,
alt_key,
desc: desc.to_string(),
}
})
.collect::<Vec<_>>();
let mut app = App::test_default();
app.push_navigation_stack(ActiveKeybindingBlock::Help.into());
populate_keymapping_table(&mut app);
assert!(app.keymapping_table.is_some());
assert_eq!(
expected_keybinding_items,
app.keymapping_table.unwrap().items
);
}
#[test]
fn test_populate_keymapping_table_populates_servarr_specific_tab_info_before_global_options() {
let mut expected_keybinding_items = LIBRARY_CONTEXT_CLUES
.iter()
.map(|(key, desc)| context_clue_to_keybinding_item(key, desc))
.collect::<Vec<_>>();
expected_keybinding_items.extend(
SERVARR_CONTEXT_CLUES
.iter()
.map(|(key, desc)| context_clue_to_keybinding_item(key, desc)),
);
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.push_navigation_stack(ActiveRadarrBlock::default().into());
populate_keymapping_table(&mut app);
assert!(app.keymapping_table.is_some());
assert_eq!(
expected_keybinding_items,
app.keymapping_table.unwrap().items
);
}
#[test]
fn test_populate_keymapping_table_populates_delegated_servarr_context_provider_options_before_global_options(
) {
let mut expected_keybinding_items = MOVIE_DETAILS_CONTEXT_CLUES
.iter()
.map(|(key, desc)| context_clue_to_keybinding_item(key, desc))
.collect::<Vec<_>>();
expected_keybinding_items.extend(
SERVARR_CONTEXT_CLUES
.iter()
.map(|(key, desc)| context_clue_to_keybinding_item(key, desc)),
);
let mut app = App::test_default();
app.data.radarr_data = RadarrData::default();
app.push_navigation_stack(ActiveRadarrBlock::MovieDetails.into());
populate_keymapping_table(&mut app);
assert!(app.keymapping_table.is_some());
assert_eq!(
expected_keybinding_items,
app.keymapping_table.unwrap().items
);
}
fn context_clue_to_keybinding_item(key: &KeyBinding, desc: &&str) -> KeybindingItem {
let (key, alt_key) = if key.alt.is_some() {
(key.key.to_string(), key.alt.as_ref().unwrap().to_string())
} else {
(key.key.to_string(), String::new())
};
KeybindingItem {
key,
alt_key,
desc: desc.to_string(),
}
}
}
+80
View File
@@ -0,0 +1,80 @@
use crate::app::App;
use crate::event::Key;
use crate::handle_table_events;
use crate::handlers::table_handler::TableHandlingConfig;
use crate::handlers::KeyEventHandler;
use crate::models::servarr_data::ActiveKeybindingBlock;
use crate::models::servarr_models::KeybindingItem;
#[cfg(test)]
#[path = "keybinding_handler_tests.rs"]
mod keybinding_handler_tests;
pub(super) struct KeybindingHandler<'a, 'b> {
key: Key,
app: &'a mut App<'b>,
}
impl KeybindingHandler<'_, '_> {
handle_table_events!(
self,
keybindings,
self.app.keymapping_table.as_mut().unwrap(),
KeybindingItem
);
}
impl<'a, 'b> KeyEventHandler<'a, 'b, ActiveKeybindingBlock> for KeybindingHandler<'a, 'b> {
fn handle(&mut self) {
let keybinding_table_handling_config = TableHandlingConfig::new(self.app.get_current_route());
if !self.handle_keybindings_table_events(keybinding_table_handling_config) {
self.handle_key_event();
}
}
fn accepts(_active_block: ActiveKeybindingBlock) -> bool {
true
}
fn new(
key: Key,
app: &'a mut App<'b>,
_active_block: ActiveKeybindingBlock,
_context: Option<ActiveKeybindingBlock>,
) -> KeybindingHandler<'a, 'b> {
KeybindingHandler { key, app }
}
fn get_key(&self) -> Key {
self.key
}
fn ignore_special_keys(&self) -> bool {
self.app.ignore_special_keys_for_textbox_input
}
fn is_ready(&self) -> bool {
self.app.keymapping_table.is_some()
}
fn handle_scroll_up(&mut self) {}
fn handle_scroll_down(&mut self) {}
fn handle_home(&mut self) {}
fn handle_end(&mut self) {}
fn handle_delete(&mut self) {}
fn handle_left_right_action(&mut self) {}
fn handle_submit(&mut self) {}
fn handle_esc(&mut self) {
self.app.keymapping_table = None;
}
fn handle_char_key_event(&mut self) {}
}
+69
View File
@@ -0,0 +1,69 @@
#[cfg(test)]
mod tests {
use crate::app::key_binding::DEFAULT_KEYBINDINGS;
use crate::app::App;
use crate::event::Key;
use crate::handlers::KeyEventHandler;
use crate::handlers::KeybindingHandler;
use crate::models::servarr_data::ActiveKeybindingBlock;
use crate::models::stateful_table::StatefulTable;
use rstest::rstest;
mod test_handle_esc {
use super::*;
use crate::models::servarr_data::radarr::radarr_data::ActiveRadarrBlock;
use pretty_assertions::assert_eq;
const ESC_KEY: Key = DEFAULT_KEYBINDINGS.esc.key;
#[test]
fn test_esc_empties_keymapping_table() {
let mut app = App::test_default();
app.is_loading = true;
app.push_navigation_stack(ActiveRadarrBlock::Movies.into());
KeybindingHandler::new(ESC_KEY, &mut app, ActiveKeybindingBlock::Help, None).handle();
assert_eq!(app.get_current_route(), ActiveRadarrBlock::Movies.into());
assert!(app.keymapping_table.is_none());
}
}
#[test]
fn test_keybinding_handler_accepts() {
assert!(KeybindingHandler::accepts(ActiveKeybindingBlock::Help));
}
#[test]
fn test_keybinding_handler_not_ready_when_keybinding_is_empty() {
let mut app = App::test_default();
app.is_loading = false;
let handler = KeybindingHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveKeybindingBlock::Help,
None,
);
assert!(!handler.is_ready());
}
#[rstest]
fn test_keybinding_handler_ready_when_keymapping_table_is_not_empty(
#[values(true, false)] is_loading: bool,
) {
let mut app = App::test_default();
app.keymapping_table = Some(StatefulTable::default());
app.is_loading = is_loading;
let handler = KeybindingHandler::new(
DEFAULT_KEYBINDINGS.esc.key,
&mut app,
ActiveKeybindingBlock::Help,
None,
);
assert!(handler.is_ready());
}
}
+60
View File
@@ -1,11 +1,20 @@
use radarr_handlers::RadarrHandler;
use sonarr_handlers::SonarrHandler;
use crate::app::context_clues::{
ContextClueProvider, ServarrContextClueProvider, SERVARR_CONTEXT_CLUES,
};
use crate::app::key_binding::KeyBinding;
use crate::app::App;
use crate::event::Key;
use crate::handlers::keybinding_handler::KeybindingHandler;
use crate::matches_key;
use crate::models::servarr_data::ActiveKeybindingBlock;
use crate::models::servarr_models::KeybindingItem;
use crate::models::stateful_table::StatefulTable;
use crate::models::{HorizontallyScrollableText, Route};
mod keybinding_handler;
mod radarr_handlers;
mod sonarr_handlers;
@@ -97,8 +106,17 @@ pub fn handle_events(key: Key, app: &mut App<'_>) {
app.server_tabs.previous();
app.pop_and_push_navigation_stack(app.server_tabs.get_active_route());
app.cancellation_token.cancel();
} else if matches_key!(help, key) && !app.ignore_special_keys_for_textbox_input {
if app.keymapping_table.is_none() {
populate_keymapping_table(app);
} else {
app.keymapping_table = None;
}
} else {
match app.get_current_route() {
_ if app.keymapping_table.is_some() => {
KeybindingHandler::new(key, app, ActiveKeybindingBlock::Help, None).handle();
}
Route::Radarr(active_radarr_block, context) => {
RadarrHandler::new(key, app, active_radarr_block, context).handle()
}
@@ -110,6 +128,48 @@ pub fn handle_events(key: Key, app: &mut App<'_>) {
}
}
fn populate_keymapping_table(app: &mut App<'_>) {
let context_clue_to_keybinding_item = |key: &KeyBinding, desc: &&str| {
let (key, alt_key) = if key.alt.is_some() {
(key.key.to_string(), key.alt.as_ref().unwrap().to_string())
} else {
(key.key.to_string(), String::new())
};
KeybindingItem {
key,
alt_key,
desc: desc.to_string(),
}
};
let mut keybindings = Vec::new();
let global_keybindings = Vec::from(SERVARR_CONTEXT_CLUES)
.iter()
.map(|(key, desc)| context_clue_to_keybinding_item(key, desc))
.collect::<Vec<_>>();
if let Some(contextual_help) = app.server_tabs.get_active_route_contextual_help() {
keybindings.extend(
contextual_help
.iter()
.map(|(key, desc)| context_clue_to_keybinding_item(key, desc)),
);
}
if let Some(contextual_help) = ServarrContextClueProvider::get_context_clues(app) {
keybindings.extend(
contextual_help
.iter()
.map(|(key, desc)| context_clue_to_keybinding_item(key, desc)),
);
}
keybindings.extend(global_keybindings);
let mut table = StatefulTable::default();
table.set_items(keybindings);
app.keymapping_table = Some(table);
}
fn handle_clear_errors(app: &mut App<'_>) {
if !app.error.text.is_empty() {
app.error = HorizontallyScrollableText::default();