Skip to content

Commit

Permalink
Allow creating key mappings that depend on the state of the focused w…
Browse files Browse the repository at this point in the history
…indow, such as what program is running inside it
  • Loading branch information
kovidgoyal committed Nov 30, 2023
1 parent 8a1571f commit f11f770
Show file tree
Hide file tree
Showing 7 changed files with 118 additions and 35 deletions.
53 changes: 36 additions & 17 deletions kitty/boss.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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():
Expand Down Expand Up @@ -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:
Expand All @@ -1378,28 +1380,45 @@ 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:
w.write_to_child(b''.join(w.encoded_key(ev) for ev in self.current_sequence))
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()
Expand Down Expand Up @@ -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)
Expand Down
9 changes: 5 additions & 4 deletions kitty/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -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

Expand Down
11 changes: 8 additions & 3 deletions kitty/keys.py
Original file line number Diff line number Diff line change
@@ -1,10 +1,10 @@
#!/usr/bin/env python
# License: GPL v3 Copyright: 2016, Kovid Goyal <kovid at kovidgoyal.net>

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
Expand All @@ -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:
Expand Down
6 changes: 4 additions & 2 deletions kitty/main.py
Original file line number Diff line number Diff line change
Expand Up @@ -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',
Expand Down
27 changes: 27 additions & 0 deletions kitty/options/definition.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 </actions>`.
''')
Expand Down
43 changes: 36 additions & 7 deletions kitty/options/utils.py
Original file line number Diff line number Diff line change
Expand Up @@ -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 = ' ┇'
Expand Down Expand Up @@ -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()
Expand Down Expand Up @@ -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]:
Expand Down
4 changes: 2 additions & 2 deletions kitty/window.py
Original file line number Diff line number Diff line change
Expand Up @@ -56,6 +56,7 @@
Screen,
add_timer,
add_window,
base64_decode,
cell_size_for_window,
click_mouse_cmd_output,
click_mouse_url,
Expand Down Expand Up @@ -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:
Expand Down

0 comments on commit f11f770

Please sign in to comment.