From 37dc6e82e4ff8dbb9857aa7358dc5d70c63455a5 Mon Sep 17 00:00:00 2001 From: Anurag <86455065+theAnuragMishra@users.noreply.github.com> Date: Tue, 24 Feb 2026 14:55:09 +0530 Subject: [PATCH] Add timeout option to key mappings --- docs/mapping.rst | 27 ++++++++++++++++++++ kitty/config.py | 1 + kitty/keys.py | 49 +++++++++++++++++++++++++++++++------ kitty/options/definition.py | 18 ++++++++++++++ kitty/options/parse.py | 3 +++ kitty/options/types.py | 2 ++ kitty/options/utils.py | 12 +++++++-- 7 files changed, 103 insertions(+), 9 deletions(-) diff --git a/docs/mapping.rst b/docs/mapping.rst index 17ac13930..8c2ab760b 100644 --- a/docs/mapping.rst +++ b/docs/mapping.rst @@ -167,6 +167,33 @@ the program running in the terminal, map it to :ac:`discard_event`:: .. _conditional_mappings: +Configuring a timeout +---------------------- + +You can also set a timeout for keyboard modes and multi-key mappings. If a +timeout is set and you don't complete the key sequence or exit the mode within +the specified time, the mode will be automatically cancelled. This is useful +for multi-key mappings where you might accidentally press the first key and +then change your mind. The timeout is specified in seconds and can be set +globally using the :opt:`map_timeout` option or per-mode using ``--timeout``:: + + # Set a global 2 second timeout for all multi-key and modal mappings + map_timeout 2.0 + + # This mode will have a 5 second timeout (overrides global setting) + map --new-mode resize --timeout 5.0 kitty_mod+r + map --mode resize h resize_window narrower + map --mode resize l resize_window wider + # ... more mappings + + # Multi-key mapping with the global timeout + map ctrl+a>h new_window + +When a timeout occurs, the mode is exited and any buffered keys are discarded. +A timeout value of zero disables the timeout. For multi-key sequences, the +timeout is restarted after each valid key press in the sequence. + + Conditional mappings depending on the state of the focused window ---------------------------------------------------------------------- diff --git a/kitty/config.py b/kitty/config.py index ae39c8f84..4e9cb53e3 100644 --- a/kitty/config.py +++ b/kitty/config.py @@ -99,6 +99,7 @@ def finalize_keys(opts: Options, accumulate_bad_lines: list[BadLine] | None = No modes[defn.options.new_mode] = nm = KeyboardMode(defn.options.new_mode) nm.on_unknown = defn.options.on_unknown nm.on_action = defn.options.on_action + nm.timeout = defn.options.timeout if defn.options.timeout is not None else opts.map_timeout defn.definition = f'push_keyboard_mode {defn.options.new_mode}' try: m = modes[defn.options.mode] diff --git a/kitty/keys.py b/kitty/keys.py index 24cbcdc2f..f1985e4f9 100644 --- a/kitty/keys.py +++ b/kitty/keys.py @@ -62,11 +62,11 @@ def shortcut_matches(s: SingleKey, ev: KeyEvent) -> bool: class Mappings: + "Manage all keyboard mappings" - ' Manage all keyboard mappings ' - - def __init__(self, global_shortcuts:dict[str, SingleKey] | None = None, callback_on_mode_change: Callable[[], Any] = lambda: None) -> None: + def __init__(self, global_shortcuts: dict[str, SingleKey] | None = None, callback_on_mode_change: Callable[[], Any] = lambda: None) -> None: self.keyboard_mode_stack: list[KeyboardMode] = [] + self.mode_timeout_timer_id: int | None = None self.update_keymap(global_shortcuts) self.callback_on_mode_change = callback_on_mode_change @@ -87,6 +87,7 @@ class Mappings: def clear_keyboard_modes(self) -> None: had_mode = bool(self.keyboard_mode_stack) + self._cancel_mode_timeout() self.keyboard_mode_stack = [] self.set_ignore_os_keyboard_processing(False) if had_mode: @@ -95,6 +96,7 @@ class Mappings: def pop_keyboard_mode(self) -> bool: passthrough = True if self.keyboard_mode_stack: + self._cancel_mode_timeout() self.keyboard_mode_stack.pop() if not self.keyboard_mode_stack: self.set_ignore_os_keyboard_processing(False) @@ -110,12 +112,38 @@ class Mappings: def _push_keyboard_mode(self, mode: KeyboardMode) -> None: self.keyboard_mode_stack.append(mode) self.set_ignore_os_keyboard_processing(True) + self._start_mode_timeout(mode) self.callback_on_mode_change() def push_keyboard_mode(self, new_mode: str) -> None: mode = self.keyboard_modes[new_mode] self._push_keyboard_mode(mode) + def _start_mode_timeout(self, mode: KeyboardMode) -> None: + if mode.timeout > 0: + from .fast_data_types import add_timer + + self._cancel_mode_timeout() + self.mode_timeout_timer_id = add_timer(self._on_mode_timeout, mode.timeout, False) + mode.timeout_timer_id = self.mode_timeout_timer_id + + def _cancel_mode_timeout(self) -> None: + if self.mode_timeout_timer_id is not None: + from .fast_data_types import remove_timer + + remove_timer(self.mode_timeout_timer_id) + self.mode_timeout_timer_id = None + + def _on_mode_timeout(self, timer_id: int | None) -> None: + self.mode_timeout_timer_id = None + if self.keyboard_mode_stack: + self.pop_keyboard_mode() + + def _get_effective_timeout(self, key_def: KeyDefinition) -> float: + if key_def.options.timeout is not None: + return key_def.options.timeout + return self.get_options().map_timeout + def matching_key_actions(self, candidates: Iterable[KeyDefinition]) -> list[KeyDefinition]: w = self.get_active_window() matches = [] @@ -128,8 +156,9 @@ class Mappings: is_applicable = True except Exception: self.clear_keyboard_modes() - self.show_error(_('Invalid key mapping'), _( - 'The match expression {0} is not valid for {1}').format(x.options.when_focus_on, '--when-focus-on')) + self.show_error( + _('Invalid key mapping'), _('The match expression {0} is not valid for {1}').format(x.options.when_focus_on, '--when-focus-on') + ) return [] else: is_applicable = True @@ -143,10 +172,10 @@ class Mappings: if not x.rest: last_terminal_idx = i if last_terminal_idx > -1: - if last_terminal_idx == len(matches) -1: + if last_terminal_idx == len(matches) - 1: matches = matches[last_terminal_idx:] else: - matches = matches[last_terminal_idx+1:] + matches = matches[last_terminal_idx + 1 :] q = matches[-1].options.when_focus_on matches = [x for x in matches if x.options.when_focus_on == q] elif matches: @@ -192,6 +221,7 @@ class Mappings: sm = KeyboardMode('__sequence__') sm.on_action = 'end' sm.sequence_keys = [ev] + sm.timeout = self._get_effective_timeout(final_actions[0]) for fa in final_actions: sm.keymap[fa.rest[0]].append(fa.shift_sequence_and_copy()) self._push_keyboard_mode(sm) @@ -206,6 +236,7 @@ class Mappings: w.send_key_sequence(*mode.sequence_keys) return consumed mode.sequence_keys.append(ev) + self._start_mode_timeout(mode) self.debug_print('\n\x1b[35mKeyPress\x1b[m matched sequence prefix, ', end='') mode.keymap.clear() for fa in final_actions: @@ -219,6 +250,8 @@ class Mappings: self.callback_on_mode_change() if not self.keyboard_mode_stack: self.set_ignore_os_keyboard_processing(False) + elif not is_root_mode and mode_pos < len(self.keyboard_mode_stack) and self.keyboard_mode_stack[mode_pos] is mode: + self._start_mode_timeout(mode) return consumed return False @@ -252,5 +285,7 @@ class Mappings: def set_cocoa_global_shortcuts(self, opts: Options) -> dict[str, SingleKey]: from .main import set_cocoa_global_shortcuts + return set_cocoa_global_shortcuts(opts) + # }}} diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 0765e8539..e04ee5aff 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -3847,6 +3847,24 @@ remove the default shortcuts. ''' ) +opt('map_timeout', '0.0', + option_type='positive_float', ctype='time', + long_text=''' +The default timeout (in seconds) for multi-key mappings and modal keyboard modes. +If you press the first key(s) of a multi-key mapping and don't press the next +key within this timeout, the mapping is cancelled and the mode is exited. A value +of zero disables the timeout. This can be overridden for specific modes using the +:code:`--timeout` option when creating a keyboard mode with :code:`--new-mode`. +For example:: + + # 2 second timeout for all mappings + map_timeout 2.0 + + # This mode will have a 5 second timeout (overrides the global 2 second timeout) + map --new-mode resize --timeout 5.0 kitty_mod+r +''' + ) + opt('+action_alias', 'launch_tab launch --type=tab --cwd=current', option_type='action_alias', add_to_default=False, diff --git a/kitty/options/parse.py b/kitty/options/parse.py index 2fff89848..055183fd9 100644 --- a/kitty/options/parse.py +++ b/kitty/options/parse.py @@ -1060,6 +1060,9 @@ class Parser: def kitty_mod(self, val: str, ans: dict[str, typing.Any]) -> None: ans['kitty_mod'] = to_modifiers(val) + def map_timeout(self, val: str, ans: dict[str, typing.Any]) -> None: + ans['map_timeout'] = positive_float(val) + def linux_bell_theme(self, val: str, ans: dict[str, typing.Any]) -> None: ans['linux_bell_theme'] = str(val) diff --git a/kitty/options/types.py b/kitty/options/types.py index c8c192d80..e21c22c70 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -390,6 +390,7 @@ option_names = ( 'macos_traditional_fullscreen', 'macos_window_resizable', 'map', + 'map_timeout', 'mark1_background', 'mark1_foreground', 'mark2_background', @@ -572,6 +573,7 @@ class Options: input_delay: int = 3 italic_font: FontSpec = FontSpec(family=None, style=None, postscript_name=None, full_name=None, system='auto', axes=(), variable_name=None, features=(), created_from_string='auto') kitty_mod: int = 5 + map_timeout: float = 0.0 linux_bell_theme: str = '__custom' linux_display_server: choices_for_linux_display_server = 'auto' listen_on: str = 'none' diff --git a/kitty/options/utils.py b/kitty/options/utils.py index 87232cd40..5db535675 100644 --- a/kitty/options/utils.py +++ b/kitty/options/utils.py @@ -1290,6 +1290,7 @@ class KeyMapOptions: mode: str = '' on_unknown: LiteralField[OnUnknown] = LiteralField[OnUnknown](get_args(OnUnknown)) on_action: LiteralField[OnAction] = LiteralField[OnAction](get_args(OnAction)) + timeout: float | None = None default_key_map_options = KeyMapOptions() @@ -1349,6 +1350,8 @@ class KeyboardMode: on_unknown: OnUnknown = get_args(OnUnknown)[0] on_action : OnAction = get_args(OnAction)[0] sequence_keys: list[defines.KeyEvent] | None = None + timeout: float = 0.0 + timeout_timer_id: int | None = None def __init__(self, name: str = '') -> None: self.name = name @@ -1361,11 +1364,15 @@ KeyboardModeMap = dict[str, KeyboardMode] def parse_options_for_map(val: str) -> tuple[KeyMapOptions, str]: expecting_arg = '' ans = KeyMapOptions() + key_map_option_converters: dict[str, Callable[[str], object]] = { + 'timeout': float, + } s = Shlex(val) while (tok := s.next_word())[0] > -1: x = tok[1] if expecting_arg: - object.__setattr__(ans, expecting_arg, x) + convert = key_map_option_converters.get(expecting_arg) + object.__setattr__(ans, expecting_arg, convert(x) if convert else x) expecting_arg = '' elif x.startswith('--'): expecting_arg = x[2:] @@ -1375,7 +1382,8 @@ def parse_options_for_map(val: str) -> tuple[KeyMapOptions, str]: if expecting_arg not in allowed_key_map_options: raise KeyError(f'The map option {x} is unknown. Allowed options: {", ".join(allowed_key_map_options)}') if sep == '=': - object.__setattr__(ans, k, v) + convert = key_map_option_converters.get(k) + object.__setattr__(ans, k, convert(v) if convert else v) expecting_arg = '' else: return ans, val[tok[0]:]