diff --git a/kitty/cli.py b/kitty/cli.py index 5229c7a92..903b44fec 100644 --- a/kitty/cli.py +++ b/kitty/cli.py @@ -7,16 +7,16 @@ import re import sys from collections import deque from typing import ( - Any, Callable, Dict, FrozenSet, Iterable, Iterator, List, Match, Optional, - Sequence, Set, Tuple, Type, TypeVar, Union, cast + Any, Callable, Dict, FrozenSet, Generator, Iterable, Iterator, List, Match, + Optional, Sequence, Set, Tuple, Type, TypeVar, Union, cast ) from .cli_stub import CLIOptions from .conf.utils import resolve_config -from .config import KeyAction +from .config import KeyAction, MouseMap from .constants import appname, defconf, is_macos, is_wayland, str_version from .options_stub import Options as OptionsStub -from .types import SingleKey +from .types import MouseEvent, SingleKey from .typing import BadLineType, SequenceMap, TypedDict @@ -759,21 +759,25 @@ SYSTEM_CONF = '/etc/xdg/kitty/kitty.conf' ShortcutMap = Dict[Tuple[SingleKey, ...], KeyAction] -def print_shortcut(key_sequence: Iterable[SingleKey], action: KeyAction) -> None: +def mod_to_names(mods: int) -> Generator[str, None, None]: from .fast_data_types import ( GLFW_MOD_ALT, GLFW_MOD_CAPS_LOCK, GLFW_MOD_CONTROL, GLFW_MOD_HYPER, - GLFW_MOD_META, GLFW_MOD_NUM_LOCK, GLFW_MOD_SHIFT, GLFW_MOD_SUPER, - glfw_get_key_name + GLFW_MOD_META, GLFW_MOD_NUM_LOCK, GLFW_MOD_SHIFT, GLFW_MOD_SUPER ) modmap = {'shift': GLFW_MOD_SHIFT, 'alt': GLFW_MOD_ALT, 'ctrl': GLFW_MOD_CONTROL, ('cmd' if is_macos else 'super'): GLFW_MOD_SUPER, 'hyper': GLFW_MOD_HYPER, 'meta': GLFW_MOD_META, 'num_lock': GLFW_MOD_NUM_LOCK, 'caps_lock': GLFW_MOD_CAPS_LOCK} + for name, val in modmap.items(): + if mods & val: + yield name + + +def print_shortcut(key_sequence: Iterable[SingleKey], action: KeyAction) -> None: + from .fast_data_types import glfw_get_key_name keys = [] for key_spec in key_sequence: names = [] mods, is_native, key = key_spec - for name, val in modmap.items(): - if mods & val: - names.append(name) + names = list(mod_to_names(mods)) if key: kname = glfw_get_key_name(0, key) if is_native else glfw_get_key_name(key, 0) names.append(kname or f'{key}') @@ -807,19 +811,43 @@ def flatten_sequence_map(m: SequenceMap) -> ShortcutMap: return ans +def compare_mousemaps(final: MouseMap, initial: MouseMap) -> None: + added = set(final) - set(initial) + removed = set(initial) - set(final) + changed = {k for k in set(final) & set(initial) if final[k] != initial[k]} + + def print_mouse_action(trigger: MouseEvent, action: KeyAction) -> None: + names = list(mod_to_names(trigger.mods)) + [f'b{trigger.button+1}'] + when = {-1: 'repeat', 1: 'press', 2: 'doublepress', 3: 'triplepress'}.get(trigger.repeat_count, trigger.repeat_count) + grabbed = 'grabbed' if trigger.grabbed else 'ungrabbed' + print('\t', '+'.join(names), when, grabbed, action) + + def print_changes(defns: MouseMap, changes: Set[MouseEvent], text: str) -> None: + if changes: + print(title(text)) + for k in sorted(changes): + print_mouse_action(k, defns[k]) + + print_changes(final, added, 'Added mouse actions:') + print_changes(initial, removed, 'Removed mouse actions:') + print_changes(final, changed, 'Changed mouse actions:') + + def compare_opts(opts: OptionsStub) -> None: from .config import defaults, load_config print('\nConfig options different from defaults:') default_opts = load_config() + ignored = ('key_definitions', 'keymap', 'sequence_map', 'mousemap', 'mouse_mappings') changed_opts = [ f for f in sorted(defaults._fields) # type: ignore - if f not in ('key_definitions', 'keymap', 'sequence_map') and getattr(opts, f) != getattr(defaults, f) + if f not in ignored and getattr(opts, f) != getattr(defaults, f) ] field_len = max(map(len, changed_opts)) if changed_opts else 20 fmt = '{{:{:d}s}}'.format(field_len) for f in changed_opts: print(title(fmt.format(f)), getattr(opts, f)) + compare_mousemaps(opts.mousemap, default_opts.mousemap) final_, initial_ = opts.keymap, default_opts.keymap final: ShortcutMap = {(k,): v for k, v in final_.items()} initial: ShortcutMap = {(k,): v for k, v in initial_.items()} diff --git a/kitty/config.py b/kitty/config.py index 92569a471..5bfc8ef31 100644 --- a/kitty/config.py +++ b/kitty/config.py @@ -19,15 +19,18 @@ from .conf.utils import ( BadLine, init_config, key_func, load_config as _load_config, merge_dicts, parse_config_base, python_string, to_bool, to_cmdline ) -from .config_data import InvalidMods, all_options, parse_shortcut, type_convert +from .config_data import ( + InvalidMods, all_options, parse_mods, parse_shortcut, type_convert +) from .constants import cache_dir, defconf, is_macos from .fonts import FontFeature from .options_stub import Options as OptionsStub -from .types import SingleKey +from .types import SingleKey, MouseEvent from .typing import TypedDict from .utils import expandvars, log_error KeyMap = Dict[SingleKey, 'KeyAction'] +MouseMap = Dict[MouseEvent, 'KeyAction'] KeySequence = Tuple[SingleKey, ...] SubSequenceMap = Dict[KeySequence, 'KeyAction'] SequenceMap = Dict[SingleKey, SubSequenceMap] @@ -344,7 +347,43 @@ all_key_actions: Set[str] = set() sequence_sep = '>' -class KeyDefinition: +class BaseDefinition: + action: KeyAction + + def resolve_kitten_aliases(self, aliases: Dict[str, Sequence[str]]) -> None: + if not self.action.args: + return + kitten = self.action.args[0] + rest = self.action.args[1] if len(self.action.args) > 1 else '' + changed = False + for key, expanded in aliases.items(): + if key == kitten: + changed = True + kitten = expanded[0] + if len(expanded) > 1: + rest = expanded[1] + ' ' + rest + if changed: + self.action = self.action._replace(args=[kitten, rest.rstrip()]) + + +class MouseMapping(BaseDefinition): + + def __init__(self, button: int, mods: int, repeat_count: int, grabbed: bool, action: KeyAction): + self.button = button + self.mods = mods + self.repeat_count = repeat_count + self.grabbed = grabbed + self.action = action + + def resolve(self, kitty_mod: int) -> None: + self.mods = defines.resolve_key_mods(kitty_mod, self.mods) + + @property + def trigger(self) -> MouseEvent: + return MouseEvent(self.button, self.mods, self.repeat_count, self.grabbed) + + +class KeyDefinition(BaseDefinition): def __init__(self, is_sequence: bool, action: KeyAction, mods: int, is_native: bool, key: int, rest: Tuple[SingleKey, ...] = ()): self.is_sequence = is_sequence @@ -363,21 +402,6 @@ class KeyDefinition: self.trigger = r(self.trigger) self.rest = tuple(map(r, self.rest)) - def resolve_kitten_aliases(self, aliases: Dict[str, Sequence[str]]) -> None: - if not self.action.args: - return - kitten = self.action.args[0] - rest = self.action.args[1] if len(self.action.args) > 1 else '' - changed = False - for key, expanded in aliases.items(): - if key == kitten: - changed = True - kitten = expanded[0] - if len(expanded) > 1: - rest = expanded[1] + ' ' + rest - if changed: - self.action = self.action._replace(args=[kitten, rest.rstrip()]) - def parse_key(val: str, key_definitions: List[KeyDefinition]) -> None: parts = val.split(maxsplit=1) @@ -430,8 +454,47 @@ def parse_key(val: str, key_definitions: List[KeyDefinition]) -> None: key_definitions.append(KeyDefinition(False, paction, mods, is_native, key)) -def parse_mouse_action(val: str, mouse_mappings: List['MouseMapping']) -> None: - pass +def parse_mouse_action(val: str, mouse_mappings: List[MouseMapping]) -> None: + parts = val.split(maxsplit=3) + if len(parts) != 4: + log_error(f'Ignoring invalid mouse action: {val}') + return + xbutton, event, modes, action = parts + kparts = xbutton.split('+') + if len(kparts) > 1: + mparts, obutton = parts[:-1], parts[-1].lower() + mods = parse_mods(mparts, obutton) + if mods is None: + return + else: + obutton = parts[0].lower() + mods = 0 + obutton = xbutton.lower() + try: + b = {'left': 'b1', 'middle': 'b3', 'right': 'b2'}.get(obutton, obutton)[1:] + button = getattr(defines, f'GLFW_MOUSE_BUTTON_{b}') + except Exception: + log_error(f'Mouse button: {xbutton} not recognized, ignoring') + return + try: + count = {'release': -1, 'press': 1, 'doublepress': 2, 'triplepress': 3}[event.lower()] + except KeyError: + log_error(f'Mouse event type: {event} not recognized, ignoring') + return + specified_modes = frozenset(modes.lower().split(',')) + if specified_modes - {'grabbed', 'ungrabbed'}: + log_error(f'Mouse modes: {modes} not recognized, ignoring') + return + try: + paction = parse_key_action(action) + except Exception: + log_error(f'Invalid mouse action: {action}. Ignoring.') + return + if paction is None: + log_error(f'Ignoring unknown mouse action: {action}') + return + for mode in specified_modes: + mouse_mappings.append(MouseMapping(button, mods, count, mode == 'grabbed', paction)) def parse_symbol_map(val: str) -> Dict[Tuple[int, int], str]: @@ -606,6 +669,7 @@ def parse_config(lines: Iterable[str], check_keys: bool = True, accumulate_bad_l ans: Dict[str, Any] = { 'symbol_map': {}, 'keymap': {}, 'sequence_map': {}, 'key_definitions': [], 'env': {}, 'kitten_aliases': {}, 'font_features': {}, 'mouse_mappings': [], + 'mousemap': {} } defs: Optional[OptionsStub] = None if check_keys: @@ -624,8 +688,7 @@ def parse_config(lines: Iterable[str], check_keys: bool = True, accumulate_bad_l def parse_defaults(lines: Iterable[str], check_keys: bool = False) -> Dict[str, Any]: - ans = parse_config(lines, check_keys) - return ans + return parse_config(lines, check_keys) xc = init_config(config_lines(all_options), parse_defaults) @@ -757,12 +820,36 @@ def finalize_keys(opts: OptionsStub) -> None: opts.sequence_map = sequence_map +def finalize_mouse_mappings(opts: OptionsStub) -> None: + defns: List[MouseMapping] = [] + for d in getattr(opts, 'mouse_mappings'): + if d is None: # clear_all_shortcuts + defns = [] + else: + defns.append(d) + kitten_aliases: List[Dict[str, Sequence[str]]] = getattr(opts, 'kitten_aliases') + for d in defns: + d.resolve(opts.kitty_mod) + if kitten_aliases and d.action.func == 'kitten': + d.resolve_kitten_aliases(kitten_aliases) + + mousemap: MouseMap = {} + for defn in defns: + is_no_op = defn.action.func in no_op_actions + if is_no_op: + mousemap.pop(defn.trigger, None) + else: + mousemap[defn.trigger] = defn.action + opts.mousemap = mousemap + + def load_config(*paths: str, overrides: Optional[Iterable[str]] = None, accumulate_bad_lines: Optional[List[BadLine]] = None) -> OptionsStub: parser = parse_config if accumulate_bad_lines is not None: parser = partial(parse_config, accumulate_bad_lines=accumulate_bad_lines) opts = _load_config(Options, defaults, parser, merge_configs, *paths, overrides=overrides) finalize_keys(opts) + finalize_mouse_mappings(opts) if opts.background_opacity < 1.0 and opts.macos_titlebar_color: log_error('Cannot use both macos_titlebar_color and background_opacity') opts.macos_titlebar_color = 0 diff --git a/kitty/options_stub.py b/kitty/options_stub.py index 3ecf0b0d9..6ed5e9d81 100644 --- a/kitty/options_stub.py +++ b/kitty/options_stub.py @@ -21,11 +21,12 @@ def generate_stub(): }, preamble_lines=( 'from kitty.types import SingleKey', - 'from kitty.config import KeyAction, KeyMap, SequenceMap', + 'from kitty.config import KeyAction, KeyMap, SequenceMap, MouseMap', ), extra_fields=( ('keymap', 'KeyMap'), ('sequence_map', 'SequenceMap'), + ('mousemap', 'MouseMap'), ) ) diff --git a/kitty/types.py b/kitty/types.py index fc554125b..903cc4c61 100644 --- a/kitty/types.py +++ b/kitty/types.py @@ -52,6 +52,13 @@ class SingleKey(NamedTuple): key: int = -1 +class MouseEvent(NamedTuple): + button: int = 0 + mods: int = 0 + repeat_count: int = 1 + grabbed: bool = False + + ConvertibleToNumbers = Union[str, bytes, int, float]