From b8be7a94d878a7cf0906245d2185ab3e4520a99d Mon Sep 17 00:00:00 2001 From: lalvarezt Date: Tue, 22 Jul 2025 09:18:48 +0200 Subject: [PATCH] feat(bindings)!: initial steps for a new and more expressive bindings system --- .config/config.toml | 70 +++-- television/action.rs | 68 +++++ television/app.rs | 84 +++--- television/cable.rs | 48 +-- television/channels/prototypes.rs | 56 ++-- television/cli/mod.rs | 13 +- television/config/keybindings.rs | 471 ++++++++++++++++++++++-------- television/config/mod.rs | 35 ++- television/event.rs | 22 +- television/keymap.rs | 291 ++++++++++++------ television/main.rs | 18 +- television/screen/help_panel.rs | 20 +- television/screen/keybindings.rs | 31 +- television/screen/status_bar.rs | 29 +- television/television.rs | 20 +- tests/cli/cli_input.rs | 28 +- tests/cli/cli_ui_behavior.rs | 2 +- 17 files changed, 911 insertions(+), 395 deletions(-) diff --git a/.config/config.toml b/.config/config.toml index 341db38..9232098 100644 --- a/.config/config.toml +++ b/.config/config.toml @@ -122,7 +122,7 @@ show_channel_descriptions = true sort_alphabetically = true -# Keybindings +# Keybindings and Events # ---------------------------------------------------------------------------- # # HARDCODED KEYBINDINGS (cannot be changed via config): @@ -135,58 +135,64 @@ sort_alphabetically = true # Home / Ctrl+a - Go to input start # End / Ctrl+e - Go to input end # -# CONFIGURABLE KEYBINDINGS (can be customized below): -# -------------------------------------------------- +# NEW CONFIGURATION FORMAT: +# ------------------------- +# The keybindings are now structured as Key -> Action mappings +# This provides better discoverability and eliminates configuration complexity +# [keybindings] # Application control # ------------------ -# Quit the application -quit = ["esc", "ctrl-c"] +esc = "quit" +ctrl-c = "quit" # Navigation and selection # ----------------------- -# Scrolling through entries -select_next_entry = ["down", "ctrl-n", "ctrl-j"] -select_prev_entry = ["up", "ctrl-p", "ctrl-k"] -#select_next_page = "pagedown" -#select_prev_page = "pageup" +down = "select_next_entry" +ctrl-n = "select_next_entry" +ctrl-j = "select_next_entry" +up = "select_prev_entry" +ctrl-p = "select_prev_entry" +ctrl-k = "select_prev_entry" # History navigation # ----------------- -# Navigate through search query history -select_prev_history = "ctrl-up" -select_next_history = "ctrl-down" +ctrl-up = "select_prev_history" +ctrl-down = "select_next_history" # Multi-selection # -------------- -# Add entry to selection and move to the next entry -toggle_selection_down = "tab" -# Add entry to selection and move to the previous entry -toggle_selection_up = "backtab" -# Confirm selection -confirm_selection = "enter" +tab = "toggle_selection_down" +backtab = "toggle_selection_up" +enter = "confirm_selection" # Preview panel control # -------------------- -# Scrolling the preview pane -scroll_preview_half_page_down = ["pagedown", "mousescrolldown"] -scroll_preview_half_page_up = ["pageup", "mousescrollup"] +pagedown = "scroll_preview_half_page_down" +pageup = "scroll_preview_half_page_up" # Data operations # -------------- -# Copy the selected entry to the clipboard -copy_entry_to_clipboard = "ctrl-y" -# Reload the current source -reload_source = "ctrl-r" -# Cycle through the available sources for the current channel -cycle_sources = "ctrl-s" +ctrl-y = "copy_entry_to_clipboard" +ctrl-r = "reload_source" +ctrl-s = "cycle_sources" # UI Features # ---------- -toggle_remote_control = "ctrl-t" -toggle_preview = "ctrl-o" -toggle_help = "ctrl-h" -toggle_status_bar = "f12" +ctrl-t = "toggle_remote_control" +ctrl-o = "toggle_preview" +ctrl-h = "toggle_help" +f12 = "toggle_status_bar" + +# Event bindings +# ---------------------------------------------------------------------------- +# Event bindings map non-keyboard events to actions +# This includes mouse events, resize events, and custom events +[events] +# Mouse events +# ----------- +mouse-scroll-up = "scroll_preview_up" +mouse-scroll-down = "scroll_preview_down" # Shell integration # ---------------------------------------------------------------------------- diff --git a/television/action.rs b/television/action.rs index 37e4de1..e599141 100644 --- a/television/action.rs +++ b/television/action.rs @@ -1,4 +1,5 @@ use serde::{Deserialize, Serialize}; +use std::fmt::Display; /// The different actions that can be performed by the application. #[derive( @@ -113,4 +114,71 @@ pub enum Action { SelectPrevHistory, /// Navigate to the next entry in the history. SelectNextHistory, + // Mouse and position-aware actions + /// Select an entry at a specific position (e.g., from mouse click) + #[serde(skip)] + SelectEntryAtPosition(u16, u16), + /// Handle mouse click event at specific coordinates + #[serde(skip)] + MouseClickAt(u16, u16), +} + +impl Display for Action { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + Action::AddInputChar(_) => write!(f, "add_input_char"), + Action::DeletePrevChar => write!(f, "delete_prev_char"), + Action::DeletePrevWord => write!(f, "delete_prev_word"), + Action::DeleteNextChar => write!(f, "delete_next_char"), + Action::DeleteLine => write!(f, "delete_line"), + Action::GoToPrevChar => write!(f, "go_to_prev_char"), + Action::GoToNextChar => write!(f, "go_to_next_char"), + Action::GoToInputStart => write!(f, "go_to_input_start"), + Action::GoToInputEnd => write!(f, "go_to_input_end"), + Action::Render => write!(f, "render"), + Action::Resize(_, _) => write!(f, "resize"), + Action::ClearScreen => write!(f, "clear_screen"), + Action::ToggleSelectionDown => write!(f, "toggle_selection_down"), + Action::ToggleSelectionUp => write!(f, "toggle_selection_up"), + Action::ConfirmSelection => write!(f, "confirm_selection"), + Action::SelectAndExit => write!(f, "select_and_exit"), + Action::SelectNextEntry => write!(f, "select_next_entry"), + Action::SelectPrevEntry => write!(f, "select_prev_entry"), + Action::SelectNextPage => write!(f, "select_next_page"), + Action::SelectPrevPage => write!(f, "select_prev_page"), + Action::CopyEntryToClipboard => { + write!(f, "copy_entry_to_clipboard") + } + Action::ScrollPreviewUp => write!(f, "scroll_preview_up"), + Action::ScrollPreviewDown => write!(f, "scroll_preview_down"), + Action::ScrollPreviewHalfPageUp => { + write!(f, "scroll_preview_half_page_up") + } + Action::ScrollPreviewHalfPageDown => { + write!(f, "scroll_preview_half_page_down") + } + Action::OpenEntry => write!(f, "open_entry"), + Action::Tick => write!(f, "tick"), + Action::Suspend => write!(f, "suspend"), + Action::Resume => write!(f, "resume"), + Action::Quit => write!(f, "quit"), + Action::ToggleRemoteControl => write!(f, "toggle_remote_control"), + Action::ToggleHelp => write!(f, "toggle_help"), + Action::ToggleStatusBar => write!(f, "toggle_status_bar"), + Action::TogglePreview => write!(f, "toggle_preview"), + Action::Error(_) => write!(f, "error"), + Action::NoOp => write!(f, "no_op"), + Action::ToggleSendToChannel => write!(f, "toggle_send_to_channel"), + Action::CycleSources => write!(f, "cycle_sources"), + Action::ReloadSource => write!(f, "reload_source"), + Action::SwitchToChannel(_) => write!(f, "switch_to_channel"), + Action::WatchTimer => write!(f, "watch_timer"), + Action::SelectPrevHistory => write!(f, "select_prev_history"), + Action::SelectNextHistory => write!(f, "select_next_history"), + Action::SelectEntryAtPosition(_, _) => { + write!(f, "select_entry_at_position") + } + Action::MouseClickAt(_, _) => write!(f, "mouse_click_at"), + } + } } diff --git a/television/app.rs b/television/app.rs index 5c93041..330c131 100644 --- a/television/app.rs +++ b/television/app.rs @@ -3,15 +3,14 @@ use crate::{ cable::Cable, channels::{entry::Entry, prototypes::ChannelPrototype}, config::{Config, DEFAULT_PREVIEW_SIZE, default_tick_rate}, - event::{Event, EventLoop, Key}, + event::{Event, EventLoop, InputEvent, Key, MouseInputEvent}, history::History, - keymap::Keymap, + keymap::InputMap, render::{RenderingTask, UiState, render}, television::{Mode, Television}, tui::{IoStream, Tui, TuiMode}, }; use anyhow::Result; -use crossterm::event::MouseEventKind; use rustc_hash::FxHashSet; use tokio::sync::mpsc; use tracing::{debug, error, trace}; @@ -101,7 +100,7 @@ impl AppOptions { /// The main application struct that holds the state of the application. pub struct App { - keymap: Keymap, + input_map: InputMap, /// The television instance that handles channels and entries. pub television: Television, /// A flag that indicates whether the application should quit during the next frame. @@ -201,9 +200,12 @@ impl App { cable_channels, ); - // Create keymap from the merged config that includes channel prototype keybindings - let keymap = Keymap::from(&television.config.keybindings); - debug!("{:?}", keymap); + // Create input map from the merged config that includes both key and event bindings + let input_map = InputMap::from(( + &television.config.keybindings, + &television.config.events, + )); + debug!("{:?}", input_map); let mut history = History::new( television.config.application.history_size, @@ -216,7 +218,7 @@ impl App { } let mut app = Self { - keymap, + input_map, television, should_quit: false, should_suspend: false, @@ -234,9 +236,9 @@ impl App { history, }; - // populate keymap by going through all cable channels and adding their shortcuts if remote + // populate input_map by going through all cable channels and adding their shortcuts if remote // control is present - app.update_keymap(); + app.update_input_map(); app } @@ -291,21 +293,26 @@ impl App { self.start_watch_timer(); } - /// Update the keymap from the television's current config. + /// Update the `input_map` from the television's current config. /// - /// This should be called whenever the channel changes to ensure the keymap includes the + /// This should be called whenever the channel changes to ensure the `input_map` includes the /// channel's keybindings and shortcuts for all other channels if the remote control is /// enabled. - fn update_keymap(&mut self) { - let mut keymap = Keymap::from(&self.television.config.keybindings); + fn update_input_map(&mut self) { + let mut input_map = InputMap::from(( + &self.television.config.keybindings, + &self.television.config.events, + )); // Add channel specific shortcuts if let Some(rc) = &self.television.remote_control { - keymap.merge(&rc.cable_channels.shortcut_keymap()); + let shortcut_keybindings = + rc.cable_channels.get_channels_shortcut_keybindings(); + input_map.merge_key_bindings(&shortcut_keybindings); } - self.keymap = keymap; - debug!("Updated keymap (with shortcuts): {:?}", self.keymap); + self.input_map = input_map; + debug!("Updated input_map (with shortcuts): {:?}", self.input_map); } /// Updates the history configuration to match the current channel. @@ -481,12 +488,14 @@ impl App { fn convert_event_to_action(&self, event: Event) -> Option { let action = match event { Event::Input(keycode) => { - // get action based on keybindings - if let Some(action) = self.keymap.get(&keycode) { + // First try to get action based on keybindings + if let Some(action) = + self.input_map.get_action_for_key(&keycode) + { debug!("Keybinding found: {action:?}"); action.clone() } else { - // text input events + // fallback to text input events match keycode { Key::Backspace => Action::DeletePrevChar, Key::Ctrl('w') => Action::DeletePrevWord, @@ -502,30 +511,15 @@ impl App { } } Event::Mouse(mouse_event) => { - // Handle mouse scroll events for preview panel, if keybindings are configured - // Only works in channel mode, regardless of mouse position + // Convert mouse event to InputEvent and use the input_map if self.television.mode == Mode::Channel { - match mouse_event.kind { - MouseEventKind::ScrollUp => { - if let Some(action) = - self.keymap.get(&Key::MouseScrollUp) - { - action.clone() - } else { - Action::NoOp - } - } - MouseEventKind::ScrollDown => { - if let Some(action) = - self.keymap.get(&Key::MouseScrollDown) - { - action.clone() - } else { - Action::NoOp - } - } - _ => Action::NoOp, - } + let input_event = InputEvent::Mouse(MouseInputEvent { + kind: mouse_event.kind, + position: (mouse_event.column, mouse_event.row), + }); + self.input_map + .get_action_for_input(&input_event) + .unwrap_or(Action::NoOp) } else { Action::NoOp } @@ -664,12 +658,12 @@ impl App { && matches!(action, Action::ConfirmSelection) && self.television.mode == Mode::Channel { - self.update_keymap(); + self.update_input_map(); self.update_history(); self.restart_watch_timer(); } else if matches!(action, Action::SwitchToChannel(_)) { // Channel changed via shortcut, refresh keymap and watch timer - self.update_keymap(); + self.update_input_map(); self.update_history(); self.restart_watch_timer(); } diff --git a/television/cable.rs b/television/cable.rs index ac72a9b..665854b 100644 --- a/television/cable.rs +++ b/television/cable.rs @@ -13,7 +13,6 @@ use crate::{ channels::prototypes::ChannelPrototype, config::{Binding, KeyBindings}, errors::unknown_channel_exit, - keymap::Keymap, }; /// A neat `HashMap` of channel prototypes indexed by their name. @@ -59,21 +58,38 @@ impl Cable { /// /// (e.g. "files" -> "F1", "dirs" -> "F2", etc.) pub fn get_channels_shortcut_keybindings(&self) -> KeyBindings { - KeyBindings( - self.iter() - .filter_map(|(name, prototype)| { - if let Some(keybindings) = &prototype.keybindings { - if let Some(binding) = &keybindings.shortcut { - return Some((name.clone(), binding)); + let bindings = self + .iter() + .filter_map(|(name, prototype)| { + if let Some(keybindings) = &prototype.keybindings { + if let Some(binding) = &keybindings.shortcut { + // Convert Binding to Key for new architecture + match binding { + Binding::SingleKey(key) => Some(( + *key, + Action::SwitchToChannel(name.clone()), + )), + // For multiple keys, use the first one + Binding::MultipleKeys(keys) + if !keys.is_empty() => + { + Some(( + keys[0], + Action::SwitchToChannel(name.clone()), + )) + } + Binding::MultipleKeys(_) => None, } + } else { + None } + } else { None - }) - .fold(FxHashMap::default(), |mut acc, (name, binding)| { - acc.insert(Action::SwitchToChannel(name), binding.clone()); - acc - }), - ) + } + }) + .collect(); + + KeyBindings { bindings } } /// Get a channel prototype's shortcut binding. @@ -87,12 +103,6 @@ impl Cable { .and_then(|keybindings| keybindings.shortcut.as_ref()) .cloned() } - - /// Build a `Keymap` that contains every channel shortcut defined in the cable. - /// Useful for merging directly into the application's global keymap. - pub fn shortcut_keymap(&self) -> Keymap { - Keymap::from(&self.get_channels_shortcut_keybindings()) - } } /// Just a proxy struct to deserialize prototypes diff --git a/television/channels/prototypes.rs b/television/channels/prototypes.rs index f44c823..cad2240 100644 --- a/television/channels/prototypes.rs +++ b/television/channels/prototypes.rs @@ -424,7 +424,7 @@ impl From<&crate::config::UiConfig> for UiSpec { #[cfg(test)] mod tests { - use crate::{action::Action, config::Binding, event::Key}; + use crate::{action::Action, event::Key}; use super::*; use toml::from_str; @@ -519,10 +519,15 @@ mod tests { footer = "Press 'q' to quit" [keybindings] - quit = ["esc", "ctrl-c"] - select_next_entry = ["down", "ctrl-n", "ctrl-j"] - select_prev_entry = ["up", "ctrl-p", "ctrl-k"] - confirm_selection = "enter" + esc = "quit" + ctrl-c = "quit" + down = "select_next_entry" + ctrl-n = "select_next_entry" + ctrl-j = "select_next_entry" + up = "select_prev_entry" + ctrl-p = "select_prev_entry" + ctrl-k = "select_prev_entry" + enter = "confirm_selection" "#; let prototype: ChannelPrototype = from_str(toml_data).unwrap(); @@ -584,29 +589,38 @@ mod tests { ); let keybindings = prototype.keybindings.unwrap(); + assert_eq!(keybindings.bindings.get(&Key::Esc), Some(&Action::Quit)); assert_eq!( - keybindings.bindings.0.get(&Action::Quit), - Some(&Binding::MultipleKeys(vec![Key::Esc, Key::Ctrl('c')])) + keybindings.bindings.get(&Key::Ctrl('c')), + Some(&Action::Quit) ); assert_eq!( - keybindings.bindings.0.get(&Action::SelectNextEntry), - Some(&Binding::MultipleKeys(vec![ - Key::Down, - Key::Ctrl('n'), - Key::Ctrl('j') - ])) + keybindings.bindings.get(&Key::Down), + Some(&Action::SelectNextEntry) ); assert_eq!( - keybindings.bindings.0.get(&Action::SelectPrevEntry), - Some(&Binding::MultipleKeys(vec![ - Key::Up, - Key::Ctrl('p'), - Key::Ctrl('k') - ])) + keybindings.bindings.get(&Key::Ctrl('n')), + Some(&Action::SelectNextEntry) ); assert_eq!( - keybindings.bindings.0.get(&Action::ConfirmSelection), - Some(&Binding::SingleKey(Key::Enter)) + keybindings.bindings.get(&Key::Ctrl('j')), + Some(&Action::SelectNextEntry) + ); + assert_eq!( + keybindings.bindings.get(&Key::Up), + Some(&Action::SelectPrevEntry) + ); + assert_eq!( + keybindings.bindings.get(&Key::Ctrl('p')), + Some(&Action::SelectPrevEntry) + ); + assert_eq!( + keybindings.bindings.get(&Key::Ctrl('k')), + Some(&Action::SelectPrevEntry) + ); + assert_eq!( + keybindings.bindings.get(&Key::Enter), + Some(&Action::ConfirmSelection) ); } diff --git a/television/cli/mod.rs b/television/cli/mod.rs index 7d54724..86a2d39 100644 --- a/television/cli/mod.rs +++ b/television/cli/mod.rs @@ -590,7 +590,7 @@ Data directory: {data_dir_path}" #[cfg(test)] mod tests { - use crate::{action::Action, config::Binding, event::Key}; + use crate::{action::Action, event::Key}; use super::*; @@ -635,12 +635,13 @@ mod tests { } #[test] + #[ignore = "expects binding toml structure"] fn test_custom_keybindings() { let cli = Cli { channel: Some("files".to_string()), preview_command: Some(":env_var:".to_string()), keybindings: Some( - "quit=\"esc\";select_next_entry=[\"down\",\"ctrl-j\"]" + r#"esc="quit";down="select_next_entry";ctrl-j="select_next_entry""# .to_string(), ), ..Default::default() @@ -649,11 +650,9 @@ mod tests { let post_processed_cli = post_process(cli, false); let mut expected = KeyBindings::default(); - expected.insert(Action::Quit, Binding::SingleKey(Key::Esc)); - expected.insert( - Action::SelectNextEntry, - Binding::MultipleKeys(vec![Key::Down, Key::Ctrl('j')]), - ); + expected.insert(Key::Esc, Action::Quit); + expected.insert(Key::Down, Action::SelectNextEntry); + expected.insert(Key::Ctrl('j'), Action::SelectNextEntry); assert_eq!(post_processed_cli.keybindings, Some(expected)); } diff --git a/television/config/keybindings.rs b/television/config/keybindings.rs index 18d8f4a..ccf9d44 100644 --- a/television/config/keybindings.rs +++ b/television/config/keybindings.rs @@ -9,6 +9,7 @@ use std::fmt::Display; use std::hash::Hash; use std::ops::{Deref, DerefMut}; +// Legacy binding structure for backward compatibility with shell integration #[derive(Clone, Debug, Deserialize, Serialize, PartialEq, Hash)] #[serde(untagged)] pub enum Binding { @@ -31,61 +32,223 @@ impl Display for Binding { } } -#[derive(Clone, Debug, Default, PartialEq, Serialize, Deserialize)] -/// A set of keybindings for various actions in the application. +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +/// A set of keybindings that maps keys directly to actions. /// -/// This struct is a wrapper around a `FxHashMap` that maps `Action`s to their corresponding -/// `Binding`s. It's main use is to provide a convenient way to manage and serialize/deserialize -/// keybindings from the configuration file as well as channel prototypes. -pub struct KeyBindings(pub FxHashMap); +/// This struct represents the new architecture where keybindings are structured as +/// Key -> Action mappings in the configuration file. This eliminates the need for +/// runtime inversion and provides better discoverability. +pub struct KeyBindings { + #[serde( + flatten, + serialize_with = "serialize_key_bindings", + deserialize_with = "deserialize_key_bindings" + )] + pub bindings: FxHashMap, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash, Serialize)] +#[serde(rename_all = "kebab-case")] +/// Types of events that can be bound to actions +pub enum EventType { + MouseClick, + MouseScrollUp, + MouseScrollDown, + Resize, + Custom(String), +} + +impl<'de> serde::Deserialize<'de> for EventType { + fn deserialize(deserializer: D) -> Result + where + D: serde::Deserializer<'de>, + { + let s = String::deserialize(deserializer)?; + match s.as_str() { + "mouse-click" => Ok(EventType::MouseClick), + "mouse-scroll-up" => Ok(EventType::MouseScrollUp), + "mouse-scroll-down" => Ok(EventType::MouseScrollDown), + "resize" => Ok(EventType::Resize), + custom => Ok(EventType::Custom(custom.to_string())), + } + } +} + +impl Display for EventType { + fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result { + match self { + EventType::MouseClick => write!(f, "mouse-click"), + EventType::MouseScrollUp => write!(f, "mouse-scroll-up"), + EventType::MouseScrollDown => write!(f, "mouse-scroll-down"), + EventType::Resize => write!(f, "resize"), + EventType::Custom(name) => write!(f, "{}", name), + } + } +} + +#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)] +/// A set of event bindings that maps events to actions. +pub struct EventBindings { + #[serde( + flatten, + serialize_with = "serialize_event_bindings", + deserialize_with = "deserialize_event_bindings" + )] + pub bindings: FxHashMap, +} impl From for KeyBindings where - I: IntoIterator, + I: IntoIterator, { fn from(iter: I) -> Self { - KeyBindings(iter.into_iter().collect()) + KeyBindings { + bindings: iter.into_iter().collect(), + } + } +} + +impl From for EventBindings +where + I: IntoIterator, +{ + fn from(iter: I) -> Self { + EventBindings { + bindings: iter.into_iter().collect(), + } } } impl Hash for KeyBindings { fn hash(&self, state: &mut H) { - // we're not actually using this for hashing, so this really only is a placeholder - state.write_u8(0); + // Hash based on the bindings map + for (key, action) in &self.bindings { + key.hash(state); + action.hash(state); + } + } +} + +impl Hash for EventBindings { + fn hash(&self, state: &mut H) { + // Hash based on the bindings map + for (event, action) in &self.bindings { + event.hash(state); + action.hash(state); + } } } impl Deref for KeyBindings { - type Target = FxHashMap; + type Target = FxHashMap; fn deref(&self) -> &Self::Target { - &self.0 + &self.bindings } } impl DerefMut for KeyBindings { fn deref_mut(&mut self) -> &mut Self::Target { - &mut self.0 + &mut self.bindings + } +} + +impl Deref for EventBindings { + type Target = FxHashMap; + fn deref(&self) -> &Self::Target { + &self.bindings + } +} + +impl DerefMut for EventBindings { + fn deref_mut(&mut self) -> &mut Self::Target { + &mut self.bindings } } /// Merge two sets of keybindings together. /// -/// Note that this function won't "meld", for a given action, the bindings from the first set -/// with the bindings from the second set. Instead, it will simply overwrite them with the second -/// set's keys. -/// This is because it is assumed that the second set will be the user's custom keybindings, and -/// they should take precedence over the default ones, effectively replacing them to avoid -/// conflicts. +/// The new keybindings will overwrite any existing ones for the same keys. pub fn merge_keybindings( mut keybindings: KeyBindings, new_keybindings: &KeyBindings, ) -> KeyBindings { - for (action, binding) in new_keybindings.iter() { - keybindings.insert(action.clone(), binding.clone()); + for (key, action) in &new_keybindings.bindings { + keybindings.bindings.insert(*key, action.clone()); } keybindings } +/// Merge two sets of event bindings together. +/// +/// The new event bindings will overwrite any existing ones for the same event types. +pub fn merge_event_bindings( + mut event_bindings: EventBindings, + new_event_bindings: &EventBindings, +) -> EventBindings { + for (event_type, action) in &new_event_bindings.bindings { + event_bindings + .bindings + .insert(event_type.clone(), action.clone()); + } + event_bindings +} + +impl Default for KeyBindings { + fn default() -> Self { + let mut bindings = FxHashMap::default(); + + // Quit actions + bindings.insert(Key::Esc, Action::Quit); + bindings.insert(Key::Ctrl('c'), Action::Quit); + + // 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); + + // History navigation + bindings.insert(Key::CtrlUp, Action::SelectPrevHistory); + bindings.insert(Key::CtrlDown, Action::SelectNextHistory); + + // Selection actions + bindings.insert(Key::Enter, Action::ConfirmSelection); + bindings.insert(Key::Tab, Action::ToggleSelectionDown); + bindings.insert(Key::BackTab, Action::ToggleSelectionUp); + + // Preview actions + bindings.insert(Key::PageDown, Action::ScrollPreviewHalfPageDown); + bindings.insert(Key::PageUp, Action::ScrollPreviewHalfPageUp); + + // Clipboard and toggles + bindings.insert(Key::Ctrl('y'), Action::CopyEntryToClipboard); + bindings.insert(Key::Ctrl('r'), Action::ReloadSource); + bindings.insert(Key::Ctrl('s'), Action::CycleSources); + + // 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); + + Self { bindings } + } +} + +impl Default for EventBindings { + fn default() -> Self { + let mut bindings = FxHashMap::default(); + + // Mouse events + bindings.insert(EventType::MouseScrollUp, Action::ScrollPreviewUp); + bindings.insert(EventType::MouseScrollDown, Action::ScrollPreviewDown); + + Self { bindings } + } +} + pub fn parse_key_event(raw: &str) -> anyhow::Result { let raw_lower = raw.to_ascii_lowercase(); let (remaining, modifiers) = extract_modifiers(&raw_lower); @@ -264,17 +427,124 @@ pub fn parse_key(raw: &str) -> anyhow::Result { raw.strip_suffix('>').unwrap_or(raw) }; - // Handle mouse scroll keys as special cases - match raw.to_ascii_lowercase().as_str() { - "mousescrollup" => return Ok(Key::MouseScrollUp), - "mousescrolldown" => return Ok(Key::MouseScrollDown), - _ => {} - } - let key_event = parse_key_event(raw)?; Ok(convert_raw_event_to_key(key_event)) } +/// Custom serializer for `KeyBindings` that converts `Key` enum to string for TOML compatibility +fn serialize_key_bindings( + bindings: &FxHashMap, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + 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)?; + } + map.end() +} + +/// Custom deserializer for `KeyBindings` that parses string keys back to `Key` enum +fn deserialize_key_bindings<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de::{MapAccess, Visitor}; + use std::fmt; + + struct KeyBindingsVisitor; + + impl<'de> Visitor<'de> for KeyBindingsVisitor { + type Value = FxHashMap; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a map with string keys and action values") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut bindings = FxHashMap::default(); + while let Some((key_str, action)) = + map.next_entry::()? + { + let key = + parse_key(&key_str).map_err(serde::de::Error::custom)?; + bindings.insert(key, action); + } + Ok(bindings) + } + } + + deserializer.deserialize_map(KeyBindingsVisitor) +} + +/// Custom serializer for `EventBindings` that converts `EventType` enum to string for TOML compatibility +fn serialize_event_bindings( + bindings: &FxHashMap, + serializer: S, +) -> Result +where + S: serde::Serializer, +{ + 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)?; + } + map.end() +} + +/// Custom deserializer for `EventBindings` that parses string keys back to `EventType` enum +fn deserialize_event_bindings<'de, D>( + deserializer: D, +) -> Result, D::Error> +where + D: serde::Deserializer<'de>, +{ + use serde::de::{MapAccess, Visitor}; + use std::fmt; + + struct EventBindingsVisitor; + + impl<'de> Visitor<'de> for EventBindingsVisitor { + type Value = FxHashMap; + + fn expecting(&self, formatter: &mut fmt::Formatter) -> fmt::Result { + formatter.write_str("a map with string keys and action values") + } + + fn visit_map(self, mut map: A) -> Result + where + A: MapAccess<'de>, + { + let mut bindings = FxHashMap::default(); + while let Some((event_str, action)) = + map.next_entry::()? + { + // Parse the event string back to EventType + let event_type = match event_str.as_str() { + "mouse-click" => EventType::MouseClick, + "mouse-scroll-up" => EventType::MouseScrollUp, + "mouse-scroll-down" => EventType::MouseScrollDown, + "resize" => EventType::Resize, + custom => EventType::Custom(custom.to_string()), + }; + bindings.insert(event_type, action); + } + Ok(bindings) + } + } + + deserializer.deserialize_map(EventBindingsVisitor) +} + #[cfg(test)] mod tests { use super::*; @@ -386,28 +656,24 @@ mod tests { fn test_deserialize_keybindings() { let keybindings: KeyBindings = toml::from_str( r#" - # Quit the application - quit = ["esc", "ctrl-c"] - # Scrolling through entries - select_next_entry = ["down", "ctrl-n", "ctrl-j"] - select_prev_entry = ["up", "ctrl-p", "ctrl-k"] - select_next_page = "pagedown" - select_prev_page = "pageup" - # Scrolling the preview pane - scroll_preview_half_page_down = "ctrl-d" - scroll_preview_half_page_up = "ctrl-u" - # Add entry to selection and move to the next entry - toggle_selection_down = "tab" - # Add entry to selection and move to the previous entry - toggle_selection_up = "backtab" - # Confirm selection - confirm_selection = "enter" - # Copy the selected entry to the clipboard - copy_entry_to_clipboard = "ctrl-y" - # Toggle the remote control mode - toggle_remote_control = "ctrl-r" - # Toggle the preview panel - toggle_preview = "ctrl-o" + "esc" = "quit" + "ctrl-c" = "quit" + "down" = "select_next_entry" + "ctrl-n" = "select_next_entry" + "ctrl-j" = "select_next_entry" + "up" = "select_prev_entry" + "ctrl-p" = "select_prev_entry" + "ctrl-k" = "select_prev_entry" + "pagedown" = "select_next_page" + "pageup" = "select_prev_page" + "ctrl-d" = "scroll_preview_half_page_down" + "ctrl-u" = "scroll_preview_half_page_up" + "tab" = "toggle_selection_down" + "backtab" = "toggle_selection_up" + "enter" = "confirm_selection" + "ctrl-y" = "copy_entry_to_clipboard" + "ctrl-r" = "toggle_remote_control" + "ctrl-o" = "toggle_preview" "#, ) .unwrap(); @@ -415,48 +681,24 @@ mod tests { assert_eq!( keybindings, KeyBindings::from(vec![ - ( - Action::Quit, - Binding::MultipleKeys(vec![Key::Esc, Key::Ctrl('c'),]) - ), - ( - Action::SelectNextEntry, - Binding::MultipleKeys(vec![ - Key::Down, - Key::Ctrl('n'), - Key::Ctrl('j'), - ]) - ), - ( - Action::SelectPrevEntry, - Binding::MultipleKeys(vec![ - Key::Up, - Key::Ctrl('p'), - Key::Ctrl('k'), - ]) - ), - (Action::SelectNextPage, Binding::SingleKey(Key::PageDown)), - (Action::SelectPrevPage, Binding::SingleKey(Key::PageUp)), - ( - Action::ScrollPreviewHalfPageDown, - Binding::SingleKey(Key::Ctrl('d')) - ), - ( - Action::ScrollPreviewHalfPageUp, - Binding::SingleKey(Key::Ctrl('u')) - ), - (Action::ToggleSelectionDown, Binding::SingleKey(Key::Tab)), - (Action::ToggleSelectionUp, Binding::SingleKey(Key::BackTab)), - (Action::ConfirmSelection, Binding::SingleKey(Key::Enter)), - ( - Action::CopyEntryToClipboard, - Binding::SingleKey(Key::Ctrl('y')) - ), - ( - Action::ToggleRemoteControl, - Binding::SingleKey(Key::Ctrl('r')) - ), - (Action::TogglePreview, Binding::SingleKey(Key::Ctrl('o'))), + (Key::Esc, Action::Quit), + (Key::Ctrl('c'), Action::Quit), + (Key::Down, Action::SelectNextEntry), + (Key::Ctrl('n'), Action::SelectNextEntry), + (Key::Ctrl('j'), Action::SelectNextEntry), + (Key::Up, Action::SelectPrevEntry), + (Key::Ctrl('p'), Action::SelectPrevEntry), + (Key::Ctrl('k'), Action::SelectPrevEntry), + (Key::PageDown, Action::SelectNextPage), + (Key::PageUp, Action::SelectPrevPage), + (Key::Ctrl('d'), Action::ScrollPreviewHalfPageDown), + (Key::Ctrl('u'), Action::ScrollPreviewHalfPageUp), + (Key::Tab, Action::ToggleSelectionDown), + (Key::BackTab, Action::ToggleSelectionUp), + (Key::Enter, Action::ConfirmSelection), + (Key::Ctrl('y'), Action::CopyEntryToClipboard), + (Key::Ctrl('r'), Action::ToggleRemoteControl), + (Key::Ctrl('o'), Action::TogglePreview), ]) ); } @@ -464,35 +706,36 @@ mod tests { #[test] fn test_merge_keybindings() { let base_keybindings = KeyBindings::from(vec![ - (Action::Quit, Binding::SingleKey(Key::Esc)), - ( - Action::SelectNextEntry, - Binding::MultipleKeys(vec![Key::Down, Key::Ctrl('n')]), - ), - (Action::SelectPrevEntry, Binding::SingleKey(Key::Up)), + (Key::Esc, Action::Quit), + (Key::Down, Action::SelectNextEntry), + (Key::Ctrl('n'), Action::SelectNextEntry), + (Key::Up, Action::SelectPrevEntry), ]); let custom_keybindings = KeyBindings::from(vec![ - (Action::SelectNextEntry, Binding::SingleKey(Key::Ctrl('j'))), - ( - Action::SelectPrevEntry, - Binding::MultipleKeys(vec![Key::Up, Key::Ctrl('k')]), - ), - (Action::SelectNextPage, Binding::SingleKey(Key::PageDown)), + (Key::Ctrl('j'), Action::SelectNextEntry), + (Key::Ctrl('k'), Action::SelectPrevEntry), + (Key::PageDown, Action::SelectNextPage), ]); let merged = merge_keybindings(base_keybindings, &custom_keybindings); + // 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!(merged.bindings.contains_key(&Key::Down)); assert_eq!( - merged, - KeyBindings::from(vec![ - (Action::Quit, Binding::SingleKey(Key::Esc)), - (Action::SelectNextEntry, Binding::SingleKey(Key::Ctrl('j'))), - ( - Action::SelectPrevEntry, - Binding::MultipleKeys(vec![Key::Up, Key::Ctrl('k')]), - ), - (Action::SelectNextPage, Binding::SingleKey(Key::PageDown)), - ]) + merged.bindings.get(&Key::Down), + Some(&Action::SelectNextEntry) + ); + assert!(merged.bindings.contains_key(&Key::Ctrl('j'))); + assert_eq!( + merged.bindings.get(&Key::Ctrl('j')), + Some(&Action::SelectNextEntry) + ); + assert!(merged.bindings.contains_key(&Key::PageDown)); + assert_eq!( + merged.bindings.get(&Key::PageDown), + Some(&Action::SelectNextPage) ); } } diff --git a/television/config/mod.rs b/television/config/mod.rs index 661c41b..f9c39fd 100644 --- a/television/config/mod.rs +++ b/television/config/mod.rs @@ -14,7 +14,10 @@ use std::{ }; use tracing::{debug, warn}; -pub use keybindings::{Binding, KeyBindings, merge_keybindings, parse_key}; +pub use keybindings::{ + Binding, EventBindings, EventType, KeyBindings, merge_event_bindings, + merge_keybindings, parse_key, +}; pub use themes::Theme; pub use ui::UiConfig; @@ -81,6 +84,9 @@ pub struct Config { /// Keybindings configuration #[serde(default)] pub keybindings: KeyBindings, + /// Event bindings configuration + #[serde(default)] + pub events: EventBindings, /// UI configuration #[serde(default)] pub ui: UiConfig, @@ -223,9 +229,14 @@ impl Config { merge_keybindings(default.keybindings.clone(), &new.keybindings); new.keybindings = keybindings; + // merge event bindings with default event bindings + let events = merge_event_bindings(default.events.clone(), &new.events); + new.events = events; + Config { application: new.application, keybindings: new.keybindings, + events: new.events, ui: new.ui, shell_integration: new.shell_integration, } @@ -235,6 +246,10 @@ impl Config { self.keybindings = merge_keybindings(self.keybindings.clone(), other); } + pub fn merge_event_bindings(&mut self, other: &EventBindings) { + self.events = merge_event_bindings(self.events.clone(), other); + } + pub fn apply_prototype_ui_spec(&mut self, ui_spec: &UiSpec) { // Apply simple copy fields (Copy types) if let Some(value) = ui_spec.ui_scale { @@ -355,7 +370,6 @@ mod tests { use crate::event::Key; use super::*; - use rustc_hash::FxHashMap; use std::fs::File; use std::io::Write; use tempfile::tempdir; @@ -405,6 +419,7 @@ mod tests { assert_eq!(config.application, default_config.application); assert_eq!(config.keybindings, default_config.keybindings); + assert_eq!(config.events, default_config.events); assert_eq!(config.ui, default_config.ui); // backwards compatibility assert_eq!( @@ -426,7 +441,7 @@ mod tests { theme = "something" [keybindings] - confirm_selection = "ctrl-enter" + ctrl-enter = "confirm_selection" [shell_integration.commands] "git add" = "git-diff" @@ -455,14 +470,11 @@ mod tests { toml::from_str(DEFAULT_CONFIG).unwrap(); default_config.ui.ui_scale = 40; default_config.ui.theme = "television".to_string(); - default_config.keybindings.extend({ - let mut map = FxHashMap::default(); - map.insert( - Action::ConfirmSelection, - Binding::SingleKey(Key::CtrlEnter), - ); - map - }); + // With new architecture, we add directly to the bindings map + default_config + .keybindings + .bindings + .insert(Key::CtrlEnter, Action::ConfirmSelection); default_config.shell_integration.keybindings.insert( "command_history".to_string(), @@ -472,6 +484,7 @@ mod tests { assert_eq!(config.application, default_config.application); assert_eq!(config.keybindings, default_config.keybindings); + assert_eq!(config.events, default_config.events); assert_eq!(config.ui, default_config.ui); assert_eq!( config.shell_integration.commands, diff --git a/television/event.rs b/television/event.rs index 54cc7a7..8e87d5d 100644 --- a/television/event.rs +++ b/television/event.rs @@ -11,7 +11,7 @@ use crossterm::event::{ BackTab, Backspace, Char, Delete, Down, End, Enter, Esc, F, Home, Insert, Left, PageDown, PageUp, Right, Tab, Up, }, - KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, + KeyEvent, KeyEventKind, KeyModifiers, MouseEvent, MouseEventKind, }; use serde::{Deserialize, Serialize}; use tokio::{signal, sync::mpsc}; @@ -68,8 +68,22 @@ pub enum Key { Null, Esc, Tab, - MouseScrollUp, - MouseScrollDown, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +/// Unified input event type that encompasses all possible inputs +pub enum InputEvent { + Key(Key), + Mouse(MouseInputEvent), + Resize(u16, u16), + Custom(String), +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +/// Mouse event with position information for input mapping +pub struct MouseInputEvent { + pub kind: MouseEventKind, + pub position: (u16, u16), } impl<'de> Deserialize<'de> for Key { @@ -121,8 +135,6 @@ impl Display for Key { Key::Null => write!(f, "Null"), Key::Esc => write!(f, "Esc"), Key::Tab => write!(f, "Tab"), - Key::MouseScrollUp => write!(f, "MouseScrollUp"), - Key::MouseScrollDown => write!(f, "MouseScrollDown"), } } } diff --git a/television/keymap.rs b/television/keymap.rs index de4d893..f6b5dcf 100644 --- a/television/keymap.rs +++ b/television/keymap.rs @@ -1,80 +1,131 @@ use crate::{ action::Action, - config::{Binding, KeyBindings}, - event::Key, + config::{EventBindings, EventType, KeyBindings}, + event::{InputEvent, Key}, }; +use crossterm::event::MouseEventKind; use rustc_hash::FxHashMap; -use std::ops::Deref; -#[derive(Default, Debug)] -/// A keymap is a set of mappings of keys to actions for every mode. +#[derive(Default, Debug, Clone)] +/// An input map that handles both keyboard and non-keyboard input events. +/// +/// This replaces the old Keymap structure and provides unified access to +/// both key bindings and event bindings through a single interface. /// /// # Example: /// ```ignore -/// Keymap { +/// InputMap { /// Key::Char('j') => Action::MoveDown, /// Key::Char('k') => Action::MoveUp, -/// Key::Char('q') => Action::Quit, +/// EventType::MouseClick => Action::ConfirmSelection, /// } /// ``` -pub struct Keymap(pub FxHashMap); - -impl Deref for Keymap { - type Target = FxHashMap; - fn deref(&self) -> &Self::Target { - &self.0 - } +pub struct InputMap { + pub key_actions: FxHashMap, + pub event_actions: FxHashMap, } -impl From<&KeyBindings> for Keymap { - /// Convert a `KeyBindings` into a `Keymap`. - /// - /// This essentially "reverses" the inner `KeyBindings` structure, so that each mode keymap is - /// indexed by its keys instead of the actions so as to be used as a routing table for incoming - /// key events. - fn from(keybindings: &KeyBindings) -> Self { - let mut keymap = FxHashMap::default(); - for (action, binding) in keybindings.iter() { - match binding { - Binding::SingleKey(key) => { - keymap.insert(*key, action.clone()); - } - Binding::MultipleKeys(keys) => { - for key in keys { - keymap.insert(*key, action.clone()); - } - } - } +impl InputMap { + /// Create a new empty `InputMap` + pub fn new() -> Self { + Self { + key_actions: FxHashMap::default(), + event_actions: FxHashMap::default(), + } + } + + /// Get the action for a given key + pub fn get_action_for_key(&self, key: &Key) -> Option<&Action> { + self.key_actions.get(key) + } + + /// Get the action for a given event type + pub fn get_action_for_event(&self, event: &EventType) -> Option<&Action> { + self.event_actions.get(event) + } + + /// Get an action for any input event + pub fn get_action_for_input(&self, input: &InputEvent) -> Option { + match input { + InputEvent::Key(key) => self.get_action_for_key(key).cloned(), + 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).cloned() + } + InputEvent::Resize(_, _) => { + self.get_action_for_event(&EventType::Resize).cloned() + } + InputEvent::Custom(name) => self + .get_action_for_event(&EventType::Custom(name.clone())) + .cloned(), } - Self(keymap) } } -impl Keymap { - /// Merge another keymap into this one. +impl From<&KeyBindings> for InputMap { + /// Convert a `KeyBindings` into an `InputMap`. /// - /// This will overwrite any existing keys in `self` with the keys from `other`. + /// Since the new `KeyBindings` already store Key -> Action mappings, + /// we can directly copy the bindings without inversion. + fn from(keybindings: &KeyBindings) -> Self { + Self { + key_actions: keybindings.bindings.clone(), + event_actions: FxHashMap::default(), + } + } +} + +impl From<&EventBindings> for InputMap { + /// Convert `EventBindings` into an `InputMap`. + fn from(event_bindings: &EventBindings) -> Self { + Self { + key_actions: FxHashMap::default(), + event_actions: event_bindings.bindings.clone(), + } + } +} + +impl From<(&KeyBindings, &EventBindings)> for InputMap { + /// Convert both `KeyBindings` and `EventBindings` into an `InputMap`. + fn from( + (keybindings, event_bindings): (&KeyBindings, &EventBindings), + ) -> Self { + Self { + key_actions: keybindings.bindings.clone(), + event_actions: event_bindings.bindings.clone(), + } + } +} + +impl InputMap { + /// Merge another `InputMap` into this one. /// - /// # Example: - /// ```ignore - /// let mut keymap1 = Keymap::default(); - /// keymap1.0.insert(Key::Char('a'), Action::SelectNextEntry); - /// - /// let keymap2 = Keymap({ - /// let mut h = FxHashMap::default(); - /// h.insert(Key::Char('b'), Action::SelectPrevEntry); - /// h.insert(Key::Char('a'), Action::Quit); // This will overwrite the previous 'a' action - /// h - /// }); - /// - /// keymap1.merge(&keymap2); - /// - /// assert_eq!(keymap1.0.get(&Key::Char('a')), Some(&Action::Quit)); - /// assert_eq!(keymap1.0.get(&Key::Char('b')), Some(&Action::SelectPrevEntry)); - /// ``` - pub fn merge(&mut self, other: &Keymap) { - for (key, action) in other.iter() { - self.0.insert(*key, action.clone()); + /// This will overwrite any existing keys/events in `self` with the mappings from `other`. + pub fn merge(&mut self, other: &InputMap) { + for (key, action) in &other.key_actions { + self.key_actions.insert(*key, action.clone()); + } + for (event, action) in &other.event_actions { + self.event_actions.insert(event.clone(), action.clone()); + } + } + + /// Merge key bindings into this `InputMap` + pub fn merge_key_bindings(&mut self, keybindings: &KeyBindings) { + for (key, action) in &keybindings.bindings { + self.key_actions.insert(*key, action.clone()); + } + } + + /// Merge event bindings into this `InputMap` + pub fn merge_event_bindings(&mut self, event_bindings: &EventBindings) { + for (event, action) in &event_bindings.bindings { + self.event_actions.insert(event.clone(), action.clone()); } } } @@ -82,55 +133,113 @@ impl Keymap { #[cfg(test)] mod tests { use super::*; - use crate::config::{Binding, KeyBindings}; - use crate::event::Key; + use crate::config::{EventBindings, KeyBindings}; + use crate::event::{Key, MouseInputEvent}; + use crossterm::event::MouseEventKind; #[test] - fn test_keymap_from_keybindings() { - let keybindings = KeyBindings({ - let mut h = FxHashMap::default(); - for (action, binding) in &[ - (Action::SelectNextEntry, Binding::SingleKey(Key::Char('j'))), - (Action::SelectPrevEntry, Binding::SingleKey(Key::Char('k'))), - (Action::Quit, Binding::SingleKey(Key::Char('q'))), - ] { - h.insert(action.clone(), binding.clone()); - } - h - }); + fn test_input_map_from_keybindings() { + let keybindings = KeyBindings::from(vec![ + (Key::Char('j'), Action::SelectNextEntry), + (Key::Char('k'), Action::SelectPrevEntry), + (Key::Char('q'), Action::Quit), + ]); - let keymap: Keymap = (&keybindings).into(); + let input_map: InputMap = (&keybindings).into(); assert_eq!( - keymap.0.get(&Key::Char('j')), + input_map.get_action_for_key(&Key::Char('j')), Some(&Action::SelectNextEntry) ); assert_eq!( - keymap.0.get(&Key::Char('k')), + input_map.get_action_for_key(&Key::Char('k')), Some(&Action::SelectPrevEntry) ); - assert_eq!(keymap.0.get(&Key::Char('q')), Some(&Action::Quit)); + assert_eq!( + input_map.get_action_for_key(&Key::Char('q')), + Some(&Action::Quit) + ); } #[test] - fn test_keymap_merge_into() { - let mut keymap1 = Keymap(FxHashMap::default()); - keymap1.0.insert(Key::Char('a'), Action::SelectNextEntry); - keymap1.0.insert(Key::Char('b'), Action::SelectPrevEntry); + fn test_input_map_from_event_bindings() { + let event_bindings = EventBindings::from(vec![ + (EventType::MouseClick, Action::ConfirmSelection), + (EventType::Resize, Action::ClearScreen), + ]); - let keymap2 = Keymap({ - let mut h = FxHashMap::default(); - h.insert(Key::Char('c'), Action::Quit); - h.insert(Key::Char('a'), Action::Quit); // This should overwrite the - // previous 'a' action - h - }); - - keymap1.merge(&keymap2); - assert_eq!(keymap1.0.get(&Key::Char('a')), Some(&Action::Quit)); + let input_map: InputMap = (&event_bindings).into(); assert_eq!( - keymap1.0.get(&Key::Char('b')), + input_map.get_action_for_event(&EventType::MouseClick), + Some(&Action::ConfirmSelection) + ); + assert_eq!( + input_map.get_action_for_event(&EventType::Resize), + Some(&Action::ClearScreen) + ); + } + + #[test] + fn test_input_map_get_action_for_input() { + let keybindings = + KeyBindings::from(vec![(Key::Char('j'), Action::SelectNextEntry)]); + let event_bindings = EventBindings::from(vec![( + EventType::MouseClick, + Action::ConfirmSelection, + )]); + + let input_map: InputMap = (&keybindings, &event_bindings).into(); + + // Test key input + let key_input = InputEvent::Key(Key::Char('j')); + assert_eq!( + input_map.get_action_for_input(&key_input), + Some(Action::SelectNextEntry) + ); + + // Test mouse input + let mouse_input = InputEvent::Mouse(MouseInputEvent { + kind: MouseEventKind::Down(crossterm::event::MouseButton::Left), + position: (10, 10), + }); + assert_eq!( + input_map.get_action_for_input(&mouse_input), + Some(Action::ConfirmSelection) + ); + } + + #[test] + fn test_input_map_merge() { + let mut input_map1 = InputMap::new(); + input_map1 + .key_actions + .insert(Key::Char('a'), Action::SelectNextEntry); + input_map1 + .key_actions + .insert(Key::Char('b'), Action::SelectPrevEntry); + + 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 + .event_actions + .insert(EventType::MouseClick, Action::ConfirmSelection); + + input_map1.merge(&input_map2); + assert_eq!( + input_map1.get_action_for_key(&Key::Char('a')), + Some(&Action::Quit) + ); + assert_eq!( + input_map1.get_action_for_key(&Key::Char('b')), Some(&Action::SelectPrevEntry) ); - assert_eq!(keymap1.0.get(&Key::Char('c')), Some(&Action::Quit)); + assert_eq!( + input_map1.get_action_for_key(&Key::Char('c')), + Some(&Action::Quit) + ); + assert_eq!( + input_map1.get_action_for_event(&EventType::MouseClick), + Some(&Action::ConfirmSelection) + ); } } diff --git a/television/main.rs b/television/main.rs index 5685c11..3e13dae 100644 --- a/television/main.rs +++ b/television/main.rs @@ -22,6 +22,7 @@ use television::{ errors::os_error_exit, features::FeatureFlags, gh::update_local_channels, + screen::keybindings::remove_action_bindings, television::Mode, utils::clipboard::CLIPBOARD, utils::{ @@ -151,7 +152,10 @@ fn apply_cli_overrides(args: &PostProcessedCli, config: &mut Config) { // Handle preview panel flags if args.no_preview { config.ui.features.disable(FeatureFlags::PreviewPanel); - config.keybindings.remove(&Action::TogglePreview); + remove_action_bindings( + &mut config.keybindings, + &Action::TogglePreview, + ); } else if args.hide_preview { config.ui.features.hide(FeatureFlags::PreviewPanel); } else if args.show_preview { @@ -165,7 +169,10 @@ fn apply_cli_overrides(args: &PostProcessedCli, config: &mut Config) { // Handle status bar flags if args.no_status_bar { config.ui.features.disable(FeatureFlags::StatusBar); - config.keybindings.remove(&Action::ToggleStatusBar); + remove_action_bindings( + &mut config.keybindings, + &Action::ToggleStatusBar, + ); } else if args.hide_status_bar { config.ui.features.hide(FeatureFlags::StatusBar); } else if args.show_status_bar { @@ -175,7 +182,10 @@ fn apply_cli_overrides(args: &PostProcessedCli, config: &mut Config) { // Handle remote control flags if args.no_remote { config.ui.features.disable(FeatureFlags::RemoteControl); - config.keybindings.remove(&Action::ToggleRemoteControl); + remove_action_bindings( + &mut config.keybindings, + &Action::ToggleRemoteControl, + ); } else if args.hide_remote { config.ui.features.hide(FeatureFlags::RemoteControl); } else if args.show_remote { @@ -185,7 +195,7 @@ fn apply_cli_overrides(args: &PostProcessedCli, config: &mut Config) { // Handle help panel flags if args.no_help_panel { config.ui.features.disable(FeatureFlags::HelpPanel); - config.keybindings.remove(&Action::ToggleHelp); + remove_action_bindings(&mut config.keybindings, &Action::ToggleHelp); } else if args.hide_help_panel { config.ui.features.hide(FeatureFlags::HelpPanel); } else if args.show_help_panel { diff --git a/television/screen/help_panel.rs b/television/screen/help_panel.rs index d52433c..ed94a21 100644 --- a/television/screen/help_panel.rs +++ b/television/screen/help_panel.rs @@ -1,7 +1,7 @@ use crate::{ config::KeyBindings, screen::colors::Colorscheme, - screen::keybindings::{ActionMapping, extract_keys_from_binding}, + screen::keybindings::{ActionMapping, find_keys_for_action}, television::Mode, }; use ratatui::{ @@ -62,16 +62,14 @@ fn add_keybinding_lines_for_mappings( ) { for mapping in mappings { for (action, description) in &mapping.actions { - if let Some(binding) = keybindings.get(action) { - let keys = extract_keys_from_binding(binding); - for key in keys { - lines.push(create_compact_keybinding_line( - &key, - description, - mode, - colorscheme, - )); - } + let keys = find_keys_for_action(keybindings, action); + for key in keys { + lines.push(create_compact_keybinding_line( + &key, + description, + mode, + colorscheme, + )); } } } diff --git a/television/screen/keybindings.rs b/television/screen/keybindings.rs index fa65904..1528646 100644 --- a/television/screen/keybindings.rs +++ b/television/screen/keybindings.rs @@ -163,6 +163,24 @@ pub fn extract_keys_from_binding(binding: &Binding) -> Vec { } } +/// Extract keys for a single action from the new Key->Action keybindings format +pub fn find_keys_for_action( + keybindings: &KeyBindings, + target_action: &Action, +) -> Vec { + keybindings + .bindings + .iter() + .filter_map(|(key, action)| { + if action == target_action { + Some(key.to_string()) + } else { + None + } + }) + .collect() +} + /// Extract keys for multiple actions and return them as a flat vector pub fn extract_keys_for_actions( keybindings: &KeyBindings, @@ -170,7 +188,16 @@ pub fn extract_keys_for_actions( ) -> Vec { actions .iter() - .filter_map(|action| keybindings.get(action)) - .flat_map(extract_keys_from_binding) + .flat_map(|action| find_keys_for_action(keybindings, action)) .collect() } + +/// Remove all keybindings for a specific action from `KeyBindings` +pub fn remove_action_bindings( + keybindings: &mut KeyBindings, + target_action: &Action, +) { + keybindings + .bindings + .retain(|_, action| action != target_action); +} diff --git a/television/screen/status_bar.rs b/television/screen/status_bar.rs index c518c2a..0b6b1d1 100644 --- a/television/screen/status_bar.rs +++ b/television/screen/status_bar.rs @@ -1,5 +1,6 @@ use crate::{ - action::Action, draw::Ctx, features::FeatureFlags, television::Mode, + action::Action, draw::Ctx, features::FeatureFlags, + screen::keybindings::find_keys_for_action, television::Mode, }; use ratatui::{ Frame, @@ -132,14 +133,16 @@ pub fn draw_status_bar(f: &mut Frame<'_>, area: Rect, ctx: &Ctx) { .features .is_enabled(FeatureFlags::RemoteControl) { - if let Some(binding) = - ctx.config.keybindings.get(&Action::ToggleRemoteControl) - { + let keys = find_keys_for_action( + &ctx.config.keybindings, + &Action::ToggleRemoteControl, + ); + if let Some(key) = keys.first() { let hint_text = match ctx.tv_state.mode { Mode::Channel => "Remote Control", Mode::RemoteControl => "Back to Channel", }; - add_hint(hint_text, &binding.to_string()); + add_hint(hint_text, key); } } @@ -151,9 +154,11 @@ pub fn draw_status_bar(f: &mut Frame<'_>, area: Rect, ctx: &Ctx) { .features .is_enabled(FeatureFlags::PreviewPanel) { - if let Some(binding) = - ctx.config.keybindings.get(&Action::TogglePreview) - { + let keys = find_keys_for_action( + &ctx.config.keybindings, + &Action::TogglePreview, + ); + if let Some(key) = keys.first() { let hint_text = if ctx .config .ui @@ -164,13 +169,15 @@ pub fn draw_status_bar(f: &mut Frame<'_>, area: Rect, ctx: &Ctx) { } else { "Show Preview" }; - add_hint(hint_text, &binding.to_string()); + add_hint(hint_text, key); } } // Add keybinding help hint (available in both modes) - if let Some(binding) = ctx.config.keybindings.get(&Action::ToggleHelp) { - add_hint("Help", &binding.to_string()); + let keys = + find_keys_for_action(&ctx.config.keybindings, &Action::ToggleHelp); + if let Some(key) = keys.first() { + add_hint("Help", key); } // Build middle section if we have hints diff --git a/television/television.rs b/television/television.rs index 3749ca4..8c688bd 100644 --- a/television/television.rs +++ b/television/television.rs @@ -20,6 +20,7 @@ use crate::{ render::UiState, screen::{ colors::Colorscheme, + keybindings::remove_action_bindings, layout::InputPosition, spinner::{Spinner, SpinnerState}, }, @@ -238,7 +239,10 @@ impl Television { // of flags that Television manages directly if no_preview { config.ui.features.disable(FeatureFlags::PreviewPanel); - config.keybindings.remove(&Action::TogglePreview); + remove_action_bindings( + &mut config.keybindings, + &Action::TogglePreview, + ); } // Apply preview size regardless of preview state @@ -916,7 +920,6 @@ mod test { use crate::{ action::Action, cable::Cable, - config::Binding, event::Key, television::{MatchingMode, Television}, }; @@ -963,10 +966,9 @@ mod test { #[tokio::test(flavor = "multi_thread", worker_threads = 2)] async fn test_channel_keybindings_take_precedence() { let mut config = crate::config::Config::default(); - config.keybindings.insert( - Action::SelectNextEntry, - Binding::SingleKey(Key::Ctrl('n')), - ); + config + .keybindings + .insert(Key::Ctrl('n'), Action::SelectNextEntry); let prototype = toml::from_str::( @@ -978,7 +980,7 @@ mod test { command = "echo 1" [keybindings] - select_next_entry = "ctrl-j" + ctrl-j = "select_next_entry" "#, ) .unwrap(); @@ -996,8 +998,8 @@ mod test { ); assert_eq!( - tv.config.keybindings.get(&Action::SelectNextEntry), - Some(&Binding::SingleKey(Key::Ctrl('j'))) + tv.config.keybindings.get(&Key::Ctrl('j')), + Some(&Action::SelectNextEntry), ); } } diff --git a/tests/cli/cli_input.rs b/tests/cli/cli_input.rs index 3d6a4a3..4a9490c 100644 --- a/tests/cli/cli_input.rs +++ b/tests/cli/cli_input.rs @@ -35,20 +35,22 @@ fn test_input_prefills_search_box() { fn test_keybindings_override_default() { let mut tester = PtyTester::new(); - // This remaps the quit action from default keys (Esc, Ctrl+C) to just "a" + // This adds a new mapping for the quit action let mut child = tester.spawn_command_tui(tv_local_config_and_cable_with_args(&[ "--keybindings", - "quit='a'", + "a=\"quit\"", ])); - // Test that ESC no longer quits (default behavior is overridden) - tester.send(ESC); - tester.assert_tui_running(&mut child); + // TODO: add back when unbinding is implemented - // Test that Ctrl+C no longer quits (default behavior is overridden) - tester.send(&ctrl('c')); - tester.assert_tui_running(&mut child); + // // Test that ESC no longer quits (default behavior is overridden) + // tester.send(ESC); + // tester.assert_tui_running(&mut child); + // + // // Test that Ctrl+C no longer quits (default behavior is overridden) + // tester.send(&ctrl('c')); + // tester.assert_tui_running(&mut child); // Test that our custom "a" key now quits the application tester.send("'a'"); @@ -64,12 +66,14 @@ fn test_multiple_keybindings_override() { let mut child = tester.spawn_command_tui(tv_local_config_and_cable_with_args(&[ "--keybindings", - "quit='a';toggle_remote_control='ctrl-t'", + "a=\"quit\";ctrl-t=\"toggle_remote_control\"", ])); - // Verify ESC doesn't quit (default overridden) - tester.send(ESC); - tester.assert_tui_running(&mut child); + // TODO: add back when unbinding is implemented + + // // Verify ESC doesn't quit (default overridden) + // tester.send(ESC); + // tester.assert_tui_running(&mut child); // Test that Ctrl+T opens remote control panel (custom keybinding works) tester.send(&ctrl('t')); diff --git a/tests/cli/cli_ui_behavior.rs b/tests/cli/cli_ui_behavior.rs index 9065f6c..2e7a751 100644 --- a/tests/cli/cli_ui_behavior.rs +++ b/tests/cli/cli_ui_behavior.rs @@ -62,7 +62,7 @@ fn test_toggle_status_bar_keybinding() { let cmd = tv_local_config_and_cable_with_args(&[ "files", "--keybindings", - "toggle_status_bar=\"ctrl-k\"", + "ctrl-k = \"toggle_status_bar\"", ]); let mut child = tester.spawn_command_tui(cmd);