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:
Павел Мешалкин
2026-03-25 19:34:13 +03:00
parent 79bde7f9a9
commit 8ffdf7d7ee
20 changed files with 987 additions and 227 deletions

View File

@@ -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

View File

@@ -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`,

View File

@@ -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

View File

@@ -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")
}
}