From 9e815212dcdab2b114fc5a89b63e9b3e7a6235e6 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 30 Nov 2023 19:44:41 +0530 Subject: [PATCH] Implement modal keyboard handling --- kitty/boss.py | 125 +++++++++++++++++------------------- kitty/config.py | 27 ++++---- kitty/fast_data_types.pyi | 2 +- kitty/keys.c | 12 ---- kitty/keys.py | 11 +--- kitty/main.py | 2 +- kitty/options/definition.py | 2 +- kitty/options/types.py | 5 +- kitty/options/utils.py | 20 +++++- kitty/state.c | 7 +- kitty/state.h | 1 - 11 files changed, 100 insertions(+), 114 deletions(-) diff --git a/kitty/boss.py b/kitty/boss.py index 245489361..28c7c5e58 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -66,7 +66,6 @@ from .fast_data_types import ( GLFW_MOD_SUPER, GLFW_MOUSE_BUTTON_LEFT, GLFW_PRESS, - GLFW_RELEASE, IMPERATIVE_CLOSE_REQUESTED, NO_CLOSE_REQUESTED, ChildMonitor, @@ -92,7 +91,6 @@ from .fast_data_types import ( get_options, get_os_window_size, global_font_size, - is_modifier_key, last_focused_os_window_id, mark_os_window_for_close, os_window_font_size, @@ -105,7 +103,7 @@ from .fast_data_types import ( set_application_quit_request, set_background_image, set_boss, - set_in_sequence_mode, + set_ignore_os_keyboard_processing, set_options, set_os_window_chrome, set_os_window_size, @@ -117,11 +115,11 @@ from .fast_data_types import ( wrapped_kitten_names, ) from .key_encoding import get_name_to_functional_number_map -from .keys import get_shortcut, shortcut_matches +from .keys import get_shortcut from .layout.base import set_layout_options from .notify import notification_activated from .options.types import Options -from .options.utils import MINIMUM_FONT_SIZE, KeyDefinition, KeyMap, SubSequenceMap +from .options.utils import MINIMUM_FONT_SIZE, KeyboardMode, KeyDefinition, KeyMap from .os_window_size import initial_window_size_func from .rgb import color_from_int from .session import Session, create_sessions, get_os_window_sizing_data @@ -297,7 +295,7 @@ class VisualSelect: set_os_window_title(self.os_window_id, '') boss = get_boss() redirect_mouse_handling(False) - boss.clear_pending_sequences() + boss.keyboard_mode_stack = [] for wid in self.window_ids: w = boss.window_id_map.get(wid) if w is not None: @@ -343,10 +341,7 @@ class Boss: self.startup_colors = {k: opts[k] for k in opts if isinstance(opts[k], Color)} self.current_visual_select: Optional[VisualSelect] = None self.startup_cursor_text_color = opts.cursor_text_color - self.pending_sequences: Optional[SubSequenceMap] = None # A list of events received so far that are potentially part of a sequence keybinding. - self.current_sequence: List[KeyEvent] = [] - self.default_pending_action: str = '' self.cached_values = cached_values self.os_window_map: Dict[int, TabManager] = {} self.os_window_death_actions: Dict[int, Callable[[], None]] = {} @@ -378,6 +373,7 @@ class Boss: set_boss(self) self.args = args self.mouse_handler: Optional[Callable[[WindowSystemMouseEvent], None]] = None + self.keyboard_mode_stack: List[KeyboardMode] = [] self.update_keymap(global_shortcuts) if is_macos: from .fast_data_types import cocoa_set_notification_activated_callback @@ -392,9 +388,11 @@ class Boss: global_shortcuts = {} self.global_shortcuts_map: KeyMap = {v: [KeyDefinition(definition=k)] for k, v in global_shortcuts.items()} self.global_shortcuts = global_shortcuts - self.keymap = get_options().keymap.copy() + self.keyboard_modes = get_options().keyboard_modes.copy() + km = self.keyboard_modes[''].keymap + self.keyboard_modes[''].keymap = km = km.copy() for sc in self.global_shortcuts.values(): - self.keymap.pop(sc, None) + km.pop(sc, None) def startup_first_child(self, os_window_id: Optional[int], startup_sessions: Iterable[Session] = ()) -> None: si = startup_sessions or create_sessions(get_options(), self.args, default_session=get_options().startup_session) @@ -1337,71 +1335,64 @@ class Boss: t = self.active_tab return None if t is None else t.active_window - def set_pending_sequences(self, sequences: SubSequenceMap, default_pending_action: str = '') -> None: - self.pending_sequences = sequences - self.default_pending_action = default_pending_action - set_in_sequence_mode(True) + @ac('misc', ''' + End the current keyboard mode switching to the previous mode. + ''') + def pop_keyboard_mode(self) -> bool: + if self.keyboard_mode_stack: + self.keyboard_mode_stack.pop() + if not self.keyboard_mode_stack: + set_ignore_os_keyboard_processing(False) + return True + return False + + @ac('misc', ''' + Switch to the specified keyboard mode, pushing it onto the stack of keyboard modes. + ''') + def push_keyboard_mode(self, new_mode: str) -> None: + mode = self.keyboard_modes[new_mode] + self._push_keyboard_mode(mode) + + def _push_keyboard_mode(self, mode: KeyboardMode) -> None: + self.keyboard_mode_stack.append(mode) + set_ignore_os_keyboard_processing(True) def dispatch_possible_special_key(self, ev: KeyEvent) -> bool: # Handles shortcuts, return True if the key was consumed - key_action = get_shortcut(self.keymap, ev) + is_root_mode = not self.keyboard_mode_stack + mode = self.keyboard_modes[''] if is_root_mode else self.keyboard_mode_stack[-1] + key_action = get_shortcut(mode.keymap, ev) if key_action is None: - sequences = get_shortcut(get_options().sequence_map, ev) - if sequences: - self.set_pending_sequences(sequences) - self.current_sequence = [ev] - return True if self.global_shortcuts_map and get_shortcut(self.global_shortcuts_map, ev): return True - elif key_action: + if self.pop_keyboard_mode(): + return True + else: final_action = self.matching_key_action(key_action) if final_action is not None: - return self.combine(final_action.definition) - return False - - def clear_pending_sequences(self) -> None: - self.pending_sequences = None - self.current_sequence = [] - self.default_pending_action = '' - set_in_sequence_mode(False) - - def process_sequence(self, ev: KeyEvent) -> bool: - # Process an event as part of a sequence. Returns whether the key - # is consumed as part of a kitty sequence keybinding. - if not self.pending_sequences: - set_in_sequence_mode(False) - return False - - if self.current_sequence: - self.current_sequence.append(ev) - if ev.action == GLFW_RELEASE or is_modifier_key(ev.key): - return True - # For a press/repeat event that's not a modifier, try matching with - # kitty bindings: - remaining = {} - matched_action = None - for seq, key_actions in self.pending_sequences.items(): - if shortcut_matches(seq[0], ev): - key_action = self.matching_key_action(key_actions) - if key_action is not None: - seq = seq[1:] - if seq: - remaining[seq] = [key_action] + mode_pos = len(self.keyboard_mode_stack) - 1 + if final_action.is_sequence: + if mode.sequence_left is None: + sm = KeyboardMode('__sequence__') + sm.end_on_action = True + sm.sequence_left = final_action.rest[1:] + sm.keymap[final_action.rest[0]].append(final_action) + self._push_keyboard_mode(sm) + elif mode.sequence_left: + mode.keymap.clear() + mode.keymap[mode.sequence_left[0]].append(final_action) + mode.sequence_left = mode.sequence_left[1:] else: - matched_action = key_action - - if remaining: - self.pending_sequences = remaining - return True - final_action = self.default_pending_action if matched_action is None else matched_action.definition - if final_action: - self.clear_pending_sequences() - self.combine(final_action) - return True - w = self.active_window - if w is not None: - w.write_to_child(b''.join(w.encoded_key(ev) for ev in self.current_sequence)) - self.clear_pending_sequences() + self.pop_keyboard_mode() + return self.combine(final_action.definition) + return True + consumed = self.combine(final_action.definition) + if consumed and not is_root_mode and mode.end_on_action: + if mode_pos < len(self.keyboard_mode_stack) and self.keyboard_mode_stack[mode_pos] is mode: + del self.keyboard_mode_stack[mode_pos] + if not self.keyboard_mode_stack: + set_ignore_os_keyboard_processing(False) + return consumed return False def matching_key_action(self, candidates: Iterable[KeyDefinition]) -> Optional[KeyDefinition]: diff --git a/kitty/config.py b/kitty/config.py index c43da60e0..324f9ffb6 100644 --- a/kitty/config.py +++ b/kitty/config.py @@ -3,7 +3,6 @@ import json import os -from collections import defaultdict from contextlib import contextmanager, suppress from functools import partial from typing import Any, Dict, Generator, Iterable, List, Optional, Tuple @@ -12,7 +11,7 @@ from .conf.utils import BadLine, parse_config_base from .conf.utils import load_config as _load_config from .constants import cache_dir, defconf from .options.types import Options, defaults, option_names -from .options.utils import KeyDefinition, KeyMap, MouseMap, MouseMapping, SequenceMap, build_action_aliases +from .options.utils import KeyboardMode, KeyboardModeMap, KeyDefinition, MouseMap, MouseMapping, build_action_aliases from .typing import TypedDict from .utils import log_error @@ -101,19 +100,21 @@ def finalize_keys(opts: Options, accumulate_bad_lines: Optional[List[BadLine]] = else: accumulate_bad_lines.append(BadLine(d.definition_location.number, d.definition_location.line, err, d.definition_location.file)) - keymap: KeyMap = defaultdict(list) - sequence_map: SequenceMap = {} + modes: KeyboardModeMap = {'': KeyboardMode()} for defn in defns: - if defn.is_sequence: - keymap.pop(defn.trigger, None) - s = sequence_map.setdefault(defn.trigger, defaultdict(list)) - s[defn.rest].append(defn) - else: - sequence_map.pop(defn.trigger, None) - keymap[defn.trigger].append(defn) - opts.keymap = keymap - opts.sequence_map = sequence_map + if defn.options.new_mode: + modes[defn.options.new_mode] = nm = KeyboardMode(defn.options.new_mode) + nm.passthrough_unknown = defn.options.passthrough_unknown + nm.end_on_action = defn.options.end_on_action + defn.definition = f'push_keyboard_mode {defn.options.new_mode}' + try: + m = modes[defn.options.mode] + except KeyError: + log_error(f'The keyboard mode {defn.options.mode} is unknown, ignoring the mapping: {defn}') + continue + m.keymap[defn.trigger].append(defn) + opts.keyboard_modes = modes def finalize_mouse_mappings(opts: Options, accumulate_bad_lines: Optional[List[BadLine]] = None) -> None: diff --git a/kitty/fast_data_types.pyi b/kitty/fast_data_types.pyi index aed8f696e..06a8e2ff2 100644 --- a/kitty/fast_data_types.pyi +++ b/kitty/fast_data_types.pyi @@ -604,7 +604,7 @@ def thread_write(fd: int, data: bytes) -> None: pass -def set_in_sequence_mode(yes: bool) -> None: +def set_ignore_os_keyboard_processing(yes: bool) -> None: pass diff --git a/kitty/keys.c b/kitty/keys.c index 24f4a714d..5de116ec6 100644 --- a/kitty/keys.c +++ b/kitty/keys.c @@ -9,7 +9,6 @@ #include "keys.h" #include "screen.h" #include "glfw-wrapper.h" -#include "control-codes.h" #include // python KeyEvent object {{{ @@ -200,17 +199,6 @@ on_key_input(GLFWkeyevent *ev) { else { consumed = ret == Py_True; Py_CLEAR(ret); } \ w = window_for_window_id(active_window_id); \ } - if (global_state.in_sequence_mode) { - debug("in sequence mode, handling as a potential shortcut\n"); - dispatch_key_event(process_sequence); - if (dispatch_ok) { - if (consumed && action != GLFW_RELEASE && w && !is_modifier_key(key)) { - w->last_special_key_pressed = key; - } - } - return; - } - if (action == GLFW_PRESS || action == GLFW_REPEAT) { w->last_special_key_pressed = 0; dispatch_key_event(dispatch_possible_special_key); diff --git a/kitty/keys.py b/kitty/keys.py index c2f924d01..eba7c1b2f 100644 --- a/kitty/keys.py +++ b/kitty/keys.py @@ -1,10 +1,10 @@ #!/usr/bin/env python # License: GPL v3 Copyright: 2016, Kovid Goyal -from typing import List, Optional, Union, overload +from typing import List, Optional from .fast_data_types import GLFW_MOD_ALT, GLFW_MOD_CONTROL, GLFW_MOD_HYPER, GLFW_MOD_META, GLFW_MOD_SHIFT, GLFW_MOD_SUPER, KeyEvent, SingleKey -from .options.utils import KeyDefinition, KeyMap, SequenceMap, SubSequenceMap +from .options.utils import KeyDefinition, KeyMap from .typing import ScreenType mod_mask = GLFW_MOD_ALT | GLFW_MOD_CONTROL | GLFW_MOD_SHIFT | GLFW_MOD_SUPER | GLFW_MOD_META | GLFW_MOD_HYPER @@ -17,12 +17,7 @@ def keyboard_mode_name(screen: ScreenType) -> str: return 'application' if screen.cursor_key_mode else 'normal' -@overload -def get_shortcut(keymap: KeyMap, ev: KeyEvent) -> Optional[List[KeyDefinition]]: ... -@overload -def get_shortcut(keymap: SequenceMap, ev: KeyEvent) -> Optional[SubSequenceMap]: ... - -def get_shortcut(keymap: Union[KeyMap, SequenceMap], ev: KeyEvent) -> Union[List[KeyDefinition], SubSequenceMap, None]: +def get_shortcut(keymap: KeyMap, ev: KeyEvent) -> Optional[List[KeyDefinition]]: 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: diff --git a/kitty/main.py b/kitty/main.py index df2eb324e..36951a1c6 100644 --- a/kitty/main.py +++ b/kitty/main.py @@ -220,7 +220,7 @@ def set_cocoa_global_shortcuts(opts: Options) -> Dict[str, SingleKey]: func_map = defaultdict(list) for k, v in opts.keymap.items(): for kd in v: - if not kd.options.when_focus_on: + if not kd.options.when_focus_on and not kd.options.mode and not kd.options.new_mode: parts = tuple(kd.definition.split()) func_map[parts].append(k) diff --git a/kitty/options/definition.py b/kitty/options/definition.py index 151d26f6f..20fb6f7f6 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -10,7 +10,7 @@ from kitty.constants import website_url definition = Definition( 'kitty', - Action('map', 'parse_map', {'keymap': 'KeyMap', 'sequence_map': 'SequenceMap', 'alias_map': 'AliasMap'}, + Action('map', 'parse_map', {'keyboard_modes': 'KeyboardModeMap', 'alias_map': 'AliasMap'}, ['KeyDefinition', 'kitty.fast_data_types.SingleKey']), Action('mouse_map', 'parse_mouse_map', {'mousemap': 'MouseMap'}, ['MouseMapping']), has_color_table=True, diff --git a/kitty/options/types.py b/kitty/options/types.py index cf8e388b3..1f48fed4a 100644 --- a/kitty/options/types.py +++ b/kitty/options/types.py @@ -9,7 +9,7 @@ from kitty.fast_data_types import Color, SingleKey import kitty.fast_data_types import kitty.fonts from kitty.options.utils import ( - AliasMap, KeyDefinition, KeyMap, MouseMap, MouseMapping, NotifyOnCmdFinish, SequenceMap, + AliasMap, KeyDefinition, KeyboardModeMap, MouseMap, MouseMapping, NotifyOnCmdFinish, TabBarMarginHeight ) import kitty.options.utils @@ -628,8 +628,7 @@ class Options: symbol_map: typing.Dict[typing.Tuple[int, int], str] = {} watcher: typing.Dict[str, str] = {} map: typing.List[kitty.options.utils.KeyDefinition] = [] - keymap: KeyMap = {} - sequence_map: SequenceMap = {} + keyboard_modes: KeyboardModeMap = {} alias_map: AliasMap = AliasMap() mouse_map: typing.List[kitty.options.utils.MouseMapping] = [] mousemap: MouseMap = {} diff --git a/kitty/options/utils.py b/kitty/options/utils.py index 03ea1d55a..ab7199a28 100644 --- a/kitty/options/utils.py +++ b/kitty/options/utils.py @@ -5,6 +5,7 @@ import enum import re import sys +from collections import defaultdict from dataclasses import dataclass, fields from functools import lru_cache from typing import Any, Callable, Container, Dict, FrozenSet, Iterable, Iterator, List, NamedTuple, Optional, Sequence, Tuple, Union @@ -35,8 +36,6 @@ from kitty.utils import expandvars, log_error, resolve_abs_or_config_path KeyMap = Dict[SingleKey, List['KeyDefinition']] MouseMap = Dict[MouseEvent, str] KeySequence = Tuple[SingleKey, ...] -SubSequenceMap = Dict[KeySequence, List['KeyDefinition']] -SequenceMap = Dict[SingleKey, SubSequenceMap] MINIMUM_FONT_SIZE = 4 default_tab_separator = ' ┇' mod_map = {'⌃': 'CONTROL', 'CTRL': 'CONTROL', '⇧': 'SHIFT', '⌥': 'ALT', 'OPTION': 'ALT', 'OPT': 'ALT', @@ -142,7 +141,7 @@ def detach_tab_parse(func: str, rest: str) -> FuncArgsType: return func, (rest,) -@func_with_args('set_background_opacity', 'goto_layout', 'toggle_layout', 'kitty_shell', 'show_kitty_doc', 'set_tab_title') +@func_with_args('set_background_opacity', 'goto_layout', 'toggle_layout', 'kitty_shell', 'show_kitty_doc', 'set_tab_title', 'push_keyboard_mode') def simple_parse(func: str, rest: str) -> FuncArgsType: return func, [rest] @@ -1157,6 +1156,7 @@ class KeyMapOptions: new_mode: str = '' mode: str = '' passthrough_unknown: BoolField = BoolField(False) + end_on_action: BoolField = BoolField(False) default_key_map_options = KeyMapOptions() @@ -1190,6 +1190,20 @@ class KeyDefinition(BaseDefinition): return ans +class KeyboardMode: + + passthrough_unknown: bool = False + end_on_action: bool = False + sequence_left: Optional[Sequence[SingleKey]] = None + + def __init__(self, name: str = '') -> None: + self.name = name + self.keymap: KeyMap = defaultdict(list) + + +KeyboardModeMap = Dict[str, KeyboardMode] + + def parse_options_for_map(val: str) -> Tuple[KeyMapOptions, List[str]]: expecting_arg = '' ans = KeyMapOptions() diff --git a/kitty/state.c b/kitty/state.c index 425f69a22..bb1b47023 100644 --- a/kitty/state.c +++ b/kitty/state.c @@ -746,9 +746,8 @@ PYWRAP1(set_options) { Py_RETURN_NONE; } -PYWRAP1(set_in_sequence_mode) { - global_state.in_sequence_mode = PyObject_IsTrue(args); - set_ignore_os_keyboard_processing(global_state.in_sequence_mode); +PYWRAP1(set_ignore_os_keyboard_processing) { + set_ignore_os_keyboard_processing(PyObject_IsTrue(args)); Py_RETURN_NONE; } @@ -1343,7 +1342,7 @@ static PyMethodDef module_methods[] = { MW(redirect_mouse_handling, METH_O), MW(mouse_selection, METH_VARARGS), MW(set_window_logo, METH_VARARGS), - MW(set_in_sequence_mode, METH_O), + MW(set_ignore_os_keyboard_processing, METH_O), MW(handle_for_window_id, METH_VARARGS), MW(update_ime_position_for_window, METH_VARARGS), MW(pt_to_px, METH_VARARGS), diff --git a/kitty/state.h b/kitty/state.h index 25dac80de..a66630763 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -265,7 +265,6 @@ typedef struct { bool has_render_frames; bool debug_rendering, debug_font_fallback; bool has_pending_resizes, has_pending_closes; - bool in_sequence_mode; bool check_for_active_animated_images; struct { double x, y; } default_dpi; id_type active_drag_in_window, tracked_drag_in_window;