From 1ba9e8694a993182123f5d47043173369c02090d Mon Sep 17 00:00:00 2001 From: Jonathan Slenders Date: Wed, 27 Nov 2019 23:27:50 +0100 Subject: [PATCH] [wip] Refactoring to the Keys class. --- prompt_toolkit/input/ansi_escape_sequences.py | 6 +- prompt_toolkit/input/vt100_parser.py | 25 +++-- prompt_toolkit/input/win32.py | 4 +- prompt_toolkit/key_binding/bindings/vi.py | 2 +- prompt_toolkit/key_binding/key_bindings.py | 61 +++------- prompt_toolkit/key_binding/key_processor.py | 17 ++- prompt_toolkit/keys.py | 106 ++++++++++++++++-- 7 files changed, 147 insertions(+), 74 deletions(-) diff --git a/prompt_toolkit/input/ansi_escape_sequences.py b/prompt_toolkit/input/ansi_escape_sequences.py index 2b374dacfa..109be8aeae 100644 --- a/prompt_toolkit/input/ansi_escape_sequences.py +++ b/prompt_toolkit/input/ansi_escape_sequences.py @@ -13,7 +13,7 @@ # Mapping of vt100 escape codes to Keys. -ANSI_SEQUENCES: Dict[str, Union[Keys, Tuple[Keys, ...]]] = { +ANSI_SEQUENCES: Dict[str, Union[str, Tuple[str, ...]]] = { '\x00': Keys.ControlAt, # Control-At (Also for Ctrl-Space) '\x01': Keys.ControlA, # Control-A (home) '\x02': Keys.ControlB, # Control-B (emacs cursor left) @@ -181,12 +181,12 @@ } -def _get_reverse_ansi_sequences() -> Dict[Keys, str]: +def _get_reverse_ansi_sequences() -> Dict[str, str]: """ Create a dictionary that maps prompt_toolkit keys back to the VT100 escape sequences. """ - result: Dict[Keys, str] = {} + result: Dict[str, str] = {} for sequence, key in ANSI_SEQUENCES.items(): if not isinstance(key, tuple): diff --git a/prompt_toolkit/input/vt100_parser.py b/prompt_toolkit/input/vt100_parser.py index 87340f4c24..aa8ef30eea 100644 --- a/prompt_toolkit/input/vt100_parser.py +++ b/prompt_toolkit/input/vt100_parser.py @@ -5,7 +5,7 @@ from typing import Callable, Dict, Generator, Tuple, Union from ..key_binding.key_processor import KeyPress -from ..keys import Keys +from ..keys import Keys, ParsedKey, parse_key from .ansi_escape_sequences import ANSI_SEQUENCES __all__ = [ @@ -76,6 +76,17 @@ def callback(key): # "od -c" and start typing. def __init__(self, feed_key_callback: Callable[[KeyPress], None]) -> None: self.feed_key_callback = feed_key_callback + + def parse(key_or_keys: Union[str, Tuple[str, ...]]) -> Tuple[ParsedKey, ...]: + " Parse/validate entry from ANSI_SEQUENCES dictionary. " + if isinstance(key_or_keys, tuple): + return tuple(parse_key(k) for k in key_or_keys) + return (parse_key(key_or_keys), ) + + self.ansi_sequences: Dict[str, Tuple[ParsedKey, ...]] = { + sequence: parse(key_or_keys) for sequence, key_or_keys in ANSI_SEQUENCES.items() + } + self.reset() def reset(self, request: bool = False) -> None: @@ -89,7 +100,7 @@ def _start_parser(self) -> None: self._input_parser = self._input_parser_generator() self._input_parser.send(None) # type: ignore - def _get_match(self, prefix: str) -> Union[None, Keys, Tuple[Keys, ...]]: + def _get_match(self, prefix: str) -> Union[None, ParsedKey, Tuple[ParsedKey, ...]]: """ Return the key (or keys) that maps to this prefix. """ @@ -97,14 +108,14 @@ def _get_match(self, prefix: str) -> Union[None, Keys, Tuple[Keys, ...]]: # (This one doesn't fit in the ANSI_SEQUENCES, because it contains # integer variables.) if _cpr_response_re.match(prefix): - return Keys.CPRResponse + return ParsedKey(Keys.CPRResponse) elif _mouse_event_re.match(prefix): - return Keys.Vt100MouseEvent + return ParsedKey(Keys.Vt100MouseEvent) # Otherwise, use the mappings. try: - return ANSI_SEQUENCES[prefix] + return self.ansi_sequences[prefix] except KeyError: return None @@ -158,7 +169,7 @@ def _input_parser_generator(self) -> Generator[None, Union[str, _Flush], None]: self._call_handler(prefix[0], prefix[0]) prefix = prefix[1:] - def _call_handler(self, key: Union[str, Keys, Tuple[Keys, ...]], + def _call_handler(self, key: Union[ParsedKey, Tuple[ParsedKey, ...]], insert_text: str) -> None: """ Callback to handler. @@ -191,7 +202,7 @@ def feed(self, data: str) -> None: # Feed content to key bindings. paste_content = self._paste_buffer[:end_index] - self.feed_key_callback(KeyPress(Keys.BracketedPaste, paste_content)) + self.feed_key_callback(KeyPress(parse_key(Keys.BracketedPaste), paste_content)) # Quit bracketed paste mode and handle remaining input. self._in_bracketed_paste = False diff --git a/prompt_toolkit/input/win32.py b/prompt_toolkit/input/win32.py index 8a043d55b9..b650c41389 100644 --- a/prompt_toolkit/input/win32.py +++ b/prompt_toolkit/input/win32.py @@ -11,7 +11,7 @@ from prompt_toolkit.eventloop import run_in_executor_with_context from prompt_toolkit.eventloop.win32 import wait_for_handles from prompt_toolkit.key_binding.key_processor import KeyPress -from prompt_toolkit.keys import Keys +from prompt_toolkit.keys import Keys, parse_key from prompt_toolkit.mouse_events import MouseEventType from prompt_toolkit.win32_types import ( INPUT_RECORD, @@ -254,7 +254,7 @@ def read(self) -> Iterable[KeyPress]: k = None if data: - yield KeyPress(Keys.BracketedPaste, ''.join(data)) + yield KeyPress(parse_key(Keys.BracketedPaste), ''.join(data)) if k is not None: yield k else: diff --git a/prompt_toolkit/key_binding/bindings/vi.py b/prompt_toolkit/key_binding/bindings/vi.py index c456764297..88f12d9d6e 100644 --- a/prompt_toolkit/key_binding/bindings/vi.py +++ b/prompt_toolkit/key_binding/bindings/vi.py @@ -175,7 +175,7 @@ def create_text_object_decorator(key_bindings: KeyBindings) -> Callable[..., Cal Create a decorator that can be used to register Vi text object implementations. """ def text_object_decorator( - *keys: Union[Keys, str], + *keys: str, filter: Filter = Always(), no_move_handler: bool = False, no_selection_handler: bool = False, eager: bool = False) -> Callable[[_TOF], _TOF]: """ diff --git a/prompt_toolkit/key_binding/key_bindings.py b/prompt_toolkit/key_binding/key_bindings.py index b719093ce3..e455bb30d3 100644 --- a/prompt_toolkit/key_binding/key_bindings.py +++ b/prompt_toolkit/key_binding/key_bindings.py @@ -9,7 +9,7 @@ kb = KeyBindings() - @kb.add(Keys.ControlX, Keys.ControlC, filter=INSERT) + @kb.add('c-x', 'c-c', filter=INSERT) def handler(event): # Handle ControlX-ControlC key sequence. pass @@ -32,7 +32,7 @@ def my_key_binding(event): ... # Later, add it to the key bindings. - kb.add(Keys.A, my_key_binding) + kb.add('c-a', my_key_binding) """ from abc import ABCMeta, abstractmethod, abstractproperty from typing import ( @@ -50,7 +50,7 @@ def my_key_binding(event): from prompt_toolkit.cache import SimpleCache from prompt_toolkit.filters import FilterOrBool, Never, to_filter -from prompt_toolkit.keys import KEY_ALIASES, Keys +from prompt_toolkit.keys import ALL_KEYS, KEY_ALIASES, Keys, parse_key, ParsedKey # Avoid circular imports. if TYPE_CHECKING: @@ -69,6 +69,9 @@ def my_key_binding(event): KeyHandlerCallable = Callable[['KeyPressEvent'], None] +# Sequence of keys presses. +KeysTuple = Tuple[ParsedKey, ...] + class Binding: """ @@ -79,7 +82,7 @@ class Binding: macro is recorded. """ def __init__( - self, keys: Tuple[Union[Keys, str], ...], + self, keys: KeysTuple, handler: KeyHandlerCallable, filter: FilterOrBool = True, eager: FilterOrBool = False, @@ -103,10 +106,6 @@ def __repr__(self) -> str: self.__class__.__name__, self.keys, self.handler) -# Sequence of keys presses. -KeysTuple = Tuple[Union[Keys, str], ...] - - class KeyBindingsBase(metaclass=ABCMeta): """ Interface for a KeyBindings. @@ -169,6 +168,10 @@ class KeyBindings(KeyBindingsBase): def _(event): print('Control-T pressed') + @kb.add(Keys.ControlT) + def _(event): + print('Control-T pressed') + @kb.add('c-a', 'c-b') def _(event): print('Control-A pressed, followed by Control-B') @@ -200,7 +203,7 @@ def _version(self) -> Hashable: return self.__version def add(self, - *keys: Union[Keys, str], + *keys: str, filter: FilterOrBool = True, eager: FilterOrBool = False, is_global: FilterOrBool = False, @@ -226,7 +229,7 @@ def add(self, """ assert keys - keys = tuple(_parse_key(k) for k in keys) + parsed_keys = tuple(parse_key(k) for k in keys) if isinstance(filter, Never): # When a filter is Never, it will always stay disabled, so in that @@ -240,7 +243,7 @@ def decorator(func: T) -> T: # We're adding an existing Binding object. self.bindings.append( Binding( - keys, func.handler, + parsed_keys, func.handler, filter=func.filter & to_filter(filter), eager=to_filter(eager) | func.eager, is_global = to_filter(is_global) | func.is_global, @@ -248,7 +251,7 @@ def decorator(func: T) -> T: record_in_macro=func.record_in_macro)) else: self.bindings.append( - Binding(keys, cast(KeyHandlerCallable, func), + Binding(parsed_keys, cast(KeyHandlerCallable, func), filter=filter, eager=eager, is_global=is_global, save_before=save_before, record_in_macro=record_in_macro)) @@ -257,7 +260,7 @@ def decorator(func: T) -> T: return func return decorator - def remove(self, *args: Union[Keys, str, KeyHandlerCallable]) -> None: + def remove(self, *args: Union[str, KeyHandlerCallable]) -> None: """ Remove a key binding. @@ -285,10 +288,10 @@ def remove(self, *args: Union[Keys, str, KeyHandlerCallable]) -> None: else: assert len(args) > 0 - args = cast(Tuple[Union[Keys, str]], args) + args = cast(Tuple[str], args) # Remove this sequence of key bindings. - keys = tuple(_parse_key(k) for k in args) + keys = tuple(parse_key(k) for k in args) for b in self.bindings: if b.keys == keys: @@ -364,34 +367,6 @@ def get() -> List[Binding]: return self._get_bindings_starting_with_keys_cache.get(keys, get) -def _parse_key(key: Union[Keys, str]) -> Union[str, Keys]: - """ - Replace key by alias and verify whether it's a valid one. - """ - # Already a parse key? -> Return it. - if isinstance(key, Keys): - return key - - # Lookup aliases. - key = KEY_ALIASES.get(key, key) - - # Replace 'space' by ' ' - if key == 'space': - key = ' ' - - # Return as `Key` object when it's a special key. - try: - return Keys(key) - except ValueError: - pass - - # Final validation. - if len(key) != 1: - raise ValueError('Invalid key: %s' % (key, )) - - return key - - def key_binding( filter: FilterOrBool = True, eager: FilterOrBool = False, diff --git a/prompt_toolkit/key_binding/key_processor.py b/prompt_toolkit/key_binding/key_processor.py index 7569d6b2f4..8be2bd8862 100644 --- a/prompt_toolkit/key_binding/key_processor.py +++ b/prompt_toolkit/key_binding/key_processor.py @@ -14,7 +14,7 @@ from prompt_toolkit.application.current import get_app from prompt_toolkit.enums import EditingMode from prompt_toolkit.filters.app import vi_navigation_mode -from prompt_toolkit.keys import Keys +from prompt_toolkit.keys import Keys, ALL_KEYS, ParsedKey, parse_key from prompt_toolkit.utils import Event from .key_bindings import Binding, KeyBindingsBase @@ -33,17 +33,16 @@ class KeyPress: """ - :param key: A `Keys` instance or text (one character). + :param key: The key that was pressed. This is either a registered key + (e.g., "c-c" for control-c or "escape") or one single character of user + input. :param data: The received string on stdin. (Often vt100 escape codes.) """ - def __init__(self, key: Union[Keys, str], data: Optional[str] = None) -> None: - assert isinstance(key, Keys) or len(key) == 1 + def __init__(self, key: ParsedKey, data: Optional[str] = None) -> None: + assert key in ALL_KEYS or len(key) == 1 if data is None: - if isinstance(key, Keys): - data = key.value - else: - data = key # 'key' is a one character string. + data = key self.key = key self.data = data @@ -62,7 +61,7 @@ def __eq__(self, other: object) -> bool: Helper object to indicate flush operation in the KeyProcessor. NOTE: the implementation is very similar to the VT100 parser. """ -_Flush = KeyPress('?', data='_Flush') +_Flush = KeyPress(parse_key('?'), data='_Flush') class KeyProcessor: diff --git a/prompt_toolkit/keys.py b/prompt_toolkit/keys.py index 3f02e7a338..be71061578 100644 --- a/prompt_toolkit/keys.py +++ b/prompt_toolkit/keys.py @@ -1,21 +1,81 @@ from enum import Enum -from typing import Dict, List +from typing import Dict, List, NewType, cast __all__ = [ 'Keys', 'ALL_KEYS', + 'register_new_key' + 'ParsedKey', + 'parse_key', ] +# Type for keys that are validated. +# (We use this because keys are defined as strings, like 'c-c', but we still +# want mypy to check that we're not passing random strings to places where a +# key is expected.) +ParsedKey = NewType('ParsedKey', str) -class Keys(str, Enum): + +# List of the names of the keys that are currently known, and can be used in +# key bindings. This is mostly used as a tool for validating the key bindings. +ALL_KEYS: List[ParsedKey] = [] + + +class _KeysMeta(type): """ - List of keys for use in key bindings. + Metaclass for `Keys`, which will register all known keys into the + `ALL_KEYS` list. + """ + def __new__(cls, name: str, bases, attrs: dict) -> "_KeysMeta": + for key in attrs.values(): + cls.register_new_key(key) + + return cast("_KeysMeta", super().__new__(cls, name, bases, attrs)) + + @classmethod + def register_new_key(cls, key: str) -> None: + """ + Register a new key in `ALL_KEYS`. + """ + if len(key) <= 1: + raise ValueError( + 'Keys should have a length of at least 2 in order to distinguish ' + 'them from individual characters typed on the input.') + + # Add to the `ALL_KEYS` list, so that prompt_toolkit will accept this key + # in key bindings. + ALL_KEYS.append(ParsedKey(key)) - Note that this is an "StrEnum", all values can be compared against - strings. + def __setattr__(self, name: str, value: str) -> None: + """ + Allow the definition of new keys by using the following syntax:: + + Keys.ControlF5 = "" + + This will automatically register the key in `ALL_KEYS`. + """ + self.register_new_key(value) + type.__setattr__(self, name, value) + + +class Keys(metaclass=_KeysMeta): """ - value: str + List of keys for use in key bindings. + + Note: Use of this class is completely optional in the key bindings. We can + as well use string literals anywhere, and this is also what's done + most of the time in the prompt_toolkit built-in key bindings. + + This class however remains an overview of what key bindings are + supported. But also, many people like to use the `Keys.SomeKey` + notation, because editors can provide code completion for that + notation. + None 2: In an earlier version, this class was a "StrEnum". This didn't work + well, because it's impossible to add new values to an enum. We need + this though, because some users can define custom keys (with custom + VT100 escape sequences) that are normally not available. + """ Escape = 'escape' # Also Control-[ ControlAt = 'c-@' # Also Control-Space. @@ -136,9 +196,6 @@ class Keys(str, Enum): Backspace = ControlH -ALL_KEYS: List[str] = [k.value for k in Keys] - - # Aliases. KEY_ALIASES: Dict[str, str] = { 'backspace': 'c-h', @@ -146,3 +203,34 @@ class Keys(str, Enum): 'enter': 'c-m', 'tab': 'c-i', } + + +def parse_key(key: str) -> ParsedKey: + """ + Replace key by alias and verify whether it's a valid one. + """ + # Lookup aliases. + key = KEY_ALIASES.get(key, key) + + # Replace 'space' by ' ' + if key == 'space': + key = ' ' + + # Accept the key when it's a special key. + if key in ALL_KEYS: + return ParsedKey(key) + + # Otherwise, expect a single character. + if len(key) != 1: + raise ValueError('Invalid key: %s' % (key, )) + + return ParsedKey(key) + + +def register_new_key(name: str) -> None: + """ + Register a new key, e.g. "control-shift-f5", so that this can be used in a + key binding. (We have some validation in the key bindings that prevent the + creation of key bindings with unknown keys.) + """ + Keys.register_new_key(name)