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