feat(input): add tmux keymap preset with one-shot prefix (#464)
Add workspace.keymap (zellij default, tmux) and workspace.prefix_mode. The tmux keymap replaces per-mode activation shortcuts with a single prefix (ctrl+space) followed by one action key. Modifier and dead-key presses keep the prefix waiting; an unbound key cancels it. Move hard-coded browser shortcuts (omnibox, reload, zoom, pane focus, session manager, ...) into workspace.shortcuts.actions defaults so they can be rebound or disabled, with a fallback for configs that predate them. Built-ins win shared keys in a stable order. config migrate adds the keymap and prefix_mode keys and the new shortcut actions while keeping user overrides.
- commit
- d625c0cbfd735cb8f348a9020a3f9a7ac3af7145
- author
- bnema · · 2026-10-07 06:54 UTC
- committer
- GitHub · · 2026-10-07 06:54 UTC
- parent
- e23543e
- signature
- GPG Unverified
B5690EEEBB952194The key is unknown
30 files · +772 -153
M README.md +1 -1
| @@ -90,7 +90,7 @@ For dependencies, distribution notes, and troubleshooting, see the [installation | |||
| 90 | 90 | ||
| 91 | 91 | ## Keyboard modes | |
| 92 | 92 | ||
| 93 | - | Dumber uses modal keybindings for browser management. | |
| 93 | + | Dumber uses modal keybindings for browser management. The default `zellij` keymap gives each mode its own key; set `workspace.keymap = "tmux"` to use a single prefix (`Ctrl+Space`) followed by one action key instead. | |
| 94 | 94 | ||
| 95 | 95 | | Mode | Default key | Used for | | |
| 96 | 96 | |------|-------------|----------| | |
| @@ -90,7 +90,7 @@ For dependencies, distribution notes, and troubleshooting, see the [installation | |||||
| 90 | 90 | ||||
| 91 | ## Keyboard modes | 91 | ## Keyboard modes | ||
| 92 | 92 | ||||
| 93 | - | Dumber uses modal keybindings for browser management. | 93 | + | Dumber uses modal keybindings for browser management. The default `zellij` keymap gives each mode its own key; set `workspace.keymap = "tmux"` to use a single prefix (`Ctrl+Space`) followed by one action key instead. |
| 94 | 94 | ||||
| 95 | | Mode | Default key | Used for | | 95 | | Mode | Default key | Used for | | ||
| 96 | |------|-------------|----------| | 96 | |------|-------------|----------| | ||
M docs/getting-started.md +1 -1
| @@ -10,7 +10,7 @@ Or use the desktop entry after running `dumber setup`. | |||
| 10 | 10 | ||
| 11 | 11 | ## Basic Navigation | |
| 12 | 12 | ||
| 13 | - | Dumber uses modal keybindings inspired by Zellij: | |
| 13 | + | Dumber uses modal keybindings inspired by Zellij. If you prefer tmux, set `keymap = "tmux"` under `[workspace]` to use one prefix key (`Ctrl+Space`) followed by a single action key; see [Keymap presets](./reference/keybindings.md#keymap-presets). | |
| 14 | 14 | ||
| 15 | 15 | | Mode | Activation | Purpose | | |
| 16 | 16 | |------|------------|---------| | |
| @@ -10,7 +10,7 @@ Or use the desktop entry after running `dumber setup`. | |||||
| 10 | 10 | ||||
| 11 | ## Basic Navigation | 11 | ## Basic Navigation | ||
| 12 | 12 | ||||
| 13 | - | Dumber uses modal keybindings inspired by Zellij: | 13 | + | Dumber uses modal keybindings inspired by Zellij. If you prefer tmux, set `keymap = "tmux"` under `[workspace]` to use one prefix key (`Ctrl+Space`) followed by a single action key; see [Keymap presets](./reference/keybindings.md#keymap-presets). |
| 14 | 14 | ||||
| 15 | | Mode | Activation | Purpose | | 15 | | Mode | Activation | Purpose | | ||
| 16 | |------|------------|---------| | 16 | |------|------------|---------| | ||
M docs/reference/configuration.md +5 -1
| @@ -98,6 +98,10 @@ | |||
| 98 | 98 | | `workspace.switch_to_tab_on_move` | bool | `true` | | | |
| 99 | 99 | | `workspace.tab_bar_position` | string | `bottom` | `top`, `bottom` | | |
| 100 | 100 | | `workspace.hide_tab_bar_when_single_tab` | bool | `true` | | | |
| 101 | + | | `workspace.keymap` | string | `zellij` | `zellij`, `tmux`; see [Keymap presets](keybindings.md#keymap-presets) | | |
| 102 | + | | `workspace.prefix_mode.activation_shortcut` | string | `ctrl+space` | prefix key for the `tmux` keymap | | |
| 103 | + | | `workspace.prefix_mode.timeout_ms` | int | `2000` | >= 0 | | |
| 104 | + | | `workspace.prefix_mode.actions.<action>` | object | see defaults | one-shot actions after the prefix | | |
| 101 | 105 | | `workspace.pane_mode.activation_shortcut` | string | `ctrl+p` | | | |
| 102 | 106 | | `workspace.pane_mode.timeout_ms` | int | `3000` | | | |
| 103 | 107 | | `workspace.pane_mode.actions.<action>` | []string | see defaults | pane mode key mappings | | |
| @@ -114,7 +118,7 @@ | |||
| 114 | 118 | | `workspace.resize_mode.actions.<action>` | []string | see defaults | resize mode key mappings | | |
| 115 | 119 | | `workspace.resize_mode.step_percent` | float | `5.0` | | | |
| 116 | 120 | | `workspace.resize_mode.min_pane_percent` | float | `10.0` | | | |
| 117 | - | | `workspace.shortcuts.actions` | map | see defaults | global shortcut action mappings | | |
| 121 | + | | `workspace.shortcuts.actions` | map | see defaults | global shortcut action mappings, including browser shortcuts (`open-omnibox`, `reload`, `focus-left`, ...); set `keys = []` to disable one | | |
| 118 | 122 | | `workspace.shortcuts.actions.toggle_floating_pane.keys` | []string | `alt+f` | key strings | | |
| 119 | 123 | | `workspace.shortcuts.actions.toggle_floating_pane.desc` | string | `Toggle floating pane` | | | |
| 120 | 124 | | `workspace.floating_pane.width_pct` | float | `0.82` | `(0,1]` | | |
| @@ -98,6 +98,10 @@ | |||||
| 98 | | `workspace.switch_to_tab_on_move` | bool | `true` | | | 98 | | `workspace.switch_to_tab_on_move` | bool | `true` | | | ||
| 99 | | `workspace.tab_bar_position` | string | `bottom` | `top`, `bottom` | | 99 | | `workspace.tab_bar_position` | string | `bottom` | `top`, `bottom` | | ||
| 100 | | `workspace.hide_tab_bar_when_single_tab` | bool | `true` | | | 100 | | `workspace.hide_tab_bar_when_single_tab` | bool | `true` | | | ||
| 101 | + | | `workspace.keymap` | string | `zellij` | `zellij`, `tmux`; see [Keymap presets](keybindings.md#keymap-presets) | | |||
| 102 | + | | `workspace.prefix_mode.activation_shortcut` | string | `ctrl+space` | prefix key for the `tmux` keymap | | |||
| 103 | + | | `workspace.prefix_mode.timeout_ms` | int | `2000` | >= 0 | | |||
| 104 | + | | `workspace.prefix_mode.actions.<action>` | object | see defaults | one-shot actions after the prefix | | |||
| 101 | | `workspace.pane_mode.activation_shortcut` | string | `ctrl+p` | | | 105 | | `workspace.pane_mode.activation_shortcut` | string | `ctrl+p` | | | ||
| 102 | | `workspace.pane_mode.timeout_ms` | int | `3000` | | | 106 | | `workspace.pane_mode.timeout_ms` | int | `3000` | | | ||
| 103 | | `workspace.pane_mode.actions.<action>` | []string | see defaults | pane mode key mappings | | 107 | | `workspace.pane_mode.actions.<action>` | []string | see defaults | pane mode key mappings | | ||
| @@ -114,7 +118,7 @@ | |||||
| 114 | | `workspace.resize_mode.actions.<action>` | []string | see defaults | resize mode key mappings | | 118 | | `workspace.resize_mode.actions.<action>` | []string | see defaults | resize mode key mappings | | ||
| 115 | | `workspace.resize_mode.step_percent` | float | `5.0` | | | 119 | | `workspace.resize_mode.step_percent` | float | `5.0` | | | ||
| 116 | | `workspace.resize_mode.min_pane_percent` | float | `10.0` | | | 120 | | `workspace.resize_mode.min_pane_percent` | float | `10.0` | | | ||
| 117 | - | | `workspace.shortcuts.actions` | map | see defaults | global shortcut action mappings | | 121 | + | | `workspace.shortcuts.actions` | map | see defaults | global shortcut action mappings, including browser shortcuts (`open-omnibox`, `reload`, `focus-left`, ...); set `keys = []` to disable one | |
| 118 | | `workspace.shortcuts.actions.toggle_floating_pane.keys` | []string | `alt+f` | key strings | | 122 | | `workspace.shortcuts.actions.toggle_floating_pane.keys` | []string | `alt+f` | key strings | | ||
| 119 | | `workspace.shortcuts.actions.toggle_floating_pane.desc` | string | `Toggle floating pane` | | | 123 | | `workspace.shortcuts.actions.toggle_floating_pane.desc` | string | `Toggle floating pane` | | | ||
| 120 | | `workspace.floating_pane.width_pct` | float | `0.82` | `(0,1]` | | 124 | | `workspace.floating_pane.width_pct` | float | `0.82` | `(0,1]` | | ||
M docs/reference/keybindings.md +53 -2
| @@ -1,6 +1,9 @@ | |||
| 1 | 1 | # Keybindings | |
| 2 | 2 | ||
| 3 | - | Dumber uses modal keybindings inspired by Zellij. Press a mode activation key, then use action keys within that mode. | |
| 3 | + | Dumber uses modal keybindings. Pick the activation style with `workspace.keymap`: | |
| 4 | + | ||
| 5 | + | - `zellij` (default): each mode has its own activation key, listed below. | |
| 6 | + | - `tmux`: one prefix key, then a single action key. See [Keymap presets](#keymap-presets). | |
| 4 | 7 | ||
| 5 | 8 | ## Mode Activation | |
| 6 | 9 | ||
| @@ -101,12 +104,60 @@ CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with th | |||
| 101 | 104 | | Confirm | `Enter` | | |
| 102 | 105 | | Cancel | `Escape` | | |
| 103 | 106 | ||
| 107 | + | ## Keymap Presets | |
| 108 | + | ||
| 109 | + | ```toml | |
| 110 | + | [workspace] | |
| 111 | + | keymap = "tmux" # or "zellij" (default) | |
| 112 | + | ``` | |
| 113 | + | ||
| 114 | + | With `keymap = "tmux"`, the Pane, Tab, Resize, and Session activation keys are unbound. Press the prefix (`Ctrl+Space` by default), then one action key. Dumber runs the action and returns to normal mode; an unbound key or `Escape` cancels the prefix. Vim Mode keeps `Ctrl+Y` in both presets. | |
| 115 | + | ||
| 116 | + | | After `Ctrl+Space` | Action | | |
| 117 | + | |--------------------|--------| | |
| 118 | + | | `%` | Split right | | |
| 119 | + | | `"` | Split down | | |
| 120 | + | | `x` | Close pane | | |
| 121 | + | | `Shift+S` | Stack pane | | |
| 122 | + | | `!` | Move pane to another tab | | |
| 123 | + | | `←` `→` `↑` `↓` | Focus pane | | |
| 124 | + | | `c` | New tab | | |
| 125 | + | | `&` | Close tab | | |
| 126 | + | | `n` / `p` | Next / previous tab | | |
| 127 | + | | `,` | Rename tab | | |
| 128 | + | | `s` | Session manager | | |
| 129 | + | | `r` | Enter Resize Mode | | |
| 130 | + | | `[` | Enter Vim Mode | | |
| 131 | + | ||
| 132 | + | Change the prefix or its actions under `[workspace.prefix_mode]`. Symbols such as `%` and `"` match whichever Shift state your layout needs, so write them without `shift+`. `Ctrl+Space` is often used to switch input methods; pick another prefix (for example `alt+a`) if your desktop already uses it. | |
| 133 | + | ||
| 134 | + | ```toml | |
| 135 | + | [workspace.prefix_mode] | |
| 136 | + | activation_shortcut = "alt+a" | |
| 137 | + | timeout_ms = 2000 | |
| 138 | + | ||
| 139 | + | [workspace.prefix_mode.actions.split-right] | |
| 140 | + | keys = ["%", "v"] | |
| 141 | + | ``` | |
| 142 | + | ||
| 104 | 143 | ## Global Shortcuts | |
| 105 | 144 | ||
| 106 | - | These work outside modal modes: | |
| 145 | + | These work outside modal modes in both keymaps. Every entry lives under `[workspace.shortcuts.actions]`, so you can rebind it or disable it with `keys = []`. | |
| 107 | 146 | ||
| 108 | 147 | | Action | Keys | | |
| 109 | 148 | |--------|------| | |
| 149 | + | | Open omnibox | `Ctrl+L` | | |
| 150 | + | | Find in page / next / previous | `Ctrl+F` / `F3`, `Ctrl+G` / `Shift+F3`, `Ctrl+Shift+G` | | |
| 151 | + | | Reload / hard reload | `Ctrl+R`, `F5` / `Ctrl+Shift+R`, `Ctrl+F5` | | |
| 152 | + | | Back / forward | `Ctrl+←` / `Ctrl+→` | | |
| 153 | + | | Zoom in / out / reset | `Ctrl++`, `Ctrl+=` / `Ctrl+-` / `Ctrl+0` | | |
| 154 | + | | Focus pane | `Alt+H/J/K/L`, `Alt+arrows` | | |
| 155 | + | | Copy URL | `Ctrl+Shift+C` | | |
| 156 | + | | Print | `Ctrl+Shift+P` | | |
| 157 | + | | Session manager | `Ctrl+Shift+S` | | |
| 158 | + | | Developer tools | `F12` | | |
| 159 | + | | Fullscreen | `F11` | | |
| 160 | + | | Quit | `Ctrl+Q` | | |
| 110 | 161 | | Toggle floating pane | `Alt+F` | | |
| 111 | 162 | | Toggle History sidebar (native GTK sidebar panel only). Ctrl+H may conflict with the browser's default History shortcut; behavior can vary by browser. | `Ctrl+H` | | |
| 112 | 163 | | Toggle Favorites sidebar (native GTK bookmarks panel) | `Ctrl+B` | | |
| @@ -1,6 +1,9 @@ | |||||
| 1 | # Keybindings | 1 | # Keybindings | ||
| 2 | 2 | ||||
| 3 | - | Dumber uses modal keybindings inspired by Zellij. Press a mode activation key, then use action keys within that mode. | 3 | + | Dumber uses modal keybindings. Pick the activation style with `workspace.keymap`: |
| 4 | + | ||||
| 5 | + | - `zellij` (default): each mode has its own activation key, listed below. | |||
| 6 | + | - `tmux`: one prefix key, then a single action key. See [Keymap presets](#keymap-presets). | |||
| 4 | 7 | ||||
| 5 | ## Mode Activation | 8 | ## Mode Activation | ||
| 6 | 9 | ||||
| @@ -101,12 +104,60 @@ CEF and WebKit execute Vim Mode scroll commands (`h/j/k/l`, `Shift+J/K`) with th | |||||
| 101 | | Confirm | `Enter` | | 104 | | Confirm | `Enter` | | ||
| 102 | | Cancel | `Escape` | | 105 | | Cancel | `Escape` | | ||
| 103 | 106 | ||||
| 107 | + | ## Keymap Presets | |||
| 108 | + | ||||
| 109 | + | ```toml | |||
| 110 | + | [workspace] | |||
| 111 | + | keymap = "tmux" # or "zellij" (default) | |||
| 112 | + | ``` | |||
| 113 | + | ||||
| 114 | + | With `keymap = "tmux"`, the Pane, Tab, Resize, and Session activation keys are unbound. Press the prefix (`Ctrl+Space` by default), then one action key. Dumber runs the action and returns to normal mode; an unbound key or `Escape` cancels the prefix. Vim Mode keeps `Ctrl+Y` in both presets. | |||
| 115 | + | ||||
| 116 | + | | After `Ctrl+Space` | Action | | |||
| 117 | + | |--------------------|--------| | |||
| 118 | + | | `%` | Split right | | |||
| 119 | + | | `"` | Split down | | |||
| 120 | + | | `x` | Close pane | | |||
| 121 | + | | `Shift+S` | Stack pane | | |||
| 122 | + | | `!` | Move pane to another tab | | |||
| 123 | + | | `←` `→` `↑` `↓` | Focus pane | | |||
| 124 | + | | `c` | New tab | | |||
| 125 | + | | `&` | Close tab | | |||
| 126 | + | | `n` / `p` | Next / previous tab | | |||
| 127 | + | | `,` | Rename tab | | |||
| 128 | + | | `s` | Session manager | | |||
| 129 | + | | `r` | Enter Resize Mode | | |||
| 130 | + | | `[` | Enter Vim Mode | | |||
| 131 | + | ||||
| 132 | + | Change the prefix or its actions under `[workspace.prefix_mode]`. Symbols such as `%` and `"` match whichever Shift state your layout needs, so write them without `shift+`. `Ctrl+Space` is often used to switch input methods; pick another prefix (for example `alt+a`) if your desktop already uses it. | |||
| 133 | + | ||||
| 134 | + | ```toml | |||
| 135 | + | [workspace.prefix_mode] | |||
| 136 | + | activation_shortcut = "alt+a" | |||
| 137 | + | timeout_ms = 2000 | |||
| 138 | + | ||||
| 139 | + | [workspace.prefix_mode.actions.split-right] | |||
| 140 | + | keys = ["%", "v"] | |||
| 141 | + | ``` | |||
| 142 | + | ||||
| 104 | ## Global Shortcuts | 143 | ## Global Shortcuts | ||
| 105 | 144 | ||||
| 106 | - | These work outside modal modes: | 145 | + | These work outside modal modes in both keymaps. Every entry lives under `[workspace.shortcuts.actions]`, so you can rebind it or disable it with `keys = []`. |
| 107 | 146 | ||||
| 108 | | Action | Keys | | 147 | | Action | Keys | | ||
| 109 | |--------|------| | 148 | |--------|------| | ||
| 149 | + | | Open omnibox | `Ctrl+L` | | |||
| 150 | + | | Find in page / next / previous | `Ctrl+F` / `F3`, `Ctrl+G` / `Shift+F3`, `Ctrl+Shift+G` | | |||
| 151 | + | | Reload / hard reload | `Ctrl+R`, `F5` / `Ctrl+Shift+R`, `Ctrl+F5` | | |||
| 152 | + | | Back / forward | `Ctrl+←` / `Ctrl+→` | | |||
| 153 | + | | Zoom in / out / reset | `Ctrl++`, `Ctrl+=` / `Ctrl+-` / `Ctrl+0` | | |||
| 154 | + | | Focus pane | `Alt+H/J/K/L`, `Alt+arrows` | | |||
| 155 | + | | Copy URL | `Ctrl+Shift+C` | | |||
| 156 | + | | Print | `Ctrl+Shift+P` | | |||
| 157 | + | | Session manager | `Ctrl+Shift+S` | | |||
| 158 | + | | Developer tools | `F12` | | |||
| 159 | + | | Fullscreen | `F11` | | |||
| 160 | + | | Quit | `Ctrl+Q` | | |||
| 110 | | Toggle floating pane | `Alt+F` | | 161 | | Toggle floating pane | `Alt+F` | | ||
| 111 | | Toggle History sidebar (native GTK sidebar panel only). Ctrl+H may conflict with the browser's default History shortcut; behavior can vary by browser. | `Ctrl+H` | | 162 | | Toggle History sidebar (native GTK sidebar panel only). Ctrl+H may conflict with the browser's default History shortcut; behavior can vary by browser. | `Ctrl+H` | | ||
| 112 | | Toggle Favorites sidebar (native GTK bookmarks panel) | `Ctrl+B` | | 163 | | Toggle Favorites sidebar (native GTK bookmarks panel) | `Ctrl+B` | | ||
M internal/application/usecase/keybindings.go +1 -0
| @@ -16,6 +16,7 @@ var _ port.AllKeybindingsResetter = (*ResetAllKeybindingsUseCase)(nil) | |||
| 16 | 16 | // validModes defines the valid keybinding modes. | |
| 17 | 17 | var validModes = map[string]bool{ | |
| 18 | 18 | "global": true, | |
| 19 | + | "prefix": true, | |
| 19 | 20 | "pane": true, | |
| 20 | 21 | "tab": true, | |
| 21 | 22 | "vim": true, | |
| @@ -16,6 +16,7 @@ var _ port.AllKeybindingsResetter = (*ResetAllKeybindingsUseCase)(nil) | |||||
| 16 | // validModes defines the valid keybinding modes. | 16 | // validModes defines the valid keybinding modes. | ||
| 17 | var validModes = map[string]bool{ | 17 | var validModes = map[string]bool{ | ||
| 18 | "global": true, | 18 | "global": true, | ||
| 19 | + | "prefix": true, | |||
| 19 | "pane": true, | 20 | "pane": true, | ||
| 20 | "tab": true, | 21 | "tab": true, | ||
| 21 | "vim": true, | 22 | "vim": true, | ||
M internal/bootstrap/runtime_config.go +1 -0
| @@ -139,6 +139,7 @@ func cloneRuntimeSearchShortcuts(in map[string]entity.RuntimeSearchShortcut) map | |||
| 139 | 139 | } | |
| 140 | 140 | ||
| 141 | 141 | func cloneWorkspaceConfig(in entity.WorkspaceConfig) entity.WorkspaceConfig { | |
| 142 | + | in.PrefixMode.Actions = cloneActionBindings(in.PrefixMode.Actions) | |
| 142 | 143 | in.PaneMode.Actions = cloneActionBindings(in.PaneMode.Actions) | |
| 143 | 144 | in.TabMode.Actions = cloneActionBindings(in.TabMode.Actions) | |
| 144 | 145 | in.VimMode.Actions = cloneActionBindings(in.VimMode.Actions) | |
| @@ -139,6 +139,7 @@ func cloneRuntimeSearchShortcuts(in map[string]entity.RuntimeSearchShortcut) map | |||||
| 139 | } | 139 | } | ||
| 140 | 140 | ||||
| 141 | func cloneWorkspaceConfig(in entity.WorkspaceConfig) entity.WorkspaceConfig { | 141 | func cloneWorkspaceConfig(in entity.WorkspaceConfig) entity.WorkspaceConfig { | ||
| 142 | + | in.PrefixMode.Actions = cloneActionBindings(in.PrefixMode.Actions) | |||
| 142 | in.PaneMode.Actions = cloneActionBindings(in.PaneMode.Actions) | 143 | in.PaneMode.Actions = cloneActionBindings(in.PaneMode.Actions) | ||
| 143 | in.TabMode.Actions = cloneActionBindings(in.TabMode.Actions) | 144 | in.TabMode.Actions = cloneActionBindings(in.TabMode.Actions) | ||
| 144 | in.VimMode.Actions = cloneActionBindings(in.VimMode.Actions) | 145 | in.VimMode.Actions = cloneActionBindings(in.VimMode.Actions) | ||
A internal/domain/entity/builtin_shortcuts.go +31 -0
| @@ -0,0 +1,31 @@ | |||
| 1 | + | package entity | |
| 2 | + | ||
| 3 | + | // BuiltinGlobalShortcuts returns the standard browser shortcuts shared by every | |
| 4 | + | // keymap preset. Config defaults include them so users can rebind or disable | |
| 5 | + | // them; the input layer also falls back to them for actions missing from an | |
| 6 | + | // older config file. An action present with empty keys stays disabled. | |
| 7 | + | func BuiltinGlobalShortcuts() map[string]ActionBinding { | |
| 8 | + | return map[string]ActionBinding{ | |
| 9 | + | "open-omnibox": {Keys: []string{"ctrl+l"}, Desc: "Open omnibox"}, | |
| 10 | + | "open-find": {Keys: []string{"ctrl+f"}, Desc: "Find in page"}, | |
| 11 | + | "find-next": {Keys: []string{"f3", "ctrl+g"}, Desc: "Find next match"}, | |
| 12 | + | "find-prev": {Keys: []string{"shift+f3", "ctrl+shift+g"}, Desc: "Find previous match"}, | |
| 13 | + | "reload": {Keys: []string{"ctrl+r", "f5"}, Desc: "Reload page"}, | |
| 14 | + | "hard-reload": {Keys: []string{"ctrl+shift+r", "ctrl+f5"}, Desc: "Reload page bypassing cache"}, | |
| 15 | + | "open-devtools": {Keys: []string{"f12"}, Desc: "Open developer tools"}, | |
| 16 | + | "go-back": {Keys: []string{"ctrl+arrowleft"}, Desc: "Go back"}, | |
| 17 | + | "go-forward": {Keys: []string{"ctrl+arrowright"}, Desc: "Go forward"}, | |
| 18 | + | "zoom-in": {Keys: []string{"ctrl+plus", "ctrl+equal"}, Desc: "Zoom in"}, | |
| 19 | + | "zoom-out": {Keys: []string{"ctrl+minus"}, Desc: "Zoom out"}, | |
| 20 | + | "zoom-reset": {Keys: []string{"ctrl+0"}, Desc: "Reset zoom"}, | |
| 21 | + | "quit": {Keys: []string{"ctrl+q"}, Desc: "Quit"}, | |
| 22 | + | "toggle-fullscreen": {Keys: []string{"f11"}, Desc: "Toggle fullscreen"}, | |
| 23 | + | "copy-url": {Keys: []string{"ctrl+shift+c"}, Desc: "Copy current URL"}, | |
| 24 | + | "print-page": {Keys: []string{"ctrl+shift+p"}, Desc: "Print page"}, | |
| 25 | + | "session-manager": {Keys: []string{"ctrl+shift+s"}, Desc: "Open session manager"}, | |
| 26 | + | "focus-left": {Keys: []string{"alt+h", "alt+arrowleft"}, Desc: "Focus pane to the left"}, | |
| 27 | + | "focus-right": {Keys: []string{"alt+l", "alt+arrowright"}, Desc: "Focus pane to the right"}, | |
| 28 | + | "focus-up": {Keys: []string{"alt+k", "alt+arrowup"}, Desc: "Focus pane above"}, | |
| 29 | + | "focus-down": {Keys: []string{"alt+j", "alt+arrowdown"}, Desc: "Focus pane below"}, | |
| 30 | + | } | |
| 31 | + | } | |
| @@ -0,0 +1,31 @@ | |||||
| 1 | + | package entity | |||
| 2 | + | ||||
| 3 | + | // BuiltinGlobalShortcuts returns the standard browser shortcuts shared by every | |||
| 4 | + | // keymap preset. Config defaults include them so users can rebind or disable | |||
| 5 | + | // them; the input layer also falls back to them for actions missing from an | |||
| 6 | + | // older config file. An action present with empty keys stays disabled. | |||
| 7 | + | func BuiltinGlobalShortcuts() map[string]ActionBinding { | |||
| 8 | + | return map[string]ActionBinding{ | |||
| 9 | + | "open-omnibox": {Keys: []string{"ctrl+l"}, Desc: "Open omnibox"}, | |||
| 10 | + | "open-find": {Keys: []string{"ctrl+f"}, Desc: "Find in page"}, | |||
| 11 | + | "find-next": {Keys: []string{"f3", "ctrl+g"}, Desc: "Find next match"}, | |||
| 12 | + | "find-prev": {Keys: []string{"shift+f3", "ctrl+shift+g"}, Desc: "Find previous match"}, | |||
| 13 | + | "reload": {Keys: []string{"ctrl+r", "f5"}, Desc: "Reload page"}, | |||
| 14 | + | "hard-reload": {Keys: []string{"ctrl+shift+r", "ctrl+f5"}, Desc: "Reload page bypassing cache"}, | |||
| 15 | + | "open-devtools": {Keys: []string{"f12"}, Desc: "Open developer tools"}, | |||
| 16 | + | "go-back": {Keys: []string{"ctrl+arrowleft"}, Desc: "Go back"}, | |||
| 17 | + | "go-forward": {Keys: []string{"ctrl+arrowright"}, Desc: "Go forward"}, | |||
| 18 | + | "zoom-in": {Keys: []string{"ctrl+plus", "ctrl+equal"}, Desc: "Zoom in"}, | |||
| 19 | + | "zoom-out": {Keys: []string{"ctrl+minus"}, Desc: "Zoom out"}, | |||
| 20 | + | "zoom-reset": {Keys: []string{"ctrl+0"}, Desc: "Reset zoom"}, | |||
| 21 | + | "quit": {Keys: []string{"ctrl+q"}, Desc: "Quit"}, | |||
| 22 | + | "toggle-fullscreen": {Keys: []string{"f11"}, Desc: "Toggle fullscreen"}, | |||
| 23 | + | "copy-url": {Keys: []string{"ctrl+shift+c"}, Desc: "Copy current URL"}, | |||
| 24 | + | "print-page": {Keys: []string{"ctrl+shift+p"}, Desc: "Print page"}, | |||
| 25 | + | "session-manager": {Keys: []string{"ctrl+shift+s"}, Desc: "Open session manager"}, | |||
| 26 | + | "focus-left": {Keys: []string{"alt+h", "alt+arrowleft"}, Desc: "Focus pane to the left"}, | |||
| 27 | + | "focus-right": {Keys: []string{"alt+l", "alt+arrowright"}, Desc: "Focus pane to the right"}, | |||
| 28 | + | "focus-up": {Keys: []string{"alt+k", "alt+arrowup"}, Desc: "Focus pane above"}, | |||
| 29 | + | "focus-down": {Keys: []string{"alt+j", "alt+arrowdown"}, Desc: "Focus pane below"}, | |||
| 30 | + | } | |||
| 31 | + | } | |||
M internal/domain/entity/config_types.go +24 -0
| @@ -78,6 +78,28 @@ func (t *TabModeConfig) GetKeyBindings() map[string]string { | |||
| 78 | 78 | return keyBindingsFromActions(t.Actions) | |
| 79 | 79 | } | |
| 80 | 80 | ||
| 81 | + | // KeymapPreset selects how workspace modes are activated. | |
| 82 | + | type KeymapPreset string | |
| 83 | + | ||
| 84 | + | const ( | |
| 85 | + | // KeymapZellij activates each mode with its own shortcut (Ctrl+P, Ctrl+T, ...). | |
| 86 | + | KeymapZellij KeymapPreset = "zellij" | |
| 87 | + | // KeymapTmux activates a single one-shot prefix key; mode shortcuts are unbound. | |
| 88 | + | KeymapTmux KeymapPreset = "tmux" | |
| 89 | + | ) | |
| 90 | + | ||
| 91 | + | // PrefixModeConfig holds the tmux-style one-shot prefix configuration. | |
| 92 | + | type PrefixModeConfig struct { | |
| 93 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | |
| 94 | + | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | |
| 95 | + | Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | |
| 96 | + | } | |
| 97 | + | ||
| 98 | + | // GetKeyBindings returns a map from key string to action name. | |
| 99 | + | func (p *PrefixModeConfig) GetKeyBindings() map[string]string { | |
| 100 | + | return keyBindingsFromActions(p.Actions) | |
| 101 | + | } | |
| 102 | + | ||
| 81 | 103 | // VimModeConfig holds vim scroll mode shortcut configuration. | |
| 82 | 104 | type VimModeConfig struct { | |
| 83 | 105 | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | |
| @@ -245,6 +267,8 @@ type ExternalLinksConfig struct { | |||
| 245 | 267 | // WorkspaceConfig holds all workspace layout and behavior settings. | |
| 246 | 268 | type WorkspaceConfig struct { | |
| 247 | 269 | NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"` | |
| 270 | + | Keymap KeymapPreset `mapstructure:"keymap" yaml:"keymap" toml:"keymap" json:"keymap"` | |
| 271 | + | PrefixMode PrefixModeConfig `mapstructure:"prefix_mode" yaml:"prefix_mode" toml:"prefix_mode" json:"prefix_mode"` | |
| 248 | 272 | PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"` | |
| 249 | 273 | TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"` | |
| 250 | 274 | VimMode VimModeConfig `mapstructure:"vim_mode" yaml:"vim_mode" toml:"vim_mode" json:"vim_mode"` | |
| @@ -78,6 +78,28 @@ func (t *TabModeConfig) GetKeyBindings() map[string]string { | |||||
| 78 | return keyBindingsFromActions(t.Actions) | 78 | return keyBindingsFromActions(t.Actions) | ||
| 79 | } | 79 | } | ||
| 80 | 80 | ||||
| 81 | + | // KeymapPreset selects how workspace modes are activated. | |||
| 82 | + | type KeymapPreset string | |||
| 83 | + | ||||
| 84 | + | const ( | |||
| 85 | + | // KeymapZellij activates each mode with its own shortcut (Ctrl+P, Ctrl+T, ...). | |||
| 86 | + | KeymapZellij KeymapPreset = "zellij" | |||
| 87 | + | // KeymapTmux activates a single one-shot prefix key; mode shortcuts are unbound. | |||
| 88 | + | KeymapTmux KeymapPreset = "tmux" | |||
| 89 | + | ) | |||
| 90 | + | ||||
| 91 | + | // PrefixModeConfig holds the tmux-style one-shot prefix configuration. | |||
| 92 | + | type PrefixModeConfig struct { | |||
| 93 | + | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | |||
| 94 | + | TimeoutMilliseconds int `mapstructure:"timeout_ms" yaml:"timeout_ms" toml:"timeout_ms" json:"timeout_ms"` | |||
| 95 | + | Actions map[string]ActionBinding `mapstructure:"actions" yaml:"actions" toml:"actions" json:"actions"` | |||
| 96 | + | } | |||
| 97 | + | ||||
| 98 | + | // GetKeyBindings returns a map from key string to action name. | |||
| 99 | + | func (p *PrefixModeConfig) GetKeyBindings() map[string]string { | |||
| 100 | + | return keyBindingsFromActions(p.Actions) | |||
| 101 | + | } | |||
| 102 | + | ||||
| 81 | // VimModeConfig holds vim scroll mode shortcut configuration. | 103 | // VimModeConfig holds vim scroll mode shortcut configuration. | ||
| 82 | type VimModeConfig struct { | 104 | type VimModeConfig struct { | ||
| 83 | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | 105 | ActivationShortcut string `mapstructure:"activation_shortcut" yaml:"activation_shortcut" toml:"activation_shortcut" json:"activation_shortcut"` //nolint:lll // struct tags must stay on one line | ||
| @@ -245,6 +267,8 @@ type ExternalLinksConfig struct { | |||||
| 245 | // WorkspaceConfig holds all workspace layout and behavior settings. | 267 | // WorkspaceConfig holds all workspace layout and behavior settings. | ||
| 246 | type WorkspaceConfig struct { | 268 | type WorkspaceConfig struct { | ||
| 247 | NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"` | 269 | NewPaneURL string `mapstructure:"new_pane_url" yaml:"new_pane_url" toml:"new_pane_url" json:"new_pane_url"` | ||
| 270 | + | Keymap KeymapPreset `mapstructure:"keymap" yaml:"keymap" toml:"keymap" json:"keymap"` | |||
| 271 | + | PrefixMode PrefixModeConfig `mapstructure:"prefix_mode" yaml:"prefix_mode" toml:"prefix_mode" json:"prefix_mode"` | |||
| 248 | PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"` | 272 | PaneMode PaneModeConfig `mapstructure:"pane_mode" yaml:"pane_mode" toml:"pane_mode" json:"pane_mode"` | ||
| 249 | TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"` | 273 | TabMode TabModeConfig `mapstructure:"tab_mode" yaml:"tab_mode" toml:"tab_mode" json:"tab_mode"` | ||
| 250 | VimMode VimModeConfig `mapstructure:"vim_mode" yaml:"vim_mode" toml:"vim_mode" json:"vim_mode"` | 274 | VimMode VimModeConfig `mapstructure:"vim_mode" yaml:"vim_mode" toml:"vim_mode" json:"vim_mode"` | ||
M internal/infrastructure/config/defaults.go +38 -2
| @@ -1,6 +1,7 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | 3 | import ( | |
| 4 | + | "maps" | |
| 4 | 5 | "os" | |
| 5 | 6 | "path/filepath" | |
| 6 | 7 | ||
| @@ -39,6 +40,9 @@ const ( | |||
| 39 | 40 | defaultOmniboxAutoOpenOnNewPane = false | |
| 40 | 41 | ||
| 41 | 42 | // Workspace defaults | |
| 43 | + | defaultKeymap = KeymapZellij | |
| 44 | + | defaultPrefixActivationShortcut = "ctrl+space" | |
| 45 | + | defaultPrefixTimeoutMilliseconds = 2000 | |
| 42 | 46 | defaultPaneActivationShortcut = "ctrl+p" | |
| 43 | 47 | defaultPaneTimeoutMilliseconds = 3000 | |
| 44 | 48 | defaultTabActivationShortcut = "ctrl+t" | |
| @@ -158,6 +162,13 @@ func getDefaultExternalThemeTemplatePath() string { | |||
| 158 | 162 | return filepath.Join(configDir, defaultExternalThemeTemplateFilename) | |
| 159 | 163 | } | |
| 160 | 164 | ||
| 165 | + | // withBuiltinGlobalShortcuts adds the preset-independent browser shortcuts | |
| 166 | + | // to the configured global shortcut defaults. | |
| 167 | + | func withBuiltinGlobalShortcuts(actions map[string]ActionBinding) map[string]ActionBinding { | |
| 168 | + | maps.Copy(actions, entity.BuiltinGlobalShortcuts()) | |
| 169 | + | return actions | |
| 170 | + | } | |
| 171 | + | ||
| 161 | 172 | // DefaultConfig returns the default configuration values for dumber. | |
| 162 | 173 | func DefaultConfig() *Config { | |
| 163 | 174 | browsingContextDefaults := defaultBrowsingContextConfig() | |
| @@ -271,6 +282,31 @@ func DefaultConfig() *Config { | |||
| 271 | 282 | Workspace: WorkspaceConfig{ | |
| 272 | 283 | NewPaneURL: defaultNewPaneURL, | |
| 273 | 284 | SwitchToTabOnMove: true, | |
| 285 | + | Keymap: defaultKeymap, | |
| 286 | + | PrefixMode: PrefixModeConfig{ | |
| 287 | + | ActivationShortcut: defaultPrefixActivationShortcut, | |
| 288 | + | TimeoutMilliseconds: defaultPrefixTimeoutMilliseconds, | |
| 289 | + | Actions: map[string]ActionBinding{ | |
| 290 | + | "split-right": {Keys: []string{"%"}, Desc: "Split pane to the right"}, | |
| 291 | + | "split-down": {Keys: []string{"\""}, Desc: "Split pane downward"}, | |
| 292 | + | "close-pane": {Keys: []string{"x"}, Desc: "Close current pane"}, | |
| 293 | + | "stack-pane": {Keys: []string{"shift+s"}, Desc: "Stack pane with sibling"}, | |
| 294 | + | "move-pane-to-tab": {Keys: []string{"!"}, Desc: "Move pane to different tab"}, | |
| 295 | + | "focus-left": {Keys: []string{"arrowleft"}, Desc: "Focus pane to the left"}, | |
| 296 | + | "focus-right": {Keys: []string{"arrowright"}, Desc: "Focus pane to the right"}, | |
| 297 | + | "focus-up": {Keys: []string{"arrowup"}, Desc: "Focus pane above"}, | |
| 298 | + | "focus-down": {Keys: []string{"arrowdown"}, Desc: "Focus pane below"}, | |
| 299 | + | "new-tab": {Keys: []string{"c"}, Desc: "Create new tab"}, | |
| 300 | + | "close-tab": {Keys: []string{"&"}, Desc: "Close current tab"}, | |
| 301 | + | "next-tab": {Keys: []string{"n"}, Desc: "Switch to next tab"}, | |
| 302 | + | "previous-tab": {Keys: []string{"p"}, Desc: "Switch to previous tab"}, | |
| 303 | + | "rename-tab": {Keys: []string{","}, Desc: "Rename current tab"}, | |
| 304 | + | "session-manager": {Keys: []string{"s"}, Desc: "Open session manager"}, | |
| 305 | + | "enter-resize-mode": {Keys: []string{"r"}, Desc: "Enter resize mode"}, | |
| 306 | + | "enter-vim-mode": {Keys: []string{"["}, Desc: "Enter Vim mode"}, | |
| 307 | + | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit prefix"}, | |
| 308 | + | }, | |
| 309 | + | }, | |
| 274 | 310 | PaneMode: PaneModeConfig{ | |
| 275 | 311 | ActivationShortcut: defaultPaneActivationShortcut, | |
| 276 | 312 | TimeoutMilliseconds: defaultPaneTimeoutMilliseconds, | |
| @@ -359,7 +395,7 @@ func DefaultConfig() *Config { | |||
| 359 | 395 | }, | |
| 360 | 396 | }, | |
| 361 | 397 | Shortcuts: GlobalShortcutsConfig{ | |
| 362 | - | Actions: map[string]ActionBinding{ | |
| 398 | + | Actions: withBuiltinGlobalShortcuts(map[string]ActionBinding{ | |
| 363 | 399 | "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"}, | |
| 364 | 400 | "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"}, | |
| 365 | 401 | "toggle-favorites-sidebar": {Keys: []string{"ctrl+b"}, Desc: "Toggle Favorites sidebar"}, | |
| @@ -372,7 +408,7 @@ func DefaultConfig() *Config { | |||
| 372 | 408 | "consume-or-expel-right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"}, | |
| 373 | 409 | "consume-or-expel-up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"}, | |
| 374 | 410 | "consume-or-expel-down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"}, | |
| 375 | - | }, | |
| 411 | + | }), | |
| 376 | 412 | }, | |
| 377 | 413 | FloatingPane: FloatingPaneConfig{ | |
| 378 | 414 | WidthPct: defaultFloatingPaneWidthPct, | |
| @@ -1,6 +1,7 @@ | |||||
| 1 | package config | 1 | package config | ||
| 2 | 2 | ||||
| 3 | import ( | 3 | import ( | ||
| 4 | + | "maps" | |||
| 4 | "os" | 5 | "os" | ||
| 5 | "path/filepath" | 6 | "path/filepath" | ||
| 6 | 7 | ||||
| @@ -39,6 +40,9 @@ const ( | |||||
| 39 | defaultOmniboxAutoOpenOnNewPane = false | 40 | defaultOmniboxAutoOpenOnNewPane = false | ||
| 40 | 41 | ||||
| 41 | // Workspace defaults | 42 | // Workspace defaults | ||
| 43 | + | defaultKeymap = KeymapZellij | |||
| 44 | + | defaultPrefixActivationShortcut = "ctrl+space" | |||
| 45 | + | defaultPrefixTimeoutMilliseconds = 2000 | |||
| 42 | defaultPaneActivationShortcut = "ctrl+p" | 46 | defaultPaneActivationShortcut = "ctrl+p" | ||
| 43 | defaultPaneTimeoutMilliseconds = 3000 | 47 | defaultPaneTimeoutMilliseconds = 3000 | ||
| 44 | defaultTabActivationShortcut = "ctrl+t" | 48 | defaultTabActivationShortcut = "ctrl+t" | ||
| @@ -158,6 +162,13 @@ func getDefaultExternalThemeTemplatePath() string { | |||||
| 158 | return filepath.Join(configDir, defaultExternalThemeTemplateFilename) | 162 | return filepath.Join(configDir, defaultExternalThemeTemplateFilename) | ||
| 159 | } | 163 | } | ||
| 160 | 164 | ||||
| 165 | + | // withBuiltinGlobalShortcuts adds the preset-independent browser shortcuts | |||
| 166 | + | // to the configured global shortcut defaults. | |||
| 167 | + | func withBuiltinGlobalShortcuts(actions map[string]ActionBinding) map[string]ActionBinding { | |||
| 168 | + | maps.Copy(actions, entity.BuiltinGlobalShortcuts()) | |||
| 169 | + | return actions | |||
| 170 | + | } | |||
| 171 | + | ||||
| 161 | // DefaultConfig returns the default configuration values for dumber. | 172 | // DefaultConfig returns the default configuration values for dumber. | ||
| 162 | func DefaultConfig() *Config { | 173 | func DefaultConfig() *Config { | ||
| 163 | browsingContextDefaults := defaultBrowsingContextConfig() | 174 | browsingContextDefaults := defaultBrowsingContextConfig() | ||
| @@ -271,6 +282,31 @@ func DefaultConfig() *Config { | |||||
| 271 | Workspace: WorkspaceConfig{ | 282 | Workspace: WorkspaceConfig{ | ||
| 272 | NewPaneURL: defaultNewPaneURL, | 283 | NewPaneURL: defaultNewPaneURL, | ||
| 273 | SwitchToTabOnMove: true, | 284 | SwitchToTabOnMove: true, | ||
| 285 | + | Keymap: defaultKeymap, | |||
| 286 | + | PrefixMode: PrefixModeConfig{ | |||
| 287 | + | ActivationShortcut: defaultPrefixActivationShortcut, | |||
| 288 | + | TimeoutMilliseconds: defaultPrefixTimeoutMilliseconds, | |||
| 289 | + | Actions: map[string]ActionBinding{ | |||
| 290 | + | "split-right": {Keys: []string{"%"}, Desc: "Split pane to the right"}, | |||
| 291 | + | "split-down": {Keys: []string{"\""}, Desc: "Split pane downward"}, | |||
| 292 | + | "close-pane": {Keys: []string{"x"}, Desc: "Close current pane"}, | |||
| 293 | + | "stack-pane": {Keys: []string{"shift+s"}, Desc: "Stack pane with sibling"}, | |||
| 294 | + | "move-pane-to-tab": {Keys: []string{"!"}, Desc: "Move pane to different tab"}, | |||
| 295 | + | "focus-left": {Keys: []string{"arrowleft"}, Desc: "Focus pane to the left"}, | |||
| 296 | + | "focus-right": {Keys: []string{"arrowright"}, Desc: "Focus pane to the right"}, | |||
| 297 | + | "focus-up": {Keys: []string{"arrowup"}, Desc: "Focus pane above"}, | |||
| 298 | + | "focus-down": {Keys: []string{"arrowdown"}, Desc: "Focus pane below"}, | |||
| 299 | + | "new-tab": {Keys: []string{"c"}, Desc: "Create new tab"}, | |||
| 300 | + | "close-tab": {Keys: []string{"&"}, Desc: "Close current tab"}, | |||
| 301 | + | "next-tab": {Keys: []string{"n"}, Desc: "Switch to next tab"}, | |||
| 302 | + | "previous-tab": {Keys: []string{"p"}, Desc: "Switch to previous tab"}, | |||
| 303 | + | "rename-tab": {Keys: []string{","}, Desc: "Rename current tab"}, | |||
| 304 | + | "session-manager": {Keys: []string{"s"}, Desc: "Open session manager"}, | |||
| 305 | + | "enter-resize-mode": {Keys: []string{"r"}, Desc: "Enter resize mode"}, | |||
| 306 | + | "enter-vim-mode": {Keys: []string{"["}, Desc: "Enter Vim mode"}, | |||
| 307 | + | "cancel": {Keys: []string{"escape"}, Desc: "Cancel/exit prefix"}, | |||
| 308 | + | }, | |||
| 309 | + | }, | |||
| 274 | PaneMode: PaneModeConfig{ | 310 | PaneMode: PaneModeConfig{ | ||
| 275 | ActivationShortcut: defaultPaneActivationShortcut, | 311 | ActivationShortcut: defaultPaneActivationShortcut, | ||
| 276 | TimeoutMilliseconds: defaultPaneTimeoutMilliseconds, | 312 | TimeoutMilliseconds: defaultPaneTimeoutMilliseconds, | ||
| @@ -359,7 +395,7 @@ func DefaultConfig() *Config { | |||||
| 359 | }, | 395 | }, | ||
| 360 | }, | 396 | }, | ||
| 361 | Shortcuts: GlobalShortcutsConfig{ | 397 | Shortcuts: GlobalShortcutsConfig{ | ||
| 362 | - | Actions: map[string]ActionBinding{ | 398 | + | Actions: withBuiltinGlobalShortcuts(map[string]ActionBinding{ |
| 363 | "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"}, | 399 | "toggle-floating-pane": {Keys: []string{"alt+f"}, Desc: "Toggle floating pane"}, | ||
| 364 | "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"}, | 400 | "toggle-history-systemview": {Keys: []string{"ctrl+h"}, Desc: "Toggle History in right split"}, | ||
| 365 | "toggle-favorites-sidebar": {Keys: []string{"ctrl+b"}, Desc: "Toggle Favorites sidebar"}, | 401 | "toggle-favorites-sidebar": {Keys: []string{"ctrl+b"}, Desc: "Toggle Favorites sidebar"}, | ||
| @@ -372,7 +408,7 @@ func DefaultConfig() *Config { | |||||
| 372 | "consume-or-expel-right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"}, | 408 | "consume-or-expel-right": {Keys: []string{"alt+]"}, Desc: "Consume/expel pane right"}, | ||
| 373 | "consume-or-expel-up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"}, | 409 | "consume-or-expel-up": {Keys: []string{"alt+{"}, Desc: "Consume/expel pane up"}, | ||
| 374 | "consume-or-expel-down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"}, | 410 | "consume-or-expel-down": {Keys: []string{"alt+}"}, Desc: "Consume/expel pane down"}, | ||
| 375 | - | }, | 411 | + | }), |
| 376 | }, | 412 | }, | ||
| 377 | FloatingPane: FloatingPaneConfig{ | 413 | FloatingPane: FloatingPaneConfig{ | ||
| 378 | WidthPct: defaultFloatingPaneWidthPct, | 414 | WidthPct: defaultFloatingPaneWidthPct, | ||
M internal/infrastructure/config/keybindings_gateway.go +27 -1
| @@ -9,7 +9,10 @@ import ( | |||
| 9 | 9 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 10 | ) | |
| 11 | 11 | ||
| 12 | - | const modeGlobal = "global" | |
| 12 | + | const ( | |
| 13 | + | modeGlobal = "global" | |
| 14 | + | modePrefix = "prefix" | |
| 15 | + | ) | |
| 13 | 16 | ||
| 14 | 17 | // KeybindingsGateway implements port.KeybindingsProvider and port.KeybindingsSaver. | |
| 15 | 18 | type KeybindingsGateway struct { | |
| @@ -30,6 +33,7 @@ func (g *KeybindingsGateway) GetKeybindings(ctx context.Context) (port.Keybindin | |||
| 30 | 33 | ||
| 31 | 34 | groups := []port.KeybindingGroup{ | |
| 32 | 35 | g.buildGlobalGroup(cfg, defaults), | |
| 36 | + | g.buildPrefixModeGroup(cfg, defaults), | |
| 33 | 37 | g.buildPaneModeGroup(cfg, defaults), | |
| 34 | 38 | g.buildTabModeGroup(cfg, defaults), | |
| 35 | 39 | g.buildVimModeGroup(cfg, defaults), | |
| @@ -47,6 +51,7 @@ func (g *KeybindingsGateway) GetDefaultKeybindings(ctx context.Context) (port.Ke | |||
| 47 | 51 | ||
| 48 | 52 | groups := []port.KeybindingGroup{ | |
| 49 | 53 | g.buildGlobalGroup(defaults, defaults), | |
| 54 | + | g.buildPrefixModeGroup(defaults, defaults), | |
| 50 | 55 | g.buildPaneModeGroup(defaults, defaults), | |
| 51 | 56 | g.buildTabModeGroup(defaults, defaults), | |
| 52 | 57 | g.buildVimModeGroup(defaults, defaults), | |
| @@ -108,6 +113,7 @@ func (g *KeybindingsGateway) ResetAllKeybindings(ctx context.Context) error { | |||
| 108 | 113 | cfg := g.mgr.Get() | |
| 109 | 114 | defaults := DefaultConfig() | |
| 110 | 115 | ||
| 116 | + | cfg.Workspace.PrefixMode.Actions = defaults.Workspace.PrefixMode.Actions | |
| 111 | 117 | cfg.Workspace.PaneMode.Actions = defaults.Workspace.PaneMode.Actions | |
| 112 | 118 | cfg.Workspace.TabMode.Actions = defaults.Workspace.TabMode.Actions | |
| 113 | 119 | cfg.Workspace.VimMode.Actions = defaults.Workspace.VimMode.Actions | |
| @@ -127,6 +133,16 @@ func (g *KeybindingsGateway) buildGlobalGroup(cfg, defaults *Config) port.Keybin | |||
| 127 | 133 | } | |
| 128 | 134 | } | |
| 129 | 135 | ||
| 136 | + | // buildPrefixModeGroup builds the tmux-style prefix group. | |
| 137 | + | func (g *KeybindingsGateway) buildPrefixModeGroup(cfg, defaults *Config) port.KeybindingGroup { | |
| 138 | + | return port.KeybindingGroup{ | |
| 139 | + | Mode: modePrefix, | |
| 140 | + | DisplayName: "Prefix (tmux keymap)", | |
| 141 | + | Bindings: g.buildModeBindings(cfg.Workspace.PrefixMode.Actions, defaults.Workspace.PrefixMode.Actions), | |
| 142 | + | Activation: cfg.Workspace.PrefixMode.ActivationShortcut, | |
| 143 | + | } | |
| 144 | + | } | |
| 145 | + | ||
| 130 | 146 | // buildPaneModeGroup builds the pane mode group. | |
| 131 | 147 | func (g *KeybindingsGateway) buildPaneModeGroup(cfg, defaults *Config) port.KeybindingGroup { | |
| 132 | 148 | return port.KeybindingGroup{ | |
| @@ -211,6 +227,11 @@ func (*KeybindingsGateway) updateKeybinding(cfg *Config, req port.SetKeybindingR | |||
| 211 | 227 | existing.Keys = req.Keys | |
| 212 | 228 | cfg.Workspace.Shortcuts.Actions[req.Action] = existing | |
| 213 | 229 | } | |
| 230 | + | case modePrefix: | |
| 231 | + | if existing, ok := cfg.Workspace.PrefixMode.Actions[req.Action]; ok { | |
| 232 | + | existing.Keys = req.Keys | |
| 233 | + | cfg.Workspace.PrefixMode.Actions[req.Action] = existing | |
| 234 | + | } | |
| 214 | 235 | case "pane": | |
| 215 | 236 | if existing, ok := cfg.Workspace.PaneMode.Actions[req.Action]; ok { | |
| 216 | 237 | existing.Keys = req.Keys | |
| @@ -246,6 +267,10 @@ func (*KeybindingsGateway) getDefaultKeys(defaults *Config, mode, action string) | |||
| 246 | 267 | if binding, ok := defaults.Workspace.Shortcuts.Actions[action]; ok { | |
| 247 | 268 | return binding.Keys | |
| 248 | 269 | } | |
| 270 | + | case modePrefix: | |
| 271 | + | if binding, ok := defaults.Workspace.PrefixMode.Actions[action]; ok { | |
| 272 | + | return binding.Keys | |
| 273 | + | } | |
| 249 | 274 | case "pane": | |
| 250 | 275 | if binding, ok := defaults.Workspace.PaneMode.Actions[action]; ok { | |
| 251 | 276 | return binding.Keys | |
| @@ -289,6 +314,7 @@ func (*KeybindingsGateway) checkConflicts(cfg *Config, targetMode, targetAction | |||
| 289 | 314 | } | |
| 290 | 315 | ||
| 291 | 316 | addBindings(modeGlobal, cfg.Workspace.Shortcuts.Actions) | |
| 317 | + | addBindings(modePrefix, cfg.Workspace.PrefixMode.Actions) | |
| 292 | 318 | addBindings("pane", cfg.Workspace.PaneMode.Actions) | |
| 293 | 319 | addBindings("tab", cfg.Workspace.TabMode.Actions) | |
| 294 | 320 | addBindings("vim", cfg.Workspace.VimMode.Actions) | |
| @@ -9,7 +9,10 @@ import ( | |||||
| 9 | "github.com/bnema/dumber/internal/logging" | 9 | "github.com/bnema/dumber/internal/logging" | ||
| 10 | ) | 10 | ) | ||
| 11 | 11 | ||||
| 12 | - | const modeGlobal = "global" | 12 | + | const ( |
| 13 | + | modeGlobal = "global" | |||
| 14 | + | modePrefix = "prefix" | |||
| 15 | + | ) | |||
| 13 | 16 | ||||
| 14 | // KeybindingsGateway implements port.KeybindingsProvider and port.KeybindingsSaver. | 17 | // KeybindingsGateway implements port.KeybindingsProvider and port.KeybindingsSaver. | ||
| 15 | type KeybindingsGateway struct { | 18 | type KeybindingsGateway struct { | ||
| @@ -30,6 +33,7 @@ func (g *KeybindingsGateway) GetKeybindings(ctx context.Context) (port.Keybindin | |||||
| 30 | 33 | ||||
| 31 | groups := []port.KeybindingGroup{ | 34 | groups := []port.KeybindingGroup{ | ||
| 32 | g.buildGlobalGroup(cfg, defaults), | 35 | g.buildGlobalGroup(cfg, defaults), | ||
| 36 | + | g.buildPrefixModeGroup(cfg, defaults), | |||
| 33 | g.buildPaneModeGroup(cfg, defaults), | 37 | g.buildPaneModeGroup(cfg, defaults), | ||
| 34 | g.buildTabModeGroup(cfg, defaults), | 38 | g.buildTabModeGroup(cfg, defaults), | ||
| 35 | g.buildVimModeGroup(cfg, defaults), | 39 | g.buildVimModeGroup(cfg, defaults), | ||
| @@ -47,6 +51,7 @@ func (g *KeybindingsGateway) GetDefaultKeybindings(ctx context.Context) (port.Ke | |||||
| 47 | 51 | ||||
| 48 | groups := []port.KeybindingGroup{ | 52 | groups := []port.KeybindingGroup{ | ||
| 49 | g.buildGlobalGroup(defaults, defaults), | 53 | g.buildGlobalGroup(defaults, defaults), | ||
| 54 | + | g.buildPrefixModeGroup(defaults, defaults), | |||
| 50 | g.buildPaneModeGroup(defaults, defaults), | 55 | g.buildPaneModeGroup(defaults, defaults), | ||
| 51 | g.buildTabModeGroup(defaults, defaults), | 56 | g.buildTabModeGroup(defaults, defaults), | ||
| 52 | g.buildVimModeGroup(defaults, defaults), | 57 | g.buildVimModeGroup(defaults, defaults), | ||
| @@ -108,6 +113,7 @@ func (g *KeybindingsGateway) ResetAllKeybindings(ctx context.Context) error { | |||||
| 108 | cfg := g.mgr.Get() | 113 | cfg := g.mgr.Get() | ||
| 109 | defaults := DefaultConfig() | 114 | defaults := DefaultConfig() | ||
| 110 | 115 | ||||
| 116 | + | cfg.Workspace.PrefixMode.Actions = defaults.Workspace.PrefixMode.Actions | |||
| 111 | cfg.Workspace.PaneMode.Actions = defaults.Workspace.PaneMode.Actions | 117 | cfg.Workspace.PaneMode.Actions = defaults.Workspace.PaneMode.Actions | ||
| 112 | cfg.Workspace.TabMode.Actions = defaults.Workspace.TabMode.Actions | 118 | cfg.Workspace.TabMode.Actions = defaults.Workspace.TabMode.Actions | ||
| 113 | cfg.Workspace.VimMode.Actions = defaults.Workspace.VimMode.Actions | 119 | cfg.Workspace.VimMode.Actions = defaults.Workspace.VimMode.Actions | ||
| @@ -127,6 +133,16 @@ func (g *KeybindingsGateway) buildGlobalGroup(cfg, defaults *Config) port.Keybin | |||||
| 127 | } | 133 | } | ||
| 128 | } | 134 | } | ||
| 129 | 135 | ||||
| 136 | + | // buildPrefixModeGroup builds the tmux-style prefix group. | |||
| 137 | + | func (g *KeybindingsGateway) buildPrefixModeGroup(cfg, defaults *Config) port.KeybindingGroup { | |||
| 138 | + | return port.KeybindingGroup{ | |||
| 139 | + | Mode: modePrefix, | |||
| 140 | + | DisplayName: "Prefix (tmux keymap)", | |||
| 141 | + | Bindings: g.buildModeBindings(cfg.Workspace.PrefixMode.Actions, defaults.Workspace.PrefixMode.Actions), | |||
| 142 | + | Activation: cfg.Workspace.PrefixMode.ActivationShortcut, | |||
| 143 | + | } | |||
| 144 | + | } | |||
| 145 | + | ||||
| 130 | // buildPaneModeGroup builds the pane mode group. | 146 | // buildPaneModeGroup builds the pane mode group. | ||
| 131 | func (g *KeybindingsGateway) buildPaneModeGroup(cfg, defaults *Config) port.KeybindingGroup { | 147 | func (g *KeybindingsGateway) buildPaneModeGroup(cfg, defaults *Config) port.KeybindingGroup { | ||
| 132 | return port.KeybindingGroup{ | 148 | return port.KeybindingGroup{ | ||
| @@ -211,6 +227,11 @@ func (*KeybindingsGateway) updateKeybinding(cfg *Config, req port.SetKeybindingR | |||||
| 211 | existing.Keys = req.Keys | 227 | existing.Keys = req.Keys | ||
| 212 | cfg.Workspace.Shortcuts.Actions[req.Action] = existing | 228 | cfg.Workspace.Shortcuts.Actions[req.Action] = existing | ||
| 213 | } | 229 | } | ||
| 230 | + | case modePrefix: | |||
| 231 | + | if existing, ok := cfg.Workspace.PrefixMode.Actions[req.Action]; ok { | |||
| 232 | + | existing.Keys = req.Keys | |||
| 233 | + | cfg.Workspace.PrefixMode.Actions[req.Action] = existing | |||
| 234 | + | } | |||
| 214 | case "pane": | 235 | case "pane": | ||
| 215 | if existing, ok := cfg.Workspace.PaneMode.Actions[req.Action]; ok { | 236 | if existing, ok := cfg.Workspace.PaneMode.Actions[req.Action]; ok { | ||
| 216 | existing.Keys = req.Keys | 237 | existing.Keys = req.Keys | ||
| @@ -246,6 +267,10 @@ func (*KeybindingsGateway) getDefaultKeys(defaults *Config, mode, action string) | |||||
| 246 | if binding, ok := defaults.Workspace.Shortcuts.Actions[action]; ok { | 267 | if binding, ok := defaults.Workspace.Shortcuts.Actions[action]; ok { | ||
| 247 | return binding.Keys | 268 | return binding.Keys | ||
| 248 | } | 269 | } | ||
| 270 | + | case modePrefix: | |||
| 271 | + | if binding, ok := defaults.Workspace.PrefixMode.Actions[action]; ok { | |||
| 272 | + | return binding.Keys | |||
| 273 | + | } | |||
| 249 | case "pane": | 274 | case "pane": | ||
| 250 | if binding, ok := defaults.Workspace.PaneMode.Actions[action]; ok { | 275 | if binding, ok := defaults.Workspace.PaneMode.Actions[action]; ok { | ||
| 251 | return binding.Keys | 276 | return binding.Keys | ||
| @@ -289,6 +314,7 @@ func (*KeybindingsGateway) checkConflicts(cfg *Config, targetMode, targetAction | |||||
| 289 | } | 314 | } | ||
| 290 | 315 | ||||
| 291 | addBindings(modeGlobal, cfg.Workspace.Shortcuts.Actions) | 316 | addBindings(modeGlobal, cfg.Workspace.Shortcuts.Actions) | ||
| 317 | + | addBindings(modePrefix, cfg.Workspace.PrefixMode.Actions) | |||
| 292 | addBindings("pane", cfg.Workspace.PaneMode.Actions) | 318 | addBindings("pane", cfg.Workspace.PaneMode.Actions) | ||
| 293 | addBindings("tab", cfg.Workspace.TabMode.Actions) | 319 | addBindings("tab", cfg.Workspace.TabMode.Actions) | ||
| 294 | addBindings("vim", cfg.Workspace.VimMode.Actions) | 320 | addBindings("vim", cfg.Workspace.VimMode.Actions) | ||
M internal/infrastructure/config/loader.go +8 -0
| @@ -698,8 +698,16 @@ func (m *Manager) setZoomAndScaleDefaults(defaults *Config) { | |||
| 698 | 698 | m.viper.SetDefault("sidebar_width", defaults.SidebarWidth) | |
| 699 | 699 | } | |
| 700 | 700 | ||
| 701 | + | func (m *Manager) setKeymapDefaults(defaults *Config) { | |
| 702 | + | m.viper.SetDefault("workspace.keymap", string(defaults.Workspace.Keymap)) | |
| 703 | + | m.viper.SetDefault("workspace.prefix_mode.activation_shortcut", defaults.Workspace.PrefixMode.ActivationShortcut) | |
| 704 | + | m.viper.SetDefault("workspace.prefix_mode.timeout_ms", defaults.Workspace.PrefixMode.TimeoutMilliseconds) | |
| 705 | + | m.viper.SetDefault("workspace.prefix_mode.actions", defaults.Workspace.PrefixMode.Actions) | |
| 706 | + | } | |
| 707 | + | ||
| 701 | 708 | func (m *Manager) setWorkspaceDefaults(defaults *Config) { | |
| 702 | 709 | m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL) | |
| 710 | + | m.setKeymapDefaults(defaults) | |
| 703 | 711 | m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut) | |
| 704 | 712 | m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds) | |
| 705 | 713 | m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions) | |
| @@ -698,8 +698,16 @@ func (m *Manager) setZoomAndScaleDefaults(defaults *Config) { | |||||
| 698 | m.viper.SetDefault("sidebar_width", defaults.SidebarWidth) | 698 | m.viper.SetDefault("sidebar_width", defaults.SidebarWidth) | ||
| 699 | } | 699 | } | ||
| 700 | 700 | ||||
| 701 | + | func (m *Manager) setKeymapDefaults(defaults *Config) { | |||
| 702 | + | m.viper.SetDefault("workspace.keymap", string(defaults.Workspace.Keymap)) | |||
| 703 | + | m.viper.SetDefault("workspace.prefix_mode.activation_shortcut", defaults.Workspace.PrefixMode.ActivationShortcut) | |||
| 704 | + | m.viper.SetDefault("workspace.prefix_mode.timeout_ms", defaults.Workspace.PrefixMode.TimeoutMilliseconds) | |||
| 705 | + | m.viper.SetDefault("workspace.prefix_mode.actions", defaults.Workspace.PrefixMode.Actions) | |||
| 706 | + | } | |||
| 707 | + | ||||
| 701 | func (m *Manager) setWorkspaceDefaults(defaults *Config) { | 708 | func (m *Manager) setWorkspaceDefaults(defaults *Config) { | ||
| 702 | m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL) | 709 | m.viper.SetDefault("workspace.new_pane_url", defaults.Workspace.NewPaneURL) | ||
| 710 | + | m.setKeymapDefaults(defaults) | |||
| 703 | m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut) | 711 | m.viper.SetDefault("workspace.pane_mode.activation_shortcut", defaults.Workspace.PaneMode.ActivationShortcut) | ||
| 704 | m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds) | 712 | m.viper.SetDefault("workspace.pane_mode.timeout_ms", defaults.Workspace.PaneMode.TimeoutMilliseconds) | ||
| 705 | m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions) | 713 | m.viper.SetDefault("workspace.pane_mode.actions", defaults.Workspace.PaneMode.Actions) | ||
M internal/infrastructure/config/migrate.go +1 -0
| @@ -302,6 +302,7 @@ type defaultActionMap struct { | |||
| 302 | 302 | func (m *Migrator) defaultActionMaps() []defaultActionMap { | |
| 303 | 303 | return []defaultActionMap{ | |
| 304 | 304 | {key: "workspace.shortcuts.actions", actions: m.defaultConfig.Workspace.Shortcuts.Actions}, | |
| 305 | + | {key: "workspace.prefix_mode.actions", actions: m.defaultConfig.Workspace.PrefixMode.Actions}, | |
| 305 | 306 | {key: "workspace.pane_mode.actions", actions: m.defaultConfig.Workspace.PaneMode.Actions}, | |
| 306 | 307 | {key: "workspace.tab_mode.actions", actions: m.defaultConfig.Workspace.TabMode.Actions}, | |
| 307 | 308 | {key: "workspace.vim_mode.actions", actions: m.defaultConfig.Workspace.VimMode.Actions}, | |
| @@ -302,6 +302,7 @@ type defaultActionMap struct { | |||||
| 302 | func (m *Migrator) defaultActionMaps() []defaultActionMap { | 302 | func (m *Migrator) defaultActionMaps() []defaultActionMap { | ||
| 303 | return []defaultActionMap{ | 303 | return []defaultActionMap{ | ||
| 304 | {key: "workspace.shortcuts.actions", actions: m.defaultConfig.Workspace.Shortcuts.Actions}, | 304 | {key: "workspace.shortcuts.actions", actions: m.defaultConfig.Workspace.Shortcuts.Actions}, | ||
| 305 | + | {key: "workspace.prefix_mode.actions", actions: m.defaultConfig.Workspace.PrefixMode.Actions}, | |||
| 305 | {key: "workspace.pane_mode.actions", actions: m.defaultConfig.Workspace.PaneMode.Actions}, | 306 | {key: "workspace.pane_mode.actions", actions: m.defaultConfig.Workspace.PaneMode.Actions}, | ||
| 306 | {key: "workspace.tab_mode.actions", actions: m.defaultConfig.Workspace.TabMode.Actions}, | 307 | {key: "workspace.tab_mode.actions", actions: m.defaultConfig.Workspace.TabMode.Actions}, | ||
| 307 | {key: "workspace.vim_mode.actions", actions: m.defaultConfig.Workspace.VimMode.Actions}, | 308 | {key: "workspace.vim_mode.actions", actions: m.defaultConfig.Workspace.VimMode.Actions}, | ||
M internal/infrastructure/config/migrate_test.go +50 -0
| @@ -1617,3 +1617,53 @@ func TestMigrator_Migrate_KeepsNewOmniboxMaxHistoryDaysOverLegacyKey(t *testing. | |||
| 1617 | 1617 | assert.Contains(t, string(migrated), "max_history_days = 7") | |
| 1618 | 1618 | assert.NotContains(t, string(migrated), "most_visited_days") | |
| 1619 | 1619 | } | |
| 1620 | + | ||
| 1621 | + | func TestMigrator_Migrate_AddsKeymapPresetAndBuiltinShortcuts(t *testing.T) { | |
| 1622 | + | home := t.TempDir() | |
| 1623 | + | t.Setenv("HOME", home) | |
| 1624 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) | |
| 1625 | + | t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) | |
| 1626 | + | t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) | |
| 1627 | + | t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) | |
| 1628 | + | ||
| 1629 | + | configFile, err := GetConfigFile() | |
| 1630 | + | require.NoError(t, err) | |
| 1631 | + | require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755)) | |
| 1632 | + | // A config written before keymap presets: no keymap, no prefix_mode, and a | |
| 1633 | + | // shortcuts table without the configurable browser actions. | |
| 1634 | + | require.NoError(t, os.WriteFile(configFile, []byte(` | |
| 1635 | + | [workspace.shortcuts.actions.quit] | |
| 1636 | + | keys = ["ctrl+shift+q"] | |
| 1637 | + | desc = "Custom quit" | |
| 1638 | + | ||
| 1639 | + | [workspace.shortcuts.actions.toggle-floating-pane] | |
| 1640 | + | keys = ["alt+f"] | |
| 1641 | + | desc = "Toggle floating pane" | |
| 1642 | + | `), 0o644)) | |
| 1643 | + | ||
| 1644 | + | m := NewMigrator() | |
| 1645 | + | result, err := m.CheckMigration() | |
| 1646 | + | require.NoError(t, err) | |
| 1647 | + | require.NotNil(t, result) | |
| 1648 | + | assert.Contains(t, result.MissingKeys, "workspace.keymap") | |
| 1649 | + | assert.Contains(t, result.MissingKeys, "workspace.prefix_mode.activation_shortcut") | |
| 1650 | + | assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.open-omnibox") | |
| 1651 | + | ||
| 1652 | + | _, err = m.Migrate() | |
| 1653 | + | require.NoError(t, err) | |
| 1654 | + | ||
| 1655 | + | mgr, err := NewManager() | |
| 1656 | + | require.NoError(t, err) | |
| 1657 | + | require.NoError(t, mgr.Load()) | |
| 1658 | + | cfg := mgr.Get() | |
| 1659 | + | ||
| 1660 | + | assert.Equal(t, KeymapZellij, cfg.Workspace.Keymap) | |
| 1661 | + | assert.Equal(t, defaultPrefixActivationShortcut, cfg.Workspace.PrefixMode.ActivationShortcut) | |
| 1662 | + | assert.Equal(t, DefaultConfig().Workspace.PrefixMode.Actions, cfg.Workspace.PrefixMode.Actions) | |
| 1663 | + | assert.Equal(t, []string{"ctrl+l"}, cfg.Workspace.Shortcuts.Actions["open-omnibox"].Keys) | |
| 1664 | + | assert.Equal(t, []string{"ctrl+shift+q"}, cfg.Workspace.Shortcuts.Actions["quit"].Keys, "user override kept") | |
| 1665 | + | ||
| 1666 | + | again, err := m.CheckMigration() | |
| 1667 | + | require.NoError(t, err) | |
| 1668 | + | assert.Nil(t, again, "migration is idempotent") | |
| 1669 | + | } | |
| @@ -1617,3 +1617,53 @@ func TestMigrator_Migrate_KeepsNewOmniboxMaxHistoryDaysOverLegacyKey(t *testing. | |||||
| 1617 | assert.Contains(t, string(migrated), "max_history_days = 7") | 1617 | assert.Contains(t, string(migrated), "max_history_days = 7") | ||
| 1618 | assert.NotContains(t, string(migrated), "most_visited_days") | 1618 | assert.NotContains(t, string(migrated), "most_visited_days") | ||
| 1619 | } | 1619 | } | ||
| 1620 | + | ||||
| 1621 | + | func TestMigrator_Migrate_AddsKeymapPresetAndBuiltinShortcuts(t *testing.T) { | |||
| 1622 | + | home := t.TempDir() | |||
| 1623 | + | t.Setenv("HOME", home) | |||
| 1624 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) | |||
| 1625 | + | t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) | |||
| 1626 | + | t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) | |||
| 1627 | + | t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) | |||
| 1628 | + | ||||
| 1629 | + | configFile, err := GetConfigFile() | |||
| 1630 | + | require.NoError(t, err) | |||
| 1631 | + | require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755)) | |||
| 1632 | + | // A config written before keymap presets: no keymap, no prefix_mode, and a | |||
| 1633 | + | // shortcuts table without the configurable browser actions. | |||
| 1634 | + | require.NoError(t, os.WriteFile(configFile, []byte(` | |||
| 1635 | + | [workspace.shortcuts.actions.quit] | |||
| 1636 | + | keys = ["ctrl+shift+q"] | |||
| 1637 | + | desc = "Custom quit" | |||
| 1638 | + | ||||
| 1639 | + | [workspace.shortcuts.actions.toggle-floating-pane] | |||
| 1640 | + | keys = ["alt+f"] | |||
| 1641 | + | desc = "Toggle floating pane" | |||
| 1642 | + | `), 0o644)) | |||
| 1643 | + | ||||
| 1644 | + | m := NewMigrator() | |||
| 1645 | + | result, err := m.CheckMigration() | |||
| 1646 | + | require.NoError(t, err) | |||
| 1647 | + | require.NotNil(t, result) | |||
| 1648 | + | assert.Contains(t, result.MissingKeys, "workspace.keymap") | |||
| 1649 | + | assert.Contains(t, result.MissingKeys, "workspace.prefix_mode.activation_shortcut") | |||
| 1650 | + | assert.Contains(t, result.MissingKeys, "workspace.shortcuts.actions.open-omnibox") | |||
| 1651 | + | ||||
| 1652 | + | _, err = m.Migrate() | |||
| 1653 | + | require.NoError(t, err) | |||
| 1654 | + | ||||
| 1655 | + | mgr, err := NewManager() | |||
| 1656 | + | require.NoError(t, err) | |||
| 1657 | + | require.NoError(t, mgr.Load()) | |||
| 1658 | + | cfg := mgr.Get() | |||
| 1659 | + | ||||
| 1660 | + | assert.Equal(t, KeymapZellij, cfg.Workspace.Keymap) | |||
| 1661 | + | assert.Equal(t, defaultPrefixActivationShortcut, cfg.Workspace.PrefixMode.ActivationShortcut) | |||
| 1662 | + | assert.Equal(t, DefaultConfig().Workspace.PrefixMode.Actions, cfg.Workspace.PrefixMode.Actions) | |||
| 1663 | + | assert.Equal(t, []string{"ctrl+l"}, cfg.Workspace.Shortcuts.Actions["open-omnibox"].Keys) | |||
| 1664 | + | assert.Equal(t, []string{"ctrl+shift+q"}, cfg.Workspace.Shortcuts.Actions["quit"].Keys, "user override kept") | |||
| 1665 | + | ||||
| 1666 | + | again, err := m.CheckMigration() | |||
| 1667 | + | require.NoError(t, err) | |||
| 1668 | + | assert.Nil(t, again, "migration is idempotent") | |||
| 1669 | + | } | |||
M internal/infrastructure/config/schema.go +11 -0
| @@ -15,6 +15,17 @@ type ColorPalette = entity.ColorPalette | |||
| 15 | 15 | // ActionBinding defines a keybinding with optional description. | |
| 16 | 16 | type ActionBinding = entity.ActionBinding | |
| 17 | 17 | ||
| 18 | + | // PrefixModeConfig defines the tmux-style one-shot prefix. | |
| 19 | + | type PrefixModeConfig = entity.PrefixModeConfig | |
| 20 | + | ||
| 21 | + | // KeymapPreset selects how workspace modes are activated. | |
| 22 | + | type KeymapPreset = entity.KeymapPreset | |
| 23 | + | ||
| 24 | + | const ( | |
| 25 | + | KeymapZellij = entity.KeymapZellij | |
| 26 | + | KeymapTmux = entity.KeymapTmux | |
| 27 | + | ) | |
| 28 | + | ||
| 18 | 29 | // PaneModeConfig defines modal behavior for pane management. | |
| 19 | 30 | type PaneModeConfig = entity.PaneModeConfig | |
| 20 | 31 | ||
| @@ -15,6 +15,17 @@ type ColorPalette = entity.ColorPalette | |||||
| 15 | // ActionBinding defines a keybinding with optional description. | 15 | // ActionBinding defines a keybinding with optional description. | ||
| 16 | type ActionBinding = entity.ActionBinding | 16 | type ActionBinding = entity.ActionBinding | ||
| 17 | 17 | ||||
| 18 | + | // PrefixModeConfig defines the tmux-style one-shot prefix. | |||
| 19 | + | type PrefixModeConfig = entity.PrefixModeConfig | |||
| 20 | + | ||||
| 21 | + | // KeymapPreset selects how workspace modes are activated. | |||
| 22 | + | type KeymapPreset = entity.KeymapPreset | |||
| 23 | + | ||||
| 24 | + | const ( | |||
| 25 | + | KeymapZellij = entity.KeymapZellij | |||
| 26 | + | KeymapTmux = entity.KeymapTmux | |||
| 27 | + | ) | |||
| 28 | + | ||||
| 18 | // PaneModeConfig defines modal behavior for pane management. | 29 | // PaneModeConfig defines modal behavior for pane management. | ||
| 19 | type PaneModeConfig = entity.PaneModeConfig | 30 | type PaneModeConfig = entity.PaneModeConfig | ||
| 20 | 31 | ||||
M internal/infrastructure/config/schema_provider.go +31 -0
| @@ -426,6 +426,37 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo | |||
| 426 | 426 | Description: "Switch focus to tab when moving pane to it", | |
| 427 | 427 | Section: SectionWorkspace, | |
| 428 | 428 | }, | |
| 429 | + | // Keymap preset | |
| 430 | + | { | |
| 431 | + | Key: "workspace.keymap", | |
| 432 | + | Type: "string", | |
| 433 | + | Default: string(defaults.Workspace.Keymap), | |
| 434 | + | Description: "Mode activation style: zellij (one shortcut per mode) or tmux (single prefix key)", | |
| 435 | + | Values: []string{string(KeymapZellij), string(KeymapTmux)}, | |
| 436 | + | Section: SectionWorkspace, | |
| 437 | + | }, | |
| 438 | + | { | |
| 439 | + | Key: "workspace.prefix_mode.activation_shortcut", | |
| 440 | + | Type: "string", | |
| 441 | + | Default: defaults.Workspace.PrefixMode.ActivationShortcut, | |
| 442 | + | Description: "Prefix key used when workspace.keymap is tmux", | |
| 443 | + | Section: SectionWorkspace, | |
| 444 | + | }, | |
| 445 | + | { | |
| 446 | + | Key: "workspace.prefix_mode.timeout_ms", | |
| 447 | + | Type: "int", | |
| 448 | + | Default: fmt.Sprintf("%d", defaults.Workspace.PrefixMode.TimeoutMilliseconds), | |
| 449 | + | Description: "Prefix timeout in milliseconds (0 = no timeout)", | |
| 450 | + | Range: ">=0", | |
| 451 | + | Section: SectionWorkspace, | |
| 452 | + | }, | |
| 453 | + | { | |
| 454 | + | Key: "workspace.prefix_mode.actions.<action>", | |
| 455 | + | Type: "object", | |
| 456 | + | Default: "(see defaults)", | |
| 457 | + | Description: "One-shot actions available after the prefix key", | |
| 458 | + | Section: SectionWorkspace, | |
| 459 | + | }, | |
| 429 | 460 | // Pane mode | |
| 430 | 461 | { | |
| 431 | 462 | Key: "workspace.pane_mode.activation_shortcut", | |
| @@ -426,6 +426,37 @@ func (*SchemaProvider) getWorkspaceKeys(defaults *Config) []entity.ConfigKeyInfo | |||||
| 426 | Description: "Switch focus to tab when moving pane to it", | 426 | Description: "Switch focus to tab when moving pane to it", | ||
| 427 | Section: SectionWorkspace, | 427 | Section: SectionWorkspace, | ||
| 428 | }, | 428 | }, | ||
| 429 | + | // Keymap preset | |||
| 430 | + | { | |||
| 431 | + | Key: "workspace.keymap", | |||
| 432 | + | Type: "string", | |||
| 433 | + | Default: string(defaults.Workspace.Keymap), | |||
| 434 | + | Description: "Mode activation style: zellij (one shortcut per mode) or tmux (single prefix key)", | |||
| 435 | + | Values: []string{string(KeymapZellij), string(KeymapTmux)}, | |||
| 436 | + | Section: SectionWorkspace, | |||
| 437 | + | }, | |||
| 438 | + | { | |||
| 439 | + | Key: "workspace.prefix_mode.activation_shortcut", | |||
| 440 | + | Type: "string", | |||
| 441 | + | Default: defaults.Workspace.PrefixMode.ActivationShortcut, | |||
| 442 | + | Description: "Prefix key used when workspace.keymap is tmux", | |||
| 443 | + | Section: SectionWorkspace, | |||
| 444 | + | }, | |||
| 445 | + | { | |||
| 446 | + | Key: "workspace.prefix_mode.timeout_ms", | |||
| 447 | + | Type: "int", | |||
| 448 | + | Default: fmt.Sprintf("%d", defaults.Workspace.PrefixMode.TimeoutMilliseconds), | |||
| 449 | + | Description: "Prefix timeout in milliseconds (0 = no timeout)", | |||
| 450 | + | Range: ">=0", | |||
| 451 | + | Section: SectionWorkspace, | |||
| 452 | + | }, | |||
| 453 | + | { | |||
| 454 | + | Key: "workspace.prefix_mode.actions.<action>", | |||
| 455 | + | Type: "object", | |||
| 456 | + | Default: "(see defaults)", | |||
| 457 | + | Description: "One-shot actions available after the prefix key", | |||
| 458 | + | Section: SectionWorkspace, | |||
| 459 | + | }, | |||
| 429 | // Pane mode | 460 | // Pane mode | ||
| 430 | { | 461 | { | ||
| 431 | Key: "workspace.pane_mode.activation_shortcut", | 462 | Key: "workspace.pane_mode.activation_shortcut", | ||
M internal/infrastructure/config/schema_provider_test.go +1 -0
| @@ -166,6 +166,7 @@ func TestSchemaProviderCoversViperDefaults(t *testing.T) { | |||
| 166 | 166 | "appearance.light_palette": "appearance.light_palette.*", | |
| 167 | 167 | "appearance.dark_palette": "appearance.dark_palette.*", | |
| 168 | 168 | "search_shortcuts": "search_shortcuts.<key>", | |
| 169 | + | "workspace.prefix_mode.actions": "workspace.prefix_mode.actions.<action>", | |
| 169 | 170 | "workspace.pane_mode.actions": "workspace.pane_mode.actions.<action>", | |
| 170 | 171 | "workspace.tab_mode.actions": "workspace.tab_mode.actions.<action>", | |
| 171 | 172 | "workspace.vim_mode.actions": "workspace.vim_mode.actions.<action>", | |
| @@ -166,6 +166,7 @@ func TestSchemaProviderCoversViperDefaults(t *testing.T) { | |||||
| 166 | "appearance.light_palette": "appearance.light_palette.*", | 166 | "appearance.light_palette": "appearance.light_palette.*", | ||
| 167 | "appearance.dark_palette": "appearance.dark_palette.*", | 167 | "appearance.dark_palette": "appearance.dark_palette.*", | ||
| 168 | "search_shortcuts": "search_shortcuts.<key>", | 168 | "search_shortcuts": "search_shortcuts.<key>", | ||
| 169 | + | "workspace.prefix_mode.actions": "workspace.prefix_mode.actions.<action>", | |||
| 169 | "workspace.pane_mode.actions": "workspace.pane_mode.actions.<action>", | 170 | "workspace.pane_mode.actions": "workspace.pane_mode.actions.<action>", | ||
| 170 | "workspace.tab_mode.actions": "workspace.tab_mode.actions.<action>", | 171 | "workspace.tab_mode.actions": "workspace.tab_mode.actions.<action>", | ||
| 171 | "workspace.vim_mode.actions": "workspace.vim_mode.actions.<action>", | 172 | "workspace.vim_mode.actions": "workspace.vim_mode.actions.<action>", | ||
M internal/infrastructure/config/transform_legacy.go +5 -0
| @@ -31,6 +31,7 @@ func NewLegacyConfigTransformer() *LegacyConfigTransformer { | |||
| 31 | 31 | // desc = "Focus pane to the left" | |
| 32 | 32 | func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]any) { | |
| 33 | 33 | actionPaths := [][]string{ | |
| 34 | + | {"workspace", "prefix_mode", "actions"}, | |
| 34 | 35 | {"workspace", "pane_mode", "actions"}, | |
| 35 | 36 | {"workspace", "tab_mode", "actions"}, | |
| 36 | 37 | {"workspace", "resize_mode", "actions"}, | |
| @@ -296,6 +297,10 @@ func (*LegacyConfigTransformer) ForceLegacyPopupsToBrowsingContexts(rawConfig ma | |||
| 296 | 297 | ||
| 297 | 298 | func (t *LegacyConfigTransformer) getDefaultDesc(path []string, actionName string) string { | |
| 298 | 299 | switch { | |
| 300 | + | case path[0] == sectionWorkspace && path[1] == "prefix_mode": | |
| 301 | + | if b, ok := t.defaults.Workspace.PrefixMode.Actions[actionName]; ok { | |
| 302 | + | return b.Desc | |
| 303 | + | } | |
| 299 | 304 | case path[0] == sectionWorkspace && path[1] == "pane_mode": | |
| 300 | 305 | if b, ok := t.defaults.Workspace.PaneMode.Actions[actionName]; ok { | |
| 301 | 306 | return b.Desc | |
| @@ -31,6 +31,7 @@ func NewLegacyConfigTransformer() *LegacyConfigTransformer { | |||||
| 31 | // desc = "Focus pane to the left" | 31 | // desc = "Focus pane to the left" | ||
| 32 | func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]any) { | 32 | func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]any) { | ||
| 33 | actionPaths := [][]string{ | 33 | actionPaths := [][]string{ | ||
| 34 | + | {"workspace", "prefix_mode", "actions"}, | |||
| 34 | {"workspace", "pane_mode", "actions"}, | 35 | {"workspace", "pane_mode", "actions"}, | ||
| 35 | {"workspace", "tab_mode", "actions"}, | 36 | {"workspace", "tab_mode", "actions"}, | ||
| 36 | {"workspace", "resize_mode", "actions"}, | 37 | {"workspace", "resize_mode", "actions"}, | ||
| @@ -296,6 +297,10 @@ func (*LegacyConfigTransformer) ForceLegacyPopupsToBrowsingContexts(rawConfig ma | |||||
| 296 | 297 | ||||
| 297 | func (t *LegacyConfigTransformer) getDefaultDesc(path []string, actionName string) string { | 298 | func (t *LegacyConfigTransformer) getDefaultDesc(path []string, actionName string) string { | ||
| 298 | switch { | 299 | switch { | ||
| 300 | + | case path[0] == sectionWorkspace && path[1] == "prefix_mode": | |||
| 301 | + | if b, ok := t.defaults.Workspace.PrefixMode.Actions[actionName]; ok { | |||
| 302 | + | return b.Desc | |||
| 303 | + | } | |||
| 299 | case path[0] == sectionWorkspace && path[1] == "pane_mode": | 304 | case path[0] == sectionWorkspace && path[1] == "pane_mode": | ||
| 300 | if b, ok := t.defaults.Workspace.PaneMode.Actions[actionName]; ok { | 305 | if b, ok := t.defaults.Workspace.PaneMode.Actions[actionName]; ok { | ||
| 301 | return b.Desc | 306 | return b.Desc | ||
M internal/infrastructure/config/validation.go +14 -0
| @@ -27,6 +27,7 @@ func validateConfig(config *Config) error { | |||
| 27 | 27 | validationErrors = append(validationErrors, validatePopups(config)...) | |
| 28 | 28 | validationErrors = append(validationErrors, validateExternalLinks(config)...) | |
| 29 | 29 | validationErrors = append(validationErrors, validateWorkspaceStyling(config)...) | |
| 30 | + | validationErrors = append(validationErrors, validateKeymap(config)...) | |
| 30 | 31 | validationErrors = append(validationErrors, validatePaneMode(config)...) | |
| 31 | 32 | validationErrors = append(validationErrors, validateTabBar(config)...) | |
| 32 | 33 | validationErrors = append(validationErrors, validateTabMode(config)...) | |
| @@ -234,6 +235,19 @@ func validateWorkspaceStyling(config *Config) []string { | |||
| 234 | 235 | return validationErrors | |
| 235 | 236 | } | |
| 236 | 237 | ||
| 238 | + | func validateKeymap(config *Config) []string { | |
| 239 | + | var validationErrors []string | |
| 240 | + | switch config.Workspace.Keymap { | |
| 241 | + | case KeymapZellij, KeymapTmux: | |
| 242 | + | default: | |
| 243 | + | validationErrors = append(validationErrors, fmt.Sprintf( | |
| 244 | + | "workspace.keymap must be 'zellij' or 'tmux' (got: %s)", config.Workspace.Keymap)) | |
| 245 | + | } | |
| 246 | + | validationErrors = append(validationErrors, validateModalModeActions( | |
| 247 | + | "prefix_mode", config.Workspace.PrefixMode.TimeoutMilliseconds, config.Workspace.PrefixMode.Actions)...) | |
| 248 | + | return validationErrors | |
| 249 | + | } | |
| 250 | + | ||
| 237 | 251 | func validatePaneMode(config *Config) []string { | |
| 238 | 252 | return validateModalModeActions("pane_mode", config.Workspace.PaneMode.TimeoutMilliseconds, config.Workspace.PaneMode.Actions) | |
| 239 | 253 | } | |
| @@ -27,6 +27,7 @@ func validateConfig(config *Config) error { | |||||
| 27 | validationErrors = append(validationErrors, validatePopups(config)...) | 27 | validationErrors = append(validationErrors, validatePopups(config)...) | ||
| 28 | validationErrors = append(validationErrors, validateExternalLinks(config)...) | 28 | validationErrors = append(validationErrors, validateExternalLinks(config)...) | ||
| 29 | validationErrors = append(validationErrors, validateWorkspaceStyling(config)...) | 29 | validationErrors = append(validationErrors, validateWorkspaceStyling(config)...) | ||
| 30 | + | validationErrors = append(validationErrors, validateKeymap(config)...) | |||
| 30 | validationErrors = append(validationErrors, validatePaneMode(config)...) | 31 | validationErrors = append(validationErrors, validatePaneMode(config)...) | ||
| 31 | validationErrors = append(validationErrors, validateTabBar(config)...) | 32 | validationErrors = append(validationErrors, validateTabBar(config)...) | ||
| 32 | validationErrors = append(validationErrors, validateTabMode(config)...) | 33 | validationErrors = append(validationErrors, validateTabMode(config)...) | ||
| @@ -234,6 +235,19 @@ func validateWorkspaceStyling(config *Config) []string { | |||||
| 234 | return validationErrors | 235 | return validationErrors | ||
| 235 | } | 236 | } | ||
| 236 | 237 | ||||
| 238 | + | func validateKeymap(config *Config) []string { | |||
| 239 | + | var validationErrors []string | |||
| 240 | + | switch config.Workspace.Keymap { | |||
| 241 | + | case KeymapZellij, KeymapTmux: | |||
| 242 | + | default: | |||
| 243 | + | validationErrors = append(validationErrors, fmt.Sprintf( | |||
| 244 | + | "workspace.keymap must be 'zellij' or 'tmux' (got: %s)", config.Workspace.Keymap)) | |||
| 245 | + | } | |||
| 246 | + | validationErrors = append(validationErrors, validateModalModeActions( | |||
| 247 | + | "prefix_mode", config.Workspace.PrefixMode.TimeoutMilliseconds, config.Workspace.PrefixMode.Actions)...) | |||
| 248 | + | return validationErrors | |||
| 249 | + | } | |||
| 250 | + | ||||
| 237 | func validatePaneMode(config *Config) []string { | 251 | func validatePaneMode(config *Config) []string { | ||
| 238 | return validateModalModeActions("pane_mode", config.Workspace.PaneMode.TimeoutMilliseconds, config.Workspace.PaneMode.Actions) | 252 | return validateModalModeActions("pane_mode", config.Workspace.PaneMode.TimeoutMilliseconds, config.Workspace.PaneMode.Actions) | ||
| 239 | } | 253 | } | ||
M internal/infrastructure/config/validation_test.go +15 -0
| @@ -531,3 +531,18 @@ func TestValidateConfig_OmniboxMaxHistoryDays(t *testing.T) { | |||
| 531 | 531 | assert.Contains(t, err.Error(), "omnibox.max_history_days must be non-negative") | |
| 532 | 532 | }) | |
| 533 | 533 | } | |
| 534 | + | ||
| 535 | + | func TestValidateKeymap(t *testing.T) { | |
| 536 | + | cfg := DefaultConfig() | |
| 537 | + | assert.Empty(t, validateKeymap(cfg)) | |
| 538 | + | ||
| 539 | + | cfg.Workspace.Keymap = KeymapTmux | |
| 540 | + | assert.Empty(t, validateKeymap(cfg)) | |
| 541 | + | ||
| 542 | + | cfg.Workspace.Keymap = "emacs" | |
| 543 | + | assert.Contains(t, validateKeymap(cfg), "workspace.keymap must be 'zellij' or 'tmux' (got: emacs)") | |
| 544 | + | ||
| 545 | + | cfg.Workspace.Keymap = KeymapZellij | |
| 546 | + | cfg.Workspace.PrefixMode.TimeoutMilliseconds = -1 | |
| 547 | + | assert.Contains(t, validateKeymap(cfg), "workspace.prefix_mode.timeout_ms must be non-negative") | |
| 548 | + | } | |
| @@ -531,3 +531,18 @@ func TestValidateConfig_OmniboxMaxHistoryDays(t *testing.T) { | |||||
| 531 | assert.Contains(t, err.Error(), "omnibox.max_history_days must be non-negative") | 531 | assert.Contains(t, err.Error(), "omnibox.max_history_days must be non-negative") | ||
| 532 | }) | 532 | }) | ||
| 533 | } | 533 | } | ||
| 534 | + | ||||
| 535 | + | func TestValidateKeymap(t *testing.T) { | |||
| 536 | + | cfg := DefaultConfig() | |||
| 537 | + | assert.Empty(t, validateKeymap(cfg)) | |||
| 538 | + | ||||
| 539 | + | cfg.Workspace.Keymap = KeymapTmux | |||
| 540 | + | assert.Empty(t, validateKeymap(cfg)) | |||
| 541 | + | ||||
| 542 | + | cfg.Workspace.Keymap = "emacs" | |||
| 543 | + | assert.Contains(t, validateKeymap(cfg), "workspace.keymap must be 'zellij' or 'tmux' (got: emacs)") | |||
| 544 | + | ||||
| 545 | + | cfg.Workspace.Keymap = KeymapZellij | |||
| 546 | + | cfg.Workspace.PrefixMode.TimeoutMilliseconds = -1 | |||
| 547 | + | assert.Contains(t, validateKeymap(cfg), "workspace.prefix_mode.timeout_ms must be non-negative") | |||
| 548 | + | } | |||
M internal/ui/app.go +2 -0
| @@ -3912,6 +3912,8 @@ func getModeToastClass(mode input.Mode) string { | |||
| 3912 | 3912 | return "toast-session-mode" | |
| 3913 | 3913 | case input.ModeResize: | |
| 3914 | 3914 | return "toast-resize-mode" | |
| 3915 | + | case input.ModePrefix: | |
| 3916 | + | return "toast-prefix-mode" | |
| 3915 | 3917 | default: | |
| 3916 | 3918 | return "" | |
| 3917 | 3919 | } | |
| @@ -3912,6 +3912,8 @@ func getModeToastClass(mode input.Mode) string { | |||||
| 3912 | return "toast-session-mode" | 3912 | return "toast-session-mode" | ||
| 3913 | case input.ModeResize: | 3913 | case input.ModeResize: | ||
| 3914 | return "toast-resize-mode" | 3914 | return "toast-resize-mode" | ||
| 3915 | + | case input.ModePrefix: | |||
| 3916 | + | return "toast-prefix-mode" | |||
| 3915 | default: | 3917 | default: | ||
| 3916 | return "" | 3918 | return "" | ||
| 3917 | } | 3919 | } | ||
M internal/ui/app_mode_frame.go +1 -1
| @@ -10,7 +10,7 @@ func (a *App) modeFrameTarget(bw *browserWindow, mode input.Mode) layout.Widget | |||
| 10 | 10 | if wsView == nil { | |
| 11 | 11 | return nil | |
| 12 | 12 | } | |
| 13 | - | if mode == input.ModeTab || mode == input.ModeSession { | |
| 13 | + | if mode == input.ModeTab || mode == input.ModeSession || mode == input.ModePrefix { | |
| 14 | 14 | return wsView.Widget() | |
| 15 | 15 | } | |
| 16 | 16 | ws := a.activeWorkspaceForBrowserWindow(bw) | |
| @@ -10,7 +10,7 @@ func (a *App) modeFrameTarget(bw *browserWindow, mode input.Mode) layout.Widget | |||||
| 10 | if wsView == nil { | 10 | if wsView == nil { | ||
| 11 | return nil | 11 | return nil | ||
| 12 | } | 12 | } | ||
| 13 | - | if mode == input.ModeTab || mode == input.ModeSession { | 13 | + | if mode == input.ModeTab || mode == input.ModeSession || mode == input.ModePrefix { |
| 14 | return wsView.Widget() | 14 | return wsView.Widget() | ||
| 15 | } | 15 | } | ||
| 16 | ws := a.activeWorkspaceForBrowserWindow(bw) | 16 | ws := a.activeWorkspaceForBrowserWindow(bw) | ||
M internal/ui/input/global_shortcuts.go +12 -12
| @@ -81,12 +81,12 @@ func NewGlobalShortcutHandler( | |||
| 81 | 81 | if workspace != nil { | |
| 82 | 82 | actionMap := globalShortcutActionMap() | |
| 83 | 83 | ||
| 84 | - | for actionName, actionBinding := range workspace.Shortcuts.Actions { | |
| 84 | + | for _, actionName := range orderedGlobalShortcutActions(workspace.Shortcuts.Actions) { | |
| 85 | 85 | action, ok := actionMap[actionName] | |
| 86 | 86 | if !ok { | |
| 87 | 87 | continue | |
| 88 | 88 | } | |
| 89 | - | for _, keyStr := range actionBinding.Keys { | |
| 89 | + | for _, keyStr := range workspace.Shortcuts.Actions[actionName].Keys { | |
| 90 | 90 | binding, ok := ParseKeyString(keyStr) | |
| 91 | 91 | if !ok { | |
| 92 | 92 | log.Warn().Str("shortcut", keyStr).Str("action", string(action)).Msg("failed to parse global shortcut") | |
| @@ -195,13 +195,6 @@ func (h *GlobalShortcutHandler) registerDefaultGlobalShortcuts(log *zerolog.Logg | |||
| 195 | 195 | Uint("keyval", uint(gdk.KEY_Tab)). | |
| 196 | 196 | Str("action", string(ActionSwitchLastTab)). | |
| 197 | 197 | Msg("registered global shortcut") | |
| 198 | - | ||
| 199 | - | // Ctrl+Shift+S for direct session manager access (needs global scope for WebView focus). | |
| 200 | - | h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager) | |
| 201 | - | log.Trace(). | |
| 202 | - | Uint("keyval", uint(gdk.KEY_s)). | |
| 203 | - | Str("action", string(ActionOpenSessionManager)). | |
| 204 | - | Msg("registered global shortcut") | |
| 205 | 198 | } | |
| 206 | 199 | ||
| 207 | 200 | // registerShortcut creates and registers a single shortcut with the controller. | |
| @@ -340,6 +333,12 @@ func (h *GlobalShortcutHandler) dispatchGlobalShortcut(actionToDispatch Action, | |||
| 340 | 333 | Msg("mode action triggered but keyboard handler not set, falling through to default handler") | |
| 341 | 334 | } | |
| 342 | 335 | ||
| 336 | + | // After the tmux prefix, dispatch through KeyboardHandler so the | |
| 337 | + | // one-shot exit follows the same order as keys it handles directly. | |
| 338 | + | if h.kbHandler != nil && h.kbHandler.Mode() == ModePrefix { | |
| 339 | + | return h.kbHandler.DispatchAction(actionToDispatch) | |
| 340 | + | } | |
| 341 | + | ||
| 343 | 342 | if h.onAction != nil { | |
| 344 | 343 | if err := h.onAction(h.ctx, actionToDispatch); err != nil { | |
| 345 | 344 | log.Error(). | |
| @@ -559,12 +558,12 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace * | |||
| 559 | 558 | if workspace != nil { | |
| 560 | 559 | actionMap := globalShortcutActionMap() | |
| 561 | 560 | ||
| 562 | - | for actionName, actionBinding := range workspace.Shortcuts.Actions { | |
| 561 | + | for _, actionName := range orderedGlobalShortcutActions(workspace.Shortcuts.Actions) { | |
| 563 | 562 | action, ok := actionMap[actionName] | |
| 564 | 563 | if !ok { | |
| 565 | 564 | continue | |
| 566 | 565 | } | |
| 567 | - | for _, keyStr := range actionBinding.Keys { | |
| 566 | + | for _, keyStr := range workspace.Shortcuts.Actions[actionName].Keys { | |
| 568 | 567 | binding, ok := ParseKeyString(keyStr) | |
| 569 | 568 | if !ok { | |
| 570 | 569 | continue | |
| @@ -840,7 +839,8 @@ func formatEventType(eventType gdk.EventType) string { | |||
| 840 | 839 | ||
| 841 | 840 | func isModeAction(action Action) bool { | |
| 842 | 841 | switch action { | |
| 843 | - | case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionEnterVimMode, ActionExitMode: | |
| 842 | + | case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionEnterVimMode, | |
| 843 | + | ActionEnterPrefixMode, ActionExitMode: | |
| 844 | 844 | return true | |
| 845 | 845 | default: | |
| 846 | 846 | return false | |
| @@ -81,12 +81,12 @@ func NewGlobalShortcutHandler( | |||||
| 81 | if workspace != nil { | 81 | if workspace != nil { | ||
| 82 | actionMap := globalShortcutActionMap() | 82 | actionMap := globalShortcutActionMap() | ||
| 83 | 83 | ||||
| 84 | - | for actionName, actionBinding := range workspace.Shortcuts.Actions { | 84 | + | for _, actionName := range orderedGlobalShortcutActions(workspace.Shortcuts.Actions) { |
| 85 | action, ok := actionMap[actionName] | 85 | action, ok := actionMap[actionName] | ||
| 86 | if !ok { | 86 | if !ok { | ||
| 87 | continue | 87 | continue | ||
| 88 | } | 88 | } | ||
| 89 | - | for _, keyStr := range actionBinding.Keys { | 89 | + | for _, keyStr := range workspace.Shortcuts.Actions[actionName].Keys { |
| 90 | binding, ok := ParseKeyString(keyStr) | 90 | binding, ok := ParseKeyString(keyStr) | ||
| 91 | if !ok { | 91 | if !ok { | ||
| 92 | log.Warn().Str("shortcut", keyStr).Str("action", string(action)).Msg("failed to parse global shortcut") | 92 | log.Warn().Str("shortcut", keyStr).Str("action", string(action)).Msg("failed to parse global shortcut") | ||
| @@ -195,13 +195,6 @@ func (h *GlobalShortcutHandler) registerDefaultGlobalShortcuts(log *zerolog.Logg | |||||
| 195 | Uint("keyval", uint(gdk.KEY_Tab)). | 195 | Uint("keyval", uint(gdk.KEY_Tab)). | ||
| 196 | Str("action", string(ActionSwitchLastTab)). | 196 | Str("action", string(ActionSwitchLastTab)). | ||
| 197 | Msg("registered global shortcut") | 197 | Msg("registered global shortcut") | ||
| 198 | - | ||||
| 199 | - | // Ctrl+Shift+S for direct session manager access (needs global scope for WebView focus). | |||
| 200 | - | h.registerShortcut(uint(gdk.KEY_s), gdk.ControlMaskValue|gdk.ShiftMaskValue, ActionOpenSessionManager) | |||
| 201 | - | log.Trace(). | |||
| 202 | - | Uint("keyval", uint(gdk.KEY_s)). | |||
| 203 | - | Str("action", string(ActionOpenSessionManager)). | |||
| 204 | - | Msg("registered global shortcut") | |||
| 205 | } | 198 | } | ||
| 206 | 199 | ||||
| 207 | // registerShortcut creates and registers a single shortcut with the controller. | 200 | // registerShortcut creates and registers a single shortcut with the controller. | ||
| @@ -340,6 +333,12 @@ func (h *GlobalShortcutHandler) dispatchGlobalShortcut(actionToDispatch Action, | |||||
| 340 | Msg("mode action triggered but keyboard handler not set, falling through to default handler") | 333 | Msg("mode action triggered but keyboard handler not set, falling through to default handler") | ||
| 341 | } | 334 | } | ||
| 342 | 335 | ||||
| 336 | + | // After the tmux prefix, dispatch through KeyboardHandler so the | |||
| 337 | + | // one-shot exit follows the same order as keys it handles directly. | |||
| 338 | + | if h.kbHandler != nil && h.kbHandler.Mode() == ModePrefix { | |||
| 339 | + | return h.kbHandler.DispatchAction(actionToDispatch) | |||
| 340 | + | } | |||
| 341 | + | ||||
| 343 | if h.onAction != nil { | 342 | if h.onAction != nil { | ||
| 344 | if err := h.onAction(h.ctx, actionToDispatch); err != nil { | 343 | if err := h.onAction(h.ctx, actionToDispatch); err != nil { | ||
| 345 | log.Error(). | 344 | log.Error(). | ||
| @@ -559,12 +558,12 @@ func (h *GlobalShortcutHandler) ReloadShortcuts(ctx context.Context, workspace * | |||||
| 559 | if workspace != nil { | 558 | if workspace != nil { | ||
| 560 | actionMap := globalShortcutActionMap() | 559 | actionMap := globalShortcutActionMap() | ||
| 561 | 560 | ||||
| 562 | - | for actionName, actionBinding := range workspace.Shortcuts.Actions { | 561 | + | for _, actionName := range orderedGlobalShortcutActions(workspace.Shortcuts.Actions) { |
| 563 | action, ok := actionMap[actionName] | 562 | action, ok := actionMap[actionName] | ||
| 564 | if !ok { | 563 | if !ok { | ||
| 565 | continue | 564 | continue | ||
| 566 | } | 565 | } | ||
| 567 | - | for _, keyStr := range actionBinding.Keys { | 566 | + | for _, keyStr := range workspace.Shortcuts.Actions[actionName].Keys { |
| 568 | binding, ok := ParseKeyString(keyStr) | 567 | binding, ok := ParseKeyString(keyStr) | ||
| 569 | if !ok { | 568 | if !ok { | ||
| 570 | continue | 569 | continue | ||
| @@ -840,7 +839,8 @@ func formatEventType(eventType gdk.EventType) string { | |||||
| 840 | 839 | ||||
| 841 | func isModeAction(action Action) bool { | 840 | func isModeAction(action Action) bool { | ||
| 842 | switch action { | 841 | switch action { | ||
| 843 | - | case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionEnterVimMode, ActionExitMode: | 842 | + | case ActionEnterTabMode, ActionEnterPaneMode, ActionEnterSessionMode, ActionEnterResizeMode, ActionEnterVimMode, |
| 843 | + | ActionEnterPrefixMode, ActionExitMode: | |||
| 844 | return true | 844 | return true | ||
| 845 | default: | 845 | default: | ||
| 846 | return false | 846 | return false | ||
M internal/ui/input/keyboard.go +39 -1
| @@ -502,6 +502,9 @@ func (h *KeyboardHandler) handleShortcutLookupResult( | |||
| 502 | 502 | log.Trace().Uint("keyval", keyval).Msg("routing native page navigation key to focused widget in vim mode") | |
| 503 | 503 | return false | |
| 504 | 504 | } | |
| 505 | + | if mode == ModePrefix { | |
| 506 | + | return h.handleUnboundPrefixKey(keyval, modifiers) | |
| 507 | + | } | |
| 505 | 508 | return mode != ModeNormal // Consume unrecognized keys in modal mode | |
| 506 | 509 | } | |
| 507 | 510 | if h.shouldPassthroughVimModeActivation(action, mode) { | |
| @@ -697,6 +700,26 @@ func (h *KeyboardHandler) confirmVimMode() { | |||
| 697 | 700 | h.modal.ExitMode(h.ctx) | |
| 698 | 701 | } | |
| 699 | 702 | ||
| 703 | + | // handleUnboundPrefixKey handles a key with no prefix action. Modifier and | |
| 704 | + | // dead-key presses (the Shift before '%') keep the prefix waiting. Any other | |
| 705 | + | // key cancels the prefix, tmux-style; modified keys then continue to their | |
| 706 | + | // normal handlers (Alt+1 tab switch, page shortcuts), plain keys are consumed. | |
| 707 | + | func (h *KeyboardHandler) handleUnboundPrefixKey(keyval uint, modifiers Modifier) bool { | |
| 708 | + | if isNonCharacterKeysym(keyval) { | |
| 709 | + | return true | |
| 710 | + | } | |
| 711 | + | h.modal.ExitMode(h.ctx) | |
| 712 | + | return !IsShortcutModified(modifiers) | |
| 713 | + | } | |
| 714 | + | ||
| 715 | + | // isNonCharacterKeysym reports modifier, ISO lock/latch, and dead keysyms: | |
| 716 | + | // keys pressed on the way to a character rather than characters themselves. | |
| 717 | + | func isNonCharacterKeysym(keyval uint) bool { | |
| 718 | + | isModifier := keyval >= uint(gdk.KEY_Shift_L) && keyval <= uint(gdk.KEY_Hyper_R) | |
| 719 | + | isISOOrDead := keyval >= uint(gdk.KEY_ISO_Lock) && keyval <= uint(gdk.KEY_dead_greek) | |
| 720 | + | return isModifier || isISOOrDead | |
| 721 | + | } | |
| 722 | + | ||
| 700 | 723 | // dispatchAction dispatches the action and handles mode-related logic. | |
| 701 | 724 | func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool { | |
| 702 | 725 | if h.handleModeAction(action) { | |
| @@ -721,7 +744,10 @@ func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool { | |||
| 721 | 744 | h.modal.ResetTimeout(h.ctx) | |
| 722 | 745 | } | |
| 723 | 746 | ||
| 724 | - | if mode != ModeNormal && ShouldAutoExitMode(action) { | |
| 747 | + | // The prefix is one-shot: any action returns to normal mode. Mode-enter | |
| 748 | + | // actions already left ModePrefix in handleModeAction. | |
| 749 | + | if (mode == ModePrefix && h.modal.Mode() == ModePrefix) || | |
| 750 | + | (mode != ModeNormal && ShouldAutoExitMode(action)) { | |
| 725 | 751 | h.modal.ExitMode(h.ctx) | |
| 726 | 752 | } | |
| 727 | 753 | ||
| @@ -755,6 +781,7 @@ func isRepeatedKeyboardActionSuppressed(action Action) bool { | |||
| 755 | 781 | ActionEnterSessionMode, | |
| 756 | 782 | ActionEnterResizeMode, | |
| 757 | 783 | ActionEnterVimMode, | |
| 784 | + | ActionEnterPrefixMode, | |
| 758 | 785 | ActionNewTab, | |
| 759 | 786 | ActionRenameTab, | |
| 760 | 787 | ActionSplitRight, | |
| @@ -880,6 +907,17 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool { | |||
| 880 | 907 | } | |
| 881 | 908 | ||
| 882 | 909 | switch action { | |
| 910 | + | case ActionEnterPrefixMode: | |
| 911 | + | if h.modal.Mode() == ModePrefix { | |
| 912 | + | h.modal.ExitMode(h.ctx) | |
| 913 | + | return true | |
| 914 | + | } | |
| 915 | + | var ms int | |
| 916 | + | if workspace != nil { | |
| 917 | + | ms = workspace.PrefixMode.TimeoutMilliseconds | |
| 918 | + | } | |
| 919 | + | h.modal.EnterPrefixMode(h.ctx, time.Duration(ms)*time.Millisecond) | |
| 920 | + | return true | |
| 883 | 921 | case ActionEnterTabMode: | |
| 884 | 922 | var ms int | |
| 885 | 923 | if workspace != nil { | |
| @@ -502,6 +502,9 @@ func (h *KeyboardHandler) handleShortcutLookupResult( | |||||
| 502 | log.Trace().Uint("keyval", keyval).Msg("routing native page navigation key to focused widget in vim mode") | 502 | log.Trace().Uint("keyval", keyval).Msg("routing native page navigation key to focused widget in vim mode") | ||
| 503 | return false | 503 | return false | ||
| 504 | } | 504 | } | ||
| 505 | + | if mode == ModePrefix { | |||
| 506 | + | return h.handleUnboundPrefixKey(keyval, modifiers) | |||
| 507 | + | } | |||
| 505 | return mode != ModeNormal // Consume unrecognized keys in modal mode | 508 | return mode != ModeNormal // Consume unrecognized keys in modal mode | ||
| 506 | } | 509 | } | ||
| 507 | if h.shouldPassthroughVimModeActivation(action, mode) { | 510 | if h.shouldPassthroughVimModeActivation(action, mode) { | ||
| @@ -697,6 +700,26 @@ func (h *KeyboardHandler) confirmVimMode() { | |||||
| 697 | h.modal.ExitMode(h.ctx) | 700 | h.modal.ExitMode(h.ctx) | ||
| 698 | } | 701 | } | ||
| 699 | 702 | ||||
| 703 | + | // handleUnboundPrefixKey handles a key with no prefix action. Modifier and | |||
| 704 | + | // dead-key presses (the Shift before '%') keep the prefix waiting. Any other | |||
| 705 | + | // key cancels the prefix, tmux-style; modified keys then continue to their | |||
| 706 | + | // normal handlers (Alt+1 tab switch, page shortcuts), plain keys are consumed. | |||
| 707 | + | func (h *KeyboardHandler) handleUnboundPrefixKey(keyval uint, modifiers Modifier) bool { | |||
| 708 | + | if isNonCharacterKeysym(keyval) { | |||
| 709 | + | return true | |||
| 710 | + | } | |||
| 711 | + | h.modal.ExitMode(h.ctx) | |||
| 712 | + | return !IsShortcutModified(modifiers) | |||
| 713 | + | } | |||
| 714 | + | ||||
| 715 | + | // isNonCharacterKeysym reports modifier, ISO lock/latch, and dead keysyms: | |||
| 716 | + | // keys pressed on the way to a character rather than characters themselves. | |||
| 717 | + | func isNonCharacterKeysym(keyval uint) bool { | |||
| 718 | + | isModifier := keyval >= uint(gdk.KEY_Shift_L) && keyval <= uint(gdk.KEY_Hyper_R) | |||
| 719 | + | isISOOrDead := keyval >= uint(gdk.KEY_ISO_Lock) && keyval <= uint(gdk.KEY_dead_greek) | |||
| 720 | + | return isModifier || isISOOrDead | |||
| 721 | + | } | |||
| 722 | + | ||||
| 700 | // dispatchAction dispatches the action and handles mode-related logic. | 723 | // dispatchAction dispatches the action and handles mode-related logic. | ||
| 701 | func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool { | 724 | func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool { | ||
| 702 | if h.handleModeAction(action) { | 725 | if h.handleModeAction(action) { | ||
| @@ -721,7 +744,10 @@ func (h *KeyboardHandler) dispatchAction(action Action, mode Mode) bool { | |||||
| 721 | h.modal.ResetTimeout(h.ctx) | 744 | h.modal.ResetTimeout(h.ctx) | ||
| 722 | } | 745 | } | ||
| 723 | 746 | ||||
| 724 | - | if mode != ModeNormal && ShouldAutoExitMode(action) { | 747 | + | // The prefix is one-shot: any action returns to normal mode. Mode-enter |
| 748 | + | // actions already left ModePrefix in handleModeAction. | |||
| 749 | + | if (mode == ModePrefix && h.modal.Mode() == ModePrefix) || | |||
| 750 | + | (mode != ModeNormal && ShouldAutoExitMode(action)) { | |||
| 725 | h.modal.ExitMode(h.ctx) | 751 | h.modal.ExitMode(h.ctx) | ||
| 726 | } | 752 | } | ||
| 727 | 753 | ||||
| @@ -755,6 +781,7 @@ func isRepeatedKeyboardActionSuppressed(action Action) bool { | |||||
| 755 | ActionEnterSessionMode, | 781 | ActionEnterSessionMode, | ||
| 756 | ActionEnterResizeMode, | 782 | ActionEnterResizeMode, | ||
| 757 | ActionEnterVimMode, | 783 | ActionEnterVimMode, | ||
| 784 | + | ActionEnterPrefixMode, | |||
| 758 | ActionNewTab, | 785 | ActionNewTab, | ||
| 759 | ActionRenameTab, | 786 | ActionRenameTab, | ||
| 760 | ActionSplitRight, | 787 | ActionSplitRight, | ||
| @@ -880,6 +907,17 @@ func (h *KeyboardHandler) handleModeAction(action Action) bool { | |||||
| 880 | } | 907 | } | ||
| 881 | 908 | ||||
| 882 | switch action { | 909 | switch action { | ||
| 910 | + | case ActionEnterPrefixMode: | |||
| 911 | + | if h.modal.Mode() == ModePrefix { | |||
| 912 | + | h.modal.ExitMode(h.ctx) | |||
| 913 | + | return true | |||
| 914 | + | } | |||
| 915 | + | var ms int | |||
| 916 | + | if workspace != nil { | |||
| 917 | + | ms = workspace.PrefixMode.TimeoutMilliseconds | |||
| 918 | + | } | |||
| 919 | + | h.modal.EnterPrefixMode(h.ctx, time.Duration(ms)*time.Millisecond) | |||
| 920 | + | return true | |||
| 883 | case ActionEnterTabMode: | 921 | case ActionEnterTabMode: | ||
| 884 | var ms int | 922 | var ms int | ||
| 885 | if workspace != nil { | 923 | if workspace != nil { | ||
M internal/ui/input/modal.go +11 -0
| @@ -28,6 +28,8 @@ const ( | |||
| 28 | 28 | ModeResize | |
| 29 | 29 | // ModeVim is the modal Vim-style scrolling mode. | |
| 30 | 30 | ModeVim | |
| 31 | + | // ModePrefix is the tmux-style one-shot prefix mode. | |
| 32 | + | ModePrefix | |
| 31 | 33 | ) | |
| 32 | 34 | ||
| 33 | 35 | // String returns a human-readable mode name. | |
| @@ -45,6 +47,8 @@ func (m Mode) String() string { | |||
| 45 | 47 | return "resize" | |
| 46 | 48 | case ModeVim: | |
| 47 | 49 | return "vim" | |
| 50 | + | case ModePrefix: | |
| 51 | + | return "prefix" | |
| 48 | 52 | default: | |
| 49 | 53 | return "unknown" | |
| 50 | 54 | } | |
| @@ -63,6 +67,8 @@ func (m Mode) DisplayName() string { | |||
| 63 | 67 | return "RESIZE MODE" | |
| 64 | 68 | case ModeVim: | |
| 65 | 69 | return "VIM MODE" | |
| 70 | + | case ModePrefix: | |
| 71 | + | return "PREFIX" | |
| 66 | 72 | default: | |
| 67 | 73 | return "" | |
| 68 | 74 | } | |
| @@ -140,6 +146,11 @@ func (m *ModalState) EnterSessionMode(ctx context.Context, timeout time.Duration | |||
| 140 | 146 | m.enterMode(ctx, ModeSession, timeout) | |
| 141 | 147 | } | |
| 142 | 148 | ||
| 149 | + | // EnterPrefixMode switches to the one-shot prefix mode with an optional timeout. | |
| 150 | + | func (m *ModalState) EnterPrefixMode(ctx context.Context, timeout time.Duration) { | |
| 151 | + | m.enterMode(ctx, ModePrefix, timeout) | |
| 152 | + | } | |
| 153 | + | ||
| 143 | 154 | // EnterResizeMode switches to resize mode with an optional timeout. | |
| 144 | 155 | func (m *ModalState) EnterResizeMode(ctx context.Context, timeout time.Duration) { | |
| 145 | 156 | m.enterMode(ctx, ModeResize, timeout) | |
| @@ -28,6 +28,8 @@ const ( | |||||
| 28 | ModeResize | 28 | ModeResize | ||
| 29 | // ModeVim is the modal Vim-style scrolling mode. | 29 | // ModeVim is the modal Vim-style scrolling mode. | ||
| 30 | ModeVim | 30 | ModeVim | ||
| 31 | + | // ModePrefix is the tmux-style one-shot prefix mode. | |||
| 32 | + | ModePrefix | |||
| 31 | ) | 33 | ) | ||
| 32 | 34 | ||||
| 33 | // String returns a human-readable mode name. | 35 | // String returns a human-readable mode name. | ||
| @@ -45,6 +47,8 @@ func (m Mode) String() string { | |||||
| 45 | return "resize" | 47 | return "resize" | ||
| 46 | case ModeVim: | 48 | case ModeVim: | ||
| 47 | return "vim" | 49 | return "vim" | ||
| 50 | + | case ModePrefix: | |||
| 51 | + | return "prefix" | |||
| 48 | default: | 52 | default: | ||
| 49 | return "unknown" | 53 | return "unknown" | ||
| 50 | } | 54 | } | ||
| @@ -63,6 +67,8 @@ func (m Mode) DisplayName() string { | |||||
| 63 | return "RESIZE MODE" | 67 | return "RESIZE MODE" | ||
| 64 | case ModeVim: | 68 | case ModeVim: | ||
| 65 | return "VIM MODE" | 69 | return "VIM MODE" | ||
| 70 | + | case ModePrefix: | |||
| 71 | + | return "PREFIX" | |||
| 66 | default: | 72 | default: | ||
| 67 | return "" | 73 | return "" | ||
| 68 | } | 74 | } | ||
| @@ -140,6 +146,11 @@ func (m *ModalState) EnterSessionMode(ctx context.Context, timeout time.Duration | |||||
| 140 | m.enterMode(ctx, ModeSession, timeout) | 146 | m.enterMode(ctx, ModeSession, timeout) | ||
| 141 | } | 147 | } | ||
| 142 | 148 | ||||
| 149 | + | // EnterPrefixMode switches to the one-shot prefix mode with an optional timeout. | |||
| 150 | + | func (m *ModalState) EnterPrefixMode(ctx context.Context, timeout time.Duration) { | |||
| 151 | + | m.enterMode(ctx, ModePrefix, timeout) | |||
| 152 | + | } | |||
| 153 | + | ||||
| 143 | // EnterResizeMode switches to resize mode with an optional timeout. | 154 | // EnterResizeMode switches to resize mode with an optional timeout. | ||
| 144 | func (m *ModalState) EnterResizeMode(ctx context.Context, timeout time.Duration) { | 155 | func (m *ModalState) EnterResizeMode(ctx context.Context, timeout time.Duration) { | ||
| 145 | m.enterMode(ctx, ModeResize, timeout) | 156 | m.enterMode(ctx, ModeResize, timeout) | ||
A internal/ui/input/prefix_mode_test.go +228 -0
| @@ -0,0 +1,228 @@ | |||
| 1 | + | package input | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | "github.com/bnema/puregotk/v4/gdk" | |
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func newTmuxWorkspace() *entity.WorkspaceConfig { | |
| 14 | + | ws := newTestWorkspace() | |
| 15 | + | ws.Keymap = entity.KeymapTmux | |
| 16 | + | ws.VimMode.ActivationShortcut = "ctrl+y" | |
| 17 | + | ws.PrefixMode = entity.PrefixModeConfig{ | |
| 18 | + | ActivationShortcut: "ctrl+space", | |
| 19 | + | Actions: map[string]entity.ActionBinding{ | |
| 20 | + | "split-right": {Keys: []string{"%"}}, | |
| 21 | + | "split-down": {Keys: []string{"\""}}, | |
| 22 | + | "new-tab": {Keys: []string{"c"}}, | |
| 23 | + | "next-tab": {Keys: []string{"n"}}, | |
| 24 | + | "enter-resize-mode": {Keys: []string{"r"}}, | |
| 25 | + | "cancel": {Keys: []string{"escape"}}, | |
| 26 | + | }, | |
| 27 | + | } | |
| 28 | + | return ws | |
| 29 | + | } | |
| 30 | + | ||
| 31 | + | func recordActions(h *KeyboardHandler) *[]Action { | |
| 32 | + | var got []Action | |
| 33 | + | h.SetOnAction(func(_ context.Context, action Action) error { | |
| 34 | + | got = append(got, action) | |
| 35 | + | return nil | |
| 36 | + | }) | |
| 37 | + | return &got | |
| 38 | + | } | |
| 39 | + | ||
| 40 | + | func TestShortcutSet_TmuxKeymapReplacesModeActivations(t *testing.T) { | |
| 41 | + | set := NewShortcutSet(context.Background(), newTmuxWorkspace(), newTestSession()) | |
| 42 | + | ||
| 43 | + | prefix, _ := ParseKeyString("ctrl+space") | |
| 44 | + | assert.Equal(t, ActionEnterPrefixMode, set.Global[prefix]) | |
| 45 | + | for _, key := range []string{"ctrl+t", "ctrl+p", "ctrl+alt+r", "ctrl+s"} { | |
| 46 | + | binding, ok := ParseKeyString(key) | |
| 47 | + | require.True(t, ok) | |
| 48 | + | _, bound := set.Global[binding] | |
| 49 | + | assert.Falsef(t, bound, "%s must not activate a mode in the tmux keymap", key) | |
| 50 | + | } | |
| 51 | + | vim, _ := ParseKeyString("ctrl+y") | |
| 52 | + | assert.Equal(t, ActionEnterVimMode, set.Global[vim], "vim mode keeps its own activation") | |
| 53 | + | } | |
| 54 | + | ||
| 55 | + | func TestShortcutSet_ZellijKeymapIgnoresPrefix(t *testing.T) { | |
| 56 | + | ws := newTmuxWorkspace() | |
| 57 | + | ws.Keymap = entity.KeymapZellij | |
| 58 | + | set := NewShortcutSet(context.Background(), ws, newTestSession()) | |
| 59 | + | ||
| 60 | + | prefix, _ := ParseKeyString("ctrl+space") | |
| 61 | + | _, bound := set.Global[prefix] | |
| 62 | + | assert.False(t, bound) | |
| 63 | + | tab, _ := ParseKeyString("ctrl+t") | |
| 64 | + | assert.Equal(t, ActionEnterTabMode, set.Global[tab]) | |
| 65 | + | } | |
| 66 | + | ||
| 67 | + | func TestHandleKeyPress_PrefixDispatchesOneActionThenExits(t *testing.T) { | |
| 68 | + | h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession()) | |
| 69 | + | got := recordActions(h) | |
| 70 | + | ||
| 71 | + | require.True(t, h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)) | |
| 72 | + | require.Equal(t, ModePrefix, h.Mode()) | |
| 73 | + | ||
| 74 | + | // '%' is typed with Shift on most layouts; the binding omits Shift. | |
| 75 | + | require.True(t, h.handleKeyPress(uint(gdk.KEY_percent), 0, gdk.ShiftMaskValue)) | |
| 76 | + | assert.Equal(t, []Action{ActionSplitRight}, *got) | |
| 77 | + | assert.Equal(t, ModeNormal, h.Mode(), "prefix is one-shot") | |
| 78 | + | ||
| 79 | + | // A plain key after the prefix exited must not dispatch again. | |
| 80 | + | assert.False(t, h.handleKeyPress(uint('n'), 0, 0)) | |
| 81 | + | assert.Len(t, *got, 1) | |
| 82 | + | } | |
| 83 | + | ||
| 84 | + | func TestHandleKeyPress_PrefixUnboundKeyCancels(t *testing.T) { | |
| 85 | + | h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession()) | |
| 86 | + | got := recordActions(h) | |
| 87 | + | ||
| 88 | + | h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue) | |
| 89 | + | assert.True(t, h.handleKeyPress(uint('z'), 0, 0), "unbound key is consumed") | |
| 90 | + | assert.Equal(t, ModeNormal, h.Mode()) | |
| 91 | + | assert.Empty(t, *got) | |
| 92 | + | } | |
| 93 | + | ||
| 94 | + | func TestHandleKeyPress_PrefixCanEnterPersistentMode(t *testing.T) { | |
| 95 | + | h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession()) | |
| 96 | + | ||
| 97 | + | h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue) | |
| 98 | + | h.handleKeyPress(uint('r'), 0, 0) | |
| 99 | + | assert.Equal(t, ModeResize, h.Mode(), "prefix action may switch into another mode") | |
| 100 | + | } | |
| 101 | + | ||
| 102 | + | func TestHandleKeyPress_PrefixPressedTwiceExits(t *testing.T) { | |
| 103 | + | h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession()) | |
| 104 | + | ||
| 105 | + | h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue) | |
| 106 | + | h.handleKeyRelease(uint(gdk.KEY_space)) | |
| 107 | + | h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue) | |
| 108 | + | assert.Equal(t, ModeNormal, h.Mode()) | |
| 109 | + | } | |
| 110 | + | ||
| 111 | + | func TestShortcutSet_BuiltinFallbacksAndOverrides(t *testing.T) { | |
| 112 | + | ws := newTestWorkspace() | |
| 113 | + | ws.Shortcuts.Actions = map[string]entity.ActionBinding{ | |
| 114 | + | "reload": {Keys: []string{}}, // disabled by user | |
| 115 | + | "quit": {Keys: []string{"ctrl+shift+q"}}, // rebound by user | |
| 116 | + | } | |
| 117 | + | set := NewShortcutSet(context.Background(), ws, newTestSession()) | |
| 118 | + | ||
| 119 | + | lookup := func(key string) (Action, bool) { | |
| 120 | + | binding, ok := ParseKeyString(key) | |
| 121 | + | require.True(t, ok, key) | |
| 122 | + | action, found := set.Global[binding] | |
| 123 | + | return action, found | |
| 124 | + | } | |
| 125 | + | ||
| 126 | + | action, ok := lookup("ctrl+l") | |
| 127 | + | assert.True(t, ok, "missing builtin action falls back to its default key") | |
| 128 | + | assert.Equal(t, ActionOpenOmnibox, action) | |
| 129 | + | ||
| 130 | + | _, ok = lookup("ctrl+r") | |
| 131 | + | assert.False(t, ok, "empty keys disable a builtin shortcut") | |
| 132 | + | ||
| 133 | + | _, ok = lookup("ctrl+q") | |
| 134 | + | assert.False(t, ok, "rebound builtin drops its default key") | |
| 135 | + | action, _ = lookup("ctrl+shift+q") | |
| 136 | + | assert.Equal(t, ActionQuit, action) | |
| 137 | + | } | |
| 138 | + | ||
| 139 | + | func TestParseKeyString_PrintableSymbols(t *testing.T) { | |
| 140 | + | for _, key := range []string{"%", "\"", "&", ",", "!"} { | |
| 141 | + | binding, ok := ParseKeyString(key) | |
| 142 | + | require.Truef(t, ok, "%q should parse", key) | |
| 143 | + | assert.Equal(t, uint(key[0]), binding.Keyval) | |
| 144 | + | assert.Equal(t, ModNone, binding.Modifiers) | |
| 145 | + | } | |
| 146 | + | } | |
| 147 | + | ||
| 148 | + | type keyEvent struct { | |
| 149 | + | keyval uint | |
| 150 | + | state gdk.ModifierType | |
| 151 | + | } | |
| 152 | + | ||
| 153 | + | // TestHandleKeyPress_PrefixRealKeySequences replays key events as GTK delivers | |
| 154 | + | // them: modifier and dead-key presses arrive before the character. | |
| 155 | + | func TestHandleKeyPress_PrefixRealKeySequences(t *testing.T) { | |
| 156 | + | shift := gdk.ShiftMaskValue | |
| 157 | + | tests := []struct { | |
| 158 | + | name string | |
| 159 | + | events []keyEvent | |
| 160 | + | wantAction []Action | |
| 161 | + | wantMode Mode | |
| 162 | + | lastResult bool | |
| 163 | + | }{ | |
| 164 | + | { | |
| 165 | + | name: "shift then percent splits right", | |
| 166 | + | events: []keyEvent{{uint(gdk.KEY_Shift_L), 0}, {uint(gdk.KEY_percent), shift}}, | |
| 167 | + | wantAction: []Action{ActionSplitRight}, | |
| 168 | + | wantMode: ModeNormal, | |
| 169 | + | lastResult: true, | |
| 170 | + | }, | |
| 171 | + | { | |
| 172 | + | name: "shift then quote splits down", | |
| 173 | + | events: []keyEvent{{uint(gdk.KEY_Shift_R), 0}, {uint(gdk.KEY_quotedbl), shift}}, | |
| 174 | + | wantAction: []Action{ActionSplitDown}, | |
| 175 | + | wantMode: ModeNormal, | |
| 176 | + | lastResult: true, | |
| 177 | + | }, | |
| 178 | + | { | |
| 179 | + | name: "dead diaeresis splits down on international layouts", | |
| 180 | + | events: []keyEvent{{uint(gdk.KEY_dead_diaeresis), 0}}, | |
| 181 | + | wantAction: []Action{ActionSplitDown}, | |
| 182 | + | wantMode: ModeNormal, | |
| 183 | + | lastResult: true, | |
| 184 | + | }, | |
| 185 | + | { | |
| 186 | + | name: "altgr alone keeps the prefix waiting", | |
| 187 | + | events: []keyEvent{{uint(gdk.KEY_ISO_Level3_Shift), 0}}, | |
| 188 | + | wantMode: ModePrefix, | |
| 189 | + | lastResult: true, | |
| 190 | + | }, | |
| 191 | + | { | |
| 192 | + | name: "unbound modified key cancels and passes through", | |
| 193 | + | events: []keyEvent{{uint(gdk.KEY_Alt_L), 0}, {uint('1'), gdk.AltMaskValue}}, | |
| 194 | + | wantMode: ModeNormal, | |
| 195 | + | lastResult: false, | |
| 196 | + | }, | |
| 197 | + | } | |
| 198 | + | ||
| 199 | + | for _, tt := range tests { | |
| 200 | + | t.Run(tt.name, func(t *testing.T) { | |
| 201 | + | h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession()) | |
| 202 | + | got := recordActions(h) | |
| 203 | + | require.True(t, h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)) | |
| 204 | + | h.handleKeyRelease(uint(gdk.KEY_space)) | |
| 205 | + | ||
| 206 | + | var result bool | |
| 207 | + | for _, ev := range tt.events { | |
| 208 | + | result = h.handleKeyPress(ev.keyval, 0, ev.state) | |
| 209 | + | } | |
| 210 | + | assert.Equal(t, tt.lastResult, result) | |
| 211 | + | assert.Equal(t, tt.wantMode, h.Mode()) | |
| 212 | + | assert.Equal(t, tt.wantAction, *got) | |
| 213 | + | }) | |
| 214 | + | } | |
| 215 | + | } | |
| 216 | + | ||
| 217 | + | func TestShortcutSet_BuiltinWinsSharedGlobalKey(t *testing.T) { | |
| 218 | + | ws := newTestWorkspace() | |
| 219 | + | ws.Shortcuts.Actions = map[string]entity.ActionBinding{ | |
| 220 | + | "toggle-floating-pane": {Keys: []string{"ctrl+l"}}, | |
| 221 | + | "open-omnibox": {Keys: []string{"ctrl+l"}}, | |
| 222 | + | } | |
| 223 | + | for range 20 { | |
| 224 | + | set := NewShortcutSet(context.Background(), ws, newTestSession()) | |
| 225 | + | binding, _ := ParseKeyString("ctrl+l") | |
| 226 | + | require.Equal(t, ActionOpenOmnibox, set.Global[binding]) | |
| 227 | + | } | |
| 228 | + | } | |
| @@ -0,0 +1,228 @@ | |||||
| 1 | + | package input | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 8 | + | "github.com/bnema/puregotk/v4/gdk" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func newTmuxWorkspace() *entity.WorkspaceConfig { | |||
| 14 | + | ws := newTestWorkspace() | |||
| 15 | + | ws.Keymap = entity.KeymapTmux | |||
| 16 | + | ws.VimMode.ActivationShortcut = "ctrl+y" | |||
| 17 | + | ws.PrefixMode = entity.PrefixModeConfig{ | |||
| 18 | + | ActivationShortcut: "ctrl+space", | |||
| 19 | + | Actions: map[string]entity.ActionBinding{ | |||
| 20 | + | "split-right": {Keys: []string{"%"}}, | |||
| 21 | + | "split-down": {Keys: []string{"\""}}, | |||
| 22 | + | "new-tab": {Keys: []string{"c"}}, | |||
| 23 | + | "next-tab": {Keys: []string{"n"}}, | |||
| 24 | + | "enter-resize-mode": {Keys: []string{"r"}}, | |||
| 25 | + | "cancel": {Keys: []string{"escape"}}, | |||
| 26 | + | }, | |||
| 27 | + | } | |||
| 28 | + | return ws | |||
| 29 | + | } | |||
| 30 | + | ||||
| 31 | + | func recordActions(h *KeyboardHandler) *[]Action { | |||
| 32 | + | var got []Action | |||
| 33 | + | h.SetOnAction(func(_ context.Context, action Action) error { | |||
| 34 | + | got = append(got, action) | |||
| 35 | + | return nil | |||
| 36 | + | }) | |||
| 37 | + | return &got | |||
| 38 | + | } | |||
| 39 | + | ||||
| 40 | + | func TestShortcutSet_TmuxKeymapReplacesModeActivations(t *testing.T) { | |||
| 41 | + | set := NewShortcutSet(context.Background(), newTmuxWorkspace(), newTestSession()) | |||
| 42 | + | ||||
| 43 | + | prefix, _ := ParseKeyString("ctrl+space") | |||
| 44 | + | assert.Equal(t, ActionEnterPrefixMode, set.Global[prefix]) | |||
| 45 | + | for _, key := range []string{"ctrl+t", "ctrl+p", "ctrl+alt+r", "ctrl+s"} { | |||
| 46 | + | binding, ok := ParseKeyString(key) | |||
| 47 | + | require.True(t, ok) | |||
| 48 | + | _, bound := set.Global[binding] | |||
| 49 | + | assert.Falsef(t, bound, "%s must not activate a mode in the tmux keymap", key) | |||
| 50 | + | } | |||
| 51 | + | vim, _ := ParseKeyString("ctrl+y") | |||
| 52 | + | assert.Equal(t, ActionEnterVimMode, set.Global[vim], "vim mode keeps its own activation") | |||
| 53 | + | } | |||
| 54 | + | ||||
| 55 | + | func TestShortcutSet_ZellijKeymapIgnoresPrefix(t *testing.T) { | |||
| 56 | + | ws := newTmuxWorkspace() | |||
| 57 | + | ws.Keymap = entity.KeymapZellij | |||
| 58 | + | set := NewShortcutSet(context.Background(), ws, newTestSession()) | |||
| 59 | + | ||||
| 60 | + | prefix, _ := ParseKeyString("ctrl+space") | |||
| 61 | + | _, bound := set.Global[prefix] | |||
| 62 | + | assert.False(t, bound) | |||
| 63 | + | tab, _ := ParseKeyString("ctrl+t") | |||
| 64 | + | assert.Equal(t, ActionEnterTabMode, set.Global[tab]) | |||
| 65 | + | } | |||
| 66 | + | ||||
| 67 | + | func TestHandleKeyPress_PrefixDispatchesOneActionThenExits(t *testing.T) { | |||
| 68 | + | h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession()) | |||
| 69 | + | got := recordActions(h) | |||
| 70 | + | ||||
| 71 | + | require.True(t, h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)) | |||
| 72 | + | require.Equal(t, ModePrefix, h.Mode()) | |||
| 73 | + | ||||
| 74 | + | // '%' is typed with Shift on most layouts; the binding omits Shift. | |||
| 75 | + | require.True(t, h.handleKeyPress(uint(gdk.KEY_percent), 0, gdk.ShiftMaskValue)) | |||
| 76 | + | assert.Equal(t, []Action{ActionSplitRight}, *got) | |||
| 77 | + | assert.Equal(t, ModeNormal, h.Mode(), "prefix is one-shot") | |||
| 78 | + | ||||
| 79 | + | // A plain key after the prefix exited must not dispatch again. | |||
| 80 | + | assert.False(t, h.handleKeyPress(uint('n'), 0, 0)) | |||
| 81 | + | assert.Len(t, *got, 1) | |||
| 82 | + | } | |||
| 83 | + | ||||
| 84 | + | func TestHandleKeyPress_PrefixUnboundKeyCancels(t *testing.T) { | |||
| 85 | + | h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession()) | |||
| 86 | + | got := recordActions(h) | |||
| 87 | + | ||||
| 88 | + | h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue) | |||
| 89 | + | assert.True(t, h.handleKeyPress(uint('z'), 0, 0), "unbound key is consumed") | |||
| 90 | + | assert.Equal(t, ModeNormal, h.Mode()) | |||
| 91 | + | assert.Empty(t, *got) | |||
| 92 | + | } | |||
| 93 | + | ||||
| 94 | + | func TestHandleKeyPress_PrefixCanEnterPersistentMode(t *testing.T) { | |||
| 95 | + | h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession()) | |||
| 96 | + | ||||
| 97 | + | h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue) | |||
| 98 | + | h.handleKeyPress(uint('r'), 0, 0) | |||
| 99 | + | assert.Equal(t, ModeResize, h.Mode(), "prefix action may switch into another mode") | |||
| 100 | + | } | |||
| 101 | + | ||||
| 102 | + | func TestHandleKeyPress_PrefixPressedTwiceExits(t *testing.T) { | |||
| 103 | + | h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession()) | |||
| 104 | + | ||||
| 105 | + | h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue) | |||
| 106 | + | h.handleKeyRelease(uint(gdk.KEY_space)) | |||
| 107 | + | h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue) | |||
| 108 | + | assert.Equal(t, ModeNormal, h.Mode()) | |||
| 109 | + | } | |||
| 110 | + | ||||
| 111 | + | func TestShortcutSet_BuiltinFallbacksAndOverrides(t *testing.T) { | |||
| 112 | + | ws := newTestWorkspace() | |||
| 113 | + | ws.Shortcuts.Actions = map[string]entity.ActionBinding{ | |||
| 114 | + | "reload": {Keys: []string{}}, // disabled by user | |||
| 115 | + | "quit": {Keys: []string{"ctrl+shift+q"}}, // rebound by user | |||
| 116 | + | } | |||
| 117 | + | set := NewShortcutSet(context.Background(), ws, newTestSession()) | |||
| 118 | + | ||||
| 119 | + | lookup := func(key string) (Action, bool) { | |||
| 120 | + | binding, ok := ParseKeyString(key) | |||
| 121 | + | require.True(t, ok, key) | |||
| 122 | + | action, found := set.Global[binding] | |||
| 123 | + | return action, found | |||
| 124 | + | } | |||
| 125 | + | ||||
| 126 | + | action, ok := lookup("ctrl+l") | |||
| 127 | + | assert.True(t, ok, "missing builtin action falls back to its default key") | |||
| 128 | + | assert.Equal(t, ActionOpenOmnibox, action) | |||
| 129 | + | ||||
| 130 | + | _, ok = lookup("ctrl+r") | |||
| 131 | + | assert.False(t, ok, "empty keys disable a builtin shortcut") | |||
| 132 | + | ||||
| 133 | + | _, ok = lookup("ctrl+q") | |||
| 134 | + | assert.False(t, ok, "rebound builtin drops its default key") | |||
| 135 | + | action, _ = lookup("ctrl+shift+q") | |||
| 136 | + | assert.Equal(t, ActionQuit, action) | |||
| 137 | + | } | |||
| 138 | + | ||||
| 139 | + | func TestParseKeyString_PrintableSymbols(t *testing.T) { | |||
| 140 | + | for _, key := range []string{"%", "\"", "&", ",", "!"} { | |||
| 141 | + | binding, ok := ParseKeyString(key) | |||
| 142 | + | require.Truef(t, ok, "%q should parse", key) | |||
| 143 | + | assert.Equal(t, uint(key[0]), binding.Keyval) | |||
| 144 | + | assert.Equal(t, ModNone, binding.Modifiers) | |||
| 145 | + | } | |||
| 146 | + | } | |||
| 147 | + | ||||
| 148 | + | type keyEvent struct { | |||
| 149 | + | keyval uint | |||
| 150 | + | state gdk.ModifierType | |||
| 151 | + | } | |||
| 152 | + | ||||
| 153 | + | // TestHandleKeyPress_PrefixRealKeySequences replays key events as GTK delivers | |||
| 154 | + | // them: modifier and dead-key presses arrive before the character. | |||
| 155 | + | func TestHandleKeyPress_PrefixRealKeySequences(t *testing.T) { | |||
| 156 | + | shift := gdk.ShiftMaskValue | |||
| 157 | + | tests := []struct { | |||
| 158 | + | name string | |||
| 159 | + | events []keyEvent | |||
| 160 | + | wantAction []Action | |||
| 161 | + | wantMode Mode | |||
| 162 | + | lastResult bool | |||
| 163 | + | }{ | |||
| 164 | + | { | |||
| 165 | + | name: "shift then percent splits right", | |||
| 166 | + | events: []keyEvent{{uint(gdk.KEY_Shift_L), 0}, {uint(gdk.KEY_percent), shift}}, | |||
| 167 | + | wantAction: []Action{ActionSplitRight}, | |||
| 168 | + | wantMode: ModeNormal, | |||
| 169 | + | lastResult: true, | |||
| 170 | + | }, | |||
| 171 | + | { | |||
| 172 | + | name: "shift then quote splits down", | |||
| 173 | + | events: []keyEvent{{uint(gdk.KEY_Shift_R), 0}, {uint(gdk.KEY_quotedbl), shift}}, | |||
| 174 | + | wantAction: []Action{ActionSplitDown}, | |||
| 175 | + | wantMode: ModeNormal, | |||
| 176 | + | lastResult: true, | |||
| 177 | + | }, | |||
| 178 | + | { | |||
| 179 | + | name: "dead diaeresis splits down on international layouts", | |||
| 180 | + | events: []keyEvent{{uint(gdk.KEY_dead_diaeresis), 0}}, | |||
| 181 | + | wantAction: []Action{ActionSplitDown}, | |||
| 182 | + | wantMode: ModeNormal, | |||
| 183 | + | lastResult: true, | |||
| 184 | + | }, | |||
| 185 | + | { | |||
| 186 | + | name: "altgr alone keeps the prefix waiting", | |||
| 187 | + | events: []keyEvent{{uint(gdk.KEY_ISO_Level3_Shift), 0}}, | |||
| 188 | + | wantMode: ModePrefix, | |||
| 189 | + | lastResult: true, | |||
| 190 | + | }, | |||
| 191 | + | { | |||
| 192 | + | name: "unbound modified key cancels and passes through", | |||
| 193 | + | events: []keyEvent{{uint(gdk.KEY_Alt_L), 0}, {uint('1'), gdk.AltMaskValue}}, | |||
| 194 | + | wantMode: ModeNormal, | |||
| 195 | + | lastResult: false, | |||
| 196 | + | }, | |||
| 197 | + | } | |||
| 198 | + | ||||
| 199 | + | for _, tt := range tests { | |||
| 200 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 201 | + | h := NewKeyboardHandler(context.Background(), newTmuxWorkspace(), newTestSession()) | |||
| 202 | + | got := recordActions(h) | |||
| 203 | + | require.True(t, h.handleKeyPress(uint(gdk.KEY_space), 0, gdk.ControlMaskValue)) | |||
| 204 | + | h.handleKeyRelease(uint(gdk.KEY_space)) | |||
| 205 | + | ||||
| 206 | + | var result bool | |||
| 207 | + | for _, ev := range tt.events { | |||
| 208 | + | result = h.handleKeyPress(ev.keyval, 0, ev.state) | |||
| 209 | + | } | |||
| 210 | + | assert.Equal(t, tt.lastResult, result) | |||
| 211 | + | assert.Equal(t, tt.wantMode, h.Mode()) | |||
| 212 | + | assert.Equal(t, tt.wantAction, *got) | |||
| 213 | + | }) | |||
| 214 | + | } | |||
| 215 | + | } | |||
| 216 | + | ||||
| 217 | + | func TestShortcutSet_BuiltinWinsSharedGlobalKey(t *testing.T) { | |||
| 218 | + | ws := newTestWorkspace() | |||
| 219 | + | ws.Shortcuts.Actions = map[string]entity.ActionBinding{ | |||
| 220 | + | "toggle-floating-pane": {Keys: []string{"ctrl+l"}}, | |||
| 221 | + | "open-omnibox": {Keys: []string{"ctrl+l"}}, | |||
| 222 | + | } | |||
| 223 | + | for range 20 { | |||
| 224 | + | set := NewShortcutSet(context.Background(), ws, newTestSession()) | |||
| 225 | + | binding, _ := ParseKeyString("ctrl+l") | |||
| 226 | + | require.Equal(t, ActionOpenOmnibox, set.Global[binding]) | |||
| 227 | + | } | |||
| 228 | + | } | |||
M internal/ui/input/shortcuts.go +143 -129
| @@ -4,6 +4,7 @@ package input | |||
| 4 | 4 | import ( | |
| 5 | 5 | "context" | |
| 6 | 6 | "maps" | |
| 7 | + | "slices" | |
| 7 | 8 | "sort" | |
| 8 | 9 | "strings" | |
| 9 | 10 | ||
| @@ -109,6 +110,7 @@ const ( | |||
| 109 | 110 | ActionEnterPaneMode Action = "enter_pane_mode" | |
| 110 | 111 | ActionEnterSessionMode Action = "enter_session_mode" | |
| 111 | 112 | ActionEnterResizeMode Action = "enter_resize_mode" | |
| 113 | + | ActionEnterPrefixMode Action = "enter_prefix_mode" | |
| 112 | 114 | ActionExitMode Action = "exit_mode" | |
| 113 | 115 | ||
| 114 | 116 | // Tab actions (global and modal) | |
| @@ -235,6 +237,8 @@ type ShortcutSet struct { | |||
| 235 | 237 | ResizeMode ShortcutTable | |
| 236 | 238 | // VimMode shortcuts are only active in vim mode. | |
| 237 | 239 | VimMode ShortcutTable | |
| 240 | + | // PrefixMode shortcuts are one-shot actions after the tmux-style prefix. | |
| 241 | + | PrefixMode ShortcutTable | |
| 238 | 242 | // vimModeSequences holds multi-key Vim mode bindings. | |
| 239 | 243 | vimModeSequences *vimkeys.Trie | |
| 240 | 244 | } | |
| @@ -251,6 +255,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess | |||
| 251 | 255 | SessionMode: make(ShortcutTable), | |
| 252 | 256 | ResizeMode: make(ShortcutTable), | |
| 253 | 257 | VimMode: make(ShortcutTable), | |
| 258 | + | PrefixMode: make(ShortcutTable), | |
| 254 | 259 | } | |
| 255 | 260 | ||
| 256 | 261 | set.buildGlobalShortcutsFromParts(ctx, workspace, session) | |
| @@ -259,6 +264,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess | |||
| 259 | 264 | set.buildPaneModeShortcuts(ctx, workspace) | |
| 260 | 265 | set.buildResizeModeShortcuts(ctx, workspace) | |
| 261 | 266 | set.buildVimModeShortcuts(ctx, workspace) | |
| 267 | + | set.buildModeShortcuts(ctx, workspace.PrefixMode.GetKeyBindings(), set.PrefixMode, "prefix") | |
| 262 | 268 | } | |
| 263 | 269 | if session != nil { | |
| 264 | 270 | set.buildSessionModeShortcuts(ctx, session) | |
| @@ -271,6 +277,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess | |||
| 271 | 277 | Int("resize", len(set.ResizeMode)). | |
| 272 | 278 | Int("session", len(set.SessionMode)). | |
| 273 | 279 | Int("vim", len(set.VimMode)). | |
| 280 | + | Int("prefix", len(set.PrefixMode)). | |
| 274 | 281 | Msg("shortcuts registered") | |
| 275 | 282 | ||
| 276 | 283 | return set | |
| @@ -278,11 +285,12 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess | |||
| 278 | 285 | ||
| 279 | 286 | // buildGlobalShortcutsFromParts populates global shortcuts from workspace and session configs. | |
| 280 | 287 | func (s *ShortcutSet) buildGlobalShortcutsFromParts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) { | |
| 288 | + | s.registerBuiltinShortcutFallbacks(workspace) | |
| 281 | 289 | s.registerActivationShortcutsFromParts(ctx, workspace, session) | |
| 282 | 290 | s.registerConfiguredShortcuts(workspace) | |
| 283 | - | s.registerStandardShortcuts() | |
| 284 | - | s.registerPaneNavigationShortcuts() | |
| 285 | - | s.registerTabSwitchShortcuts() | |
| 291 | + | // Alt+1-9, Alt+0, and Alt+Tab are registered by GlobalShortcutHandler; | |
| 292 | + | // Alt+Shift+Tab stays here as a fallback binding. | |
| 293 | + | s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab | |
| 286 | 294 | s.registerFloatingProfileShortcutsFromWorkspace(ctx, workspace) | |
| 287 | 295 | } | |
| 288 | 296 | ||
| @@ -330,77 +338,63 @@ func (s *ShortcutSet) buildVimModeShortcuts(ctx context.Context, cfg *entity.Wor | |||
| 330 | 338 | } | |
| 331 | 339 | } | |
| 332 | 340 | ||
| 341 | + | // registerActivationShortcutsFromParts registers mode activation shortcuts. | |
| 342 | + | // The zellij keymap binds one shortcut per mode; the tmux keymap binds a single | |
| 343 | + | // one-shot prefix instead of the pane, tab, resize, and session activations. | |
| 344 | + | // Vim Mode keeps its own activation in both keymaps. | |
| 333 | 345 | func (s *ShortcutSet) registerActivationShortcutsFromParts( | |
| 334 | 346 | ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig, | |
| 335 | 347 | ) { | |
| 336 | - | log := logging.FromContext(ctx) | |
| 337 | - | if workspace == nil { | |
| 338 | - | // Still register session mode if available. | |
| 339 | - | if session != nil { | |
| 340 | - | if binding, ok := ParseKeyString(session.SessionMode.ActivationShortcut); ok { | |
| 341 | - | s.Global[binding] = ActionEnterSessionMode | |
| 342 | - | log.Trace(). | |
| 343 | - | Str("shortcut", session.SessionMode.ActivationShortcut). | |
| 344 | - | Uint("keyval", binding.Keyval). | |
| 345 | - | Uint("mod", uint(binding.Modifiers)). | |
| 346 | - | Msg("session mode activation registered") | |
| 347 | - | } else { | |
| 348 | - | log.Warn().Str("shortcut", session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut") | |
| 349 | - | } | |
| 348 | + | if workspace != nil { | |
| 349 | + | s.registerActivation(ctx, workspace.VimMode.ActivationShortcut, ActionEnterVimMode) | |
| 350 | + | if workspace.Keymap == entity.KeymapTmux { | |
| 351 | + | s.registerActivation(ctx, workspace.PrefixMode.ActivationShortcut, ActionEnterPrefixMode) | |
| 352 | + | return | |
| 350 | 353 | } | |
| 351 | - | return | |
| 354 | + | s.registerActivation(ctx, workspace.TabMode.ActivationShortcut, ActionEnterTabMode) | |
| 355 | + | s.registerActivation(ctx, workspace.PaneMode.ActivationShortcut, ActionEnterPaneMode) | |
| 356 | + | s.registerActivation(ctx, workspace.ResizeMode.ActivationShortcut, ActionEnterResizeMode) | |
| 352 | 357 | } | |
| 353 | - | if binding, ok := ParseKeyString(workspace.VimMode.ActivationShortcut); ok { | |
| 354 | - | s.Global[binding] = ActionEnterVimMode | |
| 355 | - | log.Trace(). | |
| 356 | - | Str("shortcut", workspace.VimMode.ActivationShortcut). | |
| 357 | - | Uint("keyval", binding.Keyval). | |
| 358 | - | Uint("mod", uint(binding.Modifiers)). | |
| 359 | - | Msg("vim mode activation registered") | |
| 360 | - | } else { | |
| 361 | - | log.Warn().Str("shortcut", workspace.VimMode.ActivationShortcut).Msg("failed to parse vim mode activation shortcut") | |
| 362 | - | } | |
| 363 | - | if binding, ok := ParseKeyString(workspace.TabMode.ActivationShortcut); ok { | |
| 364 | - | s.Global[binding] = ActionEnterTabMode | |
| 365 | - | log.Trace(). | |
| 366 | - | Str("shortcut", workspace.TabMode.ActivationShortcut). | |
| 367 | - | Uint("keyval", binding.Keyval). | |
| 368 | - | Uint("mod", uint(binding.Modifiers)). | |
| 369 | - | Msg("tab mode activation registered") | |
| 370 | - | } else { | |
| 371 | - | log.Warn().Str("shortcut", workspace.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut") | |
| 358 | + | if session != nil { | |
| 359 | + | s.registerActivation(ctx, session.SessionMode.ActivationShortcut, ActionEnterSessionMode) | |
| 372 | 360 | } | |
| 373 | - | if binding, ok := ParseKeyString(workspace.PaneMode.ActivationShortcut); ok { | |
| 374 | - | s.Global[binding] = ActionEnterPaneMode | |
| 375 | - | log.Trace(). | |
| 376 | - | Str("shortcut", workspace.PaneMode.ActivationShortcut). | |
| 377 | - | Uint("keyval", binding.Keyval). | |
| 378 | - | Uint("mod", uint(binding.Modifiers)). | |
| 379 | - | Msg("pane mode activation registered") | |
| 380 | - | } else { | |
| 381 | - | log.Warn().Str("shortcut", workspace.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut") | |
| 361 | + | } | |
| 362 | + | ||
| 363 | + | func (s *ShortcutSet) registerActivation(ctx context.Context, shortcut string, action Action) { | |
| 364 | + | log := logging.FromContext(ctx) | |
| 365 | + | binding, ok := ParseKeyString(shortcut) | |
| 366 | + | if !ok { | |
| 367 | + | log.Warn().Str("shortcut", shortcut).Str("action", string(action)).Msg("failed to parse mode activation shortcut") | |
| 368 | + | return | |
| 382 | 369 | } | |
| 383 | - | if session != nil { | |
| 384 | - | if binding, ok := ParseKeyString(session.SessionMode.ActivationShortcut); ok { | |
| 385 | - | s.Global[binding] = ActionEnterSessionMode | |
| 386 | - | log.Trace(). | |
| 387 | - | Str("shortcut", session.SessionMode.ActivationShortcut). | |
| 388 | - | Uint("keyval", binding.Keyval). | |
| 389 | - | Uint("mod", uint(binding.Modifiers)). | |
| 390 | - | Msg("session mode activation registered") | |
| 391 | - | } else { | |
| 392 | - | log.Warn().Str("shortcut", session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut") | |
| 370 | + | s.Global[binding] = action | |
| 371 | + | log.Trace(). | |
| 372 | + | Str("shortcut", shortcut). | |
| 373 | + | Str("action", string(action)). | |
| 374 | + | Uint("keyval", binding.Keyval). | |
| 375 | + | Uint("mod", uint(binding.Modifiers)). | |
| 376 | + | Msg("mode activation registered") | |
| 377 | + | } | |
| 378 | + | ||
| 379 | + | // registerBuiltinShortcutFallbacks binds built-in browser shortcuts whose | |
| 380 | + | // action is missing from the configured global shortcuts, so configs written | |
| 381 | + | // before these actions became configurable keep working until migrated. | |
| 382 | + | func (s *ShortcutSet) registerBuiltinShortcutFallbacks(cfg *entity.WorkspaceConfig) { | |
| 383 | + | for name, binding := range entity.BuiltinGlobalShortcuts() { | |
| 384 | + | if cfg != nil { | |
| 385 | + | if _, configured := cfg.Shortcuts.Actions[name]; configured { | |
| 386 | + | continue | |
| 387 | + | } | |
| 388 | + | } | |
| 389 | + | action, ok := configActionToAction[name] | |
| 390 | + | if !ok { | |
| 391 | + | continue | |
| 392 | + | } | |
| 393 | + | for _, key := range binding.Keys { | |
| 394 | + | if kb, ok := ParseKeyString(key); ok { | |
| 395 | + | s.Global[kb] = action | |
| 396 | + | } | |
| 393 | 397 | } | |
| 394 | - | } | |
| 395 | - | if binding, ok := ParseKeyString(workspace.ResizeMode.ActivationShortcut); ok { | |
| 396 | - | s.Global[binding] = ActionEnterResizeMode | |
| 397 | - | log.Trace(). | |
| 398 | - | Str("shortcut", workspace.ResizeMode.ActivationShortcut). | |
| 399 | - | Uint("keyval", binding.Keyval). | |
| 400 | - | Uint("mod", uint(binding.Modifiers)). | |
| 401 | - | Msg("resize mode activation registered") | |
| 402 | - | } else { | |
| 403 | - | log.Warn().Str("shortcut", workspace.ResizeMode.ActivationShortcut).Msg("failed to parse resize mode activation shortcut") | |
| 404 | 398 | } | |
| 405 | 399 | } | |
| 406 | 400 | ||
| @@ -408,22 +402,12 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) { | |||
| 408 | 402 | if cfg == nil { | |
| 409 | 403 | return | |
| 410 | 404 | } | |
| 411 | - | // Note: Ctrl+T is NOT registered globally - it enters tab mode. | |
| 412 | - | // In tab mode, use: | |
| 413 | - | // n = new tab | |
| 414 | - | // x = close tab | |
| 415 | - | // l/tab = next tab | |
| 416 | - | // h/shift+tab = previous tab | |
| 417 | - | // r = rename tab | |
| 418 | - | // This follows Zellij-style modal keyboard interface. | |
| 419 | - | // | |
| 420 | - | // However, these standard browser shortcuts ARE global. | |
| 421 | - | for actionName, actionBinding := range cfg.Shortcuts.Actions { | |
| 405 | + | for _, actionName := range orderedGlobalShortcutActions(cfg.Shortcuts.Actions) { | |
| 422 | 406 | action, ok := configActionToAction[actionName] | |
| 423 | 407 | if !ok { | |
| 424 | 408 | continue | |
| 425 | 409 | } | |
| 426 | - | for _, keyStr := range actionBinding.Keys { | |
| 410 | + | for _, keyStr := range cfg.Shortcuts.Actions[actionName].Keys { | |
| 427 | 411 | if binding, ok := ParseKeyString(keyStr); ok { | |
| 428 | 412 | s.Global[binding] = action | |
| 429 | 413 | } | |
| @@ -431,6 +415,28 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) { | |||
| 431 | 415 | } | |
| 432 | 416 | } | |
| 433 | 417 | ||
| 418 | + | // orderedGlobalShortcutActions returns action names in registration order: | |
| 419 | + | // sorted, with built-in browser actions last so they win a shared key, as | |
| 420 | + | // they did when they were hard-coded. A stable order keeps the winner of a | |
| 421 | + | // key conflict the same across restarts. | |
| 422 | + | func orderedGlobalShortcutActions(actions map[string]entity.ActionBinding) []string { | |
| 423 | + | builtins := entity.BuiltinGlobalShortcuts() | |
| 424 | + | names := slices.Sorted(maps.Keys(actions)) | |
| 425 | + | slices.SortStableFunc(names, func(a, b string) int { | |
| 426 | + | _, aBuiltin := builtins[a] | |
| 427 | + | _, bBuiltin := builtins[b] | |
| 428 | + | switch { | |
| 429 | + | case aBuiltin == bBuiltin: | |
| 430 | + | return 0 | |
| 431 | + | case aBuiltin: | |
| 432 | + | return 1 | |
| 433 | + | default: | |
| 434 | + | return -1 | |
| 435 | + | } | |
| 436 | + | }) | |
| 437 | + | return names | |
| 438 | + | } | |
| 439 | + | ||
| 434 | 440 | func (s *ShortcutSet) registerFloatingProfileShortcutsFromWorkspace(ctx context.Context, workspace *entity.WorkspaceConfig) { | |
| 435 | 441 | occupied := make(map[KeyBinding]Action, len(s.Global)) | |
| 436 | 442 | maps.Copy(occupied, s.Global) | |
| @@ -537,54 +543,6 @@ func reserveGlobalOnlyShortcutBindings(occupied map[KeyBinding]Action) { | |||
| 537 | 543 | } | |
| 538 | 544 | } | |
| 539 | 545 | ||
| 540 | - | func (s *ShortcutSet) registerStandardShortcuts() { | |
| 541 | - | s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox | |
| 542 | - | s.Global[KeyBinding{uint(gdk.KEY_f), ModCtrl}] = ActionOpenFind | |
| 543 | - | s.Global[KeyBinding{uint(gdk.KEY_F3), ModNone}] = ActionFindNext | |
| 544 | - | s.Global[KeyBinding{uint(gdk.KEY_F3), ModShift}] = ActionFindPrev | |
| 545 | - | s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl}] = ActionFindNext | |
| 546 | - | s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl | ModShift}] = ActionFindPrev | |
| 547 | - | s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload | |
| 548 | - | s.Global[KeyBinding{uint('r'), ModCtrl | ModShift}] = ActionHardReload | |
| 549 | - | s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload | |
| 550 | - | s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload | |
| 551 | - | s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools | |
| 552 | - | s.Global[KeyBinding{uint(gdk.KEY_Left), ModCtrl}] = ActionGoBack | |
| 553 | - | s.Global[KeyBinding{uint(gdk.KEY_Right), ModCtrl}] = ActionGoForward | |
| 554 | - | s.Global[KeyBinding{uint(gdk.KEY_plus), ModCtrl}] = ActionZoomIn | |
| 555 | - | s.Global[KeyBinding{uint(gdk.KEY_equal), ModCtrl}] = ActionZoomIn // Ctrl+= (no shift needed) | |
| 556 | - | s.Global[KeyBinding{uint(gdk.KEY_minus), ModCtrl}] = ActionZoomOut | |
| 557 | - | s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset | |
| 558 | - | s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit | |
| 559 | - | s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen | |
| 560 | - | s.Global[KeyBinding{uint('c'), ModCtrl | ModShift}] = ActionCopyURL | |
| 561 | - | s.Global[KeyBinding{uint('p'), ModCtrl | ModShift}] = ActionPrintPage | |
| 562 | - | // Session management - direct shortcut to open session manager | |
| 563 | - | s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager | |
| 564 | - | } | |
| 565 | - | ||
| 566 | - | func (s *ShortcutSet) registerPaneNavigationShortcuts() { | |
| 567 | - | s.Global[KeyBinding{uint(gdk.KEY_h), ModAlt}] = ActionFocusLeft | |
| 568 | - | s.Global[KeyBinding{uint(gdk.KEY_l), ModAlt}] = ActionFocusRight | |
| 569 | - | s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp | |
| 570 | - | s.Global[KeyBinding{uint(gdk.KEY_j), ModAlt}] = ActionFocusDown | |
| 571 | - | ||
| 572 | - | s.Global[KeyBinding{uint(gdk.KEY_Left), ModAlt}] = ActionFocusLeft | |
| 573 | - | s.Global[KeyBinding{uint(gdk.KEY_Right), ModAlt}] = ActionFocusRight | |
| 574 | - | s.Global[KeyBinding{uint(gdk.KEY_Up), ModAlt}] = ActionFocusUp | |
| 575 | - | s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown | |
| 576 | - | } | |
| 577 | - | ||
| 578 | - | func (s *ShortcutSet) registerTabSwitchShortcuts() { | |
| 579 | - | // NOTE: Alt+1-9, Alt+0, and Alt+Tab are now handled by GlobalShortcutHandler | |
| 580 | - | // using GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL. | |
| 581 | - | // This is necessary because WebKitGTK's WebView consumes these key events | |
| 582 | - | // before they reach the EventControllerKey in capture phase. | |
| 583 | - | // | |
| 584 | - | // Only Alt+Shift+Tab remains here as a fallback binding. | |
| 585 | - | s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab | |
| 586 | - | } | |
| 587 | - | ||
| 588 | 546 | func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[string]string, dest map[KeyBinding]Action, mode string) { | |
| 589 | 547 | log := logging.FromContext(ctx) | |
| 590 | 548 | var registered, parseErrors, unknownActions int | |
| @@ -695,6 +653,31 @@ var configActionToAction = map[string]Action{ | |||
| 695 | 653 | // Session actions | |
| 696 | 654 | "session-manager": ActionOpenSessionManager, | |
| 697 | 655 | ||
| 656 | + | // Browser actions | |
| 657 | + | "open-omnibox": ActionOpenOmnibox, | |
| 658 | + | "open-find": ActionOpenFind, | |
| 659 | + | "find-next": ActionFindNext, | |
| 660 | + | "find-prev": ActionFindPrev, | |
| 661 | + | "reload": ActionReload, | |
| 662 | + | "hard-reload": ActionHardReload, | |
| 663 | + | "open-devtools": ActionOpenDevTools, | |
| 664 | + | "go-back": ActionGoBack, | |
| 665 | + | "go-forward": ActionGoForward, | |
| 666 | + | "zoom-in": ActionZoomIn, | |
| 667 | + | "zoom-out": ActionZoomOut, | |
| 668 | + | "zoom-reset": ActionZoomReset, | |
| 669 | + | "quit": ActionQuit, | |
| 670 | + | "toggle-fullscreen": ActionToggleFullscreen, | |
| 671 | + | "copy-url": ActionCopyURL, | |
| 672 | + | "print-page": ActionPrintPage, | |
| 673 | + | ||
| 674 | + | // Mode activation (used by the tmux prefix) | |
| 675 | + | "enter-pane-mode": ActionEnterPaneMode, | |
| 676 | + | "enter-tab-mode": ActionEnterTabMode, | |
| 677 | + | "enter-resize-mode": ActionEnterResizeMode, | |
| 678 | + | "enter-session-mode": ActionEnterSessionMode, | |
| 679 | + | "enter-vim-mode": ActionEnterVimMode, | |
| 680 | + | ||
| 698 | 681 | // Vim mode scroll actions | |
| 699 | 682 | "vim_scroll_left": ActionVimScrollLeft, | |
| 700 | 683 | "vim-scroll-left": ActionVimScrollLeft, | |
| @@ -854,9 +837,9 @@ func stringToKeyval(s string) (uint, bool) { | |||
| 854 | 837 | return keyval, true | |
| 855 | 838 | } | |
| 856 | 839 | ||
| 857 | - | // Single letter keys (a-z) | |
| 858 | - | if len(s) == 1 && s[0] >= 'a' && s[0] <= 'z' { | |
| 859 | - | // ASCII lowercase a=97, which matches gdk.KEY_a | |
| 840 | + | // Single printable ASCII keys (a-z, symbols such as %, ", &). | |
| 841 | + | // GDK keyvals for printable ASCII match their character codes. | |
| 842 | + | if len(s) == 1 && s[0] > ' ' && s[0] <= '~' { | |
| 860 | 843 | return uint(s[0]), true | |
| 861 | 844 | } | |
| 862 | 845 | ||
| @@ -884,12 +867,27 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) { | |||
| 884 | 867 | modeTable = s.SessionMode | |
| 885 | 868 | case ModeVim: | |
| 886 | 869 | modeTable = s.VimMode | |
| 870 | + | case ModePrefix: | |
| 871 | + | modeTable = s.PrefixMode | |
| 887 | 872 | } | |
| 888 | 873 | ||
| 889 | 874 | if modeTable != nil { | |
| 890 | 875 | if action, ok := modeTable[binding]; ok { | |
| 891 | 876 | return action, true | |
| 892 | 877 | } | |
| 878 | + | // Shifted symbols (%, ", {, ...) arrive with Shift held but are | |
| 879 | + | // configured without it, since the symbol already implies Shift. | |
| 880 | + | if isShiftedSymbol(binding) { | |
| 881 | + | if action, ok := modeTable[KeyBinding{Keyval: binding.Keyval}]; ok { | |
| 882 | + | return action, true | |
| 883 | + | } | |
| 884 | + | } | |
| 885 | + | // International layouts deliver some symbols as dead keys. | |
| 886 | + | if symbol, ok := deadKeySymbols[binding.Keyval]; ok { | |
| 887 | + | if action, ok := modeTable[KeyBinding{Keyval: symbol}]; ok { | |
| 888 | + | return action, true | |
| 889 | + | } | |
| 890 | + | } | |
| 893 | 891 | if mode == ModeVim { | |
| 894 | 892 | return "", false | |
| 895 | 893 | } | |
| @@ -903,6 +901,22 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) { | |||
| 903 | 901 | return "", false | |
| 904 | 902 | } | |
| 905 | 903 | ||
| 904 | + | // deadKeySymbols maps dead keysyms to the ASCII symbol they stand for. | |
| 905 | + | var deadKeySymbols = map[uint]uint{ | |
| 906 | + | uint(gdk.KEY_dead_diaeresis): '"', | |
| 907 | + | uint(gdk.KEY_dead_acute): '\'', | |
| 908 | + | uint(gdk.KEY_dead_grave): '`', | |
| 909 | + | uint(gdk.KEY_dead_circumflex): '^', | |
| 910 | + | uint(gdk.KEY_dead_tilde): '~', | |
| 911 | + | } | |
| 912 | + | ||
| 913 | + | // isShiftedSymbol reports a Shift-only binding on a printable ASCII symbol. | |
| 914 | + | func isShiftedSymbol(binding KeyBinding) bool { | |
| 915 | + | k := binding.Keyval | |
| 916 | + | isSymbol := k > ' ' && k <= '~' && (k < 'a' || k > 'z') && (k < 'A' || k > 'Z') | |
| 917 | + | return binding.Modifiers == ModShift && isSymbol | |
| 918 | + | } | |
| 919 | + | ||
| 906 | 920 | // ShouldAutoExitMode returns true if the action should cause modal mode to exit. | |
| 907 | 921 | func ShouldAutoExitMode(action Action) bool { | |
| 908 | 922 | switch action { | |
| @@ -4,6 +4,7 @@ package input | |||||
| 4 | import ( | 4 | import ( | ||
| 5 | "context" | 5 | "context" | ||
| 6 | "maps" | 6 | "maps" | ||
| 7 | + | "slices" | |||
| 7 | "sort" | 8 | "sort" | ||
| 8 | "strings" | 9 | "strings" | ||
| 9 | 10 | ||||
| @@ -109,6 +110,7 @@ const ( | |||||
| 109 | ActionEnterPaneMode Action = "enter_pane_mode" | 110 | ActionEnterPaneMode Action = "enter_pane_mode" | ||
| 110 | ActionEnterSessionMode Action = "enter_session_mode" | 111 | ActionEnterSessionMode Action = "enter_session_mode" | ||
| 111 | ActionEnterResizeMode Action = "enter_resize_mode" | 112 | ActionEnterResizeMode Action = "enter_resize_mode" | ||
| 113 | + | ActionEnterPrefixMode Action = "enter_prefix_mode" | |||
| 112 | ActionExitMode Action = "exit_mode" | 114 | ActionExitMode Action = "exit_mode" | ||
| 113 | 115 | ||||
| 114 | // Tab actions (global and modal) | 116 | // Tab actions (global and modal) | ||
| @@ -235,6 +237,8 @@ type ShortcutSet struct { | |||||
| 235 | ResizeMode ShortcutTable | 237 | ResizeMode ShortcutTable | ||
| 236 | // VimMode shortcuts are only active in vim mode. | 238 | // VimMode shortcuts are only active in vim mode. | ||
| 237 | VimMode ShortcutTable | 239 | VimMode ShortcutTable | ||
| 240 | + | // PrefixMode shortcuts are one-shot actions after the tmux-style prefix. | |||
| 241 | + | PrefixMode ShortcutTable | |||
| 238 | // vimModeSequences holds multi-key Vim mode bindings. | 242 | // vimModeSequences holds multi-key Vim mode bindings. | ||
| 239 | vimModeSequences *vimkeys.Trie | 243 | vimModeSequences *vimkeys.Trie | ||
| 240 | } | 244 | } | ||
| @@ -251,6 +255,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess | |||||
| 251 | SessionMode: make(ShortcutTable), | 255 | SessionMode: make(ShortcutTable), | ||
| 252 | ResizeMode: make(ShortcutTable), | 256 | ResizeMode: make(ShortcutTable), | ||
| 253 | VimMode: make(ShortcutTable), | 257 | VimMode: make(ShortcutTable), | ||
| 258 | + | PrefixMode: make(ShortcutTable), | |||
| 254 | } | 259 | } | ||
| 255 | 260 | ||||
| 256 | set.buildGlobalShortcutsFromParts(ctx, workspace, session) | 261 | set.buildGlobalShortcutsFromParts(ctx, workspace, session) | ||
| @@ -259,6 +264,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess | |||||
| 259 | set.buildPaneModeShortcuts(ctx, workspace) | 264 | set.buildPaneModeShortcuts(ctx, workspace) | ||
| 260 | set.buildResizeModeShortcuts(ctx, workspace) | 265 | set.buildResizeModeShortcuts(ctx, workspace) | ||
| 261 | set.buildVimModeShortcuts(ctx, workspace) | 266 | set.buildVimModeShortcuts(ctx, workspace) | ||
| 267 | + | set.buildModeShortcuts(ctx, workspace.PrefixMode.GetKeyBindings(), set.PrefixMode, "prefix") | |||
| 262 | } | 268 | } | ||
| 263 | if session != nil { | 269 | if session != nil { | ||
| 264 | set.buildSessionModeShortcuts(ctx, session) | 270 | set.buildSessionModeShortcuts(ctx, session) | ||
| @@ -271,6 +277,7 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess | |||||
| 271 | Int("resize", len(set.ResizeMode)). | 277 | Int("resize", len(set.ResizeMode)). | ||
| 272 | Int("session", len(set.SessionMode)). | 278 | Int("session", len(set.SessionMode)). | ||
| 273 | Int("vim", len(set.VimMode)). | 279 | Int("vim", len(set.VimMode)). | ||
| 280 | + | Int("prefix", len(set.PrefixMode)). | |||
| 274 | Msg("shortcuts registered") | 281 | Msg("shortcuts registered") | ||
| 275 | 282 | ||||
| 276 | return set | 283 | return set | ||
| @@ -278,11 +285,12 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess | |||||
| 278 | 285 | ||||
| 279 | // buildGlobalShortcutsFromParts populates global shortcuts from workspace and session configs. | 286 | // buildGlobalShortcutsFromParts populates global shortcuts from workspace and session configs. | ||
| 280 | func (s *ShortcutSet) buildGlobalShortcutsFromParts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) { | 287 | func (s *ShortcutSet) buildGlobalShortcutsFromParts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) { | ||
| 288 | + | s.registerBuiltinShortcutFallbacks(workspace) | |||
| 281 | s.registerActivationShortcutsFromParts(ctx, workspace, session) | 289 | s.registerActivationShortcutsFromParts(ctx, workspace, session) | ||
| 282 | s.registerConfiguredShortcuts(workspace) | 290 | s.registerConfiguredShortcuts(workspace) | ||
| 283 | - | s.registerStandardShortcuts() | 291 | + | // Alt+1-9, Alt+0, and Alt+Tab are registered by GlobalShortcutHandler; |
| 284 | - | s.registerPaneNavigationShortcuts() | 292 | + | // Alt+Shift+Tab stays here as a fallback binding. |
| 285 | - | s.registerTabSwitchShortcuts() | 293 | + | s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab |
| 286 | s.registerFloatingProfileShortcutsFromWorkspace(ctx, workspace) | 294 | s.registerFloatingProfileShortcutsFromWorkspace(ctx, workspace) | ||
| 287 | } | 295 | } | ||
| 288 | 296 | ||||
| @@ -330,77 +338,63 @@ func (s *ShortcutSet) buildVimModeShortcuts(ctx context.Context, cfg *entity.Wor | |||||
| 330 | } | 338 | } | ||
| 331 | } | 339 | } | ||
| 332 | 340 | ||||
| 341 | + | // registerActivationShortcutsFromParts registers mode activation shortcuts. | |||
| 342 | + | // The zellij keymap binds one shortcut per mode; the tmux keymap binds a single | |||
| 343 | + | // one-shot prefix instead of the pane, tab, resize, and session activations. | |||
| 344 | + | // Vim Mode keeps its own activation in both keymaps. | |||
| 333 | func (s *ShortcutSet) registerActivationShortcutsFromParts( | 345 | func (s *ShortcutSet) registerActivationShortcutsFromParts( | ||
| 334 | ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig, | 346 | ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig, | ||
| 335 | ) { | 347 | ) { | ||
| 336 | - | log := logging.FromContext(ctx) | 348 | + | if workspace != nil { |
| 337 | - | if workspace == nil { | 349 | + | s.registerActivation(ctx, workspace.VimMode.ActivationShortcut, ActionEnterVimMode) |
| 338 | - | // Still register session mode if available. | 350 | + | if workspace.Keymap == entity.KeymapTmux { |
| 339 | - | if session != nil { | 351 | + | s.registerActivation(ctx, workspace.PrefixMode.ActivationShortcut, ActionEnterPrefixMode) |
| 340 | - | if binding, ok := ParseKeyString(session.SessionMode.ActivationShortcut); ok { | 352 | + | return |
| 341 | - | s.Global[binding] = ActionEnterSessionMode | |||
| 342 | - | log.Trace(). | |||
| 343 | - | Str("shortcut", session.SessionMode.ActivationShortcut). | |||
| 344 | - | Uint("keyval", binding.Keyval). | |||
| 345 | - | Uint("mod", uint(binding.Modifiers)). | |||
| 346 | - | Msg("session mode activation registered") | |||
| 347 | - | } else { | |||
| 348 | - | log.Warn().Str("shortcut", session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut") | |||
| 349 | - | } | |||
| 350 | } | 353 | } | ||
| 351 | - | return | 354 | + | s.registerActivation(ctx, workspace.TabMode.ActivationShortcut, ActionEnterTabMode) |
| 355 | + | s.registerActivation(ctx, workspace.PaneMode.ActivationShortcut, ActionEnterPaneMode) | |||
| 356 | + | s.registerActivation(ctx, workspace.ResizeMode.ActivationShortcut, ActionEnterResizeMode) | |||
| 352 | } | 357 | } | ||
| 353 | - | if binding, ok := ParseKeyString(workspace.VimMode.ActivationShortcut); ok { | 358 | + | if session != nil { |
| 354 | - | s.Global[binding] = ActionEnterVimMode | 359 | + | s.registerActivation(ctx, session.SessionMode.ActivationShortcut, ActionEnterSessionMode) |
| 355 | - | log.Trace(). | |||
| 356 | - | Str("shortcut", workspace.VimMode.ActivationShortcut). | |||
| 357 | - | Uint("keyval", binding.Keyval). | |||
| 358 | - | Uint("mod", uint(binding.Modifiers)). | |||
| 359 | - | Msg("vim mode activation registered") | |||
| 360 | - | } else { | |||
| 361 | - | log.Warn().Str("shortcut", workspace.VimMode.ActivationShortcut).Msg("failed to parse vim mode activation shortcut") | |||
| 362 | - | } | |||
| 363 | - | if binding, ok := ParseKeyString(workspace.TabMode.ActivationShortcut); ok { | |||
| 364 | - | s.Global[binding] = ActionEnterTabMode | |||
| 365 | - | log.Trace(). | |||
| 366 | - | Str("shortcut", workspace.TabMode.ActivationShortcut). | |||
| 367 | - | Uint("keyval", binding.Keyval). | |||
| 368 | - | Uint("mod", uint(binding.Modifiers)). | |||
| 369 | - | Msg("tab mode activation registered") | |||
| 370 | - | } else { | |||
| 371 | - | log.Warn().Str("shortcut", workspace.TabMode.ActivationShortcut).Msg("failed to parse tab mode activation shortcut") | |||
| 372 | } | 360 | } | ||
| 373 | - | if binding, ok := ParseKeyString(workspace.PaneMode.ActivationShortcut); ok { | 361 | + | } |
| 374 | - | s.Global[binding] = ActionEnterPaneMode | 362 | + | |
| 375 | - | log.Trace(). | 363 | + | func (s *ShortcutSet) registerActivation(ctx context.Context, shortcut string, action Action) { |
| 376 | - | Str("shortcut", workspace.PaneMode.ActivationShortcut). | 364 | + | log := logging.FromContext(ctx) |
| 377 | - | Uint("keyval", binding.Keyval). | 365 | + | binding, ok := ParseKeyString(shortcut) |
| 378 | - | Uint("mod", uint(binding.Modifiers)). | 366 | + | if !ok { |
| 379 | - | Msg("pane mode activation registered") | 367 | + | log.Warn().Str("shortcut", shortcut).Str("action", string(action)).Msg("failed to parse mode activation shortcut") |
| 380 | - | } else { | 368 | + | return |
| 381 | - | log.Warn().Str("shortcut", workspace.PaneMode.ActivationShortcut).Msg("failed to parse pane mode activation shortcut") | |||
| 382 | } | 369 | } | ||
| 383 | - | if session != nil { | 370 | + | s.Global[binding] = action |
| 384 | - | if binding, ok := ParseKeyString(session.SessionMode.ActivationShortcut); ok { | 371 | + | log.Trace(). |
| 385 | - | s.Global[binding] = ActionEnterSessionMode | 372 | + | Str("shortcut", shortcut). |
| 386 | - | log.Trace(). | 373 | + | Str("action", string(action)). |
| 387 | - | Str("shortcut", session.SessionMode.ActivationShortcut). | 374 | + | Uint("keyval", binding.Keyval). |
| 388 | - | Uint("keyval", binding.Keyval). | 375 | + | Uint("mod", uint(binding.Modifiers)). |
| 389 | - | Uint("mod", uint(binding.Modifiers)). | 376 | + | Msg("mode activation registered") |
| 390 | - | Msg("session mode activation registered") | 377 | + | } |
| 391 | - | } else { | 378 | + | |
| 392 | - | log.Warn().Str("shortcut", session.SessionMode.ActivationShortcut).Msg("failed to parse session mode activation shortcut") | 379 | + | // registerBuiltinShortcutFallbacks binds built-in browser shortcuts whose |
| 380 | + | // action is missing from the configured global shortcuts, so configs written | |||
| 381 | + | // before these actions became configurable keep working until migrated. | |||
| 382 | + | func (s *ShortcutSet) registerBuiltinShortcutFallbacks(cfg *entity.WorkspaceConfig) { | |||
| 383 | + | for name, binding := range entity.BuiltinGlobalShortcuts() { | |||
| 384 | + | if cfg != nil { | |||
| 385 | + | if _, configured := cfg.Shortcuts.Actions[name]; configured { | |||
| 386 | + | continue | |||
| 387 | + | } | |||
| 388 | + | } | |||
| 389 | + | action, ok := configActionToAction[name] | |||
| 390 | + | if !ok { | |||
| 391 | + | continue | |||
| 392 | + | } | |||
| 393 | + | for _, key := range binding.Keys { | |||
| 394 | + | if kb, ok := ParseKeyString(key); ok { | |||
| 395 | + | s.Global[kb] = action | |||
| 396 | + | } | |||
| 393 | } | 397 | } | ||
| 394 | - | } | |||
| 395 | - | if binding, ok := ParseKeyString(workspace.ResizeMode.ActivationShortcut); ok { | |||
| 396 | - | s.Global[binding] = ActionEnterResizeMode | |||
| 397 | - | log.Trace(). | |||
| 398 | - | Str("shortcut", workspace.ResizeMode.ActivationShortcut). | |||
| 399 | - | Uint("keyval", binding.Keyval). | |||
| 400 | - | Uint("mod", uint(binding.Modifiers)). | |||
| 401 | - | Msg("resize mode activation registered") | |||
| 402 | - | } else { | |||
| 403 | - | log.Warn().Str("shortcut", workspace.ResizeMode.ActivationShortcut).Msg("failed to parse resize mode activation shortcut") | |||
| 404 | } | 398 | } | ||
| 405 | } | 399 | } | ||
| 406 | 400 | ||||
| @@ -408,22 +402,12 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) { | |||||
| 408 | if cfg == nil { | 402 | if cfg == nil { | ||
| 409 | return | 403 | return | ||
| 410 | } | 404 | } | ||
| 411 | - | // Note: Ctrl+T is NOT registered globally - it enters tab mode. | 405 | + | for _, actionName := range orderedGlobalShortcutActions(cfg.Shortcuts.Actions) { |
| 412 | - | // In tab mode, use: | |||
| 413 | - | // n = new tab | |||
| 414 | - | // x = close tab | |||
| 415 | - | // l/tab = next tab | |||
| 416 | - | // h/shift+tab = previous tab | |||
| 417 | - | // r = rename tab | |||
| 418 | - | // This follows Zellij-style modal keyboard interface. | |||
| 419 | - | // | |||
| 420 | - | // However, these standard browser shortcuts ARE global. | |||
| 421 | - | for actionName, actionBinding := range cfg.Shortcuts.Actions { | |||
| 422 | action, ok := configActionToAction[actionName] | 406 | action, ok := configActionToAction[actionName] | ||
| 423 | if !ok { | 407 | if !ok { | ||
| 424 | continue | 408 | continue | ||
| 425 | } | 409 | } | ||
| 426 | - | for _, keyStr := range actionBinding.Keys { | 410 | + | for _, keyStr := range cfg.Shortcuts.Actions[actionName].Keys { |
| 427 | if binding, ok := ParseKeyString(keyStr); ok { | 411 | if binding, ok := ParseKeyString(keyStr); ok { | ||
| 428 | s.Global[binding] = action | 412 | s.Global[binding] = action | ||
| 429 | } | 413 | } | ||
| @@ -431,6 +415,28 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) { | |||||
| 431 | } | 415 | } | ||
| 432 | } | 416 | } | ||
| 433 | 417 | ||||
| 418 | + | // orderedGlobalShortcutActions returns action names in registration order: | |||
| 419 | + | // sorted, with built-in browser actions last so they win a shared key, as | |||
| 420 | + | // they did when they were hard-coded. A stable order keeps the winner of a | |||
| 421 | + | // key conflict the same across restarts. | |||
| 422 | + | func orderedGlobalShortcutActions(actions map[string]entity.ActionBinding) []string { | |||
| 423 | + | builtins := entity.BuiltinGlobalShortcuts() | |||
| 424 | + | names := slices.Sorted(maps.Keys(actions)) | |||
| 425 | + | slices.SortStableFunc(names, func(a, b string) int { | |||
| 426 | + | _, aBuiltin := builtins[a] | |||
| 427 | + | _, bBuiltin := builtins[b] | |||
| 428 | + | switch { | |||
| 429 | + | case aBuiltin == bBuiltin: | |||
| 430 | + | return 0 | |||
| 431 | + | case aBuiltin: | |||
| 432 | + | return 1 | |||
| 433 | + | default: | |||
| 434 | + | return -1 | |||
| 435 | + | } | |||
| 436 | + | }) | |||
| 437 | + | return names | |||
| 438 | + | } | |||
| 439 | + | ||||
| 434 | func (s *ShortcutSet) registerFloatingProfileShortcutsFromWorkspace(ctx context.Context, workspace *entity.WorkspaceConfig) { | 440 | func (s *ShortcutSet) registerFloatingProfileShortcutsFromWorkspace(ctx context.Context, workspace *entity.WorkspaceConfig) { | ||
| 435 | occupied := make(map[KeyBinding]Action, len(s.Global)) | 441 | occupied := make(map[KeyBinding]Action, len(s.Global)) | ||
| 436 | maps.Copy(occupied, s.Global) | 442 | maps.Copy(occupied, s.Global) | ||
| @@ -537,54 +543,6 @@ func reserveGlobalOnlyShortcutBindings(occupied map[KeyBinding]Action) { | |||||
| 537 | } | 543 | } | ||
| 538 | } | 544 | } | ||
| 539 | 545 | ||||
| 540 | - | func (s *ShortcutSet) registerStandardShortcuts() { | |||
| 541 | - | s.Global[KeyBinding{uint(gdk.KEY_l), ModCtrl}] = ActionOpenOmnibox | |||
| 542 | - | s.Global[KeyBinding{uint(gdk.KEY_f), ModCtrl}] = ActionOpenFind | |||
| 543 | - | s.Global[KeyBinding{uint(gdk.KEY_F3), ModNone}] = ActionFindNext | |||
| 544 | - | s.Global[KeyBinding{uint(gdk.KEY_F3), ModShift}] = ActionFindPrev | |||
| 545 | - | s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl}] = ActionFindNext | |||
| 546 | - | s.Global[KeyBinding{uint(gdk.KEY_g), ModCtrl | ModShift}] = ActionFindPrev | |||
| 547 | - | s.Global[KeyBinding{uint(gdk.KEY_r), ModCtrl}] = ActionReload | |||
| 548 | - | s.Global[KeyBinding{uint('r'), ModCtrl | ModShift}] = ActionHardReload | |||
| 549 | - | s.Global[KeyBinding{uint(gdk.KEY_F5), ModNone}] = ActionReload | |||
| 550 | - | s.Global[KeyBinding{uint(gdk.KEY_F5), ModCtrl}] = ActionHardReload | |||
| 551 | - | s.Global[KeyBinding{uint(gdk.KEY_F12), ModNone}] = ActionOpenDevTools | |||
| 552 | - | s.Global[KeyBinding{uint(gdk.KEY_Left), ModCtrl}] = ActionGoBack | |||
| 553 | - | s.Global[KeyBinding{uint(gdk.KEY_Right), ModCtrl}] = ActionGoForward | |||
| 554 | - | s.Global[KeyBinding{uint(gdk.KEY_plus), ModCtrl}] = ActionZoomIn | |||
| 555 | - | s.Global[KeyBinding{uint(gdk.KEY_equal), ModCtrl}] = ActionZoomIn // Ctrl+= (no shift needed) | |||
| 556 | - | s.Global[KeyBinding{uint(gdk.KEY_minus), ModCtrl}] = ActionZoomOut | |||
| 557 | - | s.Global[KeyBinding{uint(gdk.KEY_0), ModCtrl}] = ActionZoomReset | |||
| 558 | - | s.Global[KeyBinding{uint(gdk.KEY_q), ModCtrl}] = ActionQuit | |||
| 559 | - | s.Global[KeyBinding{uint(gdk.KEY_F11), ModNone}] = ActionToggleFullscreen | |||
| 560 | - | s.Global[KeyBinding{uint('c'), ModCtrl | ModShift}] = ActionCopyURL | |||
| 561 | - | s.Global[KeyBinding{uint('p'), ModCtrl | ModShift}] = ActionPrintPage | |||
| 562 | - | // Session management - direct shortcut to open session manager | |||
| 563 | - | s.Global[KeyBinding{uint(gdk.KEY_s), ModCtrl | ModShift}] = ActionOpenSessionManager | |||
| 564 | - | } | |||
| 565 | - | ||||
| 566 | - | func (s *ShortcutSet) registerPaneNavigationShortcuts() { | |||
| 567 | - | s.Global[KeyBinding{uint(gdk.KEY_h), ModAlt}] = ActionFocusLeft | |||
| 568 | - | s.Global[KeyBinding{uint(gdk.KEY_l), ModAlt}] = ActionFocusRight | |||
| 569 | - | s.Global[KeyBinding{uint(gdk.KEY_k), ModAlt}] = ActionFocusUp | |||
| 570 | - | s.Global[KeyBinding{uint(gdk.KEY_j), ModAlt}] = ActionFocusDown | |||
| 571 | - | ||||
| 572 | - | s.Global[KeyBinding{uint(gdk.KEY_Left), ModAlt}] = ActionFocusLeft | |||
| 573 | - | s.Global[KeyBinding{uint(gdk.KEY_Right), ModAlt}] = ActionFocusRight | |||
| 574 | - | s.Global[KeyBinding{uint(gdk.KEY_Up), ModAlt}] = ActionFocusUp | |||
| 575 | - | s.Global[KeyBinding{uint(gdk.KEY_Down), ModAlt}] = ActionFocusDown | |||
| 576 | - | } | |||
| 577 | - | ||||
| 578 | - | func (s *ShortcutSet) registerTabSwitchShortcuts() { | |||
| 579 | - | // NOTE: Alt+1-9, Alt+0, and Alt+Tab are now handled by GlobalShortcutHandler | |||
| 580 | - | // using GtkShortcutController with GTK_SHORTCUT_SCOPE_GLOBAL. | |||
| 581 | - | // This is necessary because WebKitGTK's WebView consumes these key events | |||
| 582 | - | // before they reach the EventControllerKey in capture phase. | |||
| 583 | - | // | |||
| 584 | - | // Only Alt+Shift+Tab remains here as a fallback binding. | |||
| 585 | - | s.Global[KeyBinding{uint(gdk.KEY_Tab), ModAlt | ModShift}] = ActionSwitchLastTab | |||
| 586 | - | } | |||
| 587 | - | ||||
| 588 | func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[string]string, dest map[KeyBinding]Action, mode string) { | 546 | func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[string]string, dest map[KeyBinding]Action, mode string) { | ||
| 589 | log := logging.FromContext(ctx) | 547 | log := logging.FromContext(ctx) | ||
| 590 | var registered, parseErrors, unknownActions int | 548 | var registered, parseErrors, unknownActions int | ||
| @@ -695,6 +653,31 @@ var configActionToAction = map[string]Action{ | |||||
| 695 | // Session actions | 653 | // Session actions | ||
| 696 | "session-manager": ActionOpenSessionManager, | 654 | "session-manager": ActionOpenSessionManager, | ||
| 697 | 655 | ||||
| 656 | + | // Browser actions | |||
| 657 | + | "open-omnibox": ActionOpenOmnibox, | |||
| 658 | + | "open-find": ActionOpenFind, | |||
| 659 | + | "find-next": ActionFindNext, | |||
| 660 | + | "find-prev": ActionFindPrev, | |||
| 661 | + | "reload": ActionReload, | |||
| 662 | + | "hard-reload": ActionHardReload, | |||
| 663 | + | "open-devtools": ActionOpenDevTools, | |||
| 664 | + | "go-back": ActionGoBack, | |||
| 665 | + | "go-forward": ActionGoForward, | |||
| 666 | + | "zoom-in": ActionZoomIn, | |||
| 667 | + | "zoom-out": ActionZoomOut, | |||
| 668 | + | "zoom-reset": ActionZoomReset, | |||
| 669 | + | "quit": ActionQuit, | |||
| 670 | + | "toggle-fullscreen": ActionToggleFullscreen, | |||
| 671 | + | "copy-url": ActionCopyURL, | |||
| 672 | + | "print-page": ActionPrintPage, | |||
| 673 | + | ||||
| 674 | + | // Mode activation (used by the tmux prefix) | |||
| 675 | + | "enter-pane-mode": ActionEnterPaneMode, | |||
| 676 | + | "enter-tab-mode": ActionEnterTabMode, | |||
| 677 | + | "enter-resize-mode": ActionEnterResizeMode, | |||
| 678 | + | "enter-session-mode": ActionEnterSessionMode, | |||
| 679 | + | "enter-vim-mode": ActionEnterVimMode, | |||
| 680 | + | ||||
| 698 | // Vim mode scroll actions | 681 | // Vim mode scroll actions | ||
| 699 | "vim_scroll_left": ActionVimScrollLeft, | 682 | "vim_scroll_left": ActionVimScrollLeft, | ||
| 700 | "vim-scroll-left": ActionVimScrollLeft, | 683 | "vim-scroll-left": ActionVimScrollLeft, | ||
| @@ -854,9 +837,9 @@ func stringToKeyval(s string) (uint, bool) { | |||||
| 854 | return keyval, true | 837 | return keyval, true | ||
| 855 | } | 838 | } | ||
| 856 | 839 | ||||
| 857 | - | // Single letter keys (a-z) | 840 | + | // Single printable ASCII keys (a-z, symbols such as %, ", &). |
| 858 | - | if len(s) == 1 && s[0] >= 'a' && s[0] <= 'z' { | 841 | + | // GDK keyvals for printable ASCII match their character codes. |
| 859 | - | // ASCII lowercase a=97, which matches gdk.KEY_a | 842 | + | if len(s) == 1 && s[0] > ' ' && s[0] <= '~' { |
| 860 | return uint(s[0]), true | 843 | return uint(s[0]), true | ||
| 861 | } | 844 | } | ||
| 862 | 845 | ||||
| @@ -884,12 +867,27 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) { | |||||
| 884 | modeTable = s.SessionMode | 867 | modeTable = s.SessionMode | ||
| 885 | case ModeVim: | 868 | case ModeVim: | ||
| 886 | modeTable = s.VimMode | 869 | modeTable = s.VimMode | ||
| 870 | + | case ModePrefix: | |||
| 871 | + | modeTable = s.PrefixMode | |||
| 887 | } | 872 | } | ||
| 888 | 873 | ||||
| 889 | if modeTable != nil { | 874 | if modeTable != nil { | ||
| 890 | if action, ok := modeTable[binding]; ok { | 875 | if action, ok := modeTable[binding]; ok { | ||
| 891 | return action, true | 876 | return action, true | ||
| 892 | } | 877 | } | ||
| 878 | + | // Shifted symbols (%, ", {, ...) arrive with Shift held but are | |||
| 879 | + | // configured without it, since the symbol already implies Shift. | |||
| 880 | + | if isShiftedSymbol(binding) { | |||
| 881 | + | if action, ok := modeTable[KeyBinding{Keyval: binding.Keyval}]; ok { | |||
| 882 | + | return action, true | |||
| 883 | + | } | |||
| 884 | + | } | |||
| 885 | + | // International layouts deliver some symbols as dead keys. | |||
| 886 | + | if symbol, ok := deadKeySymbols[binding.Keyval]; ok { | |||
| 887 | + | if action, ok := modeTable[KeyBinding{Keyval: symbol}]; ok { | |||
| 888 | + | return action, true | |||
| 889 | + | } | |||
| 890 | + | } | |||
| 893 | if mode == ModeVim { | 891 | if mode == ModeVim { | ||
| 894 | return "", false | 892 | return "", false | ||
| 895 | } | 893 | } | ||
| @@ -903,6 +901,22 @@ func (s *ShortcutSet) Lookup(binding KeyBinding, mode Mode) (Action, bool) { | |||||
| 903 | return "", false | 901 | return "", false | ||
| 904 | } | 902 | } | ||
| 905 | 903 | ||||
| 904 | + | // deadKeySymbols maps dead keysyms to the ASCII symbol they stand for. | |||
| 905 | + | var deadKeySymbols = map[uint]uint{ | |||
| 906 | + | uint(gdk.KEY_dead_diaeresis): '"', | |||
| 907 | + | uint(gdk.KEY_dead_acute): '\'', | |||
| 908 | + | uint(gdk.KEY_dead_grave): '`', | |||
| 909 | + | uint(gdk.KEY_dead_circumflex): '^', | |||
| 910 | + | uint(gdk.KEY_dead_tilde): '~', | |||
| 911 | + | } | |||
| 912 | + | ||||
| 913 | + | // isShiftedSymbol reports a Shift-only binding on a printable ASCII symbol. | |||
| 914 | + | func isShiftedSymbol(binding KeyBinding) bool { | |||
| 915 | + | k := binding.Keyval | |||
| 916 | + | isSymbol := k > ' ' && k <= '~' && (k < 'a' || k > 'z') && (k < 'A' || k > 'Z') | |||
| 917 | + | return binding.Modifiers == ModShift && isSymbol | |||
| 918 | + | } | |||
| 919 | + | ||||
| 906 | // ShouldAutoExitMode returns true if the action should cause modal mode to exit. | 920 | // ShouldAutoExitMode returns true if the action should cause modal mode to exit. | ||
| 907 | func ShouldAutoExitMode(action Action) bool { | 921 | func ShouldAutoExitMode(action Action) bool { | ||
| 908 | switch action { | 922 | switch action { | ||
M internal/ui/mode_frame.go +5 -1
| @@ -304,6 +304,8 @@ func modeFrameClass(mode input.Mode) string { | |||
| 304 | 304 | return "tab-mode-active" | |
| 305 | 305 | case input.ModeSession: | |
| 306 | 306 | return "session-mode-active" | |
| 307 | + | case input.ModePrefix: | |
| 308 | + | return "prefix-mode-active" | |
| 307 | 309 | default: | |
| 308 | 310 | return "" | |
| 309 | 311 | } | |
| @@ -622,6 +624,8 @@ func modeLegendGroup(mode input.Mode, name string) string { | |||
| 622 | 624 | return "EXIT" | |
| 623 | 625 | case strings.HasPrefix(name, "split-"): | |
| 624 | 626 | return "SPLIT" | |
| 627 | + | case strings.HasPrefix(name, "enter-"): | |
| 628 | + | return "MODES" | |
| 625 | 629 | case strings.HasPrefix(name, "focus-"): | |
| 626 | 630 | return "FOCUS" | |
| 627 | 631 | case strings.HasPrefix(name, "resize-"): | |
| @@ -630,7 +634,7 @@ func modeLegendGroup(mode input.Mode, name string) string { | |||
| 630 | 634 | return "SCROLL" | |
| 631 | 635 | case mode == input.ModeVim && (strings.Contains(name, "next") || strings.Contains(name, "prev") || name == "outline"): | |
| 632 | 636 | return "JUMP" | |
| 633 | - | case mode == input.ModeTab && (name == "next-tab" || name == "previous-tab"): | |
| 637 | + | case (mode == input.ModeTab || mode == input.ModePrefix) && (name == "next-tab" || name == "previous-tab"): | |
| 634 | 638 | return "SWITCH" | |
| 635 | 639 | default: | |
| 636 | 640 | return "MANAGE" | |
| @@ -304,6 +304,8 @@ func modeFrameClass(mode input.Mode) string { | |||||
| 304 | return "tab-mode-active" | 304 | return "tab-mode-active" | ||
| 305 | case input.ModeSession: | 305 | case input.ModeSession: | ||
| 306 | return "session-mode-active" | 306 | return "session-mode-active" | ||
| 307 | + | case input.ModePrefix: | |||
| 308 | + | return "prefix-mode-active" | |||
| 307 | default: | 309 | default: | ||
| 308 | return "" | 310 | return "" | ||
| 309 | } | 311 | } | ||
| @@ -622,6 +624,8 @@ func modeLegendGroup(mode input.Mode, name string) string { | |||||
| 622 | return "EXIT" | 624 | return "EXIT" | ||
| 623 | case strings.HasPrefix(name, "split-"): | 625 | case strings.HasPrefix(name, "split-"): | ||
| 624 | return "SPLIT" | 626 | return "SPLIT" | ||
| 627 | + | case strings.HasPrefix(name, "enter-"): | |||
| 628 | + | return "MODES" | |||
| 625 | case strings.HasPrefix(name, "focus-"): | 629 | case strings.HasPrefix(name, "focus-"): | ||
| 626 | return "FOCUS" | 630 | return "FOCUS" | ||
| 627 | case strings.HasPrefix(name, "resize-"): | 631 | case strings.HasPrefix(name, "resize-"): | ||
| @@ -630,7 +634,7 @@ func modeLegendGroup(mode input.Mode, name string) string { | |||||
| 630 | return "SCROLL" | 634 | return "SCROLL" | ||
| 631 | case mode == input.ModeVim && (strings.Contains(name, "next") || strings.Contains(name, "prev") || name == "outline"): | 635 | case mode == input.ModeVim && (strings.Contains(name, "next") || strings.Contains(name, "prev") || name == "outline"): | ||
| 632 | return "JUMP" | 636 | return "JUMP" | ||
| 633 | - | case mode == input.ModeTab && (name == "next-tab" || name == "previous-tab"): | 637 | + | case (mode == input.ModeTab || mode == input.ModePrefix) && (name == "next-tab" || name == "previous-tab"): |
| 634 | return "SWITCH" | 638 | return "SWITCH" | ||
| 635 | default: | 639 | default: | ||
| 636 | return "MANAGE" | 640 | return "MANAGE" | ||
M internal/ui/mode_frame_legend.go +3 -1
| @@ -25,6 +25,8 @@ func legendActions(mode input.Mode, workspace entity.WorkspaceConfig, session en | |||
| 25 | 25 | return workspace.TabMode.Actions | |
| 26 | 26 | case input.ModeSession: | |
| 27 | 27 | return session.SessionMode.Actions | |
| 28 | + | case input.ModePrefix: | |
| 29 | + | return workspace.PrefixMode.Actions | |
| 28 | 30 | default: | |
| 29 | 31 | return nil | |
| 30 | 32 | } | |
| @@ -47,7 +49,7 @@ func (f *modeFrame) build(actions map[string]entity.ActionBinding) { | |||
| 47 | 49 | group := modeLegendGroup(f.mode, name) | |
| 48 | 50 | groups[group] = append(groups[group], name) | |
| 49 | 51 | } | |
| 50 | - | order := []string{"SPLIT", "FOCUS", "SCROLL", "RESIZE", "SWITCH", "JUMP", "MANAGE", "EXIT"} | |
| 52 | + | order := []string{"SPLIT", "FOCUS", "SCROLL", "RESIZE", "SWITCH", "JUMP", "MANAGE", "MODES", "EXIT"} | |
| 51 | 53 | index := 0 | |
| 52 | 54 | for _, groupName := range order { | |
| 53 | 55 | names := groups[groupName] | |
| @@ -25,6 +25,8 @@ func legendActions(mode input.Mode, workspace entity.WorkspaceConfig, session en | |||||
| 25 | return workspace.TabMode.Actions | 25 | return workspace.TabMode.Actions | ||
| 26 | case input.ModeSession: | 26 | case input.ModeSession: | ||
| 27 | return session.SessionMode.Actions | 27 | return session.SessionMode.Actions | ||
| 28 | + | case input.ModePrefix: | |||
| 29 | + | return workspace.PrefixMode.Actions | |||
| 28 | default: | 30 | default: | ||
| 29 | return nil | 31 | return nil | ||
| 30 | } | 32 | } | ||
| @@ -47,7 +49,7 @@ func (f *modeFrame) build(actions map[string]entity.ActionBinding) { | |||||
| 47 | group := modeLegendGroup(f.mode, name) | 49 | group := modeLegendGroup(f.mode, name) | ||
| 48 | groups[group] = append(groups[group], name) | 50 | groups[group] = append(groups[group], name) | ||
| 49 | } | 51 | } | ||
| 50 | - | order := []string{"SPLIT", "FOCUS", "SCROLL", "RESIZE", "SWITCH", "JUMP", "MANAGE", "EXIT"} | 52 | + | order := []string{"SPLIT", "FOCUS", "SCROLL", "RESIZE", "SWITCH", "JUMP", "MANAGE", "MODES", "EXIT"} |
| 51 | index := 0 | 53 | index := 0 | ||
| 52 | for _, groupName := range order { | 54 | for _, groupName := range order { | ||
| 53 | names := groups[groupName] | 55 | names := groups[groupName] | ||
M internal/ui/theme/css.go +9 -0
| @@ -937,6 +937,10 @@ func generatePaneCSS(p Palette) string { | |||
| 937 | 937 | .mode-frame-border.resize-mode-active { | |
| 938 | 938 | box-shadow: inset 0 0 0 0.25em var(--resize-mode-color); | |
| 939 | 939 | } | |
| 940 | + | ||
| 941 | + | .mode-frame-border.prefix-mode-active { | |
| 942 | + | box-shadow: inset 0 0 0 0.25em var(--pane-mode-color); | |
| 943 | + | } | |
| 940 | 944 | ` | |
| 941 | 945 | } | |
| 942 | 946 | ||
| @@ -1431,6 +1435,11 @@ func generateToasterCSS(p Palette) string { | |||
| 1431 | 1435 | background-color: var(--resize-mode-color); | |
| 1432 | 1436 | color: var(--resize-mode-text); | |
| 1433 | 1437 | } | |
| 1438 | + | ||
| 1439 | + | .toast-prefix-mode { | |
| 1440 | + | background-color: var(--pane-mode-color); | |
| 1441 | + | color: var(--pane-mode-text); | |
| 1442 | + | } | |
| 1434 | 1443 | ` | |
| 1435 | 1444 | } | |
| 1436 | 1445 | ||
| @@ -937,6 +937,10 @@ func generatePaneCSS(p Palette) string { | |||||
| 937 | .mode-frame-border.resize-mode-active { | 937 | .mode-frame-border.resize-mode-active { | ||
| 938 | box-shadow: inset 0 0 0 0.25em var(--resize-mode-color); | 938 | box-shadow: inset 0 0 0 0.25em var(--resize-mode-color); | ||
| 939 | } | 939 | } | ||
| 940 | + | ||||
| 941 | + | .mode-frame-border.prefix-mode-active { | |||
| 942 | + | box-shadow: inset 0 0 0 0.25em var(--pane-mode-color); | |||
| 943 | + | } | |||
| 940 | ` | 944 | ` | ||
| 941 | } | 945 | } | ||
| 942 | 946 | ||||
| @@ -1431,6 +1435,11 @@ func generateToasterCSS(p Palette) string { | |||||
| 1431 | background-color: var(--resize-mode-color); | 1435 | background-color: var(--resize-mode-color); | ||
| 1432 | color: var(--resize-mode-text); | 1436 | color: var(--resize-mode-text); | ||
| 1433 | } | 1437 | } | ||
| 1438 | + | ||||
| 1439 | + | .toast-prefix-mode { | |||
| 1440 | + | background-color: var(--pane-mode-color); | |||
| 1441 | + | color: var(--pane-mode-text); | |||
| 1442 | + | } | |||
| 1434 | ` | 1443 | ` | ||
| 1435 | } | 1444 | } | ||
| 1436 | 1445 | ||||
M internal/ui/theme/mode_legend_css.go +1 -0
| @@ -12,6 +12,7 @@ var modeLegendColors = []struct{ mode, color, text string }{ | |||
| 12 | 12 | {"resize", "--resize-mode-color", "--resize-mode-text"}, | |
| 13 | 13 | {"tab", "--tab-mode-color", "--tab-mode-text"}, | |
| 14 | 14 | {"session", "--session-mode-color", "--session-mode-text"}, | |
| 15 | + | {"prefix", "--pane-mode-color", "--pane-mode-text"}, | |
| 15 | 16 | } | |
| 16 | 17 | ||
| 17 | 18 | func generateModeLegendCSS() string { | |
| @@ -12,6 +12,7 @@ var modeLegendColors = []struct{ mode, color, text string }{ | |||||
| 12 | {"resize", "--resize-mode-color", "--resize-mode-text"}, | 12 | {"resize", "--resize-mode-color", "--resize-mode-text"}, | ||
| 13 | {"tab", "--tab-mode-color", "--tab-mode-text"}, | 13 | {"tab", "--tab-mode-color", "--tab-mode-text"}, | ||
| 14 | {"session", "--session-mode-color", "--session-mode-text"}, | 14 | {"session", "--session-mode-color", "--session-mode-text"}, | ||
| 15 | + | {"prefix", "--pane-mode-color", "--pane-mode-text"}, | |||
| 15 | } | 16 | } | ||
| 16 | 17 | ||||
| 17 | func generateModeLegendCSS() string { | 18 | func generateModeLegendCSS() string { | ||