mirror of
https://github.com/kovidgoyal/kitty
synced 2026-07-18 14:34:52 +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:
@@ -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