kitty/tools/tui/loop/key-encoding_test.go
Павел Мешалкин 8ffdf7d7ee 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).
2026-03-25 19:34:13 +03:00

135 lines
4.8 KiB
Go
Raw Blame History

This file contains ambiguous Unicode characters

This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.

// License: GPLv3 Copyright: 2023, Kovid Goyal, <kovid at kovidgoyal.net>
package loop
import (
"fmt"
"testing"
"github.com/google/go-cmp/cmp"
)
var _ = fmt.Print
func TestKeyEventFromCSI(t *testing.T) {
test_text := func(csi string, expected, alternate string) {
ev := KeyEventFromCSI(csi)
if ev == nil {
t.Fatalf("Failed to get parse %#v", csi)
}
if diff := cmp.Diff(expected, ev.Text); diff != "" {
t.Fatalf("Failed to get text from %#v:\n%s", csi, diff)
}
if diff := cmp.Diff(alternate, ev.AlternateKey); diff != "" {
t.Fatalf("Failed to get alternate from %#v:\n%s", csi, diff)
}
}
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")
}
}