fix(input): treat uppercase single-letter shortcuts as Shift+letter
- commit
- 85564584974026cc4ad3d81930f65cc051465a78
- author
- brice · · 2025-12-27 15:49 UTC
- parent
- cb3ee8d
- signature
- unsigned
2 files · +46 -10
M internal/ui/input/shortcuts.go +30 -9
| @@ -134,6 +134,9 @@ const ( | |||
| 134 | 134 | ActionClosePane Action = "close_pane" | |
| 135 | 135 | ActionStackPane Action = "stack_pane" | |
| 136 | 136 | ||
| 137 | + | ActionMovePaneToTab Action = "move_pane_to_tab" | |
| 138 | + | ActionMovePaneToNextTab Action = "move_pane_to_next_tab" | |
| 139 | + | ||
| 137 | 140 | ActionConsumeOrExpelLeft Action = "consume_or_expel_left" | |
| 138 | 141 | ActionConsumeOrExpelRight Action = "consume_or_expel_right" | |
| 139 | 142 | ActionConsumeOrExpelUp Action = "consume_or_expel_up" | |
| @@ -429,12 +432,14 @@ var configActionToAction = map[string]Action{ | |||
| 429 | 432 | "rename-tab": ActionRenameTab, | |
| 430 | 433 | ||
| 431 | 434 | // Pane actions | |
| 432 | - | "split-right": ActionSplitRight, | |
| 433 | - | "split-left": ActionSplitLeft, | |
| 434 | - | "split-up": ActionSplitUp, | |
| 435 | - | "split-down": ActionSplitDown, | |
| 436 | - | "close-pane": ActionClosePane, | |
| 437 | - | "stack-pane": ActionStackPane, | |
| 435 | + | "split-right": ActionSplitRight, | |
| 436 | + | "split-left": ActionSplitLeft, | |
| 437 | + | "split-up": ActionSplitUp, | |
| 438 | + | "split-down": ActionSplitDown, | |
| 439 | + | "close-pane": ActionClosePane, | |
| 440 | + | "stack-pane": ActionStackPane, | |
| 441 | + | "move-pane-to-tab": ActionMovePaneToTab, | |
| 442 | + | "move-pane-to-next-tab": ActionMovePaneToNextTab, | |
| 438 | 443 | ||
| 439 | 444 | "consume-or-expel-left": ActionConsumeOrExpelLeft, | |
| 440 | 445 | "consume-or-expel-right": ActionConsumeOrExpelRight, | |
| @@ -484,7 +489,10 @@ func ParseKeyString(s string) (KeyBinding, bool) { | |||
| 484 | 489 | return KeyBinding{}, false | |
| 485 | 490 | } | |
| 486 | 491 | ||
| 487 | - | s = strings.ToLower(strings.TrimSpace(s)) | |
| 492 | + | s = strings.TrimSpace(s) | |
| 493 | + | if s == "" { | |
| 494 | + | return KeyBinding{}, false | |
| 495 | + | } | |
| 488 | 496 | if s == "+" { | |
| 489 | 497 | return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true | |
| 490 | 498 | } | |
| @@ -499,7 +507,9 @@ func ParseKeyString(s string) (KeyBinding, bool) { | |||
| 499 | 507 | if part == "" { | |
| 500 | 508 | continue | |
| 501 | 509 | } | |
| 502 | - | switch part { | |
| 510 | + | ||
| 511 | + | lower := strings.ToLower(part) | |
| 512 | + | switch lower { | |
| 503 | 513 | case "ctrl", "control": | |
| 504 | 514 | modifiers |= ModCtrl | |
| 505 | 515 | case "shift": | |
| @@ -523,6 +533,12 @@ func ParseKeyString(s string) (KeyBinding, bool) { | |||
| 523 | 533 | return KeyBinding{}, false | |
| 524 | 534 | } | |
| 525 | 535 | ||
| 536 | + | // Treat uppercase single-letter keys as Shift+<letter>. | |
| 537 | + | if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' { | |
| 538 | + | modifiers |= ModShift | |
| 539 | + | keyPart = strings.ToLower(keyPart) | |
| 540 | + | } | |
| 541 | + | ||
| 526 | 542 | keyval, ok := stringToKeyval(keyPart) | |
| 527 | 543 | if !ok { | |
| 528 | 544 | return KeyBinding{}, false | |
| @@ -536,7 +552,11 @@ func ParseKeyString(s string) (KeyBinding, bool) { | |||
| 536 | 552 | ||
| 537 | 553 | // stringToKeyval converts a key name to its GDK keyval. | |
| 538 | 554 | func stringToKeyval(s string) (uint, bool) { | |
| 539 | - | if keyval, ok := keyvalByName[s]; ok { | |
| 555 | + | if s == "" { | |
| 556 | + | return 0, false | |
| 557 | + | } | |
| 558 | + | ||
| 559 | + | if keyval, ok := keyvalByName[strings.ToLower(s)]; ok { | |
| 540 | 560 | return keyval, true | |
| 541 | 561 | } | |
| 542 | 562 | ||
| @@ -588,6 +608,7 @@ func ShouldAutoExitMode(action Action) bool { | |||
| 588 | 608 | case ActionNewTab, ActionCloseTab, ActionRenameTab, | |
| 589 | 609 | ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown, | |
| 590 | 610 | ActionClosePane, ActionStackPane, | |
| 611 | + | ActionMovePaneToTab, ActionMovePaneToNextTab, | |
| 591 | 612 | ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown, | |
| 592 | 613 | ActionOpenSessionManager: | |
| 593 | 614 | return true | |
| @@ -134,6 +134,9 @@ const ( | |||||
| 134 | ActionClosePane Action = "close_pane" | 134 | ActionClosePane Action = "close_pane" | ||
| 135 | ActionStackPane Action = "stack_pane" | 135 | ActionStackPane Action = "stack_pane" | ||
| 136 | 136 | ||||
| 137 | + | ActionMovePaneToTab Action = "move_pane_to_tab" | |||
| 138 | + | ActionMovePaneToNextTab Action = "move_pane_to_next_tab" | |||
| 139 | + | ||||
| 137 | ActionConsumeOrExpelLeft Action = "consume_or_expel_left" | 140 | ActionConsumeOrExpelLeft Action = "consume_or_expel_left" | ||
| 138 | ActionConsumeOrExpelRight Action = "consume_or_expel_right" | 141 | ActionConsumeOrExpelRight Action = "consume_or_expel_right" | ||
| 139 | ActionConsumeOrExpelUp Action = "consume_or_expel_up" | 142 | ActionConsumeOrExpelUp Action = "consume_or_expel_up" | ||
| @@ -429,12 +432,14 @@ var configActionToAction = map[string]Action{ | |||||
| 429 | "rename-tab": ActionRenameTab, | 432 | "rename-tab": ActionRenameTab, | ||
| 430 | 433 | ||||
| 431 | // Pane actions | 434 | // Pane actions | ||
| 432 | - | "split-right": ActionSplitRight, | 435 | + | "split-right": ActionSplitRight, |
| 433 | - | "split-left": ActionSplitLeft, | 436 | + | "split-left": ActionSplitLeft, |
| 434 | - | "split-up": ActionSplitUp, | 437 | + | "split-up": ActionSplitUp, |
| 435 | - | "split-down": ActionSplitDown, | 438 | + | "split-down": ActionSplitDown, |
| 436 | - | "close-pane": ActionClosePane, | 439 | + | "close-pane": ActionClosePane, |
| 437 | - | "stack-pane": ActionStackPane, | 440 | + | "stack-pane": ActionStackPane, |
| 441 | + | "move-pane-to-tab": ActionMovePaneToTab, | |||
| 442 | + | "move-pane-to-next-tab": ActionMovePaneToNextTab, | |||
| 438 | 443 | ||||
| 439 | "consume-or-expel-left": ActionConsumeOrExpelLeft, | 444 | "consume-or-expel-left": ActionConsumeOrExpelLeft, | ||
| 440 | "consume-or-expel-right": ActionConsumeOrExpelRight, | 445 | "consume-or-expel-right": ActionConsumeOrExpelRight, | ||
| @@ -484,7 +489,10 @@ func ParseKeyString(s string) (KeyBinding, bool) { | |||||
| 484 | return KeyBinding{}, false | 489 | return KeyBinding{}, false | ||
| 485 | } | 490 | } | ||
| 486 | 491 | ||||
| 487 | - | s = strings.ToLower(strings.TrimSpace(s)) | 492 | + | s = strings.TrimSpace(s) |
| 493 | + | if s == "" { | |||
| 494 | + | return KeyBinding{}, false | |||
| 495 | + | } | |||
| 488 | if s == "+" { | 496 | if s == "+" { | ||
| 489 | return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true | 497 | return KeyBinding{Keyval: uint(gdk.KEY_plus), Modifiers: ModNone}, true | ||
| 490 | } | 498 | } | ||
| @@ -499,7 +507,9 @@ func ParseKeyString(s string) (KeyBinding, bool) { | |||||
| 499 | if part == "" { | 507 | if part == "" { | ||
| 500 | continue | 508 | continue | ||
| 501 | } | 509 | } | ||
| 502 | - | switch part { | 510 | + | |
| 511 | + | lower := strings.ToLower(part) | |||
| 512 | + | switch lower { | |||
| 503 | case "ctrl", "control": | 513 | case "ctrl", "control": | ||
| 504 | modifiers |= ModCtrl | 514 | modifiers |= ModCtrl | ||
| 505 | case "shift": | 515 | case "shift": | ||
| @@ -523,6 +533,12 @@ func ParseKeyString(s string) (KeyBinding, bool) { | |||||
| 523 | return KeyBinding{}, false | 533 | return KeyBinding{}, false | ||
| 524 | } | 534 | } | ||
| 525 | 535 | ||||
| 536 | + | // Treat uppercase single-letter keys as Shift+<letter>. | |||
| 537 | + | if len(keyPart) == 1 && keyPart[0] >= 'A' && keyPart[0] <= 'Z' { | |||
| 538 | + | modifiers |= ModShift | |||
| 539 | + | keyPart = strings.ToLower(keyPart) | |||
| 540 | + | } | |||
| 541 | + | ||||
| 526 | keyval, ok := stringToKeyval(keyPart) | 542 | keyval, ok := stringToKeyval(keyPart) | ||
| 527 | if !ok { | 543 | if !ok { | ||
| 528 | return KeyBinding{}, false | 544 | return KeyBinding{}, false | ||
| @@ -536,7 +552,11 @@ func ParseKeyString(s string) (KeyBinding, bool) { | |||||
| 536 | 552 | ||||
| 537 | // stringToKeyval converts a key name to its GDK keyval. | 553 | // stringToKeyval converts a key name to its GDK keyval. | ||
| 538 | func stringToKeyval(s string) (uint, bool) { | 554 | func stringToKeyval(s string) (uint, bool) { | ||
| 539 | - | if keyval, ok := keyvalByName[s]; ok { | 555 | + | if s == "" { |
| 556 | + | return 0, false | |||
| 557 | + | } | |||
| 558 | + | ||||
| 559 | + | if keyval, ok := keyvalByName[strings.ToLower(s)]; ok { | |||
| 540 | return keyval, true | 560 | return keyval, true | ||
| 541 | } | 561 | } | ||
| 542 | 562 | ||||
| @@ -588,6 +608,7 @@ func ShouldAutoExitMode(action Action) bool { | |||||
| 588 | case ActionNewTab, ActionCloseTab, ActionRenameTab, | 608 | case ActionNewTab, ActionCloseTab, ActionRenameTab, | ||
| 589 | ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown, | 609 | ActionSplitRight, ActionSplitLeft, ActionSplitUp, ActionSplitDown, | ||
| 590 | ActionClosePane, ActionStackPane, | 610 | ActionClosePane, ActionStackPane, | ||
| 611 | + | ActionMovePaneToTab, ActionMovePaneToNextTab, | |||
| 591 | ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown, | 612 | ActionConsumeOrExpelLeft, ActionConsumeOrExpelRight, ActionConsumeOrExpelUp, ActionConsumeOrExpelDown, | ||
| 592 | ActionOpenSessionManager: | 613 | ActionOpenSessionManager: | ||
| 593 | return true | 614 | return true | ||
M internal/ui/input/shortcuts_test.go +16 -1
| @@ -207,9 +207,11 @@ func TestParseKeyString_CaseInsensitive(t *testing.T) { | |||
| 207 | 207 | input1 string | |
| 208 | 208 | input2 string | |
| 209 | 209 | }{ | |
| 210 | - | {"ctrl+t", "CTRL+T"}, | |
| 210 | + | // Modifiers and named keys are case-insensitive. | |
| 211 | + | {"ctrl+t", "CTRL+t"}, | |
| 211 | 212 | {"Escape", "escape"}, | |
| 212 | 213 | {"Tab", "TAB"}, | |
| 214 | + | // If shift is explicitly present, the key letter case shouldn't matter. | |
| 213 | 215 | {"Ctrl+Shift+T", "ctrl+shift+t"}, | |
| 214 | 216 | } | |
| 215 | 217 | ||
| @@ -230,6 +232,17 @@ func TestParseKeyString_CaseInsensitive(t *testing.T) { | |||
| 230 | 232 | } | |
| 231 | 233 | } | |
| 232 | 234 | ||
| 235 | + | func TestParseKeyString_UppercaseSingleLetterAddsShift(t *testing.T) { | |
| 236 | + | got1, ok1 := ParseKeyString("M") | |
| 237 | + | got2, ok2 := ParseKeyString("shift+m") | |
| 238 | + | if !ok1 || !ok2 { | |
| 239 | + | t.Fatalf("ParseKeyString should succeed for M and shift+m") | |
| 240 | + | } | |
| 241 | + | if got1 != got2 { | |
| 242 | + | t.Fatalf("ParseKeyString uppercase should imply shift: got %v want %v", got1, got2) | |
| 243 | + | } | |
| 244 | + | } | |
| 245 | + | ||
| 233 | 246 | func TestShortcutSet_Lookup(t *testing.T) { | |
| 234 | 247 | set := &ShortcutSet{ | |
| 235 | 248 | Global: ShortcutTable{ | |
| @@ -336,6 +349,8 @@ func TestShouldAutoExitMode(t *testing.T) { | |||
| 336 | 349 | ActionSplitDown, | |
| 337 | 350 | ActionClosePane, | |
| 338 | 351 | ActionStackPane, | |
| 352 | + | ActionMovePaneToTab, | |
| 353 | + | ActionMovePaneToNextTab, | |
| 339 | 354 | } | |
| 340 | 355 | ||
| 341 | 356 | stayActions := []Action{ | |
| @@ -207,9 +207,11 @@ func TestParseKeyString_CaseInsensitive(t *testing.T) { | |||||
| 207 | input1 string | 207 | input1 string | ||
| 208 | input2 string | 208 | input2 string | ||
| 209 | }{ | 209 | }{ | ||
| 210 | - | {"ctrl+t", "CTRL+T"}, | 210 | + | // Modifiers and named keys are case-insensitive. |
| 211 | + | {"ctrl+t", "CTRL+t"}, | |||
| 211 | {"Escape", "escape"}, | 212 | {"Escape", "escape"}, | ||
| 212 | {"Tab", "TAB"}, | 213 | {"Tab", "TAB"}, | ||
| 214 | + | // If shift is explicitly present, the key letter case shouldn't matter. | |||
| 213 | {"Ctrl+Shift+T", "ctrl+shift+t"}, | 215 | {"Ctrl+Shift+T", "ctrl+shift+t"}, | ||
| 214 | } | 216 | } | ||
| 215 | 217 | ||||
| @@ -230,6 +232,17 @@ func TestParseKeyString_CaseInsensitive(t *testing.T) { | |||||
| 230 | } | 232 | } | ||
| 231 | } | 233 | } | ||
| 232 | 234 | ||||
| 235 | + | func TestParseKeyString_UppercaseSingleLetterAddsShift(t *testing.T) { | |||
| 236 | + | got1, ok1 := ParseKeyString("M") | |||
| 237 | + | got2, ok2 := ParseKeyString("shift+m") | |||
| 238 | + | if !ok1 || !ok2 { | |||
| 239 | + | t.Fatalf("ParseKeyString should succeed for M and shift+m") | |||
| 240 | + | } | |||
| 241 | + | if got1 != got2 { | |||
| 242 | + | t.Fatalf("ParseKeyString uppercase should imply shift: got %v want %v", got1, got2) | |||
| 243 | + | } | |||
| 244 | + | } | |||
| 245 | + | ||||
| 233 | func TestShortcutSet_Lookup(t *testing.T) { | 246 | func TestShortcutSet_Lookup(t *testing.T) { | ||
| 234 | set := &ShortcutSet{ | 247 | set := &ShortcutSet{ | ||
| 235 | Global: ShortcutTable{ | 248 | Global: ShortcutTable{ | ||
| @@ -336,6 +349,8 @@ func TestShouldAutoExitMode(t *testing.T) { | |||||
| 336 | ActionSplitDown, | 349 | ActionSplitDown, | ||
| 337 | ActionClosePane, | 350 | ActionClosePane, | ||
| 338 | ActionStackPane, | 351 | ActionStackPane, | ||
| 352 | + | ActionMovePaneToTab, | |||
| 353 | + | ActionMovePaneToNextTab, | |||
| 339 | } | 354 | } | ||
| 340 | 355 | ||||
| 341 | stayActions := []Action{ | 356 | stayActions := []Action{ | ||