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

@ -274,6 +274,55 @@ for copying to clipboard.
and you cannot have mappings with and without conditions applying to multi-keys
with the same first key.
Non-Latin keyboard layout support
--------------------------------------
When using a non-Latin keyboard layout (e.g. Russian, Arabic, Greek), letter-key
shortcuts like :kbd:`Ctrl+C` stop working because the key produces a non-Latin
character. kitty solves this with the ``--allow-fallback`` option on the ``map``
directive, which controls how shortcuts fall back to the physical key position.
The ``--allow-fallback`` option accepts a comma-separated list of fallback types:
``shifted``
Fall back to the *shifted key* — the character produced when Shift is held
with the key. This is the default for all mappings and preserves the existing
behavior.
``ascii``
Fall back to the *alternate key* — the character that the physical key would
produce in a standard US layout. This only triggers when the key produces a
non-ASCII character, so it has no effect on Latin-based layouts like Dvorak
or Colemak.
``none``
Disable all fallback matching. The mapping will only match the exact key
specified, ignoring both shifted and alternate key positions.
For example::
# Enable both shifted and ASCII fallback (used by default kitty shortcuts)
map --allow-fallback=shifted,ascii kitty_mod+c copy_to_clipboard
# Only ASCII fallback, no shifted key fallback
map --allow-fallback=ascii ctrl+s save_something
# Disable all fallback (neither shifted nor alternate key matching)
map --allow-fallback=none ctrl+x some_action
All default kitty shortcuts use ``--allow-fallback=shifted,ascii``, so they work
out of the box with non-Latin layouts. Custom mappings without an explicit
``--allow-fallback`` get the default value of ``shifted``, which preserves
backward compatibility.
.. note::
The ``ascii`` fallback uses a non-ASCII guard: it only activates when
the key produces a character with a Unicode code point above 127. This means
alternative Latin layouts (Dvorak, Colemak, etc.) are never affected by the
``ascii`` fallback — only non-Latin layouts trigger it.
Sending arbitrary text or keys to the program running in kitty
--------------------------------------------------------------------------------

View file

@ -156,15 +156,15 @@
map('Change to home directory', 'cd_home ctrl+~ cd ~')
map('Change to home directory', 'cd_home ctrl+` cd ~')
map('Change to home directory', 'cd_home ctrl+shift+` cd ~')
map('Change to temp directory', 'cd_tmp ctrl+t cd /tmp')
map('Change to temp directory', 'cd_tmp --allow-fallback=shifted,ascii ctrl+t cd /tmp')
map('Next filter', 'next_filter ctrl+f 1')
map('Previous filter', 'prev_filter alt+f -1')
map('Next filter', 'next_filter --allow-fallback=shifted,ascii ctrl+f 1')
map('Previous filter', 'prev_filter --allow-fallback=shifted,ascii alt+f -1')
map('Toggle showing dotfiles', 'toggle_dotfiles alt+h toggle dotfiles')
map('Toggle showing ignored files', 'toggle_ignorefiles alt+i toggle ignorefiles')
map('Toggle sorting by dates', 'toggle_sort_by_dates alt+d toggle sort_by_dates')
map('Toggle showing preview', 'toggle_preview alt+p toggle preview')
map('Toggle showing dotfiles', 'toggle_dotfiles --allow-fallback=shifted,ascii alt+h toggle dotfiles')
map('Toggle showing ignored files', 'toggle_ignorefiles --allow-fallback=shifted,ascii alt+i toggle ignorefiles')
map('Toggle sorting by dates', 'toggle_sort_by_dates --allow-fallback=shifted,ascii alt+d toggle sort_by_dates')
map('Toggle showing preview', 'toggle_preview --allow-fallback=shifted,ascii alt+p toggle preview')
egr() # }}}

View file

