diff --git a/television/action.rs b/television/action.rs index b7567b1..628c7ba 100644 --- a/television/action.rs +++ b/television/action.rs @@ -117,6 +117,47 @@ pub enum Action { MouseClickAt(u16, u16), } +#[derive( + Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, PartialOrd, Ord, +)] +#[serde(untagged)] +pub enum Actions { + Single(Action), + Multiple(Vec), +} + +impl Actions { + pub fn into_vec(self) -> Vec { + match self { + Actions::Single(action) => vec![action], + Actions::Multiple(actions) => actions, + } + } + + pub fn as_slice(&self) -> &[Action] { + match self { + Actions::Single(action) => std::slice::from_ref(action), + Actions::Multiple(actions) => actions.as_slice(), + } + } +} + +impl From for Actions { + fn from(action: Action) -> Self { + Actions::Single(action) + } +} + +impl From> for Actions { + fn from(actions: Vec) -> Self { + if actions.len() == 1 { + Actions::Single(actions.into_iter().next().unwrap()) + } else { + Actions::Multiple(actions) + } + } +} + impl Display for Action { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { match self { @@ -176,3 +217,106 @@ impl Display for Action { } } } + +#[cfg(test)] +mod tests { + use super::*; + + #[test] + fn test_actions_single() { + let single_action = Actions::Single(Action::Quit); + assert_eq!(single_action.into_vec(), vec![Action::Quit]); + + let single_from_action = Actions::from(Action::SelectNextEntry); + assert_eq!( + single_from_action, + Actions::Single(Action::SelectNextEntry) + ); + assert_eq!(single_from_action.as_slice(), &[Action::SelectNextEntry]); + } + + #[test] + fn test_actions_multiple() { + let actions_vec = vec![Action::CopyEntryToClipboard, Action::Quit]; + let multiple_actions = Actions::Multiple(actions_vec.clone()); + assert_eq!(multiple_actions.into_vec(), actions_vec); + + let multiple_from_vec = Actions::from(actions_vec.clone()); + assert_eq!(multiple_from_vec, Actions::Multiple(actions_vec.clone())); + assert_eq!(multiple_from_vec.as_slice(), actions_vec.as_slice()); + } + + #[test] + fn test_actions_from_single_vec_becomes_single() { + // When creating Actions from a Vec with only one element, it should become Single + let single_vec = vec![Action::TogglePreview]; + let actions = Actions::from(single_vec); + assert_eq!(actions, Actions::Single(Action::TogglePreview)); + } + + #[test] + fn test_actions_from_multi_vec_becomes_multiple() { + // When creating Actions from a Vec with multiple elements, it should become Multiple + let multi_vec = vec![ + Action::ReloadSource, + Action::SelectNextEntry, + Action::TogglePreview, + ]; + let actions = Actions::from(multi_vec.clone()); + assert_eq!(actions, Actions::Multiple(multi_vec)); + } + + #[test] + fn test_actions_as_slice() { + let single = Actions::Single(Action::DeleteLine); + assert_eq!(single.as_slice(), &[Action::DeleteLine]); + + let multiple = Actions::Multiple(vec![ + Action::ScrollPreviewUp, + Action::ScrollPreviewDown, + ]); + assert_eq!( + multiple.as_slice(), + &[Action::ScrollPreviewUp, Action::ScrollPreviewDown] + ); + } + + #[test] + fn test_actions_into_vec() { + let single = Actions::Single(Action::ConfirmSelection); + assert_eq!(single.into_vec(), vec![Action::ConfirmSelection]); + + let multiple = Actions::Multiple(vec![ + Action::ToggleHelp, + Action::ToggleStatusBar, + ]); + assert_eq!( + multiple.into_vec(), + vec![Action::ToggleHelp, Action::ToggleStatusBar] + ); + } + + #[test] + fn test_actions_hash_and_eq() { + use std::collections::HashMap; + + let actions1 = Actions::Single(Action::Quit); + let actions2 = Actions::Single(Action::Quit); + let actions3 = + Actions::Multiple(vec![Action::Quit, Action::ClearScreen]); + let actions4 = + Actions::Multiple(vec![Action::Quit, Action::ClearScreen]); + + assert_eq!(actions1, actions2); + assert_eq!(actions3, actions4); + assert_ne!(actions1, actions3); + + // Test that they can be used as HashMap keys + let mut map = HashMap::new(); + map.insert(actions1.clone(), "single"); + map.insert(actions3.clone(), "multiple"); + + assert_eq!(map.get(&actions2), Some(&"single")); + assert_eq!(map.get(&actions4), Some(&"multiple")); + } +} diff --git a/television/app.rs b/television/app.rs index 9ca26e2..858c039 100644 --- a/television/app.rs +++ b/television/app.rs @@ -412,7 +412,8 @@ impl App { > 0 { for event in event_buf.drain(..) { - if let Some(action) = self.convert_event_to_action(event) { + let actions = self.convert_event_to_actions(event); + for action in actions { if action != Action::Tick { debug!("Queuing new action: {action:?}"); } @@ -481,24 +482,26 @@ impl App { /// mode the television is in. /// /// # Arguments - /// * `event` - The event to convert to an action. + /// * `event` - The event to convert to actions. /// /// # Returns - /// The action that corresponds to the given event. - fn convert_event_to_action(&self, event: Event) -> Option { - let action = match event { + /// A vector of actions that correspond to the given event. Multiple actions + /// will be returned for keys/events bound to action sequences. + fn convert_event_to_actions(&self, event: Event) -> Vec { + let actions = match event { Event::Input(keycode) => { - // First try to get action based on keybindings - if let Some(action) = - self.input_map.get_action_for_key(&keycode) + // First try to get actions based on keybindings + if let Some(actions) = + self.input_map.get_actions_for_key(&keycode) { - debug!("Keybinding found: {action:?}"); - action.clone() + let actions_vec = actions.as_slice().to_vec(); + debug!("Keybinding found: {actions_vec:?}"); + actions_vec } else { // fallback to text input events match keycode { - Key::Char(c) => Action::AddInputChar(c), - _ => Action::NoOp, + Key::Char(c) => vec![Action::AddInputChar(c)], + _ => vec![Action::NoOp], } } } @@ -510,29 +513,32 @@ impl App { position: (mouse_event.column, mouse_event.row), }); self.input_map - .get_action_for_input(&input_event) - .unwrap_or(Action::NoOp) + .get_actions_for_input(&input_event) + .unwrap_or_else(|| vec![Action::NoOp]) } else { - Action::NoOp + vec![Action::NoOp] } } // terminal events - Event::Tick => Action::Tick, - Event::Resize(x, y) => Action::Resize(x, y), - Event::FocusGained => Action::Resume, - Event::FocusLost => Action::Suspend, - Event::Closed => Action::NoOp, + Event::Tick => vec![Action::Tick], + Event::Resize(x, y) => vec![Action::Resize(x, y)], + Event::FocusGained => vec![Action::Resume], + Event::FocusLost => vec![Action::Suspend], + Event::Closed => vec![Action::NoOp], }; - if action != Action::Tick { - trace!("Converted {event:?} to action: {action:?}"); + // Filter out Tick actions for logging + let non_tick_actions: Vec<&Action> = + actions.iter().filter(|a| **a != Action::Tick).collect(); + if !non_tick_actions.is_empty() { + trace!("Converted {event:?} to actions: {non_tick_actions:?}"); } - if action == Action::NoOp { - None - } else { - Some(action) - } + // Filter out NoOp actions + actions + .into_iter() + .filter(|action| *action != Action::NoOp) + .collect() } /// Handle actions. diff --git a/television/cable.rs b/television/cable.rs index 665854b..4f8742a 100644 --- a/television/cable.rs +++ b/television/cable.rs @@ -67,7 +67,7 @@ impl Cable { match binding { Binding::SingleKey(key) => Some(( *key, - Action::SwitchToChannel(name.clone()), + Action::SwitchToChannel(name.clone()).into(), )), // For multiple keys, use the first one Binding::MultipleKeys(keys) @@ -75,7 +75,8 @@ impl Cable { { Some(( keys[0], - Action::SwitchToChannel(name.clone()), + Action::SwitchToChannel(name.clone()) + .into(), )) } Binding::MultipleKeys(_) => None, diff --git a/television/channels/prototypes.rs b/television/channels/prototypes.rs index cad2240..9e27c6e 100644 --- a/television/channels/prototypes.rs +++ b/television/channels/prototypes.rs @@ -589,38 +589,41 @@ mod tests { ); let keybindings = prototype.keybindings.unwrap(); - assert_eq!(keybindings.bindings.get(&Key::Esc), Some(&Action::Quit)); + assert_eq!( + keybindings.bindings.get(&Key::Esc), + Some(&Action::Quit.into()) + ); assert_eq!( keybindings.bindings.get(&Key::Ctrl('c')), - Some(&Action::Quit) + Some(&Action::Quit.into()) ); assert_eq!( keybindings.bindings.get(&Key::Down), - Some(&Action::SelectNextEntry) + Some(&Action::SelectNextEntry.into()) ); assert_eq!( keybindings.bindings.get(&Key::Ctrl('n')), - Some(&Action::SelectNextEntry) + Some(&Action::SelectNextEntry.into()) ); assert_eq!( keybindings.bindings.get(&Key::Ctrl('j')), - Some(&Action::SelectNextEntry) + Some(&Action::SelectNextEntry.into()) ); assert_eq!( keybindings.bindings.get(&Key::Up), - Some(&Action::SelectPrevEntry) + Some(&Action::SelectPrevEntry.into()) ); assert_eq!( keybindings.bindings.get(&Key::Ctrl('p')), - Some(&Action::SelectPrevEntry) + Some(&Action::SelectPrevEntry.into()) ); assert_eq!( keybindings.bindings.get(&Key::Ctrl('k')), - Some(&Action::SelectPrevEntry) + Some(&Action::SelectPrevEntry.into()) ); assert_eq!( keybindings.bindings.get(&Key::Enter), - Some(&Action::ConfirmSelection) + Some(&Action::ConfirmSelection.into()) ); } diff --git a/television/cli/mod.rs b/television/cli/mod.rs index 86a2d39..8a2a199 100644 --- a/television/cli/mod.rs +++ b/television/cli/mod.rs @@ -650,9 +650,9 @@ mod tests { let post_processed_cli = post_process(cli, false); let mut expected = KeyBindings::default(); - expected.insert(Key::Esc, Action::Quit); - expected.insert(Key::Down, Action::SelectNextEntry); - expected.insert(Key::Ctrl('j'), Action::SelectNextEntry); + expected.insert(Key::Esc, Action::Quit.into()); + expected.insert(Key::Down, Action::SelectNextEntry.into()); + expected.insert(Key::Ctrl('j'), Action::SelectNextEntry.into()); assert_eq!(post_processed_cli.keybindings, Some(expected)); } diff --git a/television/config/keybindings.rs b/television/config/keybindings.rs index 0c97824..4e8d0ca 100644 --- a/television/config/keybindings.rs +++ b/television/config/keybindings.rs @@ -1,5 +1,5 @@ use crate::{ - action::Action, + action::{Action, Actions}, event::{Key, convert_raw_event_to_key}, }; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; @@ -44,7 +44,7 @@ pub struct KeyBindings { serialize_with = "serialize_key_bindings", deserialize_with = "deserialize_key_bindings" )] - pub bindings: FxHashMap, + pub bindings: FxHashMap, } #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] @@ -94,7 +94,7 @@ pub struct EventBindings { serialize_with = "serialize_event_bindings", deserialize_with = "deserialize_event_bindings" )] - pub bindings: FxHashMap, + pub bindings: FxHashMap, } impl From for KeyBindings @@ -103,7 +103,10 @@ where { fn from(iter: I) -> Self { KeyBindings { - bindings: iter.into_iter().collect(), + bindings: iter + .into_iter() + .map(|(k, a)| (k, Actions::from(a))) + .collect(), } } } @@ -114,7 +117,10 @@ where { fn from(iter: I) -> Self { EventBindings { - bindings: iter.into_iter().collect(), + bindings: iter + .into_iter() + .map(|(e, a)| (e, Actions::from(a))) + .collect(), } } } @@ -122,9 +128,9 @@ where impl Hash for KeyBindings { fn hash(&self, state: &mut H) { // Hash based on the bindings map - for (key, action) in &self.bindings { + for (key, actions) in &self.bindings { key.hash(state); - action.hash(state); + actions.hash(state); } } } @@ -132,15 +138,15 @@ impl Hash for KeyBindings { impl Hash for EventBindings { fn hash(&self, state: &mut H) { // Hash based on the bindings map - for (event, action) in &self.bindings { + for (event, actions) in &self.bindings { event.hash(state); - action.hash(state); + actions.hash(state); } } } impl Deref for KeyBindings { - type Target = FxHashMap; + type Target = FxHashMap; fn deref(&self) -> &Self::Target { &self.bindings } @@ -153,7 +159,7 @@ impl DerefMut for KeyBindings { } impl Deref for EventBindings { - type Target = FxHashMap; + type Target = FxHashMap; fn deref(&self) -> &Self::Target { &self.bindings } @@ -172,8 +178,8 @@ pub fn merge_keybindings( mut keybindings: KeyBindings, new_keybindings: &KeyBindings, ) -> KeyBindings { - for (key, action) in &new_keybindings.bindings { - keybindings.bindings.insert(*key, action.clone()); + for (key, actions) in &new_keybindings.bindings { + keybindings.bindings.insert(*key, actions.clone()); } keybindings } @@ -185,10 +191,10 @@ pub fn merge_event_bindings( mut event_bindings: EventBindings, new_event_bindings: &EventBindings, ) -> EventBindings { - for (event_type, action) in &new_event_bindings.bindings { + for (event_type, actions) in &new_event_bindings.bindings { event_bindings .bindings - .insert(event_type.clone(), action.clone()); + .insert(event_type.clone(), actions.clone()); } event_bindings } @@ -198,52 +204,53 @@ impl Default for KeyBindings { let mut bindings = FxHashMap::default(); // Quit actions - bindings.insert(Key::Esc, Action::Quit); - bindings.insert(Key::Ctrl('c'), Action::Quit); + bindings.insert(Key::Esc, Action::Quit.into()); + bindings.insert(Key::Ctrl('c'), Action::Quit.into()); // Navigation - bindings.insert(Key::Down, Action::SelectNextEntry); - bindings.insert(Key::Ctrl('n'), Action::SelectNextEntry); - bindings.insert(Key::Ctrl('j'), Action::SelectNextEntry); - bindings.insert(Key::Up, Action::SelectPrevEntry); - bindings.insert(Key::Ctrl('p'), Action::SelectPrevEntry); - bindings.insert(Key::Ctrl('k'), Action::SelectPrevEntry); + bindings.insert(Key::Down, Action::SelectNextEntry.into()); + bindings.insert(Key::Ctrl('n'), Action::SelectNextEntry.into()); + bindings.insert(Key::Ctrl('j'), Action::SelectNextEntry.into()); + bindings.insert(Key::Up, Action::SelectPrevEntry.into()); + bindings.insert(Key::Ctrl('p'), Action::SelectPrevEntry.into()); + bindings.insert(Key::Ctrl('k'), Action::SelectPrevEntry.into()); // History navigation - bindings.insert(Key::CtrlUp, Action::SelectPrevHistory); - bindings.insert(Key::CtrlDown, Action::SelectNextHistory); + bindings.insert(Key::CtrlUp, Action::SelectPrevHistory.into()); + bindings.insert(Key::CtrlDown, Action::SelectNextHistory.into()); // Selection actions - bindings.insert(Key::Enter, Action::ConfirmSelection); - bindings.insert(Key::Tab, Action::ToggleSelectionDown); - bindings.insert(Key::BackTab, Action::ToggleSelectionUp); + bindings.insert(Key::Enter, Action::ConfirmSelection.into()); + bindings.insert(Key::Tab, Action::ToggleSelectionDown.into()); + bindings.insert(Key::BackTab, Action::ToggleSelectionUp.into()); // Preview actions - bindings.insert(Key::PageDown, Action::ScrollPreviewHalfPageDown); - bindings.insert(Key::PageUp, Action::ScrollPreviewHalfPageUp); + bindings + .insert(Key::PageDown, Action::ScrollPreviewHalfPageDown.into()); + bindings.insert(Key::PageUp, Action::ScrollPreviewHalfPageUp.into()); // Clipboard and toggles - bindings.insert(Key::Ctrl('y'), Action::CopyEntryToClipboard); - bindings.insert(Key::Ctrl('r'), Action::ReloadSource); - bindings.insert(Key::Ctrl('s'), Action::CycleSources); + bindings.insert(Key::Ctrl('y'), Action::CopyEntryToClipboard.into()); + bindings.insert(Key::Ctrl('r'), Action::ReloadSource.into()); + bindings.insert(Key::Ctrl('s'), Action::CycleSources.into()); // UI Features - bindings.insert(Key::Ctrl('t'), Action::ToggleRemoteControl); - bindings.insert(Key::Ctrl('o'), Action::TogglePreview); - bindings.insert(Key::Ctrl('h'), Action::ToggleHelp); - bindings.insert(Key::F(12), Action::ToggleStatusBar); + bindings.insert(Key::Ctrl('t'), Action::ToggleRemoteControl.into()); + bindings.insert(Key::Ctrl('o'), Action::TogglePreview.into()); + bindings.insert(Key::Ctrl('h'), Action::ToggleHelp.into()); + bindings.insert(Key::F(12), Action::ToggleStatusBar.into()); // Input field actions - bindings.insert(Key::Backspace, Action::DeletePrevChar); - bindings.insert(Key::Ctrl('w'), Action::DeletePrevWord); - bindings.insert(Key::Ctrl('u'), Action::DeleteLine); - bindings.insert(Key::Delete, Action::DeleteNextChar); - bindings.insert(Key::Left, Action::GoToPrevChar); - bindings.insert(Key::Right, Action::GoToNextChar); - bindings.insert(Key::Home, Action::GoToInputStart); - bindings.insert(Key::Ctrl('a'), Action::GoToInputStart); - bindings.insert(Key::End, Action::GoToInputEnd); - bindings.insert(Key::Ctrl('e'), Action::GoToInputEnd); + bindings.insert(Key::Backspace, Action::DeletePrevChar.into()); + bindings.insert(Key::Ctrl('w'), Action::DeletePrevWord.into()); + bindings.insert(Key::Ctrl('u'), Action::DeleteLine.into()); + bindings.insert(Key::Delete, Action::DeleteNextChar.into()); + bindings.insert(Key::Left, Action::GoToPrevChar.into()); + bindings.insert(Key::Right, Action::GoToNextChar.into()); + bindings.insert(Key::Home, Action::GoToInputStart.into()); + bindings.insert(Key::Ctrl('a'), Action::GoToInputStart.into()); + bindings.insert(Key::End, Action::GoToInputEnd.into()); + bindings.insert(Key::Ctrl('e'), Action::GoToInputEnd.into()); Self { bindings } } @@ -254,8 +261,12 @@ impl Default for EventBindings { let mut bindings = FxHashMap::default(); // Mouse events - bindings.insert(EventType::MouseScrollUp, Action::ScrollPreviewUp); - bindings.insert(EventType::MouseScrollDown, Action::ScrollPreviewDown); + bindings + .insert(EventType::MouseScrollUp, Action::ScrollPreviewUp.into()); + bindings.insert( + EventType::MouseScrollDown, + Action::ScrollPreviewDown.into(), + ); Self { bindings } } @@ -445,7 +456,7 @@ pub fn parse_key(raw: &str) -> anyhow::Result { /// Custom serializer for `KeyBindings` that converts `Key` enum to string for TOML compatibility fn serialize_key_bindings( - bindings: &FxHashMap, + bindings: &FxHashMap, serializer: S, ) -> Result where @@ -453,8 +464,8 @@ where { use serde::ser::SerializeMap; let mut map = serializer.serialize_map(Some(bindings.len()))?; - for (key, action) in bindings { - map.serialize_entry(&key.to_string(), action)?; + for (key, actions) in bindings { + map.serialize_entry(&key.to_string(), actions)?; } map.end() } @@ -462,7 +473,7 @@ where /// Custom deserializer for `KeyBindings` that parses string keys back to `Key` enum fn deserialize_key_bindings<'de, D>( deserializer: D, -) -> Result, D::Error> +) -> Result, D::Error> where D: serde::Deserializer<'de>, { @@ -472,10 +483,11 @@ where struct KeyBindingsVisitor; impl<'de> Visitor<'de> for KeyBindingsVisitor { - type Value = FxHashMap; + type Value = FxHashMap; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("a map with string keys and action values") + formatter + .write_str("a map with string keys and action/actions values") } fn visit_map(self, mut map: A) -> Result @@ -494,16 +506,16 @@ where match raw_value { Value::Boolean(false) => { // Explicitly unbind key - bindings.insert(key, Action::NoOp); + bindings.insert(key, Action::NoOp.into()); } Value::Boolean(true) => { // True means do nothing (keep current binding or ignore) } - action => { - // Try to deserialize as Action - let action = Action::deserialize(action) + actions_value => { + // Try to deserialize as Actions (handles both single and multiple) + let actions = Actions::deserialize(actions_value) .map_err(Error::custom)?; - bindings.insert(key, action); + bindings.insert(key, actions); } } } @@ -516,7 +528,7 @@ where /// Custom serializer for `EventBindings` that converts `EventType` enum to string for TOML compatibility fn serialize_event_bindings( - bindings: &FxHashMap, + bindings: &FxHashMap, serializer: S, ) -> Result where @@ -524,8 +536,8 @@ where { use serde::ser::SerializeMap; let mut map = serializer.serialize_map(Some(bindings.len()))?; - for (event_type, action) in bindings { - map.serialize_entry(&event_type.to_string(), action)?; + for (event_type, actions) in bindings { + map.serialize_entry(&event_type.to_string(), actions)?; } map.end() } @@ -533,7 +545,7 @@ where /// Custom deserializer for `EventBindings` that parses string keys back to `EventType` enum fn deserialize_event_bindings<'de, D>( deserializer: D, -) -> Result, D::Error> +) -> Result, D::Error> where D: serde::Deserializer<'de>, { @@ -543,19 +555,23 @@ where struct EventBindingsVisitor; impl<'de> Visitor<'de> for EventBindingsVisitor { - type Value = FxHashMap; + type Value = FxHashMap; fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { - formatter.write_str("a map with string keys and action values") + formatter + .write_str("a map with string keys and action/actions values") } fn visit_map(self, mut map: A) -> Result where A: MapAccess<'de>, { + use serde::de::Error; + use toml::Value; + let mut bindings = FxHashMap::default(); - while let Some((event_str, action)) = - map.next_entry::()? + while let Some((event_str, raw_value)) = + map.next_entry::()? { // Parse the event string back to EventType let event_type = match event_str.as_str() { @@ -565,7 +581,11 @@ where "resize" => EventType::Resize, custom => EventType::Custom(custom.to_string()), }; - bindings.insert(event_type, action); + + // Try to deserialize as Actions (handles both single and multiple) + let actions = + Actions::deserialize(raw_value).map_err(Error::custom)?; + bindings.insert(event_type, actions); } Ok(bindings) } @@ -750,21 +770,21 @@ mod tests { // Should contain both base and custom keybindings assert!(merged.bindings.contains_key(&Key::Esc)); - assert_eq!(merged.bindings.get(&Key::Esc), Some(&Action::Quit)); + assert_eq!(merged.bindings.get(&Key::Esc), Some(&Action::Quit.into())); assert!(merged.bindings.contains_key(&Key::Down)); assert_eq!( merged.bindings.get(&Key::Down), - Some(&Action::SelectNextEntry) + Some(&Action::SelectNextEntry.into()) ); assert!(merged.bindings.contains_key(&Key::Ctrl('j'))); assert_eq!( merged.bindings.get(&Key::Ctrl('j')), - Some(&Action::SelectNextEntry) + Some(&Action::SelectNextEntry.into()) ); assert!(merged.bindings.contains_key(&Key::PageDown)); assert_eq!( merged.bindings.get(&Key::PageDown), - Some(&Action::SelectNextPage) + Some(&Action::SelectNextPage.into()) ); } @@ -781,19 +801,157 @@ mod tests { .unwrap(); // Normal action binding should work - assert_eq!(keybindings.bindings.get(&Key::Esc), Some(&Action::Quit)); + assert_eq!( + keybindings.bindings.get(&Key::Esc), + Some(&Action::Quit.into()) + ); assert_eq!( keybindings.bindings.get(&Key::Down), - Some(&Action::SelectNextEntry) + Some(&Action::SelectNextEntry.into()) ); // false should bind to NoOp (unbinding) assert_eq!( keybindings.bindings.get(&Key::Ctrl('c')), - Some(&Action::NoOp) + Some(&Action::NoOp.into()) ); // true should be ignored (no binding created) assert_eq!(keybindings.bindings.get(&Key::Up), None); } + + #[test] + fn test_deserialize_multiple_actions_per_key() { + let keybindings: KeyBindings = toml::from_str( + r#" + "esc" = "quit" + "ctrl-s" = ["reload_source", "copy_entry_to_clipboard"] + "f1" = ["toggle_help", "toggle_preview", "toggle_status_bar"] + "#, + ) + .unwrap(); + + // Single action should work + assert_eq!( + keybindings.bindings.get(&Key::Esc), + Some(&Action::Quit.into()) + ); + + // Multiple actions should work + assert_eq!( + keybindings.bindings.get(&Key::Ctrl('s')), + Some(&Actions::Multiple(vec![ + Action::ReloadSource, + Action::CopyEntryToClipboard + ])) + ); + + // Three actions should work + assert_eq!( + keybindings.bindings.get(&Key::F(1)), + Some(&Actions::Multiple(vec![ + Action::ToggleHelp, + Action::TogglePreview, + Action::ToggleStatusBar + ])) + ); + } + + #[test] + fn test_merge_keybindings_with_multiple_actions() { + let base_keybindings = KeyBindings::from(vec![ + (Key::Esc, Action::Quit), + (Key::Enter, Action::ConfirmSelection), + ]); + + let mut custom_bindings = FxHashMap::default(); + custom_bindings.insert( + Key::Ctrl('s'), + Actions::Multiple(vec![ + Action::ReloadSource, + Action::CopyEntryToClipboard, + ]), + ); + custom_bindings.insert(Key::Esc, Action::NoOp.into()); // Override + let custom_keybindings = KeyBindings { + bindings: custom_bindings, + }; + + let merged = merge_keybindings(base_keybindings, &custom_keybindings); + + // Custom multiple actions should be present + assert_eq!( + merged.bindings.get(&Key::Ctrl('s')), + Some(&Actions::Multiple(vec![ + Action::ReloadSource, + Action::CopyEntryToClipboard + ])) + ); + + // Override should work + assert_eq!(merged.bindings.get(&Key::Esc), Some(&Action::NoOp.into())); + + // Original binding should be preserved + assert_eq!( + merged.bindings.get(&Key::Enter), + Some(&Action::ConfirmSelection.into()) + ); + } + + #[test] + fn test_complex_configuration_with_all_features() { + let keybindings: KeyBindings = toml::from_str( + r#" + # Single actions + esc = "quit" + enter = "confirm_selection" + + # Multiple actions + ctrl-s = ["reload_source", "copy_entry_to_clipboard"] + f1 = ["toggle_help", "toggle_preview", "toggle_status_bar"] + + # Unbinding + ctrl-c = false + + # Single action in array format (should work) + tab = ["toggle_selection_down"] + "#, + ) + .unwrap(); + + assert_eq!(keybindings.bindings.len(), 6); // ctrl-c=false creates NoOp binding + + // Verify all binding types work correctly + assert_eq!( + keybindings.bindings.get(&Key::Esc), + Some(&Actions::Single(Action::Quit)) + ); + assert_eq!( + keybindings.bindings.get(&Key::Enter), + Some(&Action::ConfirmSelection.into()) + ); + assert_eq!( + keybindings.bindings.get(&Key::Ctrl('s')), + Some(&Actions::Multiple(vec![ + Action::ReloadSource, + Action::CopyEntryToClipboard + ])) + ); + assert_eq!( + keybindings.bindings.get(&Key::F(1)), + Some(&Actions::Multiple(vec![ + Action::ToggleHelp, + Action::TogglePreview, + Action::ToggleStatusBar + ])) + ); + assert_eq!( + keybindings.bindings.get(&Key::Ctrl('c')), + Some(&Actions::Single(Action::NoOp)) + ); + assert_eq!( + keybindings.bindings.get(&Key::Tab), + Some(&Actions::Multiple(vec![Action::ToggleSelectionDown])) + ); + } } diff --git a/television/config/mod.rs b/television/config/mod.rs index f9c39fd..a041b73 100644 --- a/television/config/mod.rs +++ b/television/config/mod.rs @@ -474,7 +474,7 @@ mod tests { default_config .keybindings .bindings - .insert(Key::CtrlEnter, Action::ConfirmSelection); + .insert(Key::CtrlEnter, Action::ConfirmSelection.into()); default_config.shell_integration.keybindings.insert( "command_history".to_string(), diff --git a/television/keymap.rs b/television/keymap.rs index f6b5dcf..aad9c45 100644 --- a/television/keymap.rs +++ b/television/keymap.rs @@ -1,5 +1,5 @@ use crate::{ - action::Action, + action::{Action, Actions}, config::{EventBindings, EventType, KeyBindings}, event::{InputEvent, Key}, }; @@ -21,8 +21,8 @@ use rustc_hash::FxHashMap; /// } /// ``` pub struct InputMap { - pub key_actions: FxHashMap, - pub event_actions: FxHashMap, + pub key_actions: FxHashMap, + pub event_actions: FxHashMap, } impl InputMap { @@ -34,20 +34,46 @@ impl InputMap { } } - /// Get the action for a given key - pub fn get_action_for_key(&self, key: &Key) -> Option<&Action> { + /// Get the actions for a given key + pub fn get_actions_for_key(&self, key: &Key) -> Option<&Actions> { self.key_actions.get(key) } - /// Get the action for a given event type - pub fn get_action_for_event(&self, event: &EventType) -> Option<&Action> { + /// Get the actions for a given event type + pub fn get_actions_for_event( + &self, + event: &EventType, + ) -> Option<&Actions> { self.event_actions.get(event) } - /// Get an action for any input event - pub fn get_action_for_input(&self, input: &InputEvent) -> Option { + /// Get the action for a given key (backward compatibility) + pub fn get_action_for_key(&self, key: &Key) -> Option { + self.key_actions.get(key).and_then(|actions| match actions { + Actions::Single(action) => Some(action.clone()), + Actions::Multiple(actions_vec) => actions_vec.first().cloned(), + }) + } + + /// Get the action for a given event type (backward compatibility) + pub fn get_action_for_event(&self, event: &EventType) -> Option { + self.event_actions + .get(event) + .and_then(|actions| match actions { + Actions::Single(action) => Some(action.clone()), + Actions::Multiple(actions_vec) => actions_vec.first().cloned(), + }) + } + + /// Get all actions for any input event + pub fn get_actions_for_input( + &self, + input: &InputEvent, + ) -> Option> { match input { - InputEvent::Key(key) => self.get_action_for_key(key).cloned(), + InputEvent::Key(key) => self + .get_actions_for_key(key) + .map(|actions| actions.as_slice().to_vec()), InputEvent::Mouse(mouse_event) => { let event_type = match mouse_event.kind { MouseEventKind::Down(_) => EventType::MouseClick, @@ -55,14 +81,32 @@ impl InputMap { MouseEventKind::ScrollDown => EventType::MouseScrollDown, _ => return None, }; - self.get_action_for_event(&event_type).cloned() + self.get_actions_for_event(&event_type) + .map(|actions| actions.as_slice().to_vec()) + } + _ => None, + } + } + + /// Get an action for any input event (backward compatibility) + pub fn get_action_for_input(&self, input: &InputEvent) -> Option { + match input { + InputEvent::Key(key) => self.get_action_for_key(key), + InputEvent::Mouse(mouse_event) => { + let event_type = match mouse_event.kind { + MouseEventKind::Down(_) => EventType::MouseClick, + MouseEventKind::ScrollUp => EventType::MouseScrollUp, + MouseEventKind::ScrollDown => EventType::MouseScrollDown, + _ => return None, + }; + self.get_action_for_event(&event_type) } InputEvent::Resize(_, _) => { - self.get_action_for_event(&EventType::Resize).cloned() + self.get_action_for_event(&EventType::Resize) + } + InputEvent::Custom(name) => { + self.get_action_for_event(&EventType::Custom(name.clone())) } - InputEvent::Custom(name) => self - .get_action_for_event(&EventType::Custom(name.clone())) - .cloned(), } } } @@ -148,15 +192,15 @@ mod tests { let input_map: InputMap = (&keybindings).into(); assert_eq!( input_map.get_action_for_key(&Key::Char('j')), - Some(&Action::SelectNextEntry) + Some(Action::SelectNextEntry) ); assert_eq!( input_map.get_action_for_key(&Key::Char('k')), - Some(&Action::SelectPrevEntry) + Some(Action::SelectPrevEntry) ); assert_eq!( input_map.get_action_for_key(&Key::Char('q')), - Some(&Action::Quit) + Some(Action::Quit) ); } @@ -170,11 +214,11 @@ mod tests { let input_map: InputMap = (&event_bindings).into(); assert_eq!( input_map.get_action_for_event(&EventType::MouseClick), - Some(&Action::ConfirmSelection) + Some(Action::ConfirmSelection) ); assert_eq!( input_map.get_action_for_event(&EventType::Resize), - Some(&Action::ClearScreen) + Some(Action::ClearScreen) ); } @@ -212,34 +256,196 @@ mod tests { let mut input_map1 = InputMap::new(); input_map1 .key_actions - .insert(Key::Char('a'), Action::SelectNextEntry); + .insert(Key::Char('a'), Action::SelectNextEntry.into()); input_map1 .key_actions - .insert(Key::Char('b'), Action::SelectPrevEntry); + .insert(Key::Char('b'), Action::SelectPrevEntry.into()); let mut input_map2 = InputMap::new(); - input_map2.key_actions.insert(Key::Char('c'), Action::Quit); - input_map2.key_actions.insert(Key::Char('a'), Action::Quit); // This should overwrite + input_map2 + .key_actions + .insert(Key::Char('c'), Action::Quit.into()); + input_map2 + .key_actions + .insert(Key::Char('a'), Action::Quit.into()); // This should overwrite input_map2 .event_actions - .insert(EventType::MouseClick, Action::ConfirmSelection); + .insert(EventType::MouseClick, Action::ConfirmSelection.into()); input_map1.merge(&input_map2); assert_eq!( input_map1.get_action_for_key(&Key::Char('a')), - Some(&Action::Quit) + Some(Action::Quit) ); assert_eq!( input_map1.get_action_for_key(&Key::Char('b')), - Some(&Action::SelectPrevEntry) + Some(Action::SelectPrevEntry) ); assert_eq!( input_map1.get_action_for_key(&Key::Char('c')), - Some(&Action::Quit) + Some(Action::Quit) ); assert_eq!( input_map1.get_action_for_event(&EventType::MouseClick), - Some(&Action::ConfirmSelection) + Some(Action::ConfirmSelection) ); } + + #[test] + fn test_input_map_multiple_actions_per_key() { + let mut key_actions = FxHashMap::default(); + key_actions.insert( + Key::Ctrl('s'), + Actions::Multiple(vec![ + Action::ReloadSource, + Action::CopyEntryToClipboard, + ]), + ); + key_actions.insert(Key::Esc, Actions::Single(Action::Quit)); + + let input_map = InputMap { + key_actions, + event_actions: FxHashMap::default(), + }; + + // Test getting all actions for multiple action binding + let ctrl_s_actions = + input_map.get_actions_for_key(&Key::Ctrl('s')).unwrap(); + assert_eq!( + ctrl_s_actions.as_slice(), + &[Action::ReloadSource, Action::CopyEntryToClipboard] + ); + + // Test backward compatibility method returns first action + assert_eq!( + input_map.get_action_for_key(&Key::Ctrl('s')), + Some(Action::ReloadSource) + ); + + // Test single action still works + assert_eq!( + input_map.get_action_for_key(&Key::Esc), + Some(Action::Quit) + ); + } + + #[test] + fn test_input_map_multiple_actions_for_input_event() { + let mut input_map = InputMap::new(); + input_map.key_actions.insert( + Key::Char('j'), + Actions::Multiple(vec![ + Action::SelectNextEntry, + Action::ScrollPreviewDown, + ]), + ); + + let key_input = InputEvent::Key(Key::Char('j')); + let actions = input_map.get_actions_for_input(&key_input).unwrap(); + + assert_eq!(actions.len(), 2); + assert_eq!(actions[0], Action::SelectNextEntry); + assert_eq!(actions[1], Action::ScrollPreviewDown); + + // Test backward compatibility returns first action + assert_eq!( + input_map.get_action_for_input(&key_input), + Some(Action::SelectNextEntry) + ); + } + + #[test] + fn test_input_map_multiple_actions_for_events() { + let mut event_actions = FxHashMap::default(); + event_actions.insert( + EventType::MouseClick, + Actions::Multiple(vec![ + Action::ConfirmSelection, + Action::TogglePreview, + ]), + ); + + let input_map = InputMap { + key_actions: FxHashMap::default(), + event_actions, + }; + + // Test mouse input with multiple actions + let mouse_input = InputEvent::Mouse(MouseInputEvent { + kind: MouseEventKind::Down(crossterm::event::MouseButton::Left), + position: (10, 10), + }); + + let actions = input_map.get_actions_for_input(&mouse_input).unwrap(); + assert_eq!(actions.len(), 2); + assert_eq!(actions[0], Action::ConfirmSelection); + assert_eq!(actions[1], Action::TogglePreview); + + // Test backward compatibility returns first action + assert_eq!( + input_map.get_action_for_input(&mouse_input), + Some(Action::ConfirmSelection) + ); + } + + #[test] + fn test_input_map_merge_multiple_actions() { + let mut input_map1 = InputMap::new(); + input_map1 + .key_actions + .insert(Key::Char('a'), Actions::Single(Action::SelectNextEntry)); + + let mut input_map2 = InputMap::new(); + input_map2.key_actions.insert( + Key::Char('a'), + Actions::Multiple(vec![Action::ReloadSource, Action::Quit]), // This should overwrite + ); + input_map2.key_actions.insert( + Key::Char('b'), + Actions::Multiple(vec![Action::TogglePreview, Action::ToggleHelp]), + ); + + input_map1.merge(&input_map2); + + // Verify the multiple actions overwrite worked + let actions_a = + input_map1.get_actions_for_key(&Key::Char('a')).unwrap(); + assert_eq!( + actions_a.as_slice(), + &[Action::ReloadSource, Action::Quit] + ); + + // Verify the new multiple actions were added + let actions_b = + input_map1.get_actions_for_key(&Key::Char('b')).unwrap(); + assert_eq!( + actions_b.as_slice(), + &[Action::TogglePreview, Action::ToggleHelp] + ); + } + + #[test] + fn test_input_map_from_keybindings_with_multiple_actions() { + let mut bindings = FxHashMap::default(); + bindings.insert( + Key::Ctrl('r'), + Actions::Multiple(vec![Action::ReloadSource, Action::ClearScreen]), + ); + bindings.insert(Key::Esc, Actions::Single(Action::Quit)); + + let keybindings = KeyBindings { bindings }; + let input_map: InputMap = (&keybindings).into(); + + // Test multiple actions are preserved + let ctrl_r_actions = + input_map.get_actions_for_key(&Key::Ctrl('r')).unwrap(); + assert_eq!( + ctrl_r_actions.as_slice(), + &[Action::ReloadSource, Action::ClearScreen] + ); + + // Test single actions still work + let esc_actions = input_map.get_actions_for_key(&Key::Esc).unwrap(); + assert_eq!(esc_actions.as_slice(), &[Action::Quit]); + } } diff --git a/television/main.rs b/television/main.rs index 3e13dae..084107b 100644 --- a/television/main.rs +++ b/television/main.rs @@ -154,7 +154,7 @@ fn apply_cli_overrides(args: &PostProcessedCli, config: &mut Config) { config.ui.features.disable(FeatureFlags::PreviewPanel); remove_action_bindings( &mut config.keybindings, - &Action::TogglePreview, + &Action::TogglePreview.into(), ); } else if args.hide_preview { config.ui.features.hide(FeatureFlags::PreviewPanel); @@ -171,7 +171,7 @@ fn apply_cli_overrides(args: &PostProcessedCli, config: &mut Config) { config.ui.features.disable(FeatureFlags::StatusBar); remove_action_bindings( &mut config.keybindings, - &Action::ToggleStatusBar, + &Action::ToggleStatusBar.into(), ); } else if args.hide_status_bar { config.ui.features.hide(FeatureFlags::StatusBar); @@ -184,7 +184,7 @@ fn apply_cli_overrides(args: &PostProcessedCli, config: &mut Config) { config.ui.features.disable(FeatureFlags::RemoteControl); remove_action_bindings( &mut config.keybindings, - &Action::ToggleRemoteControl, + &Action::ToggleRemoteControl.into(), ); } else if args.hide_remote { config.ui.features.hide(FeatureFlags::RemoteControl); @@ -195,7 +195,10 @@ fn apply_cli_overrides(args: &PostProcessedCli, config: &mut Config) { // Handle help panel flags if args.no_help_panel { config.ui.features.disable(FeatureFlags::HelpPanel); - remove_action_bindings(&mut config.keybindings, &Action::ToggleHelp); + remove_action_bindings( + &mut config.keybindings, + &Action::ToggleHelp.into(), + ); } else if args.hide_help_panel { config.ui.features.hide(FeatureFlags::HelpPanel); } else if args.show_help_panel { diff --git a/television/screen/help_panel.rs b/television/screen/help_panel.rs index ed94a21..0165c31 100644 --- a/television/screen/help_panel.rs +++ b/television/screen/help_panel.rs @@ -1,7 +1,7 @@ use crate::{ config::KeyBindings, screen::colors::Colorscheme, - screen::keybindings::{ActionMapping, find_keys_for_action}, + screen::keybindings::{ActionMapping, find_keys_for_single_action}, television::Mode, }; use ratatui::{ @@ -62,7 +62,7 @@ fn add_keybinding_lines_for_mappings( ) { for mapping in mappings { for (action, description) in &mapping.actions { - let keys = find_keys_for_action(keybindings, action); + let keys = find_keys_for_single_action(keybindings, action); for key in keys { lines.push(create_compact_keybinding_line( &key, diff --git a/television/screen/keybindings.rs b/television/screen/keybindings.rs index 1528646..a27d57d 100644 --- a/television/screen/keybindings.rs +++ b/television/screen/keybindings.rs @@ -1,5 +1,5 @@ use crate::{ - action::Action, + action::{Action, Actions}, config::{Binding, KeyBindings}, television::Mode, }; @@ -166,7 +166,7 @@ pub fn extract_keys_from_binding(binding: &Binding) -> Vec { /// Extract keys for a single action from the new Key->Action keybindings format pub fn find_keys_for_action( keybindings: &KeyBindings, - target_action: &Action, + target_action: &Actions, ) -> Vec { keybindings .bindings @@ -181,10 +181,35 @@ pub fn find_keys_for_action( .collect() } +/// Extract keys for a single action (convenience function) +pub fn find_keys_for_single_action( + keybindings: &KeyBindings, + target_action: &Action, +) -> Vec { + keybindings + .bindings + .iter() + .filter_map(|(key, actions)| { + // Check if this actions contains the target action + match actions { + Actions::Single(action) if action == target_action => { + Some(key.to_string()) + } + Actions::Multiple(action_list) + if action_list.contains(target_action) => + { + Some(key.to_string()) + } + _ => None, + } + }) + .collect() +} + /// Extract keys for multiple actions and return them as a flat vector pub fn extract_keys_for_actions( keybindings: &KeyBindings, - actions: &[Action], + actions: &[Actions], ) -> Vec { actions .iter() @@ -195,7 +220,7 @@ pub fn extract_keys_for_actions( /// Remove all keybindings for a specific action from `KeyBindings` pub fn remove_action_bindings( keybindings: &mut KeyBindings, - target_action: &Action, + target_action: &Actions, ) { keybindings .bindings diff --git a/television/screen/status_bar.rs b/television/screen/status_bar.rs index 0b6b1d1..3341d93 100644 --- a/television/screen/status_bar.rs +++ b/television/screen/status_bar.rs @@ -1,6 +1,6 @@ use crate::{ action::Action, draw::Ctx, features::FeatureFlags, - screen::keybindings::find_keys_for_action, television::Mode, + screen::keybindings::find_keys_for_single_action, television::Mode, }; use ratatui::{ Frame, @@ -133,7 +133,7 @@ pub fn draw_status_bar(f: &mut Frame<'_>, area: Rect, ctx: &Ctx) { .features .is_enabled(FeatureFlags::RemoteControl) { - let keys = find_keys_for_action( + let keys = find_keys_for_single_action( &ctx.config.keybindings, &Action::ToggleRemoteControl, ); @@ -154,7 +154,7 @@ pub fn draw_status_bar(f: &mut Frame<'_>, area: Rect, ctx: &Ctx) { .features .is_enabled(FeatureFlags::PreviewPanel) { - let keys = find_keys_for_action( + let keys = find_keys_for_single_action( &ctx.config.keybindings, &Action::TogglePreview, ); @@ -174,8 +174,10 @@ pub fn draw_status_bar(f: &mut Frame<'_>, area: Rect, ctx: &Ctx) { } // Add keybinding help hint (available in both modes) - let keys = - find_keys_for_action(&ctx.config.keybindings, &Action::ToggleHelp); + let keys = find_keys_for_single_action( + &ctx.config.keybindings, + &Action::ToggleHelp, + ); if let Some(key) = keys.first() { add_hint("Help", key); } diff --git a/television/television.rs b/television/television.rs index 8c688bd..ec17acf 100644 --- a/television/television.rs +++ b/television/television.rs @@ -241,7 +241,7 @@ impl Television { config.ui.features.disable(FeatureFlags::PreviewPanel); remove_action_bindings( &mut config.keybindings, - &Action::TogglePreview, + &Action::TogglePreview.into(), ); } @@ -968,7 +968,7 @@ mod test { let mut config = crate::config::Config::default(); config .keybindings - .insert(Key::Ctrl('n'), Action::SelectNextEntry); + .insert(Key::Ctrl('n'), Action::SelectNextEntry.into()); let prototype = toml::from_str::( @@ -999,7 +999,7 @@ mod test { assert_eq!( tv.config.keybindings.get(&Key::Ctrl('j')), - Some(&Action::SelectNextEntry), + Some(&Action::SelectNextEntry.into()), ); } }