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 { | |||
| 78 | 78 | return "Return" | |
| 79 | 79 | case "Esc": | |
| 80 | 80 | 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 | |
| 83 | 85 | case "<": | |
| 84 | 86 | return "lt" | |
| 85 | 87 | 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) { | |||
| 18 | 18 | {name: "special CR", key: Key{Sym: "CR"}, want: "<Return>"}, | |
| 19 | 19 | {name: "special Esc", key: Key{Sym: "Esc"}, want: "<Escape>"}, | |
| 20 | 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 | 36 | {name: "literal lt", key: Key{Sym: "<"}, want: "<lt>"}, | |
| 22 | 37 | {name: "alt only", key: Key{Sym: "b", Mods: ModAlt}, want: "<A-b>"}, | |
| 23 | 38 | {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) { | |||
| 12 | 12 | return nil, ErrEmptyBinding | |
| 13 | 13 | } | |
| 14 | 14 | ||
| 15 | + | if key, ok := tryParseLegacyAtom(s); ok { | |
| 16 | + | return Sequence{key}, nil | |
| 17 | + | } | |
| 18 | + | ||
| 15 | 19 | if key, ok, err := tryParseAsLegacyChord(s); ok { | |
| 16 | 20 | if err != nil { | |
| 17 | 21 | return nil, err | |
| @@ -21,6 +25,19 @@ func ParseBinding(s string) (Sequence, error) { | |||
| 21 | 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 | 41 | func legacyChordParts(s string) []string { | |
| 25 | 42 | parts := strings.Split(s, "+") | |
| 26 | 43 | tokens := make([]string, 0, len(parts)) | |
| @@ -259,6 +276,9 @@ func tryParseSpecialAt(s string, i int) (Key, int, bool) { | |||
| 259 | 276 | return Key{}, 0, false | |
| 260 | 277 | } | |
| 261 | 278 | rest := s[i:] | |
| 279 | + | if key, width, ok := tryParseNamedSpecialAt(rest); ok { | |
| 280 | + | return key, width, true | |
| 281 | + | } | |
| 262 | 282 | switch { | |
| 263 | 283 | case strings.HasPrefix(rest, "Space"): | |
| 264 | 284 | 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 | 328 | func tryParseCanonicalAt(s string, i int) (Key, int, bool) { | |
| 279 | 329 | if i >= len(s) { | |
| 280 | 330 | return Key{}, 0, false | |
| @@ -362,7 +412,8 @@ func shiftedGlyphForRune(r rune) (string, bool) { | |||
| 362 | 412 | } | |
| 363 | 413 | ||
| 364 | 414 | func symFromSpecialAlias(token string) (string, bool) { | |
| 365 | - | switch strings.ToLower(token) { | |
| 415 | + | lower := strings.ToLower(token) | |
| 416 | + | switch lower { | |
| 366 | 417 | case "cr", "return": | |
| 367 | 418 | return "CR", true | |
| 368 | 419 | case "esc", "escape": | |
| @@ -373,6 +424,35 @@ func symFromSpecialAlias(token string) (string, bool) { | |||
| 373 | 424 | return "<", true | |
| 374 | 425 | case "plus": | |
| 375 | 426 | 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 | 456 | default: | |
| 377 | 457 | return "", false | |
| 378 | 458 | } | |
| @@ -12,6 +12,10 @@ func ParseBinding(s string) (Sequence, error) { | |||||
| 12 | return nil, ErrEmptyBinding | 12 | 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, err | 21 | 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, false | 276 | 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"), true | 284 | 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, false | 330 | 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", true | 418 | return "CR", true | ||
| 368 | case "esc", "escape": | 419 | case "esc", "escape": | ||
| @@ -373,6 +424,35 @@ func symFromSpecialAlias(token string) (string, bool) { | |||||
| 373 | return "<", true | 424 | return "<", true | ||
| 374 | case "plus": | 425 | case "plus": | ||
| 375 | return "+", true | 426 | 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 "", false | 457 | return "", false | ||
| 378 | } | 458 | } | ||
M internal/domain/vimkeys/parse_test.go +52 -0
| @@ -19,8 +19,27 @@ func TestParseBinding_SingleKeys(t *testing.T) { | |||
| 19 | 19 | {input: "<Esc>", want: Sequence{{Sym: "Esc"}}}, | |
| 20 | 20 | {input: "<Escape>", want: Sequence{{Sym: "Esc"}}}, | |
| 21 | 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 | 37 | {input: "<lt>", want: Sequence{{Sym: "<"}}}, | |
| 23 | 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 | 44 | for _, tt := range tests { | |
| 26 | 45 | t.Run(tt.input, func(t *testing.T) { | |
| @@ -97,6 +116,22 @@ func TestParseBinding_CanonicalString(t *testing.T) { | |||
| 97 | 116 | {input: "<Esc>", want: "<Escape>"}, | |
| 98 | 117 | {input: "<Escape>", want: "<Escape>"}, | |
| 99 | 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 | 135 | {input: "<lt>", want: "<lt>"}, | |
| 101 | 136 | {input: "gO", want: "gO"}, | |
| 102 | 137 | } | |
| @@ -300,6 +335,23 @@ func TestParseBinding_RoundTrip(t *testing.T) { | |||
| 300 | 335 | "<CR>", | |
| 301 | 336 | "<Esc>", | |
| 302 | 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 | 355 | "<lt>", | |
| 304 | 356 | "<C-d>j", | |
| 305 | 357 | "<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 ( | |||
| 8 | 8 | "strings" | |
| 9 | 9 | ||
| 10 | 10 | "github.com/bnema/dumber/internal/domain/entity" | |
| 11 | + | "github.com/bnema/dumber/internal/domain/vimkeys" | |
| 11 | 12 | "github.com/bnema/dumber/internal/logging" | |
| 12 | 13 | "github.com/bnema/puregotk/v4/gdk" | |
| 13 | 14 | ) | |
| @@ -233,6 +234,8 @@ type ShortcutSet struct { | |||
| 233 | 234 | ResizeMode ShortcutTable | |
| 234 | 235 | // VimMode shortcuts are only active in vim mode. | |
| 235 | 236 | VimMode ShortcutTable | |
| 237 | + | // vimModeSequences holds multi-key Vim mode bindings. | |
| 238 | + | vimModeSequences *vimkeys.Trie | |
| 236 | 239 | } | |
| 237 | 240 | ||
| 238 | 241 | // NewShortcutSet creates a ShortcutSet from workspace and session configuration. | |
| @@ -302,10 +305,18 @@ func (s *ShortcutSet) buildResizeModeShortcuts(ctx context.Context, cfg *entity. | |||
| 302 | 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 | 310 | func (s *ShortcutSet) buildVimModeShortcuts(ctx context.Context, cfg *entity.WorkspaceConfig) { | |
| 307 | 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 | 320 | if binding, ok := ParseKeyString(cfg.VimMode.ActivationShortcut); ok { | |
| 310 | 321 | s.VimMode[binding] = ActionEnterVimMode | |
| 311 | 322 | 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 ShortcutTable | 234 | ResizeMode ShortcutTable | ||
| 234 | // VimMode shortcuts are only active in vim mode. | 235 | // VimMode shortcuts are only active in vim mode. | ||
| 235 | VimMode ShortcutTable | 236 | 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] = ActionEnterVimMode | 321 | 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 | + | } | |||