@ -11,6 +11,7 @@ import (
"strings"
"github.com/kovidgoyal/kitty/tools/cli"
"github.com/kovidgoyal/kitty/tools/config"
"github.com/kovidgoyal/kitty/tools/tty"
"github.com/kovidgoyal/kitty/tools/tui"
"github.com/kovidgoyal/kitty/tools/tui/loop"
@ -271,6 +272,8 @@ type Handler struct {
results_height int
show_unmapped bool
cv *utils.CachedValues[*CachedSettings]
shortcut_tracker config.ShortcutTracker
keyboard_shortcuts []*config.KeyAction
}
func (h *Handler) initialize() (string, error) {
@ -290,6 +293,8 @@ func (h *Handler) initialize() (string, error) {
settings := h.cv.Load()
h.show_unmapped = settings.ShowUnmapped
h.keyboard_shortcuts = config.ResolveShortcuts(NewConfig().KeyboardShortcuts)
if err := h.loadData(); err != nil {
return "", err
}
@ -852,16 +857,27 @@ func (h *Handler) onKeyEvent(ev *loop.KeyEvent) error {
h.triggerSelected()
return nil
}
if ev.MatchesPressOrRepeat("up") || ev.MatchesPressOrRepeat("ctrl+k") || ev.MatchesPressOrRepeat("ctrl+p") {
if ev.MatchesPressOrRepeat("up") {
ev.Handled = true
h.moveSelection(-1)
return nil
}
if ev.MatchesPressOrRepeat("down") || ev.MatchesPressOrRepeat("ctrl+j") || ev.MatchesPressOrRepeat("ctrl+n") {
if ev.MatchesPressOrRepeat("down") {
ev.Handled = true
h.moveSelection(1)
return nil
}
if ac := h.shortcut_tracker.Match(ev, h.keyboard_shortcuts); ac != nil {
ev.Handled = true
switch ac.Name {
case "selection_up":
h.moveSelection(-1)
case "selection_down":
h.moveSelection(1)
}
return nil
}
if ev.MatchesPressOrRepeat("page_up") {
ev.Handled = true
delta := max(1, int(h.screen_size.HeightCells)-4)

View file

@ -6,11 +6,38 @@
from functools import partial
from typing import Any
from kitty.conf.types import Definition
from kitty.fast_data_types import add_timer, get_boss
from kitty.typing_compat import BossType
from ..tui.handler import result_handler
definition = Definition(
'!kittens.command_palette',
)
agr = definition.add_group
egr = definition.end_group
map = definition.add_map
# shortcuts {{{
agr('shortcuts', 'Keyboard shortcuts')
map('Move selection up',
'selection_up --allow-fallback=shifted,ascii ctrl+k selection_up',
)
map('Move selection up',
'selection_up --allow-fallback=shifted,ascii ctrl+p selection_up',
)
map('Move selection down',
'selection_down --allow-fallback=shifted,ascii ctrl+j selection_down',
)
map('Move selection down',
'selection_down --allow-fallback=shifted,ascii ctrl+n selection_down',
)
egr() # }}}
def collect_keys_data(opts: Any) -> dict[str, Any]:
"""Collect all keybinding data from options into a JSON-serializable dict."""
@ -188,3 +215,5 @@ def handle_result(args: list[str], data: dict[str, Any], target_window_id: int,
cd['options'] = OPTIONS
cd['help_text'] = help_text
cd['short_desc'] = help_text
elif __name__ == '__conf__':
sys.options_definition = definition # type: ignore

View file

@ -192,21 +192,21 @@ def main(args: list[str]) -> None:
agr('shortcuts', 'Keyboard shortcuts')
map('Quit',
'quit q quit',
'quit --allow-fallback=shifted,ascii q quit',
)
map('Quit',
'quit esc quit',
)
map('Scroll down',
'scroll_down j scroll_by 1',
'scroll_down --allow-fallback=shifted,ascii j scroll_by 1',
)
map('Scroll down',
'scroll_down down scroll_by 1',
)
map('Scroll up',
'scroll_up k scroll_by -1',
'scroll_up --allow-fallback=shifted,ascii k scroll_by -1',
)
map('Scroll up',
'scroll_up up scroll_by -1',
@ -227,41 +227,41 @@ def main(args: list[str]) -> None:
'scroll_page_down space scroll_to next-page',
)
map('Scroll to next page',
'scroll_page_down ctrl+f scroll_to next-page',
'scroll_page_down --allow-fallback=shifted,ascii ctrl+f scroll_to next-page',
)
map('Scroll to previous page',
'scroll_page_up page_up scroll_to prev-page',
)
map('Scroll to previous page',
'scroll_page_up ctrl+b scroll_to prev-page',
'scroll_page_up --allow-fallback=shifted,ascii ctrl+b scroll_to prev-page',
)
map('Scroll down half page',
'scroll_half_page_down ctrl+d scroll_to next-half-page',
'scroll_half_page_down --allow-fallback=shifted,ascii ctrl+d scroll_to next-half-page',
)
map('Scroll up half page',
'scroll_half_page_up ctrl+u scroll_to prev-half-page',
'scroll_half_page_up --allow-fallback=shifted,ascii ctrl+u scroll_to prev-half-page',
)
map('Scroll to next change',
'next_change n scroll_to next-change',
'next_change --allow-fallback=shifted,ascii n scroll_to next-change',
)
map('Scroll to previous change',
'prev_change p scroll_to prev-change',
'prev_change --allow-fallback=shifted,ascii p scroll_to prev-change',
)
map('Scroll to next file',
'next_file shift+j scroll_to next-file',
'next_file --allow-fallback=shifted,ascii shift+j scroll_to next-file',
)
map('Scroll to previous file',
'prev_file shift+k scroll_to prev-file',
'prev_file --allow-fallback=shifted,ascii shift+k scroll_to prev-file',
)
map('Show all context',
'all_context a change_context all',
'all_context --allow-fallback=shifted,ascii a change_context all',
)
map('Show default context',
@ -299,15 +299,17 @@ def main(args: list[str]) -> None:
)
map('Search forward (no regex)',
'search_forward_simple f start_search substring forward',
'search_forward_simple --allow-fallback=shifted,ascii f start_search substring forward',
)
map('Search backward (no regex)',
'search_backward_simple b start_search substring backward',
'search_backward_simple --allow-fallback=shifted,ascii b start_search substring backward',
)
map('Copy selection to clipboard', 'copy_to_clipboard y copy_to_clipboard')
map('Copy selection to clipboard or exit if no selection is present', 'copy_to_clipboard_or_exit ctrl+c copy_to_clipboard_or_exit')
map('Copy selection to clipboard', 'copy_to_clipboard --allow-fallback=shifted,ascii y copy_to_clipboard')
map('Copy selection to clipboard or exit if no selection is present',
'copy_to_clipboard_or_exit --allow-fallback=shifted,ascii ctrl+c copy_to_clipboard_or_exit',
)
egr() # }}}

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 @@ def main(args: list[str]) -> None:
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

@ -71,6 +71,8 @@ type handler struct {
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,48 +519,40 @@ 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
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()
return nil
}
if ev.MatchesCaseInsensitiveTextOrKey("p") || ev.MatchesPressOrRepeat("shift+p") {
ev.Handled = true
case "place_theme":
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
case "modify_conf":
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)
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("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
}

View file

@ -87,8 +87,13 @@ def key(x: ActionGroup) -> str:
return group_title(x).lower()
def kitten_link(text: str) -> str:
x = text.split()
return f':doc:`kittens/{x[2]}`' if len(x) > 2 else ''
# skip any --flag tokens at the start, then key, then 'kitten', then kitten name
parts = text.split()
# find 'kitten' token and take the next one as the kitten name
for i, p in enumerate(parts):
if p == 'kitten' and i + 1 < len(parts):
return f':doc:`kittens/{parts[i + 1]}`'
return ''
for group in sorted(allg, key=key):
title = group_title(group)

View file

@ -10,6 +10,7 @@
from typing import Any, get_type_hints
from kitty.conf.types import Definition, MultiOption, Option, ParserFuncType, unset
from kitty.options.utils import parse_options_for_map
from kitty.simple_cli_definitions import serialize_as_go_string
from kitty.types import _T
@ -618,9 +619,15 @@ def basic_defval(d: Any) -> str:
if keyboard_shortcuts:
a('KeyboardShortcuts: []*config.KeyAction{')
for sc in keyboard_shortcuts:
aname, aargs = map(serialize_as_go_string, sc.action_def.partition(' ')[::2])
a('{'f'Name: "{aname}", Args: "{aargs}", Normalized_keys: []string''{')
ns = normalize_shortcuts(sc.key_text)
options, leftover = parse_options_for_map(sc.parseable_text)
allow_fallback = options.allow_fallback
key_spec, action = leftover.split(None, 1)
aname, _, aargs = action.partition(' ')
aname = serialize_as_go_string(aname)
aargs = serialize_as_go_string(aargs)
fb = f', AllowFallback: "{serialize_as_go_string(allow_fallback)}"'
a('{'f'Name: "{aname}", Args: "{aargs}"{fb}, Normalized_keys: []string''{')
ns = normalize_shortcuts(key_spec)
a(', '.join(f'"{serialize_as_go_string(x)}"' for x in ns) + ',')
a('}''},')
a('},')
@ -653,7 +660,6 @@ def basic_defval(d: Any) -> str:
has_parsers = bool(go_parsers or keyboard_shortcuts)
a('func (c *Config) Parse(key, val string) (err error) {')
if has_parsers:
if go_parsers:
a('switch key {')
a('default: return fmt.Errorf("Unknown configuration key: %#v", key)')
for oname, pname in go_parsers.items():

View file

@ -431,21 +431,26 @@ class ShortcutMapping(Mapping):
setting_name: str = 'map'
def __init__(
self, name: str, key: str, action_def: str, short_text: str, long_text: str, add_to_default: bool, documented: bool, group: 'Group', only: Only
self, name: str, raw_definition: str, short_text: str, long_text: str,
add_to_default: bool, documented: bool, group: 'Group', only: Only,
):
self.name = name
self.only = only
self.key = key
self.action_def = action_def
self._raw_definition = raw_definition
self.short_text = short_text
self.long_text = long_text
self.documented = documented
self.add_to_default = add_to_default
self.group = group
from kitty.options.utils import parse_options_for_map
_, remainder = parse_options_for_map(raw_definition)
parts = remainder.split(maxsplit=1)
self.key = parts[0] if parts else ''
self.action_def = parts[1] if len(parts) > 1 else ''
@property
def parseable_text(self) -> str:
return f'{self.key} {self.action_def}'
return self._raw_definition
@property
def key_text(self) -> str:
@ -733,8 +738,8 @@ def add_option(
def add_map(
self, short_text: str, defn: str, long_text: str = '', add_to_default: bool = True, documented: bool = True, only: Only = ''
) -> None:
name, key, action_def = defn.split(maxsplit=2)
sc = ShortcutMapping(name, key, action_def, short_text, long_text.strip(), add_to_default, documented, self.current_group, only)
name, rest = defn.split(maxsplit=1)
sc = ShortcutMapping(name, rest, short_text, long_text.strip(), add_to_default, documented, self.current_group, only)
self.current_group.append(sc)
self.shortcut_map.setdefault(name, []).append(sc)

2
kitty/key_encoding.py generated
View file

@ -225,6 +225,8 @@ def matches(self, spec: str | ParsedShortcut, types: int = EventType.PRESS | Eve
is_shifted = bool(self.shifted_key and self.shift)
if is_shifted and (mods & ~SHIFT, self.shifted_key) == spec:
return True
if self.alternate_key and self.key and len(self.key) == 1 and 127 < ord(self.key) < 0xE000 and (mods, self.alternate_key) == spec:
return True
return False
def matches_without_mods(self, spec: str | ParsedShortcut, types: int = EventType.PRESS | EventType.REPEAT) -> bool:

View file

@ -23,13 +23,14 @@
set_ignore_os_keyboard_processing,
)
from .options.types import Options
from .options.utils import KeyboardMode, KeyDefinition, KeyMap
from .options.utils import KeyboardMode, KeyDefinition, KeyMap, KeyMapOptions
from .typing_compat import ScreenType
if TYPE_CHECKING:
from .window import Window
mod_mask = GLFW_MOD_ALT | GLFW_MOD_CONTROL | GLFW_MOD_SHIFT | GLFW_MOD_SUPER | GLFW_MOD_META | GLFW_MOD_HYPER
_global_shortcut_options = KeyMapOptions(allow_fallback='shifted,ascii')
def keyboard_mode_name(screen: ScreenType) -> str:
@ -43,7 +44,17 @@ def get_shortcut(keymap: KeyMap, ev: KeyEvent) -> list[KeyDefinition] | None:
mods = ev.mods & mod_mask
ans = keymap.get(SingleKey(mods, False, ev.key))
if ans is None and ev.shifted_key and mods & GLFW_MOD_SHIFT:
ans = keymap.get(SingleKey(mods & (~GLFW_MOD_SHIFT), False, ev.shifted_key))
candidate = keymap.get(SingleKey(mods & (~GLFW_MOD_SHIFT), False, ev.shifted_key))
if candidate:
filtered = [d for d in candidate if 'shifted' in d.options.allow_fallback]
if filtered:
ans = filtered
if ans is None and ev.alternate_key and 127 < ev.key < 0xE000:
candidate = keymap.get(SingleKey(mods, False, ev.alternate_key))
if candidate:
filtered = [d for d in candidate if 'ascii' in d.options.allow_fallback]
if filtered:
ans = filtered
if ans is None:
ans = keymap.get(SingleKey(mods, True, ev.native_key))
return ans
@ -58,6 +69,8 @@ def shortcut_matches(s: SingleKey, ev: KeyEvent) -> bool:
return True
if ev.shifted_key and mods & GLFW_MOD_SHIFT and (mods & ~GLFW_MOD_SHIFT) == smods and ev.shifted_key == s.key:
return True
if ev.alternate_key and 127 < ev.key < 0xE000 and ev.alternate_key == s.key and mods == smods:
return True
return False
@ -77,7 +90,7 @@ def current_keyboard_mode_name(self) -> str:
def update_keymap(self, global_shortcuts: dict[str, SingleKey] | None = None) -> None:
if global_shortcuts is None:
global_shortcuts = self.set_cocoa_global_shortcuts(self.get_options()) if is_macos else {}
self.global_shortcuts_map: KeyMap = {v: [KeyDefinition(definition=k)] for k, v in global_shortcuts.items()}
self.global_shortcuts_map: KeyMap = {v: [KeyDefinition(definition=k, options=_global_shortcut_options)] for k, v in global_shortcuts.items()}
self.global_shortcuts = global_shortcuts
self.keyboard_modes = self.get_options().keyboard_modes.copy()
km = self.keyboard_modes[''].keymap

View file

@ -12,7 +12,7 @@
definition = Definition(
'kitty',
Action('map', 'parse_map', {'keyboard_modes': 'KeyboardModeMap', 'alias_map': 'AliasMap'},
['KeyDefinition', 'kitty.fast_data_types.SingleKey']),
['KeyDefinition', 'KeyMapOptions', 'kitty.fast_data_types.SingleKey']),
Action('mouse_map', 'parse_mouse_map', {'mousemap': 'MouseMap'}, ['MouseMapping']),
has_color_table=True,
)
@ -3926,6 +3926,14 @@
# multi-key shortcuts
map ctrl+x>ctrl+y>z action
For non-Latin keyboard layouts (Russian, Arabic, etc.), the :code:`--allow-fallback`
option controls physical key fallback. Values: :code:`shifted` (default shifted key
fallback), :code:`ascii` (alternate key / physical position fallback, only for non-ASCII
keys), :code:`none` (disable all fallback), or a comma-separated combination.
All default shortcuts use :code:`--allow-fallback=shifted,ascii`::
map --allow-fallback=shifted,ascii kitty_mod+c copy_to_clipboard
You can browse and trigger these actions by pressing :sc:`command_palette` to
run the command palette. The full list of actions that can be mapped to
key presses is available :doc:`here </actions>`.
@ -3998,7 +4006,7 @@
agr('shortcuts.clipboard', 'Clipboard')
map('Copy to clipboard',
'copy_to_clipboard kitty_mod+c copy_to_clipboard',
'copy_to_clipboard --allow-fallback=shifted,ascii kitty_mod+c copy_to_clipboard',
long_text='''
There is also a :ac:`copy_or_interrupt` action that can be optionally mapped
to :kbd:`Ctrl+C`. It will copy only if there is a selection and send an
@ -4009,27 +4017,27 @@
'''
)
map('Copy to clipboard or pass through',
'copy_or_noop cmd+c copy_or_noop',
'copy_or_noop --allow-fallback=shifted,ascii cmd+c copy_or_noop',
only='macos',
)
map('Paste from clipboard',
'paste_from_clipboard kitty_mod+v paste_from_clipboard',
'paste_from_clipboard --allow-fallback=shifted,ascii kitty_mod+v paste_from_clipboard',
)
map('Paste from clipboard',
'paste_from_clipboard cmd+v paste_from_clipboard',
'paste_from_clipboard --allow-fallback=shifted,ascii cmd+v paste_from_clipboard',
only='macos',
)
map('Paste from selection',
'paste_from_selection kitty_mod+s paste_from_selection',
'paste_from_selection --allow-fallback=shifted,ascii kitty_mod+s paste_from_selection',
)
map('Paste from selection',
'paste_from_selection shift+insert paste_from_selection',
)
map('Pass selection to program',
'pass_selection_to_program kitty_mod+o pass_selection_to_program',
'pass_selection_to_program --allow-fallback=shifted,ascii kitty_mod+o pass_selection_to_program',
long_text='''
You can also pass the contents of the current selection to any program with
:ac:`pass_selection_to_program`. By default, the system's open program is used,
@ -4054,7 +4062,7 @@
'scroll_line_up kitty_mod+up scroll_line_up',
)
map('Scroll line up',
'scroll_line_up kitty_mod+k scroll_line_up',
'scroll_line_up --allow-fallback=shifted,ascii kitty_mod+k scroll_line_up',
)
map('Scroll line up',
'scroll_line_up opt+cmd+page_up scroll_line_up',
@ -4069,7 +4077,7 @@
'scroll_line_down kitty_mod+down scroll_line_down',
)
map('Scroll line down',
'scroll_line_down kitty_mod+j scroll_line_down',
'scroll_line_down --allow-fallback=shifted,ascii kitty_mod+j scroll_line_down',
)
map('Scroll line down',
'scroll_line_down opt+cmd+page_down scroll_line_down',
@ -4113,7 +4121,7 @@
)
map('Scroll to previous shell prompt',
'scroll_to_previous_prompt kitty_mod+z scroll_to_prompt -1',
'scroll_to_previous_prompt --allow-fallback=shifted,ascii kitty_mod+z scroll_to_prompt -1',
long_text='''
Use a parameter of :code:`0` for :ac:`scroll_to_prompt` to scroll to the last
jumped to or the last clicked position. Requires :ref:`shell integration
@ -4121,10 +4129,10 @@
'''
)
map('Scroll to next shell prompt', 'scroll_to_next_prompt kitty_mod+x scroll_to_prompt 1')
map('Scroll to next shell prompt', 'scroll_to_next_prompt --allow-fallback=shifted,ascii kitty_mod+x scroll_to_prompt 1')
map('Browse scrollback buffer in pager',
'show_scrollback kitty_mod+h show_scrollback',
'show_scrollback --allow-fallback=shifted,ascii kitty_mod+h show_scrollback',
long_text='''
You can pipe the contents of the current screen and history buffer as
:file:`STDIN` to an arbitrary program using :option:`launch --stdin-source`.
@ -4139,7 +4147,7 @@
)
map('Browse output of the last shell command in pager',
'show_last_command_output kitty_mod+g show_last_command_output',
'show_last_command_output --allow-fallback=shifted,ascii kitty_mod+g show_last_command_output',
long_text='''
You can also define additional shortcuts to get the command output.
For example, to get the first command output on screen::
@ -4176,7 +4184,7 @@
For more sophisticated control, such as using the current selection, use :ac:`remote_control_script`.
''')
map('Search the scrollback within a pager', 'search_scrollback cmd+f search_scrollback', only='macos')
map('Search the scrollback within a pager', 'search_scrollback --allow-fallback=shifted,ascii cmd+f search_scrollback', only='macos')
egr() # }}}
@ -4220,7 +4228,7 @@
)
map('New OS window',
'new_os_window kitty_mod+n new_os_window',
'new_os_window --allow-fallback=shifted,ascii kitty_mod+n new_os_window',
long_text='''
Works like :ac:`new_window` above, except that it opens a top-level :term:`OS
window <os_window>`. In particular you can use :ac:`new_os_window_with_cwd` to
@ -4228,15 +4236,15 @@
'''
)
map('New OS window',
'new_os_window cmd+n new_os_window',
'new_os_window --allow-fallback=shifted,ascii cmd+n new_os_window',
only='macos',
)
map('Close window',
'close_window kitty_mod+w close_window',
'close_window --allow-fallback=shifted,ascii kitty_mod+w close_window',
)
map('Close window',
'close_window shift+cmd+d close_window',
'close_window --allow-fallback=shifted,ascii shift+cmd+d close_window',
only='macos',
)
@ -4249,11 +4257,11 @@
)
map('Move window forward',
'move_window_forward kitty_mod+f move_window_forward',
'move_window_forward --allow-fallback=shifted,ascii kitty_mod+f move_window_forward',
)
map('Move window backward',
'move_window_backward kitty_mod+b move_window_backward',
'move_window_backward --allow-fallback=shifted,ascii kitty_mod+b move_window_backward',
)
map('Move window to top',
@ -4261,10 +4269,10 @@
)
map('Start resizing window',
'start_resizing_window kitty_mod+r start_resizing_window',
'start_resizing_window --allow-fallback=shifted,ascii kitty_mod+r start_resizing_window',
)
map('Start resizing window',
'start_resizing_window cmd+r start_resizing_window',
'start_resizing_window --allow-fallback=shifted,ascii cmd+r start_resizing_window',
only='macos',
)
@ -4386,23 +4394,23 @@
)
map('New tab',
'new_tab kitty_mod+t new_tab',
'new_tab --allow-fallback=shifted,ascii kitty_mod+t new_tab',
)
map('New tab',
'new_tab cmd+t new_tab',
'new_tab --allow-fallback=shifted,ascii cmd+t new_tab',
only='macos',
)
map('Close tab',
'close_tab kitty_mod+q close_tab',
'close_tab --allow-fallback=shifted,ascii kitty_mod+q close_tab',
)
map('Close tab',
'close_tab cmd+w close_tab',
'close_tab --allow-fallback=shifted,ascii cmd+w close_tab',
only='macos',
)
map('Close OS window',
'close_os_window shift+cmd+w close_os_window',
'close_os_window --allow-fallback=shifted,ascii shift+cmd+w close_os_window',
only='macos',
)
@ -4415,10 +4423,10 @@
)
map('Set tab title',
'set_tab_title kitty_mod+alt+t set_tab_title',
'set_tab_title --allow-fallback=shifted,ascii kitty_mod+alt+t set_tab_title',
)
map('Set tab title',
'set_tab_title shift+cmd+i set_tab_title',
'set_tab_title --allow-fallback=shifted,ascii shift+cmd+i set_tab_title',
only='macos',
)
egr('''
@ -4444,7 +4452,7 @@
agr('shortcuts.layout', 'Layout management')
map('Next layout',
'next_layout kitty_mod+l next_layout',
'next_layout --allow-fallback=shifted,ascii kitty_mod+l next_layout',
)
egr('''
You can also create shortcuts to switch to specific :term:`layouts <layout>`::
@ -4537,7 +4545,7 @@
''')
map('Open URL',
'open_url kitty_mod+e open_url_with_hints',
'open_url --allow-fallback=shifted,ascii kitty_mod+e open_url_with_hints',
long_text='''
Open a currently visible URL using the keyboard. The program used to open the
URL is specified in :opt:`open_url_with`.
@ -4545,7 +4553,7 @@
)
map('Insert selected path',
'insert_selected_path kitty_mod+p>f kitten hints --type path --program -',
'insert_selected_path --allow-fallback=shifted,ascii kitty_mod+p>f kitten hints --type path --program -',
long_text='''
Select a path/filename and insert it into the terminal. Useful, for instance to
run :program:`git` commands on a filename output from a previous :program:`git`
@ -4554,12 +4562,12 @@
)
map('Open selected path',
'open_selected_path kitty_mod+p>shift+f kitten hints --type path',
'open_selected_path --allow-fallback=shifted,ascii kitty_mod+p>shift+f kitten hints --type path',
long_text='Select a path/filename and open it with the default open program.'
)
map('Insert chosen file',
'insert_chosen_file kitty_mod+p>c kitten choose-files',
'insert_chosen_file --allow-fallback=shifted,ascii kitty_mod+p>c kitten choose-files',
long_text='''
Select a file using the :doc:`choose-files </kittens/choose-files>` kitten and insert
it into the terminal.
@ -4567,7 +4575,7 @@
)
map('Insert chosen directory',
'insert_chosen_directory kitty_mod+p>d kitten choose-files --mode=dir',
'insert_chosen_directory --allow-fallback=shifted,ascii kitty_mod+p>d kitten choose-files --mode=dir',
long_text='''
Select a directory using the :doc:`choose-files </kittens/choose-files>` kitten and insert
it into the terminal.
@ -4576,7 +4584,7 @@
map('Insert selected line',
'insert_selected_line kitty_mod+p>l kitten hints --type line --program -',
'insert_selected_line --allow-fallback=shifted,ascii kitty_mod+p>l kitten hints --type line --program -',
long_text='''
Select a line of text and insert it into the terminal. Useful for the output of
things like: ``ls -1``.
@ -4584,12 +4592,12 @@
)
map('Insert selected word',
'insert_selected_word kitty_mod+p>w kitten hints --type word --program -',
'insert_selected_word --allow-fallback=shifted,ascii kitty_mod+p>w kitten hints --type word --program -',
long_text='Select words and insert into terminal.'
)
map('Insert selected hash',
'insert_selected_hash kitty_mod+p>h kitten hints --type hash --program -',
'insert_selected_hash --allow-fallback=shifted,ascii kitty_mod+p>h kitten hints --type hash --program -',
long_text='''
Select something that looks like a hash and insert it into the terminal. Useful
with :program:`git`, which uses SHA1 hashes to identify commits.
@ -4597,7 +4605,7 @@
)
map('Open the selected file at the selected line',
'goto_file_line kitty_mod+p>n kitten hints --type linenum',
'goto_file_line --allow-fallback=shifted,ascii kitty_mod+p>n kitten hints --type linenum',
long_text='''
Select something that looks like :code:`filename:linenum` and open it in
your default editor at the specified line number.
@ -4605,7 +4613,7 @@
)
map('Open the selected hyperlink',
'open_selected_hyperlink kitty_mod+p>y kitten hints --type hyperlink',
'open_selected_hyperlink --allow-fallback=shifted,ascii kitty_mod+p>y kitten hints --type hyperlink',
long_text='''
Select a :term:`hyperlink <hyperlinks>` (i.e. a URL that has been marked as such
by the terminal program, for example, by ``ls --hyperlink=auto``).
@ -4630,7 +4638,7 @@
'toggle_fullscreen kitty_mod+f11 toggle_fullscreen',
)
map('Toggle fullscreen',
'toggle_fullscreen ctrl+cmd+f toggle_fullscreen',
'toggle_fullscreen --allow-fallback=shifted,ascii ctrl+cmd+f toggle_fullscreen',
only='macos',
)
@ -4639,7 +4647,7 @@
)
map('Toggle macOS secure keyboard entry',
'toggle_macos_secure_keyboard_entry opt+cmd+s toggle_macos_secure_keyboard_entry',
'toggle_macos_secure_keyboard_entry --allow-fallback=shifted,ascii opt+cmd+s toggle_macos_secure_keyboard_entry',
only='macos',
)
@ -4647,7 +4655,7 @@
map('macOS Cycle through OS Windows backwards', 'macos_cycle_through_os_windows_backwards cmd+shift+` macos_cycle_through_os_windows_backwards', only='macos')
map('Unicode input',
'input_unicode_character kitty_mod+u kitten unicode_input',
'input_unicode_character --allow-fallback=shifted,ascii kitty_mod+u kitten unicode_input',
)
map('Unicode input',
'input_unicode_character ctrl+cmd+space kitten unicode_input',
@ -4671,19 +4679,19 @@
)
map('Increase background opacity',
'increase_background_opacity kitty_mod+a>m set_background_opacity +0.1',
'increase_background_opacity --allow-fallback=shifted,ascii kitty_mod+a>m set_background_opacity +0.1',
)
map('Decrease background opacity',
'decrease_background_opacity kitty_mod+a>l set_background_opacity -0.1',
'decrease_background_opacity --allow-fallback=shifted,ascii kitty_mod+a>l set_background_opacity -0.1',
)
map('Make background fully opaque',
'full_background_opacity kitty_mod+a>1 set_background_opacity 1',
'full_background_opacity --allow-fallback=shifted,ascii kitty_mod+a>1 set_background_opacity 1',
)
map('Reset background opacity',
'reset_background_opacity kitty_mod+a>d set_background_opacity default',
'reset_background_opacity --allow-fallback=shifted,ascii kitty_mod+a>d set_background_opacity default',
)
map('Reset the terminal',

96
kitty/options/types.py generated
View file

@ -11,8 +11,8 @@
from kitty.fonts import FontSpec
import kitty.fonts
from kitty.options.utils import (
AliasMap, KeyDefinition, KeyboardModeMap, MouseHideWait, MouseMap, MouseMapping, NotifyOnCmdFinish,
TabBarMarginHeight
AliasMap, KeyDefinition, KeyMapOptions, KeyboardModeMap, MouseHideWait, MouseMap, MouseMapping,
NotifyOnCmdFinish, TabBarMarginHeight
)
import kitty.options.utils
from kitty.types import FloatEdges
@ -848,23 +848,23 @@ def __setattr__(self, key: str, val: typing.Any) -> typing.Any:
defaults.map = [
# copy_to_clipboard
KeyDefinition(trigger=SingleKey(mods=256, key=99), definition='copy_to_clipboard'),
KeyDefinition(trigger=SingleKey(mods=256, key=99), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='copy_to_clipboard'),
# paste_from_clipboard
KeyDefinition(trigger=SingleKey(mods=256, key=118), definition='paste_from_clipboard'),
KeyDefinition(trigger=SingleKey(mods=256, key=118), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='paste_from_clipboard'),
# paste_from_selection
KeyDefinition(trigger=SingleKey(mods=256, key=115), definition='paste_from_selection'),
KeyDefinition(trigger=SingleKey(mods=256, key=115), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='paste_from_selection'),
# paste_from_selection
KeyDefinition(trigger=SingleKey(mods=1, key=57348), definition='paste_from_selection'),
# pass_selection_to_program
KeyDefinition(trigger=SingleKey(mods=256, key=111), definition='pass_selection_to_program'),
KeyDefinition(trigger=SingleKey(mods=256, key=111), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='pass_selection_to_program'),
# scroll_line_up
KeyDefinition(trigger=SingleKey(mods=256, key=57352), definition='scroll_line_up'),
# scroll_line_up
KeyDefinition(trigger=SingleKey(mods=256, key=107), definition='scroll_line_up'),
KeyDefinition(trigger=SingleKey(mods=256, key=107), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='scroll_line_up'),
# scroll_line_down
KeyDefinition(trigger=SingleKey(mods=256, key=57353), definition='scroll_line_down'),
# scroll_line_down
KeyDefinition(trigger=SingleKey(mods=256, key=106), definition='scroll_line_down'),
KeyDefinition(trigger=SingleKey(mods=256, key=106), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='scroll_line_down'),
# scroll_page_up
KeyDefinition(trigger=SingleKey(mods=256, key=57354), definition='scroll_page_up'),
# scroll_page_down
@ -874,33 +874,33 @@ def __setattr__(self, key: str, val: typing.Any) -> typing.Any:
# scroll_end
KeyDefinition(trigger=SingleKey(mods=256, key=57357), definition='scroll_end'),
# scroll_to_previous_prompt
KeyDefinition(trigger=SingleKey(mods=256, key=122), definition='scroll_to_prompt -1'),
KeyDefinition(trigger=SingleKey(mods=256, key=122), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='scroll_to_prompt -1'),
# scroll_to_next_prompt
KeyDefinition(trigger=SingleKey(mods=256, key=120), definition='scroll_to_prompt 1'),
KeyDefinition(trigger=SingleKey(mods=256, key=120), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='scroll_to_prompt 1'),
# show_scrollback
KeyDefinition(trigger=SingleKey(mods=256, key=104), definition='show_scrollback'),
KeyDefinition(trigger=SingleKey(mods=256, key=104), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='show_scrollback'),
# show_last_command_output
KeyDefinition(trigger=SingleKey(mods=256, key=103), definition='show_last_command_output'),
KeyDefinition(trigger=SingleKey(mods=256, key=103), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='show_last_command_output'),
# search_scrollback
KeyDefinition(trigger=SingleKey(mods=256, key=47), definition='search_scrollback'),
# new_window
KeyDefinition(trigger=SingleKey(mods=256, key=57345), definition='new_window'),
# new_os_window
KeyDefinition(trigger=SingleKey(mods=256, key=110), definition='new_os_window'),
KeyDefinition(trigger=SingleKey(mods=256, key=110), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='new_os_window'),
# close_window
KeyDefinition(trigger=SingleKey(mods=256, key=119), definition='close_window'),
KeyDefinition(trigger=SingleKey(mods=256, key=119), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='close_window'),
# next_window
KeyDefinition(trigger=SingleKey(mods=256, key=93), definition='next_window'),
# previous_window
KeyDefinition(trigger=SingleKey(mods=256, key=91), definition='previous_window'),
# move_window_forward
KeyDefinition(trigger=SingleKey(mods=256, key=102), definition='move_window_forward'),
KeyDefinition(trigger=SingleKey(mods=256, key=102), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='move_window_forward'),
# move_window_backward
KeyDefinition(trigger=SingleKey(mods=256, key=98), definition='move_window_backward'),
KeyDefinition(trigger=SingleKey(mods=256, key=98), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='move_window_backward'),
# move_window_to_top
KeyDefinition(trigger=SingleKey(mods=256, key=96), definition='move_window_to_top'),
# start_resizing_window
KeyDefinition(trigger=SingleKey(mods=256, key=114), definition='start_resizing_window'),
KeyDefinition(trigger=SingleKey(mods=256, key=114), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='start_resizing_window'),
# first_window
KeyDefinition(trigger=SingleKey(mods=256, key=49), definition='first_window'),
# second_window
@ -934,17 +934,17 @@ def __setattr__(self, key: str, val: typing.Any) -> typing.Any:
# previous_tab
KeyDefinition(trigger=SingleKey(mods=5, key=57346), definition='previous_tab'),
# new_tab
KeyDefinition(trigger=SingleKey(mods=256, key=116), definition='new_tab'),
KeyDefinition(trigger=SingleKey(mods=256, key=116), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='new_tab'),
# close_tab
KeyDefinition(trigger=SingleKey(mods=256, key=113), definition='close_tab'),
KeyDefinition(trigger=SingleKey(mods=256, key=113), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='close_tab'),
# move_tab_forward
KeyDefinition(trigger=SingleKey(mods=256, key=46), definition='move_tab_forward'),
# move_tab_backward
KeyDefinition(trigger=SingleKey(mods=256, key=44), definition='move_tab_backward'),
# set_tab_title
KeyDefinition(trigger=SingleKey(mods=258, key=116), definition='set_tab_title'),
KeyDefinition(trigger=SingleKey(mods=258, key=116), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='set_tab_title'),
# next_layout
KeyDefinition(trigger=SingleKey(mods=256, key=108), definition='next_layout'),
KeyDefinition(trigger=SingleKey(mods=256, key=108), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='next_layout'),
# increase_font_size
KeyDefinition(trigger=SingleKey(mods=256, key=61), definition='change_font_size all +2.0'),
# increase_font_size
@ -958,25 +958,25 @@ def __setattr__(self, key: str, val: typing.Any) -> typing.Any:
# reset_font_size
KeyDefinition(trigger=SingleKey(mods=256, key=57347), definition='change_font_size all 0'),
# open_url
KeyDefinition(trigger=SingleKey(mods=256, key=101), definition='open_url_with_hints'),
KeyDefinition(trigger=SingleKey(mods=256, key=101), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='open_url_with_hints'),
# insert_selected_path
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=112), rest=(SingleKey(key=102),), definition='kitten hints --type path --program -'),
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=112), rest=(SingleKey(key=102),), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='kitten hints --type path --program -'),
# open_selected_path
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=112), rest=(SingleKey(mods=1, key=102),), definition='kitten hints --type path'),
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=112), rest=(SingleKey(mods=1, key=102),), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='kitten hints --type path'),
# insert_chosen_file
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=112), rest=(SingleKey(key=99),), definition='kitten choose-files'),
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=112), rest=(SingleKey(key=99),), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='kitten choose-files'),
# insert_chosen_directory
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=112), rest=(SingleKey(key=100),), definition='kitten choose-files --mode=dir'),
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=112), rest=(SingleKey(key=100),), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='kitten choose-files --mode=dir'),
# insert_selected_line
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=112), rest=(SingleKey(key=108),), definition='kitten hints --type line --program -'),
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=112), rest=(SingleKey(key=108),), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='kitten hints --type line --program -'),
# insert_selected_word
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=112), rest=(SingleKey(key=119),), definition='kitten hints --type word --program -'),
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=112), rest=(SingleKey(key=119),), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='kitten hints --type word --program -'),
# insert_selected_hash
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=112), rest=(SingleKey(key=104),), definition='kitten hints --type hash --program -'),
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=112), rest=(SingleKey(key=104),), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='kitten hints --type hash --program -'),
# goto_file_line
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=112), rest=(SingleKey(key=110),), definition='kitten hints --type linenum'),
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=112), rest=(SingleKey(key=110),), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='kitten hints --type linenum'),
# open_selected_hyperlink
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=112), rest=(SingleKey(key=121),), definition='kitten hints --type hyperlink'),
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=112), rest=(SingleKey(key=121),), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='kitten hints --type hyperlink'),
# show_kitty_doc
KeyDefinition(trigger=SingleKey(mods=256, key=57364), definition='show_kitty_doc overview'),
# command_palette
@ -986,19 +986,19 @@ def __setattr__(self, key: str, val: typing.Any) -> typing.Any:
# toggle_maximized
KeyDefinition(trigger=SingleKey(mods=256, key=57373), definition='toggle_maximized'),
# input_unicode_character
KeyDefinition(trigger=SingleKey(mods=256, key=117), definition='kitten unicode_input'),
KeyDefinition(trigger=SingleKey(mods=256, key=117), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='kitten unicode_input'),
# edit_config_file
KeyDefinition(trigger=SingleKey(mods=256, key=57365), definition='edit_config_file'),
# kitty_shell
KeyDefinition(trigger=SingleKey(mods=256, key=57344), definition='kitty_shell window'),
# increase_background_opacity
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=97), rest=(SingleKey(key=109),), definition='set_background_opacity +0.1'),
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=97), rest=(SingleKey(key=109),), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='set_background_opacity +0.1'),
# decrease_background_opacity
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=97), rest=(SingleKey(key=108),), definition='set_background_opacity -0.1'),
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=97), rest=(SingleKey(key=108),), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='set_background_opacity -0.1'),
# full_background_opacity
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=97), rest=(SingleKey(key=49),), definition='set_background_opacity 1'),
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=97), rest=(SingleKey(key=49),), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='set_background_opacity 1'),
# reset_background_opacity
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=97), rest=(SingleKey(key=100),), definition='set_background_opacity default'),
KeyDefinition(is_sequence=True, trigger=SingleKey(mods=256, key=97), rest=(SingleKey(key=100),), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='set_background_opacity default'),
# reset_terminal
KeyDefinition(trigger=SingleKey(mods=256, key=57349), definition='clear_terminal reset active'),
# reload_config_file
@ -1008,8 +1008,8 @@ def __setattr__(self, key: str, val: typing.Any) -> typing.Any:
]
if is_macos:
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=99), definition='copy_or_noop'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=118), definition='paste_from_clipboard'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=99), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='copy_or_noop'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=118), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='paste_from_clipboard'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=10, key=57354), definition='scroll_line_up'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=57352), definition='scroll_line_up'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=10, key=57355), definition='scroll_line_down'))
@ -1018,11 +1018,11 @@ def __setattr__(self, key: str, val: typing.Any) -> typing.Any:
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=57355), definition='scroll_page_down'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=57356), definition='scroll_home'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=57357), definition='scroll_end'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=102), definition='search_scrollback'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=102), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='search_scrollback'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=57345), definition='new_window'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=110), definition='new_os_window'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=9, key=100), definition='close_window'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=114), definition='start_resizing_window'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=110), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='new_os_window'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=9, key=100), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='close_window'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=114), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='start_resizing_window'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=49), definition='first_window'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=50), definition='second_window'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=51), definition='third_window'))
@ -1034,18 +1034,18 @@ def __setattr__(self, key: str, val: typing.Any) -> typing.Any:
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=57), definition='ninth_window'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=9, key=93), definition='next_tab'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=9, key=91), definition='previous_tab'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=116), definition='new_tab'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=119), definition='close_tab'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=9, key=119), definition='close_os_window'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=9, key=105), definition='set_tab_title'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=116), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='new_tab'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=119), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='close_tab'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=9, key=119), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='close_os_window'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=9, key=105), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='set_tab_title'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=43), definition='change_font_size all +2.0'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=61), definition='change_font_size all +2.0'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=9, key=61), definition='change_font_size all +2.0'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=45), definition='change_font_size all -2.0'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=9, key=45), definition='change_font_size all -2.0'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=48), definition='change_font_size all 0'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=12, key=102), definition='toggle_fullscreen'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=10, key=115), definition='toggle_macos_secure_keyboard_entry'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=12, key=102), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='toggle_fullscreen'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=10, key=115), options=KeyMapOptions(when_focus_on='', new_mode='', mode='', on_unknown='beep', on_action='keep', timeout=None, allow_fallback='ascii,shifted'), definition='toggle_macos_secure_keyboard_entry'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=8, key=96), definition='macos_cycle_through_os_windows'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=9, key=96), definition='macos_cycle_through_os_windows_backwards'))
defaults.map.append(KeyDefinition(trigger=SingleKey(mods=12, key=32), definition='kitten unicode_input'))

