mirror of
https://github.com/kovidgoyal/kitty
synced 2026-07-09 05:35:19 +02:00
feat: add per-mapping --allow-fallback for layout-independent shortcuts
Add --allow-fallback option to the map command that controls shifted and ascii (alternate_key) fallback for individual key mappings. For non-Latin keyboard layouts, when the current layout key is non-ascii (codepoint > 127 and < 0xE000), the alternate_key from the base layout is used for matching if the mapping opts in via --allow-fallback=shifted,ascii. Default kitty bindings use --allow-fallback=shifted,ascii so they work out of the box with non-Latin layouts. User custom mappings default to --allow-fallback=shifted (preserving existing shifted_key behavior without ascii fallback). --allow-fallback=none disables all fallback for a mapping. Python side: parse_options_for_map() in options/utils.py handles flag parsing, ShortcutMapping uses it in __init__. get_shortcut() filters candidates by per-mapping allow_fallback. Go side: ParseMap() handles --allow-fallback, KeyAction stores AllowFallback, ShortcutTracker.Match passes it to matching. MatchesParsedShortcut defaults to shifted,ascii for hardcoded shortcuts. Migrated kittens (themes, command_palette, diff, choose_files) to use ShortcutTracker with configurable map entries. Tests added for Python (5 test methods) and Go (ParseMap + key matching).
This commit is contained in:
@@ -248,13 +248,74 @@ type KeyAction struct {
|
||||
Normalized_keys []string
|
||||
Name string
|
||||
Args string
|
||||
AllowFallback string
|
||||
}
|
||||
|
||||
func (self *KeyAction) String() string {
|
||||
return fmt.Sprintf("map %#v %#v %#v\n", strings.Join(self.Normalized_keys, ">"), self.Name, self.Args)
|
||||
}
|
||||
|
||||
func validateAllowFallback(value string) error {
|
||||
if value == "" || value == "none" {
|
||||
return nil
|
||||
}
|
||||
for _, part := range strings.Split(value, ",") {
|
||||
part = strings.TrimSpace(part)
|
||||
if part != "shifted" && part != "ascii" {
|
||||
return fmt.Errorf("Invalid allow-fallback value %#v, allowed values: shifted, ascii, none", part)
|
||||
}
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
func ParseMap(val string) (*KeyAction, error) {
|
||||
allow_fallback := "shifted"
|
||||
for strings.HasPrefix(val, "--") {
|
||||
flag, rest, found := strings.Cut(val, " ")
|
||||
if !found {
|
||||
return nil, fmt.Errorf("No key specified after flag %s", flag)
|
||||
}
|
||||
rest = strings.TrimSpace(rest)
|
||||
if name, value, ok := strings.Cut(flag, "="); ok {
|
||||
// --flag=value form
|
||||
name = strings.ReplaceAll(name[2:], "-", "_")
|
||||
switch name {
|
||||
case "allow_fallback":
|
||||
if err := validateAllowFallback(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value == "none" {
|
||||
allow_fallback = ""
|
||||
} else {
|
||||
allow_fallback = value
|
||||
}
|
||||
default:
|
||||
return nil, fmt.Errorf("Unknown map option: %s", flag)
|
||||
}
|
||||
} else {
|
||||
// --flag value form (space-separated)
|
||||
name := strings.ReplaceAll(flag[2:], "-", "_")
|
||||
switch name {
|
||||
case "allow_fallback":
|
||||
value, after, has_more := strings.Cut(rest, " ")
|
||||
if !has_more {
|
||||
return nil, fmt.Errorf("No key specified after %s %s", flag, value)
|
||||
}
|
||||
if err := validateAllowFallback(value); err != nil {
|
||||
return nil, err
|
||||
}
|
||||
if value == "none" {
|
||||
allow_fallback = ""
|
||||
} else {
|
||||
allow_fallback = value
|
||||
}
|
||||
rest = strings.TrimSpace(after)
|
||||
default:
|
||||
return nil, fmt.Errorf("Unknown map option: %s", flag)
|
||||
}
|
||||
}
|
||||
val = rest
|
||||
}
|
||||
spec, action, found := strings.Cut(val, " ")
|
||||
if !found {
|
||||
return nil, fmt.Errorf("No action specified for shortcut %s", val)
|
||||
@@ -262,7 +323,7 @@ func ParseMap(val string) (*KeyAction, error) {
|
||||
action = strings.TrimSpace(action)
|
||||
action_name, action_args, _ := strings.Cut(action, " ")
|
||||
action_args = strings.TrimSpace(action_args)
|
||||
return &KeyAction{Name: action_name, Args: action_args, Normalized_keys: NormalizeShortcuts(spec)}, nil
|
||||
return &KeyAction{Name: action_name, Args: action_args, Normalized_keys: NormalizeShortcuts(spec), AllowFallback: allow_fallback}, nil
|
||||
}
|
||||
|
||||
type ShortcutTracker struct {
|
||||
@@ -274,7 +335,7 @@ func (self *ShortcutTracker) Match(ev *loop.KeyEvent, all_actions []*KeyAction)
|
||||
if self.partial_num_consumed > 0 {
|
||||
ev.Handled = true
|
||||
self.partial_matches = utils.Filter(self.partial_matches, func(ac *KeyAction) bool {
|
||||
return self.partial_num_consumed < len(ac.Normalized_keys) && ev.MatchesPressOrRepeat(ac.Normalized_keys[self.partial_num_consumed])
|
||||
return self.partial_num_consumed < len(ac.Normalized_keys) && ev.MatchesPressOrRepeatWithFallback(ac.Normalized_keys[self.partial_num_consumed], ac.AllowFallback)
|
||||
})
|
||||
if len(self.partial_matches) == 0 {
|
||||
self.partial_num_consumed = 0
|
||||
@@ -282,7 +343,7 @@ func (self *ShortcutTracker) Match(ev *loop.KeyEvent, all_actions []*KeyAction)
|
||||
}
|
||||
} else {
|
||||
self.partial_matches = utils.Filter(all_actions, func(ac *KeyAction) bool {
|
||||
return ev.MatchesPressOrRepeat(ac.Normalized_keys[0])
|
||||
return ev.MatchesPressOrRepeatWithFallback(ac.Normalized_keys[0], ac.AllowFallback)
|
||||
})
|
||||
if len(self.partial_matches) == 0 {
|
||||
return nil
|
||||
|
||||
@@ -28,6 +28,140 @@ func TestStringLiteralParsing(t *testing.T) {
|
||||
}
|
||||
}
|
||||
|
||||
func TestParseMap(t *testing.T) {
|
||||
// Test without --allow-fallback (default "shifted")
|
||||
ka, err := ParseMap("ctrl+c copy_to_clipboard")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ka.AllowFallback != "shifted" {
|
||||
t.Fatalf("Expected AllowFallback 'shifted', got %#v", ka.AllowFallback)
|
||||
}
|
||||
if ka.Name != "copy_to_clipboard" {
|
||||
t.Fatalf("Expected Name 'copy_to_clipboard', got %#v", ka.Name)
|
||||
}
|
||||
if diff := cmp.Diff([]string{"ctrl+c"}, ka.Normalized_keys); diff != "" {
|
||||
t.Fatalf("Keys mismatch:\n%s", diff)
|
||||
}
|
||||
|
||||
// Test with --allow-fallback=ascii
|
||||
ka, err = ParseMap("--allow-fallback=ascii ctrl+c copy_to_clipboard")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ka.AllowFallback != "ascii" {
|
||||
t.Fatalf("Expected AllowFallback 'ascii', got %#v", ka.AllowFallback)
|
||||
}
|
||||
if ka.Name != "copy_to_clipboard" {
|
||||
t.Fatalf("Expected Name 'copy_to_clipboard', got %#v", ka.Name)
|
||||
}
|
||||
|
||||
// Test with --allow-fallback=shifted,ascii
|
||||
ka, err = ParseMap("--allow-fallback=shifted,ascii cmd+c copy_to_clipboard")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ka.AllowFallback != "shifted,ascii" {
|
||||
t.Fatalf("Expected AllowFallback 'shifted,ascii', got %#v", ka.AllowFallback)
|
||||
}
|
||||
if ka.Name != "copy_to_clipboard" {
|
||||
t.Fatalf("Expected Name 'copy_to_clipboard', got %#v", ka.Name)
|
||||
}
|
||||
if diff := cmp.Diff([]string{"super+c"}, ka.Normalized_keys); diff != "" {
|
||||
t.Fatalf("Keys mismatch:\n%s", diff)
|
||||
}
|
||||
|
||||
// Test with --allow-fallback and action args
|
||||
ka, err = ParseMap("--allow-fallback=shifted,ascii ctrl+shift+f launch --type=tab grep")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ka.AllowFallback != "shifted,ascii" {
|
||||
t.Fatalf("Expected AllowFallback 'shifted,ascii', got %#v", ka.AllowFallback)
|
||||
}
|
||||
if ka.Name != "launch" {
|
||||
t.Fatalf("Expected Name 'launch', got %#v", ka.Name)
|
||||
}
|
||||
if ka.Args != "--type=tab grep" {
|
||||
t.Fatalf("Expected Args '--type=tab grep', got %#v", ka.Args)
|
||||
}
|
||||
|
||||
// Test space form: --allow-fallback ascii (without =)
|
||||
ka, err = ParseMap("--allow-fallback ascii ctrl+c copy_to_clipboard")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ka.AllowFallback != "ascii" {
|
||||
t.Fatalf("Expected AllowFallback 'ascii', got %#v", ka.AllowFallback)
|
||||
}
|
||||
if ka.Name != "copy_to_clipboard" {
|
||||
t.Fatalf("Expected Name 'copy_to_clipboard', got %#v", ka.Name)
|
||||
}
|
||||
if diff := cmp.Diff([]string{"ctrl+c"}, ka.Normalized_keys); diff != "" {
|
||||
t.Fatalf("Keys mismatch:\n%s", diff)
|
||||
}
|
||||
|
||||
// Test space form: --allow-fallback shifted,ascii
|
||||
ka, err = ParseMap("--allow-fallback shifted,ascii cmd+c copy_to_clipboard")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ka.AllowFallback != "shifted,ascii" {
|
||||
t.Fatalf("Expected AllowFallback 'shifted,ascii', got %#v", ka.AllowFallback)
|
||||
}
|
||||
if ka.Name != "copy_to_clipboard" {
|
||||
t.Fatalf("Expected Name 'copy_to_clipboard', got %#v", ka.Name)
|
||||
}
|
||||
|
||||
// Test --allow-fallback=none (equals form)
|
||||
ka, err = ParseMap("--allow-fallback=none ctrl+c copy_to_clipboard")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ka.AllowFallback != "" {
|
||||
t.Fatalf("Expected AllowFallback '', got %#v", ka.AllowFallback)
|
||||
}
|
||||
if ka.Name != "copy_to_clipboard" {
|
||||
t.Fatalf("Expected Name 'copy_to_clipboard', got %#v", ka.Name)
|
||||
}
|
||||
|
||||
// Test --allow-fallback none (space form)
|
||||
ka, err = ParseMap("--allow-fallback none ctrl+c copy_to_clipboard")
|
||||
if err != nil {
|
||||
t.Fatal(err)
|
||||
}
|
||||
if ka.AllowFallback != "" {
|
||||
t.Fatalf("Expected AllowFallback '', got %#v", ka.AllowFallback)
|
||||
}
|
||||
if ka.Name != "copy_to_clipboard" {
|
||||
t.Fatalf("Expected Name 'copy_to_clipboard', got %#v", ka.Name)
|
||||
}
|
||||
|
||||
// Test error: unknown flag
|
||||
_, err = ParseMap("--allow-fallbak=ascii ctrl+c copy")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for unknown flag --allow-fallbak")
|
||||
}
|
||||
|
||||
// Test error: unknown flag without =
|
||||
_, err = ParseMap("--unknown ctrl+c copy")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for unknown flag --unknown")
|
||||
}
|
||||
|
||||
// Test error: invalid allow-fallback value
|
||||
_, err = ParseMap("--allow-fallback=typo ctrl+c copy")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid allow-fallback value 'typo'")
|
||||
}
|
||||
|
||||
// Test error: invalid allow-fallback value in space form
|
||||
_, err = ParseMap("--allow-fallback typo ctrl+c copy")
|
||||
if err == nil {
|
||||
t.Fatal("Expected error for invalid allow-fallback value 'typo' in space form")
|
||||
}
|
||||
}
|
||||
|
||||
func TestNormalizeShortcuts(t *testing.T) {
|
||||
for q, expected_ := range map[string]string{
|
||||
`a`: `a`,
|
||||
|
||||
@@ -7,6 +7,7 @@ import (
|
||||
"math"
|
||||
"strconv"
|
||||
"strings"
|
||||
"unicode/utf8"
|
||||
|
||||
"github.com/kovidgoyal/kitty"
|
||||
)
|
||||
@@ -285,7 +286,12 @@ func ParseShortcut(spec string) *ParsedShortcut {
|
||||
return &ans
|
||||
}
|
||||
|
||||
func (self *KeyEvent) MatchesParsedShortcut(ps *ParsedShortcut, event_type KeyEventType) bool {
|
||||
func isNonASCIIKey(key string) bool {
|
||||
r, size := utf8.DecodeRuneInString(key)
|
||||
return size > 0 && size == len(key) && r > 127 && r < 0xE000 && r != utf8.RuneError
|
||||
}
|
||||
|
||||
func (self *KeyEvent) MatchesParsedShortcutWithFallback(ps *ParsedShortcut, event_type KeyEventType, allowFallback string) bool {
|
||||
if self.Type&event_type == 0 {
|
||||
return false
|
||||
}
|
||||
@@ -293,12 +299,19 @@ func (self *KeyEvent) MatchesParsedShortcut(ps *ParsedShortcut, event_type KeyEv
|
||||
if mods == ps.Mods && self.Key == ps.KeyName {
|
||||
return true
|
||||
}
|
||||
if self.ShiftedKey != "" && mods&SHIFT != 0 && (mods & ^SHIFT) == ps.Mods && self.ShiftedKey == ps.KeyName {
|
||||
if strings.Contains(allowFallback, "shifted") && self.ShiftedKey != "" && mods&SHIFT != 0 && (mods & ^SHIFT) == ps.Mods && self.ShiftedKey == ps.KeyName {
|
||||
return true
|
||||
}
|
||||
if strings.Contains(allowFallback, "ascii") && self.AlternateKey != "" && isNonASCIIKey(self.Key) && mods == ps.Mods && self.AlternateKey == ps.KeyName {
|
||||
return true
|
||||
}
|
||||
return false
|
||||
}
|
||||
|
||||
func (self *KeyEvent) MatchesParsedShortcut(ps *ParsedShortcut, event_type KeyEventType) bool {
|
||||
return self.MatchesParsedShortcutWithFallback(ps, event_type, "shifted,ascii")
|
||||
}
|
||||
|
||||
func (self *KeyEvent) Matches(spec string, event_type KeyEventType) bool {
|
||||
return self.MatchesParsedShortcut(ParseShortcut(spec), event_type)
|
||||
}
|
||||
@@ -307,6 +320,10 @@ func (self *KeyEvent) MatchesPressOrRepeat(spec string) bool {
|
||||
return self.MatchesParsedShortcut(ParseShortcut(spec), PRESS|REPEAT)
|
||||
}
|
||||
|
||||
func (self *KeyEvent) MatchesPressOrRepeatWithFallback(spec string, allowFallback string) bool {
|
||||
return self.MatchesParsedShortcutWithFallback(ParseShortcut(spec), PRESS|REPEAT, allowFallback)
|
||||
}
|
||||
|
||||
func (self *KeyEvent) MatchesCaseSensitiveTextOrKey(spec string) bool {
|
||||
if self.MatchesParsedShortcut(ParseShortcut(spec), PRESS|REPEAT) {
|
||||
return true
|
||||
|
||||
@@ -28,3 +28,108 @@ func TestKeyEventFromCSI(t *testing.T) {
|
||||
test_text("121;;121u", "y", "")
|
||||
test_text("121::122;;121u", "y", "z")
|
||||
}
|
||||
|
||||
func TestIsNonASCIIKey(t *testing.T) {
|
||||
if !isNonASCIIKey("с") { // Cyrillic с (U+0441)
|
||||
t.Fatal("Cyrillic с should be non-ASCII")
|
||||
}
|
||||
if isNonASCIIKey("c") { // Latin c
|
||||
t.Fatal("Latin c should be ASCII")
|
||||
}
|
||||
if isNonASCIIKey("") {
|
||||
t.Fatal("empty string should not be non-ASCII")
|
||||
}
|
||||
// boundary: U+0080 (first non-ASCII) should be true
|
||||
if !isNonASCIIKey("\u0080") {
|
||||
t.Fatal("U+0080 should be non-ASCII")
|
||||
}
|
||||
// boundary: U+007F (DEL, last ASCII) should be false
|
||||
if isNonASCIIKey("\u007f") {
|
||||
t.Fatal("U+007F should be ASCII")
|
||||
}
|
||||
// boundary: U+D7FF (last valid BMP char before surrogates) should be true
|
||||
if !isNonASCIIKey("\uD7FF") {
|
||||
t.Fatal("U+D7FF should be non-ASCII (before PUA)")
|
||||
}
|
||||
// boundary: U+E000 (first PUA, functional key range) should be false
|
||||
if isNonASCIIKey("\uE000") {
|
||||
t.Fatal("U+E000 should be excluded (functional key range)")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesParsedShortcutWithFallback(t *testing.T) {
|
||||
ps := ParseShortcut("ctrl+c")
|
||||
|
||||
// Per-mapping ascii match: Cyrillic key + AlternateKey=c + allow_fallback contains ascii
|
||||
ev := &KeyEvent{Type: PRESS, Mods: CTRL, Key: "с", AlternateKey: "c"}
|
||||
if !ev.MatchesParsedShortcutWithFallback(ps, PRESS, "shifted,ascii") {
|
||||
t.Fatal("should match via AlternateKey with allow_fallback=shifted,ascii")
|
||||
}
|
||||
if !ev.MatchesParsedShortcutWithFallback(ps, PRESS, "ascii") {
|
||||
t.Fatal("should match via AlternateKey with allow_fallback=ascii")
|
||||
}
|
||||
|
||||
// Per-mapping no ascii match when allow_fallback=shifted only
|
||||
if ev.MatchesParsedShortcutWithFallback(ps, PRESS, "shifted") {
|
||||
t.Fatal("should NOT match via AlternateKey with allow_fallback=shifted (no ascii)")
|
||||
}
|
||||
|
||||
// No fallback at all
|
||||
if ev.MatchesParsedShortcutWithFallback(ps, PRESS, "") {
|
||||
t.Fatal("should NOT match with empty allow_fallback")
|
||||
}
|
||||
|
||||
// Direct Key match takes priority (always works regardless of allow_fallback)
|
||||
evDirect := &KeyEvent{Type: PRESS, Mods: CTRL, Key: "c"}
|
||||
if !evDirect.MatchesParsedShortcutWithFallback(ps, PRESS, "") {
|
||||
t.Fatal("direct Key match should work even with empty allow_fallback")
|
||||
}
|
||||
if !evDirect.MatchesParsedShortcutWithFallback(ps, PRESS, "shifted") {
|
||||
t.Fatal("direct Key match should work with any allow_fallback")
|
||||
}
|
||||
|
||||
// No AlternateKey match when Key is ASCII (Dvorak safety: non-ASCII guard)
|
||||
evDvorak := &KeyEvent{Type: PRESS, Mods: CTRL, Key: "x", AlternateKey: "c"}
|
||||
if evDvorak.MatchesParsedShortcutWithFallback(ps, PRESS, "ascii") {
|
||||
t.Fatal("should NOT match via AlternateKey when Key is ASCII (Dvorak)")
|
||||
}
|
||||
|
||||
// Shifted fallback respects per-mapping allow_fallback
|
||||
psA := ParseShortcut("a")
|
||||
evShifted := &KeyEvent{Type: PRESS, Mods: SHIFT, Key: "A", ShiftedKey: "a"}
|
||||
if !evShifted.MatchesParsedShortcutWithFallback(psA, PRESS, "shifted") {
|
||||
t.Fatal("shifted fallback should work with allow_fallback=shifted")
|
||||
}
|
||||
if evShifted.MatchesParsedShortcutWithFallback(psA, PRESS, "ascii") {
|
||||
t.Fatal("shifted fallback should NOT work with allow_fallback=ascii only")
|
||||
}
|
||||
if evShifted.MatchesParsedShortcutWithFallback(psA, PRESS, "") {
|
||||
t.Fatal("shifted fallback should NOT work with empty allow_fallback")
|
||||
}
|
||||
}
|
||||
|
||||
func TestMatchesParsedShortcutUnconditionalAlternateKey(t *testing.T) {
|
||||
// Unconditional match via MatchesPressOrRepeat (hardcoded shortcuts)
|
||||
ev := &KeyEvent{Type: PRESS, Mods: CTRL, Key: "с", AlternateKey: "c"}
|
||||
if !ev.MatchesPressOrRepeat("ctrl+c") {
|
||||
t.Fatal("MatchesPressOrRepeat should match via AlternateKey with non-ASCII guard")
|
||||
}
|
||||
|
||||
// Direct Key match takes priority in unconditional mode too
|
||||
evDirect := &KeyEvent{Type: PRESS, Mods: CTRL, Key: "c"}
|
||||
if !evDirect.MatchesPressOrRepeat("ctrl+c") {
|
||||
t.Fatal("direct Key match should work in unconditional mode")
|
||||
}
|
||||
|
||||
// No AlternateKey match when Key is ASCII (Dvorak safety)
|
||||
evDvorak := &KeyEvent{Type: PRESS, Mods: CTRL, Key: "x", AlternateKey: "c"}
|
||||
if evDvorak.MatchesPressOrRepeat("ctrl+c") {
|
||||
t.Fatal("should NOT match via AlternateKey when Key is ASCII in unconditional mode")
|
||||
}
|
||||
|
||||
// ShiftedKey still works unconditionally
|
||||
evShifted := &KeyEvent{Type: PRESS, Mods: SHIFT, Key: "A", ShiftedKey: "a"}
|
||||
if !evShifted.MatchesPressOrRepeat("a") {
|
||||
t.Fatal("ShiftedKey should match unconditionally in MatchesParsedShortcut")
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user