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

@@ -3,8 +3,59 @@
import sys
from kitty.conf.types import Definition
from kitty.simple_cli_definitions import CompletionSpec
definition = Definition(
'!kittens.themes',
)
agr = definition.add_group
egr = definition.end_group
map = definition.add_map
# shortcuts {{{
agr('shortcuts', 'Keyboard shortcuts')
# Browsing mode shortcuts
map('Quit',
'quit --allow-fallback=shifted,ascii q quit',
)
map('Scroll down',
'scroll_down --allow-fallback=shifted,ascii j scroll_down',
)
map('Scroll up',
'scroll_up --allow-fallback=shifted,ascii k scroll_up',
)
map('Start search',
'search --allow-fallback=shifted,ascii s search',
)
map('Accept theme',
'accept --allow-fallback=shifted,ascii c accept',
)
# Accepting mode shortcuts
map('Abort and return to browsing',
'abort --allow-fallback=shifted,ascii a abort',
)
map('Place theme file',
'place_theme --allow-fallback=shifted,ascii p place_theme',
)
map('Modify config file',
'modify_conf --allow-fallback=shifted,ascii m modify_conf',
)
map('Save as dark scheme',
'dark_scheme --allow-fallback=shifted,ascii d dark_scheme',
)
map('Save as light scheme',
'light_scheme --allow-fallback=shifted,ascii l light_scheme',
)
map('Save as no preference scheme',
'no_preference --allow-fallback=shifted,ascii n no_preference',
)
egr() # }}}
help_text = (
'Change the kitty theme. If no theme name is supplied, run interactively, otherwise'
' change the current theme to the specified theme name.'
@@ -57,3 +108,5 @@ elif __name__ == '__doc__':
cd['help_text'] = help_text
cd['short_desc'] = 'Manage kitty color schemes easily'
cd['args_completion'] = CompletionSpec.from_string('type:special group:complete_themes')
elif __name__ == '__conf__':
sys.options_definition = definition # type: ignore

View File

@@ -62,15 +62,17 @@ type handler struct {
opts *Options
cached_data *CachedData
state State
fetch_result chan fetch_data
all_themes *themes.Themes
themes_closer io.Closer
themes_list *ThemesList
category_filters map[string]func(*themes.Theme) bool
colors_set_once bool
tabs []string
rl *readline.Readline
state State
fetch_result chan fetch_data
all_themes *themes.Themes
themes_closer io.Closer
themes_list *ThemesList
category_filters map[string]func(*themes.Theme) bool
colors_set_once bool
tabs []string
rl *readline.Readline
shortcut_tracker config.ShortcutTracker
keyboard_shortcuts []*config.KeyAction
}
// fetching {{{
@@ -123,6 +125,7 @@ func (self *handler) initialize() {
self.category_filters = make(map[string]func(*themes.Theme) bool, len(category_filters)+1)
maps.Copy(self.category_filters, category_filters)
self.category_filters["recent"] = recent_filter(self.cached_data.Recent)
self.keyboard_shortcuts = config.ResolveShortcuts(NewConfig().KeyboardShortcuts)
go self.fetch_themes()
self.draw_screen()
}
@@ -230,7 +233,7 @@ func (self *handler) next(delta int, allow_wrapping bool) {
}
func (self *handler) on_browsing_key_event(ev *loop.KeyEvent) error {
if ev.MatchesPressOrRepeat("esc") || ev.MatchesCaseInsensitiveTextOrKey("q") {
if ev.MatchesPressOrRepeat("esc") {
self.lp.Quit(0)
ev.Handled = true
return nil
@@ -255,12 +258,12 @@ func (self *handler) on_browsing_key_event(ev *loop.KeyEvent) error {
ev.Handled = true
return nil
}
if ev.MatchesCaseInsensitiveTextOrKey("j") || ev.MatchesPressOrRepeat("down") {
if ev.MatchesPressOrRepeat("down") {
self.next(1, true)
ev.Handled = true
return nil
}
if ev.MatchesCaseInsensitiveTextOrKey("k") || ev.MatchesPressOrRepeat("up") {
if ev.MatchesPressOrRepeat("up") {
self.next(-1, true)
ev.Handled = true
return nil
@@ -281,12 +284,12 @@ func (self *handler) on_browsing_key_event(ev *loop.KeyEvent) error {
}
return nil
}
if ev.MatchesCaseInsensitiveTextOrKey("s") || ev.MatchesCaseInsensitiveTextOrKey("/") {
if ev.MatchesCaseInsensitiveTextOrKey("/") {
ev.Handled = true
self.start_search()
return nil
}
if ev.MatchesCaseInsensitiveTextOrKey("c") || ev.MatchesPressOrRepeat("enter") {
if ev.MatchesPressOrRepeat("enter") {
ev.Handled = true
if self.themes_list == nil || self.themes_list.Len() == 0 {
self.lp.Beep()
@@ -294,6 +297,27 @@ func (self *handler) on_browsing_key_event(ev *loop.KeyEvent) error {
self.state = ACCEPTING
self.draw_screen()
}
return nil
}
if ac := self.shortcut_tracker.Match(ev, self.keyboard_shortcuts); ac != nil {
switch ac.Name {
case "quit":
self.lp.Quit(0)
case "scroll_down":
self.next(1, true)
case "scroll_up":
self.next(-1, true)
case "search":
self.start_search()
case "accept":
if self.themes_list == nil || self.themes_list.Len() == 0 {
self.lp.Beep()
} else {
self.state = ACCEPTING
self.draw_screen()
}
}
return nil
}
return nil
}
@@ -495,49 +519,41 @@ func (self *handler) draw_theme_demo() {
// accepting {{{
func (self *handler) on_accepting_key_event(ev *loop.KeyEvent) error {
if ev.MatchesCaseInsensitiveTextOrKey("q") || ev.MatchesPressOrRepeat("esc") || ev.MatchesPressOrRepeat("shift+q") {
if ev.MatchesPressOrRepeat("esc") {
ev.Handled = true
self.lp.Quit(0)
return nil
}
if ev.MatchesCaseInsensitiveTextOrKey("a") || ev.MatchesPressOrRepeat("shift+a") {
ev.Handled = true
self.state = BROWSING
self.draw_screen()
if ac := self.shortcut_tracker.Match(ev, self.keyboard_shortcuts); ac != nil {
switch ac.Name {
case "quit":
self.lp.Quit(0)
case "abort":
self.state = BROWSING
self.draw_screen()
case "place_theme":
self.themes_list.CurrentTheme().SaveInDir(utils.ConfigDir())
self.update_recent()
self.lp.Quit(0)
case "modify_conf":
self.themes_list.CurrentTheme().SaveInConf(utils.ConfigDir(), self.opts.ReloadIn, self.opts.ConfigFileName)
self.update_recent()
self.lp.Quit(0)
case "dark_scheme":
self.themes_list.CurrentTheme().SaveInFile(utils.ConfigDir(), kitty.DarkThemeFileName)
self.update_recent()
self.lp.Quit(0)
case "light_scheme":
self.themes_list.CurrentTheme().SaveInFile(utils.ConfigDir(), kitty.LightThemeFileName)
self.update_recent()
self.lp.Quit(0)
case "no_preference":
self.themes_list.CurrentTheme().SaveInFile(utils.ConfigDir(), kitty.NoPreferenceThemeFileName)
self.update_recent()
self.lp.Quit(0)
}
return nil
}
if ev.MatchesCaseInsensitiveTextOrKey("p") || ev.MatchesPressOrRepeat("shift+p") {
ev.Handled = true
self.themes_list.CurrentTheme().SaveInDir(utils.ConfigDir())
self.update_recent()
self.lp.Quit(0)
return nil
}
if ev.MatchesCaseInsensitiveTextOrKey("m") || ev.MatchesPressOrRepeat("shift+m") {
ev.Handled = true
self.themes_list.CurrentTheme().SaveInConf(utils.ConfigDir(), self.opts.ReloadIn, self.opts.ConfigFileName)
self.update_recent()
self.lp.Quit(0)
return nil
}
scheme := func(name string) error {
ev.Handled = true
self.themes_list.CurrentTheme().SaveInFile(utils.ConfigDir(), name)
self.update_recent()
self.lp.Quit(0)
return nil
}
if ev.MatchesCaseInsensitiveTextOrKey("d") || ev.MatchesPressOrRepeat("shift+d") {
return scheme(kitty.DarkThemeFileName)
}
if ev.MatchesCaseInsensitiveTextOrKey("l") || ev.MatchesPressOrRepeat("shift+l") {
return scheme(kitty.LightThemeFileName)
}
if ev.MatchesCaseInsensitiveTextOrKey("n") || ev.MatchesPressOrRepeat("shift+n") {
return scheme(kitty.NoPreferenceThemeFileName)
}
return nil
}