View file

@ -1284,24 +1284,27 @@ def __init__(self, vals: tuple[T, ...]):
self._vals = vals
def __set_name__(self, owner: object, name: str) -> None:
self._name = "_" + name
self._name = name
def __get__(self, obj: object, type: type | None = None) -> T:
if obj is None:
return self._vals[0]
return getattr(obj, self._name, self._vals[0])
val = obj.__dict__.get(self._name)
if val is None or isinstance(val, LiteralField):
return self._vals[0]
return val
def __set__(self, obj: object, value: str) -> None:
if value not in self._vals:
raise KeyError(f'Invalid value for {self._name[1:]}: {value!r}')
object.__setattr__(obj, self._name, value)
raise KeyError(f'Invalid value for {self._name}: {value!r}')
obj.__dict__[self._name] = value
OnUnknown = Literal['beep', 'end', 'ignore', 'passthrough']
OnAction = Literal['keep', 'end']
@dataclass(init=False, frozen=True)
@dataclass(frozen=True)
class KeyMapOptions:
when_focus_on: str = ''
new_mode: str = ''
@ -1309,6 +1312,7 @@ class KeyMapOptions:
on_unknown: LiteralField[OnUnknown] = LiteralField[OnUnknown](get_args(OnUnknown))
on_action: LiteralField[OnAction] = LiteralField[OnAction](get_args(OnAction))
timeout: float | None = None
allow_fallback: str = 'shifted'
default_key_map_options = KeyMapOptions()
@ -1381,6 +1385,22 @@ def __init__(self, name: str = '') -> None:
key_map_option_converters['timeout'] = float
_allowed_fallback_values = frozenset(('shifted', 'ascii'))
def _convert_allow_fallback(val: str) -> str:
if not val or val == 'none':
return ''
parts = tuple(x.strip() for x in val.split(','))
invalid = set(parts) - _allowed_fallback_values
if invalid:
raise ValueError(f'allow_fallback values must be a subset of {_allowed_fallback_values}, got: {invalid}')
return ','.join(parts)
key_map_option_converters['allow_fallback'] = _convert_allow_fallback
def parse_options_for_map(val: str) -> tuple[KeyMapOptions, str]:
expecting_arg = ''
ans = KeyMapOptions()

