diff options
Diffstat (limited to 'src/input')
| -rw-r--r-- | src/input/keybind_help.rs | 269 | ||||
| -rw-r--r-- | src/input/keybindings.rs | 298 | ||||
| -rw-r--r-- | src/input/lease.rs | 452 | ||||
| -rw-r--r-- | src/input/mod.rs | 16 | ||||
| -rw-r--r-- | src/input/model.rs | 44 | ||||
| -rw-r--r-- | src/input/mouse.rs | 89 |
6 files changed, 1142 insertions, 26 deletions
diff --git a/src/input/keybind_help.rs b/src/input/keybind_help.rs new file mode 100644 index 00000000..e9462b9d --- /dev/null +++ b/src/input/keybind_help.rs @@ -0,0 +1,269 @@ +use std::borrow::Cow; + +use crossterm::event::{KeyCode, KeyModifiers}; + +use crate::{ + config::{ActionKeybinds, IndexedKeybind, Keybinds}, + input::TerminalKey, +}; + +pub(crate) type KeybindHelpEntry = (String, Cow<'static, str>); +pub(crate) type KeybindHelpGroup = (&'static str, Vec<KeybindHelpEntry>); + +pub(crate) fn keybind_help_text_char(key: &TerminalKey) -> Option<char> { + if !key.modifiers.difference(KeyModifiers::SHIFT).is_empty() { + return None; + } + if let Some(character) = key.shifted_codepoint.and_then(char::from_u32) { + return Some(character); + } + let KeyCode::Char(character) = key.code else { + return None; + }; + Some(character) +} + +fn entry(key: impl Into<String>, label: &'static str) -> KeybindHelpEntry { + (key.into(), Cow::Borrowed(label)) +} + +fn binding_label(bindings: &ActionKeybinds) -> String { + bindings.label().unwrap_or_else(|| "unset".to_owned()) +} + +fn indexed_label(bindings: &[IndexedKeybind]) -> String { + if bindings.is_empty() { + return "unset".to_owned(); + } + let mut parts = Vec::new(); + let mut index = 0; + while index < bindings.len() { + if let Some(prefix) = indexed_range_prefix(&bindings[index..]) { + parts.push(format!("{prefix}1..9")); + index += 9; + } else { + parts.push(bindings[index].label.clone()); + index += 1; + } + } + parts.join(" / ") +} + +fn indexed_range_prefix(bindings: &[IndexedKeybind]) -> Option<&str> { + let run = bindings.get(..9)?; + let prefix = run[0].label.strip_suffix('1')?; + for (offset, binding) in run.iter().enumerate() { + let digit = char::from(b'1' + offset as u8); + if binding.label.strip_suffix(digit) != Some(prefix) { + return None; + } + } + Some(prefix) +} + +pub(crate) fn keybind_help_groups( + keybinds: &Keybinds, + prefix: (crossterm::event::KeyCode, crossterm::event::KeyModifiers), +) -> Vec<KeybindHelpGroup> { + let mut groups = vec![ + ( + "global", + vec![ + entry(crate::config::format_key_combo(prefix), "prefix mode"), + entry(binding_label(&keybinds.help), "keybinds"), + entry(binding_label(&keybinds.settings), "settings"), + entry(binding_label(&keybinds.detach), "detach"), + entry(binding_label(&keybinds.reload_config), "reload config"), + entry( + binding_label(&keybinds.open_notification_target), + "open notification target", + ), + ], + ), + ( + "navigation", + vec![ + entry("esc", "back"), + entry( + format!( + "{} / {}", + binding_label(&keybinds.navigate.workspace_up), + binding_label(&keybinds.navigate.workspace_down) + ), + "workspace list", + ), + entry( + format!( + "{} / {} / {} / {} / left / right", + binding_label(&keybinds.navigate.pane_left), + binding_label(&keybinds.navigate.pane_down), + binding_label(&keybinds.navigate.pane_up), + binding_label(&keybinds.navigate.pane_right) + ), + "move focus", + ), + entry("tab / shift+tab", "cycle pane"), + entry("enter", "open workspace"), + entry("1..9", "switch workspace"), + ], + ), + ( + "workspaces / tabs", + vec![ + entry( + binding_label(&keybinds.workspace_picker), + "workspace navigation", + ), + entry(binding_label(&keybinds.goto), "session navigator"), + entry(binding_label(&keybinds.new_workspace), "new workspace"), + entry(binding_label(&keybinds.new_worktree), "new worktree"), + entry(binding_label(&keybinds.open_worktree), "open worktree"), + entry( + binding_label(&keybinds.remove_worktree), + "delete worktree checkout", + ), + entry( + binding_label(&keybinds.rename_workspace), + "rename workspace", + ), + entry(binding_label(&keybinds.close_workspace), "close workspace"), + entry( + binding_label(&keybinds.previous_workspace), + "previous workspace", + ), + entry(binding_label(&keybinds.next_workspace), "next workspace"), + entry( + indexed_label(&keybinds.switch_workspace), + "switch workspace 1-9", + ), + entry(binding_label(&keybinds.previous_agent), "previous agent"), + entry(binding_label(&keybinds.next_agent), "next agent"), + entry(indexed_label(&keybinds.focus_agent), "focus agent 1-9"), + entry(binding_label(&keybinds.new_tab), "new tab"), + entry(binding_label(&keybinds.rename_tab), "rename tab"), + entry(binding_label(&keybinds.previous_tab), "previous tab"), + entry(binding_label(&keybinds.next_tab), "next tab"), + entry(binding_label(&keybinds.move_tab_previous), "move tab left"), + entry(binding_label(&keybinds.move_tab_next), "move tab right"), + entry(indexed_label(&keybinds.switch_tab), "switch tab 1-9"), + entry(binding_label(&keybinds.close_tab), "close tab"), + ], + ), + ( + "panes", + vec![ + entry(binding_label(&keybinds.split_vertical), "split vertical"), + entry( + binding_label(&keybinds.split_horizontal), + "split horizontal", + ), + entry(binding_label(&keybinds.close_pane), "close pane"), + entry(binding_label(&keybinds.rename_pane), "rename pane"), + entry(binding_label(&keybinds.edit_scrollback), "edit scrollback"), + entry(binding_label(&keybinds.copy_mode), "copy mode"), + entry(binding_label(&keybinds.zoom), "zoom pane"), + entry(binding_label(&keybinds.resize_mode), "resize mode"), + entry( + binding_label(&keybinds.resize_pane_left), + "resize pane left", + ), + entry( + binding_label(&keybinds.resize_pane_down), + "resize pane down", + ), + entry(binding_label(&keybinds.resize_pane_up), "resize pane up"), + entry( + binding_label(&keybinds.resize_pane_right), + "resize pane right", + ), + entry(binding_label(&keybinds.toggle_sidebar), "toggle sidebar"), + entry(binding_label(&keybinds.focus_pane_left), "focus pane left"), + entry(binding_label(&keybinds.focus_pane_down), "focus pane down"), + entry(binding_label(&keybinds.focus_pane_up), "focus pane up"), + entry( + binding_label(&keybinds.focus_pane_right), + "focus pane right", + ), + entry(binding_label(&keybinds.cycle_pane_next), "cycle pane next"), + entry( + binding_label(&keybinds.cycle_pane_previous), + "cycle pane previous", + ), + entry(binding_label(&keybinds.last_pane), "last pane"), + ], + ), + ]; + + if !keybinds.custom_commands.is_empty() { + groups.push(( + "custom", + keybinds + .custom_commands + .iter() + .map(|binding| { + ( + binding.label.clone(), + binding + .description + .clone() + .map(Cow::Owned) + .unwrap_or(Cow::Borrowed("custom command")), + ) + }) + .collect(), + )); + } + groups +} + +pub(crate) fn filter_keybind_help_groups( + groups: Vec<KeybindHelpGroup>, + query: &str, +) -> Vec<KeybindHelpGroup> { + if query.is_empty() { + return groups; + } + let query = query.to_lowercase(); + groups + .into_iter() + .filter_map(|(group, entries)| { + let entries = entries + .into_iter() + .filter(|(key, label)| { + key.to_lowercase().contains(&query) || label.to_lowercase().contains(&query) + }) + .collect::<Vec<_>>(); + (!entries.is_empty()).then_some((group, entries)) + }) + .collect() +} + +#[cfg(test)] +mod tests { + use super::*; + + fn groups() -> Vec<KeybindHelpGroup> { + vec![ + ( + "workspaces / tabs", + vec![entry("w", "workspace navigation"), entry("c", "new tab")], + ), + ( + "panes", + vec![entry("v", "split vertical"), entry("x", "close pane")], + ), + ] + } + + #[test] + fn filter_matches_labels_and_shortcuts_case_insensitively() { + let filtered = filter_keybind_help_groups(groups(), "WoRk"); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].1[0].1, "workspace navigation"); + + let filtered = filter_keybind_help_groups(groups(), "x"); + assert_eq!(filtered.len(), 1); + assert_eq!(filtered[0].1[0].1, "close pane"); + assert!(filter_keybind_help_groups(groups(), "panes").is_empty()); + } +} diff --git a/src/input/keybindings.rs b/src/input/keybindings.rs new file mode 100644 index 00000000..d56de80c --- /dev/null +++ b/src/input/keybindings.rs @@ -0,0 +1,298 @@ +use crossterm::event::KeyCode; + +use crate::config::{CustomCommandKeybind, Keybinds}; + +use super::TerminalKey; + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum KeybindDispatch { + Direct, + Prefix, +} + +#[derive(Debug, Clone)] +pub(crate) enum KeybindMatch { + Action(KeybindAction), + Command(CustomCommandKeybind), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub(crate) enum KeybindAction { + NewWorkspace, + NewWorktree, + OpenWorktree, + RemoveWorktree, + RenameWorkspace, + CloseWorkspace, + SwitchWorkspace(usize), + SwitchTab(usize), + FocusAgent(usize), + WorkspacePicker, + PreviousWorkspace, + NextWorkspace, + PreviousAgent, + NextAgent, + NewTab, + RenameTab, + PreviousTab, + NextTab, + MoveTabPrevious, + MoveTabNext, + CloseTab, + RenamePane, + FocusPaneLeft, + FocusPaneDown, + FocusPaneUp, + FocusPaneRight, + SwapPaneLeft, + SwapPaneDown, + SwapPaneUp, + SwapPaneRight, + SplitVertical, + SplitHorizontal, + ClosePane, + EditScrollback, + CopyMode, + Zoom, + EnterResizeMode, + ResizePaneLeft, + ResizePaneDown, + ResizePaneUp, + ResizePaneRight, + ToggleSidebar, + CyclePaneNext, + CyclePanePrevious, + LastPane, + Help, + Settings, + ReloadConfig, + OpenNotificationTarget, + Detach, + OpenNavigator, +} + +pub(crate) fn resolve_direct_binding( + keybinds: &Keybinds, + key: &TerminalKey, +) -> Option<KeybindMatch> { + resolve_exact_binding(keybinds, key, KeybindDispatch::Direct) +} + +pub(crate) fn resolve_prefix_binding( + keybinds: &Keybinds, + key: &TerminalKey, +) -> Option<KeybindMatch> { + resolve_exact_binding(keybinds, key, KeybindDispatch::Prefix).or_else(|| { + generated_character_key(key).and_then(|generated_key| { + resolve_exact_binding(keybinds, &generated_key, KeybindDispatch::Prefix) + }) + }) +} + +pub(crate) fn resolve_non_indexed_action( + keybinds: &Keybinds, + key: &TerminalKey, + dispatch: KeybindDispatch, +) -> Option<KeybindAction> { + for (bindings, action) in [ + (&keybinds.help, KeybindAction::Help), + (&keybinds.settings, KeybindAction::Settings), + (&keybinds.workspace_picker, KeybindAction::WorkspacePicker), + (&keybinds.new_workspace, KeybindAction::NewWorkspace), + (&keybinds.new_worktree, KeybindAction::NewWorktree), + (&keybinds.open_worktree, KeybindAction::OpenWorktree), + (&keybinds.remove_worktree, KeybindAction::RemoveWorktree), + (&keybinds.rename_workspace, KeybindAction::RenameWorkspace), + (&keybinds.close_workspace, KeybindAction::CloseWorkspace), + ( + &keybinds.previous_workspace, + KeybindAction::PreviousWorkspace, + ), + (&keybinds.next_workspace, KeybindAction::NextWorkspace), + (&keybinds.previous_agent, KeybindAction::PreviousAgent), + (&keybinds.next_agent, KeybindAction::NextAgent), + (&keybinds.new_tab, KeybindAction::NewTab), + (&keybinds.rename_tab, KeybindAction::RenameTab), + (&keybinds.previous_tab, KeybindAction::PreviousTab), + (&keybinds.next_tab, KeybindAction::NextTab), + (&keybinds.move_tab_previous, KeybindAction::MoveTabPrevious), + (&keybinds.move_tab_next, KeybindAction::MoveTabNext), + (&keybinds.close_tab, KeybindAction::CloseTab), + (&keybinds.rename_pane, KeybindAction::RenamePane), + (&keybinds.edit_scrollback, KeybindAction::EditScrollback), + (&keybinds.copy_mode, KeybindAction::CopyMode), + (&keybinds.focus_pane_left, KeybindAction::FocusPaneLeft), + (&keybinds.focus_pane_down, KeybindAction::FocusPaneDown), + (&keybinds.focus_pane_up, KeybindAction::FocusPaneUp), + (&keybinds.focus_pane_right, KeybindAction::FocusPaneRight), + (&keybinds.swap_pane_left, KeybindAction::SwapPaneLeft), + (&keybinds.swap_pane_down, KeybindAction::SwapPaneDown), + (&keybinds.swap_pane_up, KeybindAction::SwapPaneUp), + (&keybinds.swap_pane_right, KeybindAction::SwapPaneRight), + (&keybinds.last_pane, KeybindAction::LastPane), + (&keybinds.cycle_pane_next, KeybindAction::CyclePaneNext), + ( + &keybinds.cycle_pane_previous, + KeybindAction::CyclePanePrevious, + ), + (&keybinds.split_vertical, KeybindAction::SplitVertical), + (&keybinds.split_horizontal, KeybindAction::SplitHorizontal), + (&keybinds.close_pane, KeybindAction::ClosePane), + (&keybinds.zoom, KeybindAction::Zoom), + (&keybinds.resize_mode, KeybindAction::EnterResizeMode), + (&keybinds.resize_pane_left, KeybindAction::ResizePaneLeft), + (&keybinds.resize_pane_down, KeybindAction::ResizePaneDown), + (&keybinds.resize_pane_up, KeybindAction::ResizePaneUp), + (&keybinds.resize_pane_right, KeybindAction::ResizePaneRight), + (&keybinds.toggle_sidebar, KeybindAction::ToggleSidebar), + (&keybinds.reload_config, KeybindAction::ReloadConfig), + ( + &keybinds.open_notification_target, + KeybindAction::OpenNotificationTarget, + ), + (&keybinds.detach, KeybindAction::Detach), + (&keybinds.goto, KeybindAction::OpenNavigator), + ] { + if action_matches(bindings, key, dispatch) { + return Some(action); + } + } + None +} + +pub(crate) fn resolve_custom_command( + keybinds: &Keybinds, + key: &TerminalKey, + dispatch: KeybindDispatch, +) -> Option<CustomCommandKeybind> { + keybinds + .custom_commands + .iter() + .find(|binding| match dispatch { + KeybindDispatch::Direct => binding.bindings.matches_direct_key(key), + KeybindDispatch::Prefix => binding.bindings.matches_prefix_key(key), + }) + .cloned() +} + +pub(crate) fn resolve_indexed_action( + keybinds: &Keybinds, + key: &TerminalKey, + dispatch: KeybindDispatch, +) -> Option<KeybindAction> { + let actual_modifiers = crate::config::normalize_key_combo((key.code, key.modifiers)).1; + + for exact_modifiers in [true, false] { + let trigger_matches = |binding: &crate::config::IndexedKeybind| { + let dispatch_matches = match dispatch { + KeybindDispatch::Direct => binding.trigger.is_direct(), + KeybindDispatch::Prefix => binding.trigger.is_prefix(), + }; + let expected_modifiers = crate::config::normalize_key_combo(binding.trigger.combo()).1; + dispatch_matches && (actual_modifiers == expected_modifiers) == exact_modifiers + }; + + for binding in &keybinds.switch_tab { + if trigger_matches(binding) { + if let Some(index) = binding.matched_index(key) { + return Some(KeybindAction::SwitchTab(index)); + } + } + } + for binding in &keybinds.switch_workspace { + if trigger_matches(binding) { + if let Some(index) = binding.matched_index(key) { + return Some(KeybindAction::SwitchWorkspace(index)); + } + } + } + for binding in &keybinds.focus_agent { + if trigger_matches(binding) { + if let Some(index) = binding.matched_index(key) { + return Some(KeybindAction::FocusAgent(index)); + } + } + } + } + + None +} + +fn resolve_exact_binding( + keybinds: &Keybinds, + key: &TerminalKey, + dispatch: KeybindDispatch, +) -> Option<KeybindMatch> { + resolve_non_indexed_action(keybinds, key, dispatch) + .map(KeybindMatch::Action) + .or_else(|| resolve_custom_command(keybinds, key, dispatch).map(KeybindMatch::Command)) + .or_else(|| resolve_indexed_action(keybinds, key, dispatch).map(KeybindMatch::Action)) +} + +fn generated_character_key(key: &TerminalKey) -> Option<TerminalKey> { + let mut characters = key.generated_text.as_deref()?.chars(); + let character = characters.next()?; + if character.is_control() || characters.next().is_some() { + return None; + } + Some(TerminalKey::new( + KeyCode::Char(character), + crossterm::event::KeyModifiers::empty(), + )) +} + +fn action_matches( + bindings: &crate::config::ActionKeybinds, + key: &TerminalKey, + dispatch: KeybindDispatch, +) -> bool { + match dispatch { + KeybindDispatch::Direct => bindings.matches_direct_key(key), + KeybindDispatch::Prefix => bindings.matches_prefix_key(key), + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyModifiers}; + + use super::*; + + #[test] + fn one_shared_resolver_handles_direct_prefix_and_indexed_bindings() { + let keybinds = Keybinds { + next_tab: crate::config::ActionKeybinds::direct("ctrl+n"), + ..Keybinds::default() + }; + + let direct = TerminalKey::new(KeyCode::Char('n'), KeyModifiers::CONTROL); + assert!(matches!( + resolve_direct_binding(&keybinds, &direct), + Some(KeybindMatch::Action(KeybindAction::NextTab)) + )); + + let help = TerminalKey::new(KeyCode::Char('?'), KeyModifiers::empty()); + assert!(matches!( + resolve_prefix_binding(&keybinds, &help), + Some(KeybindMatch::Action(KeybindAction::Help)) + )); + + let one = TerminalKey::new(KeyCode::Char('1'), KeyModifiers::empty()); + assert!(matches!( + resolve_prefix_binding(&keybinds, &one), + Some(KeybindMatch::Action(KeybindAction::SwitchTab(0))) + )); + } + + #[test] + fn prefix_resolution_uses_shared_generated_character_fallback() { + let keybinds = Keybinds::default(); + let key = TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) + .with_generated_text(Some("?".to_owned())); + + assert!(matches!( + resolve_prefix_binding(&keybinds, &key), + Some(KeybindMatch::Action(KeybindAction::Help)) + )); + } +} diff --git a/src/input/lease.rs b/src/input/lease.rs new file mode 100644 index 00000000..0352df42 --- /dev/null +++ b/src/input/lease.rs @@ -0,0 +1,452 @@ +use std::collections::HashMap; +use std::hash::Hash; + +use super::{KeyIdentity, TerminalKey}; + +#[derive(Clone, Copy, Debug, PartialEq, Eq, Hash)] +pub(crate) struct InputLeaseKey<Source> { + source: Source, + identity: KeyIdentity, +} + +impl<Source> InputLeaseKey<Source> { + pub(crate) fn new(source: Source, key: &TerminalKey) -> Self { + Self { + source, + identity: key.identity(), + } + } +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) struct ForwardedInputLease<Target> { + pub(crate) target: Target, + pub(crate) key: TerminalKey, +} + +#[derive(Clone, Debug, PartialEq, Eq)] +pub(crate) enum ConsumedInputLease<Context> { + ReprocessRepeats(Context), + SuppressRepeats, +} + +#[derive(Debug, PartialEq, Eq)] +pub(crate) enum InputLease<Context, Target> { + Forwarded(ForwardedInputLease<Target>), + Consumed(ConsumedInputLease<Context>), +} + +pub(crate) enum RepeatPlan<Context, Target> { + Forwarded(Target), + Reprocess { + context: Context, + repetitions: u16, + tracked: bool, + }, + Ignore, +} + +pub(crate) struct InputLeaseTable<Source, Context, Target> { + leases: HashMap<InputLeaseKey<Source>, InputLease<Context, Target>>, +} + +impl<Source, Context, Target> Default for InputLeaseTable<Source, Context, Target> { + fn default() -> Self { + Self { + leases: HashMap::new(), + } + } +} + +impl<Source, Context, Target> InputLeaseTable<Source, Context, Target> +where + Source: Copy + Eq + Hash, + Context: Clone + Eq, + Target: Clone + Eq, +{ + pub(crate) fn normalize_press( + &mut self, + lease_key: &InputLeaseKey<Source>, + key: TerminalKey, + ) -> TerminalKey { + if key.kind != crossterm::event::KeyEventKind::Press + || (key.generated_text.is_some() && !key.has_physical_identity()) + { + return key; + } + if key.has_physical_identity() && self.leases.contains_key(lease_key) { + key.with_kind(crossterm::event::KeyEventKind::Repeat) + } else { + self.leases.remove(lease_key); + key + } + } + + pub(crate) fn complete_press( + &mut self, + lease_key: InputLeaseKey<Source>, + key: &TerminalKey, + initial_context: Option<&Context>, + resulting_context: Option<&Context>, + target: Option<Target>, + ) -> RepeatPlan<Context, Target> { + if key.generated_text.is_some() && !key.has_physical_identity() { + return RepeatPlan::Ignore; + } + if let Some(target) = target { + self.insert_forwarded(lease_key, target, key.clone()); + return RepeatPlan::Ignore; + } + if !self.leases.contains_key(&lease_key) { + let disposition = match (initial_context, resulting_context) { + (Some(initial), Some(resulting)) if initial == resulting => { + ConsumedInputLease::ReprocessRepeats(initial.clone()) + } + _ => ConsumedInputLease::SuppressRepeats, + }; + self.insert_consumed(lease_key, disposition); + } + match self.leases.get(&lease_key) { + Some(InputLease::Consumed(ConsumedInputLease::ReprocessRepeats(context))) + if key.repeat_count > 1 => + { + RepeatPlan::Reprocess { + context: context.clone(), + repetitions: key.repeat_count - 1, + tracked: true, + } + } + _ => RepeatPlan::Ignore, + } + } + + pub(crate) fn plan_repeat( + &mut self, + lease_key: InputLeaseKey<Source>, + key: &TerminalKey, + current_context: Option<&Context>, + ) -> RepeatPlan<Context, Target> { + match self.leases.get(&lease_key) { + Some(InputLease::Forwarded(lease)) => { + return RepeatPlan::Forwarded(lease.target.clone()); + } + Some(InputLease::Consumed(ConsumedInputLease::ReprocessRepeats(context))) + if current_context == Some(context) => + { + return RepeatPlan::Reprocess { + context: context.clone(), + repetitions: key.repeat_count, + tracked: true, + }; + } + Some(InputLease::Consumed(ConsumedInputLease::ReprocessRepeats(_))) => { + self.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats); + return RepeatPlan::Ignore; + } + Some(InputLease::Consumed(ConsumedInputLease::SuppressRepeats)) => { + return RepeatPlan::Ignore; + } + None => {} + } + match current_context { + Some(context) => RepeatPlan::Reprocess { + context: context.clone(), + repetitions: key.repeat_count, + tracked: false, + }, + None => RepeatPlan::Ignore, + } + } + + pub(crate) fn reprocess_allowed( + &mut self, + lease_key: InputLeaseKey<Source>, + expected_context: &Context, + current_context: Option<&Context>, + tracked: bool, + ) -> bool { + let allowed = current_context == Some(expected_context); + if tracked && !allowed { + self.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats); + } + allowed + } + + pub(crate) fn remove_forwarded( + &mut self, + key: &InputLeaseKey<Source>, + ) -> Option<ForwardedInputLease<Target>> { + match self.leases.remove(key) { + Some(InputLease::Forwarded(lease)) => Some(lease), + Some(InputLease::Consumed(_)) | None => None, + } + } + + #[cfg(test)] + pub(crate) fn contains(&self, key: &InputLeaseKey<Source>) -> bool { + self.leases.contains_key(key) + } + + pub(crate) fn insert_forwarded( + &mut self, + key: InputLeaseKey<Source>, + target: Target, + original: TerminalKey, + ) { + self.leases.insert( + key, + InputLease::Forwarded(ForwardedInputLease { + target, + key: original, + }), + ); + } + + pub(crate) fn insert_consumed( + &mut self, + key: InputLeaseKey<Source>, + disposition: ConsumedInputLease<Context>, + ) { + self.leases.insert(key, InputLease::Consumed(disposition)); + } + + pub(crate) fn remove( + &mut self, + key: &InputLeaseKey<Source>, + ) -> Option<InputLease<Context, Target>> { + self.leases.remove(key) + } + + pub(crate) fn remove_source(&mut self, source: Source) -> Vec<ForwardedInputLease<Target>> { + let keys = self + .leases + .keys() + .filter(|key| key.source == source) + .copied() + .collect::<Vec<_>>(); + self.remove_keys(keys) + } + + pub(crate) fn remove_target(&mut self, target: &Target) -> Vec<ForwardedInputLease<Target>> { + let keys = self + .leases + .iter() + .filter_map(|(key, lease)| match lease { + InputLease::Forwarded(lease) if &lease.target == target => Some(*key), + InputLease::Forwarded(_) | InputLease::Consumed(_) => None, + }) + .collect::<Vec<_>>(); + self.remove_keys(keys) + } + + fn remove_keys( + &mut self, + keys: impl IntoIterator<Item = InputLeaseKey<Source>>, + ) -> Vec<ForwardedInputLease<Target>> { + keys.into_iter() + .filter_map(|key| match self.leases.remove(&key) { + Some(InputLease::Forwarded(lease)) => Some(lease), + Some(InputLease::Consumed(_)) | None => None, + }) + .collect() + } + + #[cfg(test)] + pub(crate) fn is_empty(&self) -> bool { + self.leases.is_empty() + } + + #[cfg(test)] + pub(crate) fn len(&self) -> usize { + self.leases.len() + } +} + +#[cfg(test)] +mod tests { + use crossterm::event::{KeyCode, KeyModifiers}; + + use super::*; + + #[derive(Clone, Debug, PartialEq, Eq)] + enum Context { + Pane, + } + + type Leases = InputLeaseTable<u64, Context, u64>; + + fn physical_generated_slash(repeat_count: u16) -> TerminalKey { + TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) + .with_generated_text(Some("/".to_owned())) + .with_windows_record(crate::input::WindowsKeyRecord { + key_down: true, + repeat_count, + virtual_key_code: 0x37, + virtual_scan_code: 0x08, + unicode: u16::from(b'/'), + control_key_state: 0x0010, + }) + } + + #[test] + fn remove_source_returns_forwarded_and_discards_consumed_leases() { + let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()); + let forwarded = InputLeaseKey::new(7, &key); + let consumed = InputLeaseKey::new( + 7, + &TerminalKey::new(KeyCode::Char('c'), KeyModifiers::CONTROL), + ); + let other_source = InputLeaseKey::new(8, &key); + let mut leases = Leases::default(); + leases.insert_forwarded(forwarded, 10, key.clone()); + leases.insert_consumed(consumed, ConsumedInputLease::SuppressRepeats); + leases.insert_forwarded(other_source, 11, key.clone()); + + assert_eq!( + leases.remove_source(7), + vec![ForwardedInputLease { target: 10, key }] + ); + assert_eq!(leases.len(), 1); + assert!(leases.contains(&other_source)); + } + + #[test] + fn remove_target_closes_only_forwarded_leases_for_that_target() { + let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()); + let removed_key = InputLeaseKey::new(7, &key); + let retained_key = InputLeaseKey::new(8, &key); + let mut leases = Leases::default(); + leases.insert_forwarded(removed_key, 10, key.clone()); + leases.insert_forwarded(retained_key, 11, key.clone()); + + assert_eq!( + leases.remove_target(&10), + vec![ForwardedInputLease { target: 10, key }] + ); + assert_eq!(leases.len(), 1); + assert!(leases.contains(&retained_key)); + } + + #[test] + fn duplicate_physical_press_normalizes_for_forwarded_and_consumed_leases() { + let record = crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 65, + virtual_scan_code: 30, + unicode: 97, + control_key_state: 0, + }; + let physical = + TerminalKey::new(KeyCode::Char('a'), KeyModifiers::empty()).with_windows_record(record); + let lease_key = InputLeaseKey::new(7, &physical); + let mut leases = Leases::default(); + + assert_eq!( + leases.normalize_press(&lease_key, physical.clone()).kind, + crossterm::event::KeyEventKind::Press + ); + leases.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats); + assert_eq!( + leases.normalize_press(&lease_key, physical.clone()).kind, + crossterm::event::KeyEventKind::Repeat + ); + leases.insert_forwarded(lease_key, 10, physical.clone()); + assert_eq!( + leases.normalize_press(&lease_key, physical).kind, + crossterm::event::KeyEventKind::Repeat + ); + } + + #[test] + fn physical_generated_text_keeps_native_repeat_lifecycle() { + let key = physical_generated_slash(3); + let lease_key = InputLeaseKey::new(7, &key); + let context = Context::Pane; + let mut leases = Leases::default(); + + assert!(matches!( + leases.complete_press(lease_key, &key, Some(&context), Some(&context), Some(10),), + RepeatPlan::Ignore + )); + let repeated = leases.normalize_press(&lease_key, key.with_repeat_count(1)); + assert_eq!(repeated.kind, crossterm::event::KeyEventKind::Repeat); + assert!(matches!( + leases.plan_repeat(lease_key, &repeated, Some(&context)), + RepeatPlan::Forwarded(10) + )); + assert!(leases.remove_forwarded(&lease_key).is_some()); + } + + #[test] + fn consumed_grouped_physical_generated_text_reprocesses_repeats() { + let key = physical_generated_slash(3); + let lease_key = InputLeaseKey::new(7, &key); + let context = Context::Pane; + let mut leases = Leases::default(); + + assert!(matches!( + leases.complete_press(lease_key, &key, Some(&context), Some(&context), None), + RepeatPlan::Reprocess { + context: Context::Pane, + repetitions: 2, + tracked: true, + } + )); + } + + #[test] + fn forwarded_semantic_generated_text_has_no_release_lease() { + let key = TerminalKey::new(KeyCode::Char('/'), KeyModifiers::SHIFT) + .with_generated_text(Some("/".to_owned())) + .with_repeat_count(3); + let lease_key = InputLeaseKey::new(7, &key); + let context = Context::Pane; + let mut leases = Leases::default(); + + assert!(matches!( + leases.complete_press(lease_key, &key, Some(&context), Some(&context), Some(10)), + RepeatPlan::Ignore + )); + assert_eq!(leases.remove_forwarded(&lease_key), None); + } + + #[test] + fn new_semantic_press_recomputes_consumed_repeat_disposition() { + let key = TerminalKey::new(KeyCode::Esc, KeyModifiers::empty()).with_repeat_count(3); + let lease_key = InputLeaseKey::new(7, &key); + let context = Context::Pane; + let mut leases = Leases::default(); + leases.insert_consumed(lease_key, ConsumedInputLease::SuppressRepeats); + + let key = leases.normalize_press(&lease_key, key); + assert!(matches!( + leases.complete_press(lease_key, &key, Some(&context), Some(&context), None), + RepeatPlan::Reprocess { + context: Context::Pane, + repetitions: 2, + tracked: true, + } + )); + } + + #[test] + fn physical_and_semantic_identities_do_not_collide() { + let record = crate::input::WindowsKeyRecord { + key_down: true, + repeat_count: 1, + virtual_key_code: 65, + virtual_scan_code: 30, + unicode: 97, + control_key_state: 0, + }; + let physical = + TerminalKey::new(KeyCode::Char('a'), KeyModifiers::empty()).with_windows_record(record); + let semantic = TerminalKey::new(KeyCode::Char('a'), KeyModifiers::empty()); + + assert_ne!( + InputLeaseKey::new(7, &physical), + InputLeaseKey::new(7, &semantic) + ); + } +} diff --git a/src/input/mod.rs b/src/input/mod.rs index 8108bf99..0cd6c4bd 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1,4 +1,7 @@ mod encode; +mod keybind_help; +mod keybindings; +mod lease; mod model; pub(crate) mod mouse; mod parse; @@ -7,12 +10,23 @@ mod parse; pub use encode::{ encode_cursor_key, encode_key, encode_mouse_button, encode_mouse_scroll, encode_terminal_key, }; +pub(crate) use keybind_help::{ + filter_keybind_help_groups, keybind_help_groups, keybind_help_text_char, +}; +pub(crate) use keybindings::{ + resolve_custom_command, resolve_direct_binding, resolve_indexed_action, + resolve_non_indexed_action, resolve_prefix_binding, KeybindAction, KeybindDispatch, + KeybindMatch, +}; +pub(crate) use lease::{InputLeaseKey, InputLeaseTable, RepeatPlan}; #[cfg(not(windows))] pub use model::ime_compatible_keyboard_enhancement_flags; #[cfg(any(unix, test))] pub use model::MouseProtocolMode; +#[cfg(any(windows, test))] +pub use model::WindowsKeyRecord; pub use model::{ host_modify_other_keys_mode, KeyIdentity, KeyboardProtocol, MouseProtocolEncoding, TerminalKey, - TextCommit, WindowsKeyRecord, + TextCommit, }; pub use parse::parse_terminal_key_sequence; diff --git a/src/input/model.rs b/src/input/model.rs index cada5506..e45ef378 100644 --- a/src/input/model.rs +++ b/src/input/model.rs @@ -3,6 +3,7 @@ use crossterm::event::KeyboardEnhancementFlags; use crossterm::event::{KeyCode, KeyEvent, KeyModifiers}; use serde::{Deserialize, Serialize}; +#[cfg(any(windows, test))] #[derive(Debug, Clone, Copy, PartialEq, Eq, Serialize, Deserialize)] pub struct WindowsKeyRecord { pub key_down: bool, @@ -32,11 +33,13 @@ impl TextCommit { } } +#[cfg(any(windows, test))] #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub struct PhysicalKeyId(u32); #[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] pub enum KeyIdentity { + #[cfg(any(windows, test))] Physical(PhysicalKeyId), Semantic(KeyCode), } @@ -47,12 +50,14 @@ pub(crate) enum KeySource { Vt { bytes: Vec<u8>, }, + #[cfg(any(windows, test))] WindowsConsole { record: WindowsKeyRecord, physical_key: Option<PhysicalKeyId>, }, } +#[cfg(any(windows, test))] impl WindowsKeyRecord { fn physical_key_id(self) -> Option<PhysicalKeyId> { const ENHANCED_KEY: u32 = 0x0100; @@ -73,6 +78,7 @@ pub struct TerminalKey { pub repeat_count: u16, pub shifted_codepoint: Option<u32>, pub generated_text: Option<String>, + physical_identity_hint: bool, source: KeySource, } @@ -85,6 +91,7 @@ impl TerminalKey { repeat_count: 1, shifted_codepoint: None, generated_text: None, + physical_identity_hint: false, source: KeySource::Synthesized, } } @@ -112,7 +119,6 @@ impl TerminalKey { self } - #[allow(dead_code)] // Reserved for the upcoming raw input parser to preserve shifted/base key pairs. pub fn with_shifted_codepoint(mut self, shifted_codepoint: u32) -> Self { self.shifted_codepoint = Some(shifted_codepoint); self @@ -132,19 +138,27 @@ impl TerminalKey { self } + #[cfg(any(windows, test))] pub fn with_windows_record(mut self, record: WindowsKeyRecord) -> Self { self.repeat_count = if self.kind == crossterm::event::KeyEventKind::Release { 1 } else { record.repeat_count.max(1) }; + let physical_key = record.physical_key_id(); + self.physical_identity_hint = physical_key.is_some(); self.source = KeySource::WindowsConsole { - physical_key: record.physical_key_id(), + physical_key, record, }; self } + pub(crate) fn with_physical_identity_hint(mut self, physical: bool) -> Self { + self.physical_identity_hint = physical; + self + } + #[cfg(any(windows, test))] pub(crate) fn vt_bytes(&self) -> Option<&[u8]> { match &self.source { @@ -163,26 +177,36 @@ impl TerminalKey { pub(crate) fn identity(&self) -> KeyIdentity { match self.source { + #[cfg(any(windows, test))] KeySource::WindowsConsole { physical_key: Some(physical_key), .. } => KeyIdentity::Physical(physical_key), + #[cfg(any(windows, test))] KeySource::WindowsConsole { physical_key: None, .. - } - | KeySource::Synthesized - | KeySource::Vt { .. } => KeyIdentity::Semantic(self.code), + } => KeyIdentity::Semantic(self.code), + KeySource::Synthesized | KeySource::Vt { .. } => KeyIdentity::Semantic(self.code), } } pub(crate) fn has_physical_identity(&self) -> bool { - matches!( - self.source, + self.physical_identity_hint || self.physical_key_id().is_some() + } + + pub(crate) fn physical_key_id(&self) -> Option<u32> { + match &self.source { + #[cfg(any(windows, test))] KeySource::WindowsConsole { - physical_key: Some(_), + physical_key: Some(PhysicalKeyId(id)), .. - } - ) + } => Some(*id), + #[cfg(any(windows, test))] + KeySource::WindowsConsole { + physical_key: None, .. + } => None, + KeySource::Synthesized | KeySource::Vt { .. } => None, + } } pub fn with_text_commit(mut self) -> Self { diff --git a/src/input/mouse.rs b/src/input/mouse.rs index 62b5f6c2..984537d1 100644 --- a/src/input/mouse.rs +++ b/src/input/mouse.rs @@ -47,10 +47,12 @@ impl HostGeometry { )) } + #[cfg(test)] fn column_boundary(self, column: u16) -> Option<u32> { boundary(column, self.cols, self.width_px) } + #[cfg(test)] fn row_boundary(self, row: u16) -> Option<u32> { boundary(row, self.rows, self.height_px) } @@ -63,28 +65,68 @@ impl HostPixels { child_width_px: u32, child_height_px: u32, ) -> Option<Position> { - let start_x = self.geometry.column_boundary(inner.x)?; - let start_y = self.geometry.row_boundary(inner.y)?; - let end_x = self - .geometry - .column_boundary(inner.x.checked_add(inner.width)?)?; - let end_y = self - .geometry - .row_boundary(inner.y.checked_add(inner.height)?)?; - let x = self.x.checked_sub(1)?.checked_sub(start_x)?; - let y = self.y.checked_sub(1)?.checked_sub(start_y)?; - let source_width = end_x.checked_sub(start_x)?; - let source_height = end_y.checked_sub(start_y)?; - if x >= source_width || y >= source_height || child_width_px == 0 || child_height_px == 0 { + let (host_column, host_row) = self.geometry.cell(self.x, self.y)?; + let end_column = inner.x.checked_add(inner.width)?; + let end_row = inner.y.checked_add(inner.height)?; + if host_column < inner.x + || host_column >= end_column + || host_row < inner.y + || host_row >= end_row + { return None; } Some(Position::Pixels { - x: scale(x, source_width, child_width_px).checked_add(1)?, - y: scale(y, source_height, child_height_px).checked_add(1)?, + x: map_axis_within_cell( + self.x, + host_column, + inner.x, + inner.width, + self.geometry.cols, + self.geometry.width_px, + child_width_px, + )?, + y: map_axis_within_cell( + self.y, + host_row, + inner.y, + inner.height, + self.geometry.rows, + self.geometry.height_px, + child_height_px, + )?, }) } } +fn map_axis_within_cell( + pixel: u32, + host_cell: u16, + pane_start: u16, + pane_cells: u16, + host_cells: u16, + host_extent: u32, + child_extent: u32, +) -> Option<u32> { + let local_cell = host_cell.checked_sub(pane_start)?; + if local_cell >= pane_cells { + return None; + } + let source_start = boundary(host_cell, host_cells, host_extent)?; + let source_end = boundary(host_cell.checked_add(1)?, host_cells, host_extent)?; + let target_start = boundary(local_cell, pane_cells, child_extent)?; + let target_end = boundary(local_cell.checked_add(1)?, pane_cells, child_extent)?; + let source_width = source_end.checked_sub(source_start)?; + let target_width = target_end.checked_sub(target_start)?; + let offset = pixel.checked_sub(1)?.checked_sub(source_start)?; + if source_width == 0 || target_width == 0 || offset >= source_width { + return None; + } + target_start + .checked_add(scale(offset, source_width, target_width))? + .checked_add(1) +} + +#[cfg(any(unix, test))] pub(crate) fn parse_report(data: &[u8]) -> Option<(u32, u32)> { let body = data.strip_prefix(b"\x1b[<")?; let body = body @@ -97,6 +139,7 @@ pub(crate) fn parse_report(data: &[u8]) -> Option<(u32, u32)> { fields.next().is_none().then_some((x, y)) } +#[cfg(any(unix, test))] pub(crate) fn report_at_cell(data: &[u8], column: u16, row: u16) -> Option<Vec<u8>> { let body = data.strip_prefix(b"\x1b[<")?; let suffix = if body.ends_with(b"M") { 'M' } else { 'm' }; @@ -114,6 +157,7 @@ pub(crate) fn report_at_cell(data: &[u8], column: u16, row: u16) -> Option<Vec<u ) } +#[cfg(any(unix, test))] fn parse_number(value: &[u8]) -> Option<u32> { (!value.is_empty() && value.iter().all(u8::is_ascii_digit)) .then(|| std::str::from_utf8(value).ok()?.parse().ok()) @@ -184,6 +228,21 @@ mod tests { } #[test] + fn fractional_scaling_preserves_the_canonical_child_cell() { + let geometry = HostGeometry::new(80, 1, 805, 20).unwrap(); + assert_eq!(geometry.cell(11, 1), Some((1, 0))); + assert_eq!( + HostPixels { + x: 11, + y: 1, + geometry, + } + .pane_position(ratatui::layout::Rect::new(0, 0, 80, 1), 800, 20), + Some(Position::Pixels { x: 11, y: 1 }) + ); + } + + #[test] fn geometry_rejects_outside_pixels_and_maps_cells() { let geometry = HostGeometry::new(80, 24, 800, 480).unwrap(); assert_eq!(geometry.cell(1, 1), Some((0, 0))); |
