fix(input): honor legacy underscore names for built-in global shortcuts
Treat a built-in browser action configured under its legacy underscore name (open_find) as configured, using the same rule as default-action completion, and resolve such names to their action. Setting open_find.keys = [] now disables the default ctrl+f, and rebinding it replaces the default instead of leaving both active.
- commit
- ef74c930b4092d585ca20894a95812b0f8fe0940
- author
- brice · · 2026-10-14 07:41 UTC
- parent
- 28632c9
- signature
- GPG Verified
CD88F9E564E0102646FB6077A1A07C7C7C3E349D· arch-gpg Verified for the committer email
2 files · +125 -4
A internal/ui/input/builtin_shortcuts_test.go +85 -0
| @@ -0,0 +1,85 @@ | |||
| 1 | + | package input | |
| 2 | + | ||
| 3 | + | import ( | |
| 4 | + | "context" | |
| 5 | + | "testing" | |
| 6 | + | ||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |
| 8 | + | "github.com/bnema/puregotk/v4/gdk" | |
| 9 | + | "github.com/stretchr/testify/assert" | |
| 10 | + | "github.com/stretchr/testify/require" | |
| 11 | + | ) | |
| 12 | + | ||
| 13 | + | func globalShortcutsConfig(actions map[string]entity.ActionBinding) *entity.WorkspaceConfig { | |
| 14 | + | return &entity.WorkspaceConfig{Shortcuts: entity.GlobalShortcutsConfig{Actions: actions}} | |
| 15 | + | } | |
| 16 | + | ||
| 17 | + | func lookupGlobal(set *ShortcutSet, keyval uint, mods Modifier) (Action, bool) { | |
| 18 | + | return set.Lookup(KeyBinding{Keyval: keyval, Modifiers: mods}, ModeNormal) | |
| 19 | + | } | |
| 20 | + | ||
| 21 | + | func TestBuiltinShortcutFallback_AppliesWhenActionIsUnconfigured(t *testing.T) { | |
| 22 | + | set := NewShortcutSet(context.Background(), globalShortcutsConfig(nil), nil) | |
| 23 | + | ||
| 24 | + | action, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl) | |
| 25 | + | require.True(t, ok) | |
| 26 | + | assert.Equal(t, ActionOpenFind, action) | |
| 27 | + | } | |
| 28 | + | ||
| 29 | + | func TestBuiltinShortcutFallback_HyphenNameWithNoKeysDisablesDefault(t *testing.T) { | |
| 30 | + | set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{ | |
| 31 | + | "open-find": {Keys: []string{}}, | |
| 32 | + | }), nil) | |
| 33 | + | ||
| 34 | + | _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl) | |
| 35 | + | assert.False(t, ok) | |
| 36 | + | } | |
| 37 | + | ||
| 38 | + | func TestBuiltinShortcutFallback_LegacyNameWithNoKeysDisablesDefault(t *testing.T) { | |
| 39 | + | set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{ | |
| 40 | + | "open_find": {Keys: []string{}}, | |
| 41 | + | }), nil) | |
| 42 | + | ||
| 43 | + | _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl) | |
| 44 | + | assert.False(t, ok, "a legacy-named action with no keys disables the built-in default") | |
| 45 | + | } | |
| 46 | + | ||
| 47 | + | func TestBuiltinShortcutFallback_LegacyNameRebindReplacesDefault(t *testing.T) { | |
| 48 | + | set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{ | |
| 49 | + | "open_find": {Keys: []string{"ctrl+shift+f"}}, | |
| 50 | + | }), nil) | |
| 51 | + | ||
| 52 | + | _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl) | |
| 53 | + | assert.False(t, ok, "the default ctrl+f is not active next to the rebinding") | |
| 54 | + | ||
| 55 | + | action, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl|ModShift) | |
| 56 | + | require.True(t, ok, "the legacy-named binding is registered") | |
| 57 | + | assert.Equal(t, ActionOpenFind, action) | |
| 58 | + | } | |
| 59 | + | ||
| 60 | + | func TestBuiltinShortcutFallback_LegacyNameOnlyAffectsItsOwnAction(t *testing.T) { | |
| 61 | + | set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{ | |
| 62 | + | "open_find": {Keys: []string{}}, | |
| 63 | + | }), nil) | |
| 64 | + | ||
| 65 | + | action, ok := lookupGlobal(set, uint(gdk.KEY_l), ModCtrl) | |
| 66 | + | require.True(t, ok) | |
| 67 | + | assert.Equal(t, ActionOpenOmnibox, action) | |
| 68 | + | } | |
| 69 | + | ||
| 70 | + | func TestBuiltinShortcutFallback_LegacyMultiWordNames(t *testing.T) { | |
| 71 | + | set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{ | |
| 72 | + | "toggle_fullscreen": {Keys: []string{}}, | |
| 73 | + | "hard_reload": {Keys: []string{"ctrl+alt+r"}}, | |
| 74 | + | }), nil) | |
| 75 | + | ||
| 76 | + | _, ok := lookupGlobal(set, uint(gdk.KEY_F11), ModNone) | |
| 77 | + | assert.False(t, ok, "toggle_fullscreen with no keys disables f11") | |
| 78 | + | ||
| 79 | + | _, ok = lookupGlobal(set, uint(gdk.KEY_R), ModCtrl|ModShift) | |
| 80 | + | assert.False(t, ok, "the default hard-reload key is replaced") | |
| 81 | + | ||
| 82 | + | action, ok := lookupGlobal(set, uint(gdk.KEY_r), ModCtrl|ModAlt) | |
| 83 | + | require.True(t, ok) | |
| 84 | + | assert.Equal(t, ActionHardReload, action) | |
| 85 | + | } | |
| @@ -0,0 +1,85 @@ | |||||
| 1 | + | package input | |||
| 2 | + | ||||
| 3 | + | import ( | |||
| 4 | + | "context" | |||
| 5 | + | "testing" | |||
| 6 | + | ||||
| 7 | + | "github.com/bnema/dumber/internal/domain/entity" | |||
| 8 | + | "github.com/bnema/puregotk/v4/gdk" | |||
| 9 | + | "github.com/stretchr/testify/assert" | |||
| 10 | + | "github.com/stretchr/testify/require" | |||
| 11 | + | ) | |||
| 12 | + | ||||
| 13 | + | func globalShortcutsConfig(actions map[string]entity.ActionBinding) *entity.WorkspaceConfig { | |||
| 14 | + | return &entity.WorkspaceConfig{Shortcuts: entity.GlobalShortcutsConfig{Actions: actions}} | |||
| 15 | + | } | |||
| 16 | + | ||||
| 17 | + | func lookupGlobal(set *ShortcutSet, keyval uint, mods Modifier) (Action, bool) { | |||
| 18 | + | return set.Lookup(KeyBinding{Keyval: keyval, Modifiers: mods}, ModeNormal) | |||
| 19 | + | } | |||
| 20 | + | ||||
| 21 | + | func TestBuiltinShortcutFallback_AppliesWhenActionIsUnconfigured(t *testing.T) { | |||
| 22 | + | set := NewShortcutSet(context.Background(), globalShortcutsConfig(nil), nil) | |||
| 23 | + | ||||
| 24 | + | action, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl) | |||
| 25 | + | require.True(t, ok) | |||
| 26 | + | assert.Equal(t, ActionOpenFind, action) | |||
| 27 | + | } | |||
| 28 | + | ||||
| 29 | + | func TestBuiltinShortcutFallback_HyphenNameWithNoKeysDisablesDefault(t *testing.T) { | |||
| 30 | + | set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{ | |||
| 31 | + | "open-find": {Keys: []string{}}, | |||
| 32 | + | }), nil) | |||
| 33 | + | ||||
| 34 | + | _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl) | |||
| 35 | + | assert.False(t, ok) | |||
| 36 | + | } | |||
| 37 | + | ||||
| 38 | + | func TestBuiltinShortcutFallback_LegacyNameWithNoKeysDisablesDefault(t *testing.T) { | |||
| 39 | + | set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{ | |||
| 40 | + | "open_find": {Keys: []string{}}, | |||
| 41 | + | }), nil) | |||
| 42 | + | ||||
| 43 | + | _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl) | |||
| 44 | + | assert.False(t, ok, "a legacy-named action with no keys disables the built-in default") | |||
| 45 | + | } | |||
| 46 | + | ||||
| 47 | + | func TestBuiltinShortcutFallback_LegacyNameRebindReplacesDefault(t *testing.T) { | |||
| 48 | + | set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{ | |||
| 49 | + | "open_find": {Keys: []string{"ctrl+shift+f"}}, | |||
| 50 | + | }), nil) | |||
| 51 | + | ||||
| 52 | + | _, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl) | |||
| 53 | + | assert.False(t, ok, "the default ctrl+f is not active next to the rebinding") | |||
| 54 | + | ||||
| 55 | + | action, ok := lookupGlobal(set, uint(gdk.KEY_f), ModCtrl|ModShift) | |||
| 56 | + | require.True(t, ok, "the legacy-named binding is registered") | |||
| 57 | + | assert.Equal(t, ActionOpenFind, action) | |||
| 58 | + | } | |||
| 59 | + | ||||
| 60 | + | func TestBuiltinShortcutFallback_LegacyNameOnlyAffectsItsOwnAction(t *testing.T) { | |||
| 61 | + | set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{ | |||
| 62 | + | "open_find": {Keys: []string{}}, | |||
| 63 | + | }), nil) | |||
| 64 | + | ||||
| 65 | + | action, ok := lookupGlobal(set, uint(gdk.KEY_l), ModCtrl) | |||
| 66 | + | require.True(t, ok) | |||
| 67 | + | assert.Equal(t, ActionOpenOmnibox, action) | |||
| 68 | + | } | |||
| 69 | + | ||||
| 70 | + | func TestBuiltinShortcutFallback_LegacyMultiWordNames(t *testing.T) { | |||
| 71 | + | set := NewShortcutSet(context.Background(), globalShortcutsConfig(map[string]entity.ActionBinding{ | |||
| 72 | + | "toggle_fullscreen": {Keys: []string{}}, | |||
| 73 | + | "hard_reload": {Keys: []string{"ctrl+alt+r"}}, | |||
| 74 | + | }), nil) | |||
| 75 | + | ||||
| 76 | + | _, ok := lookupGlobal(set, uint(gdk.KEY_F11), ModNone) | |||
| 77 | + | assert.False(t, ok, "toggle_fullscreen with no keys disables f11") | |||
| 78 | + | ||||
| 79 | + | _, ok = lookupGlobal(set, uint(gdk.KEY_R), ModCtrl|ModShift) | |||
| 80 | + | assert.False(t, ok, "the default hard-reload key is replaced") | |||
| 81 | + | ||||
| 82 | + | action, ok := lookupGlobal(set, uint(gdk.KEY_r), ModCtrl|ModAlt) | |||
| 83 | + | require.True(t, ok) | |||
| 84 | + | assert.Equal(t, ActionHardReload, action) | |||
| 85 | + | } | |||
M internal/ui/input/shortcuts.go +40 -4
| @@ -376,13 +376,39 @@ func (s *ShortcutSet) registerActivation(ctx context.Context, shortcut string, a | |||
| 376 | 376 | Msg("mode activation registered") | |
| 377 | 377 | } | |
| 378 | 378 | ||
| 379 | + | // legacyActionName returns the underscore spelling older configs use for an | |
| 380 | + | // action. It is the same rule the config loader applies when it completes | |
| 381 | + | // missing default actions, so a legacy-named action counts as configured in | |
| 382 | + | // both places. | |
| 383 | + | func legacyActionName(name string) string { | |
| 384 | + | return strings.ReplaceAll(name, "-", "_") | |
| 385 | + | } | |
| 386 | + | ||
| 387 | + | // globalShortcutAction resolves a configured global shortcut name to its | |
| 388 | + | // action. Besides the names in configActionToAction, a built-in browser action | |
| 389 | + | // is also recognized by its legacy underscore spelling. | |
| 390 | + | func globalShortcutAction(name string) (Action, bool) { | |
| 391 | + | if action, ok := configActionToAction[name]; ok { | |
| 392 | + | return action, true | |
| 393 | + | } | |
| 394 | + | for builtin := range entity.BuiltinGlobalShortcuts() { | |
| 395 | + | if legacyActionName(builtin) == name { | |
| 396 | + | action, ok := configActionToAction[builtin] | |
| 397 | + | return action, ok | |
| 398 | + | } | |
| 399 | + | } | |
| 400 | + | return "", false | |
| 401 | + | } | |
| 402 | + | ||
| 379 | 403 | // registerBuiltinShortcutFallbacks binds built-in browser shortcuts whose | |
| 380 | 404 | // action is missing from the configured global shortcuts, so configs written | |
| 381 | 405 | // before these actions became configurable keep working until migrated. | |
| 382 | 406 | func (s *ShortcutSet) registerBuiltinShortcutFallbacks(cfg *entity.WorkspaceConfig) { | |
| 383 | 407 | for name, binding := range entity.BuiltinGlobalShortcuts() { | |
| 384 | 408 | if cfg != nil { | |
| 385 | - | if _, configured := cfg.Shortcuts.Actions[name]; configured { | |
| 409 | + | _, configured := cfg.Shortcuts.Actions[name] | |
| 410 | + | _, configuredLegacy := cfg.Shortcuts.Actions[legacyActionName(name)] | |
| 411 | + | if configured || configuredLegacy { | |
| 386 | 412 | continue | |
| 387 | 413 | } | |
| 388 | 414 | } | |
| @@ -403,7 +429,7 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) { | |||
| 403 | 429 | return | |
| 404 | 430 | } | |
| 405 | 431 | for _, actionName := range orderedGlobalShortcutActions(cfg.Shortcuts.Actions) { | |
| 406 | - | action, ok := configActionToAction[actionName] | |
| 432 | + | action, ok := globalShortcutAction(actionName) | |
| 407 | 433 | if !ok { | |
| 408 | 434 | continue | |
| 409 | 435 | } | |
| @@ -421,10 +447,20 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) { | |||
| 421 | 447 | // key conflict the same across restarts. | |
| 422 | 448 | func orderedGlobalShortcutActions(actions map[string]entity.ActionBinding) []string { | |
| 423 | 449 | builtins := entity.BuiltinGlobalShortcuts() | |
| 450 | + | isBuiltin := func(name string) bool { | |
| 451 | + | if _, ok := builtins[name]; ok { | |
| 452 | + | return true | |
| 453 | + | } | |
| 454 | + | for builtin := range builtins { | |
| 455 | + | if legacyActionName(builtin) == name { | |
| 456 | + | return true | |
| 457 | + | } | |
| 458 | + | } | |
| 459 | + | return false | |
| 460 | + | } | |
| 424 | 461 | names := slices.Sorted(maps.Keys(actions)) | |
| 425 | 462 | slices.SortStableFunc(names, func(a, b string) int { | |
| 426 | - | _, aBuiltin := builtins[a] | |
| 427 | - | _, bBuiltin := builtins[b] | |
| 463 | + | aBuiltin, bBuiltin := isBuiltin(a), isBuiltin(b) | |
| 428 | 464 | switch { | |
| 429 | 465 | case aBuiltin == bBuiltin: | |
| 430 | 466 | return 0 | |
| @@ -376,13 +376,39 @@ func (s *ShortcutSet) registerActivation(ctx context.Context, shortcut string, a | |||||
| 376 | Msg("mode activation registered") | 376 | Msg("mode activation registered") | ||
| 377 | } | 377 | } | ||
| 378 | 378 | ||||
| 379 | + | // legacyActionName returns the underscore spelling older configs use for an | |||
| 380 | + | // action. It is the same rule the config loader applies when it completes | |||
| 381 | + | // missing default actions, so a legacy-named action counts as configured in | |||
| 382 | + | // both places. | |||
| 383 | + | func legacyActionName(name string) string { | |||
| 384 | + | return strings.ReplaceAll(name, "-", "_") | |||
| 385 | + | } | |||
| 386 | + | ||||
| 387 | + | // globalShortcutAction resolves a configured global shortcut name to its | |||
| 388 | + | // action. Besides the names in configActionToAction, a built-in browser action | |||
| 389 | + | // is also recognized by its legacy underscore spelling. | |||
| 390 | + | func globalShortcutAction(name string) (Action, bool) { | |||
| 391 | + | if action, ok := configActionToAction[name]; ok { | |||
| 392 | + | return action, true | |||
| 393 | + | } | |||
| 394 | + | for builtin := range entity.BuiltinGlobalShortcuts() { | |||
| 395 | + | if legacyActionName(builtin) == name { | |||
| 396 | + | action, ok := configActionToAction[builtin] | |||
| 397 | + | return action, ok | |||
| 398 | + | } | |||
| 399 | + | } | |||
| 400 | + | return "", false | |||
| 401 | + | } | |||
| 402 | + | ||||
| 379 | // registerBuiltinShortcutFallbacks binds built-in browser shortcuts whose | 403 | // registerBuiltinShortcutFallbacks binds built-in browser shortcuts whose | ||
| 380 | // action is missing from the configured global shortcuts, so configs written | 404 | // action is missing from the configured global shortcuts, so configs written | ||
| 381 | // before these actions became configurable keep working until migrated. | 405 | // before these actions became configurable keep working until migrated. | ||
| 382 | func (s *ShortcutSet) registerBuiltinShortcutFallbacks(cfg *entity.WorkspaceConfig) { | 406 | func (s *ShortcutSet) registerBuiltinShortcutFallbacks(cfg *entity.WorkspaceConfig) { | ||
| 383 | for name, binding := range entity.BuiltinGlobalShortcuts() { | 407 | for name, binding := range entity.BuiltinGlobalShortcuts() { | ||
| 384 | if cfg != nil { | 408 | if cfg != nil { | ||
| 385 | - | if _, configured := cfg.Shortcuts.Actions[name]; configured { | 409 | + | _, configured := cfg.Shortcuts.Actions[name] |
| 410 | + | _, configuredLegacy := cfg.Shortcuts.Actions[legacyActionName(name)] | |||
| 411 | + | if configured || configuredLegacy { | |||
| 386 | continue | 412 | continue | ||
| 387 | } | 413 | } | ||
| 388 | } | 414 | } | ||
| @@ -403,7 +429,7 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) { | |||||
| 403 | return | 429 | return | ||
| 404 | } | 430 | } | ||
| 405 | for _, actionName := range orderedGlobalShortcutActions(cfg.Shortcuts.Actions) { | 431 | for _, actionName := range orderedGlobalShortcutActions(cfg.Shortcuts.Actions) { | ||
| 406 | - | action, ok := configActionToAction[actionName] | 432 | + | action, ok := globalShortcutAction(actionName) |
| 407 | if !ok { | 433 | if !ok { | ||
| 408 | continue | 434 | continue | ||
| 409 | } | 435 | } | ||
| @@ -421,10 +447,20 @@ func (s *ShortcutSet) registerConfiguredShortcuts(cfg *entity.WorkspaceConfig) { | |||||
| 421 | // key conflict the same across restarts. | 447 | // key conflict the same across restarts. | ||
| 422 | func orderedGlobalShortcutActions(actions map[string]entity.ActionBinding) []string { | 448 | func orderedGlobalShortcutActions(actions map[string]entity.ActionBinding) []string { | ||
| 423 | builtins := entity.BuiltinGlobalShortcuts() | 449 | builtins := entity.BuiltinGlobalShortcuts() | ||
| 450 | + | isBuiltin := func(name string) bool { | |||
| 451 | + | if _, ok := builtins[name]; ok { | |||
| 452 | + | return true | |||
| 453 | + | } | |||
| 454 | + | for builtin := range builtins { | |||
| 455 | + | if legacyActionName(builtin) == name { | |||
| 456 | + | return true | |||
| 457 | + | } | |||
| 458 | + | } | |||
| 459 | + | return false | |||
| 460 | + | } | |||
| 424 | names := slices.Sorted(maps.Keys(actions)) | 461 | names := slices.Sorted(maps.Keys(actions)) | ||
| 425 | slices.SortStableFunc(names, func(a, b string) int { | 462 | slices.SortStableFunc(names, func(a, b string) int { | ||
| 426 | - | _, aBuiltin := builtins[a] | 463 | + | aBuiltin, bBuiltin := isBuiltin(a), isBuiltin(b) |
| 427 | - | _, bBuiltin := builtins[b] | |||
| 428 | switch { | 464 | switch { | ||
| 429 | case aBuiltin == bBuiltin: | 465 | case aBuiltin == bBuiltin: | ||
| 430 | return 0 | 466 | return 0 | ||