fix(config,input): canonicalize action names and key chords once, drop the built-in shortcut fallback
- Config load renames underscore action names to the hyphen form in every action map (hyphen wins on a duplicate), so completion, validation and the input layer see one spelling; the duplicated legacy-name rules and the underscore entries of the action tables are removed. - New domain/keychord package normalizes key strings (aliases, modifier spelling, letter case). Default-action completion and duplicate-key validation use it, and input.ParseKeyString is built on it, so right and arrowright, ctrl and control, M and shift+m are one binding everywhere. - Remove registerBuiltinShortcutFallbacks: config completion already merges missing defaults, and the fallback re-bound defaults completion had skipped for a user key conflict (for example f5 reload after new-tab = ctrl+r).
- commit
- af0e3703dcf131b6ec10ea407c2798ff42f68ecc
- author
- brice · · 2026-10-14 08:55 UTC
- parent
- aa019b4
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
19 files · +903 -446
M docs/config/index.md +14 -14
| @@ -357,7 +357,7 @@ new_pane_url = "dumb://history" | |||
| 357 | 357 | ||
| 358 | 358 | ### Action Maps | |
| 359 | 359 | ||
| 360 | - | Every action map (`workspace.shortcuts.actions`, `workspace.prefix_mode.actions`, `workspace.pane_mode.actions`, `workspace.tab_mode.actions`, `workspace.vim_mode.actions`, `workspace.resize_mode.actions`, `session.session_mode.actions`) is completed with the default actions your config does not define, so new default actions are active without editing the file. Your bindings win: a default action is skipped when one of its keys is already used by an action you configured in the same map. Set `keys = []` on an action to disable it. | |
| 360 | + | Every action map (`workspace.shortcuts.actions`, `workspace.prefix_mode.actions`, `workspace.pane_mode.actions`, `workspace.tab_mode.actions`, `workspace.vim_mode.actions`, `workspace.resize_mode.actions`, `session.session_mode.actions`) is completed with the default actions your config does not define, so new default actions are active without editing the file. Your bindings win: a default action is skipped when one of its keys is already used by an action you configured in the same map. Set `keys = []` on an action to disable it. Keys are compared by the binding they resolve to, not by spelling: `right` and `arrowright`, `ctrl` and `control`, and `M` and `shift+m` are the same key. Action names use hyphens (`stack-pane`); older underscore spellings (`stack_pane`) are still read and renamed to the hyphen form when the config loads (if both spellings are present, the hyphenated one is used). | |
| 361 | 361 | ||
| 362 | 362 | ### Pane Mode | |
| 363 | 363 | ||
| @@ -564,26 +564,26 @@ Global shortcuts are configured under `workspace.shortcuts.actions` using the sa | |||
| 564 | 564 | ||
| 565 | 565 | | Action | Default Keys | Description | | |
| 566 | 566 | |--------|--------------|-------------| | |
| 567 | - | | `toggle_floating_pane` | `alt+f` | Toggle the workspace floating pane (persistent session) | | |
| 568 | - | | `close_pane` | `ctrl+w` | Close active pane (or release floating pane when floating is active) | | |
| 569 | - | | `next_tab` | `ctrl+tab` | Switch to next tab | | |
| 570 | - | | `previous_tab` | `ctrl+shift+tab` | Switch to previous tab | | |
| 571 | - | | `consume_or_expel_left` | `alt+[` | Consume into left sibling stack, or expel left if stacked | | |
| 572 | - | | `consume_or_expel_right` | `alt+]` | Consume into right sibling stack, or expel right if stacked | | |
| 573 | - | | `consume_or_expel_up` | `alt+{` | Consume into upper sibling stack, or expel up if stacked | | |
| 574 | - | | `consume_or_expel_down` | `alt+}` | Consume into lower sibling stack, or expel down if stacked | | |
| 567 | + | | `toggle-floating-pane` | `alt+f` | Toggle the workspace floating pane (persistent session) | | |
| 568 | + | | `close-pane` | `ctrl+w` | Close active pane (or release floating pane when floating is active) | | |
| 569 | + | | `next-tab` | `ctrl+tab` | Switch to next tab | | |
| 570 | + | | `previous-tab` | `ctrl+shift+tab` | Switch to previous tab | | |
| 571 | + | | `consume-or-expel-left` | `alt+[` | Consume into left sibling stack, or expel left if stacked | | |
| 572 | + | | `consume-or-expel-right` | `alt+]` | Consume into right sibling stack, or expel right if stacked | | |
| 573 | + | | `consume-or-expel-up` | `alt+{` | Consume into upper sibling stack, or expel up if stacked | | |
| 574 | + | | `consume-or-expel-down` | `alt+}` | Consume into lower sibling stack, or expel down if stacked | | |
| 575 | 575 | ||
| 576 | 576 | **Example:** | |
| 577 | 577 | ```toml | |
| 578 | - | [workspace.shortcuts.actions.close_pane] | |
| 578 | + | [workspace.shortcuts.actions.close-pane] | |
| 579 | 579 | keys = ["ctrl+w"] | |
| 580 | 580 | desc = "Close active pane" | |
| 581 | 581 | ||
| 582 | - | [workspace.shortcuts.actions.next_tab] | |
| 582 | + | [workspace.shortcuts.actions.next-tab] | |
| 583 | 583 | keys = ["ctrl+tab", "alt+l"] | |
| 584 | 584 | desc = "Switch to next tab" | |
| 585 | 585 | ||
| 586 | - | [workspace.shortcuts.actions.toggle_floating_pane] | |
| 586 | + | [workspace.shortcuts.actions.toggle-floating-pane] | |
| 587 | 587 | keys = ["alt+f"] | |
| 588 | 588 | desc = "Toggle floating pane" | |
| 589 | 589 | ``` | |
| @@ -601,7 +601,7 @@ Global shortcuts are configured under `workspace.shortcuts.actions` using the sa | |||
| 601 | 601 | ||
| 602 | 602 | The floating workspace pane is a persistent overlay session: hiding it does not destroy its web content. | |
| 603 | 603 | ||
| 604 | - | - Default behavior includes exactly one floating shortcut: `Alt+F` (`toggle_floating_pane`). | |
| 604 | + | - Default behavior includes exactly one floating shortcut: `Alt+F` (`toggle-floating-pane`). | |
| 605 | 605 | - URL shortcuts (for example `Alt+G`) are user-defined via `workspace.floating_pane.profiles` and are empty by default. | |
| 606 | 606 | - `Alt+F` toggles visibility only and preserves floating state. | |
| 607 | 607 | - `Ctrl+W` on an active floating pane closes and releases that floating session for a fresh next open. | |
| @@ -616,7 +616,7 @@ The floating workspace pane is a persistent overlay session: hiding it does not | |||
| 616 | 616 | **Example:** | |
| 617 | 617 | ||
| 618 | 618 | ```toml | |
| 619 | - | [workspace.shortcuts.actions.toggle_floating_pane] | |
| 619 | + | [workspace.shortcuts.actions.toggle-floating-pane] | |
| 620 | 620 | keys = ["alt+f"] | |
| 621 | 621 | desc = "Toggle floating pane" | |
| 622 | 622 | ||
| @@ -357,7 +357,7 @@ new_pane_url = "dumb://history" | |||||
| 357 | 357 | ||||
| 358 | ### Action Maps | 358 | ### Action Maps | ||
| 359 | 359 | ||||
| 360 | - | Every action map (`workspace.shortcuts.actions`, `workspace.prefix_mode.actions`, `workspace.pane_mode.actions`, `workspace.tab_mode.actions`, `workspace.vim_mode.actions`, `workspace.resize_mode.actions`, `session.session_mode.actions`) is completed with the default actions your config does not define, so new default actions are active without editing the file. Your bindings win: a default action is skipped when one of its keys is already used by an action you configured in the same map. Set `keys = []` on an action to disable it. | 360 | + | Every action map (`workspace.shortcuts.actions`, `workspace.prefix_mode.actions`, `workspace.pane_mode.actions`, `workspace.tab_mode.actions`, `workspace.vim_mode.actions`, `workspace.resize_mode.actions`, `session.session_mode.actions`) is completed with the default actions your config does not define, so new default actions are active without editing the file. Your bindings win: a default action is skipped when one of its keys is already used by an action you configured in the same map. Set `keys = []` on an action to disable it. Keys are compared by the binding they resolve to, not by spelling: `right` and `arrowright`, `ctrl` and `control`, and `M` and `shift+m` are the same key. Action names use hyphens (`stack-pane`); older underscore spellings (`stack_pane`) are still read and renamed to the hyphen form when the config loads (if both spellings are present, the hyphenated one is used). |
| 361 | 361 | ||||
| 362 | ### Pane Mode | 362 | ### Pane Mode | ||
| 363 | 363 | ||||
| @@ -564,26 +564,26 @@ Global shortcuts are configured under `workspace.shortcuts.actions` using the sa | |||||
| 564 | 564 | ||||
| 565 | | Action | Default Keys | Description | | 565 | | Action | Default Keys | Description | | ||
| 566 | |--------|--------------|-------------| | 566 | |--------|--------------|-------------| | ||
| 567 | - | | `toggle_floating_pane` | `alt+f` | Toggle the workspace floating pane (persistent session) | | 567 | + | | `toggle-floating-pane` | `alt+f` | Toggle the workspace floating pane (persistent session) | |
| 568 | - | | `close_pane` | `ctrl+w` | Close active pane (or release floating pane when floating is active) | | 568 | + | | `close-pane` | `ctrl+w` | Close active pane (or release floating pane when floating is active) | |
| 569 | - | | `next_tab` | `ctrl+tab` | Switch to next tab | | 569 | + | | `next-tab` | `ctrl+tab` | Switch to next tab | |
| 570 | - | | `previous_tab` | `ctrl+shift+tab` | Switch to previous tab | | 570 | + | | `previous-tab` | `ctrl+shift+tab` | Switch to previous tab | |
| 571 | - | | `consume_or_expel_left` | `alt+[` | Consume into left sibling stack, or expel left if stacked | | 571 | + | | `consume-or-expel-left` | `alt+[` | Consume into left sibling stack, or expel left if stacked | |
| 572 | - | | `consume_or_expel_right` | `alt+]` | Consume into right sibling stack, or expel right if stacked | | 572 | + | | `consume-or-expel-right` | `alt+]` | Consume into right sibling stack, or expel right if stacked | |
| 573 | - | | `consume_or_expel_up` | `alt+{` | Consume into upper sibling stack, or expel up if stacked | | 573 | + | | `consume-or-expel-up` | `alt+{` | Consume into upper sibling stack, or expel up if stacked | |
| 574 | - | | `consume_or_expel_down` | `alt+}` | Consume into lower sibling stack, or expel down if stacked | | 574 | + | | `consume-or-expel-down` | `alt+}` | Consume into lower sibling stack, or expel down if stacked | |
| 575 | 575 | ||||
| 576 | **Example:** | 576 | **Example:** | ||
| 577 | ```toml | 577 | ```toml | ||
| 578 | - | [workspace.shortcuts.actions.close_pane] | 578 | + | [workspace.shortcuts.actions.close-pane] |
| 579 | keys = ["ctrl+w"] | 579 | keys = ["ctrl+w"] | ||
| 580 | desc = "Close active pane" | 580 | desc = "Close active pane" | ||
| 581 | 581 | ||||
| 582 | - | [workspace.shortcuts.actions.next_tab] | 582 | + | [workspace.shortcuts.actions.next-tab] |
| 583 | keys = ["ctrl+tab", "alt+l"] | 583 | keys = ["ctrl+tab", "alt+l"] | ||
| 584 | desc = "Switch to next tab" | 584 | desc = "Switch to next tab" | ||
| 585 | 585 | ||||
| 586 | - | [workspace.shortcuts.actions.toggle_floating_pane] | 586 | + | [workspace.shortcuts.actions.toggle-floating-pane] |
| 587 | keys = ["alt+f"] | 587 | keys = ["alt+f"] | ||
| 588 | desc = "Toggle floating pane" | 588 | desc = "Toggle floating pane" | ||
| 589 | ``` | 589 | ``` | ||
| @@ -601,7 +601,7 @@ Global shortcuts are configured under `workspace.shortcuts.actions` using the sa | |||||
| 601 | 601 | ||||
| 602 | The floating workspace pane is a persistent overlay session: hiding it does not destroy its web content. | 602 | The floating workspace pane is a persistent overlay session: hiding it does not destroy its web content. | ||
| 603 | 603 | ||||
| 604 | - | - Default behavior includes exactly one floating shortcut: `Alt+F` (`toggle_floating_pane`). | 604 | + | - Default behavior includes exactly one floating shortcut: `Alt+F` (`toggle-floating-pane`). |
| 605 | - URL shortcuts (for example `Alt+G`) are user-defined via `workspace.floating_pane.profiles` and are empty by default. | 605 | - URL shortcuts (for example `Alt+G`) are user-defined via `workspace.floating_pane.profiles` and are empty by default. | ||
| 606 | - `Alt+F` toggles visibility only and preserves floating state. | 606 | - `Alt+F` toggles visibility only and preserves floating state. | ||
| 607 | - `Ctrl+W` on an active floating pane closes and releases that floating session for a fresh next open. | 607 | - `Ctrl+W` on an active floating pane closes and releases that floating session for a fresh next open. | ||
| @@ -616,7 +616,7 @@ The floating workspace pane is a persistent overlay session: hiding it does not | |||||
| 616 | **Example:** | 616 | **Example:** | ||
| 617 | 617 | ||||
| 618 | ```toml | 618 | ```toml | ||
| 619 | - | [workspace.shortcuts.actions.toggle_floating_pane] | 619 | + | [workspace.shortcuts.actions.toggle-floating-pane] |
| 620 | keys = ["alt+f"] | 620 | keys = ["alt+f"] | ||
| 621 | desc = "Toggle floating pane" | 621 | desc = "Toggle floating pane" | ||
| 622 | 622 | ||||
M docs/reference/configuration.md +2 -2
| @@ -119,8 +119,8 @@ | |||
| 119 | 119 | | `workspace.resize_mode.step_percent` | float | `5.0` | | | |
| 120 | 120 | | `workspace.resize_mode.min_pane_percent` | float | `10.0` | | | |
| 121 | 121 | | `workspace.shortcuts.actions` | map | see defaults | global shortcut action mappings, including browser shortcuts (`open-omnibox`, `reload`, `focus-left`, ...); set `keys = []` to disable one | | |
| 122 | - | | `workspace.shortcuts.actions.toggle_floating_pane.keys` | []string | `alt+f` | key strings | | |
| 123 | - | | `workspace.shortcuts.actions.toggle_floating_pane.desc` | string | `Toggle floating pane` | | | |
| 122 | + | | `workspace.shortcuts.actions.toggle-floating-pane.keys` | []string | `alt+f` | key strings | | |
| 123 | + | | `workspace.shortcuts.actions.toggle-floating-pane.desc` | string | `Toggle floating pane` | | | |
| 124 | 124 | | `workspace.floating_pane.width_pct` | float | `0.82` | `(0,1]` | | |
| 125 | 125 | | `workspace.floating_pane.height_pct` | float | `0.72` | `(0,1]` | | |
| 126 | 126 | | `workspace.floating_pane.profiles.<name>.keys` | []string | | at least one key | | |
| @@ -119,8 +119,8 @@ | |||||
| 119 | | `workspace.resize_mode.step_percent` | float | `5.0` | | | 119 | | `workspace.resize_mode.step_percent` | float | `5.0` | | | ||
| 120 | | `workspace.resize_mode.min_pane_percent` | float | `10.0` | | | 120 | | `workspace.resize_mode.min_pane_percent` | float | `10.0` | | | ||
| 121 | | `workspace.shortcuts.actions` | map | see defaults | global shortcut action mappings, including browser shortcuts (`open-omnibox`, `reload`, `focus-left`, ...); set `keys = []` to disable one | | 121 | | `workspace.shortcuts.actions` | map | see defaults | global shortcut action mappings, including browser shortcuts (`open-omnibox`, `reload`, `focus-left`, ...); set `keys = []` to disable one | | ||
| 122 | - | | `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 | |
| 123 | - | | `workspace.shortcuts.actions.toggle_floating_pane.desc` | string | `Toggle floating pane` | | | 123 | + | | `workspace.shortcuts.actions.toggle-floating-pane.desc` | string | `Toggle floating pane` | | |
| 124 | | `workspace.floating_pane.width_pct` | float | `0.82` | `(0,1]` | | 124 | | `workspace.floating_pane.width_pct` | float | `0.82` | `(0,1]` | | ||
| 125 | | `workspace.floating_pane.height_pct` | float | `0.72` | `(0,1]` | | 125 | | `workspace.floating_pane.height_pct` | float | `0.72` | `(0,1]` | | ||
| 126 | | `workspace.floating_pane.profiles.<name>.keys` | []string | | at least one key | | 126 | | `workspace.floating_pane.profiles.<name>.keys` | []string | | at least one key | | ||
M docs/reference/floating-pane.md +1 -1
| @@ -35,7 +35,7 @@ OAuth flows and scripts that request popup dimensions or restricted browser chro | |||
| 35 | 35 | ## Example | |
| 36 | 36 | ||
| 37 | 37 | ```toml | |
| 38 | - | [workspace.shortcuts.actions.toggle_floating_pane] | |
| 38 | + | [workspace.shortcuts.actions.toggle-floating-pane] | |
| 39 | 39 | keys = ["alt+f"] | |
| 40 | 40 | desc = "Toggle floating pane" | |
| 41 | 41 | ||
| @@ -35,7 +35,7 @@ OAuth flows and scripts that request popup dimensions or restricted browser chro | |||||
| 35 | ## Example | 35 | ## Example | ||
| 36 | 36 | ||||
| 37 | ```toml | 37 | ```toml | ||
| 38 | - | [workspace.shortcuts.actions.toggle_floating_pane] | 38 | + | [workspace.shortcuts.actions.toggle-floating-pane] |
| 39 | keys = ["alt+f"] | 39 | keys = ["alt+f"] | ||
| 40 | desc = "Toggle floating pane" | 40 | desc = "Toggle floating pane" | ||
| 41 | 41 | ||||
M internal/domain/entity/builtin_shortcuts.go +3 -2
| @@ -2,8 +2,9 @@ package entity | |||
| 2 | 2 | ||
| 3 | 3 | // BuiltinGlobalShortcuts returns the standard browser shortcuts shared by every | |
| 4 | 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. | |
| 5 | + | // them, and config loading completes any that a config file omits (unless the | |
| 6 | + | // user already bound one of their keys). An action present with empty keys | |
| 7 | + | // stays disabled. | |
| 7 | 8 | func BuiltinGlobalShortcuts() map[string]ActionBinding { | |
| 8 | 9 | return map[string]ActionBinding{ | |
| 9 | 10 | "open-omnibox": {Keys: []string{"ctrl+l"}, Desc: "Open omnibox"}, | |
| @@ -2,8 +2,9 @@ package entity | |||||
| 2 | 2 | ||||
| 3 | // BuiltinGlobalShortcuts returns the standard browser shortcuts shared by every | 3 | // BuiltinGlobalShortcuts returns the standard browser shortcuts shared by every | ||
| 4 | // keymap preset. Config defaults include them so users can rebind or disable | 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 | 5 | + | // them, and config loading completes any that a config file omits (unless the |
| 6 | - | // older config file. An action present with empty keys stays disabled. | 6 | + | // user already bound one of their keys). An action present with empty keys |
| 7 | + | // stays disabled. | |||
| 7 | func BuiltinGlobalShortcuts() map[string]ActionBinding { | 8 | func BuiltinGlobalShortcuts() map[string]ActionBinding { | ||
| 8 | return map[string]ActionBinding{ | 9 | return map[string]ActionBinding{ | ||
| 9 | "open-omnibox": {Keys: []string{"ctrl+l"}, Desc: "Open omnibox"}, | 10 | "open-omnibox": {Keys: []string{"ctrl+l"}, Desc: "Open omnibox"}, | ||
A internal/domain/keychord/keychord.go +195 -0
| @@ -0,0 +1,195 @@ | |||
| 1 | + | // Package keychord parses and canonicalizes single key chords such as | |
| 2 | + | // "ctrl+shift+t" or "arrowright". | |
| 3 | + | // | |
| 4 | + | // It is the one place that knows which spellings name the same key (aliases, | |
| 5 | + | // modifier names, letter case), so every consumer agrees on when two config | |
| 6 | + | // strings are the same binding. It has no toolkit dependency: the input layer | |
| 7 | + | // maps the canonical key names to its own key values. | |
| 8 | + | package keychord | |
| 9 | + | ||
| 10 | + | import ( | |
| 11 | + | "slices" | |
| 12 | + | "strings" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // Mods holds the modifier flags of a chord. | |
| 16 | + | type Mods uint8 | |
| 17 | + | ||
| 18 | + | // Modifier flags. | |
| 19 | + | const ( | |
| 20 | + | Ctrl Mods = 1 << iota | |
| 21 | + | Alt | |
| 22 | + | Shift | |
| 23 | + | ) | |
| 24 | + | ||
| 25 | + | // Chord is a parsed key chord. Key is the canonical key name: one of the | |
| 26 | + | // names returned by KeyNames, or a single printable ASCII character. | |
| 27 | + | type Chord struct { | |
| 28 | + | Mods Mods | |
| 29 | + | Key string | |
| 30 | + | } | |
| 31 | + | ||
| 32 | + | // String renders the chord in canonical form: modifiers in the fixed order | |
| 33 | + | // ctrl, alt, shift, followed by the key. | |
| 34 | + | func (c Chord) String() string { | |
| 35 | + | var b strings.Builder | |
| 36 | + | if c.Mods&Ctrl != 0 { | |
| 37 | + | b.WriteString("ctrl+") | |
| 38 | + | } | |
| 39 | + | if c.Mods&Alt != 0 { | |
| 40 | + | b.WriteString("alt+") | |
| 41 | + | } | |
| 42 | + | if c.Mods&Shift != 0 { | |
| 43 | + | b.WriteString("shift+") | |
| 44 | + | } | |
| 45 | + | b.WriteString(c.Key) | |
| 46 | + | return b.String() | |
| 47 | + | } | |
| 48 | + | ||
| 49 | + | // keyAliases maps every accepted lower-case spelling of a named key to its | |
| 50 | + | // canonical name. Canonical names map to themselves. | |
| 51 | + | var keyAliases = map[string]string{ | |
| 52 | + | "escape": "escape", | |
| 53 | + | "esc": "escape", | |
| 54 | + | "return": "return", | |
| 55 | + | "enter": "return", | |
| 56 | + | "tab": "tab", | |
| 57 | + | "space": "space", | |
| 58 | + | "backspace": "backspace", | |
| 59 | + | "delete": "delete", | |
| 60 | + | "del": "delete", | |
| 61 | + | "home": "home", | |
| 62 | + | "end": "end", | |
| 63 | + | "pageup": "pageup", | |
| 64 | + | "page_up": "pageup", | |
| 65 | + | "pagedown": "pagedown", | |
| 66 | + | "page_down": "pagedown", | |
| 67 | + | "left": "left", | |
| 68 | + | "arrowleft": "left", | |
| 69 | + | "right": "right", | |
| 70 | + | "arrowright": "right", | |
| 71 | + | "up": "up", | |
| 72 | + | "arrowup": "up", | |
| 73 | + | "down": "down", | |
| 74 | + | "arrowdown": "down", | |
| 75 | + | "f1": "f1", | |
| 76 | + | "f2": "f2", | |
| 77 | + | "f3": "f3", | |
| 78 | + | "f4": "f4", | |
| 79 | + | "f5": "f5", | |
| 80 | + | "f6": "f6", | |
| 81 | + | "f7": "f7", | |
| 82 | + | "f8": "f8", | |
| 83 | + | "f9": "f9", | |
| 84 | + | "f10": "f10", | |
| 85 | + | "f11": "f11", | |
| 86 | + | "f12": "f12", | |
| 87 | + | "plus": "plus", | |
| 88 | + | "+": "plus", | |
| 89 | + | "minus": "minus", | |
| 90 | + | "-": "minus", | |
| 91 | + | "equal": "equal", | |
| 92 | + | "=": "equal", | |
| 93 | + | "bracketleft": "bracketleft", | |
| 94 | + | "[": "bracketleft", | |
| 95 | + | "bracketright": "bracketright", | |
| 96 | + | "]": "bracketright", | |
| 97 | + | "braceleft": "braceleft", | |
| 98 | + | "{": "braceleft", | |
| 99 | + | "braceright": "braceright", | |
| 100 | + | "}": "braceright", | |
| 101 | + | } | |
| 102 | + | ||
| 103 | + | // KeyNames returns the canonical names of the named keys, sorted. Printable | |
| 104 | + | // ASCII characters are also valid keys but are not listed. | |
| 105 | + | func KeyNames() []string { | |
| 106 | + | seen := make(map[string]struct{}) | |
| 107 | + | for _, canonical := range keyAliases { | |
| 108 | + | seen[canonical] = struct{}{} | |
| 109 | + | } | |
| 110 | + | names := make([]string, 0, len(seen)) | |
| 111 | + | for name := range seen { | |
| 112 | + | names = append(names, name) | |
| 113 | + | } | |
| 114 | + | slices.Sort(names) | |
| 115 | + | return names | |
| 116 | + | } | |
| 117 | + | ||
| 118 | + | // canonicalKey resolves a key spelling to its canonical name. | |
| 119 | + | func canonicalKey(name string) (string, bool) { | |
| 120 | + | if canonical, ok := keyAliases[strings.ToLower(name)]; ok { | |
| 121 | + | return canonical, true | |
| 122 | + | } | |
| 123 | + | // Single printable ASCII keys (letters, digits, symbols such as %, ", &). | |
| 124 | + | if len(name) == 1 && name[0] > ' ' && name[0] <= '~' { | |
| 125 | + | return name, true | |
| 126 | + | } | |
| 127 | + | return "", false | |
| 128 | + | } | |
| 129 | + | ||
| 130 | + | // Parse parses a config key string such as "ctrl+t", "Control+Shift+T" or | |
| 131 | + | // "alt+left". Modifier names and named keys are case-insensitive; an upper-case | |
| 132 | + | // single letter implies Shift. It returns false if the string is not a valid | |
| 133 | + | // single chord. | |
| 134 | + | func Parse(s string) (Chord, bool) { | |
| 135 | + | s = strings.TrimSpace(s) | |
| 136 | + | if s == "" { | |
| 137 | + | return Chord{}, false | |
| 138 | + | } | |
| 139 | + | if s == "+" { | |
| 140 | + | return Chord{Key: "plus"}, true | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | var mods Mods | |
| 144 | + | var keyPart string | |
| 145 | + | for _, part := range strings.Split(s, "+") { | |
| 146 | + | part = strings.TrimSpace(part) | |
| 147 | + | if part == "" { | |
| 148 | + | continue | |
| 149 | + | } | |
| 150 | + | switch strings.ToLower(part) { | |
| 151 | + | case "ctrl", "control": | |
| 152 | + | mods |= Ctrl | |
| 153 | + | case "shift": | |
| 154 | + | mods |= Shift | |
| 155 | + | case "alt": | |
| 156 | + | mods |= Alt | |
| 157 | + | default: | |
| 158 | + | if keyPart != "" { | |
| 159 | + | return Chord{}, false | |
| 160 | + | } | |
| 161 | + | keyPart = part | |
| 162 | + | } | |
| 163 | + | } | |
| 164 | + | ||
| 165 | + | // Allow "ctrl++" / "alt+shift++" where the key is "+". | |
| 166 | + | if keyPart == "" && strings.HasSuffix(s, "++") { | |
| 167 | + | keyPart = "+" | |
| 168 | + | } | |
| 169 | + | if keyPart == "" { | |
| 170 | + | return Chord{}, false | |
| 171 | + | } | |
| 172 | + | ||
| 173 | + | // An upper-case single letter is Shift+<letter>. | |
| 174 | + | if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' { | |
| 175 | + | mods |= Shift | |
| 176 | + | keyPart = strings.ToLower(keyPart) | |
| 177 | + | } | |
| 178 | + | ||
| 179 | + | key, ok := canonicalKey(keyPart) | |
| 180 | + | if !ok { | |
| 181 | + | return Chord{}, false | |
| 182 | + | } | |
| 183 | + | return Chord{Mods: mods, Key: key}, true | |
| 184 | + | } | |
| 185 | + | ||
| 186 | + | // Canonicalize returns the canonical spelling of a key string, so that two | |
| 187 | + | // strings that name the same binding compare equal. It returns false if the | |
| 188 | + | // string is not a valid single chord. | |
| 189 | + | func Canonicalize(s string) (string, bool) { | |
| 190 | + | chord, ok := Parse(s) | |
| 191 | + | if !ok { | |
| 192 | + | return "", false | |
| 193 | + | } | |
| 194 | + | return chord.String(), true | |
| 195 | + | } | |
| @@ -0,0 +1,195 @@ | |||||
| 1 | + | // Package keychord parses and canonicalizes single key chords such as | |||
| 2 | + | // "ctrl+shift+t" or "arrowright". | |||
| 3 | + | // | |||
| 4 | + | // It is the one place that knows which spellings name the same key (aliases, | |||
| 5 | + | // modifier names, letter case), so every consumer agrees on when two config | |||
| 6 | + | // strings are the same binding. It has no toolkit dependency: the input layer | |||
| 7 | + | // maps the canonical key names to its own key values. | |||
| 8 | + | package keychord | |||
| 9 | + | ||||
| 10 | + | import ( | |||
| 11 | + | "slices" | |||
| 12 | + | "strings" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // Mods holds the modifier flags of a chord. | |||
| 16 | + | type Mods uint8 | |||
| 17 | + | ||||
| 18 | + | // Modifier flags. | |||
| 19 | + | const ( | |||
| 20 | + | Ctrl Mods = 1 << iota | |||
| 21 | + | Alt | |||
| 22 | + | Shift | |||
| 23 | + | ) | |||
| 24 | + | ||||
| 25 | + | // Chord is a parsed key chord. Key is the canonical key name: one of the | |||
| 26 | + | // names returned by KeyNames, or a single printable ASCII character. | |||
| 27 | + | type Chord struct { | |||
| 28 | + | Mods Mods | |||
| 29 | + | Key string | |||
| 30 | + | } | |||
| 31 | + | ||||
| 32 | + | // String renders the chord in canonical form: modifiers in the fixed order | |||
| 33 | + | // ctrl, alt, shift, followed by the key. | |||
| 34 | + | func (c Chord) String() string { | |||
| 35 | + | var b strings.Builder | |||
| 36 | + | if c.Mods&Ctrl != 0 { | |||
| 37 | + | b.WriteString("ctrl+") | |||
| 38 | + | } | |||
| 39 | + | if c.Mods&Alt != 0 { | |||
| 40 | + | b.WriteString("alt+") | |||
| 41 | + | } | |||
| 42 | + | if c.Mods&Shift != 0 { | |||
| 43 | + | b.WriteString("shift+") | |||
| 44 | + | } | |||
| 45 | + | b.WriteString(c.Key) | |||
| 46 | + | return b.String() | |||
| 47 | + | } | |||
| 48 | + | ||||
| 49 | + | // keyAliases maps every accepted lower-case spelling of a named key to its | |||
| 50 | + | // canonical name. Canonical names map to themselves. | |||
| 51 | + | var keyAliases = map[string]string{ | |||
| 52 | + | "escape": "escape", | |||
| 53 | + | "esc": "escape", | |||
| 54 | + | "return": "return", | |||
| 55 | + | "enter": "return", | |||
| 56 | + | "tab": "tab", | |||
| 57 | + | "space": "space", | |||
| 58 | + | "backspace": "backspace", | |||
| 59 | + | "delete": "delete", | |||
| 60 | + | "del": "delete", | |||
| 61 | + | "home": "home", | |||
| 62 | + | "end": "end", | |||
| 63 | + | "pageup": "pageup", | |||
| 64 | + | "page_up": "pageup", | |||
| 65 | + | "pagedown": "pagedown", | |||
| 66 | + | "page_down": "pagedown", | |||
| 67 | + | "left": "left", | |||
| 68 | + | "arrowleft": "left", | |||
| 69 | + | "right": "right", | |||
| 70 | + | "arrowright": "right", | |||
| 71 | + | "up": "up", | |||
| 72 | + | "arrowup": "up", | |||
| 73 | + | "down": "down", | |||
| 74 | + | "arrowdown": "down", | |||
| 75 | + | "f1": "f1", | |||
| 76 | + | "f2": "f2", | |||
| 77 | + | "f3": "f3", | |||
| 78 | + | "f4": "f4", | |||
| 79 | + | "f5": "f5", | |||
| 80 | + | "f6": "f6", | |||
| 81 | + | "f7": "f7", | |||
| 82 | + | "f8": "f8", | |||
| 83 | + | "f9": "f9", | |||
| 84 | + | "f10": "f10", | |||
| 85 | + | "f11": "f11", | |||
| 86 | + | "f12": "f12", | |||
| 87 | + | "plus": "plus", | |||
| 88 | + | "+": "plus", | |||
| 89 | + | "minus": "minus", | |||
| 90 | + | "-": "minus", | |||
| 91 | + | "equal": "equal", | |||
| 92 | + | "=": "equal", | |||
| 93 | + | "bracketleft": "bracketleft", | |||
| 94 | + | "[": "bracketleft", | |||
| 95 | + | "bracketright": "bracketright", | |||
| 96 | + | "]": "bracketright", | |||
| 97 | + | "braceleft": "braceleft", | |||
| 98 | + | "{": "braceleft", | |||
| 99 | + | "braceright": "braceright", | |||
| 100 | + | "}": "braceright", | |||
| 101 | + | } | |||
| 102 | + | ||||
| 103 | + | // KeyNames returns the canonical names of the named keys, sorted. Printable | |||
| 104 | + | // ASCII characters are also valid keys but are not listed. | |||
| 105 | + | func KeyNames() []string { | |||
| 106 | + | seen := make(map[string]struct{}) | |||
| 107 | + | for _, canonical := range keyAliases { | |||
| 108 | + | seen[canonical] = struct{}{} | |||
| 109 | + | } | |||
| 110 | + | names := make([]string, 0, len(seen)) | |||
| 111 | + | for name := range seen { | |||
| 112 | + | names = append(names, name) | |||
| 113 | + | } | |||
| 114 | + | slices.Sort(names) | |||
| 115 | + | return names | |||
| 116 | + | } | |||
| 117 | + | ||||
| 118 | + | // canonicalKey resolves a key spelling to its canonical name. | |||
| 119 | + | func canonicalKey(name string) (string, bool) { | |||
| 120 | + | if canonical, ok := keyAliases[strings.ToLower(name)]; ok { | |||
| 121 | + | return canonical, true | |||
| 122 | + | } | |||
| 123 | + | // Single printable ASCII keys (letters, digits, symbols such as %, ", &). | |||
| 124 | + | if len(name) == 1 && name[0] > ' ' && name[0] <= '~' { | |||
| 125 | + | return name, true | |||
| 126 | + | } | |||
| 127 | + | return "", false | |||
| 128 | + | } | |||
| 129 | + | ||||
| 130 | + | // Parse parses a config key string such as "ctrl+t", "Control+Shift+T" or | |||
| 131 | + | // "alt+left". Modifier names and named keys are case-insensitive; an upper-case | |||
| 132 | + | // single letter implies Shift. It returns false if the string is not a valid | |||
| 133 | + | // single chord. | |||
| 134 | + | func Parse(s string) (Chord, bool) { | |||
| 135 | + | s = strings.TrimSpace(s) | |||
| 136 | + | if s == "" { | |||
| 137 | + | return Chord{}, false | |||
| 138 | + | } | |||
| 139 | + | if s == "+" { | |||
| 140 | + | return Chord{Key: "plus"}, true | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | var mods Mods | |||
| 144 | + | var keyPart string | |||
| 145 | + | for _, part := range strings.Split(s, "+") { | |||
| 146 | + | part = strings.TrimSpace(part) | |||
| 147 | + | if part == "" { | |||
| 148 | + | continue | |||
| 149 | + | } | |||
| 150 | + | switch strings.ToLower(part) { | |||
| 151 | + | case "ctrl", "control": | |||
| 152 | + | mods |= Ctrl | |||
| 153 | + | case "shift": | |||
| 154 | + | mods |= Shift | |||
| 155 | + | case "alt": | |||
| 156 | + | mods |= Alt | |||
| 157 | + | default: | |||
| 158 | + | if keyPart != "" { | |||
| 159 | + | return Chord{}, false | |||
| 160 | + | } | |||
| 161 | + | keyPart = part | |||
| 162 | + | } | |||
| 163 | + | } | |||
| 164 | + | ||||
| 165 | + | // Allow "ctrl++" / "alt+shift++" where the key is "+". | |||
| 166 | + | if keyPart == "" && strings.HasSuffix(s, "++") { | |||
| 167 | + | keyPart = "+" | |||
| 168 | + | } | |||
| 169 | + | if keyPart == "" { | |||
| 170 | + | return Chord{}, false | |||
| 171 | + | } | |||
| 172 | + | ||||
| 173 | + | // An upper-case single letter is Shift+<letter>. | |||
| 174 | + | if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' { | |||
| 175 | + | mods |= Shift | |||
| 176 | + | keyPart = strings.ToLower(keyPart) | |||
| 177 | + | } | |||
| 178 | + | ||||
| 179 | + | key, ok := canonicalKey(keyPart) | |||
| 180 | + | if !ok { | |||
| 181 | + | return Chord{}, false | |||
| 182 | + | } | |||
| 183 | + | return Chord{Mods: mods, Key: key}, true | |||
| 184 | + | } | |||
| 185 | + | ||||
| 186 | + | // Canonicalize returns the canonical spelling of a key string, so that two | |||
| 187 | + | // strings that name the same binding compare equal. It returns false if the | |||
| 188 | + | // string is not a valid single chord. | |||
| 189 | + | func Canonicalize(s string) (string, bool) { | |||
| 190 | + | chord, ok := Parse(s) | |||
| 191 | + | if !ok { | |||
| 192 | + | return "", false | |||
| 193 | + | } | |||
| 194 | + | return chord.String(), true | |||
| 195 | + | } | |||
A internal/domain/keychord/keychord_test.go +96 -0
| @@ -0,0 +1,96 @@ | |||
| 1 | + | package keychord | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "testing" | |
| 5 | + | ||
| 6 | + | "github.com/stretchr/testify/assert" | |
| 7 | + | "github.com/stretchr/testify/require" | |
| 8 | + | ) | |
| 9 | + | ||
| 10 | + | func TestCanonicalize_SameBindingSameString(t *testing.T) { | |
| 11 | + | groups := [][]string{ | |
| 12 | + | {"right", "arrowright", "Right", "ArrowRight"}, | |
| 13 | + | {"left", "arrowleft"}, | |
| 14 | + | {"escape", "esc", "Escape"}, | |
| 15 | + | {"enter", "return", "Return"}, | |
| 16 | + | {"delete", "del"}, | |
| 17 | + | {"pageup", "page_up"}, | |
| 18 | + | {"ctrl+t", "control+t", "CTRL+t", " Ctrl + t"}, | |
| 19 | + | {"ctrl+shift+t", "Ctrl+Shift+T", "shift+ctrl+t", "control+shift+T", "Control+T"}, | |
| 20 | + | {"M", "shift+m", "shift+M"}, | |
| 21 | + | {"ctrl+plus", "ctrl++", "ctrl+Plus"}, | |
| 22 | + | {"plus", "+"}, | |
| 23 | + | {"minus", "-"}, | |
| 24 | + | {"alt+[", "alt+bracketleft"}, | |
| 25 | + | {"alt+shift+tab", "shift+alt+tab"}, | |
| 26 | + | {" ctrl + t ", "ctrl+t"}, | |
| 27 | + | } | |
| 28 | + | for _, group := range groups { | |
| 29 | + | want, ok := Canonicalize(group[0]) | |
| 30 | + | require.True(t, ok, group[0]) | |
| 31 | + | for _, spelling := range group[1:] { | |
| 32 | + | got, ok := Canonicalize(spelling) | |
| 33 | + | require.True(t, ok, spelling) | |
| 34 | + | assert.Equal(t, want, got, "%q should equal %q", spelling, group[0]) | |
| 35 | + | } | |
| 36 | + | } | |
| 37 | + | } | |
| 38 | + | ||
| 39 | + | func TestCanonicalize_DifferentBindingsDiffer(t *testing.T) { | |
| 40 | + | pairs := [][2]string{ | |
| 41 | + | {"m", "M"}, | |
| 42 | + | {"ctrl+t", "alt+t"}, | |
| 43 | + | {"ctrl+t", "t"}, | |
| 44 | + | {"left", "right"}, | |
| 45 | + | {"ctrl+shift+t", "ctrl+t"}, | |
| 46 | + | {"1", "f1"}, | |
| 47 | + | } | |
| 48 | + | for _, pair := range pairs { | |
| 49 | + | a, ok := Canonicalize(pair[0]) | |
| 50 | + | require.True(t, ok, pair[0]) | |
| 51 | + | b, ok := Canonicalize(pair[1]) | |
| 52 | + | require.True(t, ok, pair[1]) | |
| 53 | + | assert.NotEqual(t, a, b, "%q vs %q", pair[0], pair[1]) | |
| 54 | + | } | |
| 55 | + | } | |
| 56 | + | ||
| 57 | + | func TestCanonicalize_CanonicalForm(t *testing.T) { | |
| 58 | + | tests := map[string]string{ | |
| 59 | + | "arrowright": "right", | |
| 60 | + | "Control+Shift+T": "ctrl+shift+t", | |
| 61 | + | "shift+alt+ctrl+Esc": "ctrl+alt+shift+escape", | |
| 62 | + | "M": "shift+m", | |
| 63 | + | "ctrl++": "ctrl+plus", | |
| 64 | + | "%": "%", | |
| 65 | + | "shift+%": "shift+%", | |
| 66 | + | "f5": "f5", | |
| 67 | + | "0": "0", | |
| 68 | + | "alt+bracketleft": "alt+bracketleft", | |
| 69 | + | "ctrl+arrowleft": "ctrl+left", | |
| 70 | + | " Alt + Page_Down ": "alt+pagedown", | |
| 71 | + | } | |
| 72 | + | for in, want := range tests { | |
| 73 | + | got, ok := Canonicalize(in) | |
| 74 | + | require.True(t, ok, in) | |
| 75 | + | assert.Equal(t, want, got, in) | |
| 76 | + | } | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestParse_Invalid(t *testing.T) { | |
| 80 | + | for _, in := range []string{"", " ", "ctrl+", "ctrl", "unknownkey", "ctrl+unknownkey", "a+b", "é"} { | |
| 81 | + | _, ok := Parse(in) | |
| 82 | + | assert.False(t, ok, "%q", in) | |
| 83 | + | } | |
| 84 | + | } | |
| 85 | + | ||
| 86 | + | func TestKeyNames_AreCanonicalAndStable(t *testing.T) { | |
| 87 | + | names := KeyNames() | |
| 88 | + | require.Contains(t, names, "left") | |
| 89 | + | require.Contains(t, names, "plus") | |
| 90 | + | require.NotContains(t, names, "arrowleft") | |
| 91 | + | for _, name := range names { | |
| 92 | + | got, ok := canonicalKey(name) | |
| 93 | + | require.True(t, ok, name) | |
| 94 | + | assert.Equal(t, name, got) | |
| 95 | + | } | |
| 96 | + | } | |
| @@ -0,0 +1,96 @@ | |||||
| 1 | + | package keychord | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "testing" | |||
| 5 | + | ||||
| 6 | + | "github.com/stretchr/testify/assert" | |||
| 7 | + | "github.com/stretchr/testify/require" | |||
| 8 | + | ) | |||
| 9 | + | ||||
| 10 | + | func TestCanonicalize_SameBindingSameString(t *testing.T) { | |||
| 11 | + | groups := [][]string{ | |||
| 12 | + | {"right", "arrowright", "Right", "ArrowRight"}, | |||
| 13 | + | {"left", "arrowleft"}, | |||
| 14 | + | {"escape", "esc", "Escape"}, | |||
| 15 | + | {"enter", "return", "Return"}, | |||
| 16 | + | {"delete", "del"}, | |||
| 17 | + | {"pageup", "page_up"}, | |||
| 18 | + | {"ctrl+t", "control+t", "CTRL+t", " Ctrl + t"}, | |||
| 19 | + | {"ctrl+shift+t", "Ctrl+Shift+T", "shift+ctrl+t", "control+shift+T", "Control+T"}, | |||
| 20 | + | {"M", "shift+m", "shift+M"}, | |||
| 21 | + | {"ctrl+plus", "ctrl++", "ctrl+Plus"}, | |||
| 22 | + | {"plus", "+"}, | |||
| 23 | + | {"minus", "-"}, | |||
| 24 | + | {"alt+[", "alt+bracketleft"}, | |||
| 25 | + | {"alt+shift+tab", "shift+alt+tab"}, | |||
| 26 | + | {" ctrl + t ", "ctrl+t"}, | |||
| 27 | + | } | |||
| 28 | + | for _, group := range groups { | |||
| 29 | + | want, ok := Canonicalize(group[0]) | |||
| 30 | + | require.True(t, ok, group[0]) | |||
| 31 | + | for _, spelling := range group[1:] { | |||
| 32 | + | got, ok := Canonicalize(spelling) | |||
| 33 | + | require.True(t, ok, spelling) | |||
| 34 | + | assert.Equal(t, want, got, "%q should equal %q", spelling, group[0]) | |||
| 35 | + | } | |||
| 36 | + | } | |||
| 37 | + | } | |||
| 38 | + | ||||
| 39 | + | func TestCanonicalize_DifferentBindingsDiffer(t *testing.T) { | |||
| 40 | + | pairs := [][2]string{ | |||
| 41 | + | {"m", "M"}, | |||
| 42 | + | {"ctrl+t", "alt+t"}, | |||
| 43 | + | {"ctrl+t", "t"}, | |||
| 44 | + | {"left", "right"}, | |||
| 45 | + | {"ctrl+shift+t", "ctrl+t"}, | |||
| 46 | + | {"1", "f1"}, | |||
| 47 | + | } | |||
| 48 | + | for _, pair := range pairs { | |||
| 49 | + | a, ok := Canonicalize(pair[0]) | |||
| 50 | + | require.True(t, ok, pair[0]) | |||
| 51 | + | b, ok := Canonicalize(pair[1]) | |||
| 52 | + | require.True(t, ok, pair[1]) | |||
| 53 | + | assert.NotEqual(t, a, b, "%q vs %q", pair[0], pair[1]) | |||
| 54 | + | } | |||
| 55 | + | } | |||
| 56 | + | ||||
| 57 | + | func TestCanonicalize_CanonicalForm(t *testing.T) { | |||
| 58 | + | tests := map[string]string{ | |||
| 59 | + | "arrowright": "right", | |||
| 60 | + | "Control+Shift+T": "ctrl+shift+t", | |||
| 61 | + | "shift+alt+ctrl+Esc": "ctrl+alt+shift+escape", | |||
| 62 | + | "M": "shift+m", | |||
| 63 | + | "ctrl++": "ctrl+plus", | |||
| 64 | + | "%": "%", | |||
| 65 | + | "shift+%": "shift+%", | |||
| 66 | + | "f5": "f5", | |||
| 67 | + | "0": "0", | |||
| 68 | + | "alt+bracketleft": "alt+bracketleft", | |||
| 69 | + | "ctrl+arrowleft": "ctrl+left", | |||
| 70 | + | " Alt + Page_Down ": "alt+pagedown", | |||
| 71 | + | } | |||
| 72 | + | for in, want := range tests { | |||
| 73 | + | got, ok := Canonicalize(in) | |||
| 74 | + | require.True(t, ok, in) | |||
| 75 | + | assert.Equal(t, want, got, in) | |||
| 76 | + | } | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestParse_Invalid(t *testing.T) { | |||
| 80 | + | for _, in := range []string{"", " ", "ctrl+", "ctrl", "unknownkey", "ctrl+unknownkey", "a+b", "é"} { | |||
| 81 | + | _, ok := Parse(in) | |||
| 82 | + | assert.False(t, ok, "%q", in) | |||
| 83 | + | } | |||
| 84 | + | } | |||
| 85 | + | ||||
| 86 | + | func TestKeyNames_AreCanonicalAndStable(t *testing.T) { | |||
| 87 | + | names := KeyNames() | |||
| 88 | + | require.Contains(t, names, "left") | |||
| 89 | + | require.Contains(t, names, "plus") | |||
| 90 | + | require.NotContains(t, names, "arrowleft") | |||
| 91 | + | for _, name := range names { | |||
| 92 | + | got, ok := canonicalKey(name) | |||
| 93 | + | require.True(t, ok, name) | |||
| 94 | + | assert.Equal(t, name, got) | |||
| 95 | + | } | |||
| 96 | + | } | |||
M internal/infrastructure/config/default_actions.go +7 -15
| @@ -4,7 +4,6 @@ import ( | |||
| 4 | 4 | "fmt" | |
| 5 | 5 | "slices" | |
| 6 | 6 | "sort" | |
| 7 | - | "strings" | |
| 8 | 7 | ||
| 9 | 8 | "github.com/bnema/dumber/internal/logging" | |
| 10 | 9 | ) | |
| @@ -18,7 +17,7 @@ type actionMapSpec struct { | |||
| 18 | 17 | // actions returns a pointer to the action map inside a Config. | |
| 19 | 18 | actions func(*Config) *map[string]ActionBinding | |
| 20 | 19 | // canonKey canonicalizes a key sequence the way the validator does for | |
| 21 | - | // duplicate detection. nil means keys are compared verbatim. | |
| 20 | + | // duplicate detection. nil means single key chords (see canonicalizeChordKey). | |
| 22 | 21 | canonKey func(string) (string, error) | |
| 23 | 22 | } | |
| 24 | 23 | ||
| @@ -38,11 +37,6 @@ func actionMapSpecs() []actionMapSpec { | |||
| 38 | 37 | } | |
| 39 | 38 | } | |
| 40 | 39 | ||
| 41 | - | // legacyActionName returns the underscore spelling used by older configs. | |
| 42 | - | func legacyActionName(name string) string { | |
| 43 | - | return strings.ReplaceAll(name, "-", "_") | |
| 44 | - | } | |
| 45 | - | ||
| 46 | 40 | // skippedDefaultAction is a default action not added because a user binding wins. | |
| 47 | 41 | type skippedDefaultAction struct { | |
| 48 | 42 | name string | |
| @@ -51,17 +45,18 @@ type skippedDefaultAction struct { | |||
| 51 | 45 | } | |
| 52 | 46 | ||
| 53 | 47 | // missingDefaultActions returns the sorted names of default actions that the | |
| 54 | - | // user did not configure (by canonical or legacy underscore name) and whose key | |
| 55 | - | // sequences are not already used by a user action. userKeys maps each user | |
| 56 | - | // action name to its keys; an action with no keys (disabled) is still present, | |
| 57 | - | // so it is never re-added. | |
| 48 | + | // user did not configure and whose key sequences are not already used by a user | |
| 49 | + | // action. userKeys maps each user action name to its keys; an action with no | |
| 50 | + | // keys (disabled) is still present, so it is never re-added. Action names are | |
| 51 | + | // expected in canonical (hyphenated) form: LegacyConfigTransformer renames | |
| 52 | + | // underscore spellings before this runs. | |
| 58 | 53 | func (s actionMapSpec) missingDefaultActions( | |
| 59 | 54 | defaults map[string]ActionBinding, | |
| 60 | 55 | userKeys map[string][]string, | |
| 61 | 56 | ) (missing []string, skipped []skippedDefaultAction) { | |
| 62 | 57 | canon := s.canonKey | |
| 63 | 58 | if canon == nil { | |
| 64 | - | canon = func(key string) (string, error) { return key, nil } | |
| 59 | + | canon = canonicalizeChordKey | |
| 65 | 60 | } | |
| 66 | 61 | canonOrRaw := func(key string) string { | |
| 67 | 62 | if c, err := canon(key); err == nil { | |
| @@ -92,9 +87,6 @@ func (s actionMapSpec) missingDefaultActions( | |||
| 92 | 87 | if _, ok := userKeys[name]; ok { | |
| 93 | 88 | continue | |
| 94 | 89 | } | |
| 95 | - | if _, ok := userKeys[legacyActionName(name)]; ok { | |
| 96 | - | continue | |
| 97 | - | } | |
| 98 | 90 | conflict := false | |
| 99 | 91 | for _, key := range defaults[name].Keys { | |
| 100 | 92 | sequence := canonOrRaw(key) | |
| @@ -4,7 +4,6 @@ import ( | |||||
| 4 | "fmt" | 4 | "fmt" | ||
| 5 | "slices" | 5 | "slices" | ||
| 6 | "sort" | 6 | "sort" | ||
| 7 | - | "strings" | |||
| 8 | 7 | ||||
| 9 | "github.com/bnema/dumber/internal/logging" | 8 | "github.com/bnema/dumber/internal/logging" | ||
| 10 | ) | 9 | ) | ||
| @@ -18,7 +17,7 @@ type actionMapSpec struct { | |||||
| 18 | // actions returns a pointer to the action map inside a Config. | 17 | // actions returns a pointer to the action map inside a Config. | ||
| 19 | actions func(*Config) *map[string]ActionBinding | 18 | actions func(*Config) *map[string]ActionBinding | ||
| 20 | // canonKey canonicalizes a key sequence the way the validator does for | 19 | // canonKey canonicalizes a key sequence the way the validator does for | ||
| 21 | - | // duplicate detection. nil means keys are compared verbatim. | 20 | + | // duplicate detection. nil means single key chords (see canonicalizeChordKey). |
| 22 | canonKey func(string) (string, error) | 21 | canonKey func(string) (string, error) | ||
| 23 | } | 22 | } | ||
| 24 | 23 | ||||
| @@ -38,11 +37,6 @@ func actionMapSpecs() []actionMapSpec { | |||||
| 38 | } | 37 | } | ||
| 39 | } | 38 | } | ||
| 40 | 39 | ||||
| 41 | - | // legacyActionName returns the underscore spelling used by older configs. | |||
| 42 | - | func legacyActionName(name string) string { | |||
| 43 | - | return strings.ReplaceAll(name, "-", "_") | |||
| 44 | - | } | |||
| 45 | - | ||||
| 46 | // skippedDefaultAction is a default action not added because a user binding wins. | 40 | // skippedDefaultAction is a default action not added because a user binding wins. | ||
| 47 | type skippedDefaultAction struct { | 41 | type skippedDefaultAction struct { | ||
| 48 | name string | 42 | name string | ||
| @@ -51,17 +45,18 @@ type skippedDefaultAction struct { | |||||
| 51 | } | 45 | } | ||
| 52 | 46 | ||||
| 53 | // missingDefaultActions returns the sorted names of default actions that the | 47 | // missingDefaultActions returns the sorted names of default actions that the | ||
| 54 | - | // user did not configure (by canonical or legacy underscore name) and whose key | 48 | + | // user did not configure and whose key sequences are not already used by a user |
| 55 | - | // sequences are not already used by a user action. userKeys maps each user | 49 | + | // action. userKeys maps each user action name to its keys; an action with no |
| 56 | - | // action name to its keys; an action with no keys (disabled) is still present, | 50 | + | // keys (disabled) is still present, so it is never re-added. Action names are |
| 57 | - | // so it is never re-added. | 51 | + | // expected in canonical (hyphenated) form: LegacyConfigTransformer renames |
| 52 | + | // underscore spellings before this runs. | |||
| 58 | func (s actionMapSpec) missingDefaultActions( | 53 | func (s actionMapSpec) missingDefaultActions( | ||
| 59 | defaults map[string]ActionBinding, | 54 | defaults map[string]ActionBinding, | ||
| 60 | userKeys map[string][]string, | 55 | userKeys map[string][]string, | ||
| 61 | ) (missing []string, skipped []skippedDefaultAction) { | 56 | ) (missing []string, skipped []skippedDefaultAction) { | ||
| 62 | canon := s.canonKey | 57 | canon := s.canonKey | ||
| 63 | if canon == nil { | 58 | if canon == nil { | ||
| 64 | - | canon = func(key string) (string, error) { return key, nil } | 59 | + | canon = canonicalizeChordKey |
| 65 | } | 60 | } | ||
| 66 | canonOrRaw := func(key string) string { | 61 | canonOrRaw := func(key string) string { | ||
| 67 | if c, err := canon(key); err == nil { | 62 | if c, err := canon(key); err == nil { | ||
| @@ -92,9 +87,6 @@ func (s actionMapSpec) missingDefaultActions( | |||||
| 92 | if _, ok := userKeys[name]; ok { | 87 | if _, ok := userKeys[name]; ok { | ||
| 93 | continue | 88 | continue | ||
| 94 | } | 89 | } | ||
| 95 | - | if _, ok := userKeys[legacyActionName(name)]; ok { | |||
| 96 | - | continue | |||
| 97 | - | } | |||
| 98 | conflict := false | 90 | conflict := false | ||
| 99 | for _, key := range defaults[name].Keys { | 91 | for _, key := range defaults[name].Keys { | ||
| 100 | sequence := canonOrRaw(key) | 92 | sequence := canonOrRaw(key) | ||
M internal/infrastructure/config/default_actions_test.go +147 -9
| @@ -122,15 +122,6 @@ func TestCompleteDefaultActions_SkipsDefaultsConflictingWithUserKeys(t *testing. | |||
| 122 | 122 | require.NoError(t, validateConfig(cfg)) | |
| 123 | 123 | } | |
| 124 | 124 | ||
| 125 | - | func TestCompleteDefaultActions_LegacyUnderscoreNameCountsAsConfigured(t *testing.T) { | |
| 126 | - | cfg := DefaultConfig() | |
| 127 | - | cfg.Workspace.PaneMode.Actions = map[string]ActionBinding{ | |
| 128 | - | "stack_pane": {Keys: []string{"ctrl+alt+f12"}}, | |
| 129 | - | } | |
| 130 | - | completeDefaultActions(cfg) | |
| 131 | - | assert.NotContains(t, cfg.Workspace.PaneMode.Actions, "stack-pane") | |
| 132 | - | } | |
| 133 | - | ||
| 134 | 125 | func TestCompleteDefaultActions_DoesNotMutateSharedMaps(t *testing.T) { | |
| 135 | 126 | cfg := DefaultConfig() | |
| 136 | 127 | original := map[string]ActionBinding{"custom": {Keys: []string{"ctrl+alt+f12"}}} | |
| @@ -261,3 +252,150 @@ keys = [] | |||
| 261 | 252 | assert.Empty(t, stack.Keys) | |
| 262 | 253 | assert.Len(t, got.Workspace.VimMode.Actions, len(DefaultConfig().Workspace.VimMode.Actions)) | |
| 263 | 254 | } | |
| 255 | + | ||
| 256 | + | func TestManagerLoad_UnderscoreActionNamesAreCanonicalizedBeforeCompletion(t *testing.T) { | |
| 257 | + | cfg := loadConfigFromTOML(t, ` | |
| 258 | + | [workspace.pane_mode.actions.stack_pane] | |
| 259 | + | keys = ["ctrl+alt+f12"] | |
| 260 | + | desc = "Mine" | |
| 261 | + | ||
| 262 | + | [workspace.resize_mode.actions.resize_increase_left] | |
| 263 | + | keys = ["z"] | |
| 264 | + | ||
| 265 | + | [workspace.prefix_mode.actions.enter_resize_mode] | |
| 266 | + | keys = ["ctrl+alt+f11"] | |
| 267 | + | ||
| 268 | + | [workspace.pane_mode.actions.focus_left] | |
| 269 | + | keys = ["ctrl+alt+f10"] | |
| 270 | + | ||
| 271 | + | [workspace.shortcuts.actions.toggle_floating_pane] | |
| 272 | + | keys = ["alt+g"] | |
| 273 | + | `) | |
| 274 | + | ||
| 275 | + | pane := cfg.Workspace.PaneMode.Actions | |
| 276 | + | assert.NotContains(t, pane, "stack_pane") | |
| 277 | + | assert.Equal(t, []string{"ctrl+alt+f12"}, pane["stack-pane"].Keys) | |
| 278 | + | assert.Equal(t, "Mine", pane["stack-pane"].Desc) | |
| 279 | + | assert.NotContains(t, pane, "focus_left") | |
| 280 | + | assert.Equal(t, []string{"ctrl+alt+f10"}, pane["focus-left"].Keys) | |
| 281 | + | ||
| 282 | + | resize := cfg.Workspace.ResizeMode.Actions | |
| 283 | + | assert.NotContains(t, resize, "resize_increase_left") | |
| 284 | + | assert.Equal(t, []string{"z"}, resize["resize-increase-left"].Keys) | |
| 285 | + | assert.Contains(t, resize, "resize-increase-right", "other defaults are still completed") | |
| 286 | + | ||
| 287 | + | prefix := cfg.Workspace.PrefixMode.Actions | |
| 288 | + | assert.NotContains(t, prefix, "enter_resize_mode") | |
| 289 | + | assert.Equal(t, []string{"ctrl+alt+f11"}, prefix["enter-resize-mode"].Keys) | |
| 290 | + | ||
| 291 | + | shortcuts := cfg.Workspace.Shortcuts.Actions | |
| 292 | + | assert.NotContains(t, shortcuts, "toggle_floating_pane") | |
| 293 | + | assert.Equal(t, []string{"alt+g"}, shortcuts["toggle-floating-pane"].Keys) | |
| 294 | + | ||
| 295 | + | for _, spec := range actionMapSpecs() { | |
| 296 | + | for name := range *spec.actions(cfg) { | |
| 297 | + | assert.NotContains(t, name, "_", "%s.%s", spec.key, name) | |
| 298 | + | } | |
| 299 | + | } | |
| 300 | + | } | |
| 301 | + | ||
| 302 | + | func TestManagerLoad_HyphenActionNameWinsOverUnderscoreDuplicate(t *testing.T) { | |
| 303 | + | cfg := loadConfigFromTOML(t, ` | |
| 304 | + | [workspace.pane_mode.actions.stack_pane] | |
| 305 | + | keys = ["ctrl+alt+f12"] | |
| 306 | + | ||
| 307 | + | [workspace.pane_mode.actions.stack-pane] | |
| 308 | + | keys = ["ctrl+alt+f11"] | |
| 309 | + | `) | |
| 310 | + | assert.Equal(t, []string{"ctrl+alt+f11"}, cfg.Workspace.PaneMode.Actions["stack-pane"].Keys) | |
| 311 | + | assert.NotContains(t, cfg.Workspace.PaneMode.Actions, "stack_pane") | |
| 312 | + | } | |
| 313 | + | ||
| 314 | + | func TestCompleteDefaultActions_AliasSpellingsOfAUserKeyBlockTheDefault(t *testing.T) { | |
| 315 | + | tests := []struct { | |
| 316 | + | name string | |
| 317 | + | userKey string | |
| 318 | + | wantSkipped string | |
| 319 | + | }{ | |
| 320 | + | {name: "right vs arrowright", userKey: "right", wantSkipped: "split-right"}, | |
| 321 | + | {name: "Right vs arrowright", userKey: "Right", wantSkipped: "split-right"}, | |
| 322 | + | {name: "uppercase letter vs explicit shift", userKey: "shift+M", wantSkipped: "move-pane-to-next-tab"}, | |
| 323 | + | {name: "explicit shift vs uppercase letter", userKey: "M", wantSkipped: "move-pane-to-next-tab"}, | |
| 324 | + | } | |
| 325 | + | for _, tt := range tests { | |
| 326 | + | t.Run(tt.name, func(t *testing.T) { | |
| 327 | + | cfg := DefaultConfig() | |
| 328 | + | cfg.Workspace.PaneMode.Actions = map[string]ActionBinding{ | |
| 329 | + | "close-pane": {Keys: []string{tt.userKey}}, | |
| 330 | + | } | |
| 331 | + | completeDefaultActions(cfg) | |
| 332 | + | ||
| 333 | + | got := cfg.Workspace.PaneMode.Actions | |
| 334 | + | assert.Equal(t, []string{tt.userKey}, got["close-pane"].Keys) | |
| 335 | + | if tt.wantSkipped != "" { | |
| 336 | + | assert.NotContains(t, got, tt.wantSkipped) | |
| 337 | + | } | |
| 338 | + | require.NoError(t, validateConfig(cfg), "completed config must stay valid") | |
| 339 | + | }) | |
| 340 | + | } | |
| 341 | + | } | |
| 342 | + | ||
| 343 | + | func TestCompleteDefaultActions_ControlSpellingBlocksGlobalDefault(t *testing.T) { | |
| 344 | + | cfg := DefaultConfig() | |
| 345 | + | cfg.Workspace.Shortcuts.Actions = map[string]ActionBinding{ | |
| 346 | + | "new-tab": {Keys: []string{"Control+r"}}, | |
| 347 | + | } | |
| 348 | + | completeDefaultActions(cfg) | |
| 349 | + | ||
| 350 | + | // Default reload is ["ctrl+r", "f5"]; "Control+r" is the same key as | |
| 351 | + | // "ctrl+r", so the user owns it and reload is not added back. | |
| 352 | + | assert.NotContains(t, cfg.Workspace.Shortcuts.Actions, "reload") | |
| 353 | + | assert.Contains(t, cfg.Workspace.Shortcuts.Actions, "hard-reload") | |
| 354 | + | assert.Contains(t, cfg.Workspace.Shortcuts.Actions, "open-omnibox") | |
| 355 | + | require.NoError(t, validateConfig(cfg)) | |
| 356 | + | } | |
| 357 | + | ||
| 358 | + | func TestManagerLoad_AliasConflictIsResolvedDeterministically(t *testing.T) { | |
| 359 | + | cfg := loadConfigFromTOML(t, ` | |
| 360 | + | [workspace.pane_mode.actions.close-pane] | |
| 361 | + | keys = ["right"] | |
| 362 | + | `) | |
| 363 | + | pane := cfg.Workspace.PaneMode.Actions | |
| 364 | + | assert.Equal(t, []string{"right"}, pane["close-pane"].Keys) | |
| 365 | + | assert.NotContains(t, pane, "split-right", "arrowright is the same key as right") | |
| 366 | + | assert.Contains(t, pane, "split-left") | |
| 367 | + | } | |
| 368 | + | ||
| 369 | + | func TestValidateModalModeActions_DetectsAliasDuplicates(t *testing.T) { | |
| 370 | + | tests := []struct { | |
| 371 | + | name string | |
| 372 | + | keysA string | |
| 373 | + | keysB string | |
| 374 | + | wantCanon string | |
| 375 | + | }{ | |
| 376 | + | {name: "right and arrowright", keysA: "right", keysB: "arrowright", wantCanon: "right"}, | |
| 377 | + | {name: "control and ctrl", keysA: "control+t", keysB: "ctrl+t", wantCanon: "ctrl+t"}, | |
| 378 | + | {name: "uppercase and explicit shift", keysA: "M", keysB: "shift+m", wantCanon: "shift+m"}, | |
| 379 | + | {name: "modifier order and case", keysA: "Shift+Ctrl+T", keysB: "ctrl+shift+t", wantCanon: "ctrl+shift+t"}, | |
| 380 | + | } | |
| 381 | + | for _, tt := range tests { | |
| 382 | + | t.Run(tt.name, func(t *testing.T) { | |
| 383 | + | errs := validateModalModeActions("pane_mode", 0, map[string]ActionBinding{ | |
| 384 | + | "action-a": {Keys: []string{tt.keysA}}, | |
| 385 | + | "action-b": {Keys: []string{tt.keysB}}, | |
| 386 | + | }) | |
| 387 | + | require.Len(t, errs, 1) | |
| 388 | + | assert.Contains(t, errs[0], "duplicate key binding '"+tt.wantCanon+"' found in pane_mode") | |
| 389 | + | assert.Contains(t, errs[0], "action-a") | |
| 390 | + | assert.Contains(t, errs[0], "action-b") | |
| 391 | + | }) | |
| 392 | + | } | |
| 393 | + | } | |
| 394 | + | ||
| 395 | + | func TestValidateModalModeActions_SpellingsOfOneKeyInOneActionAreNotDuplicates(t *testing.T) { | |
| 396 | + | // The defaults list "M" and "shift+m" together. | |
| 397 | + | errs := validateModalModeActions("pane_mode", 0, map[string]ActionBinding{ | |
| 398 | + | "move-pane-to-next-tab": {Keys: []string{"M", "shift+m"}}, | |
| 399 | + | }) | |
| 400 | + | assert.Empty(t, errs) | |
| 401 | + | } | |
| @@ -122,15 +122,6 @@ func TestCompleteDefaultActions_SkipsDefaultsConflictingWithUserKeys(t *testing. | |||||
| 122 | require.NoError(t, validateConfig(cfg)) | 122 | require.NoError(t, validateConfig(cfg)) | ||
| 123 | } | 123 | } | ||
| 124 | 124 | ||||
| 125 | - | func TestCompleteDefaultActions_LegacyUnderscoreNameCountsAsConfigured(t *testing.T) { | |||
| 126 | - | cfg := DefaultConfig() | |||
| 127 | - | cfg.Workspace.PaneMode.Actions = map[string]ActionBinding{ | |||
| 128 | - | "stack_pane": {Keys: []string{"ctrl+alt+f12"}}, | |||
| 129 | - | } | |||
| 130 | - | completeDefaultActions(cfg) | |||
| 131 | - | assert.NotContains(t, cfg.Workspace.PaneMode.Actions, "stack-pane") | |||
| 132 | - | } | |||
| 133 | - | ||||
| 134 | func TestCompleteDefaultActions_DoesNotMutateSharedMaps(t *testing.T) { | 125 | func TestCompleteDefaultActions_DoesNotMutateSharedMaps(t *testing.T) { | ||
| 135 | cfg := DefaultConfig() | 126 | cfg := DefaultConfig() | ||
| 136 | original := map[string]ActionBinding{"custom": {Keys: []string{"ctrl+alt+f12"}}} | 127 | original := map[string]ActionBinding{"custom": {Keys: []string{"ctrl+alt+f12"}}} | ||
| @@ -261,3 +252,150 @@ keys = [] | |||||
| 261 | assert.Empty(t, stack.Keys) | 252 | assert.Empty(t, stack.Keys) | ||
| 262 | assert.Len(t, got.Workspace.VimMode.Actions, len(DefaultConfig().Workspace.VimMode.Actions)) | 253 | assert.Len(t, got.Workspace.VimMode.Actions, len(DefaultConfig().Workspace.VimMode.Actions)) | ||
| 263 | } | 254 | } | ||
| 255 | + | ||||
| 256 | + | func TestManagerLoad_UnderscoreActionNamesAreCanonicalizedBeforeCompletion(t *testing.T) { | |||
| 257 | + | cfg := loadConfigFromTOML(t, ` | |||
| 258 | + | [workspace.pane_mode.actions.stack_pane] | |||
| 259 | + | keys = ["ctrl+alt+f12"] | |||
| 260 | + | desc = "Mine" | |||
| 261 | + | ||||
| 262 | + | [workspace.resize_mode.actions.resize_increase_left] | |||
| 263 | + | keys = ["z"] | |||
| 264 | + | ||||
| 265 | + | [workspace.prefix_mode.actions.enter_resize_mode] | |||
| 266 | + | keys = ["ctrl+alt+f11"] | |||
| 267 | + | ||||
| 268 | + | [workspace.pane_mode.actions.focus_left] | |||
| 269 | + | keys = ["ctrl+alt+f10"] | |||
| 270 | + | ||||
| 271 | + | [workspace.shortcuts.actions.toggle_floating_pane] | |||
| 272 | + | keys = ["alt+g"] | |||
| 273 | + | `) | |||
| 274 | + | ||||
| 275 | + | pane := cfg.Workspace.PaneMode.Actions | |||
| 276 | + | assert.NotContains(t, pane, "stack_pane") | |||
| 277 | + | assert.Equal(t, []string{"ctrl+alt+f12"}, pane["stack-pane"].Keys) | |||
| 278 | + | assert.Equal(t, "Mine", pane["stack-pane"].Desc) | |||
| 279 | + | assert.NotContains(t, pane, "focus_left") | |||
| 280 | + | assert.Equal(t, []string{"ctrl+alt+f10"}, pane["focus-left"].Keys) | |||
| 281 | + | ||||
| 282 | + | resize := cfg.Workspace.ResizeMode.Actions | |||
| 283 | + | assert.NotContains(t, resize, "resize_increase_left") | |||
| 284 | + | assert.Equal(t, []string{"z"}, resize["resize-increase-left"].Keys) | |||
| 285 | + | assert.Contains(t, resize, "resize-increase-right", "other defaults are still completed") | |||
| 286 | + | ||||
| 287 | + | prefix := cfg.Workspace.PrefixMode.Actions | |||
| 288 | + | assert.NotContains(t, prefix, "enter_resize_mode") | |||
| 289 | + | assert.Equal(t, []string{"ctrl+alt+f11"}, prefix["enter-resize-mode"].Keys) | |||
| 290 | + | ||||
| 291 | + | shortcuts := cfg.Workspace.Shortcuts.Actions | |||
| 292 | + | assert.NotContains(t, shortcuts, "toggle_floating_pane") | |||
| 293 | + | assert.Equal(t, []string{"alt+g"}, shortcuts["toggle-floating-pane"].Keys) | |||
| 294 | + | ||||
| 295 | + | for _, spec := range actionMapSpecs() { | |||
| 296 | + | for name := range *spec.actions(cfg) { | |||
| 297 | + | assert.NotContains(t, name, "_", "%s.%s", spec.key, name) | |||
| 298 | + | } | |||
| 299 | + | } | |||
| 300 | + | } | |||
| 301 | + | ||||
| 302 | + | func TestManagerLoad_HyphenActionNameWinsOverUnderscoreDuplicate(t *testing.T) { | |||
| 303 | + | cfg := loadConfigFromTOML(t, ` | |||
| 304 | + | [workspace.pane_mode.actions.stack_pane] | |||
| 305 | + | keys = ["ctrl+alt+f12"] | |||
| 306 | + | ||||
| 307 | + | [workspace.pane_mode.actions.stack-pane] | |||
| 308 | + | keys = ["ctrl+alt+f11"] | |||
| 309 | + | `) | |||
| 310 | + | assert.Equal(t, []string{"ctrl+alt+f11"}, cfg.Workspace.PaneMode.Actions["stack-pane"].Keys) | |||
| 311 | + | assert.NotContains(t, cfg.Workspace.PaneMode.Actions, "stack_pane") | |||
| 312 | + | } | |||
| 313 | + | ||||
| 314 | + | func TestCompleteDefaultActions_AliasSpellingsOfAUserKeyBlockTheDefault(t *testing.T) { | |||
| 315 | + | tests := []struct { | |||
| 316 | + | name string | |||
| 317 | + | userKey string | |||
| 318 | + | wantSkipped string | |||
| 319 | + | }{ | |||
| 320 | + | {name: "right vs arrowright", userKey: "right", wantSkipped: "split-right"}, | |||
| 321 | + | {name: "Right vs arrowright", userKey: "Right", wantSkipped: "split-right"}, | |||
| 322 | + | {name: "uppercase letter vs explicit shift", userKey: "shift+M", wantSkipped: "move-pane-to-next-tab"}, | |||
| 323 | + | {name: "explicit shift vs uppercase letter", userKey: "M", wantSkipped: "move-pane-to-next-tab"}, | |||
| 324 | + | } | |||
| 325 | + | for _, tt := range tests { | |||
| 326 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 327 | + | cfg := DefaultConfig() | |||
| 328 | + | cfg.Workspace.PaneMode.Actions = map[string]ActionBinding{ | |||
| 329 | + | "close-pane": {Keys: []string{tt.userKey}}, | |||
| 330 | + | } | |||
| 331 | + | completeDefaultActions(cfg) | |||
| 332 | + | ||||
| 333 | + | got := cfg.Workspace.PaneMode.Actions | |||
| 334 | + | assert.Equal(t, []string{tt.userKey}, got["close-pane"].Keys) | |||
| 335 | + | if tt.wantSkipped != "" { | |||
| 336 | + | assert.NotContains(t, got, tt.wantSkipped) | |||
| 337 | + | } | |||
| 338 | + | require.NoError(t, validateConfig(cfg), "completed config must stay valid") | |||
| 339 | + | }) | |||
| 340 | + | } | |||
| 341 | + | } | |||
| 342 | + | ||||
| 343 | + | func TestCompleteDefaultActions_ControlSpellingBlocksGlobalDefault(t *testing.T) { | |||
| 344 | + | cfg := DefaultConfig() | |||
| 345 | + | cfg.Workspace.Shortcuts.Actions = map[string]ActionBinding{ | |||
| 346 | + | "new-tab": {Keys: []string{"Control+r"}}, | |||
| 347 | + | } | |||
| 348 | + | completeDefaultActions(cfg) | |||
| 349 | + | ||||
| 350 | + | // Default reload is ["ctrl+r", "f5"]; "Control+r" is the same key as | |||
| 351 | + | // "ctrl+r", so the user owns it and reload is not added back. | |||
| 352 | + | assert.NotContains(t, cfg.Workspace.Shortcuts.Actions, "reload") | |||
| 353 | + | assert.Contains(t, cfg.Workspace.Shortcuts.Actions, "hard-reload") | |||
| 354 | + | assert.Contains(t, cfg.Workspace.Shortcuts.Actions, "open-omnibox") | |||
| 355 | + | require.NoError(t, validateConfig(cfg)) | |||
| 356 | + | } | |||
| 357 | + | ||||
| 358 | + | func TestManagerLoad_AliasConflictIsResolvedDeterministically(t *testing.T) { | |||
| 359 | + | cfg := loadConfigFromTOML(t, ` | |||
| 360 | + | [workspace.pane_mode.actions.close-pane] | |||
| 361 | + | keys = ["right"] | |||
| 362 | + | `) | |||
| 363 | + | pane := cfg.Workspace.PaneMode.Actions | |||
| 364 | + | assert.Equal(t, []string{"right"}, pane["close-pane"].Keys) | |||
| 365 | + | assert.NotContains(t, pane, "split-right", "arrowright is the same key as right") | |||
| 366 | + | assert.Contains(t, pane, "split-left") | |||
| 367 | + | } | |||
| 368 | + | ||||
| 369 | + | func TestValidateModalModeActions_DetectsAliasDuplicates(t *testing.T) { | |||
| 370 | + | tests := []struct { | |||
| 371 | + | name string | |||
| 372 | + | keysA string | |||
| 373 | + | keysB string | |||
| 374 | + | wantCanon string | |||
| 375 | + | }{ | |||
| 376 | + | {name: "right and arrowright", keysA: "right", keysB: "arrowright", wantCanon: "right"}, | |||
| 377 | + | {name: "control and ctrl", keysA: "control+t", keysB: "ctrl+t", wantCanon: "ctrl+t"}, | |||
| 378 | + | {name: "uppercase and explicit shift", keysA: "M", keysB: "shift+m", wantCanon: "shift+m"}, | |||
| 379 | + | {name: "modifier order and case", keysA: "Shift+Ctrl+T", keysB: "ctrl+shift+t", wantCanon: "ctrl+shift+t"}, | |||
| 380 | + | } | |||
| 381 | + | for _, tt := range tests { | |||
| 382 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 383 | + | errs := validateModalModeActions("pane_mode", 0, map[string]ActionBinding{ | |||
| 384 | + | "action-a": {Keys: []string{tt.keysA}}, | |||
| 385 | + | "action-b": {Keys: []string{tt.keysB}}, | |||
| 386 | + | }) | |||
| 387 | + | require.Len(t, errs, 1) | |||
| 388 | + | assert.Contains(t, errs[0], "duplicate key binding '"+tt.wantCanon+"' found in pane_mode") | |||
| 389 | + | assert.Contains(t, errs[0], "action-a") | |||
| 390 | + | assert.Contains(t, errs[0], "action-b") | |||
| 391 | + | }) | |||
| 392 | + | } | |||
| 393 | + | } | |||
| 394 | + | ||||
| 395 | + | func TestValidateModalModeActions_SpellingsOfOneKeyInOneActionAreNotDuplicates(t *testing.T) { | |||
| 396 | + | // The defaults list "M" and "shift+m" together. | |||
| 397 | + | errs := validateModalModeActions("pane_mode", 0, map[string]ActionBinding{ | |||
| 398 | + | "move-pane-to-next-tab": {Keys: []string{"M", "shift+m"}}, | |||
| 399 | + | }) | |||
| 400 | + | assert.Empty(t, errs) | |||
| 401 | + | } | |||
M internal/infrastructure/config/transform_legacy.go +94 -35
| @@ -1,6 +1,12 @@ | |||
| 1 | 1 | package config | |
| 2 | 2 | ||
| 3 | - | import "math" | |
| 3 | + | import ( | |
| 4 | + | "math" | |
| 5 | + | "slices" | |
| 6 | + | "strings" | |
| 7 | + | ||
| 8 | + | "github.com/bnema/dumber/internal/logging" | |
| 9 | + | ) | |
| 4 | 10 | ||
| 5 | 11 | const ( | |
| 6 | 12 | sectionWorkspace = "workspace" | |
| @@ -18,8 +24,29 @@ func NewLegacyConfigTransformer() *LegacyConfigTransformer { | |||
| 18 | 24 | return &LegacyConfigTransformer{defaults: DefaultConfig()} | |
| 19 | 25 | } | |
| 20 | 26 | ||
| 21 | - | // TransformLegacyActions converts old-format action bindings to new format. | |
| 22 | - | // This handles migration from: | |
| 27 | + | // legacyActionSlicePaths are the action maps that older configs wrote as plain | |
| 28 | + | // key lists. Vim Mode never had that format. | |
| 29 | + | var legacyActionSlicePaths = [][]string{ | |
| 30 | + | {"workspace", "prefix_mode", "actions"}, | |
| 31 | + | {"workspace", "pane_mode", "actions"}, | |
| 32 | + | {"workspace", "tab_mode", "actions"}, | |
| 33 | + | {"workspace", "resize_mode", "actions"}, | |
| 34 | + | {"workspace", "shortcuts", "actions"}, | |
| 35 | + | {"session", "session_mode", "actions"}, | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | // actionNamePaths are all configurable action maps. | |
| 39 | + | var actionNamePaths = append(slices.Clone(legacyActionSlicePaths), []string{"workspace", "vim_mode", "actions"}) | |
| 40 | + | ||
| 41 | + | // TransformLegacyActions canonicalizes action names and converts old-format | |
| 42 | + | // action bindings to the new format. | |
| 43 | + | // | |
| 44 | + | // Names: older configs spell actions with underscores (stack_pane); the | |
| 45 | + | // canonical spelling is hyphenated (stack-pane). Every action map is renamed to | |
| 46 | + | // the hyphen form so the rest of the system only ever sees one spelling. When a | |
| 47 | + | // map holds both spellings of an action, the hyphenated one wins. | |
| 48 | + | // | |
| 49 | + | // Bindings: this handles migration from: | |
| 23 | 50 | // | |
| 24 | 51 | // [workspace.pane_mode.actions] | |
| 25 | 52 | // "focus-left" = ["h", "arrowleft"] | |
| @@ -30,20 +57,72 @@ func NewLegacyConfigTransformer() *LegacyConfigTransformer { | |||
| 30 | 57 | // keys = ["h", "arrowleft"] | |
| 31 | 58 | // desc = "Focus pane to the left" | |
| 32 | 59 | func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]any) { | |
| 33 | - | actionPaths := [][]string{ | |
| 34 | - | {"workspace", "prefix_mode", "actions"}, | |
| 35 | - | {"workspace", "pane_mode", "actions"}, | |
| 36 | - | {"workspace", "tab_mode", "actions"}, | |
| 37 | - | {"workspace", "resize_mode", "actions"}, | |
| 38 | - | {"workspace", "shortcuts", "actions"}, | |
| 39 | - | {"session", "session_mode", "actions"}, | |
| 60 | + | for _, path := range actionNamePaths { | |
| 61 | + | if actions, ok := rawActionSection(rawConfig, path); ok { | |
| 62 | + | canonicalizeActionNames(actions, strings.Join(path, ".")) | |
| 63 | + | } | |
| 40 | 64 | } | |
| 65 | + | for _, path := range legacyActionSlicePaths { | |
| 66 | + | if actions, ok := rawActionSection(rawConfig, path); ok { | |
| 67 | + | t.transformActionSection(actions, path) | |
| 68 | + | } | |
| 69 | + | } | |
| 70 | + | } | |
| 41 | 71 | ||
| 42 | - | for _, path := range actionPaths { | |
| 43 | - | t.transformActionSection(rawConfig, path) | |
| 72 | + | // canonicalActionName returns the canonical (hyphenated) spelling of an action name. | |
| 73 | + | func canonicalActionName(name string) string { | |
| 74 | + | return strings.ReplaceAll(name, "_", "-") | |
| 75 | + | } | |
| 76 | + | ||
| 77 | + | // canonicalizeActionNames renames underscore action names to their hyphenated | |
| 78 | + | // form in place. If both spellings exist, the hyphenated entry is kept. | |
| 79 | + | func canonicalizeActionNames(actions map[string]any, section string) { | |
| 80 | + | var legacyNames []string | |
| 81 | + | for name := range actions { | |
| 82 | + | if canonicalActionName(name) != name { | |
| 83 | + | legacyNames = append(legacyNames, name) | |
| 84 | + | } | |
| 85 | + | } | |
| 86 | + | slices.Sort(legacyNames) | |
| 87 | + | ||
| 88 | + | for _, name := range legacyNames { | |
| 89 | + | canonical := canonicalActionName(name) | |
| 90 | + | value := actions[name] | |
| 91 | + | delete(actions, name) | |
| 92 | + | ||
| 93 | + | log := logging.NewFromEnv() | |
| 94 | + | if _, exists := actions[canonical]; exists { | |
| 95 | + | log.Debug(). | |
| 96 | + | Str("section", section). | |
| 97 | + | Str("action", name). | |
| 98 | + | Str("canonical", canonical). | |
| 99 | + | Msg("config: legacy action name ignored because the canonical spelling is also configured") | |
| 100 | + | continue | |
| 101 | + | } | |
| 102 | + | actions[canonical] = value | |
| 103 | + | log.Debug(). | |
| 104 | + | Str("section", section). | |
| 105 | + | Str("action", name). | |
| 106 | + | Str("canonical", canonical). | |
| 107 | + | Msg("config: legacy action name renamed") | |
| 44 | 108 | } | |
| 45 | 109 | } | |
| 46 | 110 | ||
| 111 | + | // rawActionSection returns the raw action map at path, if the path exists and | |
| 112 | + | // holds a table. | |
| 113 | + | func rawActionSection(rawConfig map[string]any, path []string) (map[string]any, bool) { | |
| 114 | + | current := rawConfig | |
| 115 | + | for _, key := range path[:len(path)-1] { | |
| 116 | + | next, ok := current[key].(map[string]any) | |
| 117 | + | if !ok { | |
| 118 | + | return nil, false | |
| 119 | + | } | |
| 120 | + | current = next | |
| 121 | + | } | |
| 122 | + | actions, ok := current[path[len(path)-1]].(map[string]any) | |
| 123 | + | return actions, ok | |
| 124 | + | } | |
| 125 | + | ||
| 47 | 126 | // TransformLegacyEngineConfig removes deprecated engine config keys. | |
| 48 | 127 | func (t *LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) { | |
| 49 | 128 | adaptiveEnabled, hasAdaptive := nestedConfigBoolValue(rawConfig, "engine", "cef", "adaptive_windowless_frame_rate") | |
| @@ -217,29 +296,9 @@ func int64ConfigValue(value any) (int64, bool) { | |||
| 217 | 296 | } | |
| 218 | 297 | } | |
| 219 | 298 | ||
| 220 | - | func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) { | |
| 221 | - | // Navigate to the parent of the actions section | |
| 222 | - | current := rawConfig | |
| 223 | - | for _, key := range path[:len(path)-1] { | |
| 224 | - | next, ok := current[key].(map[string]any) | |
| 225 | - | if !ok { | |
| 226 | - | return // Path doesn't exist | |
| 227 | - | } | |
| 228 | - | current = next | |
| 229 | - | } | |
| 230 | - | ||
| 231 | - | actionsKey := path[len(path)-1] | |
| 232 | - | actionsRaw, ok := current[actionsKey] | |
| 233 | - | if !ok { | |
| 234 | - | return | |
| 235 | - | } | |
| 236 | - | ||
| 237 | - | actions, ok := actionsRaw.(map[string]any) | |
| 238 | - | if !ok { | |
| 239 | - | return | |
| 240 | - | } | |
| 241 | - | ||
| 242 | - | // Check each action and transform if needed | |
| 299 | + | // transformActionSection converts old-format (key list) bindings of one action | |
| 300 | + | // map to the ActionBinding format. | |
| 301 | + | func (t *LegacyConfigTransformer) transformActionSection(actions map[string]any, path []string) { | |
| 243 | 302 | for actionName, actionValue := range actions { | |
| 244 | 303 | // If value is a slice (old format), convert to ActionBinding format | |
| 245 | 304 | if slice, ok := actionValue.([]any); ok { | |
| @@ -1,6 +1,12 @@ | |||||
| 1 | package config | 1 | package config | ||
| 2 | 2 | ||||
| 3 | - | import "math" | 3 | + | import ( |
| 4 | + | "math" | |||
| 5 | + | "slices" | |||
| 6 | + | "strings" | |||
| 7 | + | ||||
| 8 | + | "github.com/bnema/dumber/internal/logging" | |||
| 9 | + | ) | |||
| 4 | 10 | ||||
| 5 | const ( | 11 | const ( | ||
| 6 | sectionWorkspace = "workspace" | 12 | sectionWorkspace = "workspace" | ||
| @@ -18,8 +24,29 @@ func NewLegacyConfigTransformer() *LegacyConfigTransformer { | |||||
| 18 | return &LegacyConfigTransformer{defaults: DefaultConfig()} | 24 | return &LegacyConfigTransformer{defaults: DefaultConfig()} | ||
| 19 | } | 25 | } | ||
| 20 | 26 | ||||
| 21 | - | // TransformLegacyActions converts old-format action bindings to new format. | 27 | + | // legacyActionSlicePaths are the action maps that older configs wrote as plain |
| 22 | - | // This handles migration from: | 28 | + | // key lists. Vim Mode never had that format. |
| 29 | + | var legacyActionSlicePaths = [][]string{ | |||
| 30 | + | {"workspace", "prefix_mode", "actions"}, | |||
| 31 | + | {"workspace", "pane_mode", "actions"}, | |||
| 32 | + | {"workspace", "tab_mode", "actions"}, | |||
| 33 | + | {"workspace", "resize_mode", "actions"}, | |||
| 34 | + | {"workspace", "shortcuts", "actions"}, | |||
| 35 | + | {"session", "session_mode", "actions"}, | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | // actionNamePaths are all configurable action maps. | |||
| 39 | + | var actionNamePaths = append(slices.Clone(legacyActionSlicePaths), []string{"workspace", "vim_mode", "actions"}) | |||
| 40 | + | ||||
| 41 | + | // TransformLegacyActions canonicalizes action names and converts old-format | |||
| 42 | + | // action bindings to the new format. | |||
| 43 | + | // | |||
| 44 | + | // Names: older configs spell actions with underscores (stack_pane); the | |||
| 45 | + | // canonical spelling is hyphenated (stack-pane). Every action map is renamed to | |||
| 46 | + | // the hyphen form so the rest of the system only ever sees one spelling. When a | |||
| 47 | + | // map holds both spellings of an action, the hyphenated one wins. | |||
| 48 | + | // | |||
| 49 | + | // Bindings: this handles migration from: | |||
| 23 | // | 50 | // | ||
| 24 | // [workspace.pane_mode.actions] | 51 | // [workspace.pane_mode.actions] | ||
| 25 | // "focus-left" = ["h", "arrowleft"] | 52 | // "focus-left" = ["h", "arrowleft"] | ||
| @@ -30,20 +57,72 @@ func NewLegacyConfigTransformer() *LegacyConfigTransformer { | |||||
| 30 | // keys = ["h", "arrowleft"] | 57 | // keys = ["h", "arrowleft"] | ||
| 31 | // desc = "Focus pane to the left" | 58 | // desc = "Focus pane to the left" | ||
| 32 | func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]any) { | 59 | func (t *LegacyConfigTransformer) TransformLegacyActions(rawConfig map[string]any) { | ||
| 33 | - | actionPaths := [][]string{ | 60 | + | for _, path := range actionNamePaths { |
| 34 | - | {"workspace", "prefix_mode", "actions"}, | 61 | + | if actions, ok := rawActionSection(rawConfig, path); ok { |
| 35 | - | {"workspace", "pane_mode", "actions"}, | 62 | + | canonicalizeActionNames(actions, strings.Join(path, ".")) |
| 36 | - | {"workspace", "tab_mode", "actions"}, | 63 | + | } |
| 37 | - | {"workspace", "resize_mode", "actions"}, | |||
| 38 | - | {"workspace", "shortcuts", "actions"}, | |||
| 39 | - | {"session", "session_mode", "actions"}, | |||
| 40 | } | 64 | } | ||
| 65 | + | for _, path := range legacyActionSlicePaths { | |||
| 66 | + | if actions, ok := rawActionSection(rawConfig, path); ok { | |||
| 67 | + | t.transformActionSection(actions, path) | |||
| 68 | + | } | |||
| 69 | + | } | |||
| 70 | + | } | |||
| 41 | 71 | ||||
| 42 | - | for _, path := range actionPaths { | 72 | + | // canonicalActionName returns the canonical (hyphenated) spelling of an action name. |
| 43 | - | t.transformActionSection(rawConfig, path) | 73 | + | func canonicalActionName(name string) string { |
| 74 | + | return strings.ReplaceAll(name, "_", "-") | |||
| 75 | + | } | |||
| 76 | + | ||||
| 77 | + | // canonicalizeActionNames renames underscore action names to their hyphenated | |||
| 78 | + | // form in place. If both spellings exist, the hyphenated entry is kept. | |||
| 79 | + | func canonicalizeActionNames(actions map[string]any, section string) { | |||
| 80 | + | var legacyNames []string | |||
| 81 | + | for name := range actions { | |||
| 82 | + | if canonicalActionName(name) != name { | |||
| 83 | + | legacyNames = append(legacyNames, name) | |||
| 84 | + | } | |||
| 85 | + | } | |||
| 86 | + | slices.Sort(legacyNames) | |||
| 87 | + | ||||
| 88 | + | for _, name := range legacyNames { | |||
| 89 | + | canonical := canonicalActionName(name) | |||
| 90 | + | value := actions[name] | |||
| 91 | + | delete(actions, name) | |||
| 92 | + | ||||
| 93 | + | log := logging.NewFromEnv() | |||
| 94 | + | if _, exists := actions[canonical]; exists { | |||
| 95 | + | log.Debug(). | |||
| 96 | + | Str("section", section). | |||
| 97 | + | Str("action", name). | |||
| 98 | + | Str("canonical", canonical). | |||
| 99 | + | Msg("config: legacy action name ignored because the canonical spelling is also configured") | |||
| 100 | + | continue | |||
| 101 | + | } | |||
| 102 | + | actions[canonical] = value | |||
| 103 | + | log.Debug(). | |||
| 104 | + | Str("section", section). | |||
| 105 | + | Str("action", name). | |||
| 106 | + | Str("canonical", canonical). | |||
| 107 | + | Msg("config: legacy action name renamed") | |||
| 44 | } | 108 | } | ||
| 45 | } | 109 | } | ||
| 46 | 110 | ||||
| 111 | + | // rawActionSection returns the raw action map at path, if the path exists and | |||
| 112 | + | // holds a table. | |||
| 113 | + | func rawActionSection(rawConfig map[string]any, path []string) (map[string]any, bool) { | |||
| 114 | + | current := rawConfig | |||
| 115 | + | for _, key := range path[:len(path)-1] { | |||
| 116 | + | next, ok := current[key].(map[string]any) | |||
| 117 | + | if !ok { | |||
| 118 | + | return nil, false | |||
| 119 | + | } | |||
| 120 | + | current = next | |||
| 121 | + | } | |||
| 122 | + | actions, ok := current[path[len(path)-1]].(map[string]any) | |||
| 123 | + | return actions, ok | |||
| 124 | + | } | |||
| 125 | + | ||||
| 47 | // TransformLegacyEngineConfig removes deprecated engine config keys. | 126 | // TransformLegacyEngineConfig removes deprecated engine config keys. | ||
| 48 | func (t *LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) { | 127 | func (t *LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) { | ||
| 49 | adaptiveEnabled, hasAdaptive := nestedConfigBoolValue(rawConfig, "engine", "cef", "adaptive_windowless_frame_rate") | 128 | adaptiveEnabled, hasAdaptive := nestedConfigBoolValue(rawConfig, "engine", "cef", "adaptive_windowless_frame_rate") | ||
| @@ -217,29 +296,9 @@ func int64ConfigValue(value any) (int64, bool) { | |||||
| 217 | } | 296 | } | ||
| 218 | } | 297 | } | ||
| 219 | 298 | ||||
| 220 | - | func (t *LegacyConfigTransformer) transformActionSection(rawConfig map[string]any, path []string) { | 299 | + | // transformActionSection converts old-format (key list) bindings of one action |
| 221 | - | // Navigate to the parent of the actions section | 300 | + | // map to the ActionBinding format. |
| 222 | - | current := rawConfig | 301 | + | func (t *LegacyConfigTransformer) transformActionSection(actions map[string]any, path []string) { |
| 223 | - | for _, key := range path[:len(path)-1] { | |||
| 224 | - | next, ok := current[key].(map[string]any) | |||
| 225 | - | if !ok { | |||
| 226 | - | return // Path doesn't exist | |||
| 227 | - | } | |||
| 228 | - | current = next | |||
| 229 | - | } | |||
| 230 | - | ||||
| 231 | - | actionsKey := path[len(path)-1] | |||
| 232 | - | actionsRaw, ok := current[actionsKey] | |||
| 233 | - | if !ok { | |||
| 234 | - | return | |||
| 235 | - | } | |||
| 236 | - | ||||
| 237 | - | actions, ok := actionsRaw.(map[string]any) | |||
| 238 | - | if !ok { | |||
| 239 | - | return | |||
| 240 | - | } | |||
| 241 | - | ||||
| 242 | - | // Check each action and transform if needed | |||
| 243 | for actionName, actionValue := range actions { | 302 | for actionName, actionValue := range actions { | ||
| 244 | // If value is a slice (old format), convert to ActionBinding format | 303 | // If value is a slice (old format), convert to ActionBinding format | ||
| 245 | if slice, ok := actionValue.([]any); ok { | 304 | if slice, ok := actionValue.([]any); ok { | ||
M internal/infrastructure/config/transform_legacy_test.go +72 -2
| @@ -89,8 +89,9 @@ func TestTransformLegacyActions_AllModes(t *testing.T) { | |||
| 89 | 89 | ||
| 90 | 90 | // Check shortcuts | |
| 91 | 91 | shortcutActions := rawConfig["workspace"].(map[string]any)["shortcuts"].(map[string]any)["actions"].(map[string]any) | |
| 92 | - | closePane, ok := shortcutActions["close_pane"].(map[string]any) | |
| 93 | - | require.True(t, ok) | |
| 92 | + | closePane, ok := shortcutActions["close-pane"].(map[string]any) | |
| 93 | + | require.True(t, ok, "underscore action names are renamed to the hyphen form") | |
| 94 | + | assert.NotContains(t, shortcutActions, "close_pane") | |
| 94 | 95 | assert.Equal(t, []string{"ctrl+w"}, closePane["keys"]) | |
| 95 | 96 | ||
| 96 | 97 | // Check session mode | |
| @@ -404,3 +405,72 @@ func TestTransformLegacyActions_UnknownAction(t *testing.T) { | |||
| 404 | 405 | assert.Equal(t, []string{"x"}, binding["keys"]) | |
| 405 | 406 | assert.Empty(t, binding["desc"], "unknown action should have empty description") | |
| 406 | 407 | } | |
| 408 | + | ||
| 409 | + | func TestTransformLegacyActions_RenamesUnderscoreNamesInEveryActionMap(t *testing.T) { | |
| 410 | + | raw := map[string]any{ | |
| 411 | + | "workspace": map[string]any{ | |
| 412 | + | "shortcuts": map[string]any{"actions": map[string]any{"toggle_floating_pane": map[string]any{"keys": []any{"alt+g"}}}}, | |
| 413 | + | "prefix_mode": map[string]any{"actions": map[string]any{"enter_resize_mode": map[string]any{"keys": []any{"z"}}}}, | |
| 414 | + | "pane_mode": map[string]any{"actions": map[string]any{"focus_left": map[string]any{"keys": []any{"z"}}}}, | |
| 415 | + | "tab_mode": map[string]any{"actions": map[string]any{"new_tab": map[string]any{"keys": []any{"z"}}}}, | |
| 416 | + | "resize_mode": map[string]any{"actions": map[string]any{"resize_increase_left": map[string]any{"keys": []any{"z"}}}}, | |
| 417 | + | "vim_mode": map[string]any{"actions": map[string]any{"vim_scroll_left": map[string]any{"keys": []any{"z"}}}}, | |
| 418 | + | }, | |
| 419 | + | "session": map[string]any{ | |
| 420 | + | "session_mode": map[string]any{"actions": map[string]any{"session_manager": map[string]any{"keys": []any{"z"}}}}, | |
| 421 | + | }, | |
| 422 | + | } | |
| 423 | + | ||
| 424 | + | NewLegacyConfigTransformer().TransformLegacyActions(raw) | |
| 425 | + | ||
| 426 | + | want := map[string]string{ | |
| 427 | + | "shortcuts": "toggle-floating-pane", | |
| 428 | + | "prefix_mode": "enter-resize-mode", | |
| 429 | + | "pane_mode": "focus-left", | |
| 430 | + | "tab_mode": "new-tab", | |
| 431 | + | "resize_mode": "resize-increase-left", | |
| 432 | + | "vim_mode": "vim-scroll-left", | |
| 433 | + | } | |
| 434 | + | workspace := raw["workspace"].(map[string]any) | |
| 435 | + | for section, name := range want { | |
| 436 | + | actions := workspace[section].(map[string]any)["actions"].(map[string]any) | |
| 437 | + | assert.Len(t, actions, 1, section) | |
| 438 | + | assert.Contains(t, actions, name, section) | |
| 439 | + | } | |
| 440 | + | sessionActions := raw["session"].(map[string]any)["session_mode"].(map[string]any)["actions"].(map[string]any) | |
| 441 | + | assert.Len(t, sessionActions, 1) | |
| 442 | + | assert.Contains(t, sessionActions, "session-manager") | |
| 443 | + | } | |
| 444 | + | ||
| 445 | + | func TestTransformLegacyActions_HyphenSpellingWinsOverUnderscore(t *testing.T) { | |
| 446 | + | raw := map[string]any{ | |
| 447 | + | "workspace": map[string]any{ | |
| 448 | + | "pane_mode": map[string]any{"actions": map[string]any{ | |
| 449 | + | "stack_pane": map[string]any{"keys": []any{"q"}}, | |
| 450 | + | "stack-pane": map[string]any{"keys": []any{"w"}}, | |
| 451 | + | }}, | |
| 452 | + | }, | |
| 453 | + | } | |
| 454 | + | ||
| 455 | + | NewLegacyConfigTransformer().TransformLegacyActions(raw) | |
| 456 | + | ||
| 457 | + | actions := raw["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any) | |
| 458 | + | require.Len(t, actions, 1) | |
| 459 | + | assert.Equal(t, []any{"w"}, actions["stack-pane"].(map[string]any)["keys"]) | |
| 460 | + | } | |
| 461 | + | ||
| 462 | + | func TestTransformLegacyActions_RenamesUnderscoreNamesInSliceFormat(t *testing.T) { | |
| 463 | + | raw := map[string]any{ | |
| 464 | + | "workspace": map[string]any{ | |
| 465 | + | "pane_mode": map[string]any{"actions": map[string]any{"stack_pane": []any{"q"}}}, | |
| 466 | + | }, | |
| 467 | + | } | |
| 468 | + | ||
| 469 | + | NewLegacyConfigTransformer().TransformLegacyActions(raw) | |
| 470 | + | ||
| 471 | + | actions := raw["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any) | |
| 472 | + | binding, ok := actions["stack-pane"].(map[string]any) | |
| 473 | + | require.True(t, ok) | |
| 474 | + | assert.Equal(t, []string{"q"}, binding["keys"]) | |
| 475 | + | assert.Equal(t, DefaultConfig().Workspace.PaneMode.Actions["stack-pane"].Desc, binding["desc"]) | |
| 476 | + | } | |
| @@ -89,8 +89,9 @@ func TestTransformLegacyActions_AllModes(t *testing.T) { | |||||
| 89 | 89 | ||||
| 90 | // Check shortcuts | 90 | // Check shortcuts | ||
| 91 | shortcutActions := rawConfig["workspace"].(map[string]any)["shortcuts"].(map[string]any)["actions"].(map[string]any) | 91 | shortcutActions := rawConfig["workspace"].(map[string]any)["shortcuts"].(map[string]any)["actions"].(map[string]any) | ||
| 92 | - | closePane, ok := shortcutActions["close_pane"].(map[string]any) | 92 | + | closePane, ok := shortcutActions["close-pane"].(map[string]any) |
| 93 | - | require.True(t, ok) | 93 | + | require.True(t, ok, "underscore action names are renamed to the hyphen form") |
| 94 | + | assert.NotContains(t, shortcutActions, "close_pane") | |||
| 94 | assert.Equal(t, []string{"ctrl+w"}, closePane["keys"]) | 95 | assert.Equal(t, []string{"ctrl+w"}, closePane["keys"]) | ||
| 95 | 96 | ||||
| 96 | // Check session mode | 97 | // Check session mode | ||
| @@ -404,3 +405,72 @@ func TestTransformLegacyActions_UnknownAction(t *testing.T) { | |||||
| 404 | assert.Equal(t, []string{"x"}, binding["keys"]) | 405 | assert.Equal(t, []string{"x"}, binding["keys"]) | ||
| 405 | assert.Empty(t, binding["desc"], "unknown action should have empty description") | 406 | assert.Empty(t, binding["desc"], "unknown action should have empty description") | ||
| 406 | } | 407 | } | ||
| 408 | + | ||||
| 409 | + | func TestTransformLegacyActions_RenamesUnderscoreNamesInEveryActionMap(t *testing.T) { | |||
| 410 | + | raw := map[string]any{ | |||
| 411 | + | "workspace": map[string]any{ | |||
| 412 | + | "shortcuts": map[string]any{"actions": map[string]any{"toggle_floating_pane": map[string]any{"keys": []any{"alt+g"}}}}, | |||
| 413 | + | "prefix_mode": map[string]any{"actions": map[string]any{"enter_resize_mode": map[string]any{"keys": []any{"z"}}}}, | |||
| 414 | + | "pane_mode": map[string]any{"actions": map[string]any{"focus_left": map[string]any{"keys": []any{"z"}}}}, | |||
| 415 | + | "tab_mode": map[string]any{"actions": map[string]any{"new_tab": map[string]any{"keys": []any{"z"}}}}, | |||
| 416 | + | "resize_mode": map[string]any{"actions": map[string]any{"resize_increase_left": map[string]any{"keys": []any{"z"}}}}, | |||
| 417 | + | "vim_mode": map[string]any{"actions": map[string]any{"vim_scroll_left": map[string]any{"keys": []any{"z"}}}}, | |||
| 418 | + | }, | |||
| 419 | + | "session": map[string]any{ | |||
| 420 | + | "session_mode": map[string]any{"actions": map[string]any{"session_manager": map[string]any{"keys": []any{"z"}}}}, | |||
| 421 | + | }, | |||
| 422 | + | } | |||
| 423 | + | ||||
| 424 | + | NewLegacyConfigTransformer().TransformLegacyActions(raw) | |||
| 425 | + | ||||
| 426 | + | want := map[string]string{ | |||
| 427 | + | "shortcuts": "toggle-floating-pane", | |||
| 428 | + | "prefix_mode": "enter-resize-mode", | |||
| 429 | + | "pane_mode": "focus-left", | |||
| 430 | + | "tab_mode": "new-tab", | |||
| 431 | + | "resize_mode": "resize-increase-left", | |||
| 432 | + | "vim_mode": "vim-scroll-left", | |||
| 433 | + | } | |||
| 434 | + | workspace := raw["workspace"].(map[string]any) | |||
| 435 | + | for section, name := range want { | |||
| 436 | + | actions := workspace[section].(map[string]any)["actions"].(map[string]any) | |||
| 437 | + | assert.Len(t, actions, 1, section) | |||
| 438 | + | assert.Contains(t, actions, name, section) | |||
| 439 | + | } | |||
| 440 | + | sessionActions := raw["session"].(map[string]any)["session_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 441 | + | assert.Len(t, sessionActions, 1) | |||
| 442 | + | assert.Contains(t, sessionActions, "session-manager") | |||
| 443 | + | } | |||
| 444 | + | ||||
| 445 | + | func TestTransformLegacyActions_HyphenSpellingWinsOverUnderscore(t *testing.T) { | |||
| 446 | + | raw := map[string]any{ | |||
| 447 | + | "workspace": map[string]any{ | |||
| 448 | + | "pane_mode": map[string]any{"actions": map[string]any{ | |||
| 449 | + | "stack_pane": map[string]any{"keys": []any{"q"}}, | |||
| 450 | + | "stack-pane": map[string]any{"keys": []any{"w"}}, | |||
| 451 | + | }}, | |||
| 452 | + | }, | |||
| 453 | + | } | |||
| 454 | + | ||||
| 455 | + | NewLegacyConfigTransformer().TransformLegacyActions(raw) | |||
| 456 | + | ||||
| 457 | + | actions := raw["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 458 | + | require.Len(t, actions, 1) | |||
| 459 | + | assert.Equal(t, []any{"w"}, actions["stack-pane"].(map[string]any)["keys"]) | |||
| 460 | + | } | |||
| 461 | + | ||||
| 462 | + | func TestTransformLegacyActions_RenamesUnderscoreNamesInSliceFormat(t *testing.T) { | |||
| 463 | + | raw := map[string]any{ | |||
| 464 | + | "workspace": map[string]any{ | |||
| 465 | + | "pane_mode": map[string]any{"actions": map[string]any{"stack_pane": []any{"q"}}}, | |||
| 466 | + | }, | |||
| 467 | + | } | |||
| 468 | + | ||||
| 469 | + | NewLegacyConfigTransformer().TransformLegacyActions(raw) | |||
| 470 | + | ||||
| 471 | + | actions := raw["workspace"].(map[string]any)["pane_mode"].(map[string]any)["actions"].(map[string]any) | |||
| 472 | + | binding, ok := actions["stack-pane"].(map[string]any) | |||
| 473 | + | require.True(t, ok) | |||
| 474 | + | assert.Equal(t, []string{"q"}, binding["keys"]) | |||
| 475 | + | assert.Equal(t, DefaultConfig().Workspace.PaneMode.Actions["stack-pane"].Desc, binding["desc"]) | |||
| 476 | + | } | |||
M internal/infrastructure/config/validation.go +53 -26
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "sort" | |
| 10 | 10 | "strings" | |
| 11 | 11 | ||
| 12 | + | "github.com/bnema/dumber/internal/domain/keychord" | |
| 12 | 13 | domainurl "github.com/bnema/dumber/internal/domain/url" | |
| 13 | 14 | domainvalidation "github.com/bnema/dumber/internal/domain/validation" | |
| 14 | 15 | "github.com/bnema/dumber/internal/domain/vimkeys" | |
| @@ -299,8 +300,12 @@ func validateModalModeActionsWithKeyCanon( | |||
| 299 | 300 | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions cannot be empty", modePath)) | |
| 300 | 301 | } | |
| 301 | 302 | ||
| 303 | + | // Single-chord maps accept several spellings of one key under the same | |
| 304 | + | // action (for example "M" and "shift+m"); that is redundant, not a conflict. | |
| 305 | + | // Vim sequences keep reporting any repeated sequence. | |
| 306 | + | allowSameActionAliases := keyCanon == nil | |
| 302 | 307 | if keyCanon == nil { | |
| 303 | - | keyCanon = func(key string) (string, error) { return key, nil } | |
| 308 | + | keyCanon = canonicalizeChordKey | |
| 304 | 309 | } | |
| 305 | 310 | ||
| 306 | 311 | actionNames := make([]string, 0, len(actions)) | |
| @@ -328,32 +333,10 @@ func validateModalModeActionsWithKeyCanon( | |||
| 328 | 333 | continue | |
| 329 | 334 | } | |
| 330 | 335 | if modePath == "vim_mode" { | |
| 331 | - | sequence, parseErr := vimkeys.ParseBinding(canonical) | |
| 332 | - | if parseErr == nil { | |
| 333 | - | if len(sequence) > 0 && isVimCountStartKey(sequence[0]) { | |
| 334 | - | validationErrors = append(validationErrors, fmt.Sprintf( | |
| 335 | - | "workspace.%s.actions.%s cannot start with a count digit (1-9): '%s'", | |
| 336 | - | modePath, | |
| 337 | - | action, | |
| 338 | - | canonical, | |
| 339 | - | )) | |
| 340 | - | } | |
| 341 | - | legacyExit := action == "confirm" || action == "cancel" | |
| 342 | - | if !legacyExit { | |
| 343 | - | for _, key := range sequence { | |
| 344 | - | if key.Mods == 0 && (key.Sym == "CR" || key.Sym == "Esc") { | |
| 345 | - | validationErrors = append(validationErrors, fmt.Sprintf( | |
| 346 | - | "workspace.%s.actions.%s cannot bind Enter or Escape outside the modal fallback actions", | |
| 347 | - | modePath, | |
| 348 | - | action, | |
| 349 | - | )) | |
| 350 | - | break | |
| 351 | - | } | |
| 352 | - | } | |
| 353 | - | } | |
| 354 | - | } | |
| 336 | + | validationErrors = append(validationErrors, validateVimBindingKey(modePath, action, canonical)...) | |
| 355 | 337 | } | |
| 356 | - | if existingAction, exists := seenSequence[canonical]; exists { | |
| 338 | + | existingAction, exists := seenSequence[canonical] | |
| 339 | + | if exists && (!allowSameActionAliases || existingAction != action) { | |
| 357 | 340 | validationErrors = append(validationErrors, fmt.Sprintf( | |
| 358 | 341 | "duplicate key binding '%s' found in %s actions '%s' and '%s'", | |
| 359 | 342 | canonical, | |
| @@ -368,6 +351,50 @@ func validateModalModeActionsWithKeyCanon( | |||
| 368 | 351 | return validationErrors | |
| 369 | 352 | } | |
| 370 | 353 | ||
| 354 | + | // validateVimBindingKey checks the Vim-specific rules of one canonical binding. | |
| 355 | + | func validateVimBindingKey(modePath, action, canonical string) []string { | |
| 356 | + | sequence, err := vimkeys.ParseBinding(canonical) | |
| 357 | + | if err != nil { | |
| 358 | + | return nil | |
| 359 | + | } | |
| 360 | + | var validationErrors []string | |
| 361 | + | if len(sequence) > 0 && isVimCountStartKey(sequence[0]) { | |
| 362 | + | validationErrors = append(validationErrors, fmt.Sprintf( | |
| 363 | + | "workspace.%s.actions.%s cannot start with a count digit (1-9): '%s'", | |
| 364 | + | modePath, | |
| 365 | + | action, | |
| 366 | + | canonical, | |
| 367 | + | )) | |
| 368 | + | } | |
| 369 | + | legacyExit := action == "confirm" || action == "cancel" | |
| 370 | + | if legacyExit { | |
| 371 | + | return validationErrors | |
| 372 | + | } | |
| 373 | + | for _, key := range sequence { | |
| 374 | + | if key.Mods == 0 && (key.Sym == "CR" || key.Sym == "Esc") { | |
| 375 | + | validationErrors = append(validationErrors, fmt.Sprintf( | |
| 376 | + | "workspace.%s.actions.%s cannot bind Enter or Escape outside the modal fallback actions", | |
| 377 | + | modePath, | |
| 378 | + | action, | |
| 379 | + | )) | |
| 380 | + | break | |
| 381 | + | } | |
| 382 | + | } | |
| 383 | + | return validationErrors | |
| 384 | + | } | |
| 385 | + | ||
| 386 | + | // canonicalizeChordKey canonicalizes a single-chord key string the way the | |
| 387 | + | // input layer resolves it (aliases, modifier spelling, letter case), so | |
| 388 | + | // differently spelled bindings of one key compare equal. A string that is not a | |
| 389 | + | // valid chord is returned unchanged: the input layer rejects it with a warning | |
| 390 | + | // when it is loaded, and it cannot collide with a valid binding. | |
| 391 | + | func canonicalizeChordKey(key string) (string, error) { | |
| 392 | + | if canonical, ok := keychord.Canonicalize(key); ok { | |
| 393 | + | return canonical, nil | |
| 394 | + | } | |
| 395 | + | return key, nil | |
| 396 | + | } | |
| 397 | + | ||
| 371 | 398 | func isVimCountStartKey(key vimkeys.Key) bool { | |
| 372 | 399 | return key.Mods == 0 && len(key.Sym) == 1 && key.Sym[0] >= '1' && key.Sym[0] <= '9' | |
| 373 | 400 | } | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "sort" | 9 | "sort" | ||
| 10 | "strings" | 10 | "strings" | ||
| 11 | 11 | ||||
| 12 | + | "github.com/bnema/dumber/internal/domain/keychord" | |||
| 12 | domainurl "github.com/bnema/dumber/internal/domain/url" | 13 | domainurl "github.com/bnema/dumber/internal/domain/url" | ||
| 13 | domainvalidation "github.com/bnema/dumber/internal/domain/validation" | 14 | domainvalidation "github.com/bnema/dumber/internal/domain/validation" | ||
| 14 | "github.com/bnema/dumber/internal/domain/vimkeys" | 15 | "github.com/bnema/dumber/internal/domain/vimkeys" | ||
| @@ -299,8 +300,12 @@ func validateModalModeActionsWithKeyCanon( | |||||
| 299 | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions cannot be empty", modePath)) | 300 | validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions cannot be empty", modePath)) | ||
| 300 | } | 301 | } | ||
| 301 | 302 | ||||
| 303 | + | // Single-chord maps accept several spellings of one key under the same | |||
| 304 | + | // action (for example "M" and "shift+m"); that is redundant, not a conflict. | |||
| 305 | + | // Vim sequences keep reporting any repeated sequence. | |||
| 306 | + | allowSameActionAliases := keyCanon == nil | |||
| 302 | if keyCanon == nil { | 307 | if keyCanon == nil { | ||
| 303 | - | keyCanon = func(key string) (string, error) { return key, nil } | 308 | + | keyCanon = canonicalizeChordKey |
| 304 | } | 309 | } | ||
| 305 | 310 | ||||
| 306 | actionNames := make([]string, 0, len(actions)) | 311 | actionNames := make([]string, 0, len(actions)) | ||
| @@ -328,32 +333,10 @@ func validateModalModeActionsWithKeyCanon( | |||||
| 328 | continue | 333 | continue | ||
| 329 | } | 334 | } | ||
| 330 | if modePath == "vim_mode" { | 335 | if modePath == "vim_mode" { | ||
| 331 | - | sequence, parseErr := vimkeys.ParseBinding(canonical) | 336 | + | validationErrors = append(validationErrors, validateVimBindingKey(modePath, action, canonical)...) |
| 332 | - | if parseErr == nil { | |||
| 333 | - | if len(sequence) > 0 && isVimCountStartKey(sequence[0]) { | |||
| 334 | - | validationErrors = append(validationErrors, fmt.Sprintf( | |||
| 335 | - | "workspace.%s.actions.%s cannot start with a count digit (1-9): '%s'", | |||
| 336 | - | modePath, | |||
| 337 | - | action, | |||
| 338 | - | canonical, | |||
| 339 | - | )) | |||
| 340 | - | } | |||
| 341 | - | legacyExit := action == "confirm" || action == "cancel" | |||
| 342 | - | if !legacyExit { | |||
| 343 | - | for _, key := range sequence { | |||
| 344 | - | if key.Mods == 0 && (key.Sym == "CR" || key.Sym == "Esc") { | |||
| 345 | - | validationErrors = append(validationErrors, fmt.Sprintf( | |||
| 346 | - | "workspace.%s.actions.%s cannot bind Enter or Escape outside the modal fallback actions", | |||
| 347 | - | modePath, | |||
| 348 | - | action, | |||
| 349 | - | )) | |||
| 350 | - | break | |||
| 351 | - | } | |||
| 352 | - | } | |||
| 353 | - | } | |||
| 354 | - | } | |||
| 355 | } | 337 | } | ||
| 356 | - | if existingAction, exists := seenSequence[canonical]; exists { | 338 | + | existingAction, exists := seenSequence[canonical] |
| 339 | + | if exists && (!allowSameActionAliases || existingAction != action) { | |||
| 357 | validationErrors = append(validationErrors, fmt.Sprintf( | 340 | validationErrors = append(validationErrors, fmt.Sprintf( | ||
| 358 | "duplicate key binding '%s' found in %s actions '%s' and '%s'", | 341 | "duplicate key binding '%s' found in %s actions '%s' and '%s'", | ||
| 359 | canonical, | 342 | canonical, | ||
| @@ -368,6 +351,50 @@ func validateModalModeActionsWithKeyCanon( | |||||
| 368 | return validationErrors | 351 | return validationErrors | ||
| 369 | } | 352 | } | ||
| 370 | 353 | ||||
| 354 | + | // validateVimBindingKey checks the Vim-specific rules of one canonical binding. | |||
| 355 | + | func validateVimBindingKey(modePath, action, canonical string) []string { | |||
| 356 | + | sequence, err := vimkeys.ParseBinding(canonical) | |||
| 357 | + | if err != nil { | |||
| 358 | + | return nil | |||
| 359 | + | } | |||
| 360 | + | var validationErrors []string | |||
| 361 | + | if len(sequence) > 0 && isVimCountStartKey(sequence[0]) { | |||
| 362 | + | validationErrors = append(validationErrors, fmt.Sprintf( | |||
| 363 | + | "workspace.%s.actions.%s cannot start with a count digit (1-9): '%s'", | |||
| 364 | + | modePath, | |||
| 365 | + | action, | |||
| 366 | + | canonical, | |||
| 367 | + | )) | |||
| 368 | + | } | |||
| 369 | + | legacyExit := action == "confirm" || action == "cancel" | |||
| 370 | + | if legacyExit { | |||
| 371 | + | return validationErrors | |||
| 372 | + | } | |||
| 373 | + | for _, key := range sequence { | |||
| 374 | + | if key.Mods == 0 && (key.Sym == "CR" || key.Sym == "Esc") { | |||
| 375 | + | validationErrors = append(validationErrors, fmt.Sprintf( | |||
| 376 | + | "workspace.%s.actions.%s cannot bind Enter or Escape outside the modal fallback actions", | |||
| 377 | + | modePath, | |||
| 378 | + | action, | |||
| 379 | + | )) | |||
| 380 | + | break | |||
| 381 | + | } | |||
| 382 | + | } | |||
| 383 | + | return validationErrors | |||
| 384 | + | } | |||
| 385 | + | ||||
| 386 | + | // canonicalizeChordKey canonicalizes a single-chord key string the way the | |||
| 387 | + | // input layer resolves it (aliases, modifier spelling, letter case), so | |||
| 388 | + | // differently spelled bindings of one key compare equal. A string that is not a | |||
| 389 | + | // valid chord is returned unchanged: the input layer rejects it with a warning | |||
| 390 | + | // when it is loaded, and it cannot collide with a valid binding. | |||
| 391 | + | func canonicalizeChordKey(key string) (string, error) { | |||
| 392 | + | if canonical, ok := keychord.Canonicalize(key); ok { | |||
| 393 | + | return canonical, nil | |||
| 394 | + | } | |||
| 395 | + | return key, nil | |||
| 396 | + | } | |||
| 397 | + | ||||
| 371 | func isVimCountStartKey(key vimkeys.Key) bool { | 398 | func isVimCountStartKey(key vimkeys.Key) bool { | ||
| 372 | return key.Mods == 0 && len(key.Sym) == 1 && key.Sym[0] >= '1' && key.Sym[0] <= '9' | 399 | return key.Mods == 0 && len(key.Sym) == 1 && key.Sym[0] >= '1' && key.Sym[0] <= '9' | ||
| 373 | } | 400 | } | ||
D internal/ui/input/builtin_shortcuts_test.go +0 -85
| @@ -1,85 +0,0 @@ | |||
| 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 globalShortcutsConfig(actions map[string]entity.ActionBinding) *entity.WorkspaceConfig { | |
| 14 | - | return &entity.WorkspaceConfig{Shortcuts: entity.GlobalShortcutsConfig{Actions: actions}} | |
| 15 | - | } | |
| 16 | - | ||
| 17 | - | func lookupGlobal(set *ShortcutSet, keyval uint, mods Modifier) (Action, bool) { | |
| 18 | - | return set.Lookup(KeyBinding{Keyval: keyval, Modifiers: mods}, ModeNormal) | |
| 19 | - | } | |
| 20 | - | ||
| 21 | - | func TestBuiltinShortcutFallback_AppliesWhenActionIsUnconfigured(t *testing.T) { | |
| 22 | - | set := NewShortcutSet(context.Background(), globalShortcutsConfig(nil), nil) | |
| 23 | - | ||
| 24 | - | action, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl) | |
| 25 | - | require.True(t, ok) | |
| 26 | - | assert.Equal(t, ActionOpenFind, action) | |
| 27 | - | } | |
| 28 | - | ||
| 29 | - | func TestBuiltinShortcutFallback_HyphenNameWithNoKeysDisablesDefault(t *testing.T) { | |
| 30 | - | set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{ | |
| 31 | - | "open-find": {Keys: []string{}}, | |
| 32 | - | }), nil) | |
| 33 | - | ||
| 34 | - | _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl) | |
| 35 | - | assert.False(t, ok) | |
| 36 | - | } | |
| 37 | - | ||
| 38 | - | func TestBuiltinShortcutFallback_LegacyNameWithNoKeysDisablesDefault(t *testing.T) { | |
| 39 | - | set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{ | |
| 40 | - | "open_find": {Keys: []string{}}, | |
| 41 | - | }), nil) | |
| 42 | - | ||
| 43 | - | _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl) | |
| 44 | - | assert.False(t, ok, "a legacy-named action with no keys disables the built-in default") | |
| 45 | - | } | |
| 46 | - | ||
| 47 | - | func TestBuiltinShortcutFallback_LegacyNameRebindReplacesDefault(t *testing.T) { | |
| 48 | - | set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{ | |
| 49 | - | "open_find": {Keys: []string{"ctrl+shift+f"}}, | |
| 50 | - | }), nil) | |
| 51 | - | ||
| 52 | - | _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl) | |
| 53 | - | assert.False(t, ok, "the default ctrl+f is not active next to the rebinding") | |
| 54 | - | ||
| 55 | - | action, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl|ModShift) | |
| 56 | - | require.True(t, ok, "the legacy-named binding is registered") | |
| 57 | - | assert.Equal(t, ActionOpenFind, action) | |
| 58 | - | } | |
| 59 | - | ||
| 60 | - | func TestBuiltinShortcutFallback_LegacyNameOnlyAffectsItsOwnAction(t *testing.T) { | |
| 61 | - | set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{ | |
| 62 | - | "open_find": {Keys: []string{}}, | |
| 63 | - | }), nil) | |
| 64 | - | ||
| 65 | - | action, ok := lookupGlobal(set, uint(gdk.KEY_l), ModCtrl) | |
| 66 | - | require.True(t, ok) | |
| 67 | - | assert.Equal(t, ActionOpenOmnibox, action) | |
| 68 | - | } | |
| 69 | - | ||
| 70 | - | func TestBuiltinShortcutFallback_LegacyMultiWordNames(t *testing.T) { | |
| 71 | - | set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{ | |
| 72 | - | "toggle_fullscreen": {Keys: []string{}}, | |
| 73 | - | "hard_reload": {Keys: []string{"ctrl+alt+r"}}, | |
| 74 | - | }), nil) | |
| 75 | - | ||
| 76 | - | _, ok := lookupGlobal(set, uint(gdk.KEY_F11), ModNone) | |
| 77 | - | assert.False(t, ok, "toggle_fullscreen with no keys disables f11") | |
| 78 | - | ||
| 79 | - | _, ok = lookupGlobal(set, uint(gdk.KEY_R), ModCtrl|ModShift) | |
| 80 | - | assert.False(t, ok, "the default hard-reload key is replaced") | |
| 81 | - | ||
| 82 | - | action, ok := lookupGlobal(set, uint(gdk.KEY_r), ModCtrl|ModAlt) | |
| 83 | - | require.True(t, ok) | |
| 84 | - | assert.Equal(t, ActionHardReload, action) | |
| 85 | - | } | |
| @@ -1,85 +0,0 @@ | |||||
| 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 globalShortcutsConfig(actions map[string]entity.ActionBinding) *entity.WorkspaceConfig { | |||
| 14 | - | return &entity.WorkspaceConfig{Shortcuts: entity.GlobalShortcutsConfig{Actions: actions}} | |||
| 15 | - | } | |||
| 16 | - | ||||
| 17 | - | func lookupGlobal(set *ShortcutSet, keyval uint, mods Modifier) (Action, bool) { | |||
| 18 | - | return set.Lookup(KeyBinding{Keyval: keyval, Modifiers: mods}, ModeNormal) | |||
| 19 | - | } | |||
| 20 | - | ||||
| 21 | - | func TestBuiltinShortcutFallback_AppliesWhenActionIsUnconfigured(t *testing.T) { | |||
| 22 | - | set := NewShortcutSet(context.Background(), globalShortcutsConfig(nil), nil) | |||
| 23 | - | ||||
| 24 | - | action, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl) | |||
| 25 | - | require.True(t, ok) | |||
| 26 | - | assert.Equal(t, ActionOpenFind, action) | |||
| 27 | - | } | |||
| 28 | - | ||||
| 29 | - | func TestBuiltinShortcutFallback_HyphenNameWithNoKeysDisablesDefault(t *testing.T) { | |||
| 30 | - | set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{ | |||
| 31 | - | "open-find": {Keys: []string{}}, | |||
| 32 | - | }), nil) | |||
| 33 | - | ||||
| 34 | - | _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl) | |||
| 35 | - | assert.False(t, ok) | |||
| 36 | - | } | |||
| 37 | - | ||||
| 38 | - | func TestBuiltinShortcutFallback_LegacyNameWithNoKeysDisablesDefault(t *testing.T) { | |||
| 39 | - | set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{ | |||
| 40 | - | "open_find": {Keys: []string{}}, | |||
| 41 | - | }), nil) | |||
| 42 | - | ||||
| 43 | - | _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl) | |||
| 44 | - | assert.False(t, ok, "a legacy-named action with no keys disables the built-in default") | |||
| 45 | - | } | |||
| 46 | - | ||||
| 47 | - | func TestBuiltinShortcutFallback_LegacyNameRebindReplacesDefault(t *testing.T) { | |||
| 48 | - | set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{ | |||
| 49 | - | "open_find": {Keys: []string{"ctrl+shift+f"}}, | |||
| 50 | - | }), nil) | |||
| 51 | - | ||||
| 52 | - | _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl) | |||
| 53 | - | assert.False(t, ok, "the default ctrl+f is not active next to the rebinding") | |||
| 54 | - | ||||
| 55 | - | action, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl|ModShift) | |||
| 56 | - | require.True(t, ok, "the legacy-named binding is registered") | |||
| 57 | - | assert.Equal(t, ActionOpenFind, action) | |||
| 58 | - | } | |||
| 59 | - | ||||
| 60 | - | func TestBuiltinShortcutFallback_LegacyNameOnlyAffectsItsOwnAction(t *testing.T) { | |||
| 61 | - | set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{ | |||
| 62 | - | "open_find": {Keys: []string{}}, | |||
| 63 | - | }), nil) | |||
| 64 | - | ||||
| 65 | - | action, ok := lookupGlobal(set, uint(gdk.KEY_l), ModCtrl) | |||
| 66 | - | require.True(t, ok) | |||
| 67 | - | assert.Equal(t, ActionOpenOmnibox, action) | |||
| 68 | - | } | |||
| 69 | - | ||||
| 70 | - | func TestBuiltinShortcutFallback_LegacyMultiWordNames(t *testing.T) { | |||
| 71 | - | set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{ | |||
| 72 | - | "toggle_fullscreen": {Keys: []string{}}, | |||
| 73 | - | "hard_reload": {Keys: []string{"ctrl+alt+r"}}, | |||
| 74 | - | }), nil) | |||
| 75 | - | ||||
| 76 | - | _, ok := lookupGlobal(set, uint(gdk.KEY_F11), ModNone) | |||
| 77 | - | assert.False(t, ok, "toggle_fullscreen with no keys disables f11") | |||
| 78 | - | ||||
| 79 | - | _, ok = lookupGlobal(set, uint(gdk.KEY_R), ModCtrl|ModShift) | |||
| 80 | - | assert.False(t, ok, "the default hard-reload key is replaced") | |||
| 81 | - | ||||
| 82 | - | action, ok := lookupGlobal(set, uint(gdk.KEY_r), ModCtrl|ModAlt) | |||
| 83 | - | require.True(t, ok) | |||
| 84 | - | assert.Equal(t, ActionHardReload, action) | |||
| 85 | - | } | |||
A internal/ui/input/config_completion_test.go +150 -0
| @@ -0,0 +1,150 @@ | |||
| 1 | + | package input | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "os" | |
| 6 | + | "path/filepath" | |
| 7 | + | "testing" | |
| 8 | + | ||
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |
| 10 | + | "github.com/bnema/puregotk/v4/gdk" | |
| 11 | + | "github.com/stretchr/testify/assert" | |
| 12 | + | "github.com/stretchr/testify/require" | |
| 13 | + | ) | |
| 14 | + | ||
| 15 | + | // loadShortcutsFromTOML loads a config file through the real config loader | |
| 16 | + | // (legacy transforms, default completion, validation) and builds the shortcut | |
| 17 | + | // set the application would build from it. | |
| 18 | + | func loadShortcutsFromTOML(t *testing.T, content string) *ShortcutSet { | |
| 19 | + | t.Helper() | |
| 20 | + | ||
| 21 | + | home := t.TempDir() | |
| 22 | + | t.Setenv("HOME", home) | |
| 23 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) | |
| 24 | + | t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) | |
| 25 | + | t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) | |
| 26 | + | t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) | |
| 27 | + | ||
| 28 | + | configFile, err := config.GetConfigFile() | |
| 29 | + | require.NoError(t, err) | |
| 30 | + | require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755)) | |
| 31 | + | require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644)) | |
| 32 | + | ||
| 33 | + | mgr, err := config.NewManager() | |
| 34 | + | require.NoError(t, err) | |
| 35 | + | require.NoError(t, mgr.Load()) | |
| 36 | + | ||
| 37 | + | cfg := mgr.Get() | |
| 38 | + | return NewShortcutSet(context.Background(), &cfg.Workspace, &cfg.Session) | |
| 39 | + | } | |
| 40 | + | ||
| 41 | + | func lookupGlobal(set *ShortcutSet, keyval uint, mods Modifier) (Action, bool) { | |
| 42 | + | return set.Lookup(KeyBinding{Keyval: keyval, Modifiers: mods}, ModeNormal) | |
| 43 | + | } | |
| 44 | + | ||
| 45 | + | func TestConfigCompletion_UserKeyConflictIsNotResurrectedByInput(t *testing.T) { | |
| 46 | + | // ctrl+r is a default key of reload, so completion leaves reload out. The | |
| 47 | + | // input layer must not bind reload's other default key (f5) on its own. | |
| 48 | + | set := loadShortcutsFromTOML(t, ` | |
| 49 | + | [workspace.shortcuts.actions.new-tab] | |
| 50 | + | keys = ["ctrl+r"] | |
| 51 | + | `) | |
| 52 | + | ||
| 53 | + | action, ok := lookupGlobal(set, uint(gdk.KEY_r), ModCtrl) | |
| 54 | + | require.True(t, ok) | |
| 55 | + | assert.Equal(t, ActionNewTab, action) | |
| 56 | + | ||
| 57 | + | _, ok = lookupGlobal(set, uint(gdk.KEY_F5), ModNone) | |
| 58 | + | assert.False(t, ok, "f5 belongs to the skipped reload default and must stay unbound") | |
| 59 | + | ||
| 60 | + | action, ok = lookupGlobal(set, uint(gdk.KEY_l), ModCtrl) | |
| 61 | + | require.True(t, ok, "unrelated defaults are still completed") | |
| 62 | + | assert.Equal(t, ActionOpenOmnibox, action) | |
| 63 | + | } | |
| 64 | + | ||
| 65 | + | func TestConfigCompletion_DisabledBuiltinStaysDisabled(t *testing.T) { | |
| 66 | + | set := loadShortcutsFromTOML(t, ` | |
| 67 | + | [workspace.shortcuts.actions.open-find] | |
| 68 | + | keys = [] | |
| 69 | + | `) | |
| 70 | + | ||
| 71 | + | _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl) | |
| 72 | + | assert.False(t, ok) | |
| 73 | + | ||
| 74 | + | action, ok := lookupGlobal(set, uint(gdk.KEY_l), ModCtrl) | |
| 75 | + | require.True(t, ok) | |
| 76 | + | assert.Equal(t, ActionOpenOmnibox, action) | |
| 77 | + | } | |
| 78 | + | ||
| 79 | + | func TestConfigCompletion_AliasSpellingBlocksDefaultAndInputAgrees(t *testing.T) { | |
| 80 | + | set := loadShortcutsFromTOML(t, ` | |
| 81 | + | [workspace.pane_mode.actions.close-pane] | |
| 82 | + | keys = ["right"] | |
| 83 | + | `) | |
| 84 | + | ||
| 85 | + | action, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_Right)}, ModePane) | |
| 86 | + | require.True(t, ok) | |
| 87 | + | assert.Equal(t, ActionClosePane, action, "the user binding wins deterministically over split-right's arrowright") | |
| 88 | + | ||
| 89 | + | action, ok = set.Lookup(KeyBinding{Keyval: uint('r')}, ModePane) | |
| 90 | + | assert.False(t, ok, "split-right is skipped entirely, including its other key %v", action) | |
| 91 | + | } | |
| 92 | + | ||
| 93 | + | func TestConfigCompletion_UnderscoreNamesWorkEndToEnd(t *testing.T) { | |
| 94 | + | set := loadShortcutsFromTOML(t, ` | |
| 95 | + | [workspace.pane_mode.actions.stack_pane] | |
| 96 | + | keys = ["ctrl+alt+f12"] | |
| 97 | + | ||
| 98 | + | [workspace.pane_mode.actions.focus_left] | |
| 99 | + | keys = ["ctrl+alt+f11"] | |
| 100 | + | ||
| 101 | + | [workspace.resize_mode.actions.resize_increase_left] | |
| 102 | + | keys = ["z"] | |
| 103 | + | ||
| 104 | + | [workspace.prefix_mode.actions.enter_resize_mode] | |
| 105 | + | keys = ["ctrl+alt+f10"] | |
| 106 | + | ||
| 107 | + | [workspace.tab_mode.actions.new_tab] | |
| 108 | + | keys = ["ctrl+alt+f9"] | |
| 109 | + | ||
| 110 | + | [workspace.shortcuts.actions.toggle_floating_pane] | |
| 111 | + | keys = ["alt+g"] | |
| 112 | + | ||
| 113 | + | [workspace.shortcuts.actions.open_find] | |
| 114 | + | keys = ["ctrl+shift+f"] | |
| 115 | + | `) | |
| 116 | + | ||
| 117 | + | paneBinding := func(keyval uint) KeyBinding { | |
| 118 | + | return KeyBinding{Keyval: keyval, Modifiers: ModCtrl | ModAlt} | |
| 119 | + | } | |
| 120 | + | ||
| 121 | + | tests := []struct { | |
| 122 | + | name string | |
| 123 | + | binding KeyBinding | |
| 124 | + | mode Mode | |
| 125 | + | want Action | |
| 126 | + | }{ | |
| 127 | + | {"pane stack_pane", paneBinding(uint(gdk.KEY_F12)), ModePane, ActionStackPane}, | |
| 128 | + | {"pane focus_left", paneBinding(uint(gdk.KEY_F11)), ModePane, ActionFocusLeft}, | |
| 129 | + | {"resize resize_increase_left", KeyBinding{Keyval: uint('z')}, ModeResize, ActionResizeIncreaseLeft}, | |
| 130 | + | {"prefix enter_resize_mode", paneBinding(uint(gdk.KEY_F10)), ModePrefix, ActionEnterResizeMode}, | |
| 131 | + | {"tab new_tab", paneBinding(uint(gdk.KEY_F9)), ModeTab, ActionNewTab}, | |
| 132 | + | {"global toggle_floating_pane", KeyBinding{Keyval: uint('g'), Modifiers: ModAlt}, ModeNormal, ActionToggleFloatingPane}, | |
| 133 | + | {"global open_find", KeyBinding{Keyval: uint('f'), Modifiers: ModCtrl | ModShift}, ModeNormal, ActionOpenFind}, | |
| 134 | + | } | |
| 135 | + | for _, tt := range tests { | |
| 136 | + | t.Run(tt.name, func(t *testing.T) { | |
| 137 | + | got, ok := set.Lookup(tt.binding, tt.mode) | |
| 138 | + | require.True(t, ok) | |
| 139 | + | assert.Equal(t, tt.want, got) | |
| 140 | + | }) | |
| 141 | + | } | |
| 142 | + | ||
| 143 | + | // The underscore-named user bindings replaced the defaults they renamed. | |
| 144 | + | _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl) | |
| 145 | + | assert.False(t, ok, "default ctrl+f is replaced by the user's open_find binding") | |
| 146 | + | _, ok = set.Lookup(KeyBinding{Keyval: uint('s')}, ModePane) | |
| 147 | + | assert.False(t, ok, "default stack-pane key is replaced by the user's stack_pane binding") | |
| 148 | + | _, ok = set.Lookup(KeyBinding{Keyval: uint('h')}, ModeResize) | |
| 149 | + | assert.False(t, ok, "default resize-increase-left key is replaced") | |
| 150 | + | } | |
| @@ -0,0 +1,150 @@ | |||||
| 1 | + | package input | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "os" | |||
| 6 | + | "path/filepath" | |||
| 7 | + | "testing" | |||
| 8 | + | ||||
| 9 | + | "github.com/bnema/dumber/internal/infrastructure/config" | |||
| 10 | + | "github.com/bnema/puregotk/v4/gdk" | |||
| 11 | + | "github.com/stretchr/testify/assert" | |||
| 12 | + | "github.com/stretchr/testify/require" | |||
| 13 | + | ) | |||
| 14 | + | ||||
| 15 | + | // loadShortcutsFromTOML loads a config file through the real config loader | |||
| 16 | + | // (legacy transforms, default completion, validation) and builds the shortcut | |||
| 17 | + | // set the application would build from it. | |||
| 18 | + | func loadShortcutsFromTOML(t *testing.T, content string) *ShortcutSet { | |||
| 19 | + | t.Helper() | |||
| 20 | + | ||||
| 21 | + | home := t.TempDir() | |||
| 22 | + | t.Setenv("HOME", home) | |||
| 23 | + | t.Setenv("XDG_CONFIG_HOME", filepath.Join(home, ".config")) | |||
| 24 | + | t.Setenv("XDG_DATA_HOME", filepath.Join(home, ".local", "share")) | |||
| 25 | + | t.Setenv("XDG_STATE_HOME", filepath.Join(home, ".local", "state")) | |||
| 26 | + | t.Setenv("XDG_CACHE_HOME", filepath.Join(home, ".cache")) | |||
| 27 | + | ||||
| 28 | + | configFile, err := config.GetConfigFile() | |||
| 29 | + | require.NoError(t, err) | |||
| 30 | + | require.NoError(t, os.MkdirAll(filepath.Dir(configFile), 0o755)) | |||
| 31 | + | require.NoError(t, os.WriteFile(configFile, []byte(content), 0o644)) | |||
| 32 | + | ||||
| 33 | + | mgr, err := config.NewManager() | |||
| 34 | + | require.NoError(t, err) | |||
| 35 | + | require.NoError(t, mgr.Load()) | |||
| 36 | + | ||||
| 37 | + | cfg := mgr.Get() | |||
| 38 | + | return NewShortcutSet(context.Background(), &cfg.Workspace, &cfg.Session) | |||
| 39 | + | } | |||
| 40 | + | ||||
| 41 | + | func lookupGlobal(set *ShortcutSet, keyval uint, mods Modifier) (Action, bool) { | |||
| 42 | + | return set.Lookup(KeyBinding{Keyval: keyval, Modifiers: mods}, ModeNormal) | |||
| 43 | + | } | |||
| 44 | + | ||||
| 45 | + | func TestConfigCompletion_UserKeyConflictIsNotResurrectedByInput(t *testing.T) { | |||
| 46 | + | // ctrl+r is a default key of reload, so completion leaves reload out. The | |||
| 47 | + | // input layer must not bind reload's other default key (f5) on its own. | |||
| 48 | + | set := loadShortcutsFromTOML(t, ` | |||
| 49 | + | [workspace.shortcuts.actions.new-tab] | |||
| 50 | + | keys = ["ctrl+r"] | |||
| 51 | + | `) | |||
| 52 | + | ||||
| 53 | + | action, ok := lookupGlobal(set, uint(gdk.KEY_r), ModCtrl) | |||
| 54 | + | require.True(t, ok) | |||
| 55 | + | assert.Equal(t, ActionNewTab, action) | |||
| 56 | + | ||||
| 57 | + | _, ok = lookupGlobal(set, uint(gdk.KEY_F5), ModNone) | |||
| 58 | + | assert.False(t, ok, "f5 belongs to the skipped reload default and must stay unbound") | |||
| 59 | + | ||||
| 60 | + | action, ok = lookupGlobal(set, uint(gdk.KEY_l), ModCtrl) | |||
| 61 | + | require.True(t, ok, "unrelated defaults are still completed") | |||
| 62 | + | assert.Equal(t, ActionOpenOmnibox, action) | |||
| 63 | + | } | |||
| 64 | + | ||||
| 65 | + | func TestConfigCompletion_DisabledBuiltinStaysDisabled(t *testing.T) { | |||
| 66 | + | set := loadShortcutsFromTOML(t, ` | |||
| 67 | + | [workspace.shortcuts.actions.open-find] | |||
| 68 | + | keys = [] | |||
| 69 | + | `) | |||
| 70 | + | ||||
| 71 | + | _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl) | |||
| 72 | + | assert.False(t, ok) | |||
| 73 | + | ||||
| 74 | + | action, ok := lookupGlobal(set, uint(gdk.KEY_l), ModCtrl) | |||
| 75 | + | require.True(t, ok) | |||
| 76 | + | assert.Equal(t, ActionOpenOmnibox, action) | |||
| 77 | + | } | |||
| 78 | + | ||||
| 79 | + | func TestConfigCompletion_AliasSpellingBlocksDefaultAndInputAgrees(t *testing.T) { | |||
| 80 | + | set := loadShortcutsFromTOML(t, ` | |||
| 81 | + | [workspace.pane_mode.actions.close-pane] | |||
| 82 | + | keys = ["right"] | |||
| 83 | + | `) | |||
| 84 | + | ||||
| 85 | + | action, ok := set.Lookup(KeyBinding{Keyval: uint(gdk.KEY_Right)}, ModePane) | |||
| 86 | + | require.True(t, ok) | |||
| 87 | + | assert.Equal(t, ActionClosePane, action, "the user binding wins deterministically over split-right's arrowright") | |||
| 88 | + | ||||
| 89 | + | action, ok = set.Lookup(KeyBinding{Keyval: uint('r')}, ModePane) | |||
| 90 | + | assert.False(t, ok, "split-right is skipped entirely, including its other key %v", action) | |||
| 91 | + | } | |||
| 92 | + | ||||
| 93 | + | func TestConfigCompletion_UnderscoreNamesWorkEndToEnd(t *testing.T) { | |||
| 94 | + | set := loadShortcutsFromTOML(t, ` | |||
| 95 | + | [workspace.pane_mode.actions.stack_pane] | |||
| 96 | + | keys = ["ctrl+alt+f12"] | |||
| 97 | + | ||||
| 98 | + | [workspace.pane_mode.actions.focus_left] | |||
| 99 | + | keys = ["ctrl+alt+f11"] | |||
| 100 | + | ||||
| 101 | + | [workspace.resize_mode.actions.resize_increase_left] | |||
| 102 | + | keys = ["z"] | |||
| 103 | + | ||||
| 104 | + | [workspace.prefix_mode.actions.enter_resize_mode] | |||
| 105 | + | keys = ["ctrl+alt+f10"] | |||
| 106 | + | ||||
| 107 | + | [workspace.tab_mode.actions.new_tab] | |||
| 108 | + | keys = ["ctrl+alt+f9"] | |||
| 109 | + | ||||
| 110 | + | [workspace.shortcuts.actions.toggle_floating_pane] | |||
| 111 | + | keys = ["alt+g"] | |||
| 112 | + | ||||
| 113 | + | [workspace.shortcuts.actions.open_find] | |||
| 114 | + | keys = ["ctrl+shift+f"] | |||
| 115 | + | `) | |||
| 116 | + | ||||
| 117 | + | paneBinding := func(keyval uint) KeyBinding { | |||
| 118 | + | return KeyBinding{Keyval: keyval, Modifiers: ModCtrl | ModAlt} | |||
| 119 | + | } | |||
| 120 | + | ||||
| 121 | + | tests := []struct { | |||
| 122 | + | name string | |||
| 123 | + | binding KeyBinding | |||
| 124 | + | mode Mode | |||
| 125 | + | want Action | |||
| 126 | + | }{ | |||
| 127 | + | {"pane stack_pane", paneBinding(uint(gdk.KEY_F12)), ModePane, ActionStackPane}, | |||
| 128 | + | {"pane focus_left", paneBinding(uint(gdk.KEY_F11)), ModePane, ActionFocusLeft}, | |||
| 129 | + | {"resize resize_increase_left", KeyBinding{Keyval: uint('z')}, ModeResize, ActionResizeIncreaseLeft}, | |||
| 130 | + | {"prefix enter_resize_mode", paneBinding(uint(gdk.KEY_F10)), ModePrefix, ActionEnterResizeMode}, | |||
| 131 | + | {"tab new_tab", paneBinding(uint(gdk.KEY_F9)), ModeTab, ActionNewTab}, | |||
| 132 | + | {"global toggle_floating_pane", KeyBinding{Keyval: uint('g'), Modifiers: ModAlt}, ModeNormal, ActionToggleFloatingPane}, | |||
| 133 | + | {"global open_find", KeyBinding{Keyval: uint('f'), Modifiers: ModCtrl | ModShift}, ModeNormal, ActionOpenFind}, | |||
| 134 | + | } | |||
| 135 | + | for _, tt := range tests { | |||
| 136 | + | t.Run(tt.name, func(t *testing.T) { | |||
| 137 | + | got, ok := set.Lookup(tt.binding, tt.mode) | |||
| 138 | + | require.True(t, ok) | |||
| 139 | + | assert.Equal(t, tt.want, got) | |||
| 140 | + | }) | |||
| 141 | + | } | |||
| 142 | + | ||||
| 143 | + | // The underscore-named user bindings replaced the defaults they renamed. | |||
| 144 | + | _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl) | |||
| 145 | + | assert.False(t, ok, "default ctrl+f is replaced by the user's open_find binding") | |||
| 146 | + | _, ok = set.Lookup(KeyBinding{Keyval: uint('s')}, ModePane) | |||
| 147 | + | assert.False(t, ok, "default stack-pane key is replaced by the user's stack_pane binding") | |||
| 148 | + | _, ok = set.Lookup(KeyBinding{Keyval: uint('h')}, ModeResize) | |||
| 149 | + | assert.False(t, ok, "default resize-increase-left key is replaced") | |||
| 150 | + | } | |||
M internal/ui/input/floating_shortcuts_test.go +4 -3
| @@ -16,9 +16,10 @@ func defaultWorkspaceConfig() *entity.WorkspaceConfig { | |||
| 16 | 16 | return &entity.WorkspaceConfig{ | |
| 17 | 17 | Shortcuts: entity.GlobalShortcutsConfig{ | |
| 18 | 18 | Actions: map[string]entity.ActionBinding{ | |
| 19 | - | "toggle_floating_pane": {Keys: []string{"alt+f"}}, | |
| 20 | - | "consume_or_expel_left": {Keys: []string{"alt+["}}, | |
| 21 | - | "consume_or_expel_right": {Keys: []string{"alt+]"}}, | |
| 19 | + | "toggle-floating-pane": {Keys: []string{"alt+f"}}, | |
| 20 | + | "consume-or-expel-left": {Keys: []string{"alt+["}}, | |
| 21 | + | "consume-or-expel-right": {Keys: []string{"alt+]"}}, | |
| 22 | + | "open-omnibox": {Keys: []string{"ctrl+l"}}, | |
| 22 | 23 | }, | |
| 23 | 24 | }, | |
| 24 | 25 | } | |
| @@ -16,9 +16,10 @@ func defaultWorkspaceConfig() *entity.WorkspaceConfig { | |||||
| 16 | return &entity.WorkspaceConfig{ | 16 | return &entity.WorkspaceConfig{ | ||
| 17 | Shortcuts: entity.GlobalShortcutsConfig{ | 17 | Shortcuts: entity.GlobalShortcutsConfig{ | ||
| 18 | Actions: map[string]entity.ActionBinding{ | 18 | Actions: map[string]entity.ActionBinding{ | ||
| 19 | - | "toggle_floating_pane": {Keys: []string{"alt+f"}}, | 19 | + | "toggle-floating-pane": {Keys: []string{"alt+f"}}, |
| 20 | - | "consume_or_expel_left": {Keys: []string{"alt+["}}, | 20 | + | "consume-or-expel-left": {Keys: []string{"alt+["}}, |
| 21 | - | "consume_or_expel_right": {Keys: []string{"alt+]"}}, | 21 | + | "consume-or-expel-right": {Keys: []string{"alt+]"}}, |
| 22 | + | "open-omnibox": {Keys: []string{"ctrl+l"}}, | |||
| 22 | }, | 23 | }, | ||
| 23 | }, | 24 | }, | ||
| 24 | } | 25 | } | ||
M internal/ui/input/global_shortcuts.go +0 -9
| @@ -504,23 +504,14 @@ func shouldIgnoreGlobalShortcutInMode(mode Mode, action Action) bool { | |||
| 504 | 504 | ||
| 505 | 505 | func globalShortcutActionMap() map[string]Action { | |
| 506 | 506 | return map[string]Action{ | |
| 507 | - | "toggle_floating_pane": ActionToggleFloatingPane, | |
| 508 | 507 | "toggle-floating-pane": ActionToggleFloatingPane, | |
| 509 | - | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |
| 510 | 508 | "consume-or-expel-left": ActionConsumeOrExpelLeft, | |
| 511 | - | "consume_or_expel_right": ActionConsumeOrExpelRight, | |
| 512 | 509 | "consume-or-expel-right": ActionConsumeOrExpelRight, | |
| 513 | - | "consume_or_expel_up": ActionConsumeOrExpelUp, | |
| 514 | 510 | "consume-or-expel-up": ActionConsumeOrExpelUp, | |
| 515 | - | "consume_or_expel_down": ActionConsumeOrExpelDown, | |
| 516 | 511 | "consume-or-expel-down": ActionConsumeOrExpelDown, | |
| 517 | - | "toggle_history_systemview": ActionToggleHistorySystemView, | |
| 518 | 512 | "toggle-history-systemview": ActionToggleHistorySystemView, | |
| 519 | - | "toggle_favorites_sidebar": ActionToggleFavoritesSidebar, | |
| 520 | 513 | "toggle-favorites-sidebar": ActionToggleFavoritesSidebar, | |
| 521 | - | "toggle_current_page_favorite": ActionToggleCurrentPageFavorite, | |
| 522 | 514 | "toggle-current-page-favorite": ActionToggleCurrentPageFavorite, | |
| 523 | - | "toggle_config_systemview": ActionToggleConfigSystemView, | |
| 524 | 515 | "toggle-config-systemview": ActionToggleConfigSystemView, | |
| 525 | 516 | } | |
| 526 | 517 | } | |
| @@ -504,23 +504,14 @@ func shouldIgnoreGlobalShortcutInMode(mode Mode, action Action) bool { | |||||
| 504 | 504 | ||||
| 505 | func globalShortcutActionMap() map[string]Action { | 505 | func globalShortcutActionMap() map[string]Action { | ||
| 506 | return map[string]Action{ | 506 | return map[string]Action{ | ||
| 507 | - | "toggle_floating_pane": ActionToggleFloatingPane, | |||
| 508 | "toggle-floating-pane": ActionToggleFloatingPane, | 507 | "toggle-floating-pane": ActionToggleFloatingPane, | ||
| 509 | - | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |||
| 510 | "consume-or-expel-left": ActionConsumeOrExpelLeft, | 508 | "consume-or-expel-left": ActionConsumeOrExpelLeft, | ||
| 511 | - | "consume_or_expel_right": ActionConsumeOrExpelRight, | |||
| 512 | "consume-or-expel-right": ActionConsumeOrExpelRight, | 509 | "consume-or-expel-right": ActionConsumeOrExpelRight, | ||
| 513 | - | "consume_or_expel_up": ActionConsumeOrExpelUp, | |||
| 514 | "consume-or-expel-up": ActionConsumeOrExpelUp, | 510 | "consume-or-expel-up": ActionConsumeOrExpelUp, | ||
| 515 | - | "consume_or_expel_down": ActionConsumeOrExpelDown, | |||
| 516 | "consume-or-expel-down": ActionConsumeOrExpelDown, | 511 | "consume-or-expel-down": ActionConsumeOrExpelDown, | ||
| 517 | - | "toggle_history_systemview": ActionToggleHistorySystemView, | |||
| 518 | "toggle-history-systemview": ActionToggleHistorySystemView, | 512 | "toggle-history-systemview": ActionToggleHistorySystemView, | ||
| 519 | - | "toggle_favorites_sidebar": ActionToggleFavoritesSidebar, | |||
| 520 | "toggle-favorites-sidebar": ActionToggleFavoritesSidebar, | 513 | "toggle-favorites-sidebar": ActionToggleFavoritesSidebar, | ||
| 521 | - | "toggle_current_page_favorite": ActionToggleCurrentPageFavorite, | |||
| 522 | "toggle-current-page-favorite": ActionToggleCurrentPageFavorite, | 514 | "toggle-current-page-favorite": ActionToggleCurrentPageFavorite, | ||
| 523 | - | "toggle_config_systemview": ActionToggleConfigSystemView, | |||
| 524 | "toggle-config-systemview": ActionToggleConfigSystemView, | 515 | "toggle-config-systemview": ActionToggleConfigSystemView, | ||
| 525 | } | 516 | } | ||
| 526 | } | 517 | } | ||
M internal/ui/input/keyboard_test.go +2 -1
| @@ -16,6 +16,7 @@ import ( | |||
| 16 | 16 | // Used by tests that construct a KeyboardHandler. | |
| 17 | 17 | func newTestWorkspace() *entity.WorkspaceConfig { | |
| 18 | 18 | return &entity.WorkspaceConfig{ | |
| 19 | + | Shortcuts: entity.GlobalShortcutsConfig{Actions: entity.BuiltinGlobalShortcuts()}, | |
| 19 | 20 | TabMode: entity.TabModeConfig{ActivationShortcut: "ctrl+t"}, | |
| 20 | 21 | PaneMode: entity.PaneModeConfig{ActivationShortcut: "ctrl+p"}, | |
| 21 | 22 | ResizeMode: entity.ResizeModeConfig{ActivationShortcut: "ctrl+alt+r"}, | |
| @@ -331,7 +332,7 @@ func TestHandleKeyPress_DoesNotExitResizeModeWhileActivationKeyIsHeld(t *testing | |||
| 331 | 332 | func TestHandleKeyPress_DoesNotRetoggleFloatingPaneAfterUnrelatedRelease(t *testing.T) { | |
| 332 | 333 | workspace := newTestWorkspace() | |
| 333 | 334 | workspace.Shortcuts.Actions = map[string]entity.ActionBinding{ | |
| 334 | - | "toggle_floating_pane": {Keys: []string{"alt+f"}}, | |
| 335 | + | "toggle-floating-pane": {Keys: []string{"alt+f"}}, | |
| 335 | 336 | } | |
| 336 | 337 | h := NewKeyboardHandler(context.Background(), workspace, newTestSession()) | |
| 337 | 338 | calls := 0 | |
| @@ -16,6 +16,7 @@ import ( | |||||
| 16 | // Used by tests that construct a KeyboardHandler. | 16 | // Used by tests that construct a KeyboardHandler. | ||
| 17 | func newTestWorkspace() *entity.WorkspaceConfig { | 17 | func newTestWorkspace() *entity.WorkspaceConfig { | ||
| 18 | return &entity.WorkspaceConfig{ | 18 | return &entity.WorkspaceConfig{ | ||
| 19 | + | Shortcuts: entity.GlobalShortcutsConfig{Actions: entity.BuiltinGlobalShortcuts()}, | |||
| 19 | TabMode: entity.TabModeConfig{ActivationShortcut: "ctrl+t"}, | 20 | TabMode: entity.TabModeConfig{ActivationShortcut: "ctrl+t"}, | ||
| 20 | PaneMode: entity.PaneModeConfig{ActivationShortcut: "ctrl+p"}, | 21 | PaneMode: entity.PaneModeConfig{ActivationShortcut: "ctrl+p"}, | ||
| 21 | ResizeMode: entity.ResizeModeConfig{ActivationShortcut: "ctrl+alt+r"}, | 22 | ResizeMode: entity.ResizeModeConfig{ActivationShortcut: "ctrl+alt+r"}, | ||
| @@ -331,7 +332,7 @@ func TestHandleKeyPress_DoesNotExitResizeModeWhileActivationKeyIsHeld(t *testing | |||||
| 331 | func TestHandleKeyPress_DoesNotRetoggleFloatingPaneAfterUnrelatedRelease(t *testing.T) { | 332 | func TestHandleKeyPress_DoesNotRetoggleFloatingPaneAfterUnrelatedRelease(t *testing.T) { | ||
| 332 | workspace := newTestWorkspace() | 333 | workspace := newTestWorkspace() | ||
| 333 | workspace.Shortcuts.Actions = map[string]entity.ActionBinding{ | 334 | workspace.Shortcuts.Actions = map[string]entity.ActionBinding{ | ||
| 334 | - | "toggle_floating_pane": {Keys: []string{"alt+f"}}, | 335 | + | "toggle-floating-pane": {Keys: []string{"alt+f"}}, |
| 335 | } | 336 | } | ||
| 336 | h := NewKeyboardHandler(context.Background(), workspace, newTestSession()) | 337 | h := NewKeyboardHandler(context.Background(), workspace, newTestSession()) | ||
| 337 | calls := 0 | 338 | calls := 0 | ||
M internal/ui/input/prefix_mode_test.go +4 -6
| @@ -108,12 +108,10 @@ func TestHandleKeyPress_PrefixPressedTwiceExits(t *testing.T) { | |||
| 108 | 108 | assert.Equal(t, ModeNormal, h.Mode()) | |
| 109 | 109 | } | |
| 110 | 110 | ||
| 111 | - | func TestShortcutSet_BuiltinFallbacksAndOverrides(t *testing.T) { | |
| 111 | + | func TestShortcutSet_BuiltinShortcutsAndOverrides(t *testing.T) { | |
| 112 | 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 | - | } | |
| 113 | + | ws.Shortcuts.Actions["reload"] = entity.ActionBinding{Keys: []string{}} // disabled by user | |
| 114 | + | ws.Shortcuts.Actions["quit"] = entity.ActionBinding{Keys: []string{"ctrl+shift+q"}} // rebound by user | |
| 117 | 115 | set := NewShortcutSet(context.Background(), ws, newTestSession()) | |
| 118 | 116 | ||
| 119 | 117 | lookup := func(key string) (Action, bool) { | |
| @@ -124,7 +122,7 @@ func TestShortcutSet_BuiltinFallbacksAndOverrides(t *testing.T) { | |||
| 124 | 122 | } | |
| 125 | 123 | ||
| 126 | 124 | action, ok := lookup("ctrl+l") | |
| 127 | - | assert.True(t, ok, "missing builtin action falls back to its default key") | |
| 125 | + | assert.True(t, ok, "untouched builtin action keeps its default key") | |
| 128 | 126 | assert.Equal(t, ActionOpenOmnibox, action) | |
| 129 | 127 | ||
| 130 | 128 | _, ok = lookup("ctrl+r") | |
| @@ -108,12 +108,10 @@ func TestHandleKeyPress_PrefixPressedTwiceExits(t *testing.T) { | |||||
| 108 | assert.Equal(t, ModeNormal, h.Mode()) | 108 | assert.Equal(t, ModeNormal, h.Mode()) | ||
| 109 | } | 109 | } | ||
| 110 | 110 | ||||
| 111 | - | func TestShortcutSet_BuiltinFallbacksAndOverrides(t *testing.T) { | 111 | + | func TestShortcutSet_BuiltinShortcutsAndOverrides(t *testing.T) { |
| 112 | ws := newTestWorkspace() | 112 | ws := newTestWorkspace() | ||
| 113 | - | ws.Shortcuts.Actions = map[string]entity.ActionBinding{ | 113 | + | ws.Shortcuts.Actions["reload"] = entity.ActionBinding{Keys: []string{}} // disabled by user |
| 114 | - | "reload": {Keys: []string{}}, // disabled by user | 114 | + | ws.Shortcuts.Actions["quit"] = entity.ActionBinding{Keys: []string{"ctrl+shift+q"}} // rebound by user |
| 115 | - | "quit": {Keys: []string{"ctrl+shift+q"}}, // rebound by user | |||
| 116 | - | } | |||
| 117 | set := NewShortcutSet(context.Background(), ws, newTestSession()) | 115 | set := NewShortcutSet(context.Background(), ws, newTestSession()) | ||
| 118 | 116 | ||||
| 119 | lookup := func(key string) (Action, bool) { | 117 | lookup := func(key string) (Action, bool) { | ||
| @@ -124,7 +122,7 @@ func TestShortcutSet_BuiltinFallbacksAndOverrides(t *testing.T) { | |||||
| 124 | } | 122 | } | ||
| 125 | 123 | ||||
| 126 | action, ok := lookup("ctrl+l") | 124 | action, ok := lookup("ctrl+l") | ||
| 127 | - | assert.True(t, ok, "missing builtin action falls back to its default key") | 125 | + | assert.True(t, ok, "untouched builtin action keeps its default key") |
| 128 | assert.Equal(t, ActionOpenOmnibox, action) | 126 | assert.Equal(t, ActionOpenOmnibox, action) | ||
| 129 | 127 | ||||
| 130 | _, ok = lookup("ctrl+r") | 128 | _, ok = lookup("ctrl+r") | ||
M internal/ui/input/shortcuts.go +56 -207
| @@ -9,6 +9,7 @@ import ( | |||
| 9 | 9 | "strings" | |
| 10 | 10 | ||
| 11 | 11 | "github.com/bnema/dumber/internal/domain/entity" | |
| 12 | + | "github.com/bnema/dumber/internal/domain/keychord" | |
| 12 | 13 | "github.com/bnema/dumber/internal/domain/vimkeys" | |
| 13 | 14 | "github.com/bnema/dumber/internal/logging" | |
| 14 | 15 | "github.com/bnema/puregotk/v4/gdk" | |
| @@ -31,67 +32,42 @@ const ( | |||
| 31 | 32 | // modifierMask filters out non-standard modifiers from GDK state. | |
| 32 | 33 | const modifierMask = ModCtrl | ModShift | ModAlt | |
| 33 | 34 | ||
| 35 | + | // keyvalByName maps the canonical key names of the keychord package to GDK | |
| 36 | + | // keyvals. Printable ASCII keys map to their character code and need no entry. | |
| 34 | 37 | var keyvalByName = map[string]uint{ | |
| 35 | - | "escape": uint(gdk.KEY_Escape), | |
| 36 | - | "esc": uint(gdk.KEY_Escape), | |
| 37 | - | "return": uint(gdk.KEY_Return), | |
| 38 | - | "enter": uint(gdk.KEY_Return), | |
| 39 | - | "tab": uint(gdk.KEY_Tab), | |
| 40 | - | "space": uint(gdk.KEY_space), | |
| 41 | - | "backspace": uint(gdk.KEY_BackSpace), | |
| 42 | - | "delete": uint(gdk.KEY_Delete), | |
| 43 | - | "del": uint(gdk.KEY_Delete), | |
| 44 | - | "home": uint(gdk.KEY_Home), | |
| 45 | - | "end": uint(gdk.KEY_End), | |
| 46 | - | "pageup": uint(gdk.KEY_Page_Up), | |
| 47 | - | "page_up": uint(gdk.KEY_Page_Up), | |
| 48 | - | "pagedown": uint(gdk.KEY_Page_Down), | |
| 49 | - | "page_down": uint(gdk.KEY_Page_Down), | |
| 50 | - | "left": uint(gdk.KEY_Left), | |
| 51 | - | "arrowleft": uint(gdk.KEY_Left), | |
| 52 | - | "right": uint(gdk.KEY_Right), | |
| 53 | - | "arrowright": uint(gdk.KEY_Right), | |
| 54 | - | "up": uint(gdk.KEY_Up), | |
| 55 | - | "arrowup": uint(gdk.KEY_Up), | |
| 56 | - | "down": uint(gdk.KEY_Down), | |
| 57 | - | "arrowdown": uint(gdk.KEY_Down), | |
| 58 | - | "f1": uint(gdk.KEY_F1), | |
| 59 | - | "f2": uint(gdk.KEY_F2), | |
| 60 | - | "f3": uint(gdk.KEY_F3), | |
| 61 | - | "f4": uint(gdk.KEY_F4), | |
| 62 | - | "f5": uint(gdk.KEY_F5), | |
| 63 | - | "f6": uint(gdk.KEY_F6), | |
| 64 | - | "f7": uint(gdk.KEY_F7), | |
| 65 | - | "f8": uint(gdk.KEY_F8), | |
| 66 | - | "f9": uint(gdk.KEY_F9), | |
| 67 | - | "f10": uint(gdk.KEY_F10), | |
| 68 | - | "f11": uint(gdk.KEY_F11), | |
| 69 | - | "f12": uint(gdk.KEY_F12), | |
| 70 | - | "plus": uint(gdk.KEY_plus), | |
| 71 | - | "+": uint(gdk.KEY_plus), | |
| 72 | - | "minus": uint(gdk.KEY_minus), | |
| 73 | - | "-": uint(gdk.KEY_minus), | |
| 74 | - | "equal": uint(gdk.KEY_equal), | |
| 75 | - | "=": uint(gdk.KEY_equal), | |
| 76 | - | "0": uint(gdk.KEY_0), | |
| 77 | - | "1": uint(gdk.KEY_1), | |
| 78 | - | "2": uint(gdk.KEY_2), | |
| 79 | - | "3": uint(gdk.KEY_3), | |
| 80 | - | "4": uint(gdk.KEY_4), | |
| 81 | - | "5": uint(gdk.KEY_5), | |
| 82 | - | "6": uint(gdk.KEY_6), | |
| 83 | - | "7": uint(gdk.KEY_7), | |
| 84 | - | "8": uint(gdk.KEY_8), | |
| 85 | - | "9": uint(gdk.KEY_9), | |
| 86 | - | ||
| 38 | + | "escape": uint(gdk.KEY_Escape), | |
| 39 | + | "return": uint(gdk.KEY_Return), | |
| 40 | + | "tab": uint(gdk.KEY_Tab), | |
| 41 | + | "space": uint(gdk.KEY_space), | |
| 42 | + | "backspace": uint(gdk.KEY_BackSpace), | |
| 43 | + | "delete": uint(gdk.KEY_Delete), | |
| 44 | + | "home": uint(gdk.KEY_Home), | |
| 45 | + | "end": uint(gdk.KEY_End), | |
| 46 | + | "pageup": uint(gdk.KEY_Page_Up), | |
| 47 | + | "pagedown": uint(gdk.KEY_Page_Down), | |
| 48 | + | "left": uint(gdk.KEY_Left), | |
| 49 | + | "right": uint(gdk.KEY_Right), | |
| 50 | + | "up": uint(gdk.KEY_Up), | |
| 51 | + | "down": uint(gdk.KEY_Down), | |
| 52 | + | "f1": uint(gdk.KEY_F1), | |
| 53 | + | "f2": uint(gdk.KEY_F2), | |
| 54 | + | "f3": uint(gdk.KEY_F3), | |
| 55 | + | "f4": uint(gdk.KEY_F4), | |
| 56 | + | "f5": uint(gdk.KEY_F5), | |
| 57 | + | "f6": uint(gdk.KEY_F6), | |
| 58 | + | "f7": uint(gdk.KEY_F7), | |
| 59 | + | "f8": uint(gdk.KEY_F8), | |
| 60 | + | "f9": uint(gdk.KEY_F9), | |
| 61 | + | "f10": uint(gdk.KEY_F10), | |
| 62 | + | "f11": uint(gdk.KEY_F11), | |
| 63 | + | "f12": uint(gdk.KEY_F12), | |
| 64 | + | "plus": uint(gdk.KEY_plus), | |
| 65 | + | "minus": uint(gdk.KEY_minus), | |
| 66 | + | "equal": uint(gdk.KEY_equal), | |
| 87 | 67 | "bracketleft": uint(gdk.KEY_bracketleft), | |
| 88 | 68 | "bracketright": uint(gdk.KEY_bracketright), | |
| 89 | - | "[": uint(gdk.KEY_bracketleft), | |
| 90 | - | "]": uint(gdk.KEY_bracketright), | |
| 91 | 69 | "braceleft": uint(gdk.KEY_braceleft), | |
| 92 | 70 | "braceright": uint(gdk.KEY_braceright), | |
| 93 | - | "{": uint(gdk.KEY_braceleft), | |
| 94 | - | "}": uint(gdk.KEY_braceright), | |
| 95 | 71 | } | |
| 96 | 72 | ||
| 97 | 73 | // KeyBinding represents a single key combination. | |
| @@ -285,7 +261,6 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess | |||
| 285 | 261 | ||
| 286 | 262 | // buildGlobalShortcutsFromParts populates global shortcuts from workspace and session configs. | |
| 287 | 263 | func (s *ShortcutSet) buildGlobalShortcutsFromParts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) { | |
| 288 | - | s.registerBuiltinShortcutFallbacks(workspace) | |
| 289 | 264 | s.registerActivationShortcutsFromParts(ctx, workspace, session) | |
| 290 | 265 | s.registerConfiguredShortcuts(workspace) | |
| 291 | 266 | // Alt+1-9, Alt+0, and Alt+Tab are registered by GlobalShortcutHandler; | |
| @@ -376,60 +351,12 @@ func (s *ShortcutSet) registerActivation(ctx context.Context, shortcut string, a | |||
| 376 | 351 | Msg("mode activation registered") | |
| 377 | 352 | } | |
| 378 | 353 | ||
| 379 | - | // legacyActionName returns the underscore spelling older configs use for an | |
| 380 | - | // action. It is the same rule the config loader applies when it completes | |
| 381 | - | // missing default actions, so a legacy-named action counts as configured in | |
| 382 | - | // both places. | |
| 383 | - | func legacyActionName(name string) string { | |
| 384 | - | return strings.ReplaceAll(name, "-", "_") | |
| 385 | - | } | |
| 386 | - | ||
| 387 | - | // globalShortcutAction resolves a configured global shortcut name to its | |
| 388 | - | // action. Besides the names in configActionToAction, a built-in browser action | |
| 389 | - | // is also recognized by its legacy underscore spelling. | |
| 390 | - | func globalShortcutAction(name string) (Action, bool) { | |
| 391 | - | if action, ok := configActionToAction[name]; ok { | |
| 392 | - | return action, true | |
| 393 | - | } | |
| 394 | - | for builtin := range entity.BuiltinGlobalShortcuts() { | |
| 395 | - | if legacyActionName(builtin) == name { | |
| 396 | - | action, ok := configActionToAction[builtin] | |
| 397 | - | return action, ok | |
| 398 | - | } | |
| 399 | - | } | |
| 400 | - | return "", false | |
| 401 | - | } | |
| 402 | - | ||
| 403 | - | // registerBuiltinShortcutFallbacks binds built-in browser shortcuts whose | |
| 404 | - | // action is missing from the configured global shortcuts, so configs written | |
| 405 | - | // before these actions became configurable keep working until migrated. | |
| 406 | - | func (s *ShortcutSet) registerBuiltinShortcutFallbacks(cfg *entity.WorkspaceConfig) { | |
| 407 | - | for name, binding := range entity.BuiltinGlobalShortcuts() { | |
| 408 | - | if cfg != nil { | |
| 409 | - | _, configured := cfg.Shortcuts.Actions[name] | |
| 410 | - | _, configuredLegacy := cfg.Shortcuts.Actions[legacyActionName(name)] | |
| 411 | - | if configured || configuredLegacy { | |
| 412 | - | continue | |
| 413 | - | } | |
| 414 | - | } | |
| 415 | - | action, ok := configActionToAction[name] | |
| 416 | - | if !ok { | |
| 417 | - | continue | |
| 418 | - | } | |
| 419 | - | for _, key := range binding.Keys { | |
| 420 | - | if kb, ok := ParseKeyString(key); ok { | |
| 421 | - | s.Global[kb] = action | |
| 422 | - | } | |
| 423 | - | } | |
| 424 | - | } | |
| 425 | - | } | |
| 426 | - | ||
| 427 | 354 | func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) { | |
| 428 | 355 | if cfg == nil { | |
| 429 | 356 | return | |
| 430 | 357 | } | |
| 431 | 358 | for _, actionName := range orderedGlobalShortcutActions(cfg.Shortcuts.Actions) { | |
| 432 | - | action, ok := globalShortcutAction(actionName) | |
| 359 | + | action, ok := configActionToAction[actionName] | |
| 433 | 360 | if !ok { | |
| 434 | 361 | continue | |
| 435 | 362 | } | |
| @@ -448,15 +375,8 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) { | |||
| 448 | 375 | func orderedGlobalShortcutActions(actions map[string]entity.ActionBinding) []string { | |
| 449 | 376 | builtins := entity.BuiltinGlobalShortcuts() | |
| 450 | 377 | isBuiltin := func(name string) bool { | |
| 451 | - | if _, ok := builtins[name]; ok { | |
| 452 | - | return true | |
| 453 | - | } | |
| 454 | - | for builtin := range builtins { | |
| 455 | - | if legacyActionName(builtin) == name { | |
| 456 | - | return true | |
| 457 | - | } | |
| 458 | - | } | |
| 459 | - | return false | |
| 378 | + | _, ok := builtins[name] | |
| 379 | + | return ok | |
| 460 | 380 | } | |
| 461 | 381 | names := slices.Sorted(maps.Keys(actions)) | |
| 462 | 382 | slices.SortStableFunc(names, func(a, b string) int { | |
| @@ -609,57 +529,37 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin | |||
| 609 | 529 | Msg(mode + " mode shortcuts built") | |
| 610 | 530 | } | |
| 611 | 531 | ||
| 532 | + | // configActionToAction maps canonical (hyphenated) config action names to | |
| 533 | + | // actions. Config loading canonicalizes legacy underscore spellings, so only | |
| 534 | + | // hyphenated names are looked up here. | |
| 612 | 535 | var configActionToAction = map[string]Action{ | |
| 613 | - | "toggle_floating_pane": ActionToggleFloatingPane, | |
| 614 | 536 | "toggle-floating-pane": ActionToggleFloatingPane, | |
| 615 | - | "toggle_history_systemview": ActionToggleHistorySystemView, | |
| 616 | 537 | "toggle-history-systemview": ActionToggleHistorySystemView, | |
| 617 | - | "toggle_favorites_sidebar": ActionToggleFavoritesSidebar, | |
| 618 | 538 | "toggle-favorites-sidebar": ActionToggleFavoritesSidebar, | |
| 619 | - | "toggle_current_page_favorite": ActionToggleCurrentPageFavorite, | |
| 620 | 539 | "toggle-current-page-favorite": ActionToggleCurrentPageFavorite, | |
| 621 | - | "toggle_config_systemview": ActionToggleConfigSystemView, | |
| 622 | 540 | "toggle-config-systemview": ActionToggleConfigSystemView, | |
| 623 | 541 | ||
| 624 | 542 | // Tab actions | |
| 625 | - | "new_tab": ActionNewTab, | |
| 626 | 543 | "new-tab": ActionNewTab, | |
| 627 | - | "close_tab": ActionCloseTab, | |
| 628 | 544 | "close-tab": ActionCloseTab, | |
| 629 | - | "next_tab": ActionNextTab, | |
| 630 | 545 | "next-tab": ActionNextTab, | |
| 631 | - | "previous_tab": ActionPreviousTab, | |
| 632 | 546 | "previous-tab": ActionPreviousTab, | |
| 633 | - | "rename_tab": ActionRenameTab, | |
| 634 | 547 | "rename-tab": ActionRenameTab, | |
| 635 | 548 | ||
| 636 | 549 | // Pane actions | |
| 637 | - | "split_right": ActionSplitRight, | |
| 638 | 550 | "split-right": ActionSplitRight, | |
| 639 | - | "split_left": ActionSplitLeft, | |
| 640 | 551 | "split-left": ActionSplitLeft, | |
| 641 | - | "split_up": ActionSplitUp, | |
| 642 | 552 | "split-up": ActionSplitUp, | |
| 643 | - | "split_down": ActionSplitDown, | |
| 644 | 553 | "split-down": ActionSplitDown, | |
| 645 | - | "close_pane": ActionClosePane, | |
| 646 | 554 | "close-pane": ActionClosePane, | |
| 647 | - | "stack_pane": ActionStackPane, | |
| 648 | 555 | "stack-pane": ActionStackPane, | |
| 649 | - | "move_pane_to_tab": ActionMovePaneToTab, | |
| 650 | 556 | "move-pane-to-tab": ActionMovePaneToTab, | |
| 651 | - | "move_pane_to_next_tab": ActionMovePaneToNextTab, | |
| 652 | 557 | "move-pane-to-next-tab": ActionMovePaneToNextTab, | |
| 653 | - | "eject_pane_to_window": ActionEjectPaneToWindow, | |
| 654 | 558 | "eject-pane-to-window": ActionEjectPaneToWindow, | |
| 655 | 559 | ||
| 656 | - | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |
| 657 | 560 | "consume-or-expel-left": ActionConsumeOrExpelLeft, | |
| 658 | - | "consume_or_expel_right": ActionConsumeOrExpelRight, | |
| 659 | 561 | "consume-or-expel-right": ActionConsumeOrExpelRight, | |
| 660 | - | "consume_or_expel_up": ActionConsumeOrExpelUp, | |
| 661 | 562 | "consume-or-expel-up": ActionConsumeOrExpelUp, | |
| 662 | - | "consume_or_expel_down": ActionConsumeOrExpelDown, | |
| 663 | 563 | "consume-or-expel-down": ActionConsumeOrExpelDown, | |
| 664 | 564 | ||
| 665 | 565 | // Focus navigation | |
| @@ -715,17 +615,11 @@ var configActionToAction = map[string]Action{ | |||
| 715 | 615 | "enter-vim-mode": ActionEnterVimMode, | |
| 716 | 616 | ||
| 717 | 617 | // Vim mode scroll actions | |
| 718 | - | "vim_scroll_left": ActionVimScrollLeft, | |
| 719 | 618 | "vim-scroll-left": ActionVimScrollLeft, | |
| 720 | - | "vim_scroll_down": ActionVimScrollDown, | |
| 721 | 619 | "vim-scroll-down": ActionVimScrollDown, | |
| 722 | - | "vim_scroll_up": ActionVimScrollUp, | |
| 723 | 620 | "vim-scroll-up": ActionVimScrollUp, | |
| 724 | - | "vim_scroll_right": ActionVimScrollRight, | |
| 725 | 621 | "vim-scroll-right": ActionVimScrollRight, | |
| 726 | - | "vim_scroll_down_fast": ActionVimScrollDownFast, | |
| 727 | 622 | "vim-scroll-down-fast": ActionVimScrollDownFast, | |
| 728 | - | "vim_scroll_up_fast": ActionVimScrollUpFast, | |
| 729 | 623 | "vim-scroll-up-fast": ActionVimScrollUpFast, | |
| 730 | 624 | } | |
| 731 | 625 | ||
| @@ -796,87 +690,42 @@ func mapConfigAction(configAction string) Action { | |||
| 796 | 690 | } | |
| 797 | 691 | ||
| 798 | 692 | // ParseKeyString converts a config key string like "ctrl+t" to a KeyBinding. | |
| 693 | + | // Alias, modifier and case handling is shared with the config layer through | |
| 694 | + | // keychord, so strings that canonicalize equal always parse to the same binding. | |
| 799 | 695 | // Returns false if the string cannot be parsed. | |
| 800 | 696 | func ParseKeyString(s string) (KeyBinding, bool) { | |
| 801 | - | if s == "" { | |
| 697 | + | chord, ok := keychord.Parse(s) | |
| 698 | + | if !ok { | |
| 802 | 699 | return KeyBinding{}, false | |
| 803 | 700 | } | |
| 804 | - | ||
| 805 | - | s = strings.TrimSpace(s) | |
| 806 | - | if s == "" { | |
| 701 | + | keyval, ok := keychordKeyval(chord.Key) | |
| 702 | + | if !ok { | |
| 807 | 703 | return KeyBinding{}, false | |
| 808 | 704 | } | |
| 809 | - | if s == "+" { | |
| 810 | - | return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true | |
| 811 | - | } | |
| 812 | - | ||
| 813 | - | parts := strings.Split(s, "+") | |
| 814 | 705 | ||
| 815 | 706 | var modifiers Modifier | |
| 816 | - | var keyPart string | |
| 817 | - | ||
| 818 | - | for _, part := range parts { | |
| 819 | - | part = strings.TrimSpace(part) | |
| 820 | - | if part == "" { | |
| 821 | - | continue | |
| 822 | - | } | |
| 823 | - | ||
| 824 | - | lower := strings.ToLower(part) | |
| 825 | - | switch lower { | |
| 826 | - | case "ctrl", "control": | |
| 827 | - | modifiers |= ModCtrl | |
| 828 | - | case "shift": | |
| 829 | - | modifiers |= ModShift | |
| 830 | - | case "alt": | |
| 831 | - | modifiers |= ModAlt | |
| 832 | - | default: | |
| 833 | - | if keyPart != "" { | |
| 834 | - | return KeyBinding{}, false | |
| 835 | - | } | |
| 836 | - | keyPart = part | |
| 837 | - | } | |
| 707 | + | if chord.Mods&keychord.Ctrl != 0 { | |
| 708 | + | modifiers |= ModCtrl | |
| 838 | 709 | } | |
| 839 | - | ||
| 840 | - | // Allow parsing "ctrl++" / "alt+shift++" where the key is "+". | |
| 841 | - | if keyPart == "" && strings.HasSuffix(s, "++") { | |
| 842 | - | keyPart = "+" | |
| 843 | - | } | |
| 844 | - | ||
| 845 | - | if keyPart == "" { | |
| 846 | - | return KeyBinding{}, false | |
| 710 | + | if chord.Mods&keychord.Alt != 0 { | |
| 711 | + | modifiers |= ModAlt | |
| 847 | 712 | } | |
| 848 | - | ||
| 849 | - | // Treat uppercase single-letter keys as Shift+<letter>. | |
| 850 | - | if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' { | |
| 713 | + | if chord.Mods&keychord.Shift != 0 { | |
| 851 | 714 | modifiers |= ModShift | |
| 852 | - | keyPart = strings.ToLower(keyPart) | |
| 853 | - | } | |
| 854 | - | ||
| 855 | - | keyval, ok := stringToKeyval(keyPart) | |
| 856 | - | if !ok { | |
| 857 | - | return KeyBinding{}, false | |
| 858 | 715 | } | |
| 859 | - | ||
| 860 | - | return KeyBinding{ | |
| 861 | - | Keyval: keyval, | |
| 862 | - | Modifiers: modifiers, | |
| 863 | - | }, true | |
| 716 | + | return KeyBinding{Keyval: keyval, Modifiers: modifiers}, true | |
| 864 | 717 | } | |
| 865 | 718 | ||
| 866 | - | // stringToKeyval converts a key name to its GDK keyval. | |
| 867 | - | func stringToKeyval(s string) (uint, bool) { | |
| 868 | - | if s == "" { | |
| 869 | - | return 0, false | |
| 870 | - | } | |
| 871 | - | ||
| 872 | - | if keyval, ok := keyvalByName[strings.ToLower(s)]; ok { | |
| 719 | + | // keychordKeyval converts a canonical keychord key name to its GDK keyval. | |
| 720 | + | func keychordKeyval(name string) (uint, bool) { | |
| 721 | + | if keyval, ok := keyvalByName[name]; ok { | |
| 873 | 722 | return keyval, true | |
| 874 | 723 | } | |
| 875 | 724 | ||
| 876 | - | // Single printable ASCII keys (a-z, symbols such as %, ", &). | |
| 725 | + | // Single printable ASCII keys (a-z, 0-9, symbols such as %, ", &). | |
| 877 | 726 | // GDK keyvals for printable ASCII match their character codes. | |
| 878 | - | if len(s) == 1 && s[0] > ' ' && s[0] <= '~' { | |
| 879 | - | return uint(s[0]), true | |
| 727 | + | if len(name) == 1 && name[0] > ' ' && name[0] <= '~' { | |
| 728 | + | return uint(name[0]), true | |
| 880 | 729 | } | |
| 881 | 730 | ||
| 882 | 731 | return 0, false | |
| @@ -9,6 +9,7 @@ import ( | |||||
| 9 | "strings" | 9 | "strings" | ||
| 10 | 10 | ||||
| 11 | "github.com/bnema/dumber/internal/domain/entity" | 11 | "github.com/bnema/dumber/internal/domain/entity" | ||
| 12 | + | "github.com/bnema/dumber/internal/domain/keychord" | |||
| 12 | "github.com/bnema/dumber/internal/domain/vimkeys" | 13 | "github.com/bnema/dumber/internal/domain/vimkeys" | ||
| 13 | "github.com/bnema/dumber/internal/logging" | 14 | "github.com/bnema/dumber/internal/logging" | ||
| 14 | "github.com/bnema/puregotk/v4/gdk" | 15 | "github.com/bnema/puregotk/v4/gdk" | ||
| @@ -31,67 +32,42 @@ const ( | |||||
| 31 | // modifierMask filters out non-standard modifiers from GDK state. | 32 | // modifierMask filters out non-standard modifiers from GDK state. | ||
| 32 | const modifierMask = ModCtrl | ModShift | ModAlt | 33 | const modifierMask = ModCtrl | ModShift | ModAlt | ||
| 33 | 34 | ||||
| 35 | + | // keyvalByName maps the canonical key names of the keychord package to GDK | |||
| 36 | + | // keyvals. Printable ASCII keys map to their character code and need no entry. | |||
| 34 | var keyvalByName = map[string]uint{ | 37 | var keyvalByName = map[string]uint{ | ||
| 35 | - | "escape": uint(gdk.KEY_Escape), | 38 | + | "escape": uint(gdk.KEY_Escape), |
| 36 | - | "esc": uint(gdk.KEY_Escape), | 39 | + | "return": uint(gdk.KEY_Return), |
| 37 | - | "return": uint(gdk.KEY_Return), | 40 | + | "tab": uint(gdk.KEY_Tab), |
| 38 | - | "enter": uint(gdk.KEY_Return), | 41 | + | "space": uint(gdk.KEY_space), |
| 39 | - | "tab": uint(gdk.KEY_Tab), | 42 | + | "backspace": uint(gdk.KEY_BackSpace), |
| 40 | - | "space": uint(gdk.KEY_space), | 43 | + | "delete": uint(gdk.KEY_Delete), |
| 41 | - | "backspace": uint(gdk.KEY_BackSpace), | 44 | + | "home": uint(gdk.KEY_Home), |
| 42 | - | "delete": uint(gdk.KEY_Delete), | 45 | + | "end": uint(gdk.KEY_End), |
| 43 | - | "del": uint(gdk.KEY_Delete), | 46 | + | "pageup": uint(gdk.KEY_Page_Up), |
| 44 | - | "home": uint(gdk.KEY_Home), | 47 | + | "pagedown": uint(gdk.KEY_Page_Down), |
| 45 | - | "end": uint(gdk.KEY_End), | 48 | + | "left": uint(gdk.KEY_Left), |
| 46 | - | "pageup": uint(gdk.KEY_Page_Up), | 49 | + | "right": uint(gdk.KEY_Right), |
| 47 | - | "page_up": uint(gdk.KEY_Page_Up), | 50 | + | "up": uint(gdk.KEY_Up), |
| 48 | - | "pagedown": uint(gdk.KEY_Page_Down), | 51 | + | "down": uint(gdk.KEY_Down), |
| 49 | - | "page_down": uint(gdk.KEY_Page_Down), | 52 | + | "f1": uint(gdk.KEY_F1), |
| 50 | - | "left": uint(gdk.KEY_Left), | 53 | + | "f2": uint(gdk.KEY_F2), |
| 51 | - | "arrowleft": uint(gdk.KEY_Left), | 54 | + | "f3": uint(gdk.KEY_F3), |
| 52 | - | "right": uint(gdk.KEY_Right), | 55 | + | "f4": uint(gdk.KEY_F4), |
| 53 | - | "arrowright": uint(gdk.KEY_Right), | 56 | + | "f5": uint(gdk.KEY_F5), |
| 54 | - | "up": uint(gdk.KEY_Up), | 57 | + | "f6": uint(gdk.KEY_F6), |
| 55 | - | "arrowup": uint(gdk.KEY_Up), | 58 | + | "f7": uint(gdk.KEY_F7), |
| 56 | - | "down": uint(gdk.KEY_Down), | 59 | + | "f8": uint(gdk.KEY_F8), |
| 57 | - | "arrowdown": uint(gdk.KEY_Down), | 60 | + | "f9": uint(gdk.KEY_F9), |
| 58 | - | "f1": uint(gdk.KEY_F1), | 61 | + | "f10": uint(gdk.KEY_F10), |
| 59 | - | "f2": uint(gdk.KEY_F2), | 62 | + | "f11": uint(gdk.KEY_F11), |
| 60 | - | "f3": uint(gdk.KEY_F3), | 63 | + | "f12": uint(gdk.KEY_F12), |
| 61 | - | "f4": uint(gdk.KEY_F4), | 64 | + | "plus": uint(gdk.KEY_plus), |
| 62 | - | "f5": uint(gdk.KEY_F5), | 65 | + | "minus": uint(gdk.KEY_minus), |
| 63 | - | "f6": uint(gdk.KEY_F6), | 66 | + | "equal": uint(gdk.KEY_equal), |
| 64 | - | "f7": uint(gdk.KEY_F7), | |||
| 65 | - | "f8": uint(gdk.KEY_F8), | |||
| 66 | - | "f9": uint(gdk.KEY_F9), | |||
| 67 | - | "f10": uint(gdk.KEY_F10), | |||
| 68 | - | "f11": uint(gdk.KEY_F11), | |||
| 69 | - | "f12": uint(gdk.KEY_F12), | |||
| 70 | - | "plus": uint(gdk.KEY_plus), | |||
| 71 | - | "+": uint(gdk.KEY_plus), | |||
| 72 | - | "minus": uint(gdk.KEY_minus), | |||
| 73 | - | "-": uint(gdk.KEY_minus), | |||
| 74 | - | "equal": uint(gdk.KEY_equal), | |||
| 75 | - | "=": uint(gdk.KEY_equal), | |||
| 76 | - | "0": uint(gdk.KEY_0), | |||
| 77 | - | "1": uint(gdk.KEY_1), | |||
| 78 | - | "2": uint(gdk.KEY_2), | |||
| 79 | - | "3": uint(gdk.KEY_3), | |||
| 80 | - | "4": uint(gdk.KEY_4), | |||
| 81 | - | "5": uint(gdk.KEY_5), | |||
| 82 | - | "6": uint(gdk.KEY_6), | |||
| 83 | - | "7": uint(gdk.KEY_7), | |||
| 84 | - | "8": uint(gdk.KEY_8), | |||
| 85 | - | "9": uint(gdk.KEY_9), | |||
| 86 | - | ||||
| 87 | "bracketleft": uint(gdk.KEY_bracketleft), | 67 | "bracketleft": uint(gdk.KEY_bracketleft), | ||
| 88 | "bracketright": uint(gdk.KEY_bracketright), | 68 | "bracketright": uint(gdk.KEY_bracketright), | ||
| 89 | - | "[": uint(gdk.KEY_bracketleft), | |||
| 90 | - | "]": uint(gdk.KEY_bracketright), | |||
| 91 | "braceleft": uint(gdk.KEY_braceleft), | 69 | "braceleft": uint(gdk.KEY_braceleft), | ||
| 92 | "braceright": uint(gdk.KEY_braceright), | 70 | "braceright": uint(gdk.KEY_braceright), | ||
| 93 | - | "{": uint(gdk.KEY_braceleft), | |||
| 94 | - | "}": uint(gdk.KEY_braceright), | |||
| 95 | } | 71 | } | ||
| 96 | 72 | ||||
| 97 | // KeyBinding represents a single key combination. | 73 | // KeyBinding represents a single key combination. | ||
| @@ -285,7 +261,6 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess | |||||
| 285 | 261 | ||||
| 286 | // buildGlobalShortcutsFromParts populates global shortcuts from workspace and session configs. | 262 | // buildGlobalShortcutsFromParts populates global shortcuts from workspace and session configs. | ||
| 287 | func (s *ShortcutSet) buildGlobalShortcutsFromParts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) { | 263 | func (s *ShortcutSet) buildGlobalShortcutsFromParts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) { | ||
| 288 | - | s.registerBuiltinShortcutFallbacks(workspace) | |||
| 289 | s.registerActivationShortcutsFromParts(ctx, workspace, session) | 264 | s.registerActivationShortcutsFromParts(ctx, workspace, session) | ||
| 290 | s.registerConfiguredShortcuts(workspace) | 265 | s.registerConfiguredShortcuts(workspace) | ||
| 291 | // Alt+1-9, Alt+0, and Alt+Tab are registered by GlobalShortcutHandler; | 266 | // Alt+1-9, Alt+0, and Alt+Tab are registered by GlobalShortcutHandler; | ||
| @@ -376,60 +351,12 @@ func (s *ShortcutSet) registerActivation(ctx context.Context, shortcut string, a | |||||
| 376 | Msg("mode activation registered") | 351 | Msg("mode activation registered") | ||
| 377 | } | 352 | } | ||
| 378 | 353 | ||||
| 379 | - | // legacyActionName returns the underscore spelling older configs use for an | |||
| 380 | - | // action. It is the same rule the config loader applies when it completes | |||
| 381 | - | // missing default actions, so a legacy-named action counts as configured in | |||
| 382 | - | // both places. | |||
| 383 | - | func legacyActionName(name string) string { | |||
| 384 | - | return strings.ReplaceAll(name, "-", "_") | |||
| 385 | - | } | |||
| 386 | - | ||||
| 387 | - | // globalShortcutAction resolves a configured global shortcut name to its | |||
| 388 | - | // action. Besides the names in configActionToAction, a built-in browser action | |||
| 389 | - | // is also recognized by its legacy underscore spelling. | |||
| 390 | - | func globalShortcutAction(name string) (Action, bool) { | |||
| 391 | - | if action, ok := configActionToAction[name]; ok { | |||
| 392 | - | return action, true | |||
| 393 | - | } | |||
| 394 | - | for builtin := range entity.BuiltinGlobalShortcuts() { | |||
| 395 | - | if legacyActionName(builtin) == name { | |||
| 396 | - | action, ok := configActionToAction[builtin] | |||
| 397 | - | return action, ok | |||
| 398 | - | } | |||
| 399 | - | } | |||
| 400 | - | return "", false | |||
| 401 | - | } | |||
| 402 | - | ||||
| 403 | - | // registerBuiltinShortcutFallbacks binds built-in browser shortcuts whose | |||
| 404 | - | // action is missing from the configured global shortcuts, so configs written | |||
| 405 | - | // before these actions became configurable keep working until migrated. | |||
| 406 | - | func (s *ShortcutSet) registerBuiltinShortcutFallbacks(cfg *entity.WorkspaceConfig) { | |||
| 407 | - | for name, binding := range entity.BuiltinGlobalShortcuts() { | |||
| 408 | - | if cfg != nil { | |||
| 409 | - | _, configured := cfg.Shortcuts.Actions[name] | |||
| 410 | - | _, configuredLegacy := cfg.Shortcuts.Actions[legacyActionName(name)] | |||
| 411 | - | if configured || configuredLegacy { | |||
| 412 | - | continue | |||
| 413 | - | } | |||
| 414 | - | } | |||
| 415 | - | action, ok := configActionToAction[name] | |||
| 416 | - | if !ok { | |||
| 417 | - | continue | |||
| 418 | - | } | |||
| 419 | - | for _, key := range binding.Keys { | |||
| 420 | - | if kb, ok := ParseKeyString(key); ok { | |||
| 421 | - | s.Global[kb] = action | |||
| 422 | - | } | |||
| 423 | - | } | |||
| 424 | - | } | |||
| 425 | - | } | |||
| 426 | - | ||||
| 427 | func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) { | 354 | func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) { | ||
| 428 | if cfg == nil { | 355 | if cfg == nil { | ||
| 429 | return | 356 | return | ||
| 430 | } | 357 | } | ||
| 431 | for _, actionName := range orderedGlobalShortcutActions(cfg.Shortcuts.Actions) { | 358 | for _, actionName := range orderedGlobalShortcutActions(cfg.Shortcuts.Actions) { | ||
| 432 | - | action, ok := globalShortcutAction(actionName) | 359 | + | action, ok := configActionToAction[actionName] |
| 433 | if !ok { | 360 | if !ok { | ||
| 434 | continue | 361 | continue | ||
| 435 | } | 362 | } | ||
| @@ -448,15 +375,8 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) { | |||||
| 448 | func orderedGlobalShortcutActions(actions map[string]entity.ActionBinding) []string { | 375 | func orderedGlobalShortcutActions(actions map[string]entity.ActionBinding) []string { | ||
| 449 | builtins := entity.BuiltinGlobalShortcuts() | 376 | builtins := entity.BuiltinGlobalShortcuts() | ||
| 450 | isBuiltin := func(name string) bool { | 377 | isBuiltin := func(name string) bool { | ||
| 451 | - | if _, ok := builtins[name]; ok { | 378 | + | _, ok := builtins[name] |
| 452 | - | return true | 379 | + | return ok |
| 453 | - | } | |||
| 454 | - | for builtin := range builtins { | |||
| 455 | - | if legacyActionName(builtin) == name { | |||
| 456 | - | return true | |||
| 457 | - | } | |||
| 458 | - | } | |||
| 459 | - | return false | |||
| 460 | } | 380 | } | ||
| 461 | names := slices.Sorted(maps.Keys(actions)) | 381 | names := slices.Sorted(maps.Keys(actions)) | ||
| 462 | slices.SortStableFunc(names, func(a, b string) int { | 382 | slices.SortStableFunc(names, func(a, b string) int { | ||
| @@ -609,57 +529,37 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin | |||||
| 609 | Msg(mode + " mode shortcuts built") | 529 | Msg(mode + " mode shortcuts built") | ||
| 610 | } | 530 | } | ||
| 611 | 531 | ||||
| 532 | + | // configActionToAction maps canonical (hyphenated) config action names to | |||
| 533 | + | // actions. Config loading canonicalizes legacy underscore spellings, so only | |||
| 534 | + | // hyphenated names are looked up here. | |||
| 612 | var configActionToAction = map[string]Action{ | 535 | var configActionToAction = map[string]Action{ | ||
| 613 | - | "toggle_floating_pane": ActionToggleFloatingPane, | |||
| 614 | "toggle-floating-pane": ActionToggleFloatingPane, | 536 | "toggle-floating-pane": ActionToggleFloatingPane, | ||
| 615 | - | "toggle_history_systemview": ActionToggleHistorySystemView, | |||
| 616 | "toggle-history-systemview": ActionToggleHistorySystemView, | 537 | "toggle-history-systemview": ActionToggleHistorySystemView, | ||
| 617 | - | "toggle_favorites_sidebar": ActionToggleFavoritesSidebar, | |||
| 618 | "toggle-favorites-sidebar": ActionToggleFavoritesSidebar, | 538 | "toggle-favorites-sidebar": ActionToggleFavoritesSidebar, | ||
| 619 | - | "toggle_current_page_favorite": ActionToggleCurrentPageFavorite, | |||
| 620 | "toggle-current-page-favorite": ActionToggleCurrentPageFavorite, | 539 | "toggle-current-page-favorite": ActionToggleCurrentPageFavorite, | ||
| 621 | - | "toggle_config_systemview": ActionToggleConfigSystemView, | |||
| 622 | "toggle-config-systemview": ActionToggleConfigSystemView, | 540 | "toggle-config-systemview": ActionToggleConfigSystemView, | ||
| 623 | 541 | ||||
| 624 | // Tab actions | 542 | // Tab actions | ||
| 625 | - | "new_tab": ActionNewTab, | |||
| 626 | "new-tab": ActionNewTab, | 543 | "new-tab": ActionNewTab, | ||
| 627 | - | "close_tab": ActionCloseTab, | |||
| 628 | "close-tab": ActionCloseTab, | 544 | "close-tab": ActionCloseTab, | ||
| 629 | - | "next_tab": ActionNextTab, | |||
| 630 | "next-tab": ActionNextTab, | 545 | "next-tab": ActionNextTab, | ||
| 631 | - | "previous_tab": ActionPreviousTab, | |||
| 632 | "previous-tab": ActionPreviousTab, | 546 | "previous-tab": ActionPreviousTab, | ||
| 633 | - | "rename_tab": ActionRenameTab, | |||
| 634 | "rename-tab": ActionRenameTab, | 547 | "rename-tab": ActionRenameTab, | ||
| 635 | 548 | ||||
| 636 | // Pane actions | 549 | // Pane actions | ||
| 637 | - | "split_right": ActionSplitRight, | |||
| 638 | "split-right": ActionSplitRight, | 550 | "split-right": ActionSplitRight, | ||
| 639 | - | "split_left": ActionSplitLeft, | |||
| 640 | "split-left": ActionSplitLeft, | 551 | "split-left": ActionSplitLeft, | ||
| 641 | - | "split_up": ActionSplitUp, | |||
| 642 | "split-up": ActionSplitUp, | 552 | "split-up": ActionSplitUp, | ||
| 643 | - | "split_down": ActionSplitDown, | |||
| 644 | "split-down": ActionSplitDown, | 553 | "split-down": ActionSplitDown, | ||
| 645 | - | "close_pane": ActionClosePane, | |||
| 646 | "close-pane": ActionClosePane, | 554 | "close-pane": ActionClosePane, | ||
| 647 | - | "stack_pane": ActionStackPane, | |||
| 648 | "stack-pane": ActionStackPane, | 555 | "stack-pane": ActionStackPane, | ||
| 649 | - | "move_pane_to_tab": ActionMovePaneToTab, | |||
| 650 | "move-pane-to-tab": ActionMovePaneToTab, | 556 | "move-pane-to-tab": ActionMovePaneToTab, | ||
| 651 | - | "move_pane_to_next_tab": ActionMovePaneToNextTab, | |||
| 652 | "move-pane-to-next-tab": ActionMovePaneToNextTab, | 557 | "move-pane-to-next-tab": ActionMovePaneToNextTab, | ||
| 653 | - | "eject_pane_to_window": ActionEjectPaneToWindow, | |||
| 654 | "eject-pane-to-window": ActionEjectPaneToWindow, | 558 | "eject-pane-to-window": ActionEjectPaneToWindow, | ||
| 655 | 559 | ||||
| 656 | - | "consume_or_expel_left": ActionConsumeOrExpelLeft, | |||
| 657 | "consume-or-expel-left": ActionConsumeOrExpelLeft, | 560 | "consume-or-expel-left": ActionConsumeOrExpelLeft, | ||
| 658 | - | "consume_or_expel_right": ActionConsumeOrExpelRight, | |||
| 659 | "consume-or-expel-right": ActionConsumeOrExpelRight, | 561 | "consume-or-expel-right": ActionConsumeOrExpelRight, | ||
| 660 | - | "consume_or_expel_up": ActionConsumeOrExpelUp, | |||
| 661 | "consume-or-expel-up": ActionConsumeOrExpelUp, | 562 | "consume-or-expel-up": ActionConsumeOrExpelUp, | ||
| 662 | - | "consume_or_expel_down": ActionConsumeOrExpelDown, | |||
| 663 | "consume-or-expel-down": ActionConsumeOrExpelDown, | 563 | "consume-or-expel-down": ActionConsumeOrExpelDown, | ||
| 664 | 564 | ||||
| 665 | // Focus navigation | 565 | // Focus navigation | ||
| @@ -715,17 +615,11 @@ var configActionToAction = map[string]Action{ | |||||
| 715 | "enter-vim-mode": ActionEnterVimMode, | 615 | "enter-vim-mode": ActionEnterVimMode, | ||
| 716 | 616 | ||||
| 717 | // Vim mode scroll actions | 617 | // Vim mode scroll actions | ||
| 718 | - | "vim_scroll_left": ActionVimScrollLeft, | |||
| 719 | "vim-scroll-left": ActionVimScrollLeft, | 618 | "vim-scroll-left": ActionVimScrollLeft, | ||
| 720 | - | "vim_scroll_down": ActionVimScrollDown, | |||
| 721 | "vim-scroll-down": ActionVimScrollDown, | 619 | "vim-scroll-down": ActionVimScrollDown, | ||
| 722 | - | "vim_scroll_up": ActionVimScrollUp, | |||
| 723 | "vim-scroll-up": ActionVimScrollUp, | 620 | "vim-scroll-up": ActionVimScrollUp, | ||
| 724 | - | "vim_scroll_right": ActionVimScrollRight, | |||
| 725 | "vim-scroll-right": ActionVimScrollRight, | 621 | "vim-scroll-right": ActionVimScrollRight, | ||
| 726 | - | "vim_scroll_down_fast": ActionVimScrollDownFast, | |||
| 727 | "vim-scroll-down-fast": ActionVimScrollDownFast, | 622 | "vim-scroll-down-fast": ActionVimScrollDownFast, | ||
| 728 | - | "vim_scroll_up_fast": ActionVimScrollUpFast, | |||
| 729 | "vim-scroll-up-fast": ActionVimScrollUpFast, | 623 | "vim-scroll-up-fast": ActionVimScrollUpFast, | ||
| 730 | } | 624 | } | ||
| 731 | 625 | ||||
| @@ -796,87 +690,42 @@ func mapConfigAction(configAction string) Action { | |||||
| 796 | } | 690 | } | ||
| 797 | 691 | ||||
| 798 | // ParseKeyString converts a config key string like "ctrl+t" to a KeyBinding. | 692 | // ParseKeyString converts a config key string like "ctrl+t" to a KeyBinding. | ||
| 693 | + | // Alias, modifier and case handling is shared with the config layer through | |||
| 694 | + | // keychord, so strings that canonicalize equal always parse to the same binding. | |||
| 799 | // Returns false if the string cannot be parsed. | 695 | // Returns false if the string cannot be parsed. | ||
| 800 | func ParseKeyString(s string) (KeyBinding, bool) { | 696 | func ParseKeyString(s string) (KeyBinding, bool) { | ||
| 801 | - | if s == "" { | 697 | + | chord, ok := keychord.Parse(s) |
| 698 | + | if !ok { | |||
| 802 | return KeyBinding{}, false | 699 | return KeyBinding{}, false | ||
| 803 | } | 700 | } | ||
| 804 | - | 701 | + | keyval, ok := keychordKeyval(chord.Key) | |
| 805 | - | s = strings.TrimSpace(s) | 702 | + | if !ok { |
| 806 | - | if s == "" { | |||
| 807 | return KeyBinding{}, false | 703 | return KeyBinding{}, false | ||
| 808 | } | 704 | } | ||
| 809 | - | if s == "+" { | |||
| 810 | - | return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true | |||
| 811 | - | } | |||
| 812 | - | ||||
| 813 | - | parts := strings.Split(s, "+") | |||
| 814 | 705 | ||||
| 815 | var modifiers Modifier | 706 | var modifiers Modifier | ||
| 816 | - | var keyPart string | 707 | + | if chord.Mods&keychord.Ctrl != 0 { |
| 817 | - | 708 | + | modifiers |= ModCtrl | |
| 818 | - | for _, part := range parts { | |||
| 819 | - | part = strings.TrimSpace(part) | |||
| 820 | - | if part == "" { | |||
| 821 | - | continue | |||
| 822 | - | } | |||
| 823 | - | ||||
| 824 | - | lower := strings.ToLower(part) | |||
| 825 | - | switch lower { | |||
| 826 | - | case "ctrl", "control": | |||
| 827 | - | modifiers |= ModCtrl | |||
| 828 | - | case "shift": | |||
| 829 | - | modifiers |= ModShift | |||
| 830 | - | case "alt": | |||
| 831 | - | modifiers |= ModAlt | |||
| 832 | - | default: | |||
| 833 | - | if keyPart != "" { | |||
| 834 | - | return KeyBinding{}, false | |||
| 835 | - | } | |||
| 836 | - | keyPart = part | |||
| 837 | - | } | |||
| 838 | } | 709 | } | ||
| 839 | - | 710 | + | if chord.Mods&keychord.Alt != 0 { | |
| 840 | - | // Allow parsing "ctrl++" / "alt+shift++" where the key is "+". | 711 | + | modifiers |= ModAlt |
| 841 | - | if keyPart == "" && strings.HasSuffix(s, "++") { | |||
| 842 | - | keyPart = "+" | |||
| 843 | - | } | |||
| 844 | - | ||||
| 845 | - | if keyPart == "" { | |||
| 846 | - | return KeyBinding{}, false | |||
| 847 | } | 712 | } | ||
| 848 | - | 713 | + | if chord.Mods&keychord.Shift != 0 { | |
| 849 | - | // Treat uppercase single-letter keys as Shift+<letter>. | |||
| 850 | - | if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' { | |||
| 851 | modifiers |= ModShift | 714 | modifiers |= ModShift | ||
| 852 | - | keyPart = strings.ToLower(keyPart) | |||
| 853 | - | } | |||
| 854 | - | ||||
| 855 | - | keyval, ok := stringToKeyval(keyPart) | |||
| 856 | - | if !ok { | |||
| 857 | - | return KeyBinding{}, false | |||
| 858 | } | 715 | } | ||
| 859 | - | 716 | + | return KeyBinding{Keyval: keyval, Modifiers: modifiers}, true | |
| 860 | - | return KeyBinding{ | |||
| 861 | - | Keyval: keyval, | |||
| 862 | - | Modifiers: modifiers, | |||
| 863 | - | }, true | |||
| 864 | } | 717 | } | ||
| 865 | 718 | ||||
| 866 | - | // stringToKeyval converts a key name to its GDK keyval. | 719 | + | // keychordKeyval converts a canonical keychord key name to its GDK keyval. |
| 867 | - | func stringToKeyval(s string) (uint, bool) { | 720 | + | func keychordKeyval(name string) (uint, bool) { |
| 868 | - | if s == "" { | 721 | + | if keyval, ok := keyvalByName[name]; ok { |
| 869 | - | return 0, false | |||
| 870 | - | } | |||
| 871 | - | ||||
| 872 | - | if keyval, ok := keyvalByName[strings.ToLower(s)]; ok { | |||
| 873 | return keyval, true | 722 | return keyval, true | ||
| 874 | } | 723 | } | ||
| 875 | 724 | ||||
| 876 | - | // Single printable ASCII keys (a-z, symbols such as %, ", &). | 725 | + | // Single printable ASCII keys (a-z, 0-9, symbols such as %, ", &). |
| 877 | // GDK keyvals for printable ASCII match their character codes. | 726 | // GDK keyvals for printable ASCII match their character codes. | ||
| 878 | - | if len(s) == 1 && s[0] > ' ' && s[0] <= '~' { | 727 | + | if len(name) == 1 && name[0] > ' ' && name[0] <= '~' { |
| 879 | - | return uint(s[0]), true | 728 | + | return uint(name[0]), true |
| 880 | } | 729 | } | ||
| 881 | 730 | ||||
| 882 | return 0, false | 731 | return 0, false | ||
M internal/ui/input/shortcuts_test.go +3 -29
| @@ -380,7 +380,7 @@ func TestShouldAutoExitMode(t *testing.T) { | |||
| 380 | 380 | } | |
| 381 | 381 | ||
| 382 | 382 | func TestMapConfigAction_EjectPaneToWindowAliases(t *testing.T) { | |
| 383 | - | tests := []string{"eject_pane_to_window", "eject-pane-to-window"} | |
| 383 | + | tests := []string{"eject-pane-to-window"} | |
| 384 | 384 | for _, name := range tests { | |
| 385 | 385 | t.Run(name, func(t *testing.T) { | |
| 386 | 386 | if got := mapConfigAction(name); got != ActionEjectPaneToWindow { | |
| @@ -391,29 +391,9 @@ func TestMapConfigAction_EjectPaneToWindowAliases(t *testing.T) { | |||
| 391 | 391 | } | |
| 392 | 392 | ||
| 393 | 393 | func TestMapConfigAction_ToggleFloatingPane(t *testing.T) { | |
| 394 | - | action := mapConfigAction("toggle_floating_pane") | |
| 394 | + | action := mapConfigAction("toggle-floating-pane") | |
| 395 | 395 | if action != ActionToggleFloatingPane { | |
| 396 | - | t.Fatalf("mapConfigAction(toggle_floating_pane) = %s, want %s", action, ActionToggleFloatingPane) | |
| 397 | - | } | |
| 398 | - | } | |
| 399 | - | ||
| 400 | - | func TestMapConfigAction_ToggleSystemViews(t *testing.T) { | |
| 401 | - | tests := []struct { | |
| 402 | - | name string | |
| 403 | - | want Action | |
| 404 | - | }{ | |
| 405 | - | {name: "toggle_history_systemview", want: ActionToggleHistorySystemView}, | |
| 406 | - | {name: "toggle_favorites_sidebar", want: ActionToggleFavoritesSidebar}, | |
| 407 | - | {name: "toggle_current_page_favorite", want: ActionToggleCurrentPageFavorite}, | |
| 408 | - | {name: "toggle_config_systemview", want: ActionToggleConfigSystemView}, | |
| 409 | - | } | |
| 410 | - | ||
| 411 | - | for _, tt := range tests { | |
| 412 | - | t.Run(tt.name, func(t *testing.T) { | |
| 413 | - | if got := mapConfigAction(tt.name); got != tt.want { | |
| 414 | - | t.Fatalf("mapConfigAction(%s) = %s, want %s", tt.name, got, tt.want) | |
| 415 | - | } | |
| 416 | - | }) | |
| 396 | + | t.Fatalf("mapConfigAction(toggle-floating-pane) = %s, want %s", action, ActionToggleFloatingPane) | |
| 417 | 397 | } | |
| 418 | 398 | } | |
| 419 | 399 | ||
| @@ -580,17 +560,11 @@ func TestMapConfigAction_VimScrollActions(t *testing.T) { | |||
| 580 | 560 | config string | |
| 581 | 561 | expected Action | |
| 582 | 562 | }{ | |
| 583 | - | {name: "vim_scroll_left", config: "vim_scroll_left", expected: ActionVimScrollLeft}, | |
| 584 | 563 | {name: "vim-scroll-left", config: "vim-scroll-left", expected: ActionVimScrollLeft}, | |
| 585 | - | {name: "vim_scroll_down", config: "vim_scroll_down", expected: ActionVimScrollDown}, | |
| 586 | 564 | {name: "vim-scroll-down", config: "vim-scroll-down", expected: ActionVimScrollDown}, | |
| 587 | - | {name: "vim_scroll_up", config: "vim_scroll_up", expected: ActionVimScrollUp}, | |
| 588 | 565 | {name: "vim-scroll-up", config: "vim-scroll-up", expected: ActionVimScrollUp}, | |
| 589 | - | {name: "vim_scroll_right", config: "vim_scroll_right", expected: ActionVimScrollRight}, | |
| 590 | 566 | {name: "vim-scroll-right", config: "vim-scroll-right", expected: ActionVimScrollRight}, | |
| 591 | - | {name: "vim_scroll_down_fast", config: "vim_scroll_down_fast", expected: ActionVimScrollDownFast}, | |
| 592 | 567 | {name: "vim-scroll-down-fast", config: "vim-scroll-down-fast", expected: ActionVimScrollDownFast}, | |
| 593 | - | {name: "vim_scroll_up_fast", config: "vim_scroll_up_fast", expected: ActionVimScrollUpFast}, | |
| 594 | 568 | {name: "vim-scroll-up-fast", config: "vim-scroll-up-fast", expected: ActionVimScrollUpFast}, | |
| 595 | 569 | } | |
| 596 | 570 | ||
| @@ -380,7 +380,7 @@ func TestShouldAutoExitMode(t *testing.T) { | |||||
| 380 | } | 380 | } | ||
| 381 | 381 | ||||
| 382 | func TestMapConfigAction_EjectPaneToWindowAliases(t *testing.T) { | 382 | func TestMapConfigAction_EjectPaneToWindowAliases(t *testing.T) { | ||
| 383 | - | tests := []string{"eject_pane_to_window", "eject-pane-to-window"} | 383 | + | tests := []string{"eject-pane-to-window"} |
| 384 | for _, name := range tests { | 384 | for _, name := range tests { | ||
| 385 | t.Run(name, func(t *testing.T) { | 385 | t.Run(name, func(t *testing.T) { | ||
| 386 | if got := mapConfigAction(name); got != ActionEjectPaneToWindow { | 386 | if got := mapConfigAction(name); got != ActionEjectPaneToWindow { | ||
| @@ -391,29 +391,9 @@ func TestMapConfigAction_EjectPaneToWindowAliases(t *testing.T) { | |||||
| 391 | } | 391 | } | ||
| 392 | 392 | ||||
| 393 | func TestMapConfigAction_ToggleFloatingPane(t *testing.T) { | 393 | func TestMapConfigAction_ToggleFloatingPane(t *testing.T) { | ||
| 394 | - | action := mapConfigAction("toggle_floating_pane") | 394 | + | action := mapConfigAction("toggle-floating-pane") |
| 395 | if action != ActionToggleFloatingPane { | 395 | if action != ActionToggleFloatingPane { | ||
| 396 | - | t.Fatalf("mapConfigAction(toggle_floating_pane) = %s, want %s", action, ActionToggleFloatingPane) | 396 | + | t.Fatalf("mapConfigAction(toggle-floating-pane) = %s, want %s", action, ActionToggleFloatingPane) |
| 397 | - | } | |||
| 398 | - | } | |||
| 399 | - | ||||
| 400 | - | func TestMapConfigAction_ToggleSystemViews(t *testing.T) { | |||
| 401 | - | tests := []struct { | |||
| 402 | - | name string | |||
| 403 | - | want Action | |||
| 404 | - | }{ | |||
| 405 | - | {name: "toggle_history_systemview", want: ActionToggleHistorySystemView}, | |||
| 406 | - | {name: "toggle_favorites_sidebar", want: ActionToggleFavoritesSidebar}, | |||
| 407 | - | {name: "toggle_current_page_favorite", want: ActionToggleCurrentPageFavorite}, | |||
| 408 | - | {name: "toggle_config_systemview", want: ActionToggleConfigSystemView}, | |||
| 409 | - | } | |||
| 410 | - | ||||
| 411 | - | for _, tt := range tests { | |||
| 412 | - | t.Run(tt.name, func(t *testing.T) { | |||
| 413 | - | if got := mapConfigAction(tt.name); got != tt.want { | |||
| 414 | - | t.Fatalf("mapConfigAction(%s) = %s, want %s", tt.name, got, tt.want) | |||
| 415 | - | } | |||
| 416 | - | }) | |||
| 417 | } | 397 | } | ||
| 418 | } | 398 | } | ||
| 419 | 399 | ||||
| @@ -580,17 +560,11 @@ func TestMapConfigAction_VimScrollActions(t *testing.T) { | |||||
| 580 | config string | 560 | config string | ||
| 581 | expected Action | 561 | expected Action | ||
| 582 | }{ | 562 | }{ | ||
| 583 | - | {name: "vim_scroll_left", config: "vim_scroll_left", expected: ActionVimScrollLeft}, | |||
| 584 | {name: "vim-scroll-left", config: "vim-scroll-left", expected: ActionVimScrollLeft}, | 563 | {name: "vim-scroll-left", config: "vim-scroll-left", expected: ActionVimScrollLeft}, | ||
| 585 | - | {name: "vim_scroll_down", config: "vim_scroll_down", expected: ActionVimScrollDown}, | |||
| 586 | {name: "vim-scroll-down", config: "vim-scroll-down", expected: ActionVimScrollDown}, | 564 | {name: "vim-scroll-down", config: "vim-scroll-down", expected: ActionVimScrollDown}, | ||
| 587 | - | {name: "vim_scroll_up", config: "vim_scroll_up", expected: ActionVimScrollUp}, | |||
| 588 | {name: "vim-scroll-up", config: "vim-scroll-up", expected: ActionVimScrollUp}, | 565 | {name: "vim-scroll-up", config: "vim-scroll-up", expected: ActionVimScrollUp}, | ||
| 589 | - | {name: "vim_scroll_right", config: "vim_scroll_right", expected: ActionVimScrollRight}, | |||
| 590 | {name: "vim-scroll-right", config: "vim-scroll-right", expected: ActionVimScrollRight}, | 566 | {name: "vim-scroll-right", config: "vim-scroll-right", expected: ActionVimScrollRight}, | ||
| 591 | - | {name: "vim_scroll_down_fast", config: "vim_scroll_down_fast", expected: ActionVimScrollDownFast}, | |||
| 592 | {name: "vim-scroll-down-fast", config: "vim-scroll-down-fast", expected: ActionVimScrollDownFast}, | 567 | {name: "vim-scroll-down-fast", config: "vim-scroll-down-fast", expected: ActionVimScrollDownFast}, | ||
| 593 | - | {name: "vim_scroll_up_fast", config: "vim_scroll_up_fast", expected: ActionVimScrollUpFast}, | |||
| 594 | {name: "vim-scroll-up-fast", config: "vim-scroll-up-fast", expected: ActionVimScrollUpFast}, | 568 | {name: "vim-scroll-up-fast", config: "vim-scroll-up-fast", expected: ActionVimScrollUpFast}, | ||
| 595 | } | 569 | } | ||
| 596 | 570 | ||||