Add timeout option to key mappings

This commit is contained in:
Anurag
2026-02-24 14:55:09 +05:30
parent ced3a98aa8
commit 37dc6e82e4
7 changed files with 103 additions and 9 deletions

View File

@@ -167,6 +167,33 @@ the program running in the terminal, map it to :ac:`discard_event`::
.. _conditional_mappings: .. _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 Conditional mappings depending on the state of the focused window
---------------------------------------------------------------------- ----------------------------------------------------------------------

View File

@@ -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) modes[defn.options.new_mode] = nm = KeyboardMode(defn.options.new_mode)
nm.on_unknown = defn.options.on_unknown nm.on_unknown = defn.options.on_unknown
nm.on_action = defn.options.on_action 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}' defn.definition = f'push_keyboard_mode {defn.options.new_mode}'
try: try:
m = modes[defn.options.mode] m = modes[defn.options.mode]

View File

@@ -62,11 +62,11 @@ def shortcut_matches(s: SingleKey, ev: KeyEvent) -> bool:
class Mappings: 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.keyboard_mode_stack: list[KeyboardMode] = []
self.mode_timeout_timer_id: int | None = None
self.update_keymap(global_shortcuts) self.update_keymap(global_shortcuts)
self.callback_on_mode_change = callback_on_mode_change self.callback_on_mode_change = callback_on_mode_change
@@ -87,6 +87,7 @@ class Mappings:
def clear_keyboard_modes(self) -> None: def clear_keyboard_modes(self) -> None:
had_mode = bool(self.keyboard_mode_stack) had_mode = bool(self.keyboard_mode_stack)
self._cancel_mode_timeout()
self.keyboard_mode_stack = [] self.keyboard_mode_stack = []
self.set_ignore_os_keyboard_processing(False) self.set_ignore_os_keyboard_processing(False)
if had_mode: if had_mode:
@@ -95,6 +96,7 @@ class Mappings:
def pop_keyboard_mode(self) -> bool: def pop_keyboard_mode(self) -> bool:
passthrough = True passthrough = True
if self.keyboard_mode_stack: if self.keyboard_mode_stack:
self._cancel_mode_timeout()
self.keyboard_mode_stack.pop() self.keyboard_mode_stack.pop()
if not self.keyboard_mode_stack: if not self.keyboard_mode_stack:
self.set_ignore_os_keyboard_processing(False) self.set_ignore_os_keyboard_processing(False)
@@ -110,12 +112,38 @@ class Mappings:
def _push_keyboard_mode(self, mode: KeyboardMode) -> None: def _push_keyboard_mode(self, mode: KeyboardMode) -> None:
self.keyboard_mode_stack.append(mode) self.keyboard_mode_stack.append(mode)
self.set_ignore_os_keyboard_processing(True) self.set_ignore_os_keyboard_processing(True)
self._start_mode_timeout(mode)
self.callback_on_mode_change() self.callback_on_mode_change()
def push_keyboard_mode(self, new_mode: str) -> None: def push_keyboard_mode(self, new_mode: str) -> None:
mode = self.keyboard_modes[new_mode] mode = self.keyboard_modes[new_mode]
self._push_keyboard_mode(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]: def matching_key_actions(self, candidates: Iterable[KeyDefinition]) -> list[KeyDefinition]:
w = self.get_active_window() w = self.get_active_window()
matches = [] matches = []
@@ -128,8 +156,9 @@ class Mappings:
is_applicable = True is_applicable = True
except Exception: except Exception:
self.clear_keyboard_modes() self.clear_keyboard_modes()
self.show_error(_('Invalid key mapping'), _( self.show_error(
'The match expression {0} is not valid for {1}').format(x.options.when_focus_on, '--when-focus-on')) _('Invalid key mapping'), _('The match expression {0} is not valid for {1}').format(x.options.when_focus_on, '--when-focus-on')
)
return [] return []
else: else:
is_applicable = True is_applicable = True
@@ -143,10 +172,10 @@ class Mappings:
if not x.rest: if not x.rest:
last_terminal_idx = i last_terminal_idx = i
if last_terminal_idx > -1: if last_terminal_idx > -1:
if last_terminal_idx == len(matches) -1: if last_terminal_idx == len(matches) - 1:
matches = matches[last_terminal_idx:] matches = matches[last_terminal_idx:]
else: else:
matches = matches[last_terminal_idx+1:] matches = matches[last_terminal_idx + 1 :]
q = matches[-1].options.when_focus_on q = matches[-1].options.when_focus_on
matches = [x for x in matches if x.options.when_focus_on == q] matches = [x for x in matches if x.options.when_focus_on == q]
elif matches: elif matches:
@@ -192,6 +221,7 @@ class Mappings:
sm = KeyboardMode('__sequence__') sm = KeyboardMode('__sequence__')
sm.on_action = 'end' sm.on_action = 'end'
sm.sequence_keys = [ev] sm.sequence_keys = [ev]
sm.timeout = self._get_effective_timeout(final_actions[0])
for fa in final_actions: for fa in final_actions:
sm.keymap[fa.rest[0]].append(fa.shift_sequence_and_copy()) sm.keymap[fa.rest[0]].append(fa.shift_sequence_and_copy())
self._push_keyboard_mode(sm) self._push_keyboard_mode(sm)
@@ -206,6 +236,7 @@ class Mappings:
w.send_key_sequence(*mode.sequence_keys) w.send_key_sequence(*mode.sequence_keys)
return consumed return consumed
mode.sequence_keys.append(ev) mode.sequence_keys.append(ev)
self._start_mode_timeout(mode)
self.debug_print('\n\x1b[35mKeyPress\x1b[m matched sequence prefix, ', end='') self.debug_print('\n\x1b[35mKeyPress\x1b[m matched sequence prefix, ', end='')
mode.keymap.clear() mode.keymap.clear()
for fa in final_actions: for fa in final_actions:
@@ -219,6 +250,8 @@ class Mappings:
self.callback_on_mode_change() self.callback_on_mode_change()
if not self.keyboard_mode_stack: if not self.keyboard_mode_stack:
self.set_ignore_os_keyboard_processing(False) 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 consumed
return False return False
@@ -252,5 +285,7 @@ class Mappings:
def set_cocoa_global_shortcuts(self, opts: Options) -> dict[str, SingleKey]: def set_cocoa_global_shortcuts(self, opts: Options) -> dict[str, SingleKey]:
from .main import set_cocoa_global_shortcuts from .main import set_cocoa_global_shortcuts
return set_cocoa_global_shortcuts(opts) return set_cocoa_global_shortcuts(opts)
# }}} # }}}

View File

@@ -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', opt('+action_alias', 'launch_tab launch --type=tab --cwd=current',
option_type='action_alias', option_type='action_alias',
add_to_default=False, add_to_default=False,

View File

@@ -1060,6 +1060,9 @@ class Parser:
def kitty_mod(self, val: str, ans: dict[str, typing.Any]) -> None: def kitty_mod(self, val: str, ans: dict[str, typing.Any]) -> None:
ans['kitty_mod'] = to_modifiers(val) 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: def linux_bell_theme(self, val: str, ans: dict[str, typing.Any]) -> None:
ans['linux_bell_theme'] = str(val) ans['linux_bell_theme'] = str(val)

View File

@@ -390,6 +390,7 @@ option_names = (
'macos_traditional_fullscreen', 'macos_traditional_fullscreen',
'macos_window_resizable', 'macos_window_resizable',
'map', 'map',
'map_timeout',
'mark1_background', 'mark1_background',
'mark1_foreground', 'mark1_foreground',
'mark2_background', 'mark2_background',
@@ -572,6 +573,7 @@ class Options:
input_delay: int = 3 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') 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 kitty_mod: int = 5
map_timeout: float = 0.0
linux_bell_theme: str = '__custom' linux_bell_theme: str = '__custom'
linux_display_server: choices_for_linux_display_server = 'auto' linux_display_server: choices_for_linux_display_server = 'auto'
listen_on: str = 'none' listen_on: str = 'none'

View File

@@ -1290,6 +1290,7 @@ class KeyMapOptions:
mode: str = '' mode: str = ''
on_unknown: LiteralField[OnUnknown] = LiteralField[OnUnknown](get_args(OnUnknown)) on_unknown: LiteralField[OnUnknown] = LiteralField[OnUnknown](get_args(OnUnknown))
on_action: LiteralField[OnAction] = LiteralField[OnAction](get_args(OnAction)) on_action: LiteralField[OnAction] = LiteralField[OnAction](get_args(OnAction))
timeout: float | None = None
default_key_map_options = KeyMapOptions() default_key_map_options = KeyMapOptions()
@@ -1349,6 +1350,8 @@ class KeyboardMode:
on_unknown: OnUnknown = get_args(OnUnknown)[0] on_unknown: OnUnknown = get_args(OnUnknown)[0]
on_action : OnAction = get_args(OnAction)[0] on_action : OnAction = get_args(OnAction)[0]
sequence_keys: list[defines.KeyEvent] | None = None sequence_keys: list[defines.KeyEvent] | None = None
timeout: float = 0.0
timeout_timer_id: int | None = None
def __init__(self, name: str = '') -> None: def __init__(self, name: str = '') -> None:
self.name = name self.name = name
@@ -1361,11 +1364,15 @@ KeyboardModeMap = dict[str, KeyboardMode]
def parse_options_for_map(val: str) -> tuple[KeyMapOptions, str]: def parse_options_for_map(val: str) -> tuple[KeyMapOptions, str]:
expecting_arg = '' expecting_arg = ''
ans = KeyMapOptions() ans = KeyMapOptions()
key_map_option_converters: dict[str, Callable[[str], object]] = {
'timeout': float,
}
s = Shlex(val) s = Shlex(val)
while (tok := s.next_word())[0] > -1: while (tok := s.next_word())[0] > -1:
x = tok[1] x = tok[1]
if expecting_arg: 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 = '' expecting_arg = ''
elif x.startswith('--'): elif x.startswith('--'):
expecting_arg = x[2:] 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: 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)}') raise KeyError(f'The map option {x} is unknown. Allowed options: {", ".join(allowed_key_map_options)}')
if sep == '=': 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 = '' expecting_arg = ''
else: else:
return ans, val[tok[0]:] return ans, val[tok[0]:]