feat(bindings): add support for running multiple actions per key/event

This commit is contained in:
lalvarezt 2025-07-22 15:53:42 +02:00
parent f305f4a55e
commit fbd9920179
13 changed files with 713 additions and 165 deletions

View File

@ -117,6 +117,47 @@ pub enum Action {
MouseClickAt(u16, u16), MouseClickAt(u16, u16),
} }
#[derive(
Debug, Clone, PartialEq, Eq, Serialize, Deserialize, Hash, PartialOrd, Ord,
)]
#[serde(untagged)]
pub enum Actions {
Single(Action),
Multiple(Vec<Action>),
}
impl Actions {
pub fn into_vec(self) -> Vec<Action> {
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<Action> for Actions {
fn from(action: Action) -> Self {
Actions::Single(action)
}
}
impl From<Vec<Action>> for Actions {
fn from(actions: Vec<Action>) -> Self {
if actions.len() == 1 {
Actions::Single(actions.into_iter().next().unwrap())
} else {
Actions::Multiple(actions)
}
}
}
impl Display for Action { impl Display for Action {
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
match self { 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"));
}
}

View File

@ -412,7 +412,8 @@ impl App {
> 0 > 0
{ {
for event in event_buf.drain(..) { 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 { if action != Action::Tick {
debug!("Queuing new action: {action:?}"); debug!("Queuing new action: {action:?}");
} }
@ -481,24 +482,26 @@ impl App {
/// mode the television is in. /// mode the television is in.
/// ///
/// # Arguments /// # Arguments
/// * `event` - The event to convert to an action. /// * `event` - The event to convert to actions.
/// ///
/// # Returns /// # Returns
/// The action that corresponds to the given event. /// A vector of actions that correspond to the given event. Multiple actions
fn convert_event_to_action(&self, event: Event<Key>) -> Option<Action> { /// will be returned for keys/events bound to action sequences.
let action = match event { fn convert_event_to_actions(&self, event: Event<Key>) -> Vec<Action> {
let actions = match event {
Event::Input(keycode) => { Event::Input(keycode) => {
// First try to get action based on keybindings // First try to get actions based on keybindings
if let Some(action) = if let Some(actions) =
self.input_map.get_action_for_key(&keycode) self.input_map.get_actions_for_key(&keycode)
{ {
debug!("Keybinding found: {action:?}"); let actions_vec = actions.as_slice().to_vec();
action.clone() debug!("Keybinding found: {actions_vec:?}");
actions_vec
} else { } else {
// fallback to text input events // fallback to text input events
match keycode { match keycode {
Key::Char(c) => Action::AddInputChar(c), Key::Char(c) => vec![Action::AddInputChar(c)],
_ => Action::NoOp, _ => vec![Action::NoOp],
} }
} }
} }
@ -510,29 +513,32 @@ impl App {
position: (mouse_event.column, mouse_event.row), position: (mouse_event.column, mouse_event.row),
}); });
self.input_map self.input_map
.get_action_for_input(&input_event) .get_actions_for_input(&input_event)
.unwrap_or(Action::NoOp) .unwrap_or_else(|| vec![Action::NoOp])
} else { } else {
Action::NoOp vec![Action::NoOp]
} }
} }
// terminal events // terminal events
Event::Tick => Action::Tick, Event::Tick => vec![Action::Tick],
Event::Resize(x, y) => Action::Resize(x, y), Event::Resize(x, y) => vec![Action::Resize(x, y)],
Event::FocusGained => Action::Resume, Event::FocusGained => vec![Action::Resume],
Event::FocusLost => Action::Suspend, Event::FocusLost => vec![Action::Suspend],
Event::Closed => Action::NoOp, Event::Closed => vec![Action::NoOp],
}; };
if action != Action::Tick { // Filter out Tick actions for logging
trace!("Converted {event:?} to action: {action:?}"); 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 { // Filter out NoOp actions
None actions
} else { .into_iter()
Some(action) .filter(|action| *action != Action::NoOp)
} .collect()
} }
/// Handle actions. /// Handle actions.

View File

@ -67,7 +67,7 @@ impl Cable {
match binding { match binding {
Binding::SingleKey(key) => Some(( Binding::SingleKey(key) => Some((
*key, *key,
Action::SwitchToChannel(name.clone()), Action::SwitchToChannel(name.clone()).into(),
)), )),
// For multiple keys, use the first one // For multiple keys, use the first one
Binding::MultipleKeys(keys) Binding::MultipleKeys(keys)
@ -75,7 +75,8 @@ impl Cable {
{ {
Some(( Some((
keys[0], keys[0],
Action::SwitchToChannel(name.clone()), Action::SwitchToChannel(name.clone())
.into(),
)) ))
} }
Binding::MultipleKeys(_) => None, Binding::MultipleKeys(_) => None,

View File

@ -589,38 +589,41 @@ mod tests {
); );
let keybindings = prototype.keybindings.unwrap(); 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!( assert_eq!(
keybindings.bindings.get(&Key::Ctrl('c')), keybindings.bindings.get(&Key::Ctrl('c')),
Some(&Action::Quit) Some(&Action::Quit.into())
); );
assert_eq!( assert_eq!(
keybindings.bindings.get(&Key::Down), keybindings.bindings.get(&Key::Down),
Some(&Action::SelectNextEntry) Some(&Action::SelectNextEntry.into())
); );
assert_eq!( assert_eq!(
keybindings.bindings.get(&Key::Ctrl('n')), keybindings.bindings.get(&Key::Ctrl('n')),
Some(&Action::SelectNextEntry) Some(&Action::SelectNextEntry.into())
); );
assert_eq!( assert_eq!(
keybindings.bindings.get(&Key::Ctrl('j')), keybindings.bindings.get(&Key::Ctrl('j')),
Some(&Action::SelectNextEntry) Some(&Action::SelectNextEntry.into())
); );
assert_eq!( assert_eq!(
keybindings.bindings.get(&Key::Up), keybindings.bindings.get(&Key::Up),
Some(&Action::SelectPrevEntry) Some(&Action::SelectPrevEntry.into())
); );
assert_eq!( assert_eq!(
keybindings.bindings.get(&Key::Ctrl('p')), keybindings.bindings.get(&Key::Ctrl('p')),
Some(&Action::SelectPrevEntry) Some(&Action::SelectPrevEntry.into())
); );
assert_eq!( assert_eq!(
keybindings.bindings.get(&Key::Ctrl('k')), keybindings.bindings.get(&Key::Ctrl('k')),
Some(&Action::SelectPrevEntry) Some(&Action::SelectPrevEntry.into())
); );
assert_eq!( assert_eq!(
keybindings.bindings.get(&Key::Enter), keybindings.bindings.get(&Key::Enter),
Some(&Action::ConfirmSelection) Some(&Action::ConfirmSelection.into())
); );
} }

View File

@ -650,9 +650,9 @@ mod tests {
let post_processed_cli = post_process(cli, false); let post_processed_cli = post_process(cli, false);
let mut expected = KeyBindings::default(); let mut expected = KeyBindings::default();
expected.insert(Key::Esc, Action::Quit); expected.insert(Key::Esc, Action::Quit.into());
expected.insert(Key::Down, Action::SelectNextEntry); expected.insert(Key::Down, Action::SelectNextEntry.into());
expected.insert(Key::Ctrl('j'), Action::SelectNextEntry); expected.insert(Key::Ctrl('j'), Action::SelectNextEntry.into());
assert_eq!(post_processed_cli.keybindings, Some(expected)); assert_eq!(post_processed_cli.keybindings, Some(expected));
} }

View File

@ -1,5 +1,5 @@
use crate::{ use crate::{
action::Action, action::{Action, Actions},
event::{Key, convert_raw_event_to_key}, event::{Key, convert_raw_event_to_key},
}; };
use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers};
@ -44,7 +44,7 @@ pub struct KeyBindings {
serialize_with = "serialize_key_bindings", serialize_with = "serialize_key_bindings",
deserialize_with = "deserialize_key_bindings" deserialize_with = "deserialize_key_bindings"
)] )]
pub bindings: FxHashMap<Key, Action>, pub bindings: FxHashMap<Key, Actions>,
} }
#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] #[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)]
@ -94,7 +94,7 @@ pub struct EventBindings {
serialize_with = "serialize_event_bindings", serialize_with = "serialize_event_bindings",
deserialize_with = "deserialize_event_bindings" deserialize_with = "deserialize_event_bindings"
)] )]
pub bindings: FxHashMap<EventType, Action>, pub bindings: FxHashMap<EventType, Actions>,
} }
impl<I> From<I> for KeyBindings impl<I> From<I> for KeyBindings
@ -103,7 +103,10 @@ where
{ {
fn from(iter: I) -> Self { fn from(iter: I) -> Self {
KeyBindings { 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 { fn from(iter: I) -> Self {
EventBindings { 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 { impl Hash for KeyBindings {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) { fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
// Hash based on the bindings map // Hash based on the bindings map
for (key, action) in &self.bindings { for (key, actions) in &self.bindings {
key.hash(state); key.hash(state);
action.hash(state); actions.hash(state);
} }
} }
} }
@ -132,15 +138,15 @@ impl Hash for KeyBindings {
impl Hash for EventBindings { impl Hash for EventBindings {
fn hash<H: std::hash::Hasher>(&self, state: &mut H) { fn hash<H: std::hash::Hasher>(&self, state: &mut H) {
// Hash based on the bindings map // Hash based on the bindings map
for (event, action) in &self.bindings { for (event, actions) in &self.bindings {
event.hash(state); event.hash(state);
action.hash(state); actions.hash(state);
} }
} }
} }
impl Deref for KeyBindings { impl Deref for KeyBindings {
type Target = FxHashMap<Key, Action>; type Target = FxHashMap<Key, Actions>;
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
&self.bindings &self.bindings
} }
@ -153,7 +159,7 @@ impl DerefMut for KeyBindings {
} }
impl Deref for EventBindings { impl Deref for EventBindings {
type Target = FxHashMap<EventType, Action>; type Target = FxHashMap<EventType, Actions>;
fn deref(&self) -> &Self::Target { fn deref(&self) -> &Self::Target {
&self.bindings &self.bindings
} }
@ -172,8 +178,8 @@ pub fn merge_keybindings(
mut keybindings: KeyBindings, mut keybindings: KeyBindings,
new_keybindings: &KeyBindings, new_keybindings: &KeyBindings,
) -> KeyBindings { ) -> KeyBindings {
for (key, action) in &new_keybindings.bindings { for (key, actions) in &new_keybindings.bindings {
keybindings.bindings.insert(*key, action.clone()); keybindings.bindings.insert(*key, actions.clone());
} }
keybindings keybindings
} }
@ -185,10 +191,10 @@ pub fn merge_event_bindings(
mut event_bindings: EventBindings, mut event_bindings: EventBindings,
new_event_bindings: &EventBindings, new_event_bindings: &EventBindings,
) -> EventBindings { ) -> EventBindings {
for (event_type, action) in &new_event_bindings.bindings { for (event_type, actions) in &new_event_bindings.bindings {
event_bindings event_bindings
.bindings .bindings
.insert(event_type.clone(), action.clone()); .insert(event_type.clone(), actions.clone());
} }
event_bindings event_bindings
} }
@ -198,52 +204,53 @@ impl Default for KeyBindings {
let mut bindings = FxHashMap::default(); let mut bindings = FxHashMap::default();
// Quit actions // Quit actions
bindings.insert(Key::Esc, Action::Quit); bindings.insert(Key::Esc, Action::Quit.into());
bindings.insert(Key::Ctrl('c'), Action::Quit); bindings.insert(Key::Ctrl('c'), Action::Quit.into());
// Navigation // Navigation
bindings.insert(Key::Down, Action::SelectNextEntry); bindings.insert(Key::Down, Action::SelectNextEntry.into());
bindings.insert(Key::Ctrl('n'), Action::SelectNextEntry); bindings.insert(Key::Ctrl('n'), Action::SelectNextEntry.into());
bindings.insert(Key::Ctrl('j'), Action::SelectNextEntry); bindings.insert(Key::Ctrl('j'), Action::SelectNextEntry.into());
bindings.insert(Key::Up, Action::SelectPrevEntry); bindings.insert(Key::Up, Action::SelectPrevEntry.into());
bindings.insert(Key::Ctrl('p'), Action::SelectPrevEntry); bindings.insert(Key::Ctrl('p'), Action::SelectPrevEntry.into());
bindings.insert(Key::Ctrl('k'), Action::SelectPrevEntry); bindings.insert(Key::Ctrl('k'), Action::SelectPrevEntry.into());
// History navigation // History navigation
bindings.insert(Key::CtrlUp, Action::SelectPrevHistory); bindings.insert(Key::CtrlUp, Action::SelectPrevHistory.into());
bindings.insert(Key::CtrlDown, Action::SelectNextHistory); bindings.insert(Key::CtrlDown, Action::SelectNextHistory.into());
// Selection actions // Selection actions
bindings.insert(Key::Enter, Action::ConfirmSelection); bindings.insert(Key::Enter, Action::ConfirmSelection.into());
bindings.insert(Key::Tab, Action::ToggleSelectionDown); bindings.insert(Key::Tab, Action::ToggleSelectionDown.into());
bindings.insert(Key::BackTab, Action::ToggleSelectionUp); bindings.insert(Key::BackTab, Action::ToggleSelectionUp.into());
// Preview actions // Preview actions
bindings.insert(Key::PageDown, Action::ScrollPreviewHalfPageDown); bindings
bindings.insert(Key::PageUp, Action::ScrollPreviewHalfPageUp); .insert(Key::PageDown, Action::ScrollPreviewHalfPageDown.into());
bindings.insert(Key::PageUp, Action::ScrollPreviewHalfPageUp.into());
// Clipboard and toggles // Clipboard and toggles
bindings.insert(Key::Ctrl('y'), Action::CopyEntryToClipboard); bindings.insert(Key::Ctrl('y'), Action::CopyEntryToClipboard.into());
bindings.insert(Key::Ctrl('r'), Action::ReloadSource); bindings.insert(Key::Ctrl('r'), Action::ReloadSource.into());
bindings.insert(Key::Ctrl('s'), Action::CycleSources); bindings.insert(Key::Ctrl('s'), Action::CycleSources.into());
// UI Features // UI Features
bindings.insert(Key::Ctrl('t'), Action::ToggleRemoteControl); bindings.insert(Key::Ctrl('t'), Action::ToggleRemoteControl.into());
bindings.insert(Key::Ctrl('o'), Action::TogglePreview); bindings.insert(Key::Ctrl('o'), Action::TogglePreview.into());
bindings.insert(Key::Ctrl('h'), Action::ToggleHelp); bindings.insert(Key::Ctrl('h'), Action::ToggleHelp.into());
bindings.insert(Key::F(12), Action::ToggleStatusBar); bindings.insert(Key::F(12), Action::ToggleStatusBar.into());
// Input field actions // Input field actions
bindings.insert(Key::Backspace, Action::DeletePrevChar); bindings.insert(Key::Backspace, Action::DeletePrevChar.into());
bindings.insert(Key::Ctrl('w'), Action::DeletePrevWord); bindings.insert(Key::Ctrl('w'), Action::DeletePrevWord.into());
bindings.insert(Key::Ctrl('u'), Action::DeleteLine); bindings.insert(Key::Ctrl('u'), Action::DeleteLine.into());
bindings.insert(Key::Delete, Action::DeleteNextChar); bindings.insert(Key::Delete, Action::DeleteNextChar.into());
bindings.insert(Key::Left, Action::GoToPrevChar); bindings.insert(Key::Left, Action::GoToPrevChar.into());
bindings.insert(Key::Right, Action::GoToNextChar); bindings.insert(Key::Right, Action::GoToNextChar.into());
bindings.insert(Key::Home, Action::GoToInputStart); bindings.insert(Key::Home, Action::GoToInputStart.into());
bindings.insert(Key::Ctrl('a'), Action::GoToInputStart); bindings.insert(Key::Ctrl('a'), Action::GoToInputStart.into());
bindings.insert(Key::End, Action::GoToInputEnd); bindings.insert(Key::End, Action::GoToInputEnd.into());
bindings.insert(Key::Ctrl('e'), Action::GoToInputEnd); bindings.insert(Key::Ctrl('e'), Action::GoToInputEnd.into());
Self { bindings } Self { bindings }
} }
@ -254,8 +261,12 @@ impl Default for EventBindings {
let mut bindings = FxHashMap::default(); let mut bindings = FxHashMap::default();
// Mouse events // Mouse events
bindings.insert(EventType::MouseScrollUp, Action::ScrollPreviewUp); bindings
bindings.insert(EventType::MouseScrollDown, Action::ScrollPreviewDown); .insert(EventType::MouseScrollUp, Action::ScrollPreviewUp.into());
bindings.insert(
EventType::MouseScrollDown,
Action::ScrollPreviewDown.into(),
);
Self { bindings } Self { bindings }
} }
@ -445,7 +456,7 @@ pub fn parse_key(raw: &str) -> anyhow::Result<Key, String> {
/// Custom serializer for `KeyBindings` that converts `Key` enum to string for TOML compatibility /// Custom serializer for `KeyBindings` that converts `Key` enum to string for TOML compatibility
fn serialize_key_bindings<S>( fn serialize_key_bindings<S>(
bindings: &FxHashMap<Key, Action>, bindings: &FxHashMap<Key, Actions>,
serializer: S, serializer: S,
) -> Result<S::Ok, S::Error> ) -> Result<S::Ok, S::Error>
where where
@ -453,8 +464,8 @@ where
{ {
use serde::ser::SerializeMap; use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(Some(bindings.len()))?; let mut map = serializer.serialize_map(Some(bindings.len()))?;
for (key, action) in bindings { for (key, actions) in bindings {
map.serialize_entry(&key.to_string(), action)?; map.serialize_entry(&key.to_string(), actions)?;
} }
map.end() map.end()
} }
@ -462,7 +473,7 @@ where
/// Custom deserializer for `KeyBindings` that parses string keys back to `Key` enum /// Custom deserializer for `KeyBindings` that parses string keys back to `Key` enum
fn deserialize_key_bindings<'de, D>( fn deserialize_key_bindings<'de, D>(
deserializer: D, deserializer: D,
) -> Result<FxHashMap<Key, Action>, D::Error> ) -> Result<FxHashMap<Key, Actions>, D::Error>
where where
D: serde::Deserializer<'de>, D: serde::Deserializer<'de>,
{ {
@ -472,10 +483,11 @@ where
struct KeyBindingsVisitor; struct KeyBindingsVisitor;
impl<'de> Visitor<'de> for KeyBindingsVisitor { impl<'de> Visitor<'de> for KeyBindingsVisitor {
type Value = FxHashMap<Key, Action>; type Value = FxHashMap<Key, Actions>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 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<A>(self, mut map: A) -> Result<Self::Value, A::Error> fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
@ -494,16 +506,16 @@ where
match raw_value { match raw_value {
Value::Boolean(false) => { Value::Boolean(false) => {
// Explicitly unbind key // Explicitly unbind key
bindings.insert(key, Action::NoOp); bindings.insert(key, Action::NoOp.into());
} }
Value::Boolean(true) => { Value::Boolean(true) => {
// True means do nothing (keep current binding or ignore) // True means do nothing (keep current binding or ignore)
} }
action => { actions_value => {
// Try to deserialize as Action // Try to deserialize as Actions (handles both single and multiple)
let action = Action::deserialize(action) let actions = Actions::deserialize(actions_value)
.map_err(Error::custom)?; .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 /// Custom serializer for `EventBindings` that converts `EventType` enum to string for TOML compatibility
fn serialize_event_bindings<S>( fn serialize_event_bindings<S>(
bindings: &FxHashMap<EventType, Action>, bindings: &FxHashMap<EventType, Actions>,
serializer: S, serializer: S,
) -> Result<S::Ok, S::Error> ) -> Result<S::Ok, S::Error>
where where
@ -524,8 +536,8 @@ where
{ {
use serde::ser::SerializeMap; use serde::ser::SerializeMap;
let mut map = serializer.serialize_map(Some(bindings.len()))?; let mut map = serializer.serialize_map(Some(bindings.len()))?;
for (event_type, action) in bindings { for (event_type, actions) in bindings {
map.serialize_entry(&event_type.to_string(), action)?; map.serialize_entry(&event_type.to_string(), actions)?;
} }
map.end() map.end()
} }
@ -533,7 +545,7 @@ where
/// Custom deserializer for `EventBindings` that parses string keys back to `EventType` enum /// Custom deserializer for `EventBindings` that parses string keys back to `EventType` enum
fn deserialize_event_bindings<'de, D>( fn deserialize_event_bindings<'de, D>(
deserializer: D, deserializer: D,
) -> Result<FxHashMap<EventType, Action>, D::Error> ) -> Result<FxHashMap<EventType, Actions>, D::Error>
where where
D: serde::Deserializer<'de>, D: serde::Deserializer<'de>,
{ {
@ -543,19 +555,23 @@ where
struct EventBindingsVisitor; struct EventBindingsVisitor;
impl<'de> Visitor<'de> for EventBindingsVisitor { impl<'de> Visitor<'de> for EventBindingsVisitor {
type Value = FxHashMap<EventType, Action>; type Value = FxHashMap<EventType, Actions>;
fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { 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<A>(self, mut map: A) -> Result<Self::Value, A::Error> fn visit_map<A>(self, mut map: A) -> Result<Self::Value, A::Error>
where where
A: MapAccess<'de>, A: MapAccess<'de>,
{ {
use serde::de::Error;
use toml::Value;
let mut bindings = FxHashMap::default(); let mut bindings = FxHashMap::default();
while let Some((event_str, action)) = while let Some((event_str, raw_value)) =
map.next_entry::<String, Action>()? map.next_entry::<String, Value>()?
{ {
// Parse the event string back to EventType // Parse the event string back to EventType
let event_type = match event_str.as_str() { let event_type = match event_str.as_str() {
@ -565,7 +581,11 @@ where
"resize" => EventType::Resize, "resize" => EventType::Resize,
custom => EventType::Custom(custom.to_string()), 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) Ok(bindings)
} }
@ -750,21 +770,21 @@ mod tests {
// Should contain both base and custom keybindings // Should contain both base and custom keybindings
assert!(merged.bindings.contains_key(&Key::Esc)); 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!(merged.bindings.contains_key(&Key::Down));
assert_eq!( assert_eq!(
merged.bindings.get(&Key::Down), merged.bindings.get(&Key::Down),
Some(&Action::SelectNextEntry) Some(&Action::SelectNextEntry.into())
); );
assert!(merged.bindings.contains_key(&Key::Ctrl('j'))); assert!(merged.bindings.contains_key(&Key::Ctrl('j')));
assert_eq!( assert_eq!(
merged.bindings.get(&Key::Ctrl('j')), merged.bindings.get(&Key::Ctrl('j')),
Some(&Action::SelectNextEntry) Some(&Action::SelectNextEntry.into())
); );
assert!(merged.bindings.contains_key(&Key::PageDown)); assert!(merged.bindings.contains_key(&Key::PageDown));
assert_eq!( assert_eq!(
merged.bindings.get(&Key::PageDown), merged.bindings.get(&Key::PageDown),
Some(&Action::SelectNextPage) Some(&Action::SelectNextPage.into())
); );
} }
@ -781,19 +801,157 @@ mod tests {
.unwrap(); .unwrap();
// Normal action binding should work // 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!( assert_eq!(
keybindings.bindings.get(&Key::Down), keybindings.bindings.get(&Key::Down),
Some(&Action::SelectNextEntry) Some(&Action::SelectNextEntry.into())
); );
// false should bind to NoOp (unbinding) // false should bind to NoOp (unbinding)
assert_eq!( assert_eq!(
keybindings.bindings.get(&Key::Ctrl('c')), keybindings.bindings.get(&Key::Ctrl('c')),
Some(&Action::NoOp) Some(&Action::NoOp.into())
); );
// true should be ignored (no binding created) // true should be ignored (no binding created)
assert_eq!(keybindings.bindings.get(&Key::Up), None); 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]))
);
}
} }

View File

@ -474,7 +474,7 @@ mod tests {
default_config default_config
.keybindings .keybindings
.bindings .bindings
.insert(Key::CtrlEnter, Action::ConfirmSelection); .insert(Key::CtrlEnter, Action::ConfirmSelection.into());
default_config.shell_integration.keybindings.insert( default_config.shell_integration.keybindings.insert(
"command_history".to_string(), "command_history".to_string(),

View File

@ -1,5 +1,5 @@
use crate::{ use crate::{
action::Action, action::{Action, Actions},
config::{EventBindings, EventType, KeyBindings}, config::{EventBindings, EventType, KeyBindings},
event::{InputEvent, Key}, event::{InputEvent, Key},
}; };
@ -21,8 +21,8 @@ use rustc_hash::FxHashMap;
/// } /// }
/// ``` /// ```
pub struct InputMap { pub struct InputMap {
pub key_actions: FxHashMap<Key, Action>, pub key_actions: FxHashMap<Key, Actions>,
pub event_actions: FxHashMap<EventType, Action>, pub event_actions: FxHashMap<EventType, Actions>,
} }
impl InputMap { impl InputMap {
@ -34,20 +34,46 @@ impl InputMap {
} }
} }
/// Get the action for a given key /// Get the actions for a given key
pub fn get_action_for_key(&self, key: &Key) -> Option<&Action> { pub fn get_actions_for_key(&self, key: &Key) -> Option<&Actions> {
self.key_actions.get(key) self.key_actions.get(key)
} }
/// Get the action for a given event type /// Get the actions for a given event type
pub fn get_action_for_event(&self, event: &EventType) -> Option<&Action> { pub fn get_actions_for_event(
&self,
event: &EventType,
) -> Option<&Actions> {
self.event_actions.get(event) self.event_actions.get(event)
} }
/// Get an action for any input event /// Get the action for a given key (backward compatibility)
pub fn get_action_for_input(&self, input: &InputEvent) -> Option<Action> { pub fn get_action_for_key(&self, key: &Key) -> Option<Action> {
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<Action> {
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<Vec<Action>> {
match input { 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) => { InputEvent::Mouse(mouse_event) => {
let event_type = match mouse_event.kind { let event_type = match mouse_event.kind {
MouseEventKind::Down(_) => EventType::MouseClick, MouseEventKind::Down(_) => EventType::MouseClick,
@ -55,14 +81,32 @@ impl InputMap {
MouseEventKind::ScrollDown => EventType::MouseScrollDown, MouseEventKind::ScrollDown => EventType::MouseScrollDown,
_ => return None, _ => 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<Action> {
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(_, _) => { 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(); let input_map: InputMap = (&keybindings).into();
assert_eq!( assert_eq!(
input_map.get_action_for_key(&Key::Char('j')), input_map.get_action_for_key(&Key::Char('j')),
Some(&Action::SelectNextEntry) Some(Action::SelectNextEntry)
); );
assert_eq!( assert_eq!(
input_map.get_action_for_key(&Key::Char('k')), input_map.get_action_for_key(&Key::Char('k')),
Some(&Action::SelectPrevEntry) Some(Action::SelectPrevEntry)
); );
assert_eq!( assert_eq!(
input_map.get_action_for_key(&Key::Char('q')), 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(); let input_map: InputMap = (&event_bindings).into();
assert_eq!( assert_eq!(
input_map.get_action_for_event(&EventType::MouseClick), input_map.get_action_for_event(&EventType::MouseClick),
Some(&Action::ConfirmSelection) Some(Action::ConfirmSelection)
); );
assert_eq!( assert_eq!(
input_map.get_action_for_event(&EventType::Resize), 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(); let mut input_map1 = InputMap::new();
input_map1 input_map1
.key_actions .key_actions
.insert(Key::Char('a'), Action::SelectNextEntry); .insert(Key::Char('a'), Action::SelectNextEntry.into());
input_map1 input_map1
.key_actions .key_actions
.insert(Key::Char('b'), Action::SelectPrevEntry); .insert(Key::Char('b'), Action::SelectPrevEntry.into());
let mut input_map2 = InputMap::new(); let mut input_map2 = InputMap::new();
input_map2.key_actions.insert(Key::Char('c'), Action::Quit); input_map2
input_map2.key_actions.insert(Key::Char('a'), Action::Quit); // This should overwrite .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 input_map2
.event_actions .event_actions
.insert(EventType::MouseClick, Action::ConfirmSelection); .insert(EventType::MouseClick, Action::ConfirmSelection.into());
input_map1.merge(&input_map2); input_map1.merge(&input_map2);
assert_eq!( assert_eq!(
input_map1.get_action_for_key(&Key::Char('a')), input_map1.get_action_for_key(&Key::Char('a')),
Some(&Action::Quit) Some(Action::Quit)
); );
assert_eq!( assert_eq!(
input_map1.get_action_for_key(&Key::Char('b')), input_map1.get_action_for_key(&Key::Char('b')),
Some(&Action::SelectPrevEntry) Some(Action::SelectPrevEntry)
); );
assert_eq!( assert_eq!(
input_map1.get_action_for_key(&Key::Char('c')), input_map1.get_action_for_key(&Key::Char('c')),
Some(&Action::Quit) Some(Action::Quit)
); );
assert_eq!( assert_eq!(
input_map1.get_action_for_event(&EventType::MouseClick), 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]);
}
} }

View File

@ -154,7 +154,7 @@ fn apply_cli_overrides(args: &PostProcessedCli, config: &mut Config) {
config.ui.features.disable(FeatureFlags::PreviewPanel); config.ui.features.disable(FeatureFlags::PreviewPanel);
remove_action_bindings( remove_action_bindings(
&mut config.keybindings, &mut config.keybindings,
&Action::TogglePreview, &Action::TogglePreview.into(),
); );
} else if args.hide_preview { } else if args.hide_preview {
config.ui.features.hide(FeatureFlags::PreviewPanel); 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); config.ui.features.disable(FeatureFlags::StatusBar);
remove_action_bindings( remove_action_bindings(
&mut config.keybindings, &mut config.keybindings,
&Action::ToggleStatusBar, &Action::ToggleStatusBar.into(),
); );
} else if args.hide_status_bar { } else if args.hide_status_bar {
config.ui.features.hide(FeatureFlags::StatusBar); 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); config.ui.features.disable(FeatureFlags::RemoteControl);
remove_action_bindings( remove_action_bindings(
&mut config.keybindings, &mut config.keybindings,
&Action::ToggleRemoteControl, &Action::ToggleRemoteControl.into(),
); );
} else if args.hide_remote { } else if args.hide_remote {
config.ui.features.hide(FeatureFlags::RemoteControl); config.ui.features.hide(FeatureFlags::RemoteControl);
@ -195,7 +195,10 @@ fn apply_cli_overrides(args: &PostProcessedCli, config: &mut Config) {
// Handle help panel flags // Handle help panel flags
if args.no_help_panel { if args.no_help_panel {
config.ui.features.disable(FeatureFlags::HelpPanel); 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 { } else if args.hide_help_panel {
config.ui.features.hide(FeatureFlags::HelpPanel); config.ui.features.hide(FeatureFlags::HelpPanel);
} else if args.show_help_panel { } else if args.show_help_panel {

View File

@ -1,7 +1,7 @@
use crate::{ use crate::{
config::KeyBindings, config::KeyBindings,
screen::colors::Colorscheme, screen::colors::Colorscheme,
screen::keybindings::{ActionMapping, find_keys_for_action}, screen::keybindings::{ActionMapping, find_keys_for_single_action},
television::Mode, television::Mode,
}; };
use ratatui::{ use ratatui::{
@ -62,7 +62,7 @@ fn add_keybinding_lines_for_mappings(
) { ) {
for mapping in mappings { for mapping in mappings {
for (action, description) in &mapping.actions { 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 { for key in keys {
lines.push(create_compact_keybinding_line( lines.push(create_compact_keybinding_line(
&key, &key,

View File

@ -1,5 +1,5 @@
use crate::{ use crate::{
action::Action, action::{Action, Actions},
config::{Binding, KeyBindings}, config::{Binding, KeyBindings},
television::Mode, television::Mode,
}; };
@ -166,7 +166,7 @@ pub fn extract_keys_from_binding(binding: &Binding) -> Vec<String> {
/// Extract keys for a single action from the new Key->Action keybindings format /// Extract keys for a single action from the new Key->Action keybindings format
pub fn find_keys_for_action( pub fn find_keys_for_action(
keybindings: &KeyBindings, keybindings: &KeyBindings,
target_action: &Action, target_action: &Actions,
) -> Vec<String> { ) -> Vec<String> {
keybindings keybindings
.bindings .bindings
@ -181,10 +181,35 @@ pub fn find_keys_for_action(
.collect() .collect()
} }
/// Extract keys for a single action (convenience function)
pub fn find_keys_for_single_action(
keybindings: &KeyBindings,
target_action: &Action,
) -> Vec<String> {
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 /// Extract keys for multiple actions and return them as a flat vector
pub fn extract_keys_for_actions( pub fn extract_keys_for_actions(
keybindings: &KeyBindings, keybindings: &KeyBindings,
actions: &[Action], actions: &[Actions],
) -> Vec<String> { ) -> Vec<String> {
actions actions
.iter() .iter()
@ -195,7 +220,7 @@ pub fn extract_keys_for_actions(
/// Remove all keybindings for a specific action from `KeyBindings` /// Remove all keybindings for a specific action from `KeyBindings`
pub fn remove_action_bindings( pub fn remove_action_bindings(
keybindings: &mut KeyBindings, keybindings: &mut KeyBindings,
target_action: &Action, target_action: &Actions,
) { ) {
keybindings keybindings
.bindings .bindings

View File

@ -1,6 +1,6 @@
use crate::{ use crate::{
action::Action, draw::Ctx, features::FeatureFlags, 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::{ use ratatui::{
Frame, Frame,
@ -133,7 +133,7 @@ pub fn draw_status_bar(f: &mut Frame<'_>, area: Rect, ctx: &Ctx) {
.features .features
.is_enabled(FeatureFlags::RemoteControl) .is_enabled(FeatureFlags::RemoteControl)
{ {
let keys = find_keys_for_action( let keys = find_keys_for_single_action(
&ctx.config.keybindings, &ctx.config.keybindings,
&Action::ToggleRemoteControl, &Action::ToggleRemoteControl,
); );
@ -154,7 +154,7 @@ pub fn draw_status_bar(f: &mut Frame<'_>, area: Rect, ctx: &Ctx) {
.features .features
.is_enabled(FeatureFlags::PreviewPanel) .is_enabled(FeatureFlags::PreviewPanel)
{ {
let keys = find_keys_for_action( let keys = find_keys_for_single_action(
&ctx.config.keybindings, &ctx.config.keybindings,
&Action::TogglePreview, &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) // Add keybinding help hint (available in both modes)
let keys = let keys = find_keys_for_single_action(
find_keys_for_action(&ctx.config.keybindings, &Action::ToggleHelp); &ctx.config.keybindings,
&Action::ToggleHelp,
);
if let Some(key) = keys.first() { if let Some(key) = keys.first() {
add_hint("Help", key); add_hint("Help", key);
} }

View File

@ -241,7 +241,7 @@ impl Television {
config.ui.features.disable(FeatureFlags::PreviewPanel); config.ui.features.disable(FeatureFlags::PreviewPanel);
remove_action_bindings( remove_action_bindings(
&mut config.keybindings, &mut config.keybindings,
&Action::TogglePreview, &Action::TogglePreview.into(),
); );
} }
@ -968,7 +968,7 @@ mod test {
let mut config = crate::config::Config::default(); let mut config = crate::config::Config::default();
config config
.keybindings .keybindings
.insert(Key::Ctrl('n'), Action::SelectNextEntry); .insert(Key::Ctrl('n'), Action::SelectNextEntry.into());
let prototype = let prototype =
toml::from_str::<crate::channels::prototypes::ChannelPrototype>( toml::from_str::<crate::channels::prototypes::ChannelPrototype>(
@ -999,7 +999,7 @@ mod test {
assert_eq!( assert_eq!(
tv.config.keybindings.get(&Key::Ctrl('j')), tv.config.keybindings.get(&Key::Ctrl('j')),
Some(&Action::SelectNextEntry), Some(&Action::SelectNextEntry.into()),
); );
} }
} }