detached af0e370

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 VerifiedCD88F9E564E0102646FB6077A1A07C7C7C3E349D · 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"
357357
358358 ### Action Maps
359359
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).
361361
362362 ### Pane Mode
363363
@@ -564,26 +564,26 @@ Global shortcuts are configured under `workspace.shortcuts.actions` using the sa
564564
565565 | Action | Default Keys | Description |
566566 |--------|--------------|-------------|
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 |
575575
576576 **Example:**
577577 ```toml
578-[workspace.shortcuts.actions.close_pane]
578+[workspace.shortcuts.actions.close-pane]
579579 keys = ["ctrl+w"]
580580 desc = "Close active pane"
581581
582-[workspace.shortcuts.actions.next_tab]
582+[workspace.shortcuts.actions.next-tab]
583583 keys = ["ctrl+tab", "alt+l"]
584584 desc = "Switch to next tab"
585585
586-[workspace.shortcuts.actions.toggle_floating_pane]
586+[workspace.shortcuts.actions.toggle-floating-pane]
587587 keys = ["alt+f"]
588588 desc = "Toggle floating pane"
589589 ```
@@ -601,7 +601,7 @@ Global shortcuts are configured under `workspace.shortcuts.actions` using the sa
601601
602602 The floating workspace pane is a persistent overlay session: hiding it does not destroy its web content.
603603
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`).
605605 - URL shortcuts (for example `Alt+G`) are user-defined via `workspace.floating_pane.profiles` and are empty by default.
606606 - `Alt+F` toggles visibility only and preserves floating state.
607607 - `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
616616 **Example:**
617617
618618 ```toml
619-[workspace.shortcuts.actions.toggle_floating_pane]
619+[workspace.shortcuts.actions.toggle-floating-pane]
620620 keys = ["alt+f"]
621621 desc = "Toggle floating pane"
622622
@@ -357,7 +357,7 @@ new_pane_url = "dumb://history"
357 357
358 ### Action Maps358 ### 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 Mode362 ### 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 ```toml577 ```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 ```toml618 ```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 @@
119119 | `workspace.resize_mode.step_percent` | float | `5.0` | |
120120 | `workspace.resize_mode.min_pane_percent` | float | `10.0` | |
121121 | `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` | |
124124 | `workspace.floating_pane.width_pct` | float | `0.82` | `(0,1]` |
125125 | `workspace.floating_pane.height_pct` | float | `0.72` | `(0,1]` |
126126 | `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
3535 ## Example
3636
3737 ```toml
38-[workspace.shortcuts.actions.toggle_floating_pane]
38+[workspace.shortcuts.actions.toggle-floating-pane]
3939 keys = ["alt+f"]
4040 desc = "Toggle floating pane"
4141
@@ -35,7 +35,7 @@ OAuth flows and scripts that request popup dimensions or restricted browser chro
35 ## Example35 ## Example
36 36
37 ```toml37 ```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
22
33 // BuiltinGlobalShortcuts returns the standard browser shortcuts shared by every
44 // 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.
78 func BuiltinGlobalShortcuts() map[string]ActionBinding {
89 return map[string]ActionBinding{
910 "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 every3 // BuiltinGlobalShortcuts returns the standard browser shortcuts shared by every
4 // keymap preset. Config defaults include them so users can rebind or disable4 // 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 an5+// 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 (
44 "fmt"
55 "slices"
66 "sort"
7- "strings"
87
98 "github.com/bnema/dumber/internal/logging"
109 )
@@ -18,7 +17,7 @@ type actionMapSpec struct {
1817 // actions returns a pointer to the action map inside a Config.
1918 actions func(*Config) *map[string]ActionBinding
2019 // 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).
2221 canonKey func(string) (string, error)
2322 }
2423
@@ -38,11 +37,6 @@ func actionMapSpecs() []actionMapSpec {
3837 }
3938 }
4039
41-// legacyActionName returns the underscore spelling used by older configs.
42-func legacyActionName(name string) string {
43- return strings.ReplaceAll(name, "-", "_")
44-}
45-
4640 // skippedDefaultAction is a default action not added because a user binding wins.
4741 type skippedDefaultAction struct {
4842 name string
@@ -51,17 +45,18 @@ type skippedDefaultAction struct {
5145 }
5246
5347 // 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.
5853 func (s actionMapSpec) missingDefaultActions(
5954 defaults map[string]ActionBinding,
6055 userKeys map[string][]string,
6156 ) (missing []string, skipped []skippedDefaultAction) {
6257 canon := s.canonKey
6358 if canon == nil {
64- canon = func(key string) (string, error) { return key, nil }
59+ canon = canonicalizeChordKey
6560 }
6661 canonOrRaw := func(key string) string {
6762 if c, err := canon(key); err == nil {
@@ -92,9 +87,6 @@ func (s actionMapSpec) missingDefaultActions(
9287 if _, ok := userKeys[name]; ok {
9388 continue
9489 }
95- if _, ok := userKeys[legacyActionName(name)]; ok {
96- continue
97- }
9890 conflict := false
9991 for _, key := range defaults[name].Keys {
10092 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]ActionBinding18 actions func(*Config) *map[string]ActionBinding
20 // canonKey canonicalizes a key sequence the way the validator does for19 // 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 string42 name string
@@ -51,17 +45,18 @@ type skippedDefaultAction struct {
51 }45 }
52 46
53 // missingDefaultActions returns the sorted names of default actions that the47 // missingDefaultActions returns the sorted names of default actions that the
54-// user did not configure (by canonical or legacy underscore name) and whose key48+// 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 user49+// 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.canonKey57 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 continue88 continue
94 }89 }
95- if _, ok := userKeys[legacyActionName(name)]; ok {
96- continue
97- }
98 conflict := false90 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.
122122 require.NoError(t, validateConfig(cfg))
123123 }
124124
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-
134125 func TestCompleteDefaultActions_DoesNotMutateSharedMaps(t *testing.T) {
135126 cfg := DefaultConfig()
136127 original := map[string]ActionBinding{"custom": {Keys: []string{"ctrl+alt+f12"}}}
@@ -261,3 +252,150 @@ keys = []
261252 assert.Empty(t, stack.Keys)
262253 assert.Len(t, got.Workspace.VimMode.Actions, len(DefaultConfig().Workspace.VimMode.Actions))
263254 }
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 @@
11 package config
22
3-import "math"
3+import (
4+ "math"
5+ "slices"
6+ "strings"
7+
8+ "github.com/bnema/dumber/internal/logging"
9+)
410
511 const (
612 sectionWorkspace = "workspace"
@@ -18,8 +24,29 @@ func NewLegacyConfigTransformer() *LegacyConfigTransformer {
1824 return &LegacyConfigTransformer{defaults: DefaultConfig()}
1925 }
2026
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:
2350 //
2451 // [workspace.pane_mode.actions]
2552 // "focus-left" = ["h", "arrowleft"]
@@ -30,20 +57,72 @@ func NewLegacyConfigTransformer() *LegacyConfigTransformer {
3057 // keys = ["h", "arrowleft"]
3158 // desc = "Focus pane to the left"
3259 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+ }
4064 }
65+ for _, path := range legacyActionSlicePaths {
66+ if actions, ok := rawActionSection(rawConfig, path); ok {
67+ t.transformActionSection(actions, path)
68+ }
69+ }
70+}
4171
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")
44108 }
45109 }
46110
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+
47126 // TransformLegacyEngineConfig removes deprecated engine config keys.
48127 func (t *LegacyConfigTransformer) TransformLegacyEngineConfig(rawConfig map[string]any) {
49128 adaptiveEnabled, hasAdaptive := nestedConfigBoolValue(rawConfig, "engine", "cef", "adaptive_windowless_frame_rate")
@@ -217,29 +296,9 @@ func int64ConfigValue(value any) (int64, bool) {
217296 }
218297 }
219298
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) {
243302 for actionName, actionValue := range actions {
244303 // If value is a slice (old format), convert to ActionBinding format
245304 if slice, ok := actionValue.([]any); ok {
@@ -1,6 +1,12 @@
1 package config1 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 section300+// map to the ActionBinding format.
222- current := rawConfig301+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 format303 // 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) {
8989
9090 // Check shortcuts
9191 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")
9495 assert.Equal(t, []string{"ctrl+w"}, closePane["keys"])
9596
9697 // Check session mode
@@ -404,3 +405,72 @@ func TestTransformLegacyActions_UnknownAction(t *testing.T) {
404405 assert.Equal(t, []string{"x"}, binding["keys"])
405406 assert.Empty(t, binding["desc"], "unknown action should have empty description")
406407 }
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 shortcuts90 // 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 mode97 // 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 (
99 "sort"
1010 "strings"
1111
12+ "github.com/bnema/dumber/internal/domain/keychord"
1213 domainurl "github.com/bnema/dumber/internal/domain/url"
1314 domainvalidation "github.com/bnema/dumber/internal/domain/validation"
1415 "github.com/bnema/dumber/internal/domain/vimkeys"
@@ -299,8 +300,12 @@ func validateModalModeActionsWithKeyCanon(
299300 validationErrors = append(validationErrors, fmt.Sprintf("workspace.%s.actions cannot be empty", modePath))
300301 }
301302
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
302307 if keyCanon == nil {
303- keyCanon = func(key string) (string, error) { return key, nil }
308+ keyCanon = canonicalizeChordKey
304309 }
305310
306311 actionNames := make([]string, 0, len(actions))
@@ -328,32 +333,10 @@ func validateModalModeActionsWithKeyCanon(
328333 continue
329334 }
330335 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)...)
355337 }
356- if existingAction, exists := seenSequence[canonical]; exists {
338+ existingAction, exists := seenSequence[canonical]
339+ if exists && (!allowSameActionAliases || existingAction != action) {
357340 validationErrors = append(validationErrors, fmt.Sprintf(
358341 "duplicate key binding '%s' found in %s actions '%s' and '%s'",
359342 canonical,
@@ -368,6 +351,50 @@ func validateModalModeActionsWithKeyCanon(
368351 return validationErrors
369352 }
370353
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+
371398 func isVimCountStartKey(key vimkeys.Key) bool {
372399 return key.Mods == 0 && len(key.Sym) == 1 && key.Sym[0] >= '1' && key.Sym[0] <= '9'
373400 }
@@ -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 continue333 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 validationErrors351 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 {
1616 return &entity.WorkspaceConfig{
1717 Shortcuts: entity.GlobalShortcutsConfig{
1818 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"}},
2223 },
2324 },
2425 }
@@ -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 {
504504
505505 func globalShortcutActionMap() map[string]Action {
506506 return map[string]Action{
507- "toggle_floating_pane": ActionToggleFloatingPane,
508507 "toggle-floating-pane": ActionToggleFloatingPane,
509- "consume_or_expel_left": ActionConsumeOrExpelLeft,
510508 "consume-or-expel-left": ActionConsumeOrExpelLeft,
511- "consume_or_expel_right": ActionConsumeOrExpelRight,
512509 "consume-or-expel-right": ActionConsumeOrExpelRight,
513- "consume_or_expel_up": ActionConsumeOrExpelUp,
514510 "consume-or-expel-up": ActionConsumeOrExpelUp,
515- "consume_or_expel_down": ActionConsumeOrExpelDown,
516511 "consume-or-expel-down": ActionConsumeOrExpelDown,
517- "toggle_history_systemview": ActionToggleHistorySystemView,
518512 "toggle-history-systemview": ActionToggleHistorySystemView,
519- "toggle_favorites_sidebar": ActionToggleFavoritesSidebar,
520513 "toggle-favorites-sidebar": ActionToggleFavoritesSidebar,
521- "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
522514 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
523- "toggle_config_systemview": ActionToggleConfigSystemView,
524515 "toggle-config-systemview": ActionToggleConfigSystemView,
525516 }
526517 }
@@ -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 (
1616 // Used by tests that construct a KeyboardHandler.
1717 func newTestWorkspace() *entity.WorkspaceConfig {
1818 return &entity.WorkspaceConfig{
19+ Shortcuts: entity.GlobalShortcutsConfig{Actions: entity.BuiltinGlobalShortcuts()},
1920 TabMode: entity.TabModeConfig{ActivationShortcut: "ctrl+t"},
2021 PaneMode: entity.PaneModeConfig{ActivationShortcut: "ctrl+p"},
2122 ResizeMode: entity.ResizeModeConfig{ActivationShortcut: "ctrl+alt+r"},
@@ -331,7 +332,7 @@ func TestHandleKeyPress_DoesNotExitResizeModeWhileActivationKeyIsHeld(t *testing
331332 func TestHandleKeyPress_DoesNotRetoggleFloatingPaneAfterUnrelatedRelease(t *testing.T) {
332333 workspace := newTestWorkspace()
333334 workspace.Shortcuts.Actions = map[string]entity.ActionBinding{
334- "toggle_floating_pane": {Keys: []string{"alt+f"}},
335+ "toggle-floating-pane": {Keys: []string{"alt+f"}},
335336 }
336337 h := NewKeyboardHandler(context.Background(), workspace, newTestSession())
337338 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 := 0338 calls := 0
M internal/ui/input/prefix_mode_test.go +4 -6
@@ -108,12 +108,10 @@ func TestHandleKeyPress_PrefixPressedTwiceExits(t *testing.T) {
108108 assert.Equal(t, ModeNormal, h.Mode())
109109 }
110110
111-func TestShortcutSet_BuiltinFallbacksAndOverrides(t *testing.T) {
111+func TestShortcutSet_BuiltinShortcutsAndOverrides(t *testing.T) {
112112 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
117115 set := NewShortcutSet(context.Background(), ws, newTestSession())
118116
119117 lookup := func(key string) (Action, bool) {
@@ -124,7 +122,7 @@ func TestShortcutSet_BuiltinFallbacksAndOverrides(t *testing.T) {
124122 }
125123
126124 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")
128126 assert.Equal(t, ActionOpenOmnibox, action)
129127
130128 _, 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 user114+ 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 (
99 "strings"
1010
1111 "github.com/bnema/dumber/internal/domain/entity"
12+ "github.com/bnema/dumber/internal/domain/keychord"
1213 "github.com/bnema/dumber/internal/domain/vimkeys"
1314 "github.com/bnema/dumber/internal/logging"
1415 "github.com/bnema/puregotk/v4/gdk"
@@ -31,67 +32,42 @@ const (
3132 // modifierMask filters out non-standard modifiers from GDK state.
3233 const modifierMask = ModCtrl | ModShift | ModAlt
3334
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.
3437 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),
8767 "bracketleft": uint(gdk.KEY_bracketleft),
8868 "bracketright": uint(gdk.KEY_bracketright),
89- "[": uint(gdk.KEY_bracketleft),
90- "]": uint(gdk.KEY_bracketright),
9169 "braceleft": uint(gdk.KEY_braceleft),
9270 "braceright": uint(gdk.KEY_braceright),
93- "{": uint(gdk.KEY_braceleft),
94- "}": uint(gdk.KEY_braceright),
9571 }
9672
9773 // KeyBinding represents a single key combination.
@@ -285,7 +261,6 @@ func NewShortcutSet(ctx context.Context, workspace *entity.WorkspaceConfig, sess
285261
286262 // buildGlobalShortcutsFromParts populates global shortcuts from workspace and session configs.
287263 func (s *ShortcutSet) buildGlobalShortcutsFromParts(ctx context.Context, workspace *entity.WorkspaceConfig, session *entity.SessionConfig) {
288- s.registerBuiltinShortcutFallbacks(workspace)
289264 s.registerActivationShortcutsFromParts(ctx, workspace, session)
290265 s.registerConfiguredShortcuts(workspace)
291266 // 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
376351 Msg("mode activation registered")
377352 }
378353
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-
427354 func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
428355 if cfg == nil {
429356 return
430357 }
431358 for _, actionName := range orderedGlobalShortcutActions(cfg.Shortcuts.Actions) {
432- action, ok := globalShortcutAction(actionName)
359+ action, ok := configActionToAction[actionName]
433360 if !ok {
434361 continue
435362 }
@@ -448,15 +375,8 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) {
448375 func orderedGlobalShortcutActions(actions map[string]entity.ActionBinding) []string {
449376 builtins := entity.BuiltinGlobalShortcuts()
450377 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
460380 }
461381 names := slices.Sorted(maps.Keys(actions))
462382 slices.SortStableFunc(names, func(a, b string) int {
@@ -609,57 +529,37 @@ func (s *ShortcutSet) buildModeShortcuts(ctx context.Context, bindings map[strin
609529 Msg(mode + " mode shortcuts built")
610530 }
611531
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.
612535 var configActionToAction = map[string]Action{
613- "toggle_floating_pane": ActionToggleFloatingPane,
614536 "toggle-floating-pane": ActionToggleFloatingPane,
615- "toggle_history_systemview": ActionToggleHistorySystemView,
616537 "toggle-history-systemview": ActionToggleHistorySystemView,
617- "toggle_favorites_sidebar": ActionToggleFavoritesSidebar,
618538 "toggle-favorites-sidebar": ActionToggleFavoritesSidebar,
619- "toggle_current_page_favorite": ActionToggleCurrentPageFavorite,
620539 "toggle-current-page-favorite": ActionToggleCurrentPageFavorite,
621- "toggle_config_systemview": ActionToggleConfigSystemView,
622540 "toggle-config-systemview": ActionToggleConfigSystemView,
623541
624542 // Tab actions
625- "new_tab": ActionNewTab,
626543 "new-tab": ActionNewTab,
627- "close_tab": ActionCloseTab,
628544 "close-tab": ActionCloseTab,
629- "next_tab": ActionNextTab,
630545 "next-tab": ActionNextTab,
631- "previous_tab": ActionPreviousTab,
632546 "previous-tab": ActionPreviousTab,
633- "rename_tab": ActionRenameTab,
634547 "rename-tab": ActionRenameTab,
635548
636549 // Pane actions
637- "split_right": ActionSplitRight,
638550 "split-right": ActionSplitRight,
639- "split_left": ActionSplitLeft,
640551 "split-left": ActionSplitLeft,
641- "split_up": ActionSplitUp,
642552 "split-up": ActionSplitUp,
643- "split_down": ActionSplitDown,
644553 "split-down": ActionSplitDown,
645- "close_pane": ActionClosePane,
646554 "close-pane": ActionClosePane,
647- "stack_pane": ActionStackPane,
648555 "stack-pane": ActionStackPane,
649- "move_pane_to_tab": ActionMovePaneToTab,
650556 "move-pane-to-tab": ActionMovePaneToTab,
651- "move_pane_to_next_tab": ActionMovePaneToNextTab,
652557 "move-pane-to-next-tab": ActionMovePaneToNextTab,
653- "eject_pane_to_window": ActionEjectPaneToWindow,
654558 "eject-pane-to-window": ActionEjectPaneToWindow,
655559
656- "consume_or_expel_left": ActionConsumeOrExpelLeft,
657560 "consume-or-expel-left": ActionConsumeOrExpelLeft,
658- "consume_or_expel_right": ActionConsumeOrExpelRight,
659561 "consume-or-expel-right": ActionConsumeOrExpelRight,
660- "consume_or_expel_up": ActionConsumeOrExpelUp,
661562 "consume-or-expel-up": ActionConsumeOrExpelUp,
662- "consume_or_expel_down": ActionConsumeOrExpelDown,
663563 "consume-or-expel-down": ActionConsumeOrExpelDown,
664564
665565 // Focus navigation
@@ -715,17 +615,11 @@ var configActionToAction = map[string]Action{
715615 "enter-vim-mode": ActionEnterVimMode,
716616
717617 // Vim mode scroll actions
718- "vim_scroll_left": ActionVimScrollLeft,
719618 "vim-scroll-left": ActionVimScrollLeft,
720- "vim_scroll_down": ActionVimScrollDown,
721619 "vim-scroll-down": ActionVimScrollDown,
722- "vim_scroll_up": ActionVimScrollUp,
723620 "vim-scroll-up": ActionVimScrollUp,
724- "vim_scroll_right": ActionVimScrollRight,
725621 "vim-scroll-right": ActionVimScrollRight,
726- "vim_scroll_down_fast": ActionVimScrollDownFast,
727622 "vim-scroll-down-fast": ActionVimScrollDownFast,
728- "vim_scroll_up_fast": ActionVimScrollUpFast,
729623 "vim-scroll-up-fast": ActionVimScrollUpFast,
730624 }
731625
@@ -796,87 +690,42 @@ func mapConfigAction(configAction string) Action {
796690 }
797691
798692 // 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.
799695 // Returns false if the string cannot be parsed.
800696 func ParseKeyString(s string) (KeyBinding, bool) {
801- if s == "" {
697+ chord, ok := keychord.Parse(s)
698+ if !ok {
802699 return KeyBinding{}, false
803700 }
804-
805- s = strings.TrimSpace(s)
806- if s == "" {
701+ keyval, ok := keychordKeyval(chord.Key)
702+ if !ok {
807703 return KeyBinding{}, false
808704 }
809- if s == "+" {
810- return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true
811- }
812-
813- parts := strings.Split(s, "+")
814705
815706 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
838709 }
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
847712 }
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 {
851714 modifiers |= ModShift
852- keyPart = strings.ToLower(keyPart)
853- }
854-
855- keyval, ok := stringToKeyval(keyPart)
856- if !ok {
857- return KeyBinding{}, false
858715 }
859-
860- return KeyBinding{
861- Keyval: keyval,
862- Modifiers: modifiers,
863- }, true
716+ return KeyBinding{Keyval: keyval, Modifiers: modifiers}, true
864717 }
865718
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 {
873722 return keyval, true
874723 }
875724
876- // Single printable ASCII keys (a-z, symbols such as %, ", &).
725+ // Single printable ASCII keys (a-z, 0-9, symbols such as %, ", &).
877726 // 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
880729 }
881730
882731 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 | ModAlt33 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 return356 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 continue361 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 true379+ 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 actions542 // 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 actions549 // 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 navigation565 // 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 actions617 // 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{}, false699 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{}, false703 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 Modifier706 var modifiers Modifier
816- var keyPart string707+ 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 |= ModShift714 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, true722 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]), true728+ return uint(name[0]), true
880 }729 }
881 730
882 return 0, false731 return 0, false
M internal/ui/input/shortcuts_test.go +3 -29
@@ -380,7 +380,7 @@ func TestShouldAutoExitMode(t *testing.T) {
380380 }
381381
382382 func TestMapConfigAction_EjectPaneToWindowAliases(t *testing.T) {
383- tests := []string{"eject_pane_to_window", "eject-pane-to-window"}
383+ tests := []string{"eject-pane-to-window"}
384384 for _, name := range tests {
385385 t.Run(name, func(t *testing.T) {
386386 if got := mapConfigAction(name); got != ActionEjectPaneToWindow {
@@ -391,29 +391,9 @@ func TestMapConfigAction_EjectPaneToWindowAliases(t *testing.T) {
391391 }
392392
393393 func TestMapConfigAction_ToggleFloatingPane(t *testing.T) {
394- action := mapConfigAction("toggle_floating_pane")
394+ action := mapConfigAction("toggle-floating-pane")
395395 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)
417397 }
418398 }
419399
@@ -580,17 +560,11 @@ func TestMapConfigAction_VimScrollActions(t *testing.T) {
580560 config string
581561 expected Action
582562 }{
583- {name: "vim_scroll_left", config: "vim_scroll_left", expected: ActionVimScrollLeft},
584563 {name: "vim-scroll-left", config: "vim-scroll-left", expected: ActionVimScrollLeft},
585- {name: "vim_scroll_down", config: "vim_scroll_down", expected: ActionVimScrollDown},
586564 {name: "vim-scroll-down", config: "vim-scroll-down", expected: ActionVimScrollDown},
587- {name: "vim_scroll_up", config: "vim_scroll_up", expected: ActionVimScrollUp},
588565 {name: "vim-scroll-up", config: "vim-scroll-up", expected: ActionVimScrollUp},
589- {name: "vim_scroll_right", config: "vim_scroll_right", expected: ActionVimScrollRight},
590566 {name: "vim-scroll-right", config: "vim-scroll-right", expected: ActionVimScrollRight},
591- {name: "vim_scroll_down_fast", config: "vim_scroll_down_fast", expected: ActionVimScrollDownFast},
592567 {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},
594568 {name: "vim-scroll-up-fast", config: "vim-scroll-up-fast", expected: ActionVimScrollUpFast},
595569 }
596570
@@ -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 string560 config string
581 expected Action561 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

Keyboard shortcuts

Single-key shortcuts are on. ctrl k and esc work whatever this says.

ctrl k :
go to a section, branch, tag, file or repository
?
this list
j k
move the row cursor down / up
enter
open the row under the cursor; on Files, a directory opens or closes in place
↑ ↓ ← → home end
in the file tree (focus on a row): previous / next row, close / open a directory or go to its parent / first entry, first / last row. They never open a file
l h
on Files: open a directory or a file / close a directory or go up to its parent, then to the parent page. Elsewhere: into the side panel (on a merge request, its files) / back to the content; there j k move between its controls
g g G
first / last row
/
filter the list; on Files, find a file anywhere in the repository (esc clears, enter opens the best match)
b
switch branch or tag
g s g f g c g b g m g i g r g a
tabs: summary, files, commits, branches & tags, merge requests, issues, releases, actions (when available)
g h
home
w
wide mode: use the whole width of the screen, or not (remembered)
y
copy a permalink pinned to the commit
d u x
diff: split / unified / collapse or expand all
e E
diff: the whole file in view / every file, with the lines the hunks hide
n
merge request: next note in the diff
esc
close a dialog, leave a field

Branches 8

Tags 42

↑↓ moveenter openesc close