detached 4f688ff

feat(page-mode): add configurable vim sequences

commit
4f688ff6566b746b4a9bbf7c120f65e25e3c31e9
author
brice · · 2026-07-29 04:36 UTC
committer
bnema · · 2026-08-14 02:42 UTC
parent
5ea141f
signature
unsigned
9 files · +744 -5
M internal/domain/vimkeys/key.go +4 -2
@@ -78,8 +78,10 @@ func angleAliasForSym(sym string) string {
7878 return "Return"
7979 case "Esc":
8080 return "Escape"
81- case "Space":
82- return "Space"
81+ case "Space", "Tab", "BackSpace", "Delete",
82+ "Left", "Right", "Up", "Down",
83+ "Home", "End", "PageUp", "PageDown":
84+ return sym
8385 case "<":
8486 return "lt"
8587 default:
@@ -78,8 +78,10 @@ func angleAliasForSym(sym string) string {
78 return "Return"78 return "Return"
79 case "Esc":79 case "Esc":
80 return "Escape"80 return "Escape"
81- case "Space":81+ case "Space", "Tab", "BackSpace", "Delete",
82- return "Space"82+ "Left", "Right", "Up", "Down",
83+ "Home", "End", "PageUp", "PageDown":
84+ return sym
83 case "<":85 case "<":
84 return "lt"86 return "lt"
85 default:87 default:
M internal/domain/vimkeys/key_test.go +15 -0
@@ -18,6 +18,21 @@ func TestKeyString(t *testing.T) {
1818 {name: "special CR", key: Key{Sym: "CR"}, want: "<Return>"},
1919 {name: "special Esc", key: Key{Sym: "Esc"}, want: "<Escape>"},
2020 {name: "special Space", key: Key{Sym: "Space"}, want: "<Space>"},
21+ {name: "special Tab", key: Key{Sym: "Tab"}, want: "<Tab>"},
22+ {name: "special BackSpace", key: Key{Sym: "BackSpace"}, want: "<BackSpace>"},
23+ {name: "special Delete", key: Key{Sym: "Delete"}, want: "<Delete>"},
24+ {name: "special Left", key: Key{Sym: "Left"}, want: "<Left>"},
25+ {name: "special Right", key: Key{Sym: "Right"}, want: "<Right>"},
26+ {name: "special Up", key: Key{Sym: "Up"}, want: "<Up>"},
27+ {name: "special Down", key: Key{Sym: "Down"}, want: "<Down>"},
28+ {name: "special Home", key: Key{Sym: "Home"}, want: "<Home>"},
29+ {name: "special End", key: Key{Sym: "End"}, want: "<End>"},
30+ {name: "special PageUp", key: Key{Sym: "PageUp"}, want: "<PageUp>"},
31+ {name: "special PageDown", key: Key{Sym: "PageDown"}, want: "<PageDown>"},
32+ {name: "ctrl Tab", key: Key{Sym: "Tab", Mods: ModCtrl}, want: "<C-Tab>"},
33+ {name: "shift BackSpace", key: Key{Sym: "BackSpace", Mods: ModShift}, want: "<S-BackSpace>"},
34+ {name: "alt Delete", key: Key{Sym: "Delete", Mods: ModAlt}, want: "<A-Delete>"},
35+ {name: "ctrl Left", key: Key{Sym: "Left", Mods: ModCtrl}, want: "<C-Left>"},
2136 {name: "literal lt", key: Key{Sym: "<"}, want: "<lt>"},
2237 {name: "alt only", key: Key{Sym: "b", Mods: ModAlt}, want: "<A-b>"},
2338 {name: "ctrl alt", key: Key{Sym: "c", Mods: ModCtrl | ModAlt}, want: "<C-A-c>"},
@@ -18,6 +18,21 @@ func TestKeyString(t *testing.T) {
18 {name: "special CR", key: Key{Sym: "CR"}, want: "<Return>"},18 {name: "special CR", key: Key{Sym: "CR"}, want: "<Return>"},
19 {name: "special Esc", key: Key{Sym: "Esc"}, want: "<Escape>"},19 {name: "special Esc", key: Key{Sym: "Esc"}, want: "<Escape>"},
20 {name: "special Space", key: Key{Sym: "Space"}, want: "<Space>"},20 {name: "special Space", key: Key{Sym: "Space"}, want: "<Space>"},
21+ {name: "special Tab", key: Key{Sym: "Tab"}, want: "<Tab>"},
22+ {name: "special BackSpace", key: Key{Sym: "BackSpace"}, want: "<BackSpace>"},
23+ {name: "special Delete", key: Key{Sym: "Delete"}, want: "<Delete>"},
24+ {name: "special Left", key: Key{Sym: "Left"}, want: "<Left>"},
25+ {name: "special Right", key: Key{Sym: "Right"}, want: "<Right>"},
26+ {name: "special Up", key: Key{Sym: "Up"}, want: "<Up>"},
27+ {name: "special Down", key: Key{Sym: "Down"}, want: "<Down>"},
28+ {name: "special Home", key: Key{Sym: "Home"}, want: "<Home>"},
29+ {name: "special End", key: Key{Sym: "End"}, want: "<End>"},
30+ {name: "special PageUp", key: Key{Sym: "PageUp"}, want: "<PageUp>"},
31+ {name: "special PageDown", key: Key{Sym: "PageDown"}, want: "<PageDown>"},
32+ {name: "ctrl Tab", key: Key{Sym: "Tab", Mods: ModCtrl}, want: "<C-Tab>"},
33+ {name: "shift BackSpace", key: Key{Sym: "BackSpace", Mods: ModShift}, want: "<S-BackSpace>"},
34+ {name: "alt Delete", key: Key{Sym: "Delete", Mods: ModAlt}, want: "<A-Delete>"},
35+ {name: "ctrl Left", key: Key{Sym: "Left", Mods: ModCtrl}, want: "<C-Left>"},
21 {name: "literal lt", key: Key{Sym: "<"}, want: "<lt>"},36 {name: "literal lt", key: Key{Sym: "<"}, want: "<lt>"},
22 {name: "alt only", key: Key{Sym: "b", Mods: ModAlt}, want: "<A-b>"},37 {name: "alt only", key: Key{Sym: "b", Mods: ModAlt}, want: "<A-b>"},
23 {name: "ctrl alt", key: Key{Sym: "c", Mods: ModCtrl | ModAlt}, want: "<C-A-c>"},38 {name: "ctrl alt", key: Key{Sym: "c", Mods: ModCtrl | ModAlt}, want: "<C-A-c>"},
M internal/domain/vimkeys/parse.go +81 -1
@@ -12,6 +12,10 @@ func ParseBinding(s string) (Sequence, error) {
1212 return nil, ErrEmptyBinding
1313 }
1414
15+ if key, ok := tryParseLegacyAtom(s); ok {
16+ return Sequence{key}, nil
17+ }
18+
1519 if key, ok, err := tryParseAsLegacyChord(s); ok {
1620 if err != nil {
1721 return nil, err
@@ -21,6 +25,19 @@ func ParseBinding(s string) (Sequence, error) {
2125 return parseRawSequence(s)
2226 }
2327
28+// tryParseLegacyAtom treats raw UI key names enter/escape (and aliases) as single
29+// keys so they remain compatible with the GTK shortcut table contract.
30+func tryParseLegacyAtom(s string) (Key, bool) {
31+ switch strings.ToLower(s) {
32+ case "enter", "return":
33+ return Key{Sym: "CR"}, true
34+ case "escape", "esc":
35+ return Key{Sym: "Esc"}, true
36+ default:
37+ return Key{}, false
38+ }
39+}
40+
2441 func legacyChordParts(s string) []string {
2542 parts := strings.Split(s, "+")
2643 tokens := make([]string, 0, len(parts))
@@ -259,6 +276,9 @@ func tryParseSpecialAt(s string, i int) (Key, int, bool) {
259276 return Key{}, 0, false
260277 }
261278 rest := s[i:]
279+ if key, width, ok := tryParseNamedSpecialAt(rest); ok {
280+ return key, width, true
281+ }
262282 switch {
263283 case strings.HasPrefix(rest, "Space"):
264284 return Key{Sym: "Space"}, len("Space"), true
@@ -275,6 +295,36 @@ func tryParseSpecialAt(s string, i int) (Key, int, bool) {
275295 }
276296 }
277297
298+// tryParseNamedSpecialAt matches longer canonical named keys before letter runs.
299+func tryParseNamedSpecialAt(rest string) (Key, int, bool) {
300+ switch {
301+ case strings.HasPrefix(rest, "BackSpace"):
302+ return Key{Sym: "BackSpace"}, len("BackSpace"), true
303+ case strings.HasPrefix(rest, "PageDown"):
304+ return Key{Sym: "PageDown"}, len("PageDown"), true
305+ case strings.HasPrefix(rest, "PageUp"):
306+ return Key{Sym: "PageUp"}, len("PageUp"), true
307+ case strings.HasPrefix(rest, "Delete"):
308+ return Key{Sym: "Delete"}, len("Delete"), true
309+ case strings.HasPrefix(rest, "Right"):
310+ return Key{Sym: "Right"}, len("Right"), true
311+ case strings.HasPrefix(rest, "Left"):
312+ return Key{Sym: "Left"}, len("Left"), true
313+ case strings.HasPrefix(rest, "Home"):
314+ return Key{Sym: "Home"}, len("Home"), true
315+ case strings.HasPrefix(rest, "Down"):
316+ return Key{Sym: "Down"}, len("Down"), true
317+ case strings.HasPrefix(rest, "Tab"):
318+ return Key{Sym: "Tab"}, len("Tab"), true
319+ case strings.HasPrefix(rest, "End"):
320+ return Key{Sym: "End"}, len("End"), true
321+ case strings.HasPrefix(rest, "Up"):
322+ return Key{Sym: "Up"}, len("Up"), true
323+ default:
324+ return Key{}, 0, false
325+ }
326+}
327+
278328 func tryParseCanonicalAt(s string, i int) (Key, int, bool) {
279329 if i >= len(s) {
280330 return Key{}, 0, false
@@ -362,7 +412,8 @@ func shiftedGlyphForRune(r rune) (string, bool) {
362412 }
363413
364414 func symFromSpecialAlias(token string) (string, bool) {
365- switch strings.ToLower(token) {
415+ lower := strings.ToLower(token)
416+ switch lower {
366417 case "cr", "return":
367418 return "CR", true
368419 case "esc", "escape":
@@ -373,6 +424,35 @@ func symFromSpecialAlias(token string) (string, bool) {
373424 return "<", true
374425 case "plus":
375426 return "+", true
427+ default:
428+ return namedSymFromAlias(lower)
429+ }
430+}
431+
432+func namedSymFromAlias(lower string) (string, bool) {
433+ switch lower {
434+ case "tab":
435+ return "Tab", true
436+ case "backspace":
437+ return "BackSpace", true
438+ case "delete":
439+ return "Delete", true
440+ case "left":
441+ return "Left", true
442+ case "right":
443+ return "Right", true
444+ case "up":
445+ return "Up", true
446+ case "down":
447+ return "Down", true
448+ case "home":
449+ return "Home", true
450+ case "end":
451+ return "End", true
452+ case "pageup":
453+ return "PageUp", true
454+ case "pagedown":
455+ return "PageDown", true
376456 default:
377457 return "", false
378458 }
@@ -12,6 +12,10 @@ func ParseBinding(s string) (Sequence, error) {
12 return nil, ErrEmptyBinding12 return nil, ErrEmptyBinding
13 }13 }
14 14
15+ if key, ok := tryParseLegacyAtom(s); ok {
16+ return Sequence{key}, nil
17+ }
18+
15 if key, ok, err := tryParseAsLegacyChord(s); ok {19 if key, ok, err := tryParseAsLegacyChord(s); ok {
16 if err != nil {20 if err != nil {
17 return nil, err21 return nil, err
@@ -21,6 +25,19 @@ func ParseBinding(s string) (Sequence, error) {
21 return parseRawSequence(s)25 return parseRawSequence(s)
22 }26 }
23 27
28+// tryParseLegacyAtom treats raw UI key names enter/escape (and aliases) as single
29+// keys so they remain compatible with the GTK shortcut table contract.
30+func tryParseLegacyAtom(s string) (Key, bool) {
31+ switch strings.ToLower(s) {
32+ case "enter", "return":
33+ return Key{Sym: "CR"}, true
34+ case "escape", "esc":
35+ return Key{Sym: "Esc"}, true
36+ default:
37+ return Key{}, false
38+ }
39+}
40+
24 func legacyChordParts(s string) []string {41 func legacyChordParts(s string) []string {
25 parts := strings.Split(s, "+")42 parts := strings.Split(s, "+")
26 tokens := make([]string, 0, len(parts))43 tokens := make([]string, 0, len(parts))
@@ -259,6 +276,9 @@ func tryParseSpecialAt(s string, i int) (Key, int, bool) {
259 return Key{}, 0, false276 return Key{}, 0, false
260 }277 }
261 rest := s[i:]278 rest := s[i:]
279+ if key, width, ok := tryParseNamedSpecialAt(rest); ok {
280+ return key, width, true
281+ }
262 switch {282 switch {
263 case strings.HasPrefix(rest, "Space"):283 case strings.HasPrefix(rest, "Space"):
264 return Key{Sym: "Space"}, len("Space"), true284 return Key{Sym: "Space"}, len("Space"), true
@@ -275,6 +295,36 @@ func tryParseSpecialAt(s string, i int) (Key, int, bool) {
275 }295 }
276 }296 }
277 297
298+// tryParseNamedSpecialAt matches longer canonical named keys before letter runs.
299+func tryParseNamedSpecialAt(rest string) (Key, int, bool) {
300+ switch {
301+ case strings.HasPrefix(rest, "BackSpace"):
302+ return Key{Sym: "BackSpace"}, len("BackSpace"), true
303+ case strings.HasPrefix(rest, "PageDown"):
304+ return Key{Sym: "PageDown"}, len("PageDown"), true
305+ case strings.HasPrefix(rest, "PageUp"):
306+ return Key{Sym: "PageUp"}, len("PageUp"), true
307+ case strings.HasPrefix(rest, "Delete"):
308+ return Key{Sym: "Delete"}, len("Delete"), true
309+ case strings.HasPrefix(rest, "Right"):
310+ return Key{Sym: "Right"}, len("Right"), true
311+ case strings.HasPrefix(rest, "Left"):
312+ return Key{Sym: "Left"}, len("Left"), true
313+ case strings.HasPrefix(rest, "Home"):
314+ return Key{Sym: "Home"}, len("Home"), true
315+ case strings.HasPrefix(rest, "Down"):
316+ return Key{Sym: "Down"}, len("Down"), true
317+ case strings.HasPrefix(rest, "Tab"):
318+ return Key{Sym: "Tab"}, len("Tab"), true
319+ case strings.HasPrefix(rest, "End"):
320+ return Key{Sym: "End"}, len("End"), true
321+ case strings.HasPrefix(rest, "Up"):
322+ return Key{Sym: "Up"}, len("Up"), true
323+ default:
324+ return Key{}, 0, false
325+ }
326+}
327+
278 func tryParseCanonicalAt(s string, i int) (Key, int, bool) {328 func tryParseCanonicalAt(s string, i int) (Key, int, bool) {
279 if i >= len(s) {329 if i >= len(s) {
280 return Key{}, 0, false330 return Key{}, 0, false
@@ -362,7 +412,8 @@ func shiftedGlyphForRune(r rune) (string, bool) {
362 }412 }
363 413
364 func symFromSpecialAlias(token string) (string, bool) {414 func symFromSpecialAlias(token string) (string, bool) {
365- switch strings.ToLower(token) {415+ lower := strings.ToLower(token)
416+ switch lower {
366 case "cr", "return":417 case "cr", "return":
367 return "CR", true418 return "CR", true
368 case "esc", "escape":419 case "esc", "escape":
@@ -373,6 +424,35 @@ func symFromSpecialAlias(token string) (string, bool) {
373 return "<", true424 return "<", true
374 case "plus":425 case "plus":
375 return "+", true426 return "+", true
427+ default:
428+ return namedSymFromAlias(lower)
429+ }
430+}
431+
432+func namedSymFromAlias(lower string) (string, bool) {
433+ switch lower {
434+ case "tab":
435+ return "Tab", true
436+ case "backspace":
437+ return "BackSpace", true
438+ case "delete":
439+ return "Delete", true
440+ case "left":
441+ return "Left", true
442+ case "right":
443+ return "Right", true
444+ case "up":
445+ return "Up", true
446+ case "down":
447+ return "Down", true
448+ case "home":
449+ return "Home", true
450+ case "end":
451+ return "End", true
452+ case "pageup":
453+ return "PageUp", true
454+ case "pagedown":
455+ return "PageDown", true
376 default:456 default:
377 return "", false457 return "", false
378 }458 }
M internal/domain/vimkeys/parse_test.go +52 -0
@@ -19,8 +19,27 @@ func TestParseBinding_SingleKeys(t *testing.T) {
1919 {input: "<Esc>", want: Sequence{{Sym: "Esc"}}},
2020 {input: "<Escape>", want: Sequence{{Sym: "Esc"}}},
2121 {input: "<Space>", want: Sequence{{Sym: "Space"}}},
22+ {input: "<Tab>", want: Sequence{{Sym: "Tab"}}},
23+ {input: "<BackSpace>", want: Sequence{{Sym: "BackSpace"}}},
24+ {input: "<Delete>", want: Sequence{{Sym: "Delete"}}},
25+ {input: "<Left>", want: Sequence{{Sym: "Left"}}},
26+ {input: "<Right>", want: Sequence{{Sym: "Right"}}},
27+ {input: "<Up>", want: Sequence{{Sym: "Up"}}},
28+ {input: "<Down>", want: Sequence{{Sym: "Down"}}},
29+ {input: "<Home>", want: Sequence{{Sym: "Home"}}},
30+ {input: "<End>", want: Sequence{{Sym: "End"}}},
31+ {input: "<PageUp>", want: Sequence{{Sym: "PageUp"}}},
32+ {input: "<PageDown>", want: Sequence{{Sym: "PageDown"}}},
33+ {input: "<C-Tab>", want: Sequence{{Sym: "Tab", Mods: ModCtrl}}},
34+ {input: "<S-BackSpace>", want: Sequence{{Sym: "BackSpace", Mods: ModShift}}},
35+ {input: "<A-Delete>", want: Sequence{{Sym: "Delete", Mods: ModAlt}}},
36+ {input: "<C-Left>", want: Sequence{{Sym: "Left", Mods: ModCtrl}}},
2237 {input: "<lt>", want: Sequence{{Sym: "<"}}},
2338 {input: "<Plus>", want: Sequence{{Sym: "+"}}},
39+ {input: "enter", want: Sequence{{Sym: "CR"}}},
40+ {input: "escape", want: Sequence{{Sym: "Esc"}}},
41+ {input: "Enter", want: Sequence{{Sym: "CR"}}},
42+ {input: "Escape", want: Sequence{{Sym: "Esc"}}},
2443 }
2544 for _, tt := range tests {
2645 t.Run(tt.input, func(t *testing.T) {
@@ -97,6 +116,22 @@ func TestParseBinding_CanonicalString(t *testing.T) {
97116 {input: "<Esc>", want: "<Escape>"},
98117 {input: "<Escape>", want: "<Escape>"},
99118 {input: "<Space>", want: "<Space>"},
119+ {input: "<Tab>", want: "<Tab>"},
120+ {input: "<BackSpace>", want: "<BackSpace>"},
121+ {input: "<Delete>", want: "<Delete>"},
122+ {input: "<Left>", want: "<Left>"},
123+ {input: "<Right>", want: "<Right>"},
124+ {input: "<Up>", want: "<Up>"},
125+ {input: "<Down>", want: "<Down>"},
126+ {input: "<Home>", want: "<Home>"},
127+ {input: "<End>", want: "<End>"},
128+ {input: "<PageUp>", want: "<PageUp>"},
129+ {input: "<PageDown>", want: "<PageDown>"},
130+ {input: "<C-Tab>", want: "<C-Tab>"},
131+ {input: "<S-BackSpace>", want: "<S-BackSpace>"},
132+ {input: "<A-Delete>", want: "<A-Delete>"},
133+ {input: "<C-Home>", want: "<C-Home>"},
134+ {input: "<A-PageDown>", want: "<A-PageDown>"},
100135 {input: "<lt>", want: "<lt>"},
101136 {input: "gO", want: "gO"},
102137 }
@@ -300,6 +335,23 @@ func TestParseBinding_RoundTrip(t *testing.T) {
300335 "<CR>",
301336 "<Esc>",
302337 "<Space>",
338+ "<Tab>",
339+ "<BackSpace>",
340+ "<Delete>",
341+ "<Left>",
342+ "<Right>",
343+ "<Up>",
344+ "<Down>",
345+ "<Home>",
346+ "<End>",
347+ "<PageUp>",
348+ "<PageDown>",
349+ "<C-Tab>",
350+ "<S-BackSpace>",
351+ "<A-Delete>",
352+ "<C-Left>",
353+ "<S-Home>",
354+ "<A-PageUp>",
303355 "<lt>",
304356 "<C-d>j",
305357 "<Escape>j",
@@ -19,8 +19,27 @@ func TestParseBinding_SingleKeys(t *testing.T) {
19 {input: "<Esc>", want: Sequence{{Sym: "Esc"}}},19 {input: "<Esc>", want: Sequence{{Sym: "Esc"}}},
20 {input: "<Escape>", want: Sequence{{Sym: "Esc"}}},20 {input: "<Escape>", want: Sequence{{Sym: "Esc"}}},
21 {input: "<Space>", want: Sequence{{Sym: "Space"}}},21 {input: "<Space>", want: Sequence{{Sym: "Space"}}},
22+ {input: "<Tab>", want: Sequence{{Sym: "Tab"}}},
23+ {input: "<BackSpace>", want: Sequence{{Sym: "BackSpace"}}},
24+ {input: "<Delete>", want: Sequence{{Sym: "Delete"}}},
25+ {input: "<Left>", want: Sequence{{Sym: "Left"}}},
26+ {input: "<Right>", want: Sequence{{Sym: "Right"}}},
27+ {input: "<Up>", want: Sequence{{Sym: "Up"}}},
28+ {input: "<Down>", want: Sequence{{Sym: "Down"}}},
29+ {input: "<Home>", want: Sequence{{Sym: "Home"}}},
30+ {input: "<End>", want: Sequence{{Sym: "End"}}},
31+ {input: "<PageUp>", want: Sequence{{Sym: "PageUp"}}},
32+ {input: "<PageDown>", want: Sequence{{Sym: "PageDown"}}},
33+ {input: "<C-Tab>", want: Sequence{{Sym: "Tab", Mods: ModCtrl}}},
34+ {input: "<S-BackSpace>", want: Sequence{{Sym: "BackSpace", Mods: ModShift}}},
35+ {input: "<A-Delete>", want: Sequence{{Sym: "Delete", Mods: ModAlt}}},
36+ {input: "<C-Left>", want: Sequence{{Sym: "Left", Mods: ModCtrl}}},
22 {input: "<lt>", want: Sequence{{Sym: "<"}}},37 {input: "<lt>", want: Sequence{{Sym: "<"}}},
23 {input: "<Plus>", want: Sequence{{Sym: "+"}}},38 {input: "<Plus>", want: Sequence{{Sym: "+"}}},
39+ {input: "enter", want: Sequence{{Sym: "CR"}}},
40+ {input: "escape", want: Sequence{{Sym: "Esc"}}},
41+ {input: "Enter", want: Sequence{{Sym: "CR"}}},
42+ {input: "Escape", want: Sequence{{Sym: "Esc"}}},
24 }43 }
25 for _, tt := range tests {44 for _, tt := range tests {
26 t.Run(tt.input, func(t *testing.T) {45 t.Run(tt.input, func(t *testing.T) {
@@ -97,6 +116,22 @@ func TestParseBinding_CanonicalString(t *testing.T) {
97 {input: "<Esc>", want: "<Escape>"},116 {input: "<Esc>", want: "<Escape>"},
98 {input: "<Escape>", want: "<Escape>"},117 {input: "<Escape>", want: "<Escape>"},
99 {input: "<Space>", want: "<Space>"},118 {input: "<Space>", want: "<Space>"},
119+ {input: "<Tab>", want: "<Tab>"},
120+ {input: "<BackSpace>", want: "<BackSpace>"},
121+ {input: "<Delete>", want: "<Delete>"},
122+ {input: "<Left>", want: "<Left>"},
123+ {input: "<Right>", want: "<Right>"},
124+ {input: "<Up>", want: "<Up>"},
125+ {input: "<Down>", want: "<Down>"},
126+ {input: "<Home>", want: "<Home>"},
127+ {input: "<End>", want: "<End>"},
128+ {input: "<PageUp>", want: "<PageUp>"},
129+ {input: "<PageDown>", want: "<PageDown>"},
130+ {input: "<C-Tab>", want: "<C-Tab>"},
131+ {input: "<S-BackSpace>", want: "<S-BackSpace>"},
132+ {input: "<A-Delete>", want: "<A-Delete>"},
133+ {input: "<C-Home>", want: "<C-Home>"},
134+ {input: "<A-PageDown>", want: "<A-PageDown>"},
100 {input: "<lt>", want: "<lt>"},135 {input: "<lt>", want: "<lt>"},
101 {input: "gO", want: "gO"},136 {input: "gO", want: "gO"},
102 }137 }
@@ -300,6 +335,23 @@ func TestParseBinding_RoundTrip(t *testing.T) {
300 "<CR>",335 "<CR>",
301 "<Esc>",336 "<Esc>",
302 "<Space>",337 "<Space>",
338+ "<Tab>",
339+ "<BackSpace>",
340+ "<Delete>",
341+ "<Left>",
342+ "<Right>",
343+ "<Up>",
344+ "<Down>",
345+ "<Home>",
346+ "<End>",
347+ "<PageUp>",
348+ "<PageDown>",
349+ "<C-Tab>",
350+ "<S-BackSpace>",
351+ "<A-Delete>",
352+ "<C-Left>",
353+ "<S-Home>",
354+ "<A-PageUp>",
303 "<lt>",355 "<lt>",
304 "<C-d>j",356 "<C-d>j",
305 "<Escape>j",357 "<Escape>j",
M internal/ui/input/shortcuts.go +13 -2
@@ -8,6 +8,7 @@ import (
88 "strings"
99
1010 "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/domain/vimkeys"
1112 "github.com/bnema/dumber/internal/logging"
1213 "github.com/bnema/puregotk/v4/gdk"
1314 )
@@ -233,6 +234,8 @@ type ShortcutSet struct {
233234 ResizeMode ShortcutTable
234235 // VimMode shortcuts are only active in vim mode.
235236 VimMode ShortcutTable
237+ // vimModeSequences holds multi-key Vim mode bindings.
238+ vimModeSequences *vimkeys.Trie
236239 }
237240
238241 // NewShortcutSet creates a ShortcutSet from workspace and session configuration.
@@ -302,10 +305,18 @@ func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *entity.
302305 s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize")
303306 }
304307
305-// buildVimModeShortcuts populates vim mode shortcuts from config.
308+// buildVimModeShortcuts populates Vim mode shortcuts from config.
309+// Trie-owned multi-key bindings are excluded from the legacy table.
306310 func (s *ShortcutSet) buildVimModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
307311 log := logging.FromContext(ctx)
308- s.buildModeShortcuts(ctx, cfg.VimMode.GetKeyBindings(), s.VimMode, "vim")
312+ trie, owned := buildVimModeTrie(&cfg.VimMode)
313+ s.vimModeSequences = trie
314+ legacy := filterLegacyVimModeBindings(cfg.VimMode.GetKeyBindings(), owned)
315+ log.Debug().
316+ Int("owned", len(owned)).
317+ Int("legacy", len(legacy)).
318+ Msg("vim mode sequence trie built")
319+ s.buildModeShortcuts(ctx, legacy, s.VimMode, "vim")
309320 if binding, ok := ParseKeyString(cfg.VimMode.ActivationShortcut); ok {
310321 s.VimMode[binding] = ActionEnterVimMode
311322 log.Trace().
@@ -8,6 +8,7 @@ import (
8 "strings"8 "strings"
9 9
10 "github.com/bnema/dumber/internal/domain/entity"10 "github.com/bnema/dumber/internal/domain/entity"
11+ "github.com/bnema/dumber/internal/domain/vimkeys"
11 "github.com/bnema/dumber/internal/logging"12 "github.com/bnema/dumber/internal/logging"
12 "github.com/bnema/puregotk/v4/gdk"13 "github.com/bnema/puregotk/v4/gdk"
13 )14 )
@@ -233,6 +234,8 @@ type ShortcutSet struct {
233 ResizeMode ShortcutTable234 ResizeMode ShortcutTable
234 // VimMode shortcuts are only active in vim mode.235 // VimMode shortcuts are only active in vim mode.
235 VimMode ShortcutTable236 VimMode ShortcutTable
237+ // vimModeSequences holds multi-key Vim mode bindings.
238+ vimModeSequences *vimkeys.Trie
236 }239 }
237 240
238 // NewShortcutSet creates a ShortcutSet from workspace and session configuration.241 // NewShortcutSet creates a ShortcutSet from workspace and session configuration.
@@ -302,10 +305,18 @@ func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *entity.
302 s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize")305 s.buildModeShortcuts(ctx, cfg.ResizeMode.GetKeyBindings(), s.ResizeMode, "resize")
303 }306 }
304 307
305-// buildVimModeShortcuts populates vim mode shortcuts from config.308+// buildVimModeShortcuts populates Vim mode shortcuts from config.
309+// Trie-owned multi-key bindings are excluded from the legacy table.
306 func (s *ShortcutSet) buildVimModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {310 func (s *ShortcutSet) buildVimModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) {
307 log := logging.FromContext(ctx)311 log := logging.FromContext(ctx)
308- s.buildModeShortcuts(ctx, cfg.VimMode.GetKeyBindings(), s.VimMode, "vim")312+ trie, owned := buildVimModeTrie(&cfg.VimMode)
313+ s.vimModeSequences = trie
314+ legacy := filterLegacyVimModeBindings(cfg.VimMode.GetKeyBindings(), owned)
315+ log.Debug().
316+ Int("owned", len(owned)).
317+ Int("legacy", len(legacy)).
318+ Msg("vim mode sequence trie built")
319+ s.buildModeShortcuts(ctx, legacy, s.VimMode, "vim")
309 if binding, ok := ParseKeyString(cfg.VimMode.ActivationShortcut); ok {320 if binding, ok := ParseKeyString(cfg.VimMode.ActivationShortcut); ok {
310 s.VimMode[binding] = ActionEnterVimMode321 s.VimMode[binding] = ActionEnterVimMode
311 log.Trace().322 log.Trace().
A internal/ui/input/vimkeys_bridge.go +91 -0
@@ -0,0 +1,91 @@
1+package input
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/dumber/internal/domain/vimkeys"
7+ "github.com/bnema/puregotk/v4/gdk"
8+)
9+
10+// namedKeyvalSym maps a GDK named keyval onto a canonical vimkeys symbol.
11+// Immutable switch mirrors Phase 1 parser quality (no mutable package map).
12+func namedKeyvalSym(keyval uint) (string, bool) {
13+ switch keyval {
14+ case uint(gdk.KEY_Escape):
15+ return "Esc", true
16+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
17+ return "CR", true
18+ case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab):
19+ return "Tab", true
20+ case uint(gdk.KEY_BackSpace):
21+ return "BackSpace", true
22+ case uint(gdk.KEY_space):
23+ return "Space", true
24+ case uint(gdk.KEY_Left):
25+ return "Left", true
26+ case uint(gdk.KEY_Right):
27+ return "Right", true
28+ case uint(gdk.KEY_Up):
29+ return "Up", true
30+ case uint(gdk.KEY_Down):
31+ return "Down", true
32+ case uint(gdk.KEY_Home):
33+ return "Home", true
34+ case uint(gdk.KEY_End):
35+ return "End", true
36+ case uint(gdk.KEY_Page_Up):
37+ return "PageUp", true
38+ case uint(gdk.KEY_Page_Down):
39+ return "PageDown", true
40+ case uint(gdk.KEY_Delete):
41+ return "Delete", true
42+ default:
43+ return "", false
44+ }
45+}
46+
47+// KeyvalToVimKey converts a GDK key event into a vimkeys.Key.
48+// Named keys keep modifiers. Printable non-letter glyphs drop Shift because the
49+// glyph already encodes it; Ctrl and Alt are preserved.
50+func KeyvalToVimKey(keyval uint, state gdk.ModifierType) (vimkeys.Key, bool) {
51+ mods := vimModsFromState(state)
52+ if sym, ok := namedKeyvalSym(keyval); ok {
53+ return vimkeys.Key{Sym: sym, Mods: mods}, true
54+ }
55+
56+ r := rune(keyvalToUnicode(keyval))
57+ if r == 0 || r < 0x20 {
58+ return vimkeys.Key{}, false
59+ }
60+ if r >= 'A' && r <= 'Z' {
61+ return vimkeys.Key{Sym: strings.ToLower(string(r)), Mods: mods}, true
62+ }
63+ if r >= 'a' && r <= 'z' {
64+ return vimkeys.Key{Sym: string(r), Mods: mods}, true
65+ }
66+ return vimkeys.Key{Sym: string(r), Mods: mods &^ vimkeys.ModShift}, true
67+}
68+
69+func vimModsFromState(state gdk.ModifierType) vimkeys.Mods {
70+ var mods vimkeys.Mods
71+ if state&gdk.ControlMaskValue != 0 {
72+ mods |= vimkeys.ModCtrl
73+ }
74+ if state&gdk.ShiftMaskValue != 0 {
75+ mods |= vimkeys.ModShift
76+ }
77+ if state&gdk.AltMaskValue != 0 {
78+ mods |= vimkeys.ModAlt
79+ }
80+ return mods
81+}
82+
83+// keyvalToUnicode mirrors gdk_keyval_to_unicode for Latin-1 graphic keysyms
84+// (identity mapping) and falls back to GDK for the remaining keyvals.
85+func keyvalToUnicode(keyval uint) uint32 {
86+ // ASCII graphic Latin-1 keysyms share their Unicode code points.
87+ if keyval >= 0x20 && keyval <= 0x7e {
88+ return uint32(keyval)
89+ }
90+ return gdk.KeyvalToUnicode(keyval)
91+}
@@ -0,0 +1,91 @@
1+package input
2+
3+import (
4+ "strings"
5+
6+ "github.com/bnema/dumber/internal/domain/vimkeys"
7+ "github.com/bnema/puregotk/v4/gdk"
8+)
9+
10+// namedKeyvalSym maps a GDK named keyval onto a canonical vimkeys symbol.
11+// Immutable switch mirrors Phase 1 parser quality (no mutable package map).
12+func namedKeyvalSym(keyval uint) (string, bool) {
13+ switch keyval {
14+ case uint(gdk.KEY_Escape):
15+ return "Esc", true
16+ case uint(gdk.KEY_Return), uint(gdk.KEY_KP_Enter):
17+ return "CR", true
18+ case uint(gdk.KEY_Tab), uint(gdk.KEY_ISO_Left_Tab):
19+ return "Tab", true
20+ case uint(gdk.KEY_BackSpace):
21+ return "BackSpace", true
22+ case uint(gdk.KEY_space):
23+ return "Space", true
24+ case uint(gdk.KEY_Left):
25+ return "Left", true
26+ case uint(gdk.KEY_Right):
27+ return "Right", true
28+ case uint(gdk.KEY_Up):
29+ return "Up", true
30+ case uint(gdk.KEY_Down):
31+ return "Down", true
32+ case uint(gdk.KEY_Home):
33+ return "Home", true
34+ case uint(gdk.KEY_End):
35+ return "End", true
36+ case uint(gdk.KEY_Page_Up):
37+ return "PageUp", true
38+ case uint(gdk.KEY_Page_Down):
39+ return "PageDown", true
40+ case uint(gdk.KEY_Delete):
41+ return "Delete", true
42+ default:
43+ return "", false
44+ }
45+}
46+
47+// KeyvalToVimKey converts a GDK key event into a vimkeys.Key.
48+// Named keys keep modifiers. Printable non-letter glyphs drop Shift because the
49+// glyph already encodes it; Ctrl and Alt are preserved.
50+func KeyvalToVimKey(keyval uint, state gdk.ModifierType) (vimkeys.Key, bool) {
51+ mods := vimModsFromState(state)
52+ if sym, ok := namedKeyvalSym(keyval); ok {
53+ return vimkeys.Key{Sym: sym, Mods: mods}, true
54+ }
55+
56+ r := rune(keyvalToUnicode(keyval))
57+ if r == 0 || r < 0x20 {
58+ return vimkeys.Key{}, false
59+ }
60+ if r >= 'A' && r <= 'Z' {
61+ return vimkeys.Key{Sym: strings.ToLower(string(r)), Mods: mods}, true
62+ }
63+ if r >= 'a' && r <= 'z' {
64+ return vimkeys.Key{Sym: string(r), Mods: mods}, true
65+ }
66+ return vimkeys.Key{Sym: string(r), Mods: mods &^ vimkeys.ModShift}, true
67+}
68+
69+func vimModsFromState(state gdk.ModifierType) vimkeys.Mods {
70+ var mods vimkeys.Mods
71+ if state&gdk.ControlMaskValue != 0 {
72+ mods |= vimkeys.ModCtrl
73+ }
74+ if state&gdk.ShiftMaskValue != 0 {
75+ mods |= vimkeys.ModShift
76+ }
77+ if state&gdk.AltMaskValue != 0 {
78+ mods |= vimkeys.ModAlt
79+ }
80+ return mods
81+}
82+
83+// keyvalToUnicode mirrors gdk_keyval_to_unicode for Latin-1 graphic keysyms
84+// (identity mapping) and falls back to GDK for the remaining keyvals.
85+func keyvalToUnicode(keyval uint) uint32 {
86+ // ASCII graphic Latin-1 keysyms share their Unicode code points.
87+ if keyval >= 0x20 && keyval <= 0x7e {
88+ return uint32(keyval)
89+ }
90+ return gdk.KeyvalToUnicode(keyval)
91+}
A internal/ui/input/vimkeys_bridge_test.go +200 -0
@@ -0,0 +1,200 @@
1+package input
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/domain/vimkeys"
7+ "github.com/bnema/puregotk/v4/gdk"
8+)
9+
10+func TestKeyvalToVimKey(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ keyval uint
14+ state gdk.ModifierType
15+ want vimkeys.Key
16+ wantOk bool
17+ }{
18+ {
19+ name: "braceright with shift encodes glyph without shift mod",
20+ keyval: uint(gdk.KEY_braceright),
21+ state: gdk.ShiftMaskValue,
22+ want: vimkeys.Key{Sym: "}"},
23+ wantOk: true,
24+ },
25+ {
26+ name: "uppercase J with shift keeps letter and shift",
27+ keyval: uint(gdk.KEY_J),
28+ state: gdk.ShiftMaskValue,
29+ want: vimkeys.Key{Sym: "j", Mods: vimkeys.ModShift},
30+ wantOk: true,
31+ },
32+ {
33+ name: "lowercase j without mods",
34+ keyval: uint(gdk.KEY_j),
35+ state: gdk.NoModifierMaskValue,
36+ want: vimkeys.Key{Sym: "j"},
37+ wantOk: true,
38+ },
39+ {
40+ name: "named Escape keeps ctrl modifier",
41+ keyval: uint(gdk.KEY_Escape),
42+ state: gdk.ControlMaskValue,
43+ want: vimkeys.Key{Sym: "Esc", Mods: vimkeys.ModCtrl},
44+ wantOk: true,
45+ },
46+ {
47+ name: "named Return keeps shift modifier",
48+ keyval: uint(gdk.KEY_Return),
49+ state: gdk.ShiftMaskValue,
50+ want: vimkeys.Key{Sym: "CR", Mods: vimkeys.ModShift},
51+ wantOk: true,
52+ },
53+ {
54+ name: "named KP_Enter maps to CR",
55+ keyval: uint(gdk.KEY_KP_Enter),
56+ state: gdk.NoModifierMaskValue,
57+ want: vimkeys.Key{Sym: "CR"},
58+ wantOk: true,
59+ },
60+ {
61+ name: "named Space keeps alt modifier",
62+ keyval: uint(gdk.KEY_space),
63+ state: gdk.AltMaskValue,
64+ want: vimkeys.Key{Sym: "Space", Mods: vimkeys.ModAlt},
65+ wantOk: true,
66+ },
67+ {
68+ name: "named Tab maps to Tab",
69+ keyval: uint(gdk.KEY_Tab),
70+ state: gdk.NoModifierMaskValue,
71+ want: vimkeys.Key{Sym: "Tab"},
72+ wantOk: true,
73+ },
74+ {
75+ name: "named ISO_Left_Tab maps to Tab",
76+ keyval: uint(gdk.KEY_ISO_Left_Tab),
77+ state: gdk.ShiftMaskValue,
78+ want: vimkeys.Key{Sym: "Tab", Mods: vimkeys.ModShift},
79+ wantOk: true,
80+ },
81+ {
82+ name: "named BackSpace is BackSpace not BS",
83+ keyval: uint(gdk.KEY_BackSpace),
84+ state: gdk.NoModifierMaskValue,
85+ want: vimkeys.Key{Sym: "BackSpace"},
86+ wantOk: true,
87+ },
88+ {
89+ name: "named Delete is Delete not Del",
90+ keyval: uint(gdk.KEY_Delete),
91+ state: gdk.ControlMaskValue,
92+ want: vimkeys.Key{Sym: "Delete", Mods: vimkeys.ModCtrl},
93+ wantOk: true,
94+ },
95+ {
96+ name: "named Left keeps ctrl",
97+ keyval: uint(gdk.KEY_Left),
98+ state: gdk.ControlMaskValue,
99+ want: vimkeys.Key{Sym: "Left", Mods: vimkeys.ModCtrl},
100+ wantOk: true,
101+ },
102+ {
103+ name: "named Right",
104+ keyval: uint(gdk.KEY_Right),
105+ state: gdk.NoModifierMaskValue,
106+ want: vimkeys.Key{Sym: "Right"},
107+ wantOk: true,
108+ },
109+ {
110+ name: "named Up",
111+ keyval: uint(gdk.KEY_Up),
112+ state: gdk.NoModifierMaskValue,
113+ want: vimkeys.Key{Sym: "Up"},
114+ wantOk: true,
115+ },
116+ {
117+ name: "named Down",
118+ keyval: uint(gdk.KEY_Down),
119+ state: gdk.NoModifierMaskValue,
120+ want: vimkeys.Key{Sym: "Down"},
121+ wantOk: true,
122+ },
123+ {
124+ name: "named Home",
125+ keyval: uint(gdk.KEY_Home),
126+ state: gdk.NoModifierMaskValue,
127+ want: vimkeys.Key{Sym: "Home"},
128+ wantOk: true,
129+ },
130+ {
131+ name: "named End",
132+ keyval: uint(gdk.KEY_End),
133+ state: gdk.NoModifierMaskValue,
134+ want: vimkeys.Key{Sym: "End"},
135+ wantOk: true,
136+ },
137+ {
138+ name: "named PageUp",
139+ keyval: uint(gdk.KEY_Page_Up),
140+ state: gdk.NoModifierMaskValue,
141+ want: vimkeys.Key{Sym: "PageUp"},
142+ wantOk: true,
143+ },
144+ {
145+ name: "named PageDown",
146+ keyval: uint(gdk.KEY_Page_Down),
147+ state: gdk.AltMaskValue,
148+ want: vimkeys.Key{Sym: "PageDown", Mods: vimkeys.ModAlt},
149+ wantOk: true,
150+ },
151+ {
152+ name: "ctrl+d printable letter keeps ctrl",
153+ keyval: uint(gdk.KEY_d),
154+ state: gdk.ControlMaskValue,
155+ want: vimkeys.Key{Sym: "d", Mods: vimkeys.ModCtrl},
156+ wantOk: true,
157+ },
158+ {
159+ name: "non-printable rejected",
160+ keyval: 0,
161+ state: gdk.NoModifierMaskValue,
162+ wantOk: false,
163+ },
164+ }
165+
166+ for _, tt := range tests {
167+ t.Run(tt.name+"_roundtrip_string", func(t *testing.T) {
168+ if !tt.wantOk {
169+ return
170+ }
171+ got, ok := KeyvalToVimKey(tt.keyval, tt.state)
172+ if !ok {
173+ t.Fatalf("KeyvalToVimKey() ok = false")
174+ }
175+ canonical := got.String()
176+ parsed, err := vimkeys.ParseBinding(canonical)
177+ if err != nil {
178+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
179+ }
180+ if !parsed.Equal(vimkeys.Sequence{got}) {
181+ t.Fatalf("round-trip %q -> %v, want %v", canonical, parsed, vimkeys.Sequence{got})
182+ }
183+ })
184+ }
185+
186+ for _, tt := range tests {
187+ t.Run(tt.name, func(t *testing.T) {
188+ got, ok := KeyvalToVimKey(tt.keyval, tt.state)
189+ if ok != tt.wantOk {
190+ t.Fatalf("KeyvalToVimKey() ok = %v, want %v", ok, tt.wantOk)
191+ }
192+ if !tt.wantOk {
193+ return
194+ }
195+ if got != tt.want {
196+ t.Fatalf("KeyvalToVimKey() = %#v, want %#v", got, tt.want)
197+ }
198+ })
199+ }
200+}
@@ -0,0 +1,200 @@
1+package input
2+
3+import (
4+ "testing"
5+
6+ "github.com/bnema/dumber/internal/domain/vimkeys"
7+ "github.com/bnema/puregotk/v4/gdk"
8+)
9+
10+func TestKeyvalToVimKey(t *testing.T) {
11+ tests := []struct {
12+ name string
13+ keyval uint
14+ state gdk.ModifierType
15+ want vimkeys.Key
16+ wantOk bool
17+ }{
18+ {
19+ name: "braceright with shift encodes glyph without shift mod",
20+ keyval: uint(gdk.KEY_braceright),
21+ state: gdk.ShiftMaskValue,
22+ want: vimkeys.Key{Sym: "}"},
23+ wantOk: true,
24+ },
25+ {
26+ name: "uppercase J with shift keeps letter and shift",
27+ keyval: uint(gdk.KEY_J),
28+ state: gdk.ShiftMaskValue,
29+ want: vimkeys.Key{Sym: "j", Mods: vimkeys.ModShift},
30+ wantOk: true,
31+ },
32+ {
33+ name: "lowercase j without mods",
34+ keyval: uint(gdk.KEY_j),
35+ state: gdk.NoModifierMaskValue,
36+ want: vimkeys.Key{Sym: "j"},
37+ wantOk: true,
38+ },
39+ {
40+ name: "named Escape keeps ctrl modifier",
41+ keyval: uint(gdk.KEY_Escape),
42+ state: gdk.ControlMaskValue,
43+ want: vimkeys.Key{Sym: "Esc", Mods: vimkeys.ModCtrl},
44+ wantOk: true,
45+ },
46+ {
47+ name: "named Return keeps shift modifier",
48+ keyval: uint(gdk.KEY_Return),
49+ state: gdk.ShiftMaskValue,
50+ want: vimkeys.Key{Sym: "CR", Mods: vimkeys.ModShift},
51+ wantOk: true,
52+ },
53+ {
54+ name: "named KP_Enter maps to CR",
55+ keyval: uint(gdk.KEY_KP_Enter),
56+ state: gdk.NoModifierMaskValue,
57+ want: vimkeys.Key{Sym: "CR"},
58+ wantOk: true,
59+ },
60+ {
61+ name: "named Space keeps alt modifier",
62+ keyval: uint(gdk.KEY_space),
63+ state: gdk.AltMaskValue,
64+ want: vimkeys.Key{Sym: "Space", Mods: vimkeys.ModAlt},
65+ wantOk: true,
66+ },
67+ {
68+ name: "named Tab maps to Tab",
69+ keyval: uint(gdk.KEY_Tab),
70+ state: gdk.NoModifierMaskValue,
71+ want: vimkeys.Key{Sym: "Tab"},
72+ wantOk: true,
73+ },
74+ {
75+ name: "named ISO_Left_Tab maps to Tab",
76+ keyval: uint(gdk.KEY_ISO_Left_Tab),
77+ state: gdk.ShiftMaskValue,
78+ want: vimkeys.Key{Sym: "Tab", Mods: vimkeys.ModShift},
79+ wantOk: true,
80+ },
81+ {
82+ name: "named BackSpace is BackSpace not BS",
83+ keyval: uint(gdk.KEY_BackSpace),
84+ state: gdk.NoModifierMaskValue,
85+ want: vimkeys.Key{Sym: "BackSpace"},
86+ wantOk: true,
87+ },
88+ {
89+ name: "named Delete is Delete not Del",
90+ keyval: uint(gdk.KEY_Delete),
91+ state: gdk.ControlMaskValue,
92+ want: vimkeys.Key{Sym: "Delete", Mods: vimkeys.ModCtrl},
93+ wantOk: true,
94+ },
95+ {
96+ name: "named Left keeps ctrl",
97+ keyval: uint(gdk.KEY_Left),
98+ state: gdk.ControlMaskValue,
99+ want: vimkeys.Key{Sym: "Left", Mods: vimkeys.ModCtrl},
100+ wantOk: true,
101+ },
102+ {
103+ name: "named Right",
104+ keyval: uint(gdk.KEY_Right),
105+ state: gdk.NoModifierMaskValue,
106+ want: vimkeys.Key{Sym: "Right"},
107+ wantOk: true,
108+ },
109+ {
110+ name: "named Up",
111+ keyval: uint(gdk.KEY_Up),
112+ state: gdk.NoModifierMaskValue,
113+ want: vimkeys.Key{Sym: "Up"},
114+ wantOk: true,
115+ },
116+ {
117+ name: "named Down",
118+ keyval: uint(gdk.KEY_Down),
119+ state: gdk.NoModifierMaskValue,
120+ want: vimkeys.Key{Sym: "Down"},
121+ wantOk: true,
122+ },
123+ {
124+ name: "named Home",
125+ keyval: uint(gdk.KEY_Home),
126+ state: gdk.NoModifierMaskValue,
127+ want: vimkeys.Key{Sym: "Home"},
128+ wantOk: true,
129+ },
130+ {
131+ name: "named End",
132+ keyval: uint(gdk.KEY_End),
133+ state: gdk.NoModifierMaskValue,
134+ want: vimkeys.Key{Sym: "End"},
135+ wantOk: true,
136+ },
137+ {
138+ name: "named PageUp",
139+ keyval: uint(gdk.KEY_Page_Up),
140+ state: gdk.NoModifierMaskValue,
141+ want: vimkeys.Key{Sym: "PageUp"},
142+ wantOk: true,
143+ },
144+ {
145+ name: "named PageDown",
146+ keyval: uint(gdk.KEY_Page_Down),
147+ state: gdk.AltMaskValue,
148+ want: vimkeys.Key{Sym: "PageDown", Mods: vimkeys.ModAlt},
149+ wantOk: true,
150+ },
151+ {
152+ name: "ctrl+d printable letter keeps ctrl",
153+ keyval: uint(gdk.KEY_d),
154+ state: gdk.ControlMaskValue,
155+ want: vimkeys.Key{Sym: "d", Mods: vimkeys.ModCtrl},
156+ wantOk: true,
157+ },
158+ {
159+ name: "non-printable rejected",
160+ keyval: 0,
161+ state: gdk.NoModifierMaskValue,
162+ wantOk: false,
163+ },
164+ }
165+
166+ for _, tt := range tests {
167+ t.Run(tt.name+"_roundtrip_string", func(t *testing.T) {
168+ if !tt.wantOk {
169+ return
170+ }
171+ got, ok := KeyvalToVimKey(tt.keyval, tt.state)
172+ if !ok {
173+ t.Fatalf("KeyvalToVimKey() ok = false")
174+ }
175+ canonical := got.String()
176+ parsed, err := vimkeys.ParseBinding(canonical)
177+ if err != nil {
178+ t.Fatalf("ParseBinding(%q) error = %v", canonical, err)
179+ }
180+ if !parsed.Equal(vimkeys.Sequence{got}) {
181+ t.Fatalf("round-trip %q -> %v, want %v", canonical, parsed, vimkeys.Sequence{got})
182+ }
183+ })
184+ }
185+
186+ for _, tt := range tests {
187+ t.Run(tt.name, func(t *testing.T) {
188+ got, ok := KeyvalToVimKey(tt.keyval, tt.state)
189+ if ok != tt.wantOk {
190+ t.Fatalf("KeyvalToVimKey() ok = %v, want %v", ok, tt.wantOk)
191+ }
192+ if !tt.wantOk {
193+ return
194+ }
195+ if got != tt.want {
196+ t.Fatalf("KeyvalToVimKey() = %#v, want %#v", got, tt.want)
197+ }
198+ })
199+ }
200+}
A internal/ui/input/vimkeys_dispatch.go +79 -0
@@ -0,0 +1,79 @@
1+package input
2+
3+import (
4+ "sort"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ "github.com/bnema/dumber/internal/domain/vimkeys"
8+)
9+
10+// trieOwnsBinding reports whether a raw config binding belongs to the Vim
11+// sequence trie rather than the legacy single-chord Page Mode table.
12+// Multi-key sequences and Vim-only chords that ParseKeyString cannot read are owned.
13+func trieOwnsBinding(raw string) (vimkeys.Sequence, bool) {
14+ seq, err := vimkeys.ParseBinding(raw)
15+ if err != nil {
16+ return nil, false
17+ }
18+ if len(seq) >= 2 {
19+ return seq, true
20+ }
21+ if _, ok := ParseKeyString(raw); !ok {
22+ return seq, true
23+ }
24+ return nil, false
25+}
26+
27+// buildVimModeTrie inserts trie-owned Vim mode bindings. Action names are
28+// sorted so the first sorted name wins on Insert conflicts (validation already
29+// rejects duplicates).
30+func buildVimModeTrie(cfg *entity.VimModeConfig) (*vimkeys.Trie, map[string]bool) {
31+ trie := vimkeys.NewTrie()
32+ owned := make(map[string]bool)
33+ if cfg == nil {
34+ return trie, owned
35+ }
36+
37+ actions := make([]string, 0, len(cfg.Actions))
38+ for action := range cfg.Actions {
39+ actions = append(actions, action)
40+ }
41+ sort.Strings(actions)
42+
43+ for _, action := range actions {
44+ binding := cfg.Actions[action]
45+ keys := append([]string(nil), binding.Keys...)
46+ sort.Strings(keys)
47+ for _, key := range keys {
48+ seq, ok := trieOwnsBinding(key)
49+ if !ok {
50+ continue
51+ }
52+ owned[key] = true
53+ if err := trie.Insert(seq, action); err != nil {
54+ continue
55+ }
56+ }
57+ }
58+ return trie, owned
59+}
60+
61+// VimModeSequences returns the Vim sequence trie, if any.
62+func (s *ShortcutSet) VimModeSequences() *vimkeys.Trie {
63+ if s == nil {
64+ return nil
65+ }
66+ return s.vimModeSequences
67+}
68+
69+// filterLegacyVimModeBindings copies GetKeyBindings entries that the trie does not own.
70+func filterLegacyVimModeBindings(bindings map[string]string, owned map[string]bool) map[string]string {
71+ legacy := make(map[string]string, len(bindings))
72+ for key, action := range bindings {
73+ if owned[key] {
74+ continue
75+ }
76+ legacy[key] = action
77+ }
78+ return legacy
79+}
@@ -0,0 +1,79 @@
1+package input
2+
3+import (
4+ "sort"
5+
6+ "github.com/bnema/dumber/internal/domain/entity"
7+ "github.com/bnema/dumber/internal/domain/vimkeys"
8+)
9+
10+// trieOwnsBinding reports whether a raw config binding belongs to the Vim
11+// sequence trie rather than the legacy single-chord Page Mode table.
12+// Multi-key sequences and Vim-only chords that ParseKeyString cannot read are owned.
13+func trieOwnsBinding(raw string) (vimkeys.Sequence, bool) {
14+ seq, err := vimkeys.ParseBinding(raw)
15+ if err != nil {
16+ return nil, false
17+ }
18+ if len(seq) >= 2 {
19+ return seq, true
20+ }
21+ if _, ok := ParseKeyString(raw); !ok {
22+ return seq, true
23+ }
24+ return nil, false
25+}
26+
27+// buildVimModeTrie inserts trie-owned Vim mode bindings. Action names are
28+// sorted so the first sorted name wins on Insert conflicts (validation already
29+// rejects duplicates).
30+func buildVimModeTrie(cfg *entity.VimModeConfig) (*vimkeys.Trie, map[string]bool) {
31+ trie := vimkeys.NewTrie()
32+ owned := make(map[string]bool)
33+ if cfg == nil {
34+ return trie, owned
35+ }
36+
37+ actions := make([]string, 0, len(cfg.Actions))
38+ for action := range cfg.Actions {
39+ actions = append(actions, action)
40+ }
41+ sort.Strings(actions)
42+
43+ for _, action := range actions {
44+ binding := cfg.Actions[action]
45+ keys := append([]string(nil), binding.Keys...)
46+ sort.Strings(keys)
47+ for _, key := range keys {
48+ seq, ok := trieOwnsBinding(key)
49+ if !ok {
50+ continue
51+ }
52+ owned[key] = true
53+ if err := trie.Insert(seq, action); err != nil {
54+ continue
55+ }
56+ }
57+ }
58+ return trie, owned
59+}
60+
61+// VimModeSequences returns the Vim sequence trie, if any.
62+func (s *ShortcutSet) VimModeSequences() *vimkeys.Trie {
63+ if s == nil {
64+ return nil
65+ }
66+ return s.vimModeSequences
67+}
68+
69+// filterLegacyVimModeBindings copies GetKeyBindings entries that the trie does not own.
70+func filterLegacyVimModeBindings(bindings map[string]string, owned map[string]bool) map[string]string {
71+ legacy := make(map[string]string, len(bindings))
72+ for key, action := range bindings {
73+ if owned[key] {
74+ continue
75+ }
76+ legacy[key] = action
77+ }
78+ return legacy
79+}
A internal/ui/input/vimkeys_dispatch_test.go +209 -0
@@ -0,0 +1,209 @@
1+package input
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/domain/vimkeys"
9+ "github.com/bnema/puregotk/v4/gdk"
10+)
11+
12+func TestTrieOwnsBinding(t *testing.T) {
13+ cases := map[string]bool{
14+ "]]": true,
15+ "yah": true,
16+ "gO": true,
17+ "<C-d>": true,
18+ "j": false,
19+ "shift+j": false,
20+ "}": false,
21+ "enter": false,
22+ "escape": false,
23+ "<Nope>": false,
24+ }
25+
26+ for raw, wantOwned := range cases {
27+ t.Run(raw, func(t *testing.T) {
28+ seq, owned := trieOwnsBinding(raw)
29+ if owned != wantOwned {
30+ t.Fatalf("trieOwnsBinding(%q) owned = %v, want %v (seq=%v)", raw, owned, wantOwned, seq)
31+ }
32+ if !wantOwned {
33+ return
34+ }
35+ if len(seq) == 0 {
36+ t.Fatalf("trieOwnsBinding(%q) returned empty sequence", raw)
37+ }
38+ })
39+ }
40+}
41+
42+func TestTrieOwnsBinding_CrossContractAtoms(t *testing.T) {
43+ tests := []struct {
44+ raw string
45+ want vimkeys.Sequence
46+ }{
47+ {raw: "}", want: vimkeys.Sequence{{Sym: "}"}}},
48+ {raw: "<C-d>", want: vimkeys.Sequence{{Sym: "d", Mods: vimkeys.ModCtrl}}},
49+ {raw: "<CR>", want: vimkeys.Sequence{{Sym: "CR"}}},
50+ {raw: "J", want: vimkeys.Sequence{{Sym: "j", Mods: vimkeys.ModShift}}},
51+ {raw: "enter", want: vimkeys.Sequence{{Sym: "CR"}}},
52+ {raw: "escape", want: vimkeys.Sequence{{Sym: "Esc"}}},
53+ }
54+
55+ for _, tt := range tests {
56+ t.Run(tt.raw, func(t *testing.T) {
57+ got, err := vimkeys.ParseBinding(tt.raw)
58+ if err != nil {
59+ t.Fatalf("ParseBinding(%q) error = %v", tt.raw, err)
60+ }
61+ if !got.Equal(tt.want) {
62+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.raw, got, tt.want)
63+ }
64+ _, owned := trieOwnsBinding(tt.raw)
65+ switch tt.raw {
66+ case "<C-d>", "<CR>":
67+ if !owned {
68+ t.Fatalf("trieOwnsBinding(%q) = false, want true", tt.raw)
69+ }
70+ default:
71+ if owned {
72+ t.Fatalf("trieOwnsBinding(%q) = true, want false", tt.raw)
73+ }
74+ }
75+ })
76+ }
77+}
78+
79+func TestBuildPageModeTrie(t *testing.T) {
80+ cfg := &entity.PageModeConfig{
81+ Actions: map[string]entity.ActionBinding{
82+ "page-scroll-down": {Keys: []string{"j"}},
83+ "heading-next": {Keys: []string{"]]"}},
84+ "yank-section": {Keys: []string{"yah"}},
85+ "outline": {Keys: []string{"gO"}},
86+ "half-page-down": {Keys: []string{"<C-d>"}},
87+ "confirm": {Keys: []string{"enter"}},
88+ "cancel": {Keys: []string{"escape"}},
89+ },
90+ }
91+
92+ trie, owned := buildPageModeTrie(cfg)
93+ if trie == nil {
94+ t.Fatal("buildPageModeTrie returned nil trie")
95+ }
96+
97+ wantOwned := map[string]bool{
98+ "]]": true,
99+ "yah": true,
100+ "gO": true,
101+ "<C-d>": true,
102+ }
103+ for key, want := range wantOwned {
104+ if owned[key] != want {
105+ t.Fatalf("owned[%q] = %v, want %v", key, owned[key], want)
106+ }
107+ }
108+ for _, legacy := range []string{"j", "enter", "escape"} {
109+ if owned[legacy] {
110+ t.Fatalf("legacy binding %q should not be owned", legacy)
111+ }
112+ }
113+
114+ seq, err := vimkeys.ParseBinding("]]")
115+ if err != nil {
116+ t.Fatalf("ParseBinding(]]) error = %v", err)
117+ }
118+ node, ok := trie.Walk(seq)
119+ if !ok || !node.Exact || node.Action != "heading-next" {
120+ t.Fatalf("Walk(]]) = (%#v, %v), want exact heading-next", node, ok)
121+ }
122+}
123+
124+func pageModeSequencesOwnershipFixture(t *testing.T) *ShortcutSet {
125+ t.Helper()
126+
127+ workspace := &entity.WorkspaceConfig{
128+ PageMode: entity.PageModeConfig{
129+ ActivationShortcut: "ctrl+y",
130+ Actions: map[string]entity.ActionBinding{
131+ "page-scroll-down": {Keys: []string{"j"}},
132+ "page-scroll-down-fast": {Keys: []string{"shift+j"}},
133+ "confirm": {Keys: []string{"enter"}},
134+ "cancel": {Keys: []string{"escape"}},
135+ "heading-next": {Keys: []string{"]]"}},
136+ "yank-section": {Keys: []string{"yah"}},
137+ "outline": {Keys: []string{"gO"}},
138+ "half-page-down": {Keys: []string{"<C-d>"}},
139+ },
140+ },
141+ }
142+
143+ set := NewShortcutSet(context.Background(), workspace, nil)
144+ if set.PageModeSequences() == nil {
145+ t.Fatal("PageModeSequences() is nil")
146+ }
147+ return set
148+}
149+
150+func assertLegacyPageModeBindings(t *testing.T, set *ShortcutSet) {
151+ t.Helper()
152+
153+ jBinding, jOK := ParseKeyString("j")
154+ if !jOK {
155+ t.Fatal("ParseKeyString(j) failed")
156+ }
157+ if action, found := set.PageMode[jBinding]; !found || action != ActionPageScrollDown {
158+ t.Fatalf("legacy j missing from PageMode: found=%v action=%q", found, action)
159+ }
160+
161+ enterBinding := KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone}
162+ if action, found := set.PageMode[enterBinding]; !found || action != ActionExitMode {
163+ t.Fatalf("legacy enter missing from PageMode: found=%v action=%q", found, action)
164+ }
165+
166+ escapeBinding := KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone}
167+ if action, found := set.PageMode[escapeBinding]; !found || action != ActionExitMode {
168+ t.Fatalf("legacy escape missing from PageMode: found=%v action=%q", found, action)
169+ }
170+}
171+
172+func assertOwnedBindingsAbsentFromPageMode(t *testing.T, set *ShortcutSet) {
173+ t.Helper()
174+
175+ for _, key := range []string{"]]", "yah", "gO", "<C-d>"} {
176+ if binding, parsed := ParseKeyString(key); parsed {
177+ if action, found := set.PageMode[binding]; found {
178+ t.Fatalf("owned binding %q leaked into PageMode as %q", key, action)
179+ }
180+ }
181+ }
182+}
183+
184+func assertPageModeTrieYankSection(t *testing.T, set *ShortcutSet) {
185+ t.Helper()
186+
187+ seq, err := vimkeys.ParseBinding("yah")
188+ if err != nil {
189+ t.Fatalf("ParseBinding(yah) error = %v", err)
190+ }
191+ node, walked := set.PageModeSequences().Walk(seq)
192+ if !walked || !node.Exact || node.Action != "yank-section" {
193+ t.Fatalf("PageModeSequences Walk(yah) = (%#v, %v), want exact yank-section", node, walked)
194+ }
195+}
196+
197+func TestShortcutSet_PageModeSequencesOwnership(t *testing.T) {
198+ set := pageModeSequencesOwnershipFixture(t)
199+
200+ t.Run("legacy bindings", func(t *testing.T) {
201+ assertLegacyPageModeBindings(t, set)
202+ })
203+ t.Run("owned bindings absent from legacy table", func(t *testing.T) {
204+ assertOwnedBindingsAbsentFromPageMode(t, set)
205+ })
206+ t.Run("trie resolves owned sequence", func(t *testing.T) {
207+ assertPageModeTrieYankSection(t, set)
208+ })
209+}
@@ -0,0 +1,209 @@
1+package input
2+
3+import (
4+ "context"
5+ "testing"
6+
7+ "github.com/bnema/dumber/internal/domain/entity"
8+ "github.com/bnema/dumber/internal/domain/vimkeys"
9+ "github.com/bnema/puregotk/v4/gdk"
10+)
11+
12+func TestTrieOwnsBinding(t *testing.T) {
13+ cases := map[string]bool{
14+ "]]": true,
15+ "yah": true,
16+ "gO": true,
17+ "<C-d>": true,
18+ "j": false,
19+ "shift+j": false,
20+ "}": false,
21+ "enter": false,
22+ "escape": false,
23+ "<Nope>": false,
24+ }
25+
26+ for raw, wantOwned := range cases {
27+ t.Run(raw, func(t *testing.T) {
28+ seq, owned := trieOwnsBinding(raw)
29+ if owned != wantOwned {
30+ t.Fatalf("trieOwnsBinding(%q) owned = %v, want %v (seq=%v)", raw, owned, wantOwned, seq)
31+ }
32+ if !wantOwned {
33+ return
34+ }
35+ if len(seq) == 0 {
36+ t.Fatalf("trieOwnsBinding(%q) returned empty sequence", raw)
37+ }
38+ })
39+ }
40+}
41+
42+func TestTrieOwnsBinding_CrossContractAtoms(t *testing.T) {
43+ tests := []struct {
44+ raw string
45+ want vimkeys.Sequence
46+ }{
47+ {raw: "}", want: vimkeys.Sequence{{Sym: "}"}}},
48+ {raw: "<C-d>", want: vimkeys.Sequence{{Sym: "d", Mods: vimkeys.ModCtrl}}},
49+ {raw: "<CR>", want: vimkeys.Sequence{{Sym: "CR"}}},
50+ {raw: "J", want: vimkeys.Sequence{{Sym: "j", Mods: vimkeys.ModShift}}},
51+ {raw: "enter", want: vimkeys.Sequence{{Sym: "CR"}}},
52+ {raw: "escape", want: vimkeys.Sequence{{Sym: "Esc"}}},
53+ }
54+
55+ for _, tt := range tests {
56+ t.Run(tt.raw, func(t *testing.T) {
57+ got, err := vimkeys.ParseBinding(tt.raw)
58+ if err != nil {
59+ t.Fatalf("ParseBinding(%q) error = %v", tt.raw, err)
60+ }
61+ if !got.Equal(tt.want) {
62+ t.Fatalf("ParseBinding(%q) = %v, want %v", tt.raw, got, tt.want)
63+ }
64+ _, owned := trieOwnsBinding(tt.raw)
65+ switch tt.raw {
66+ case "<C-d>", "<CR>":
67+ if !owned {
68+ t.Fatalf("trieOwnsBinding(%q) = false, want true", tt.raw)
69+ }
70+ default:
71+ if owned {
72+ t.Fatalf("trieOwnsBinding(%q) = true, want false", tt.raw)
73+ }
74+ }
75+ })
76+ }
77+}
78+
79+func TestBuildPageModeTrie(t *testing.T) {
80+ cfg := &entity.PageModeConfig{
81+ Actions: map[string]entity.ActionBinding{
82+ "page-scroll-down": {Keys: []string{"j"}},
83+ "heading-next": {Keys: []string{"]]"}},
84+ "yank-section": {Keys: []string{"yah"}},
85+ "outline": {Keys: []string{"gO"}},
86+ "half-page-down": {Keys: []string{"<C-d>"}},
87+ "confirm": {Keys: []string{"enter"}},
88+ "cancel": {Keys: []string{"escape"}},
89+ },
90+ }
91+
92+ trie, owned := buildPageModeTrie(cfg)
93+ if trie == nil {
94+ t.Fatal("buildPageModeTrie returned nil trie")
95+ }
96+
97+ wantOwned := map[string]bool{
98+ "]]": true,
99+ "yah": true,
100+ "gO": true,
101+ "<C-d>": true,
102+ }
103+ for key, want := range wantOwned {
104+ if owned[key] != want {
105+ t.Fatalf("owned[%q] = %v, want %v", key, owned[key], want)
106+ }
107+ }
108+ for _, legacy := range []string{"j", "enter", "escape"} {
109+ if owned[legacy] {
110+ t.Fatalf("legacy binding %q should not be owned", legacy)
111+ }
112+ }
113+
114+ seq, err := vimkeys.ParseBinding("]]")
115+ if err != nil {
116+ t.Fatalf("ParseBinding(]]) error = %v", err)
117+ }
118+ node, ok := trie.Walk(seq)
119+ if !ok || !node.Exact || node.Action != "heading-next" {
120+ t.Fatalf("Walk(]]) = (%#v, %v), want exact heading-next", node, ok)
121+ }
122+}
123+
124+func pageModeSequencesOwnershipFixture(t *testing.T) *ShortcutSet {
125+ t.Helper()
126+
127+ workspace := &entity.WorkspaceConfig{
128+ PageMode: entity.PageModeConfig{
129+ ActivationShortcut: "ctrl+y",
130+ Actions: map[string]entity.ActionBinding{
131+ "page-scroll-down": {Keys: []string{"j"}},
132+ "page-scroll-down-fast": {Keys: []string{"shift+j"}},
133+ "confirm": {Keys: []string{"enter"}},
134+ "cancel": {Keys: []string{"escape"}},
135+ "heading-next": {Keys: []string{"]]"}},
136+ "yank-section": {Keys: []string{"yah"}},
137+ "outline": {Keys: []string{"gO"}},
138+ "half-page-down": {Keys: []string{"<C-d>"}},
139+ },
140+ },
141+ }
142+
143+ set := NewShortcutSet(context.Background(), workspace, nil)
144+ if set.PageModeSequences() == nil {
145+ t.Fatal("PageModeSequences() is nil")
146+ }
147+ return set
148+}
149+
150+func assertLegacyPageModeBindings(t *testing.T, set *ShortcutSet) {
151+ t.Helper()
152+
153+ jBinding, jOK := ParseKeyString("j")
154+ if !jOK {
155+ t.Fatal("ParseKeyString(j) failed")
156+ }
157+ if action, found := set.PageMode[jBinding]; !found || action != ActionPageScrollDown {
158+ t.Fatalf("legacy j missing from PageMode: found=%v action=%q", found, action)
159+ }
160+
161+ enterBinding := KeyBinding{Keyval: uint(gdk.KEY_Return), Modifiers: ModNone}
162+ if action, found := set.PageMode[enterBinding]; !found || action != ActionExitMode {
163+ t.Fatalf("legacy enter missing from PageMode: found=%v action=%q", found, action)
164+ }
165+
166+ escapeBinding := KeyBinding{Keyval: uint(gdk.KEY_Escape), Modifiers: ModNone}
167+ if action, found := set.PageMode[escapeBinding]; !found || action != ActionExitMode {
168+ t.Fatalf("legacy escape missing from PageMode: found=%v action=%q", found, action)
169+ }
170+}
171+
172+func assertOwnedBindingsAbsentFromPageMode(t *testing.T, set *ShortcutSet) {
173+ t.Helper()
174+
175+ for _, key := range []string{"]]", "yah", "gO", "<C-d>"} {
176+ if binding, parsed := ParseKeyString(key); parsed {
177+ if action, found := set.PageMode[binding]; found {
178+ t.Fatalf("owned binding %q leaked into PageMode as %q", key, action)
179+ }
180+ }
181+ }
182+}
183+
184+func assertPageModeTrieYankSection(t *testing.T, set *ShortcutSet) {
185+ t.Helper()
186+
187+ seq, err := vimkeys.ParseBinding("yah")
188+ if err != nil {
189+ t.Fatalf("ParseBinding(yah) error = %v", err)
190+ }
191+ node, walked := set.PageModeSequences().Walk(seq)
192+ if !walked || !node.Exact || node.Action != "yank-section" {
193+ t.Fatalf("PageModeSequences Walk(yah) = (%#v, %v), want exact yank-section", node, walked)
194+ }
195+}
196+
197+func TestShortcutSet_PageModeSequencesOwnership(t *testing.T) {
198+ set := pageModeSequencesOwnershipFixture(t)
199+
200+ t.Run("legacy bindings", func(t *testing.T) {
201+ assertLegacyPageModeBindings(t, set)
202+ })
203+ t.Run("owned bindings absent from legacy table", func(t *testing.T) {
204+ assertOwnedBindingsAbsentFromPageMode(t, set)
205+ })
206+ t.Run("trie resolves owned sequence", func(t *testing.T) {
207+ assertPageModeTrieYankSection(t, set)
208+ })
209+}

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