From 530fd611259ebac49676e442a69f04b9a49dfdd9 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Sat, 31 Mar 2018 11:31:48 +0530 Subject: [PATCH] Add support for multi-key shortcuts Fixes #338 --- kitty/boss.py | 40 ++++++++++++++++++++++++---- kitty/config.py | 68 +++++++++++++++++++++++++++++++++++++++--------- kitty/keys.c | 4 +++ kitty/keys.py | 4 +++ kitty/kitty.conf | 6 +++++ kitty/state.c | 6 +++++ kitty/state.h | 1 + 7 files changed, 111 insertions(+), 18 deletions(-) diff --git a/kitty/boss.py b/kitty/boss.py index 47f97e6c6..8880143c6 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -19,11 +19,11 @@ from .fast_data_types import ( ChildMonitor, create_os_window, current_os_window, destroy_global_data, destroy_sprite_map, get_clipboard_string, glfw_post_empty_event, layout_sprite_map, mark_os_window_for_close, set_clipboard_string, - set_dpi_from_os_window, show_window, toggle_fullscreen, - viewport_for_window + set_dpi_from_os_window, set_in_sequence_mode, show_window, + toggle_fullscreen, viewport_for_window ) from .fonts.render import prerender, resize_fonts, set_font_family -from .keys import get_shortcut +from .keys import get_shortcut, shortcut_matches from .remote_control import handle_cmd from .session import create_session from .tabs import SpecialWindow, SpecialWindowInstance, TabManager @@ -79,6 +79,7 @@ class Boss: def __init__(self, os_window_id, opts, args, cached_values): self.window_id_map = WeakValueDictionary() + self.pending_sequences = None self.cached_values = cached_values self.os_window_map = {} self.cursor_blinking = True @@ -352,8 +353,37 @@ class Boss: def dispatch_special_key(self, key, scancode, action, mods): # Handles shortcuts, return True if the key was consumed key_action = get_shortcut(self.opts.keymap, mods, key, scancode) - self.current_key_press_info = key, scancode, action, mods - return self.dispatch_action(key_action) + if key_action is None: + sequences = get_shortcut(self.opts.sequence_map, mods, key, scancode) + if sequences: + self.pending_sequences = sequences + set_in_sequence_mode(True) + return True + else: + self.current_key_press_info = key, scancode, action, mods + return self.dispatch_action(key_action) + + def process_sequence(self, key, scancode, action, mods): + if not self.pending_sequences: + set_in_sequence_mode(False) + + remaining = {} + matched_action = None + for seq, key_action in self.pending_sequences.items(): + if shortcut_matches(seq[0], mods, key, scancode): + seq = seq[1:] + if seq: + remaining[seq] = key_action + else: + matched_action = key_action + + if remaining: + self.pending_sequences = remaining + else: + self.pending_sequences = None + set_in_sequence_mode(False) + if matched_action is not None: + self.dispatch_action(matched_action) def default_bg_changed_for(self, window_id): w = self.window_id_map.get(window_id) diff --git a/kitty/config.py b/kitty/config.py index 21a7c4966..bac077733 100644 --- a/kitty/config.py +++ b/kitty/config.py @@ -120,16 +120,36 @@ def parse_key_action(action): return KeyAction(func, args) -def parse_key(val, keymap): +all_key_actions = set() +sequence_sep = '>' + + +def parse_key(val, keymap, sequence_map): sc, action = val.partition(' ')[::2] - sc, action = sc.strip(), action.strip() + sc, action = sc.strip().strip(sequence_sep), action.strip() if not sc or not action: return - mods, key = parse_shortcut(sc) - if key is None: - log_error('Shortcut: {} has unknown key, ignoring'.format( - val)) - return + is_sequence = sequence_sep in sc + if is_sequence: + trigger = None + rest = [] + for part in sc.split(sequence_sep): + mods, key = parse_shortcut(part) + if key is None: + log_error('Shortcut: {} has unknown key, ignoring'.format( + sc)) + return + if trigger is None: + trigger = mods, key + else: + rest.append((mods, key)) + rest = tuple(rest) + else: + mods, key = parse_shortcut(sc) + if key is None: + log_error('Shortcut: {} has unknown key, ignoring'.format( + sc)) + return try: paction = parse_key_action(action) except Exception: @@ -137,7 +157,12 @@ def parse_key(val, keymap): action)) else: if paction is not None: - keymap[(mods, key)] = paction + all_key_actions.add(paction.func) + if is_sequence: + s = sequence_map.setdefault(trigger, {}) + s[rest] = paction + else: + keymap[(mods, key)] = paction def parse_symbol_map(val): @@ -177,7 +202,7 @@ def parse_send_text_bytes(text): ).encode('utf-8') -def parse_send_text(val, keymap): +def parse_send_text(val, keymap, sequence_map): parts = val.split(' ') def abort(msg): @@ -190,7 +215,7 @@ def parse_send_text(val, keymap): mode, sc = parts[:2] text = ' '.join(parts[2:]) key_str = '{} send_text {} {}'.format(sc, mode, text) - return parse_key(key_str, keymap) + return parse_key(key_str, keymap, sequence_map) def to_modifiers(val): @@ -317,14 +342,14 @@ for a in ('active', 'inactive'): def special_handling(key, val, ans): if key == 'map': - parse_key(val, ans['keymap']) + parse_key(val, ans['keymap'], ans['sequence_map']) return True if key == 'symbol_map': ans['symbol_map'].update(parse_symbol_map(val)) return True if key == 'send_text': # For legacy compatibility - parse_send_text(val, ans['keymap']) + parse_send_text(val, ans['keymap'], ans['sequence_map']) return True @@ -337,6 +362,7 @@ default_config_path = os.path.join( def parse_config(lines, check_keys=True): ans = { 'keymap': {}, + 'sequence_map': {}, 'symbol_map': {}, } parse_config_base( @@ -351,7 +377,7 @@ def parse_config(lines, check_keys=True): Options, defaults = init_config(default_config_path, parse_config) -actions = frozenset(a.func for a in defaults.keymap.values()) | frozenset( +actions = frozenset(all_key_actions) | frozenset( 'combine send_text goto_tab goto_layout set_font_size new_tab_with_cwd new_window_with_cwd new_os_window_with_cwd'. split() ) @@ -370,6 +396,20 @@ def merge_keymaps(defaults, newvals): return ans +def merge_sequence_maps(defaults, newvals): + ans = {t: r.copy() for t, r in defaults.items()} + for trigger, rest_map in newvals.items(): + s = ans.setdefault(trigger, {}) + for k, v in rest_map.items(): + f = v.func + if f in no_op_actions: + s.pop(k, None) + continue + if f in actions: + s[k] = v + return {k: v for k, v in ans.items() if v} + + def merge_dicts(defaults, newvals): ans = defaults.copy() ans.update(newvals) @@ -383,6 +423,8 @@ def merge_configs(defaults, vals): newvals = vals.get(k, {}) if k == 'keymap': ans['keymap'] = merge_keymaps(v, newvals) + elif k == 'sequence_map': + ans['sequence_map'] = merge_sequence_maps(v, newvals) else: ans[k] = merge_dicts(v, newvals) else: diff --git a/kitty/keys.c b/kitty/keys.c index 7d73004f8..013adf820 100644 --- a/kitty/keys.c +++ b/kitty/keys.c @@ -101,6 +101,10 @@ void on_key_input(int key, int scancode, int action, int mods, const char* text, int state UNUSED) { Window *w = active_window(); if (!w) return; + if (global_state.in_sequence_mode) { + if (action != GLFW_RELEASE) call_boss(process_sequence, "iiii", key, scancode, action, mods); + return; + } Screen *screen = w->render_data.screen; bool has_text = text && !is_ascii_control_char(text[0]); #ifdef __APPLE__ diff --git a/kitty/keys.py b/kitty/keys.py index b99ae3a84..6f7240ec0 100644 --- a/kitty/keys.py +++ b/kitty/keys.py @@ -251,6 +251,10 @@ def get_shortcut(keymap, mods, key, scancode): return keymap.get((mods & 0b1111, key)) +def shortcut_matches(s, mods, key, scancode): + return s[0] & 0b1111 == mods & 0b1111 and s[1] == key + + def generate_key_table(): # To run this, use: python3 . -c "from kitty.keys import *; generate_key_table()" import os diff --git a/kitty/kitty.conf b/kitty/kitty.conf index d94d8feda..072465c43 100644 --- a/kitty/kitty.conf +++ b/kitty/kitty.conf @@ -323,6 +323,12 @@ term xterm-kitty # For example: # map ctrl+shift+e combine : new_window : next_layout # this will create a new window and switch to the next available layout +# +# You can use multi-key shortcuts using the syntax shown below: +# map key1>key2>key3 action +# For example: +# map ctrl+f>2 set_font_size 20 +# this will change the font size to 20 points when you press ctrl+f and then 3 # Clipboard {{{ map ctrl+shift+v paste_from_clipboard diff --git a/kitty/state.c b/kitty/state.c index 198750967..d43d848c2 100644 --- a/kitty/state.c +++ b/kitty/state.c @@ -284,6 +284,7 @@ os_window_regions(OSWindow *os_window, Region *central, Region *tab_bar) { #define KKI(name) PYWRAP1(name) { id_type a, b; unsigned int c; PA("KKI", &a, &b, &c); name(a, b, c); Py_RETURN_NONE; } #define KKII(name) PYWRAP1(name) { id_type a, b; unsigned int c, d; PA("KKII", &a, &b, &c, &d); name(a, b, c, d); Py_RETURN_NONE; } #define KK5I(name) PYWRAP1(name) { id_type a, b; unsigned int c, d, e, f, g; PA("KKIIIII", &a, &b, &c, &d, &e, &f, &g); name(a, b, c, d, e, f, g); Py_RETURN_NONE; } +#define BOOL_SET(name) PYWRAP1(set_##name) { global_state.name = PyObject_IsTrue(args); Py_RETURN_NONE; } static inline color_type color_as_int(PyObject *color) { @@ -367,6 +368,8 @@ PYWRAP1(set_options) { GA(keymap); set_special_keys(ret); Py_DECREF(ret); if (PyErr_Occurred()) return NULL; + GA(sequence_map); set_special_keys(ret); + Py_DECREF(ret); if (PyErr_Occurred()) return NULL; #define read_adjust(name) { \ PyObject *al = PyObject_GetAttrString(opts, #name); \ @@ -386,6 +389,8 @@ PYWRAP1(set_options) { Py_RETURN_NONE; } +BOOL_SET(in_sequence_mode) + PYWRAP1(set_tab_bar_render_data) { ScreenRenderData d = {0}; id_type os_window_id; @@ -555,6 +560,7 @@ KK5I(add_borders_rect) static PyMethodDef module_methods[] = { MW(current_os_window, METH_NOARGS), MW(set_options, METH_VARARGS), + MW(set_in_sequence_mode, METH_O), MW(handle_for_window_id, METH_VARARGS), MW(set_logical_dpi, METH_VARARGS), MW(pt_to_px, METH_O), diff --git a/kitty/state.h b/kitty/state.h index 41d6e6494..6ac0d42a9 100644 --- a/kitty/state.h +++ b/kitty/state.h @@ -132,6 +132,7 @@ typedef struct { bool is_wayland; bool debug_gl, debug_font_fallback; bool has_pending_resizes; + bool in_sequence_mode; } GlobalState; extern GlobalState global_state;