View file

@ -654,3 +654,222 @@ def __call__(self, *keys: str):
self.ae(tm('m', 'a'), [True, True])
self.ae(tm.actions, ['push_keyboard_mode mw', 'new_window'])
af(tm.ignore_os_keyboard_processing)
def test_match_physical_keys_removed(self):
# match_physical_keys global option has been removed in favor of per-mapping --allow-fallback
# Verify that get_shortcut does NOT match via alternate_key without per-mapping allow_fallback='ascii'
from kitty.keys import get_shortcut
from kitty.options.utils import KeyDefinition
ctrl = defines.GLFW_MOD_CONTROL
cyrillic_s = 0x441 # Cyrillic 'с'
latin_c = ord('c')
kd = KeyDefinition(definition='copy_to_clipboard') # default allow_fallback='shifted'
keymap = {defines.SingleKey(ctrl, False, latin_c): [kd]}
# alternate_key should NOT match since default allow_fallback='shifted' (no 'ascii')
ev = defines.KeyEvent(cyrillic_s, 0, latin_c, ctrl)
self.assertIsNone(get_shortcut(keymap, ev))
# direct key match still works
ev = defines.KeyEvent(latin_c, 0, latin_c, ctrl)
result = get_shortcut(keymap, ev)
self.assertIsNotNone(result)
self.assertIs(result[0], kd)
def test_get_shortcut_per_mapping_fallback(self):
from kitty.keys import get_shortcut
from kitty.options.utils import KeyDefinition, KeyMapOptions
ctrl = defines.GLFW_MOD_CONTROL
shift = defines.GLFW_MOD_SHIFT
cyrillic_s = 0x441 # Cyrillic 'с' (on physical 'c' key in Russian layout)
latin_c = ord('c')
def make_kd(definition='test_action', allow_fallback='shifted'):
opts = KeyMapOptions()
object.__setattr__(opts, 'allow_fallback', allow_fallback)
return KeyDefinition(definition=definition, options=opts)
# non-ASCII key + alternate_key + allow_fallback includes ascii → match
kd_ascii = make_kd('copy', allow_fallback='ascii,shifted')
keymap = {defines.SingleKey(ctrl, False, latin_c): [kd_ascii]}
ev = defines.KeyEvent(cyrillic_s, 0, latin_c, ctrl)
result = get_shortcut(keymap, ev)
self.assertIsNotNone(result)
self.assertIs(result[0], kd_ascii)
# non-ASCII key + alternate_key + allow_fallback='shifted' (no ascii) → no ascii match
kd_shifted_only = make_kd('copy', allow_fallback='shifted')
keymap = {defines.SingleKey(ctrl, False, latin_c): [kd_shifted_only]}
ev = defines.KeyEvent(cyrillic_s, 0, latin_c, ctrl)
self.assertIsNone(get_shortcut(keymap, ev))
# shifted_key + allow_fallback='shifted' → match
# When Shift+key pressed: key='C'(67), shifted_key='c'(99), lookup SingleKey(0, False, 'c')
kd_shifted = make_kd('zoom', allow_fallback='shifted')
keymap = {defines.SingleKey(0, False, latin_c): [kd_shifted]}
ev = defines.KeyEvent(ord('C'), latin_c, 0, shift)
result = get_shortcut(keymap, ev)
self.assertIsNotNone(result)
self.assertIs(result[0], kd_shifted)
# shifted_key + allow_fallback='ascii' (no shifted) → no shifted match
kd_ascii_only = make_kd('zoom', allow_fallback='ascii')
keymap = {defines.SingleKey(0, False, latin_c): [kd_ascii_only]}
ev = defines.KeyEvent(ord('C'), latin_c, 0, shift)
self.assertIsNone(get_shortcut(keymap, ev))
# allow_fallback='' (empty) → no fallback at all
kd_none = make_kd('copy', allow_fallback='')
keymap = {defines.SingleKey(ctrl, False, latin_c): [kd_none]}
# ascii fallback blocked
ev = defines.KeyEvent(cyrillic_s, 0, latin_c, ctrl)
self.assertIsNone(get_shortcut(keymap, ev))
# shifted fallback blocked
ev = defines.KeyEvent(ord('C'), latin_c, 0, ctrl | shift)
self.assertIsNone(get_shortcut(keymap, ev))
# ASCII key (Dvorak) + alternate_key → no fallback (non-ASCII guard: key must be > 127)
kd_dvorak = make_kd('test', allow_fallback='ascii,shifted')
keymap = {defines.SingleKey(ctrl, False, latin_c): [kd_dvorak]}
ev = defines.KeyEvent(ord('k'), 0, latin_c, ctrl) # key='k' is ASCII
self.assertIsNone(get_shortcut(keymap, ev))
# functional key (PUA range 0xE000+) + alternate_key → no fallback (functional keys excluded)
kd_functional = make_kd('escape_action', allow_fallback='ascii,shifted')
keymap = {defines.SingleKey(ctrl, False, latin_c): [kd_functional]}
ev = defines.KeyEvent(0xE000, 0, latin_c, ctrl) # ESCAPE key (functional, PUA range)
self.assertIsNone(get_shortcut(keymap, ev))
# boundary: 0xDFFF (last codepoint before PUA) → should match via ascii fallback
kd_boundary = make_kd('boundary_action', allow_fallback='ascii,shifted')
keymap = {defines.SingleKey(ctrl, False, latin_c): [kd_boundary]}
ev = defines.KeyEvent(0xDFFF, 0, latin_c, ctrl)
self.assertIsNotNone(get_shortcut(keymap, ev))
# boundary: 128 (first non-ASCII codepoint) → should match via ascii fallback
ev = defines.KeyEvent(128, 0, latin_c, ctrl)
self.assertIsNotNone(get_shortcut(keymap, ev))
# direct key match takes priority over alternate_key fallback
kd_direct = make_kd('direct_action', allow_fallback='ascii,shifted')
kd_alt = make_kd('alt_action', allow_fallback='ascii,shifted')
keymap = {
defines.SingleKey(ctrl, False, cyrillic_s): [kd_direct],
defines.SingleKey(ctrl, False, latin_c): [kd_alt],
}
ev = defines.KeyEvent(cyrillic_s, 0, latin_c, ctrl)
result = get_shortcut(keymap, ev)
self.assertIs(result[0], kd_direct) # direct match, not ascii fallback
def test_shortcut_matches_alternate_key(self):
from kitty.keys import shortcut_matches
ctrl = defines.GLFW_MOD_CONTROL
cyrillic_s = 0x441 # Cyrillic 'с'
latin_c = ord('c')
s = defines.SingleKey(ctrl, False, latin_c)
# non-ASCII key + alternate_key → match (unconditional with non-ASCII guard)
ev = defines.KeyEvent(cyrillic_s, 0, latin_c, ctrl)
self.assertTrue(shortcut_matches(s, ev))
# ASCII key + alternate_key → no match (non-ASCII guard blocks it)
ev = defines.KeyEvent(ord('k'), 0, latin_c, ctrl)
self.assertFalse(shortcut_matches(s, ev))
# direct key match still works
ev = defines.KeyEvent(latin_c, 0, 0, ctrl)
self.assertTrue(shortcut_matches(s, ev))
# no alternate_key → no match
ev = defines.KeyEvent(cyrillic_s, 0, 0, ctrl)
self.assertFalse(shortcut_matches(s, ev))
# mods mismatch → no match even with alternate_key
ev = defines.KeyEvent(cyrillic_s, 0, latin_c, defines.GLFW_MOD_ALT)
self.assertFalse(shortcut_matches(s, ev))
# functional key (PUA range 0xE000+) + alternate_key → no match
ev = defines.KeyEvent(0xE000, 0, latin_c, ctrl) # ESCAPE key (functional)
self.assertFalse(shortcut_matches(s, ev))
def test_key_event_matches_alternate_key(self):
from kitty.key_encoding import EventType, KeyEvent
ctrl = 0x4 # CTRL modifier in kitty protocol encoding
# non-ASCII key + alternate_key → match via alternate_key fallback
ev = KeyEvent(type=EventType.PRESS, mods=ctrl, key='\u0441', alternate_key='c', ctrl=True) # Cyrillic 'с'
self.assertTrue(ev.matches('ctrl+c'))
# direct key match still works
ev = KeyEvent(type=EventType.PRESS, mods=ctrl, key='c', ctrl=True)
self.assertTrue(ev.matches('ctrl+c'))
# ASCII key + alternate_key → no match (non-ASCII guard: key must be non-ASCII)
ev = KeyEvent(type=EventType.PRESS, mods=ctrl, key='k', alternate_key='c', ctrl=True)
self.assertFalse(ev.matches('ctrl+c'))
# no alternate_key → no match for non-ASCII key
ev = KeyEvent(type=EventType.PRESS, mods=ctrl, key='\u0441', ctrl=True) # Cyrillic 'с', no alternate_key
self.assertFalse(ev.matches('ctrl+c'))
# mods mismatch → no match even with alternate_key
ev = KeyEvent(type=EventType.PRESS, mods=0x2, key='\u0441', alternate_key='c', alt=True) # ALT, not CTRL
self.assertFalse(ev.matches('ctrl+c'))
# shifted_key still works alongside alternate_key
ev = KeyEvent(type=EventType.PRESS, mods=0x1, key='C', shifted_key='c', shift=True)
self.assertTrue(ev.matches('c'))
# functional key name (multi-char key like "ENTER") → no alternate_key fallback (guard blocks it)
ev = KeyEvent(type=EventType.PRESS, mods=ctrl, key='ENTER', alternate_key='c', ctrl=True)
self.assertFalse(ev.matches('ctrl+c'))
# functional key (single-char PUA 0xE000+) → no alternate_key fallback
ev = KeyEvent(type=EventType.PRESS, mods=ctrl, key='\ue000', alternate_key='c', ctrl=True)
self.assertFalse(ev.matches('ctrl+c'))
def test_allow_fallback_parsing(self):
from kitty.options.utils import parse_map
def first_kd(val):
return next(iter(parse_map(val)))
# default: no --allow-fallback → allow_fallback='shifted'
kd = first_kd('ctrl+c copy_to_clipboard')
self.ae(kd.options.allow_fallback, 'shifted')
# --allow-fallback=shifted,ascii
kd = first_kd('--allow-fallback=shifted,ascii ctrl+c copy_to_clipboard')
self.assertIn('shifted', kd.options.allow_fallback)
self.assertIn('ascii', kd.options.allow_fallback)
# --allow-fallback=ascii (only ascii, no shifted)
kd = first_kd('--allow-fallback=ascii ctrl+c copy_to_clipboard')
self.ae(kd.options.allow_fallback, 'ascii')
self.assertNotIn('shifted', kd.options.allow_fallback)
# --allow-fallback=shifted (explicit, same as default)
kd = first_kd('--allow-fallback=shifted ctrl+c copy_to_clipboard')
self.ae(kd.options.allow_fallback, 'shifted')
# invalid value raises
self.assertRaises(ValueError, first_kd, '--allow-fallback=bogus ctrl+c copy_to_clipboard')
# order normalization: ascii,shifted → sorted as ascii,shifted
kd = first_kd('--allow-fallback=ascii,shifted ctrl+c copy_to_clipboard')
self.ae(kd.options.allow_fallback, 'ascii,shifted')
# --allow-fallback=none → empty string (no fallback)
kd = first_kd('--allow-fallback=none ctrl+c copy_to_clipboard')
self.ae(kd.options.allow_fallback, '')
# combined with other options
kd = first_kd('--when-focus-on 1 --allow-fallback=ascii ctrl+c copy_to_clipboard')
self.ae(kd.options.allow_fallback, 'ascii')
self.ae(kd.options.when_focus_on, '1')

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