mirror of
https://github.com/alexpasmantier/television.git
synced 2025-07-29 06:11:37 +00:00
feat(bindings): add support for running multiple actions per key/event
This commit is contained in:
parent
f305f4a55e
commit
fbd9920179
@ -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<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 {
|
||||
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"));
|
||||
}
|
||||
}
|
||||
|
@ -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<Key>) -> Option<Action> {
|
||||
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<Key>) -> Vec<Action> {
|
||||
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.
|
||||
|
@ -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,
|
||||
|
@ -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())
|
||||
);
|
||||
}
|
||||
|
||||
|
@ -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));
|
||||
}
|
||||
|
@ -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<Key, Action>,
|
||||
pub bindings: FxHashMap<Key, Actions>,
|
||||
}
|
||||
|
||||
#[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<EventType, Action>,
|
||||
pub bindings: FxHashMap<EventType, Actions>,
|
||||
}
|
||||
|
||||
impl<I> From<I> 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<H: std::hash::Hasher>(&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<H: std::hash::Hasher>(&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<Key, Action>;
|
||||
type Target = FxHashMap<Key, Actions>;
|
||||
fn deref(&self) -> &Self::Target {
|
||||
&self.bindings
|
||||
}
|
||||
@ -153,7 +159,7 @@ impl DerefMut for KeyBindings {
|
||||
}
|
||||
|
||||
impl Deref for EventBindings {
|
||||
type Target = FxHashMap<EventType, Action>;
|
||||
type Target = FxHashMap<EventType, Actions>;
|
||||
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<Key, String> {
|
||||
|
||||
/// Custom serializer for `KeyBindings` that converts `Key` enum to string for TOML compatibility
|
||||
fn serialize_key_bindings<S>(
|
||||
bindings: &FxHashMap<Key, Action>,
|
||||
bindings: &FxHashMap<Key, Actions>,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error>
|
||||
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<FxHashMap<Key, Action>, D::Error>
|
||||
) -> Result<FxHashMap<Key, Actions>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
@ -472,10 +483,11 @@ where
|
||||
struct 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 {
|
||||
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>
|
||||
@ -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<S>(
|
||||
bindings: &FxHashMap<EventType, Action>,
|
||||
bindings: &FxHashMap<EventType, Actions>,
|
||||
serializer: S,
|
||||
) -> Result<S::Ok, S::Error>
|
||||
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<FxHashMap<EventType, Action>, D::Error>
|
||||
) -> Result<FxHashMap<EventType, Actions>, D::Error>
|
||||
where
|
||||
D: serde::Deserializer<'de>,
|
||||
{
|
||||
@ -543,19 +555,23 @@ where
|
||||
struct 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 {
|
||||
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>
|
||||
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::<String, Action>()?
|
||||
while let Some((event_str, raw_value)) =
|
||||
map.next_entry::<String, Value>()?
|
||||
{
|
||||
// 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]))
|
||||
);
|
||||
}
|
||||
}
|
||||
|
@ -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(),
|
||||
|
@ -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<Key, Action>,
|
||||
pub event_actions: FxHashMap<EventType, Action>,
|
||||
pub key_actions: FxHashMap<Key, Actions>,
|
||||
pub event_actions: FxHashMap<EventType, Actions>,
|
||||
}
|
||||
|
||||
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<Action> {
|
||||
/// Get the action for a given key (backward compatibility)
|
||||
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 {
|
||||
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<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(_, _) => {
|
||||
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]);
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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,
|
||||
|
@ -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<String> {
|
||||
/// 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<String> {
|
||||
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<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
|
||||
pub fn extract_keys_for_actions(
|
||||
keybindings: &KeyBindings,
|
||||
actions: &[Action],
|
||||
actions: &[Actions],
|
||||
) -> Vec<String> {
|
||||
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
|
||||
|
@ -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);
|
||||
}
|
||||
|
@ -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::<crate::channels::prototypes::ChannelPrototype>(
|
||||
@ -999,7 +999,7 @@ mod test {
|
||||
|
||||
assert_eq!(
|
||||
tv.config.keybindings.get(&Key::Ctrl('j')),
|
||||
Some(&Action::SelectNextEntry),
|
||||
Some(&Action::SelectNextEntry.into()),
|
||||
);
|
||||
}
|
||||
}
|
||||
|
Loading…
x
Reference in New Issue
Block a user