diff --git a/.gitignore b/.gitignore index 294e8c8..5104a10 100644 --- a/.gitignore +++ b/.gitignore @@ -1,3 +1,4 @@ /target .worktrees .claude +docs/superpowers/ diff --git a/CHANGELOG.md b/CHANGELOG.md index d69d0ee..dd786ff 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,11 +5,16 @@ Format follows [Keep a Changelog](https://keepachangelog.com/en/1.1.0/). ## [Unreleased] +### Added +- configurable keymap: override, add, or disable modifier shortcuts (Ctrl / Ctrl+Shift / Alt / ⌘-Super and `Ctrl+W` chords) from a new `[keybindings]` config table; `"binding" = "action-name"` (`"none"` disables a default); invalid entries are skipped, logged, and surfaced as a transient status-bar notice +- macOS Command (⌘) / Linux Super keyboard shortcuts: ⌘V paste, ⌘C copy, ⌘N/⌘T new tab, ⌘W close tab, ⌘1–⌘9 select tab, ⌘Q quit, ⌘, config, ⌘F search, ⌘K clear scrollback, ⌘+/⌘-/⌘= font size. Previously only Ctrl/Ctrl+Shift bindings were recognized, so ⌘V did not paste on macOS (these ⌘ defaults are now user-overridable via [keybindings]) + ### Changed - reorganize `src/` flat files into module subdirectories: `config/`, `session/`, `theme/`, `input/` (+ motion, mouse), `ui/` (+ command_palette, statusbar, tabs), `renderer/` (+ views, render_ops, screenshot) - move VT parsing to per-pane background threads with a 1 MB bounded channel; eliminates terminal freezes during heavy output (e.g. `find /`) and keeps Ctrl+C responsive regardless of backlog size ### Fixed +- `Ctrl+W` chord tails are now matched case-insensitively (except `R`), so shifted tails like `Ctrl+W V` / `Ctrl+W S` split panes again - mouse selection highlight now tracks content when the viewport is scrolled during a drag - `install.sh` quick install failed on systems with an older `gh` CLI (e.g. Debian 13, which ships gh 2.46 without the `attestation` subcommand): `gh attestation verify` errored with `unknown command "attestation"` and the script reported a misleading "provenance verification failed". The quick install no longer depends on `gh` at all — integrity is verified solely against the public `checksums-sha256.txt` (SHA-256), which needs no authentication diff --git a/README.md b/README.md index d52e0a8..58b9f52 100644 --- a/README.md +++ b/README.md @@ -238,6 +238,27 @@ size stays consistent across displays of any density. If you previously increase | `Ctrl+Shift+K` | Clear scrollback | | `Ctrl+Shift+L` | Toggle session logging for active pane | +#### macOS (⌘ Command) + +On macOS the platform-standard ⌘ shortcuts are also available (Linux/Windows: the Super key): + +| Binding | Action | +|---|---| +| `⌘V` | Paste | +| `⌘C` | Copy selection (Visual mode) | +| `⌘N` / `⌘T` | New tab | +| `⌘W` | Close tab | +| `⌘1`..`⌘9` | Jump to tab by position | +| `⌘Q` | Quit | +| `⌘,` | Open config panel | +| `⌘F` | Open scrollback search | +| `⌘K` | Clear scrollback | +| `⌘+` | Increase font size | +| `⌘-` | Decrease font size | +| `⌘=` / `⌘0` | Reset font size | + +While ⌘/Super is held, an unmapped key is ignored (never sent to the shell). ⌘ shortcuts are inactive in passthrough mode (`Ctrl+B`). + ### Modes | Binding | Action | @@ -334,8 +355,8 @@ Navigate freely to position the cursor, press `v` to set the selection anchor, t | Binding | Action | |---|---| -| `Ctrl+Shift+C` | Copy selection | -| `Ctrl+Shift+V` | Paste | +| `Ctrl+Shift+C` / `⌘C` | Copy selection | +| `Ctrl+Shift+V` / `⌘V` | Paste | ## Architecture diff --git a/assets/config.toml b/assets/config.toml index fbbd7ef..9348b79 100644 --- a/assets/config.toml +++ b/assets/config.toml @@ -76,3 +76,20 @@ palette = [ "#a3babf", "#f8f8f2", ] + +[keybindings] +# Override, add, or disable keyboard shortcuts. Format: +# "binding" = "action-name" ("none" disables a built-in default) +# Modifiers (order-insensitive, joined with '+'): ctrl shift alt cmd +# cmd resolves to Command (⌘) on macOS and Super on Linux/Windows. +# Key tokens: a single character (v , + =) or a named key +# (enter escape tab space backspace delete pageup pagedown home end +# arrowup arrowdown arrowleft arrowright f1..f12). +# Chord (Ctrl+W prefix): a space-separated tail key, e.g. "ctrl+w x". +# Bare unmodified characters are rejected in global scope (they would shadow typing). +# Examples (commented out — empty table = full built-in defaults): +# "cmd+v" = "paste" +# "cmd+k" = "none" +# "ctrl+e" = "new_tab" +# "ctrl+w x" = "close_pane" +# See doc/SPEC.md "Configurable Keymap" for the full action-name list. diff --git a/doc/LLMs.md b/doc/LLMs.md index 3015bbe..5adbc54 100644 --- a/doc/LLMs.md +++ b/doc/LLMs.md @@ -10,7 +10,8 @@ Full spec: `doc/SPEC.md`. This file is the dense implementation reference. | `src/main.rs` | `App`; event loop; winit wiring; `wakeup_pending` | | `src/app_state.rs` | `AppState`, `TabState`, `PaneEntry`, `AppEffect`; action dispatch | | `src/drain.rs` | `ParseEffect` enum; `spawn_parser_thread`; `drain_effects` | -| `src/input/keybindings.rs` | `Action` enum; `handle_key`, `handle_ctrl_w` | +| `src/input/keybindings.rs` | `Action` enum; literal/PTY encoding + not-yet-tabled per-mode handlers (Insert/Normal/Visual/QuitSave/Screenshot) | +| `src/input/keymap.rs` | `KeyMap`, `BindingKey`, `Mods`, `KeyToken`, `ModeClass`, `default_keymap()`, binding parser, `action_from_name`/`name_of_action` registry — single source of truth for shortcut bindings | | `src/input/mode.rs` | `InputMode` enum (Insert / Normal / Visual / QuitSave) | | `src/pty/session.rs` | `PtySession` — fork PTY, spawn shell, read/write bytes | | `src/terminal/grid.rs` | `Grid`, `Cell`, `Color` — VT cell grid + scrollback | @@ -99,6 +100,18 @@ Constants in `src/ui/layout.rs`: `TAB_BAR_H = 22`, `STATUS_BAR_H = 22`. - `App.hovered_url: Option` — updated on `CursorMoved`; cursor switches to `Pointer` when hovering a link - Left-click on a linked cell opens the URL via `xdg-open` (Linux) / `open` (macOS) +## Configurable Keymap (implemented) + +- Single source of truth: `src/input/keymap.rs`. `default_keymap()` returns all built-in Global-scope shortcuts (Ctrl, Ctrl+Shift, Alt, ⌘/Super, `Ctrl+W` chords) AS DATA. +- `KeyMap::from_config(&KeybindingsConfig)` overlays the user's `[keybindings]` table onto a copy of the defaults: valid `binding=action` inserts/replaces; `binding="none"` removes; invalid entries are skipped + collected as `KeymapError` (Parse / UnknownAction / ShadowsInput). +- Dispatch: `handle_key` → `handle_key_modified(keymap, ...)` builds `(Mods, KeyToken)`, calls `keymap.lookup(ModeClass::Global, &bkey)` FIRST. Hit → `action_from_name(name, DispatchCtx { grid_rows, mode })`. Miss → if `cmd` held, swallow (`Action::None`); else fall through to `handle_key_inner` (encoding/per-mode fallback). +- `Ctrl+W` chords: `handle_ctrl_w(keymap, event)` → `handle_ctrl_w_keymap` looks up a `BindingKey { mods: ctrl, token: 'w', chord_tail: Some((no_mods, tail)) }`. Tail token is NOT lowercased, so `Ctrl+W R` (rotate backward) stays distinct from `Ctrl+W r` (forward). +- `cmd` token = ⌘ (macOS) / Super (Linux/Win); winit reports both via `super_key()` → the `cmd` flag. +- Map stores `&'static str` action names (not `Action` — several variants aren't `Clone` and some need runtime context). `intern_known_name` interns validated names; keep it in sync with `action_from_name`. +- `AppState` builds the merged `KeyMap` once in `new()` and stores `keymap`, `keymap_invalid_count`, `keymap_notice_until`. `render_ops` surfaces `keymap_notice()` into the status bar `right_text` (transient ~6 s). +- Config: `KeybindingsConfig(BTreeMap)` newtype, `#[serde(default)]` field on `Config`; documented example block lives in `assets/config.toml`. No `F_*`/`Field` in `tui_config.rs` — a dynamic map has no Field representation; keybindings are file-configured. +- Out of scope (Phase 2): `normal:` / `visual:` modal remapping (rows not yet populated); arbitrary new chord prefixes. + ## Core Types (abbreviated) ```rust @@ -209,10 +222,12 @@ One line per entry, imperative mood, lowercase first letter. Example: - regex search with copy-match and clear-scrollback actions ``` -### 1. Keybinding -- Add variant to `Action` in `src/input/keybindings.rs` -- Return it from `handle_key` or `handle_ctrl_w` -- Handle it in the `match action` block(s) in `src/main.rs` (two blocks: `ctrl_w_pending` path and normal path) +### 1. Keybinding / Action +- Add a variant to `Action` in `src/input/keybindings.rs` (only if a new action type is needed) +- Add a `name ↔ Action` entry to the registry in `src/input/keymap.rs` (`action_from_name` + `name_of_action`) +- Add a default row to `default_keymap()` in `src/input/keymap.rs` +- Handle the `Action` in `AppState::dispatch_action` (`src/app_state.rs`) if not already handled +- Literal text / cursor / PTY-byte encoding is NOT a keymap binding — it stays in `handle_insert`/`cursor_seq`/`encode_*` (the fallback) ### 2. Config option - Add field to the right `*Config` struct in `src/config.rs` with `#[serde(default = "fn_name")]` diff --git a/doc/SPEC.md b/doc/SPEC.md index 43e3241..3f31dbd 100644 --- a/doc/SPEC.md +++ b/doc/SPEC.md @@ -452,6 +452,60 @@ writing a file. ## Key Bindings Reference +### Configurable Keymap + +mmterm's modifier shortcuts are a single data-driven table (`src/input/keymap.rs`). +`default_keymap()` defines all built-in Global-scope shortcuts as data; the user's +`[keybindings]` config overlays the same table. + +**Config format** + +```toml +[keybindings] +"cmd+v" = "paste" # override / add a binding +"cmd+k" = "none" # disable a built-in default +"ctrl+e" = "new_tab" # add a new shortcut +"ctrl+w x" = "close_pane" # chord (space-separated tail key) +``` + +**Grammar** +- Modifiers (order-insensitive, `+`-joined): `ctrl` `shift` `alt` `cmd`. + `cmd` resolves to Command (⌘) on macOS and Super on Linux/Windows. +- Key token: a single character (`v` `,` `+` `=`) or a named key + (`enter` `escape` `tab` `space` `backspace` `delete` `pageup` `pagedown` + `home` `end` `arrowup/down/left/right` `f1`..`f12`); letters are case-insensitive. +- Chord: a space-separated tail key after a prefix (today only `ctrl+w`). +- `"none"` disables the matching built-in (returns the key to its raw terminal meaning). + +**Merge semantics** +- Defaults always load; a valid entry inserts/replaces; `"none"` removes; an empty + table = full defaults. +- Invalid entries are skipped, `log::warn!`-ed, counted, and surfaced as a transient + status-bar notice `"N keybindings invalid — see log"`. The app always starts. +- Validation rejects: unparseable bindings, unknown action names, and **bare + unmodified-character** bindings in Global scope (they would shadow literal typing). + +**Bindable action names** + +| Name | Action | +|---|---| +| `paste`, `copy` | clipboard | +| `new_tab`, `close_tab`, `next_tab`, `prev_tab`, `move_tab_left`, `move_tab_right`, `rename_tab` | tabs | +| `go_to_tab_1`..`go_to_tab_9` | select tab N | +| `split_horizontal`, `split_vertical`, `auto_split`, `close_pane` | splits | +| `focus_left/right/up/down`, `focus_next` | pane focus | +| `zoom_pane`, `rotate_panes_forward`, `rotate_panes_backward` | pane layout | +| `resize_pane_left/right/up/down` | pane resize | +| `scroll_page_up`, `scroll_page_down`, `scroll_to_top`, `scroll_to_bottom`, `clear_scrollback` | scroll | +| `search_open`, `search_next`, `search_prev` | search | +| `increase_font_size`, `decrease_font_size`, `reset_font_size` | font | +| `open_config`, `open_command_palette`, `toggle_fullscreen`, `toggle_log`, `toggle_passthrough`, `screenshot_open`, `quit` | app / ui | +| `cycle_mode`, `enter_normal_mode` | mode | +| `ctrl_w_prefix` | start a `Ctrl+W` chord | + +Modal (`normal:` / `visual:`) remapping is a future phase; literal text and cursor/PTY +encoding are never bindable — they are the fallback when no binding matches. + ### Global (all modes) | Binding | Action | @@ -482,6 +536,28 @@ writing a file. | `Ctrl+\` | Enter Normal mode | | `Ctrl+B` | Toggle passthrough mode (see below) | +### macOS Command (⌘) / Super + +The platform-standard ⌘ shortcuts are routed when the Super modifier is held +(macOS Command; Linux/Windows Super key). They take priority over mode dispatch; +while Super is held an unmapped key is swallowed (never sent to the PTY). Inactive +in passthrough mode. + +| Binding | Action | +|---|---| +| `⌘V` | Paste | +| `⌘C` | Copy selection (Visual mode) | +| `⌘N` / `⌘T` | New tab | +| `⌘W` | Close tab | +| `⌘1`..`⌘9` | Jump to tab by position | +| `⌘Q` | Quit | +| `⌘,` | Open config panel | +| `⌘F` | Open scrollback search | +| `⌘K` | Clear scrollback | +| `⌘+` | Increase font size | +| `⌘-` | Decrease font size | +| `⌘=` / `⌘0` | Reset font size | + ### Pane Management (`Ctrl+W` prefix) | Binding | Action | diff --git a/src/app_event.rs b/src/app_event.rs index c2fce62..ef52e40 100644 --- a/src/app_event.rs +++ b/src/app_event.rs @@ -456,6 +456,7 @@ impl App { self.tab_mut().passthrough = false; self.request_redraw(); } else { + // Passthrough bypasses the keymap entirely (raw PTY bytes). let action = handle_key_passthrough(event, &self.modifiers, app_cursor); if let crate::input::keybindings::Action::SendToPty(bytes) = action { self.do_send_to_pty(bytes); @@ -496,6 +497,7 @@ impl App { } let action = handle_key( + &self.state.keymap, &event, &self.modifiers, &self.state.mode, @@ -561,7 +563,7 @@ impl App { } if self.state.ctrl_w_pending { self.state.ctrl_w_pending = false; - let action = handle_ctrl_w(event); + let action = handle_ctrl_w(&self.state.keymap, event); self.execute_action(action, event_loop); return true; } diff --git a/src/app_state.rs b/src/app_state.rs index 29cf09e..c8249f2 100644 --- a/src/app_state.rs +++ b/src/app_state.rs @@ -2,13 +2,14 @@ use arboard::Clipboard; use crossbeam_channel::Receiver; use std::collections::HashMap; use std::sync::{Arc, Mutex}; -use std::time::Instant; +use std::time::{Duration, Instant}; use crate::config::Config; use crate::config::tui_config::ConfigPanel; use crate::dpi::Logical; use crate::input::InputMode; use crate::input::keybindings::Action; +use crate::input::keymap::KeyMap; use crate::renderer::FontMetrics; use crate::theme::ResolvedTheme; use crate::ui::{Layout, Pane, SeparatorHandle, SplitDir}; @@ -100,10 +101,26 @@ pub struct AppState { pub available_update: Option, /// An update was self-applied this session (Linux) — drives the "restart" badge. pub update_applied: Option, + /// Merged keymap (built once at startup from `config.keybindings`). + pub keymap: KeyMap, + /// Number of invalid keybinding entries skipped at load (drives the notice). + pub keymap_invalid_count: usize, + /// When set (and in the future), the status bar shows the invalid-keymap notice. + pub keymap_notice_until: Option, } impl AppState { pub fn new(config: Config, theme: ResolvedTheme) -> Self { + let (keymap, keymap_errors) = KeyMap::from_config(&config.keybindings); + for e in &keymap_errors { + log::warn!("invalid keybinding: {e:?}"); + } + let keymap_invalid_count = keymap_errors.len(); + let keymap_notice_until = if keymap_invalid_count > 0 { + Some(Instant::now() + Duration::from_secs(6)) + } else { + None + }; Self { tabs: Vec::new(), active_tab: 0, @@ -128,6 +145,22 @@ impl AppState { drag_separator: None, available_update: None, update_applied: None, + keymap, + keymap_invalid_count, + keymap_notice_until, + } + } + + /// The transient invalid-keymap notice text, if active. + pub fn keymap_notice(&self) -> Option { + let until = self.keymap_notice_until?; + if Instant::now() < until && self.keymap_invalid_count > 0 { + Some(format!( + "{} keybindings invalid — see log", + self.keymap_invalid_count + )) + } else { + None } } diff --git a/src/app_state_test.rs b/src/app_state_test.rs index 29af7ba..ae36468 100644 --- a/src/app_state_test.rs +++ b/src/app_state_test.rs @@ -1280,3 +1280,31 @@ fn search_history_clears_before_history_on_push() { s.push_search_history("committed".to_string()); assert!(s.search_before_history.is_empty()); } + +// ── Keymap wiring ─────────────────────────────────────────────────────────── + +#[test] +fn appstate_builds_default_keymap_with_clean_config() { + let cfg = Config::default(); + let theme = crate::theme::default_theme(); + let st = AppState::new(cfg, theme); + assert_eq!(st.keymap_invalid_count, 0); + assert!(st.keymap_notice().is_none()); + // sanity: a known default is present. + assert!(!st.keymap.is_empty()); +} + +#[test] +fn appstate_counts_invalid_keybindings_and_sets_notice() { + let mut cfg = Config::default(); + cfg.keybindings + .0 + .insert("ctrl+".to_string(), "new_tab".to_string()); // parse error + cfg.keybindings + .0 + .insert("e".to_string(), "new_tab".to_string()); // shadows input + let theme = crate::theme::default_theme(); + let st = AppState::new(cfg, theme); + assert_eq!(st.keymap_invalid_count, 2); + assert!(st.keymap_notice().is_some()); +} diff --git a/src/config/config_test.rs b/src/config/config_test.rs index 2315511..c39dad7 100644 --- a/src/config/config_test.rs +++ b/src/config/config_test.rs @@ -281,3 +281,42 @@ fn general_update_defaults() { assert!(cfg.general.auto_update_check); // daily check on by default assert!(!cfg.general.auto_update_install); // silent self-replace opt-in (off) } + +#[test] +fn config_default_keybindings_is_empty() { + let cfg = Config::default(); + assert!(cfg.keybindings.0.is_empty()); +} + +#[test] +fn config_deserializes_keybindings_table() { + let toml = r#" +[font] +family = "X" +size = 12.0 + +[keybindings] +"cmd+v" = "paste" +"ctrl+e" = "new_tab" +"#; + let cfg: Config = toml::from_str(toml).expect("parse"); + assert_eq!( + cfg.keybindings.0.get("cmd+v").map(String::as_str), + Some("paste") + ); + assert_eq!( + cfg.keybindings.0.get("ctrl+e").map(String::as_str), + Some("new_tab") + ); +} + +#[test] +fn config_missing_keybindings_defaults_empty() { + let toml = r#" +[font] +family = "X" +size = 12.0 +"#; + let cfg: Config = toml::from_str(toml).expect("parse"); + assert!(cfg.keybindings.0.is_empty()); +} diff --git a/src/config/mod.rs b/src/config/mod.rs index 93b2945..35551d1 100644 --- a/src/config/mod.rs +++ b/src/config/mod.rs @@ -1,6 +1,7 @@ pub mod tui_config; use serde::{Deserialize, Serialize}; +use std::collections::BTreeMap; use std::path::PathBuf; use crate::terminal::grid::Color; @@ -39,8 +40,17 @@ pub struct Config { #[serde(default)] pub general: GeneralConfig, + + #[serde(default)] + pub keybindings: KeybindingsConfig, } +/// User keybinding overrides: `"binding" = "action-name"` (or `"none"` to +/// disable a default). Empty map = full built-in defaults. Parsed into the +/// merged `KeyMap` at startup; see `src/input/keymap.rs`. +#[derive(Debug, Clone, Default, Serialize, Deserialize)] +pub struct KeybindingsConfig(pub BTreeMap); + fn default_true() -> bool { true } diff --git a/src/config/tui_config.rs b/src/config/tui_config.rs index e593afd..9796fe1 100644 --- a/src/config/tui_config.rs +++ b/src/config/tui_config.rs @@ -1,8 +1,8 @@ use std::collections::HashSet; use crate::config::{ - ColorsConfig, Config, FontConfig, GeneralConfig, LogConfig, ShellConfig, StatusBarConfig, - TerminalConfig, ThemeConfig, WindowConfig, + ColorsConfig, Config, FontConfig, GeneralConfig, KeybindingsConfig, LogConfig, ShellConfig, + StatusBarConfig, TerminalConfig, ThemeConfig, WindowConfig, }; use crate::theme::{list_themes, themes_dir}; @@ -87,6 +87,8 @@ pub struct ConfigPanel { pub status: Option, /// Section names that are currently collapsed (body fields hidden). pub collapsed: HashSet<&'static str>, + /// User keybinding overrides, carried through unchanged (not editable here). + pub keybindings: KeybindingsConfig, } impl ConfigPanel { @@ -294,6 +296,7 @@ impl ConfigPanel { edit_buf: String::new(), status: None, collapsed, + keybindings: cfg.keybindings.clone(), } } @@ -682,6 +685,7 @@ impl ConfigPanel { auto_update_check, auto_update_install, }, + keybindings: self.keybindings.clone(), }) } diff --git a/src/config/tui_config_test.rs b/src/config/tui_config_test.rs index a85bc7d..5b19e89 100644 --- a/src/config/tui_config_test.rs +++ b/src/config/tui_config_test.rs @@ -689,3 +689,29 @@ fn collapsed_count_font_is_1() { // Font section has F_FONT_FAMILY (header) + F_FONT_SIZE (body) assert_eq!(panel.collapsed_count("Font"), 1); } + +// ── keybindings round-trip ─────────────────────────────────────────────────── + +#[test] +fn build_config_preserves_keybindings() { + // The TUI editor has no Field for keybindings (a dynamic map). Round-tripping + // a Config through from_config -> build_config must keep the table intact. + use crate::config::KeybindingsConfig; + let mut cfg = Config::default(); + cfg.keybindings = KeybindingsConfig( + [ + ( + "ctrl+shift+p".to_string(), + "open_command_palette".to_string(), + ), + ("ctrl+w x".to_string(), "close_pane".to_string()), + ] + .into_iter() + .collect(), + ); + + let panel = ConfigPanel::from_config(&cfg); + let rebuilt = panel.build_config().expect("build_config should succeed"); + + assert_eq!(rebuilt.keybindings.0, cfg.keybindings.0); +} diff --git a/src/input/keybindings.rs b/src/input/keybindings.rs index 5bb1dd3..8acbd94 100644 --- a/src/input/keybindings.rs +++ b/src/input/keybindings.rs @@ -2,6 +2,10 @@ use winit::event::{ElementState, KeyEvent, Modifiers}; use winit::keyboard::{Key, NamedKey}; use super::mode::InputMode; +use crate::input::keymap::{ + BindingKey, DispatchCtx, InputModeKind, KeyMap, ModeClass, Mods, action_from_name, + token_from_key, +}; pub enum Action { SendToPty(Vec), @@ -71,6 +75,7 @@ pub enum Action { } pub fn handle_key( + keymap: &KeyMap, event: &KeyEvent, modifiers: &Modifiers, mode: &InputMode, @@ -81,11 +86,67 @@ pub fn handle_key( if event.state != ElementState::Pressed { return Action::None; } - let ctrl = modifiers.state().control_key(); - let shift = modifiers.state().shift_key(); - let alt = modifiers.state().alt_key(); - handle_key_inner( + let st = modifiers.state(); + handle_key_modified( + keymap, &event.logical_key, + st.control_key(), + st.shift_key(), + st.alt_key(), + st.super_key(), + mode, + grid_cols, + grid_rows, + application_cursor_keys, + ) +} + +/// Routes a key by its modifier flags. The keymap is consulted FIRST; on a miss +/// we fall through to the encoding/mode handlers. Split from `handle_key` so the +/// dispatch is unit-testable without constructing winit `Modifiers`/`KeyEvent`. +#[allow(clippy::too_many_arguments)] +pub(crate) fn handle_key_modified( + keymap: &KeyMap, + key: &Key, + ctrl: bool, + shift: bool, + alt: bool, + cmd: bool, + mode: &InputMode, + grid_cols: usize, + grid_rows: usize, + application_cursor_keys: bool, +) -> Action { + let mods = Mods { + ctrl, + shift, + alt, + cmd, + }; + if let Some(token) = token_from_key(key, /* lowercase */ true) { + let bkey = BindingKey { + mods, + token, + chord_tail: None, + }; + if let Some(name) = keymap.lookup(ModeClass::Global, &bkey) { + let ctx = DispatchCtx { + grid_rows, + mode: InputModeKind::of(mode), + }; + if let Some(action) = action_from_name(name, ctx) { + return action; + } + } + } + + // Keymap miss. When ⌘/Super is held, swallow so a bare ⌘ never leaks + // to the PTY (matches prior behavior). Otherwise fall through to encoding. + if cmd { + return Action::None; + } + handle_key_inner( + key, ctrl, shift, alt, @@ -96,11 +157,48 @@ pub fn handle_key( ) } -pub fn handle_ctrl_w(event: &KeyEvent) -> Action { +pub fn handle_ctrl_w(keymap: &KeyMap, event: &KeyEvent) -> Action { if event.state != ElementState::Pressed { return Action::None; } - ctrl_w_action(&event.logical_key) + handle_ctrl_w_keymap(keymap, &event.logical_key) +} + +/// Resolve a `Ctrl+W ` chord against the keymap. Chord tails are stored +/// case-preserved, but only `R` differs from `r` (rotate backward vs forward). +/// We look up the case-preserved tail FIRST so `R` resolves to its own binding, +/// then retry with the lowercased tail so shifted tails like `Ctrl+W V` / `S` +/// still match the lowercase `v` / `s` chords. +pub(crate) fn handle_ctrl_w_keymap(keymap: &KeyMap, key: &Key) -> Action { + let make_bkey = |tail| BindingKey { + mods: Mods { + ctrl: true, + shift: false, + alt: false, + cmd: false, + }, + token: crate::input::keymap::KeyToken::Char("w".into()), + chord_tail: Some((Mods::default(), tail)), + }; + + let name = token_from_key(key, /* lowercase */ false) + .and_then(|tail| keymap.lookup(ModeClass::Global, &make_bkey(tail))) + .or_else(|| { + token_from_key(key, /* lowercase */ true) + .and_then(|tail| keymap.lookup(ModeClass::Global, &make_bkey(tail))) + }); + + match name { + Some(name) => action_from_name( + name, + DispatchCtx { + grid_rows: 0, + mode: InputModeKind::Insert, + }, + ) + .unwrap_or(Action::None), + None => Action::None, + } } #[allow(clippy::too_many_arguments)] @@ -114,9 +212,24 @@ pub(crate) fn handle_key_inner( grid_rows: usize, application_cursor_keys: bool, ) -> Action { - if let Some(action) = handle_global_shortcuts(key, ctrl, shift, alt, mode, grid_rows) { - return action; + // Alt+Tab is swallowed (never leaks ESC-Tab to the PTY), matching pre-keymap + // behavior. Passthrough mode bypasses handle_key_inner, so it still sends raw. + if alt && !ctrl && matches!(key, Key::Named(NamedKey::Tab)) { + return Action::None; + } + + // Ctrl+C copies the selection while in Visual mode (else falls through to + // raw 0x03 in Insert). Not a Global keymap row because that would also + // intercept Ctrl+C in Insert. + if ctrl + && !shift + && !alt + && matches!(mode, InputMode::Visual { .. }) + && matches!(key, Key::Character(s) if s.eq_ignore_ascii_case("c")) + { + return Action::Copy; } + match mode { InputMode::Insert => handle_insert(key, ctrl, shift, alt, application_cursor_keys), InputMode::Normal => handle_normal(key, grid_rows), @@ -143,177 +256,6 @@ pub(crate) fn handle_key_inner( } } -// ── Global shortcut sub-handlers ──────────────────────────────────────────── - -fn ctrl_shift_char_action(s: &str) -> Option { - match s.to_lowercase().as_str() { - "v" => Some(Action::Paste), - "w" => Some(Action::CloseTab), - "r" => Some(Action::RenameTab), - "k" => Some(Action::ClearScrollback), - "l" => Some(Action::ToggleLog), - "p" => Some(Action::OpenCommandPalette), - _ => None, - } -} - -fn ctrl_shift_action(key: &Key) -> Option { - match key { - Key::Character(s) => ctrl_shift_char_action(s), - Key::Named(NamedKey::ArrowUp) => Some(Action::ResizePaneUp), - Key::Named(NamedKey::ArrowDown) => Some(Action::ResizePaneDown), - Key::Named(NamedKey::ArrowRight) => Some(Action::ResizePaneRight), - Key::Named(NamedKey::ArrowLeft) => Some(Action::ResizePaneLeft), - Key::Named(NamedKey::PageUp) => Some(Action::MoveTabLeft), - Key::Named(NamedKey::PageDown) => Some(Action::MoveTabRight), - Key::Named(NamedKey::Home) => Some(Action::ScrollToTop), - Key::Named(NamedKey::End) => Some(Action::ScrollToBottom), - _ => None, - } -} - -fn ctrl_char_key_action(s: &str) -> Option { - match s.to_lowercase().as_str() { - "q" => Some(Action::Quit), - "," => Some(Action::OpenConfig), - "t" => Some(Action::NewTab), - "b" => Some(Action::TogglePassthrough), - "+" | "=" => Some(Action::IncreaseFontSize), - "-" => Some(Action::DecreaseFontSize), - "0" => Some(Action::ResetFontSize), - _ => None, - } -} - -fn ctrl_char_action(key: &Key, alt: bool) -> Option { - match key { - Key::Character(s) => ctrl_char_key_action(s), - Key::Named(NamedKey::PageUp) => Some(Action::PrevTab), - Key::Named(NamedKey::PageDown) => Some(Action::NextTab), - Key::Named(NamedKey::Enter) if !alt => Some(Action::ToggleFullscreen), - _ => None, - } -} - -fn alt_action(key: &Key, ctrl: bool, shift: bool) -> Option { - if !ctrl && *key == Key::Named(NamedKey::Tab) { - return Some(Action::None); - } - if !ctrl - && !shift - && let Key::Character(s) = key - && let Some(d) = s.chars().next().and_then(|c| c.to_digit(10)) - && d >= 1 - { - return Some(Action::GoToTab((d - 1) as usize)); - } - None -} - -fn visual_mode_init() -> InputMode { - InputMode::Visual { - start_col: 0, - start_row: 0, - cur_col: 0, - cur_row: 0, - anchored: false, - } -} - -fn ctrl_dot_next_mode(mode: &InputMode) -> InputMode { - match mode { - InputMode::Insert => InputMode::Normal, - InputMode::Normal => visual_mode_init(), - _ => InputMode::Insert, - } -} - -fn ctrl_special_char_action(s: &str, mode: &InputMode) -> Option { - if s == "." { - return Some(Action::SetMode(ctrl_dot_next_mode(mode))); - } - if s == "\\" || s == "|" { - return Some(Action::SetMode(InputMode::Normal)); - } - None -} - -fn shift_scroll_action(key: &Key, grid_rows: usize) -> Option { - match key { - Key::Named(NamedKey::PageUp) => Some(Action::ScrollUp(grid_rows)), - Key::Named(NamedKey::PageDown) => Some(Action::ScrollDown(grid_rows)), - _ => None, - } -} - -fn handle_ctrl_only(key: &Key, alt: bool, mode: &InputMode) -> Option { - if let Key::Character(s) = key { - if s.eq_ignore_ascii_case("w") { - return Some(Action::CtrlWPrefix); - } - if let Some(a) = ctrl_special_char_action(s, mode) { - return Some(a); - } - if s.eq_ignore_ascii_case("c") && matches!(mode, InputMode::Visual { .. }) { - return Some(Action::Copy); - } - } - ctrl_char_action(key, alt) -} - -fn handle_global_shortcuts( - key: &Key, - ctrl: bool, - shift: bool, - alt: bool, - mode: &InputMode, - grid_rows: usize, -) -> Option { - if ctrl && shift { - return ctrl_shift_action(key); - } - if ctrl { - return handle_ctrl_only(key, alt, mode); - } - if shift && let Some(a) = shift_scroll_action(key, grid_rows) { - return Some(a); - } - if alt { - return alt_action(key, false, shift); - } - None -} - -pub(crate) fn ctrl_w_action(key: &Key) -> Action { - match key { - Key::Character(s) => { - if s.as_str() == "R" { - return Action::RotatePanesBackward; - } - match s.to_lowercase().as_str() { - "v" => Action::SplitH, - "s" => Action::SplitV, - "a" => Action::AutoSplit, - "h" => Action::FocusLeft, - "l" => Action::FocusRight, - "k" => Action::FocusUp, - "j" => Action::FocusDown, - "w" => Action::FocusNext, - "q" => Action::ClosePane, - "z" => Action::ZoomPane, - "r" => Action::RotatePanesForward, - "p" => Action::ScreenshotOpen, - _ => Action::None, - } - } - Key::Named(NamedKey::ArrowLeft) => Action::FocusLeft, - Key::Named(NamedKey::ArrowRight) => Action::FocusRight, - Key::Named(NamedKey::ArrowUp) => Action::FocusUp, - Key::Named(NamedKey::ArrowDown) => Action::FocusDown, - _ => Action::None, - } -} - /// Bypasses all mmterm shortcuts and encodes the key as raw PTY bytes (Insert mode encoding). /// Used when passthrough mode is active. Ctrl+B is NOT handled here — the caller must /// intercept it to exit passthrough before calling this. @@ -432,6 +374,16 @@ fn handle_insert( } } +fn visual_mode_init() -> InputMode { + InputMode::Visual { + start_col: 0, + start_row: 0, + cur_col: 0, + cur_row: 0, + anchored: false, + } +} + fn handle_normal(key: &Key, grid_rows: usize) -> Action { match key { Key::Named(NamedKey::Escape) => Action::SetMode(InputMode::Insert), diff --git a/src/input/keybindings_test.rs b/src/input/keybindings_test.rs index cd48e61..a1efed8 100644 --- a/src/input/keybindings_test.rs +++ b/src/input/keybindings_test.rs @@ -11,125 +11,7 @@ fn named(k: NamedKey) -> Key { Key::Named(k) } -// ── ctrl_w_action ──────────────────────────────────────────────────────────── - -#[test] -fn ctrl_w_v_splits_horizontal() { - assert!(matches!(ctrl_w_action(&char_key("v")), Action::SplitH)); -} - -#[test] -fn ctrl_w_s_splits_vertical() { - assert!(matches!(ctrl_w_action(&char_key("s")), Action::SplitV)); -} - -#[test] -fn ctrl_w_a_auto_splits() { - assert!(matches!(ctrl_w_action(&char_key("a")), Action::AutoSplit)); -} - -#[test] -fn ctrl_w_h_focuses_left() { - assert!(matches!(ctrl_w_action(&char_key("h")), Action::FocusLeft)); -} - -#[test] -fn ctrl_w_l_focuses_right() { - assert!(matches!(ctrl_w_action(&char_key("l")), Action::FocusRight)); -} - -#[test] -fn ctrl_w_k_focuses_up() { - assert!(matches!(ctrl_w_action(&char_key("k")), Action::FocusUp)); -} - -#[test] -fn ctrl_w_j_focuses_down() { - assert!(matches!(ctrl_w_action(&char_key("j")), Action::FocusDown)); -} - -#[test] -fn ctrl_w_w_focuses_next() { - assert!(matches!(ctrl_w_action(&char_key("w")), Action::FocusNext)); -} - -#[test] -fn ctrl_w_q_closes_pane() { - assert!(matches!(ctrl_w_action(&char_key("q")), Action::ClosePane)); -} - -#[test] -fn ctrl_w_z_zooms_pane() { - assert!(matches!(ctrl_w_action(&char_key("z")), Action::ZoomPane)); -} - -#[test] -fn ctrl_w_r_rotates_forward() { - assert!(matches!( - ctrl_w_action(&char_key("r")), - Action::RotatePanesForward - )); -} - -#[test] -fn ctrl_w_uppercase_r_rotates_backward() { - assert!(matches!( - ctrl_w_action(&char_key("R")), - Action::RotatePanesBackward - )); -} - -#[test] -fn ctrl_w_uppercase_v_splits_horizontal() { - assert!(matches!(ctrl_w_action(&char_key("V")), Action::SplitH)); -} - -#[test] -fn ctrl_w_arrow_left_focuses_left() { - assert!(matches!( - ctrl_w_action(&named(NamedKey::ArrowLeft)), - Action::FocusLeft - )); -} - -#[test] -fn ctrl_w_arrow_right_focuses_right() { - assert!(matches!( - ctrl_w_action(&named(NamedKey::ArrowRight)), - Action::FocusRight - )); -} - -#[test] -fn ctrl_w_arrow_up_focuses_up() { - assert!(matches!( - ctrl_w_action(&named(NamedKey::ArrowUp)), - Action::FocusUp - )); -} - -#[test] -fn ctrl_w_arrow_down_focuses_down() { - assert!(matches!( - ctrl_w_action(&named(NamedKey::ArrowDown)), - Action::FocusDown - )); -} - -#[test] -fn ctrl_w_unknown_key_returns_none() { - assert!(matches!(ctrl_w_action(&char_key("x")), Action::None)); -} - -#[test] -fn ctrl_w_named_escape_returns_none() { - assert!(matches!( - ctrl_w_action(&named(NamedKey::Escape)), - Action::None - )); -} - -// ── handle_key_inner — global shortcuts ───────────────────────────────────── +// ── shared mode helpers ────────────────────────────────────────────────────── fn insert() -> InputMode { InputMode::Insert @@ -147,84 +29,56 @@ fn visual() -> InputMode { } } -#[test] -fn ctrl_w_char_returns_ctrl_w_prefix() { - let a = handle_key_inner(&char_key("w"), true, false, false, &insert(), 80, 24, false); - assert!(matches!(a, Action::CtrlWPrefix)); -} +// ── keymap-driven dispatch ──────────────────────────────────────────────────── -#[test] -fn ctrl_dot_from_insert_enters_normal() { - let a = handle_key_inner(&char_key("."), true, false, false, &insert(), 80, 24, false); - assert!(matches!(a, Action::SetMode(InputMode::Normal))); -} - -#[test] -fn ctrl_dot_from_normal_enters_visual() { - let a = handle_key_inner(&char_key("."), true, false, false, &normal(), 80, 24, false); - assert!(matches!(a, Action::SetMode(InputMode::Visual { .. }))); -} +use crate::input::keymap::{KeyMap, default_keymap}; -#[test] -fn ctrl_dot_from_visual_enters_insert() { - let a = handle_key_inner(&char_key("."), true, false, false, &visual(), 80, 24, false); - assert!(matches!(a, Action::SetMode(InputMode::Insert))); +fn km() -> KeyMap { + default_keymap() } #[test] -fn ctrl_dot_from_search_enters_insert() { - let mode = InputMode::Search { - query: String::new(), - history_pos: None, - }; - let a = handle_key_inner(&char_key("."), true, false, false, &mode, 80, 24, false); - assert!(matches!(a, Action::SetMode(InputMode::Insert))); -} - -#[test] -fn ctrl_backslash_enters_normal() { - let a = handle_key_inner( - &char_key("\\"), +fn dispatch_ctrl_q_quit_via_keymap() { + let a = handle_key_modified( + &km(), + &char_key("q"), true, false, false, + false, &insert(), 80, 24, false, ); - assert!(matches!(a, Action::SetMode(InputMode::Normal))); -} - -#[test] -fn ctrl_pipe_enters_normal() { - let a = handle_key_inner(&char_key("|"), true, false, false, &insert(), 80, 24, false); - assert!(matches!(a, Action::SetMode(InputMode::Normal))); + assert!(matches!(a, Action::Quit)); } #[test] -fn ctrl_shift_v_pastes() { - let a = handle_key_inner(&char_key("v"), true, true, false, &insert(), 80, 24, false); +fn dispatch_cmd_v_pastes_via_keymap() { + let a = handle_key_modified( + &km(), + &char_key("v"), + false, + false, + false, + true, + &insert(), + 80, + 24, + false, + ); assert!(matches!(a, Action::Paste)); } #[test] -fn ctrl_shift_w_closes_tab() { - let a = handle_key_inner(&char_key("w"), true, true, false, &insert(), 80, 24, false); - assert!(matches!(a, Action::CloseTab)); -} - -#[test] -fn ctrl_shift_r_renames_tab() { - let a = handle_key_inner(&char_key("r"), true, true, false, &insert(), 80, 24, false); - assert!(matches!(a, Action::RenameTab)); -} - -#[test] -fn ctrl_shift_arrow_up_resizes_pane_up() { - let a = handle_key_inner( - &named(NamedKey::ArrowUp), - true, +fn dispatch_alt_tab_is_swallowed() { + // Alt+Tab must not leak ESC-Tab to the PTY (preserves pre-keymap behavior). + let a = handle_key_modified( + &km(), + &named(NamedKey::Tab), + false, + false, true, false, &insert(), @@ -232,13 +86,16 @@ fn ctrl_shift_arrow_up_resizes_pane_up() { 24, false, ); - assert!(matches!(a, Action::ResizePaneUp)); + assert!(matches!(a, Action::None)); } #[test] -fn ctrl_shift_arrow_down_resizes_pane_down() { - let a = handle_key_inner( - &named(NamedKey::ArrowDown), +fn dispatch_alt_shift_tab_is_swallowed() { + // The guard ignores Shift, so Alt+Shift+Tab is swallowed too (no ESC-backtab leak). + let a = handle_key_modified( + &km(), + &named(NamedKey::Tab), + false, true, true, false, @@ -247,32 +104,37 @@ fn ctrl_shift_arrow_down_resizes_pane_down() { 24, false, ); - assert!(matches!(a, Action::ResizePaneDown)); -} - -#[test] -fn ctrl_shift_k_clears_scrollback() { - let a = handle_key_inner(&char_key("k"), true, true, false, &insert(), 80, 24, false); - assert!(matches!(a, Action::ClearScrollback)); -} - -#[test] -fn ctrl_shift_l_toggles_log() { - let a = handle_key_inner(&char_key("l"), true, true, false, &insert(), 80, 24, false); - assert!(matches!(a, Action::ToggleLog)); + assert!(matches!(a, Action::None)); } #[test] -fn ctrl_shift_l_uppercase_toggles_log() { - let a = handle_key_inner(&char_key("L"), true, true, false, &insert(), 80, 24, false); - assert!(matches!(a, Action::ToggleLog)); +fn dispatch_alt_tab_is_swallowed_in_normal_and_visual() { + // The guard precedes the mode match, so Alt+Tab is swallowed in every mode. + for mode in [normal(), visual()] { + let a = handle_key_modified( + &km(), + &named(NamedKey::Tab), + false, + false, + true, + false, + &mode, + 80, + 24, + false, + ); + assert!(matches!(a, Action::None)); + } } #[test] -fn ctrl_shift_page_up_moves_tab_left() { - let a = handle_key_inner( - &named(NamedKey::PageUp), +fn dispatch_ctrl_alt_tab_falls_through_to_tab_byte() { + // The guard excludes ctrl, so Ctrl+Alt+Tab falls through to the Tab byte. + let a = handle_key_modified( + &km(), + &named(NamedKey::Tab), true, + false, true, false, &insert(), @@ -280,167 +142,263 @@ fn ctrl_shift_page_up_moves_tab_left() { 24, false, ); - assert!(matches!(a, Action::MoveTabLeft)); + assert!(matches!(a, Action::SendToPty(ref v) if v == &[b'\t'])); } #[test] -fn ctrl_shift_page_down_moves_tab_right() { - let a = handle_key_inner( - &named(NamedKey::PageDown), - true, - true, +fn dispatch_cmd_v_pastes_in_normal_mode_too() { + // Global scope: cmd+v works regardless of mode. + let a = handle_key_modified( + &km(), + &char_key("v"), false, - &insert(), + false, + false, + true, + &normal(), 80, 24, false, ); - assert!(matches!(a, Action::MoveTabRight)); + assert!(matches!(a, Action::Paste)); } #[test] -fn ctrl_shift_home_scrolls_to_top() { - let a = handle_key_inner( - &named(NamedKey::Home), - true, - true, +fn dispatch_cmd_unmapped_is_swallowed() { + // ⌘+z is not bound → None (never leaks to PTY), preserving today's behavior. + let a = handle_key_modified( + &km(), + &char_key("z"), + false, false, + false, + true, &insert(), 80, 24, false, ); - assert!(matches!(a, Action::ScrollToTop)); + assert!(matches!(a, Action::None)); } #[test] -fn ctrl_shift_end_scrolls_to_bottom() { - let a = handle_key_inner( - &named(NamedKey::End), - true, +fn dispatch_ctrl_cmd_both_held_prefers_keymap_or_falls_through() { + // Ctrl+⌘+v: a binding key with ctrl+cmd is not in defaults → cmd path swallows. + let a = handle_key_modified( + &km(), + &char_key("v"), true, false, + false, + true, &insert(), 80, 24, false, ); - assert!(matches!(a, Action::ScrollToBottom)); -} - -#[test] -fn ctrl_c_in_visual_copies() { - let a = handle_key_inner(&char_key("c"), true, false, false, &visual(), 80, 24, false); - assert!(matches!(a, Action::Copy)); -} - -#[test] -fn ctrl_c_in_insert_does_not_copy() { - // In insert mode ctrl+c is sent as byte 0x03 to the PTY - let a = handle_key_inner(&char_key("c"), true, false, false, &insert(), 80, 24, false); - assert!(matches!(a, Action::SendToPty(ref v) if v == &[3])); -} - -#[test] -fn ctrl_q_quits() { - let a = handle_key_inner(&char_key("q"), true, false, false, &insert(), 80, 24, false); - assert!(matches!(a, Action::Quit)); -} - -#[test] -fn ctrl_comma_opens_config() { - let a = handle_key_inner(&char_key(","), true, false, false, &insert(), 80, 24, false); - assert!(matches!(a, Action::OpenConfig)); + // No ctrl+cmd+v default → cmd held → swallow. + assert!(matches!(a, Action::None)); } #[test] -fn ctrl_t_opens_new_tab() { - let a = handle_key_inner(&char_key("t"), true, false, false, &insert(), 80, 24, false); - assert!(matches!(a, Action::NewTab)); +fn dispatch_unmapped_ctrl_key_falls_through_to_encoding() { + // Ctrl+a is not a shortcut → falls through to handle_insert → raw byte 0x01. + let a = handle_key_modified( + &km(), + &char_key("a"), + true, + false, + false, + false, + &insert(), + 80, + 24, + false, + ); + assert!(matches!(a, Action::SendToPty(ref v) if v == &[1u8])); } #[test] -fn ctrl_plus_increases_font_size() { - let a = handle_key_inner(&char_key("+"), true, false, false, &insert(), 80, 24, false); - assert!(matches!(a, Action::IncreaseFontSize)); +fn dispatch_plain_char_falls_through_to_encoding() { + let a = handle_key_modified( + &km(), + &char_key("a"), + false, + false, + false, + false, + &insert(), + 80, + 24, + false, + ); + assert!(matches!(a, Action::SendToPty(ref v) if v == b"a")); } #[test] -fn ctrl_equals_increases_font_size() { - let a = handle_key_inner(&char_key("="), true, false, false, &insert(), 80, 24, false); - assert!(matches!(a, Action::IncreaseFontSize)); +fn dispatch_shift_page_up_scrolls_page_via_keymap() { + let a = handle_key_modified( + &km(), + &named(NamedKey::PageUp), + false, + true, + false, + false, + &insert(), + 80, + 24, + false, + ); + assert!(matches!(a, Action::ScrollUp(24))); } #[test] -fn ctrl_minus_decreases_font_size() { - let a = handle_key_inner(&char_key("-"), true, false, false, &insert(), 80, 24, false); - assert!(matches!(a, Action::DecreaseFontSize)); +fn dispatch_ctrl_dot_cycles_mode_via_keymap() { + let a = handle_key_modified( + &km(), + &char_key("."), + true, + false, + false, + false, + &insert(), + 80, + 24, + false, + ); + assert!(matches!(a, Action::SetMode(InputMode::Normal))); } #[test] -fn ctrl_zero_resets_font_size() { - let a = handle_key_inner(&char_key("0"), true, false, false, &insert(), 80, 24, false); - assert!(matches!(a, Action::ResetFontSize)); +fn dispatch_override_none_returns_raw_byte() { + // With cmd+v disabled, ⌘+v is unmapped → swallowed (cmd path), NOT paste. + let (km2, _e) = KeyMap::from_config(&crate::config::KeybindingsConfig( + [("cmd+v".to_string(), "none".to_string())] + .into_iter() + .collect(), + )); + let a = handle_key_modified( + &km2, + &char_key("v"), + false, + false, + false, + true, + &insert(), + 80, + 24, + false, + ); + assert!(matches!(a, Action::None)); } #[test] -fn ctrl_page_up_prev_tab() { - let a = handle_key_inner( - &named(NamedKey::PageUp), +fn dispatch_ctrl_b_unmapped_after_disable_sends_raw() { + // Disabling ctrl+b returns it to its raw terminal meaning (byte 0x02) via encoding fallback. + let (km2, _e) = KeyMap::from_config(&crate::config::KeybindingsConfig( + [("ctrl+b".to_string(), "none".to_string())] + .into_iter() + .collect(), + )); + let a = handle_key_modified( + &km2, + &char_key("b"), true, false, false, + false, &insert(), 80, 24, false, ); - assert!(matches!(a, Action::PrevTab)); + assert!(matches!(a, Action::SendToPty(ref v) if v == &[2u8])); } #[test] -fn ctrl_page_down_next_tab() { - let a = handle_key_inner( - &named(NamedKey::PageDown), +fn dispatch_ctrl_c_in_insert_still_sends_raw() { + // The Ctrl+C-in-Visual guard lives in handle_key_inner but only fires for + // Visual mode; Insert still yields raw byte 0x03. + let a = handle_key_modified( + &km(), + &char_key("c"), true, false, false, + false, &insert(), 80, 24, false, ); - assert!(matches!(a, Action::NextTab)); + assert!(matches!(a, Action::SendToPty(ref v) if v == &[3u8])); } #[test] -fn shift_page_up_scrolls_full_page() { - let a = handle_key_inner( - &named(NamedKey::PageUp), - false, +fn dispatch_ctrl_c_in_visual_copies() { + let a = handle_key_modified( + &km(), + &char_key("c"), true, false, - &insert(), + false, + false, + &visual(), 80, 24, false, ); - assert!(matches!(a, Action::ScrollUp(24))); + assert!(matches!(a, Action::Copy)); +} + +// ── ctrl_w chords via keymap ────────────────────────────────────────────────── + +#[test] +fn dispatch_ctrl_w_chord_v_splits() { + let a = handle_ctrl_w_keymap(&km(), &char_key("v")); + assert!(matches!(a, Action::SplitH)); +} + +#[test] +fn dispatch_ctrl_w_chord_uppercase_r_backward() { + let a = handle_ctrl_w_keymap(&km(), &char_key("R")); + assert!(matches!(a, Action::RotatePanesBackward)); +} + +#[test] +fn dispatch_ctrl_w_chord_lowercase_r_forward() { + let a = handle_ctrl_w_keymap(&km(), &char_key("r")); + assert!(matches!(a, Action::RotatePanesForward)); +} + +#[test] +fn dispatch_ctrl_w_chord_override() { + let (km2, _e) = KeyMap::from_config(&crate::config::KeybindingsConfig( + [("ctrl+w x".to_string(), "close_pane".to_string())] + .into_iter() + .collect(), + )); + let a = handle_ctrl_w_keymap(&km2, &char_key("x")); + assert!(matches!(a, Action::ClosePane)); } #[test] -fn shift_page_down_scrolls_full_page() { - let a = handle_key_inner( - &named(NamedKey::PageDown), - false, - true, - false, - &insert(), - 80, - 24, - false, - ); - assert!(matches!(a, Action::ScrollDown(24))); +fn dispatch_ctrl_w_chord_uppercase_v_still_splits() { + // Ctrl+W V (shift held) must behave like Ctrl+W v (case-insensitive tail, except R). + let a = handle_ctrl_w_keymap(&km(), &char_key("V")); + assert!(matches!(a, Action::SplitH)); +} + +#[test] +fn dispatch_ctrl_w_chord_uppercase_s_splits_vertical() { + let a = handle_ctrl_w_keymap(&km(), &char_key("S")); + assert!(matches!(a, Action::SplitV)); +} + +#[test] +fn dispatch_ctrl_w_chord_unknown_returns_none() { + let a = handle_ctrl_w_keymap(&km(), &char_key("y")); + assert!(matches!(a, Action::None)); } // ── Insert mode ────────────────────────────────────────────────────────────── @@ -597,51 +555,6 @@ fn insert_char_sends_utf8_bytes() { assert!(matches!(a, Action::SendToPty(ref v) if v == b"a")); } -#[test] -fn ctrl_enter_toggles_fullscreen_from_insert() { - let a = handle_key_inner( - &named(NamedKey::Enter), - true, - false, - false, - &insert(), - 80, - 24, - false, - ); - assert!(matches!(a, Action::ToggleFullscreen)); -} - -#[test] -fn ctrl_enter_toggles_fullscreen_from_normal() { - let a = handle_key_inner( - &named(NamedKey::Enter), - true, - false, - false, - &normal(), - 80, - 24, - false, - ); - assert!(matches!(a, Action::ToggleFullscreen)); -} - -#[test] -fn ctrl_enter_toggles_fullscreen_from_visual() { - let a = handle_key_inner( - &named(NamedKey::Enter), - true, - false, - false, - &visual(), - 80, - 24, - false, - ); - assert!(matches!(a, Action::ToggleFullscreen)); -} - #[test] fn insert_arrow_up_normal_cursor() { let a = handle_key_inner( @@ -1621,36 +1534,6 @@ fn visual_unrecognized_named_key_returns_none() { // ── Alt modifier encoding ───────────────────────────────────────────────────── -#[test] -fn insert_alt_tab_returns_none() { - let a = handle_key_inner( - &named(NamedKey::Tab), - false, - false, - true, - &insert(), - 80, - 24, - false, - ); - assert!(matches!(a, Action::None)); -} - -#[test] -fn insert_alt_shift_tab_returns_none() { - let a = handle_key_inner( - &named(NamedKey::Tab), - false, - true, - true, - &insert(), - 80, - 24, - false, - ); - assert!(matches!(a, Action::None)); -} - #[test] fn insert_alt_char_sends_esc_prefixed() { let a = handle_key_inner(&char_key("b"), false, false, true, &insert(), 80, 24, false); @@ -1719,23 +1602,6 @@ fn insert_alt_arrow_falls_through_to_regular_match() { assert!(matches!(a, Action::SendToPty(ref v) if v == b"\x1b[D")); } -#[test] -fn insert_alt_tab_consumed_silently() { - // Alt+Tab is intercepted before the alt-encoding block to avoid sending - // it to the PTY while the window manager switches focus. - let a = handle_key_inner( - &named(NamedKey::Tab), - false, - false, - true, - &insert(), - 80, - 24, - false, - ); - assert!(matches!(a, Action::None)); -} - #[test] fn insert_alt_enter_sends_escape_cr() { let a = handle_key_inner( @@ -1766,77 +1632,7 @@ fn insert_alt_backspace_sends_escape_del() { assert!(matches!(a, Action::SendToPty(ref v) if v == &[0x1b, 0x7f])); } -// ── Alt+1..9 → GoToTab ────────────────────────────────────────────────────── - -#[test] -fn alt_1_goes_to_tab_0() { - let a = handle_key_inner(&char_key("1"), false, false, true, &insert(), 80, 24, false); - assert!(matches!(a, Action::GoToTab(0))); -} - -#[test] -fn alt_5_goes_to_tab_4() { - let a = handle_key_inner(&char_key("5"), false, false, true, &insert(), 80, 24, false); - assert!(matches!(a, Action::GoToTab(4))); -} - -#[test] -fn alt_9_goes_to_tab_8() { - let a = handle_key_inner(&char_key("9"), false, false, true, &insert(), 80, 24, false); - assert!(matches!(a, Action::GoToTab(8))); -} - -#[test] -fn alt_0_does_not_go_to_tab() { - let a = handle_key_inner(&char_key("0"), false, false, true, &insert(), 80, 24, false); - assert!(!matches!(a, Action::GoToTab(_))); -} - -#[test] -fn alt_1_works_from_normal_mode() { - let a = handle_key_inner(&char_key("1"), false, false, true, &normal(), 80, 24, false); - assert!(matches!(a, Action::GoToTab(0))); -} - -#[test] -fn ctrl_alt_1_does_not_go_to_tab() { - let a = handle_key_inner(&char_key("1"), true, false, true, &insert(), 80, 24, false); - assert!(!matches!(a, Action::GoToTab(_))); -} - -// ── Ctrl+Enter / Alt+key in Insert mode ────────────────────────────────────── - -#[test] -fn ctrl_enter_toggles_fullscreen() { - // Ctrl+Enter is intercepted globally before per-mode handling. - let a = handle_key_inner( - &named(NamedKey::Enter), - true, - false, - false, - &InputMode::Insert, - 80, - 24, - false, - ); - assert!(matches!(a, Action::ToggleFullscreen)); -} - -#[test] -fn alt_tab_is_consumed_silently() { - // Alt+Tab is swallowed so the WM focus-switch keystroke isn't forwarded to the PTY. - let a = handle_key_inner( - &named(NamedKey::Tab), - false, - false, - true, - &InputMode::Insert, - 80, - 24, - false, - ); - assert!(matches!(a, Action::None)); -} +// ── Alt+key in Insert mode ──────────────────────────────────────────────────── #[test] fn alt_enter_in_insert_sends_esc_cr() { @@ -1933,30 +1729,6 @@ fn palette_mode() -> InputMode { } } -#[test] -fn ctrl_shift_p_opens_palette_from_insert() { - let a = handle_key_inner(&char_key("p"), true, true, false, &insert(), 80, 24, false); - assert!(matches!(a, Action::OpenCommandPalette)); -} - -#[test] -fn ctrl_shift_p_opens_palette_from_normal() { - let a = handle_key_inner(&char_key("p"), true, true, false, &normal(), 80, 24, false); - assert!(matches!(a, Action::OpenCommandPalette)); -} - -#[test] -fn ctrl_shift_p_opens_palette_from_visual() { - let a = handle_key_inner(&char_key("p"), true, true, false, &visual(), 80, 24, false); - assert!(matches!(a, Action::OpenCommandPalette)); -} - -#[test] -fn ctrl_shift_p_opens_palette_uppercase_p() { - let a = handle_key_inner(&char_key("P"), true, true, false, &insert(), 80, 24, false); - assert!(matches!(a, Action::OpenCommandPalette)); -} - #[test] fn command_palette_mode_swallows_chars() { // Keys in CommandPalette mode return None so the handler in main.rs can intercept them. @@ -1973,19 +1745,6 @@ fn command_palette_mode_swallows_chars() { assert!(matches!(a, Action::None)); } -#[test] -fn ctrl_shift_p_not_triggered_without_ctrl() { - let a = handle_key_inner(&char_key("p"), false, true, false, &insert(), 80, 24, false); - assert!(!matches!(a, Action::OpenCommandPalette)); -} - -#[test] -fn ctrl_shift_p_not_triggered_without_shift() { - // Ctrl+P without shift is forwarded to PTY (SendToPty), not OpenCommandPalette. - let a = handle_key_inner(&char_key("p"), true, false, false, &insert(), 80, 24, false); - assert!(!matches!(a, Action::OpenCommandPalette)); -} - // ── pick_seq ───────────────────────────────────────────────────────────────── #[test] @@ -2021,47 +1780,6 @@ fn cursor_seq_unknown_returns_none() { assert!(cursor_seq(&named(NamedKey::F1), false).is_none()); } -// ── handle_ctrl_only ───────────────────────────────────────────────────────── - -#[test] -fn handle_ctrl_only_w_returns_ctrl_w_prefix() { - assert!(matches!( - handle_ctrl_only(&char_key("w"), false, &insert()), - Some(Action::CtrlWPrefix) - )); -} - -#[test] -fn handle_ctrl_only_q_returns_quit() { - assert!(matches!( - handle_ctrl_only(&char_key("q"), false, &insert()), - Some(Action::Quit) - )); -} - -#[test] -fn handle_ctrl_only_c_in_visual_returns_copy() { - let mode = InputMode::Visual { - start_col: 0, - start_row: 0, - cur_col: 0, - cur_row: 0, - anchored: true, - }; - assert!(matches!( - handle_ctrl_only(&char_key("c"), false, &mode), - Some(Action::Copy) - )); -} - -#[test] -fn handle_ctrl_only_c_in_insert_not_copy() { - assert!(!matches!( - handle_ctrl_only(&char_key("c"), false, &insert()), - Some(Action::Copy) - )); -} - // ── visual_up_action / visual_down_action ──────────────────────────────────── #[test] @@ -2151,64 +1869,6 @@ fn visual_char_unknown_returns_none() { )); } -// ── ctrl_shift_char_action ─────────────────────────────────────────────────── - -#[test] -fn ctrl_shift_char_v_pastes() { - assert!(matches!(ctrl_shift_char_action("v"), Some(Action::Paste))); -} - -#[test] -fn ctrl_shift_char_uppercase_v_pastes() { - assert!(matches!(ctrl_shift_char_action("V"), Some(Action::Paste))); -} - -#[test] -fn ctrl_shift_char_w_closes_tab() { - assert!(matches!( - ctrl_shift_char_action("w"), - Some(Action::CloseTab) - )); -} - -#[test] -fn ctrl_shift_char_unknown_returns_none() { - assert!(ctrl_shift_char_action("x").is_none()); -} - -// ── ctrl_char_key_action ───────────────────────────────────────────────────── - -#[test] -fn ctrl_char_key_q_quits() { - assert!(matches!(ctrl_char_key_action("q"), Some(Action::Quit))); -} - -#[test] -fn ctrl_char_key_uppercase_q_quits() { - assert!(matches!(ctrl_char_key_action("Q"), Some(Action::Quit))); -} - -#[test] -fn ctrl_char_key_plus_increases_font() { - assert!(matches!( - ctrl_char_key_action("+"), - Some(Action::IncreaseFontSize) - )); -} - -#[test] -fn ctrl_char_key_equals_increases_font() { - assert!(matches!( - ctrl_char_key_action("="), - Some(Action::IncreaseFontSize) - )); -} - -#[test] -fn ctrl_char_key_unknown_returns_none() { - assert!(ctrl_char_key_action("z").is_none()); -} - // ── Screenshot keybindings ─────────────────────────────────────────────────── fn screenshot_mode() -> InputMode { @@ -2220,14 +1880,6 @@ fn screenshot_mode() -> InputMode { } } -#[test] -fn ctrl_w_p_opens_screenshot() { - assert!(matches!( - ctrl_w_action(&char_key("p")), - Action::ScreenshotOpen - )); -} - #[test] fn screenshot_arrow_right_moves_right() { let a = handle_key_inner( @@ -2394,52 +2046,16 @@ fn screenshot_esc_exits_to_insert() { } #[test] -fn screenshot_ctrl_shift_arrow_resizes_pane_not_screenshot() { - // Global shortcuts still fire even in Screenshot mode — Ctrl+Shift+Arrow resizes panes. +fn screenshot_esc_still_exits_to_insert() { let a = handle_key_inner( - &named(NamedKey::ArrowRight), - true, - true, + &named(NamedKey::Escape), + false, + false, false, &screenshot_mode(), 80, 24, false, ); - assert!(matches!(a, Action::ResizePaneRight)); -} - -// ── TogglePassthrough (Ctrl+B) ──────────────────────────────────────────────── - -#[test] -fn ctrl_b_in_insert_toggles_passthrough() { - let a = handle_key_inner(&char_key("b"), true, false, false, &insert(), 80, 24, false); - assert!(matches!(a, Action::TogglePassthrough)); -} - -#[test] -fn ctrl_b_in_normal_toggles_passthrough() { - // Global shortcut fires regardless of mode. - let a = handle_key_inner(&char_key("b"), true, false, false, &normal(), 80, 24, false); - assert!(matches!(a, Action::TogglePassthrough)); -} - -#[test] -fn ctrl_b_in_visual_toggles_passthrough() { - let a = handle_key_inner(&char_key("b"), true, false, false, &visual(), 80, 24, false); - assert!(matches!(a, Action::TogglePassthrough)); -} - -#[test] -fn ctrl_shift_b_does_not_toggle_passthrough() { - // Ctrl+Shift+B is not a bound shortcut, should not toggle passthrough. - let a = handle_key_inner(&char_key("b"), true, true, false, &insert(), 80, 24, false); - assert!(!matches!(a, Action::TogglePassthrough)); -} - -#[test] -fn ctrl_alt_b_also_toggles_passthrough() { - // Ctrl takes priority over Alt for char keys — consistent with Ctrl+Alt+T → NewTab. - let a = handle_key_inner(&char_key("b"), true, false, true, &insert(), 80, 24, false); - assert!(matches!(a, Action::TogglePassthrough)); + assert!(matches!(a, Action::SetMode(InputMode::Insert))); } diff --git a/src/input/keymap.rs b/src/input/keymap.rs new file mode 100644 index 0000000..74b9efa --- /dev/null +++ b/src/input/keymap.rs @@ -0,0 +1,710 @@ +//! Single source of truth for shortcut bindings. +//! +//! `default_keymap()` returns all built-in modifier/chord shortcuts as data. +//! `KeyMap::from_config` overlays the user's `[keybindings]` config onto a copy +//! of the defaults (insert/replace; `"none"` removes; invalid entries skipped + +//! collected as `KeymapError`). Dispatch consults `lookup` first; a miss falls +//! through to the literal/PTY encoding handlers in `keybindings.rs`. +// +use std::collections::HashMap; + +use winit::keyboard::{Key, NamedKey}; + +use crate::config::KeybindingsConfig; +use crate::input::keybindings::Action; +use crate::input::mode::InputMode; + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash, Default)] +pub struct Mods { + pub ctrl: bool, + pub shift: bool, + pub alt: bool, + pub cmd: bool, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub enum KeyToken { + /// Single character as winit reports it in `Key::Character`. Primary tokens + /// are lowercased; chord-tail tokens keep their original case (so `Ctrl+W R` + /// stays distinct from `Ctrl+W r`). + Char(String), + Named(NamedKey), +} + +#[derive(Debug, Clone, Copy, PartialEq, Eq, Hash)] +pub enum ModeClass { + Global, + Normal, + Visual, +} + +#[derive(Debug, Clone, PartialEq, Eq, Hash)] +pub struct BindingKey { + pub mods: Mods, + pub token: KeyToken, + pub chord_tail: Option<(Mods, KeyToken)>, +} + +#[derive(Debug, Clone, PartialEq, Eq)] +pub enum KeymapError { + Parse { raw: String, reason: String }, + UnknownAction { raw: String, name: String }, + ShadowsInput { raw: String }, +} + +/// Parse a single binding string into its mode scope + `BindingKey`. +/// Grammar: `[normal:|visual:] mods*('+') key [SPACE tail-mods*('+') tail-key]`. +pub fn parse_binding(raw: &str) -> Result<(ModeClass, BindingKey), String> { + let trimmed = raw.trim(); + if trimmed.is_empty() { + return Err("empty binding".into()); + } + + // Mode prefix. + let (scope, rest) = if let Some(r) = trimmed.strip_prefix("normal:") { + (ModeClass::Normal, r) + } else if let Some(r) = trimmed.strip_prefix("visual:") { + (ModeClass::Visual, r) + } else { + (ModeClass::Global, trimmed) + }; + + // Chord: split on the first ASCII space into head + tail. + let mut parts = rest.splitn(2, ' '); + let head = parts.next().unwrap_or("").trim(); + let tail = parts.next().map(str::trim).filter(|s| !s.is_empty()); + + let (mods, token) = parse_combo(head, /* lowercase */ true)?; + let chord_tail = match tail { + Some(t) => { + let (tmods, ttoken) = parse_combo(t, /* lowercase */ false)?; + Some((tmods, ttoken)) + } + None => None, + }; + + Ok(( + scope, + BindingKey { + mods, + token, + chord_tail, + }, + )) +} + +/// Parse `ctrl+shift+v` style combo. The final segment is the key; the rest are +/// modifiers. `lowercase` controls whether a single-letter key is folded. +fn parse_combo(s: &str, lowercase: bool) -> Result<(Mods, KeyToken), String> { + if s.is_empty() { + return Err("empty key combo".into()); + } + // A bare "+" or "=" key with no modifiers. + if s == "+" { + return Ok((Mods::default(), KeyToken::Char("+".into()))); + } + + let mut mods = Mods::default(); + // Determine the key segment and the modifier prefix. A combo ending in `++` + // means the key itself is `+`, e.g. `cmd++`: the modifiers are everything up + // to that doubled `+`. A combo ending in a single `+` (e.g. `ctrl+`) is a + // dangling separator with no key and is rejected. + let (modifier_str, key_seg): (&str, String) = if let Some(mods_str) = s.strip_suffix("++") { + (mods_str, "+".to_string()) + } else if s.ends_with('+') { + return Err("binding ends with a trailing `+` separator".into()); + } else { + match s.rsplit_once('+') { + Some((m, k)) => (m, k.to_string()), + None => ("", s.to_string()), + } + }; + + if !modifier_str.is_empty() { + for m in modifier_str.split('+') { + apply_modifier(&mut mods, m)?; + } + } + + let token = parse_key_token(&key_seg, lowercase)?; + Ok((mods, token)) +} + +fn apply_modifier(mods: &mut Mods, m: &str) -> Result<(), String> { + match m.to_lowercase().as_str() { + "ctrl" | "control" => mods.ctrl = true, + "shift" => mods.shift = true, + "alt" | "option" => mods.alt = true, + "cmd" | "super" | "win" | "meta" => mods.cmd = true, + other => return Err(format!("unknown modifier `{other}`")), + } + Ok(()) +} + +fn parse_key_token(s: &str, lowercase: bool) -> Result { + if let Some(named) = named_key_from_name(s) { + return Ok(KeyToken::Named(named)); + } + // Single grapheme / char key. + let chars: Vec = s.chars().collect(); + if chars.len() != 1 { + return Err(format!("unknown key `{s}`")); + } + let c = chars[0]; + let stored = if lowercase { + c.to_lowercase().to_string() + } else { + c.to_string() + }; + Ok(KeyToken::Char(stored)) +} + +fn named_key_from_name(s: &str) -> Option { + let n = s.to_lowercase(); + Some(match n.as_str() { + "enter" | "return" => NamedKey::Enter, + "escape" | "esc" => NamedKey::Escape, + "tab" => NamedKey::Tab, + "space" => NamedKey::Space, + "backspace" => NamedKey::Backspace, + "delete" | "del" => NamedKey::Delete, + "pageup" => NamedKey::PageUp, + "pagedown" => NamedKey::PageDown, + "home" => NamedKey::Home, + "end" => NamedKey::End, + "arrowup" | "up" => NamedKey::ArrowUp, + "arrowdown" | "down" => NamedKey::ArrowDown, + "arrowleft" | "left" => NamedKey::ArrowLeft, + "arrowright" | "right" => NamedKey::ArrowRight, + "f1" => NamedKey::F1, + "f2" => NamedKey::F2, + "f3" => NamedKey::F3, + "f4" => NamedKey::F4, + "f5" => NamedKey::F5, + "f6" => NamedKey::F6, + "f7" => NamedKey::F7, + "f8" => NamedKey::F8, + "f9" => NamedKey::F9, + "f10" => NamedKey::F10, + "f11" => NamedKey::F11, + "f12" => NamedKey::F12, + _ => return None, + }) +} + +/// Build a `KeyToken` from a live winit `Key`, applying the same lowercase rule +/// used for primary tokens. Returns `None` for keys we never bind. +pub fn token_from_key(key: &Key, lowercase: bool) -> Option { + match key { + Key::Character(s) => { + let stored = if lowercase { + s.to_lowercase() + } else { + s.to_string() + }; + Some(KeyToken::Char(stored)) + } + Key::Named(n) => Some(KeyToken::Named(*n)), + _ => None, + } +} + +/// Runtime context a few actions need at build time. +#[derive(Debug, Clone, Copy)] +pub struct DispatchCtx { + pub grid_rows: usize, + /// Used by `cycle_mode` (Ctrl+.) to pick the next mode. + pub mode: InputModeKind, +} + +/// A `Copy` projection of `InputMode` carrying only what the registry needs. +#[derive(Debug, Clone, Copy, PartialEq, Eq)] +pub enum InputModeKind { + Insert, + Normal, + Visual, + Other, +} + +impl InputModeKind { + pub fn of(mode: &InputMode) -> Self { + match mode { + InputMode::Insert => InputModeKind::Insert, + InputMode::Normal => InputModeKind::Normal, + InputMode::Visual { .. } => InputModeKind::Visual, + _ => InputModeKind::Other, + } + } +} + +fn visual_mode_init() -> InputMode { + InputMode::Visual { + start_col: 0, + start_row: 0, + cur_col: 0, + cur_row: 0, + anchored: false, + } +} + +fn cycle_mode_next(kind: InputModeKind) -> InputMode { + match kind { + InputModeKind::Insert => InputMode::Normal, + InputModeKind::Normal => visual_mode_init(), + _ => InputMode::Insert, + } +} + +/// Map a user-bindable action name to a concrete `Action`. Returns `None` for +/// unknown names (the validator turns that into `KeymapError::UnknownAction`). +/// `"none"` is intentionally NOT here — it is the reserved disable keyword. +pub fn action_from_name(name: &str, ctx: DispatchCtx) -> Option { + let a = match name { + // clipboard + "paste" => Action::Paste, + "copy" => Action::Copy, + // tabs + "new_tab" => Action::NewTab, + "close_tab" => Action::CloseTab, + "next_tab" => Action::NextTab, + "prev_tab" => Action::PrevTab, + "move_tab_left" => Action::MoveTabLeft, + "move_tab_right" => Action::MoveTabRight, + "rename_tab" => Action::RenameTab, + "go_to_tab_1" => Action::GoToTab(0), + "go_to_tab_2" => Action::GoToTab(1), + "go_to_tab_3" => Action::GoToTab(2), + "go_to_tab_4" => Action::GoToTab(3), + "go_to_tab_5" => Action::GoToTab(4), + "go_to_tab_6" => Action::GoToTab(5), + "go_to_tab_7" => Action::GoToTab(6), + "go_to_tab_8" => Action::GoToTab(7), + "go_to_tab_9" => Action::GoToTab(8), + // panes + "split_horizontal" => Action::SplitH, + "split_vertical" => Action::SplitV, + "auto_split" => Action::AutoSplit, + "close_pane" => Action::ClosePane, + "focus_left" => Action::FocusLeft, + "focus_right" => Action::FocusRight, + "focus_up" => Action::FocusUp, + "focus_down" => Action::FocusDown, + "focus_next" => Action::FocusNext, + "zoom_pane" => Action::ZoomPane, + "rotate_panes_forward" => Action::RotatePanesForward, + "rotate_panes_backward" => Action::RotatePanesBackward, + "resize_pane_right" => Action::ResizePaneRight, + "resize_pane_left" => Action::ResizePaneLeft, + "resize_pane_down" => Action::ResizePaneDown, + "resize_pane_up" => Action::ResizePaneUp, + // scroll + "scroll_page_up" => Action::ScrollUp(ctx.grid_rows), + "scroll_page_down" => Action::ScrollDown(ctx.grid_rows), + "scroll_to_top" => Action::ScrollToTop, + "scroll_to_bottom" => Action::ScrollToBottom, + "clear_scrollback" => Action::ClearScrollback, + // search + "search_open" => Action::SearchOpen, + "search_next" => Action::SearchNext, + "search_prev" => Action::SearchPrev, + // font size + "increase_font_size" => Action::IncreaseFontSize, + "decrease_font_size" => Action::DecreaseFontSize, + "reset_font_size" => Action::ResetFontSize, + // ui / app + "open_config" => Action::OpenConfig, + "open_command_palette" => Action::OpenCommandPalette, + "toggle_fullscreen" => Action::ToggleFullscreen, + "toggle_log" => Action::ToggleLog, + "toggle_passthrough" => Action::TogglePassthrough, + "screenshot_open" => Action::ScreenshotOpen, + "quit" => Action::Quit, + // modes + "cycle_mode" => Action::SetMode(cycle_mode_next(ctx.mode)), + "enter_normal_mode" => Action::SetMode(InputMode::Normal), + // pane chord prefix + "ctrl_w_prefix" => Action::CtrlWPrefix, + _ => return None, + }; + Some(a) +} + +/// Reverse map: the canonical name for an `Action`, for docs / validation +/// messages. Parameterized + internal actions return `None` (they are not a +/// single stable user name) except where a fixed name exists. +#[cfg(test)] +pub fn name_of_action(action: &Action) -> Option<&'static str> { + Some(match action { + Action::Paste => "paste", + Action::Copy => "copy", + Action::NewTab => "new_tab", + Action::CloseTab => "close_tab", + Action::NextTab => "next_tab", + Action::PrevTab => "prev_tab", + Action::MoveTabLeft => "move_tab_left", + Action::MoveTabRight => "move_tab_right", + Action::RenameTab => "rename_tab", + Action::SplitH => "split_horizontal", + Action::SplitV => "split_vertical", + Action::AutoSplit => "auto_split", + Action::ClosePane => "close_pane", + Action::FocusLeft => "focus_left", + Action::FocusRight => "focus_right", + Action::FocusUp => "focus_up", + Action::FocusDown => "focus_down", + Action::FocusNext => "focus_next", + Action::ZoomPane => "zoom_pane", + Action::RotatePanesForward => "rotate_panes_forward", + Action::RotatePanesBackward => "rotate_panes_backward", + Action::ResizePaneRight => "resize_pane_right", + Action::ResizePaneLeft => "resize_pane_left", + Action::ResizePaneDown => "resize_pane_down", + Action::ResizePaneUp => "resize_pane_up", + Action::ScrollToTop => "scroll_to_top", + Action::ScrollToBottom => "scroll_to_bottom", + Action::ClearScrollback => "clear_scrollback", + Action::SearchOpen => "search_open", + Action::SearchNext => "search_next", + Action::SearchPrev => "search_prev", + Action::IncreaseFontSize => "increase_font_size", + Action::DecreaseFontSize => "decrease_font_size", + Action::ResetFontSize => "reset_font_size", + Action::OpenConfig => "open_config", + Action::OpenCommandPalette => "open_command_palette", + Action::ToggleFullscreen => "toggle_fullscreen", + Action::ToggleLog => "toggle_log", + Action::TogglePassthrough => "toggle_passthrough", + Action::ScreenshotOpen => "screenshot_open", + Action::Quit => "quit", + Action::CtrlWPrefix => "ctrl_w_prefix", + _ => return None, + }) +} + +/// The merged binding table. Values are registry action names (`&'static str`). +#[derive(Debug, Clone, Default)] +pub struct KeyMap { + map: HashMap<(ModeClass, BindingKey), &'static str>, +} + +impl KeyMap { + fn insert(&mut self, scope: ModeClass, key: BindingKey, name: &'static str) { + self.map.insert((scope, key), name); + } + + pub fn lookup(&self, scope: ModeClass, key: &BindingKey) -> Option<&'static str> { + self.map.get(&(scope, key.clone())).copied() + } + + #[cfg(test)] + pub fn len(&self) -> usize { + self.map.len() + } + + #[cfg(test)] + pub fn is_empty(&self) -> bool { + self.map.is_empty() + } +} + +impl KeyMap { + /// Build the merged keymap: defaults overlaid with the user's config. + /// Returns the map + any per-entry errors (skipped entries). + pub fn from_config(cfg: &KeybindingsConfig) -> (KeyMap, Vec) { + let mut km = default_keymap(); + let mut errors = Vec::new(); + + for (raw, action) in &cfg.0 { + let (scope, key) = match parse_binding(raw) { + Ok(v) => v, + Err(reason) => { + errors.push(KeymapError::Parse { + raw: raw.clone(), + reason, + }); + continue; + } + }; + + // "none" disables a default → remove the entry. + if action == "none" { + km.map.remove(&(scope, key)); + continue; + } + + // Shadows-input guard: bare unmodified single-char in Global scope. + if scope == ModeClass::Global + && key.mods == Mods::default() + && key.chord_tail.is_none() + && matches!(key.token, KeyToken::Char(_)) + { + errors.push(KeymapError::ShadowsInput { raw: raw.clone() }); + continue; + } + + // Validate the action name against the registry. Use a probe ctx; + // names that depend on ctx still resolve to Some(_) here. + let probe = DispatchCtx { + grid_rows: 1, + mode: InputModeKind::Insert, + }; + if action_from_name(action, probe).is_none() { + errors.push(KeymapError::UnknownAction { + raw: raw.clone(), + name: action.clone(), + }); + continue; + } + + // The validator guaranteed `action_from_name` returned `Some`, so the + // name is one of the known registry names and `intern_known_name` + // resolves it to a `&'static str` (kept in sync with the registry). + let static_name = intern_known_name(action) + .expect("validated name must be in NAMES — keep intern_known_name in sync"); + km.insert(scope, key, static_name); + } + + (km, errors) + } +} + +/// Intern a validated action name to `&'static str`. Covers every name accepted +/// by `action_from_name`, including the parameterized ones that `name_of_action` +/// cannot reverse. Keep in sync with `action_from_name`. +fn intern_known_name(name: &str) -> Option<&'static str> { + const NAMES: &[&str] = &[ + "paste", + "copy", + "new_tab", + "close_tab", + "next_tab", + "prev_tab", + "move_tab_left", + "move_tab_right", + "rename_tab", + "go_to_tab_1", + "go_to_tab_2", + "go_to_tab_3", + "go_to_tab_4", + "go_to_tab_5", + "go_to_tab_6", + "go_to_tab_7", + "go_to_tab_8", + "go_to_tab_9", + "split_horizontal", + "split_vertical", + "auto_split", + "close_pane", + "focus_left", + "focus_right", + "focus_up", + "focus_down", + "focus_next", + "zoom_pane", + "rotate_panes_forward", + "rotate_panes_backward", + "resize_pane_right", + "resize_pane_left", + "resize_pane_down", + "resize_pane_up", + "scroll_page_up", + "scroll_page_down", + "scroll_to_top", + "scroll_to_bottom", + "clear_scrollback", + "search_open", + "search_next", + "search_prev", + "increase_font_size", + "decrease_font_size", + "reset_font_size", + "open_config", + "open_command_palette", + "toggle_fullscreen", + "toggle_log", + "toggle_passthrough", + "screenshot_open", + "quit", + "cycle_mode", + "enter_normal_mode", + "ctrl_w_prefix", + ]; + NAMES.iter().copied().find(|&n| n == name) +} + +fn ch(c: &str) -> KeyToken { + KeyToken::Char(c.into()) +} + +fn nk(n: NamedKey) -> KeyToken { + KeyToken::Named(n) +} + +const M_CTRL: Mods = Mods { + ctrl: true, + shift: false, + alt: false, + cmd: false, +}; +const M_CTRL_SHIFT: Mods = Mods { + ctrl: true, + shift: true, + alt: false, + cmd: false, +}; +const M_ALT: Mods = Mods { + ctrl: false, + shift: false, + alt: true, + cmd: false, +}; +const M_SHIFT: Mods = Mods { + ctrl: false, + shift: true, + alt: false, + cmd: false, +}; +const M_CMD: Mods = Mods { + ctrl: false, + shift: false, + alt: false, + cmd: true, +}; + +/// All built-in Global-scope shortcut bindings, as data. Mirrors today's +/// `handle_global_shortcuts` + `ctrl_w_action` + the curated `cmd` set. +/// +/// Intentionally left to the `keybindings.rs` fallthrough (NOT in this table): +/// - `Ctrl+C` copy-in-Visual: mode-conditional, handled by `handle_ctrl_only`. +/// - Chord tails are stored case-preserved (only `R` differs from `r`). The +/// Task-4 dispatcher MUST lowercase the live tail token before lookup so that +/// shifted tails like `Ctrl+W V` still match `Ctrl+W v`, while passing the +/// exact glyph for the `R`/`r` distinction. (Design decision #4 in the plan.) +pub fn default_keymap() -> KeyMap { + let mut km = KeyMap::default(); + { + let mut g = |m: Mods, t: KeyToken, name: &'static str| { + km.insert( + ModeClass::Global, + BindingKey { + mods: m, + token: t, + chord_tail: None, + }, + name, + ) + }; + + // ── Ctrl (handle_ctrl_only + ctrl_char_action) ─────────────────────── + g(M_CTRL, ch("w"), "ctrl_w_prefix"); + g(M_CTRL, ch("q"), "quit"); + g(M_CTRL, ch(","), "open_config"); + g(M_CTRL, ch("t"), "new_tab"); + g(M_CTRL, ch("b"), "toggle_passthrough"); + g(M_CTRL, ch("+"), "increase_font_size"); + g(M_CTRL, ch("="), "increase_font_size"); + g(M_CTRL, ch("-"), "decrease_font_size"); + g(M_CTRL, ch("0"), "reset_font_size"); + g(M_CTRL, nk(NamedKey::PageUp), "prev_tab"); + g(M_CTRL, nk(NamedKey::PageDown), "next_tab"); + g(M_CTRL, nk(NamedKey::Enter), "toggle_fullscreen"); + // Ctrl+. cycles mode; Ctrl+\ and Ctrl+| enter Normal. + g(M_CTRL, ch("."), "cycle_mode"); + g(M_CTRL, ch("\\"), "enter_normal_mode"); + g(M_CTRL, ch("|"), "enter_normal_mode"); + + // ── Ctrl+Shift (ctrl_shift_action) ─────────────────────────────────────── + g(M_CTRL_SHIFT, ch("v"), "paste"); + g(M_CTRL_SHIFT, ch("w"), "close_tab"); + g(M_CTRL_SHIFT, ch("r"), "rename_tab"); + g(M_CTRL_SHIFT, ch("k"), "clear_scrollback"); + g(M_CTRL_SHIFT, ch("l"), "toggle_log"); + g(M_CTRL_SHIFT, ch("p"), "open_command_palette"); + g(M_CTRL_SHIFT, nk(NamedKey::ArrowUp), "resize_pane_up"); + g(M_CTRL_SHIFT, nk(NamedKey::ArrowDown), "resize_pane_down"); + g(M_CTRL_SHIFT, nk(NamedKey::ArrowRight), "resize_pane_right"); + g(M_CTRL_SHIFT, nk(NamedKey::ArrowLeft), "resize_pane_left"); + g(M_CTRL_SHIFT, nk(NamedKey::PageUp), "move_tab_left"); + g(M_CTRL_SHIFT, nk(NamedKey::PageDown), "move_tab_right"); + g(M_CTRL_SHIFT, nk(NamedKey::Home), "scroll_to_top"); + g(M_CTRL_SHIFT, nk(NamedKey::End), "scroll_to_bottom"); + + // ── Shift (shift_scroll_action) ────────────────────────────────────────── + g(M_SHIFT, nk(NamedKey::PageUp), "scroll_page_up"); + g(M_SHIFT, nk(NamedKey::PageDown), "scroll_page_down"); + + // ── Alt (alt_action: Alt+1..9 → go_to_tab) ─────────────────────────────── + g(M_ALT, ch("1"), "go_to_tab_1"); + g(M_ALT, ch("2"), "go_to_tab_2"); + g(M_ALT, ch("3"), "go_to_tab_3"); + g(M_ALT, ch("4"), "go_to_tab_4"); + g(M_ALT, ch("5"), "go_to_tab_5"); + g(M_ALT, ch("6"), "go_to_tab_6"); + g(M_ALT, ch("7"), "go_to_tab_7"); + g(M_ALT, ch("8"), "go_to_tab_8"); + g(M_ALT, ch("9"), "go_to_tab_9"); + + // ── Cmd / Super (cmd_char_action) ──────────────────────────────────────── + g(M_CMD, ch("v"), "paste"); + g(M_CMD, ch("c"), "copy"); + g(M_CMD, ch("n"), "new_tab"); + g(M_CMD, ch("t"), "new_tab"); + g(M_CMD, ch("w"), "close_tab"); + g(M_CMD, ch("q"), "quit"); + g(M_CMD, ch(","), "open_config"); + g(M_CMD, ch("f"), "search_open"); + g(M_CMD, ch("k"), "clear_scrollback"); + g(M_CMD, ch("+"), "increase_font_size"); + g(M_CMD, ch("-"), "decrease_font_size"); + g(M_CMD, ch("="), "reset_font_size"); + g(M_CMD, ch("0"), "reset_font_size"); + g(M_CMD, ch("1"), "go_to_tab_1"); + g(M_CMD, ch("2"), "go_to_tab_2"); + g(M_CMD, ch("3"), "go_to_tab_3"); + g(M_CMD, ch("4"), "go_to_tab_4"); + g(M_CMD, ch("5"), "go_to_tab_5"); + g(M_CMD, ch("6"), "go_to_tab_6"); + g(M_CMD, ch("7"), "go_to_tab_7"); + g(M_CMD, ch("8"), "go_to_tab_8"); + g(M_CMD, ch("9"), "go_to_tab_9"); + } + + // ── Ctrl+W chords (ctrl_w_action) ──────────────────────────────────────── + { + let no_mods = Mods::default(); + let mut chord = |tail: KeyToken, name: &'static str| { + km.insert( + ModeClass::Global, + BindingKey { + mods: M_CTRL, + token: ch("w"), + chord_tail: Some((no_mods, tail)), + }, + name, + ) + }; + chord(ch("v"), "split_horizontal"); + chord(ch("s"), "split_vertical"); + chord(ch("a"), "auto_split"); + chord(ch("h"), "focus_left"); + chord(ch("l"), "focus_right"); + chord(ch("k"), "focus_up"); + chord(ch("j"), "focus_down"); + chord(ch("w"), "focus_next"); + chord(ch("q"), "close_pane"); + chord(ch("z"), "zoom_pane"); + chord(ch("r"), "rotate_panes_forward"); + chord(ch("R"), "rotate_panes_backward"); // uppercase, case-preserved tail + chord(ch("p"), "screenshot_open"); + chord(nk(NamedKey::ArrowLeft), "focus_left"); + chord(nk(NamedKey::ArrowRight), "focus_right"); + chord(nk(NamedKey::ArrowUp), "focus_up"); + chord(nk(NamedKey::ArrowDown), "focus_down"); + } + + km +} + +#[cfg(test)] +#[path = "keymap_test.rs"] +mod tests; diff --git a/src/input/keymap_test.rs b/src/input/keymap_test.rs new file mode 100644 index 0000000..418e567 --- /dev/null +++ b/src/input/keymap_test.rs @@ -0,0 +1,564 @@ +use super::*; +use crate::input::keybindings::Action; +use crate::input::mode::InputMode; +use winit::keyboard::NamedKey; + +fn mods(ctrl: bool, shift: bool, alt: bool, cmd: bool) -> Mods { + Mods { + ctrl, + shift, + alt, + cmd, + } +} + +#[test] +fn parse_simple_cmd_v() { + let (scope, key) = parse_binding("cmd+v").expect("should parse"); + assert_eq!(scope, ModeClass::Global); + assert_eq!(key.mods, mods(false, false, false, true)); + assert_eq!(key.token, KeyToken::Char("v".into())); + assert!(key.chord_tail.is_none()); +} + +#[test] +fn parse_is_modifier_order_insensitive() { + let a = parse_binding("ctrl+shift+v").unwrap().1; + let b = parse_binding("shift+ctrl+v").unwrap().1; + assert_eq!(a, b); +} + +#[test] +fn parse_letters_are_lowercased() { + let a = parse_binding("ctrl+V").unwrap().1; + assert_eq!(a.token, KeyToken::Char("v".into())); +} + +#[test] +fn parse_named_keys() { + assert_eq!( + parse_binding("shift+pageup").unwrap().1.token, + KeyToken::Named(NamedKey::PageUp) + ); + assert_eq!( + parse_binding("enter").unwrap().1.token, + KeyToken::Named(NamedKey::Enter) + ); + assert_eq!( + parse_binding("ctrl+arrowleft").unwrap().1.token, + KeyToken::Named(NamedKey::ArrowLeft) + ); + assert_eq!( + parse_binding("f12").unwrap().1.token, + KeyToken::Named(NamedKey::F12) + ); +} + +#[test] +fn parse_punctuation_tokens() { + assert_eq!( + parse_binding("ctrl+,").unwrap().1.token, + KeyToken::Char(",".into()) + ); + assert_eq!( + parse_binding("cmd++").unwrap().1.token, + KeyToken::Char("+".into()) + ); + assert_eq!( + parse_binding("cmd+=").unwrap().1.token, + KeyToken::Char("=".into()) + ); +} + +#[test] +fn parse_mode_prefix() { + let (scope, _) = parse_binding("normal:g").unwrap(); + assert_eq!(scope, ModeClass::Normal); + let (scope, _) = parse_binding("visual:w").unwrap(); + assert_eq!(scope, ModeClass::Visual); +} + +#[test] +fn parse_chord_tail() { + let key = parse_binding("ctrl+w x").unwrap().1; + assert_eq!(key.mods, mods(true, false, false, false)); + assert_eq!(key.token, KeyToken::Char("w".into())); + let (tmods, ttoken) = key.chord_tail.expect("chord tail"); + assert_eq!(tmods, mods(false, false, false, false)); + assert_eq!(ttoken, KeyToken::Char("x".into())); +} + +#[test] +fn parse_chord_tail_preserves_uppercase() { + // Ctrl+W R must keep the uppercase tail (shift) distinct from Ctrl+W r. + let key = parse_binding("ctrl+w R").unwrap().1; + let (_tmods, ttoken) = key.chord_tail.unwrap(); + assert_eq!(ttoken, KeyToken::Char("R".into())); +} + +#[test] +fn parse_empty_trailing_plus_errors() { + assert!(parse_binding("ctrl+").is_err()); +} + +#[test] +fn parse_unknown_modifier_errors() { + assert!(parse_binding("hyper+v").is_err()); +} + +#[test] +fn parse_unknown_named_key_errors() { + assert!(parse_binding("ctrl+nope").is_err()); +} + +#[test] +fn parse_empty_errors() { + assert!(parse_binding("").is_err()); + assert!(parse_binding(" ").is_err()); +} + +// ── Action registry ────────────────────────────────────────────────────────── + +fn ctx(grid_rows: usize, mode: InputMode) -> DispatchCtx { + DispatchCtx { + grid_rows, + mode: InputModeKind::of(&mode), + } +} + +#[test] +fn registry_paste() { + assert!(matches!( + action_from_name("paste", ctx(24, InputMode::Insert)), + Some(Action::Paste) + )); +} + +#[test] +fn registry_new_tab() { + assert!(matches!( + action_from_name("new_tab", ctx(24, InputMode::Insert)), + Some(Action::NewTab) + )); +} + +#[test] +fn registry_scroll_page_up_uses_grid_rows() { + assert!(matches!( + action_from_name("scroll_page_up", ctx(40, InputMode::Insert)), + Some(Action::ScrollUp(40)) + )); +} + +#[test] +fn registry_go_to_tab_1_is_index_0() { + assert!(matches!( + action_from_name("go_to_tab_1", ctx(24, InputMode::Insert)), + Some(Action::GoToTab(0)) + )); + assert!(matches!( + action_from_name("go_to_tab_9", ctx(24, InputMode::Insert)), + Some(Action::GoToTab(8)) + )); +} + +#[test] +fn registry_cycle_mode_from_insert_is_normal() { + assert!(matches!( + action_from_name("cycle_mode", ctx(24, InputMode::Insert)), + Some(Action::SetMode(InputMode::Normal)) + )); +} + +#[test] +fn registry_cycle_mode_from_normal_is_visual() { + assert!(matches!( + action_from_name("cycle_mode", ctx(24, InputMode::Normal)), + Some(Action::SetMode(InputMode::Visual { + anchored: false, + .. + })) + )); +} + +#[test] +fn registry_cycle_mode_from_visual_is_insert() { + let visual = InputMode::Visual { + start_col: 0, + start_row: 0, + cur_col: 0, + cur_row: 0, + anchored: true, + }; + assert!(matches!( + action_from_name("cycle_mode", ctx(24, visual)), + Some(Action::SetMode(InputMode::Insert)) + )); +} + +#[test] +fn registry_enter_normal_mode() { + assert!(matches!( + action_from_name("enter_normal_mode", ctx(24, InputMode::Insert)), + Some(Action::SetMode(InputMode::Normal)) + )); +} + +#[test] +fn registry_unknown_returns_none() { + assert!(action_from_name("definitely_not_an_action", ctx(24, InputMode::Insert)).is_none()); +} + +#[test] +fn registry_none_keyword_is_not_an_action() { + // "none" is the reserved disable value handled by from_config, NOT a bindable action. + assert!(action_from_name("none", ctx(24, InputMode::Insert)).is_none()); +} + +#[test] +fn every_registry_name_is_interned() { + // Drift guard: `from_config` validates a binding's action via `action_from_name` + // then `intern_known_name(...).expect(...)` to obtain a &'static str. If a name is + // added to `action_from_name` but not to `intern_known_name`'s NAMES list, startup + // panics. Assert both agree for every canonical registry name. + let names = [ + "paste", + "copy", + "new_tab", + "close_tab", + "next_tab", + "prev_tab", + "move_tab_left", + "move_tab_right", + "rename_tab", + "go_to_tab_1", + "go_to_tab_2", + "go_to_tab_3", + "go_to_tab_4", + "go_to_tab_5", + "go_to_tab_6", + "go_to_tab_7", + "go_to_tab_8", + "go_to_tab_9", + "split_horizontal", + "split_vertical", + "auto_split", + "close_pane", + "focus_left", + "focus_right", + "focus_up", + "focus_down", + "focus_next", + "zoom_pane", + "rotate_panes_forward", + "rotate_panes_backward", + "resize_pane_right", + "resize_pane_left", + "resize_pane_down", + "resize_pane_up", + "scroll_page_up", + "scroll_page_down", + "scroll_to_top", + "scroll_to_bottom", + "clear_scrollback", + "search_open", + "search_next", + "search_prev", + "increase_font_size", + "decrease_font_size", + "reset_font_size", + "open_config", + "open_command_palette", + "toggle_fullscreen", + "toggle_log", + "toggle_passthrough", + "screenshot_open", + "quit", + "cycle_mode", + "enter_normal_mode", + "ctrl_w_prefix", + ]; + for n in names { + assert!( + action_from_name(n, ctx(24, InputMode::Insert)).is_some(), + "registry missing {n}" + ); + assert!( + intern_known_name(n).is_some(), + "intern_known_name missing {n}" + ); + } +} + +#[test] +fn name_of_action_roundtrips_paste() { + assert_eq!(name_of_action(&Action::Paste), Some("paste")); +} + +#[test] +fn name_of_action_internal_returns_none() { + assert_eq!(name_of_action(&Action::SendToPty(vec![1])), None); + assert_eq!(name_of_action(&Action::None), None); +} + +// ── KeyMap + default_keymap + lookup ────────────────────────────────────────── + +fn tok(c: &str) -> KeyToken { + KeyToken::Char(c.into()) +} + +#[test] +fn default_has_ctrl_q_quit() { + let km = default_keymap(); + let key = BindingKey { + mods: mods(true, false, false, false), + token: tok("q"), + chord_tail: None, + }; + assert_eq!(km.lookup(ModeClass::Global, &key), Some("quit")); +} + +#[test] +fn default_has_ctrl_shift_v_paste() { + let km = default_keymap(); + let key = BindingKey { + mods: mods(true, true, false, false), + token: tok("v"), + chord_tail: None, + }; + assert_eq!(km.lookup(ModeClass::Global, &key), Some("paste")); +} + +#[test] +fn default_has_cmd_v_paste() { + let km = default_keymap(); + let key = BindingKey { + mods: mods(false, false, false, true), + token: tok("v"), + chord_tail: None, + }; + assert_eq!(km.lookup(ModeClass::Global, &key), Some("paste")); +} + +#[test] +fn default_has_cmd_digit_go_to_tab() { + let km = default_keymap(); + let key = BindingKey { + mods: mods(false, false, false, true), + token: tok("3"), + chord_tail: None, + }; + assert_eq!(km.lookup(ModeClass::Global, &key), Some("go_to_tab_3")); +} + +#[test] +fn default_has_alt_digit_go_to_tab() { + let km = default_keymap(); + let key = BindingKey { + mods: mods(false, false, true, false), + token: tok("1"), + chord_tail: None, + }; + assert_eq!(km.lookup(ModeClass::Global, &key), Some("go_to_tab_1")); +} + +#[test] +fn default_has_ctrl_w_prefix_bare() { + let km = default_keymap(); + let key = BindingKey { + mods: mods(true, false, false, false), + token: tok("w"), + chord_tail: None, + }; + assert_eq!(km.lookup(ModeClass::Global, &key), Some("ctrl_w_prefix")); +} + +#[test] +fn default_has_ctrl_w_chord_split() { + let km = default_keymap(); + let key = BindingKey { + mods: mods(true, false, false, false), + token: tok("w"), + chord_tail: Some((mods(false, false, false, false), tok("v"))), + }; + assert_eq!(km.lookup(ModeClass::Global, &key), Some("split_horizontal")); +} + +#[test] +fn default_has_ctrl_w_chord_uppercase_r_backward() { + let km = default_keymap(); + let key = BindingKey { + mods: mods(true, false, false, false), + token: tok("w"), + chord_tail: Some((mods(false, false, false, false), KeyToken::Char("R".into()))), + }; + assert_eq!( + km.lookup(ModeClass::Global, &key), + Some("rotate_panes_backward") + ); +} + +#[test] +fn lookup_miss_returns_none() { + let km = default_keymap(); + let key = BindingKey { + mods: mods(false, false, false, false), + token: tok("a"), + chord_tail: None, + }; + assert_eq!(km.lookup(ModeClass::Global, &key), None); +} + +// ── token_from_key (runtime winit Key → KeyToken) ───────────────────────────── + +#[test] +fn token_from_key_lowercases_when_requested() { + let upper = winit::keyboard::Key::Character("V".into()); + assert_eq!( + token_from_key(&upper, true), + Some(KeyToken::Char("v".into())) + ); +} + +#[test] +fn token_from_key_preserves_case_for_chord_tail() { + let upper = winit::keyboard::Key::Character("R".into()); + assert_eq!( + token_from_key(&upper, false), + Some(KeyToken::Char("R".into())) + ); +} + +#[test] +fn token_from_key_named_key() { + let enter = winit::keyboard::Key::Named(NamedKey::Enter); + assert_eq!( + token_from_key(&enter, true), + Some(KeyToken::Named(NamedKey::Enter)) + ); +} + +#[test] +fn token_from_key_unmapped_returns_none() { + let dead = winit::keyboard::Key::Dead(None); + assert_eq!(token_from_key(&dead, true), None); +} + +// ── from_config overlay + validation ────────────────────────────────────────── + +use crate::config::KeybindingsConfig; +use std::collections::BTreeMap; + +fn kbc(pairs: &[(&str, &str)]) -> KeybindingsConfig { + let mut m = BTreeMap::new(); + for (k, v) in pairs { + m.insert((*k).to_string(), (*v).to_string()); + } + KeybindingsConfig(m) +} + +#[test] +fn from_config_empty_equals_defaults() { + let (km, errs) = KeyMap::from_config(&kbc(&[])); + assert!(errs.is_empty()); + assert_eq!(km.len(), default_keymap().len()); +} + +#[test] +fn from_config_adds_new_binding() { + let (km, errs) = KeyMap::from_config(&kbc(&[("ctrl+e", "new_tab")])); + assert!(errs.is_empty()); + let key = BindingKey { + mods: mods(true, false, false, false), + token: tok("e"), + chord_tail: None, + }; + assert_eq!(km.lookup(ModeClass::Global, &key), Some("new_tab")); +} + +#[test] +fn from_config_override_replaces_default() { + // Rebind cmd+v (default paste) to copy. + let (km, errs) = KeyMap::from_config(&kbc(&[("cmd+v", "copy")])); + assert!(errs.is_empty()); + let key = BindingKey { + mods: mods(false, false, false, true), + token: tok("v"), + chord_tail: None, + }; + assert_eq!(km.lookup(ModeClass::Global, &key), Some("copy")); +} + +#[test] +fn from_config_none_removes_default() { + let (km, errs) = KeyMap::from_config(&kbc(&[("cmd+k", "none")])); + assert!(errs.is_empty()); + let key = BindingKey { + mods: mods(false, false, false, true), + token: tok("k"), + chord_tail: None, + }; + assert_eq!(km.lookup(ModeClass::Global, &key), None); +} + +#[test] +fn from_config_chord_override() { + // Rebind the ctrl+w x slot (unused by default) to close_pane. + let (km, errs) = KeyMap::from_config(&kbc(&[("ctrl+w x", "close_pane")])); + assert!(errs.is_empty()); + let key = BindingKey { + mods: mods(true, false, false, false), + token: tok("w"), + chord_tail: Some((mods(false, false, false, false), tok("x"))), + }; + assert_eq!(km.lookup(ModeClass::Global, &key), Some("close_pane")); +} + +#[test] +fn from_config_parse_error_is_collected_and_skipped() { + let (km, errs) = KeyMap::from_config(&kbc(&[("ctrl+", "new_tab")])); + assert_eq!(errs.len(), 1); + assert!(matches!(errs[0], KeymapError::Parse { .. })); + // defaults untouched. + assert_eq!(km.len(), default_keymap().len()); +} + +#[test] +fn from_config_unknown_action_collected_and_skipped() { + let (km, errs) = KeyMap::from_config(&kbc(&[("ctrl+e", "bogus_action")])); + assert_eq!(errs.len(), 1); + assert!(matches!(errs[0], KeymapError::UnknownAction { .. })); + let key = BindingKey { + mods: mods(true, false, false, false), + token: tok("e"), + chord_tail: None, + }; + assert_eq!(km.lookup(ModeClass::Global, &key), None); +} + +#[test] +fn from_config_bare_char_global_is_shadows_input() { + let (km, errs) = KeyMap::from_config(&kbc(&[("e", "new_tab")])); + assert_eq!(errs.len(), 1); + assert!(matches!(errs[0], KeymapError::ShadowsInput { .. })); + let key = BindingKey { + mods: mods(false, false, false, false), + token: tok("e"), + chord_tail: None, + }; + assert_eq!(km.lookup(ModeClass::Global, &key), None); +} + +#[test] +fn from_config_bare_char_in_normal_scope_allowed() { + // normal: scope permits bare chars (no literal text in Normal mode). + let (_km, errs) = KeyMap::from_config(&kbc(&[("normal:g", "scroll_to_top")])); + assert!(errs.is_empty()); +} + +#[test] +fn from_config_bare_named_key_global_allowed() { + // A bare Named key (e.g. enter) does not shadow literal char typing. + let (_km, errs) = KeyMap::from_config(&kbc(&[("enter", "toggle_fullscreen")])); + assert!(errs.is_empty()); +} diff --git a/src/input/mod.rs b/src/input/mod.rs index 5697790..bc1f199 100644 --- a/src/input/mod.rs +++ b/src/input/mod.rs @@ -1,4 +1,5 @@ pub mod keybindings; +pub mod keymap; mod mode; pub mod motion; pub mod mouse; diff --git a/src/renderer/overlays_test.rs b/src/renderer/overlays_test.rs index a0370a3..64d2e37 100644 --- a/src/renderer/overlays_test.rs +++ b/src/renderer/overlays_test.rs @@ -1,6 +1,7 @@ use std::collections::HashSet; use super::*; +use crate::config::KeybindingsConfig; use crate::config::tui_config::{ConfigPanel, Field, FieldKind}; fn make_panel(value: &str, kind: FieldKind) -> ConfigPanel { @@ -17,6 +18,7 @@ fn make_panel(value: &str, kind: FieldKind) -> ConfigPanel { edit_buf: String::new(), status: None, collapsed: HashSet::new(), + keybindings: KeybindingsConfig::default(), } } @@ -298,6 +300,7 @@ fn make_section_panel(collapsed: bool) -> ConfigPanel { edit_buf: String::new(), status: None, collapsed: c, + keybindings: KeybindingsConfig::default(), } } diff --git a/src/renderer/render_ops.rs b/src/renderer/render_ops.rs index 38a020d..47950cd 100644 --- a/src/renderer/render_ops.rs +++ b/src/renderer/render_ops.rs @@ -179,11 +179,12 @@ impl App { let views = views::collect_pane_views(&self.state, &guards, w, h, tab_h, status_h); let draw_separators: &[[u32; 4]] = if zoomed { &[] } else { &separators }; - let right_text = statusbar::resolve( + let configured_right = statusbar::resolve( &self.state.config.status_bar.right, cwd_raw.as_deref(), &Local::now(), ); + let right_text = self.state.keymap_notice().or(configured_right); let bell_flash_intensity = bell_flash_intensity(self.state.tabs[self.state.active_tab].bell_flash_start); let pane_title_raw = pane_osc_title_raw.as_deref();