aboutsummaryrefslogtreecommitdiff
path: root/src/input
diff options
context:
space:
mode:
Diffstat (limited to 'src/input')
-rw-r--r--src/input/keybind_help.rs269
-rw-r--r--src/input/keybindings.rs298
-rw-r--r--src/input/lease.rs452
-rw-r--r--src/input/mod.rs16
-rw-r--r--src/input/model.rs44
-rw-r--r--src/input/mouse.rs89
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)));