diff --git a/docs/mapping.rst b/docs/mapping.rst index 8c2ab760b..61dd16980 100644 --- a/docs/mapping.rst +++ b/docs/mapping.rst @@ -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 -------------------------------------------------------------------------------- diff --git a/kittens/choose_files/main.py b/kittens/choose_files/main.py index f01aeb044..d0812eeda 100644 --- a/kittens/choose_files/main.py +++ b/kittens/choose_files/main.py @@ -156,15 +156,15 @@ map('Change to root directory', 'cd_root ctrl+/ cd /') 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() # }}} diff --git a/kittens/command_palette/main.go b/kittens/command_palette/main.go index 683d155ff..f0bfbe8f8 100644 --- a/kittens/command_palette/main.go +++ b/kittens/command_palette/main.go @@ -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" @@ -256,21 +257,23 @@ type CachedSettings struct { } type Handler struct { - lp *loop.Loop - screen_size loop.ScreenSize - all_items []DisplayItem - filtered_idx []int // indices into all_items for current results - match_infos []matchInfo // parallel to filtered_idx, valid when query != "" - query string - selected_idx int - scroll_offset int - input_data InputData - result string // action definition to execute after exit - display_lines []displayLine - results_start_y int - results_height int - show_unmapped bool - cv *utils.CachedValues[*CachedSettings] + lp *loop.Loop + screen_size loop.ScreenSize + all_items []DisplayItem + filtered_idx []int // indices into all_items for current results + match_infos []matchInfo // parallel to filtered_idx, valid when query != "" + query string + selected_idx int + scroll_offset int + input_data InputData + result string // action definition to execute after exit + display_lines []displayLine + results_start_y int + 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) diff --git a/kittens/command_palette/main.py b/kittens/command_palette/main.py index 1ae64f18e..682c144fe 100644 --- a/kittens/command_palette/main.py +++ b/kittens/command_palette/main.py @@ -6,11 +6,38 @@ import sys 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 @@ elif __name__ == '__doc__': cd['options'] = OPTIONS cd['help_text'] = help_text cd['short_desc'] = help_text +elif __name__ == '__conf__': + sys.options_definition = definition # type: ignore diff --git a/kittens/diff/main.py b/kittens/diff/main.py index 9d6ed6ca4..eb6b30577 100644 --- a/kittens/diff/main.py +++ b/kittens/diff/main.py @@ -192,21 +192,21 @@ egr() # }}} 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 @@ map('Scroll to next page', '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 @@ map('Scroll to previous search match', ) 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() # }}} diff --git a/kittens/themes/main.py b/kittens/themes/main.py index 6ffa35e8d..f8b7ed9eb 100644 --- a/kittens/themes/main.py +++ b/kittens/themes/main.py @@ -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 diff --git a/kittens/themes/ui.go b/kittens/themes/ui.go index 55a082118..f36198329 100644 --- a/kittens/themes/ui.go +++ b/kittens/themes/ui.go @@ -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 } diff --git a/kitty/actions.py b/kitty/actions.py index 0b28408f8..b04f2648a 100644 --- a/kitty/actions.py +++ b/kitty/actions.py @@ -87,8 +87,13 @@ def as_rst() -> 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) diff --git a/kitty/conf/generate.py b/kitty/conf/generate.py index 6f94a0246..0f72f385c 100644 --- a/kitty/conf/generate.py +++ b/kitty/conf/generate.py @@ -10,6 +10,7 @@ from collections.abc import Callable, Iterator 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 gen_go_code(defn: Definition) -> 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,23 +660,22 @@ def gen_go_code(defn: Definition) -> 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(): - ol = oname.lower() - is_multiple = oname in multiopts - a(f'case "{ol}":') - if is_multiple: - a(f'var temp_val []{go_types[oname]}') - else: - a(f'var temp_val {go_types[oname]}') - a(f'temp_val, err = {pname}') - a(f'if err != nil {{ return fmt.Errorf("Failed to parse {ol} = %#v with error: %w", val, err) }}') - if is_multiple: - a(f'c.{oname} = append(c.{oname}, temp_val...)') - else: - a(f'c.{oname} = temp_val') + a('switch key {') + a('default: return fmt.Errorf("Unknown configuration key: %#v", key)') + for oname, pname in go_parsers.items(): + ol = oname.lower() + is_multiple = oname in multiopts + a(f'case "{ol}":') + if is_multiple: + a(f'var temp_val []{go_types[oname]}') + else: + a(f'var temp_val {go_types[oname]}') + a(f'temp_val, err = {pname}') + a(f'if err != nil {{ return fmt.Errorf("Failed to parse {ol} = %#v with error: %w", val, err) }}') + if is_multiple: + a(f'c.{oname} = append(c.{oname}, temp_val...)') + else: + a(f'c.{oname} = temp_val') if keyboard_shortcuts: a('case "map":') a('tempsc, err := config.ParseMap(val)') diff --git a/kitty/conf/types.py b/kitty/conf/types.py index a066d3d82..b61238d2f 100644 --- a/kitty/conf/types.py +++ b/kitty/conf/types.py @@ -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 @@ class Definition: 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) diff --git a/kitty/key_encoding.py b/kitty/key_encoding.py index 8fea44dba..1eeaa9ce9 100644 --- a/kitty/key_encoding.py +++ b/kitty/key_encoding.py @@ -225,6 +225,8 @@ class KeyEvent(NamedTuple): 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: diff --git a/kitty/keys.py b/kitty/keys.py index 6a7922218..0d121ca31 100644 --- a/kitty/keys.py +++ b/kitty/keys.py @@ -23,13 +23,14 @@ from .fast_data_types import ( 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 @@ class Mappings: 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 diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 1dab7ef25..23f1ef023 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -12,7 +12,7 @@ from kitty.options.utils import pointer_shape_names 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 @@ Some quick examples to illustrate common tasks:: # 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 `. @@ -3998,7 +4006,7 @@ option applied. 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 @@ the key through to the application running in the terminal if there is no select ''' ) 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 @@ map('Scroll line up', '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 @@ map('Scroll line down', '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 bottom', ) 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 @@ jumped to or the last clicked position. Requires :ref:`shell integration ''' ) -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 @@ see :doc:`launch`. ) 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 @@ a manual mapping with a special pager for this, you can use something like: 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 window', ) 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 `. In particular you can use :ac:`new_os_window_with_cwd` to @@ -4228,15 +4236,15 @@ open a window with the current working directory. ''' ) 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('Previous window', ) 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('Move window to top', ) 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('Previous tab', ) 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('Move tab backward', ) 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 @@ end of the tabs list, use:: 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 `:: @@ -4537,7 +4545,7 @@ insert it into the terminal or copy it to the clipboard. ''') 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 @@ URL is specified in :opt:`open_url_with`. ) 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 @@ command. ) 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 ` kitten and insert it into the terminal. @@ -4567,7 +4575,7 @@ it into the terminal. ) 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 ` kitten and insert it into the terminal. @@ -4576,7 +4584,7 @@ it into the terminal. 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 @@ things like: ``ls -1``. ) 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 @@ with :program:`git`, which uses SHA1 hashes to identify commits. ) 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 @@ your default editor at the specified line number. ) 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 ` (i.e. a URL that has been marked as such by the terminal program, for example, by ``ls --hyperlink=auto``). @@ -4630,7 +4638,7 @@ map('Toggle fullscreen', '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 maximized', ) 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', 'macos_cycle_through_os_windows cmd+` maco 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 @@ Open the kitty shell in a new :code:`window` / :code:`tab` / :code:`overlay` / ) 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', diff --git a/kitty/options/types.py b/kitty/options/types.py index 5bf444fc5..0bfe619f0 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -11,8 +11,8 @@ import kitty.fast_data_types 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 @@ defaults.watcher = {} 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 @@ defaults.map = [ # 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 @@ defaults.map = [ # 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 @@ defaults.map = [ # 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 @@ defaults.map = [ # 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 @@ defaults.map = [ ] 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 @@ if is_macos: 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 @@ if is_macos: 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')) diff --git a/kitty/options/utils.py b/kitty/options/utils.py index 0189dc19e..a5d2b1211 100644 --- a/kitty/options/utils.py +++ b/kitty/options/utils.py @@ -1284,24 +1284,27 @@ class LiteralField(Generic[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 @@ key_map_option_converters: defaultdict[str, Callable[[str], Any]] = defaultdict( 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() diff --git a/kitty_tests/keys.py b/kitty_tests/keys.py index 1754e784d..ac432a63e 100644 --- a/kitty_tests/keys.py +++ b/kitty_tests/keys.py @@ -654,3 +654,222 @@ class TestKeys(BaseTest): 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') diff --git a/tools/config/utils.go b/tools/config/utils.go index 48df04ef3..09f656249 100644 --- a/tools/config/utils.go +++ b/tools/config/utils.go @@ -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 diff --git a/tools/config/utils_test.go b/tools/config/utils_test.go index 1f10df356..30cf33324 100644 --- a/tools/config/utils_test.go +++ b/tools/config/utils_test.go @@ -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`, diff --git a/tools/tui/loop/key-encoding.go b/tools/tui/loop/key-encoding.go index 42ae94249..77fbaa5c4 100644 --- a/tools/tui/loop/key-encoding.go +++ b/tools/tui/loop/key-encoding.go @@ -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 diff --git a/tools/tui/loop/key-encoding_test.go b/tools/tui/loop/key-encoding_test.go index 1bcb87493..90f625306 100644 --- a/tools/tui/loop/key-encoding_test.go +++ b/tools/tui/loop/key-encoding_test.go @@ -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") + } +}