Implement modal keyboard handling

This commit is contained in:
Kovid Goyal 2023-11-30 19:44:41 +05:30
parent cb418a0040
commit 9e815212dc
No known key found for this signature in database
GPG key ID: 06BC317B515ACE7C
11 changed files with 100 additions and 114 deletions

View file

@ -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]:

View file

@ -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:

View file

@ -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

View file

@ -9,7 +9,6 @@
#include "keys.h"
#include "screen.h"
#include "glfw-wrapper.h"
#include "control-codes.h"
#include <structmember.h>
// 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);

View file

@ -1,10 +1,10 @@
#!/usr/bin/env python
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>
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:

View file

@ -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)

View file

@ -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,

View file

@ -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 = {}

View file

@ -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()

View file

@ -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),

View file

@ -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;