From f11f77001103cdf7364d91438c07a2c2c9a306e5 Mon Sep 17 00:00:00 2001 From: Kovid Goyal Date: Thu, 30 Nov 2023 11:35:30 +0530 Subject: [PATCH] Allow creating key mappings that depend on the state of the focused window, such as what program is running inside it --- kitty/boss.py | 53 +++++++++++++++++++++++++------------ kitty/config.py | 9 ++++--- kitty/keys.py | 11 +++++--- kitty/main.py | 6 +++-- kitty/options/definition.py | 27 +++++++++++++++++++ kitty/options/utils.py | 43 +++++++++++++++++++++++++----- kitty/window.py | 4 +-- 7 files changed, 118 insertions(+), 35 deletions(-) diff --git a/kitty/boss.py b/kitty/boss.py index c9aa5c7acb8..039f9a94330 100644 --- a/kitty/boss.py +++ b/kitty/boss.py @@ -121,7 +121,7 @@ from .layout.base import set_layout_options from .notify import notification_activated from .options.types import Options -from .options.utils import MINIMUM_FONT_SIZE, KeyMap, SubSequenceMap +from .options.utils import MINIMUM_FONT_SIZE, KeyDefinition, KeyMap, SubSequenceMap 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 @@ -390,7 +390,7 @@ def update_keymap(self, global_shortcuts:Optional[Dict[str, SingleKey]] = None) global_shortcuts = set_cocoa_global_shortcuts(get_options()) else: global_shortcuts = {} - self.global_shortcuts_map: KeyMap = {v: k for k, v in global_shortcuts.items()} + 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() for sc in self.global_shortcuts.values(): @@ -1347,14 +1347,16 @@ def dispatch_possible_special_key(self, ev: KeyEvent) -> bool: key_action = get_shortcut(self.keymap, ev) if key_action is None: sequences = get_shortcut(get_options().sequence_map, ev) - if sequences and not isinstance(sequences, str): + 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 and isinstance(key_action, str): - return self.combine(key_action) + elif key_action: + 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: @@ -1378,21 +1380,23 @@ def process_sequence(self, ev: KeyEvent) -> bool: # kitty bindings: remaining = {} matched_action = None - for seq, key_action in self.pending_sequences.items(): + for seq, key_actions in self.pending_sequences.items(): if shortcut_matches(seq[0], ev): - seq = seq[1:] - if seq: - remaining[seq] = key_action - else: - matched_action = key_action + key_action = self.matching_key_action(key_actions) + if key_action is not None: + seq = seq[1:] + if seq: + remaining[seq] = [key_action] + else: + matched_action = key_action if remaining: self.pending_sequences = remaining return True - matched_action = matched_action or self.default_pending_action - if matched_action: + final_action = self.default_pending_action if matched_action is None else matched_action.definition + if final_action: self.clear_pending_sequences() - self.combine(matched_action) + self.combine(final_action) return True w = self.active_window if w is not None: @@ -1400,6 +1404,21 @@ def process_sequence(self, ev: KeyEvent) -> bool: self.clear_pending_sequences() return False + def matching_key_action(self, candidates: Iterable[KeyDefinition]) -> Optional[KeyDefinition]: + w = self.active_window + ans = None + for x in candidates: + if x.when_focus_on: + try: + if w and w in self.match_windows(x.when_focus_on): + ans = x + except Exception: + import traceback + traceback.print_exc() + else: + ans = x + return ans + def cancel_current_visual_select(self) -> None: if self.current_visual_select: self.current_visual_select.cancel() @@ -1434,16 +1453,16 @@ def visual_window_select_action( for idx, window in tab.windows.iter_windows_with_number(only_visible=True): if only_window_ids and window.id not in only_window_ids: continue - ac = f'visual_window_select_action_trigger {window.id}' + ac = KeyDefinition(definition=f'visual_window_select_action_trigger {window.id}') if idx >= len(alphanumerics): break ch = alphanumerics[idx] window.screen.set_window_char(ch) self.current_visual_select.window_ids.append(window.id) for mods in (0, GLFW_MOD_CONTROL, GLFW_MOD_CONTROL | GLFW_MOD_SHIFT, GLFW_MOD_SUPER, GLFW_MOD_ALT, GLFW_MOD_SHIFT): - pending_sequences[(SingleKey(mods=mods, key=ord(ch.lower())),)] = ac + pending_sequences[(SingleKey(mods=mods, key=ord(ch.lower())),)] = [ac] if ch in string.digits: - pending_sequences[(SingleKey(mods=mods, key=fmap[f'KP_{ch}']),)] = ac + pending_sequences[(SingleKey(mods=mods, key=fmap[f'KP_{ch}']),)] = [ac] if len(self.current_visual_select.window_ids) > 1: self.set_pending_sequences(pending_sequences, default_pending_action='visual_window_select_action_trigger 0') redirect_mouse_handling(True) diff --git a/kitty/config.py b/kitty/config.py index c7dea8bed02..c43da60e0b2 100644 --- a/kitty/config.py +++ b/kitty/config.py @@ -3,6 +3,7 @@ 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 @@ -100,17 +101,17 @@ 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 = {} + keymap: KeyMap = defaultdict(list) sequence_map: SequenceMap = {} for defn in defns: if defn.is_sequence: keymap.pop(defn.trigger, None) - s = sequence_map.setdefault(defn.trigger, {}) - s[defn.rest] = defn.definition + s = sequence_map.setdefault(defn.trigger, defaultdict(list)) + s[defn.rest].append(defn) else: sequence_map.pop(defn.trigger, None) - keymap[defn.trigger] = defn.definition + keymap[defn.trigger].append(defn) opts.keymap = keymap opts.sequence_map = sequence_map diff --git a/kitty/keys.py b/kitty/keys.py index e044c136e8e..554f2178f20 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 Union +from typing import List, Optional, Union, overload 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 KeyMap, SequenceMap, SubSequenceMap +from .options.utils import KeyDefinition, KeyMap, SequenceMap, SubSequenceMap 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,7 +17,12 @@ def keyboard_mode_name(screen: ScreenType) -> str: return 'application' if screen.cursor_key_mode else 'normal' -def get_shortcut(keymap: Union[KeyMap, SequenceMap], ev: KeyEvent) -> Union[str, SubSequenceMap, None]: +@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]: 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 b8f2f60d2e4..115a0ec4435 100644 --- a/kitty/main.py +++ b/kitty/main.py @@ -219,8 +219,10 @@ def set_cocoa_global_shortcuts(opts: Options) -> Dict[str, SingleKey]: from collections import defaultdict func_map = defaultdict(list) for k, v in opts.keymap.items(): - parts = tuple(v.split()) - func_map[parts].append(k) + for kd in v: + if not kd.when_focus_on: + parts = tuple(kd.definition.split()) + func_map[parts].append(k) for ac in ('new_os_window', 'close_os_window', 'close_tab', 'edit_config_file', 'previous_tab', 'next_tab', 'new_tab', 'new_window', 'close_window', 'toggle_macos_secure_keyboard_entry', 'toggle_fullscreen', diff --git a/kitty/options/definition.py b/kitty/options/definition.py index f02292d4e9a..151d26f6f46 100644 --- a/kitty/options/definition.py +++ b/kitty/options/definition.py @@ -3447,6 +3447,33 @@ map ctrl+f>2 set_font_size 20 +You can create mappings that apply when the focused window matches some condition, +such as having a particular program running. For example:: + + map --when-focus-on title:keyboard.protocol kitty_mod+t + +This will cause :kbd:`kitty_mod+t` (the default shortcut for opening a new tab) +to be unmapped only when the focused window +has :code:`keyboard protocol` in its title. Run the show-key kitten as:: + + kitten show-key -m kitty + +and press :kbd:`ctrl+shift+t` and instead of a new tab opening, you will +see the key press being reported by the kitten. :code:`--when-focus-on` can test +the focused window using very powerful criteria, see :ref:`search_syntax` for +details. Note that spaces are not allowed in the argument of --when-focus-on. +Use the . character or :code:`\\\\s` to match spaces. +A more practical example unmaps the key when the focused window is running vim:: + + map --when-focus-on var:in_editor + +In order to make this work, you need the following lines in your :file:`.vimrc`:: + + let &t_ti = &t_ti . "\\033]1337;SetUserVar=in_editor=MQo\\007" + let &t_te = &t_te . "\\033]1337;SetUserVar=in_editor\\007" + +These cause vim to set the :code:`in_editor` variable in kitty and unset it when leaving vim. + The full list of actions that can be mapped to key presses is available :doc:`here `. ''') diff --git a/kitty/options/utils.py b/kitty/options/utils.py index b3f52a4f7de..e659480e970 100644 --- a/kitty/options/utils.py +++ b/kitty/options/utils.py @@ -31,10 +31,10 @@ from kitty.types import FloatEdges, MouseEvent from kitty.utils import expandvars, log_error, resolve_abs_or_config_path -KeyMap = Dict[SingleKey, str] +KeyMap = Dict[SingleKey, List['KeyDefinition']] MouseMap = Dict[MouseEvent, str] KeySequence = Tuple[SingleKey, ...] -SubSequenceMap = Dict[KeySequence, str] +SubSequenceMap = Dict[KeySequence, List['KeyDefinition']] SequenceMap = Dict[SingleKey, SubSequenceMap] MINIMUM_FONT_SIZE = 4 default_tab_separator = ' ┇' @@ -1138,31 +1138,60 @@ class KeyDefinition(BaseDefinition): def __init__( self, is_sequence: bool = False, trigger: SingleKey = SingleKey(), - rest: Tuple[SingleKey, ...] = (), definition: str = '' + rest: Tuple[SingleKey, ...] = (), definition: str = '', + when_focus_on: str = '', ): super().__init__(definition) self.is_sequence = is_sequence self.trigger = trigger self.rest = rest + self.when_focus_on = when_focus_on def __repr__(self) -> str: - return self.pretty_repr('is_sequence', 'trigger', 'rest') + return self.pretty_repr('is_sequence', 'trigger', 'rest', 'when_focus_on') def resolve_and_copy(self, kitty_mod: int) -> 'KeyDefinition': def r(k: SingleKey) -> SingleKey: return k.resolve_kitty_mod(kitty_mod) ans = KeyDefinition( self.is_sequence, r(self.trigger), tuple(map(r, self.rest)), - self.definition + self.definition, self.when_focus_on ) ans.definition_location = self.definition_location return ans +def parse_options_for_map(val: str) -> Tuple[Dict[str, str], List[str]]: + expecting_arg = '' + ans = {} + parts = val.split() + for i, x in enumerate(parts): + if expecting_arg: + ans[expecting_arg] = x + expecting_arg = '' + elif x.startswith('--'): + expecting_arg = x[2:] + k, sep, v = expecting_arg.partition('=') + if sep == '=': + ans[k] = v + expecting_arg = '' + else: + return ans, parts[i:] + return ans, [] + + def parse_map(val: str) -> Iterable[KeyDefinition]: parts = val.split(maxsplit=1) + options: Dict[str, str] = {} if len(parts) == 2: sc, action = parts + if sc.startswith('--'): + options, parts = parse_options_for_map(val) + if len(parts) == 1: + sc, action = parts[0], '' + else: + sc = parts[0] + action = ' '.join(parts[1:]) else: sc, action = val, '' sc, action = sc.strip().strip(sequence_sep), action.strip() @@ -1197,10 +1226,10 @@ def parse_map(val: str) -> Iterable[KeyDefinition]: return if is_sequence: if trigger is not None: - yield KeyDefinition(True, trigger, rest, definition=action) + yield KeyDefinition(True, trigger, rest, definition=action, when_focus_on=options.get('when-focus-on', '')) else: assert key is not None - yield KeyDefinition(False, SingleKey(mods, is_native, key), definition=action) + yield KeyDefinition(False, SingleKey(mods, is_native, key), definition=action, when_focus_on=options.get('when-focus-on', '')) def parse_mouse_map(val: str) -> Iterable[MouseMapping]: diff --git a/kitty/window.py b/kitty/window.py index 7618bf191f2..418dc4836d7 100644 --- a/kitty/window.py +++ b/kitty/window.py @@ -56,6 +56,7 @@ Screen, add_timer, add_window, + base64_decode, cell_size_for_window, click_mouse_cmd_output, click_mouse_url, @@ -947,9 +948,8 @@ def osc_1337(self, raw_data: str) -> None: for record in raw_data.split(';'): key, _, val = record.partition('=') if key == 'SetUserVar': - from base64 import standard_b64decode ukey, has_equal, uval = val.partition('=') - self.set_user_var(ukey, (standard_b64decode(uval) if uval else b'') if has_equal == '=' else None) + self.set_user_var(ukey, (base64_decode(uval) if uval else b'') if has_equal == '=' else None) def desktop_notify(self, osc_code: int, raw_data: str) -> None: if osc_code == 1337: