diff options
author | Marc Jauvin <marc.jauvin@gmail.com> | 2018-03-16 14:28:36 -0400 |
---|---|---|
committer | Marc Jauvin <marc.jauvin@gmail.com> | 2018-03-16 14:28:36 -0400 |
commit | b7159d780a69daf104da939438938d262cd86000 (patch) | |
tree | ab9dc382617174d366dae51cda89186cfbd2bcb9 /qutebrowser/keyinput | |
parent | c9f6cd507b55dabe4d4d8f7841955837a634ff20 (diff) | |
parent | f7074b80d0a68eec6fdfd13f2f82acc94ff2951e (diff) | |
download | qutebrowser-b7159d780a69daf104da939438938d262cd86000.tar.gz qutebrowser-b7159d780a69daf104da939438938d262cd86000.zip |
Merge 'origin/master' into tab-input-mode
Diffstat (limited to 'qutebrowser/keyinput')
-rw-r--r-- | qutebrowser/keyinput/basekeyparser.py | 299 | ||||
-rw-r--r-- | qutebrowser/keyinput/keyparser.py | 77 | ||||
-rw-r--r-- | qutebrowser/keyinput/keyutils.py | 558 | ||||
-rw-r--r-- | qutebrowser/keyinput/modeman.py | 75 | ||||
-rw-r--r-- | qutebrowser/keyinput/modeparsers.py | 201 |
5 files changed, 837 insertions, 373 deletions
diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 89120f922..f0f2c6f28 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -19,14 +19,12 @@ """Base class for vim-like key sequence parser.""" -import enum -import re -import unicodedata - from PyQt5.QtCore import pyqtSignal, QObject +from PyQt5.QtGui import QKeySequence from qutebrowser.config import config from qutebrowser.utils import usertypes, log, utils +from qutebrowser.keyinput import keyutils class BaseKeyParser(QObject): @@ -43,24 +41,16 @@ class BaseKeyParser(QObject): definitive: Keychain matches exactly. none: No more matches possible. - Types: type of a key binding. - chain: execute() was called via a chain-like key binding - special: execute() was called via a special key binding - do_log: Whether to log keypresses or not. passthrough: Whether unbound keys should be passed through with this handler. Attributes: bindings: Bound key bindings - special_bindings: Bound special bindings (<Foo>). _win_id: The window ID this keyparser is associated with. - _warn_on_keychains: Whether a warning should be logged when binding - keychains in a section which does not support them. - _keystring: The currently entered key sequence + _sequence: The currently entered key sequence _modename: The name of the input mode associated with this keyparser. _supports_count: Whether count is supported - _supports_chains: Whether keychains are supported Signals: keystring_updated: Emitted when the keystring is updated. @@ -76,27 +66,18 @@ class BaseKeyParser(QObject): do_log = True passthrough = False - Match = enum.Enum('Match', ['partial', 'definitive', 'other', 'none']) - Type = enum.Enum('Type', ['chain', 'special']) - - def __init__(self, win_id, parent=None, supports_count=None, - supports_chains=False): + def __init__(self, win_id, parent=None, supports_count=True): super().__init__(parent) self._win_id = win_id self._modename = None - self._keystring = '' - if supports_count is None: - supports_count = supports_chains + self._sequence = keyutils.KeySequence() + self._count = '' self._supports_count = supports_count - self._supports_chains = supports_chains - self._warn_on_keychains = True self.bindings = {} - self.special_bindings = {} config.instance.changed.connect(self._on_config_changed) def __repr__(self): - return utils.get_repr(self, supports_count=self._supports_count, - supports_chains=self._supports_chains) + return utils.get_repr(self, supports_count=self._supports_count) def _debug_log(self, message): """Log a message to the debug log if logging is active. @@ -107,62 +88,66 @@ class BaseKeyParser(QObject): if self.do_log: log.keyboard.debug(message) - def _handle_special_key(self, e): - """Handle a new keypress with special keys (<Foo>). - - Return True if the keypress has been handled, and False if not. - - Args: - e: the KeyPressEvent from Qt. - - Return: - True if event has been handled, False otherwise. - """ - binding = utils.keyevent_to_string(e) - if binding is None: - self._debug_log("Ignoring only-modifier keyeevent.") - return False - - if binding not in self.special_bindings: - key_mappings = config.val.bindings.key_mappings - try: - binding = key_mappings['<{}>'.format(binding)][1:-1] - except KeyError: - pass - - try: - cmdstr = self.special_bindings[binding] - except KeyError: - self._debug_log("No special binding found for {}.".format(binding)) - return False - count, _command = self._split_count(self._keystring) - self.execute(cmdstr, self.Type.special, count) - self.clear_keystring() - return True - - def _split_count(self, keystring): - """Get count and command from the current keystring. + def _match_key(self, sequence): + """Try to match a given keystring with any bound keychain. Args: - keystring: The key string to split. + sequence: The command string to find. Return: - A (count, command) tuple. + A tuple (matchtype, binding). + matchtype: Match.definitive, Match.partial or Match.none. + binding: - None with Match.partial/Match.none. + - The found binding with Match.definitive. """ - if self._supports_count: - (countstr, cmd_input) = re.fullmatch(r'(\d*)(.*)', - keystring).groups() - count = int(countstr) if countstr else None - if count == 0 and not cmd_input: - cmd_input = keystring - count = None - else: - cmd_input = keystring - count = None - return count, cmd_input - - def _handle_single_key(self, e): - """Handle a new keypress with a single key (no modifiers). + assert sequence + assert not isinstance(sequence, str) + result = QKeySequence.NoMatch + + for seq, cmd in self.bindings.items(): + assert not isinstance(seq, str), seq + match = sequence.matches(seq) + if match == QKeySequence.ExactMatch: + return match, cmd + elif match == QKeySequence.PartialMatch: + result = QKeySequence.PartialMatch + + return result, None + + def _match_without_modifiers(self, sequence): + """Try to match a key with optional modifiers stripped.""" + self._debug_log("Trying match without modifiers") + sequence = sequence.strip_modifiers() + match, binding = self._match_key(sequence) + return match, binding, sequence + + def _match_key_mapping(self, sequence): + """Try to match a key in bindings.key_mappings.""" + self._debug_log("Trying match with key_mappings") + mapped = sequence.with_mappings(config.val.bindings.key_mappings) + if sequence != mapped: + self._debug_log("Mapped {} -> {}".format( + sequence, mapped)) + match, binding = self._match_key(mapped) + sequence = mapped + return match, binding, sequence + return QKeySequence.NoMatch, None, sequence + + def _match_count(self, sequence, dry_run): + """Try to match a key as count.""" + txt = str(sequence[-1]) # To account for sequences changed above. + if (txt.isdigit() and self._supports_count and + not (not self._count and txt == '0')): + self._debug_log("Trying match as count") + assert len(txt) == 1, txt + if not dry_run: + self._count += txt + self.keystring_updated.emit(self._count + str(self._sequence)) + return True + return False + + def handle(self, e, *, dry_run=False): + """Handle a new keypress. Separate the keypress into count/command, then check if it matches any possible command, and either run the command, ignore it, or @@ -170,109 +155,62 @@ class BaseKeyParser(QObject): Args: e: the KeyPressEvent from Qt. + dry_run: Don't actually execute anything, only check whether there + would be a match. Return: - A self.Match member. + A QKeySequence match. """ - txt = e.text() key = e.key() - self._debug_log("Got key: 0x{:x} / text: '{}'".format(key, txt)) + txt = str(keyutils.KeyInfo.from_event(e)) + self._debug_log("Got key: 0x{:x} / modifiers: 0x{:x} / text: '{}' / " + "dry_run {}".format(key, int(e.modifiers()), txt, + dry_run)) - if len(txt) == 1: - category = unicodedata.category(txt) - is_control_char = (category == 'Cc') - else: - is_control_char = False - - if (not txt) or is_control_char: - self._debug_log("Ignoring, no text char") - return self.Match.none - - count, cmd_input = self._split_count(self._keystring + txt) - match, binding = self._match_key(cmd_input) - if match == self.Match.none: - mappings = config.val.bindings.key_mappings - mapped = mappings.get(txt, None) - if mapped is not None: - txt = mapped - count, cmd_input = self._split_count(self._keystring + txt) - match, binding = self._match_key(cmd_input) - - self._keystring += txt - if match == self.Match.definitive: + if keyutils.is_modifier_key(key): + self._debug_log("Ignoring, only modifier") + return QKeySequence.NoMatch + + try: + sequence = self._sequence.append_event(e) + except keyutils.KeyParseError as ex: + self._debug_log("{} Aborting keychain.".format(ex)) + self.clear_keystring() + return QKeySequence.NoMatch + + match, binding = self._match_key(sequence) + if match == QKeySequence.NoMatch: + match, binding, sequence = self._match_without_modifiers(sequence) + if match == QKeySequence.NoMatch: + match, binding, sequence = self._match_key_mapping(sequence) + if match == QKeySequence.NoMatch: + was_count = self._match_count(sequence, dry_run) + if was_count: + return QKeySequence.ExactMatch + + if dry_run: + return match + + self._sequence = sequence + + if match == QKeySequence.ExactMatch: self._debug_log("Definitive match for '{}'.".format( - self._keystring)) + sequence)) + count = int(self._count) if self._count else None self.clear_keystring() - self.execute(binding, self.Type.chain, count) - elif match == self.Match.partial: + self.execute(binding, count) + elif match == QKeySequence.PartialMatch: self._debug_log("No match for '{}' (added {})".format( - self._keystring, txt)) - elif match == self.Match.none: + sequence, txt)) + self.keystring_updated.emit(self._count + str(sequence)) + elif match == QKeySequence.NoMatch: self._debug_log("Giving up with '{}', no matches".format( - self._keystring)) + sequence)) self.clear_keystring() - elif match == self.Match.other: - pass else: raise utils.Unreachable("Invalid match value {!r}".format(match)) - return match - - def _match_key(self, cmd_input): - """Try to match a given keystring with any bound keychain. - - Args: - cmd_input: The command string to find. - - Return: - A tuple (matchtype, binding). - matchtype: Match.definitive, Match.partial or Match.none. - binding: - None with Match.partial/Match.none. - - The found binding with Match.definitive. - """ - if not cmd_input: - # Only a count, no command yet, but we handled it - return (self.Match.other, None) - # A (cmd_input, binding) tuple (k, v of bindings) or None. - definitive_match = None - partial_match = False - # Check definitive match - try: - definitive_match = (cmd_input, self.bindings[cmd_input]) - except KeyError: - pass - # Check partial match - for binding in self.bindings: - if definitive_match is not None and binding == definitive_match[0]: - # We already matched that one - continue - elif binding.startswith(cmd_input): - partial_match = True - break - if definitive_match is not None: - return (self.Match.definitive, definitive_match[1]) - elif partial_match: - return (self.Match.partial, None) - else: - return (self.Match.none, None) - - def handle(self, e): - """Handle a new keypress and call the respective handlers. - - Args: - e: the KeyPressEvent from Qt - - Return: - True if the event was handled, False otherwise. - """ - handled = self._handle_special_key(e) - if handled or not self._supports_chains: - return handled - match = self._handle_single_key(e) - # don't emit twice if the keystring was cleared in self.clear_keystring - if self._keystring: - self.keystring_updated.emit(self._keystring) - return match != self.Match.none + return match @config.change_filter('bindings') def _on_config_changed(self): @@ -295,37 +233,26 @@ class BaseKeyParser(QObject): else: self._modename = modename self.bindings = {} - self.special_bindings = {} for key, cmd in config.key_instance.get_bindings_for(modename).items(): + assert not isinstance(key, str), key assert cmd - self._parse_key_command(modename, key, cmd) - - def _parse_key_command(self, modename, key, cmd): - """Parse the keys and their command and store them in the object.""" - if utils.is_special_key(key): - self.special_bindings[key[1:-1]] = cmd - elif self._supports_chains: self.bindings[key] = cmd - elif self._warn_on_keychains: - log.keyboard.warning("Ignoring keychain '{}' in mode '{}' because " - "keychains are not supported there." - .format(key, modename)) - def execute(self, cmdstr, keytype, count=None): + def execute(self, cmdstr, count=None): """Handle a completed keychain. Args: cmdstr: The command to execute as a string. - keytype: Type.chain or Type.special count: The count if given. """ raise NotImplementedError def clear_keystring(self): """Clear the currently entered key sequence.""" - if self._keystring: - self._debug_log("discarding keystring '{}'.".format( - self._keystring)) - self._keystring = '' - self.keystring_updated.emit(self._keystring) + if self._sequence: + self._debug_log("Clearing keystring (was: {}).".format( + self._sequence)) + self._sequence = keyutils.KeySequence() + self._count = '' + self.keystring_updated.emit('') diff --git a/qutebrowser/keyinput/keyparser.py b/qutebrowser/keyinput/keyparser.py deleted file mode 100644 index 8ae27412e..000000000 --- a/qutebrowser/keyinput/keyparser.py +++ /dev/null @@ -1,77 +0,0 @@ -# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: - -# Copyright 2014-2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org> -# -# This file is part of qutebrowser. -# -# qutebrowser is free software: you can redistribute it and/or modify -# it under the terms of the GNU General Public License as published by -# the Free Software Foundation, either version 3 of the License, or -# (at your option) any later version. -# -# qutebrowser is distributed in the hope that it will be useful, -# but WITHOUT ANY WARRANTY; without even the implied warranty of -# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the -# GNU General Public License for more details. -# -# You should have received a copy of the GNU General Public License -# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. - -"""Advanced keyparsers.""" - -import traceback - -from qutebrowser.keyinput.basekeyparser import BaseKeyParser -from qutebrowser.utils import message, utils -from qutebrowser.commands import runners, cmdexc - - -class CommandKeyParser(BaseKeyParser): - - """KeyChainParser for command bindings. - - Attributes: - _commandrunner: CommandRunner instance. - """ - - def __init__(self, win_id, parent=None, supports_count=None, - supports_chains=False): - super().__init__(win_id, parent, supports_count, supports_chains) - self._commandrunner = runners.CommandRunner(win_id) - - def execute(self, cmdstr, _keytype, count=None): - try: - self._commandrunner.run(cmdstr, count) - except cmdexc.Error as e: - message.error(str(e), stack=traceback.format_exc()) - - -class PassthroughKeyParser(CommandKeyParser): - - """KeyChainParser which passes through normal keys. - - Used for insert/passthrough modes. - - Attributes: - _mode: The mode this keyparser is for. - """ - - do_log = False - passthrough = True - - def __init__(self, win_id, mode, parent=None, warn=True): - """Constructor. - - Args: - mode: The mode this keyparser is for. - parent: Qt parent. - warn: Whether to warn if an ignored key was bound. - """ - super().__init__(win_id, parent, supports_chains=False) - self._warn_on_keychains = warn - self._read_config(mode) - self._mode = mode - - def __repr__(self): - return utils.get_repr(self, mode=self._mode, - warn=self._warn_on_keychains) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py new file mode 100644 index 000000000..1f34fcae0 --- /dev/null +++ b/qutebrowser/keyinput/keyutils.py @@ -0,0 +1,558 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2014-2018 Florian Bruhin (The Compiler) <mail@qutebrowser.org> +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see <http://www.gnu.org/licenses/>. + +"""Our own QKeySequence-like class and related utilities.""" + +import itertools + +import attr +from PyQt5.QtCore import Qt, QEvent +from PyQt5.QtGui import QKeySequence, QKeyEvent + +from qutebrowser.utils import utils + + +# Map Qt::Key values to their Qt::KeyboardModifier value. +_MODIFIER_MAP = { + Qt.Key_Shift: Qt.ShiftModifier, + Qt.Key_Control: Qt.ControlModifier, + Qt.Key_Alt: Qt.AltModifier, + Qt.Key_Meta: Qt.MetaModifier, + Qt.Key_Mode_switch: Qt.GroupSwitchModifier, +} + + +def _assert_plain_key(key): + """Make sure this is a key without KeyboardModifiers mixed in.""" + assert not key & Qt.KeyboardModifierMask, hex(key) + + +def _assert_plain_modifier(key): + """Make sure this is a modifier without a key mixed in.""" + assert not key & ~Qt.KeyboardModifierMask, hex(key) + + +def _is_printable(key): + _assert_plain_key(key) + return key <= 0xff and key not in [Qt.Key_Space, 0x0] + + +def is_special(key, modifiers): + """Check whether this key requires special key syntax.""" + _assert_plain_key(key) + _assert_plain_modifier(modifiers) + return not (_is_printable(key) and + modifiers in [Qt.ShiftModifier, Qt.NoModifier]) + + +def is_modifier_key(key): + """Test whether the given key is a modifier. + + This only considers keys which are part of Qt::KeyboardModifiers, i.e. + which would interrupt a key chain like "yY" when handled. + """ + _assert_plain_key(key) + return key in _MODIFIER_MAP + + +def _check_valid_utf8(s, data): + """Make sure the given string is valid UTF-8. + + Makes sure there are no chars where Qt did fall back to weird UTF-16 + surrogates. + """ + try: + s.encode('utf-8') + except UnicodeEncodeError as e: # pragma: no cover + raise ValueError("Invalid encoding in 0x{:x} -> {}: {}" + .format(data, s, e)) + + +def _key_to_string(key): + """Convert a Qt::Key member to a meaningful name. + + Args: + key: A Qt::Key member. + + Return: + A name of the key as a string. + """ + _assert_plain_key(key) + special_names_str = { + # Some keys handled in a weird way by QKeySequence::toString. + # See https://bugreports.qt.io/browse/QTBUG-40030 + # Most are unlikely to be ever needed, but you never know ;) + # For dead/combining keys, we return the corresponding non-combining + # key, as that's easier to add to the config. + + 'Super_L': 'Super L', + 'Super_R': 'Super R', + 'Hyper_L': 'Hyper L', + 'Hyper_R': 'Hyper R', + 'Direction_L': 'Direction L', + 'Direction_R': 'Direction R', + + 'Shift': 'Shift', + 'Control': 'Control', + 'Meta': 'Meta', + 'Alt': 'Alt', + + 'AltGr': 'AltGr', + 'Multi_key': 'Multi key', + 'SingleCandidate': 'Single Candidate', + 'Mode_switch': 'Mode switch', + 'Dead_Grave': '`', + 'Dead_Acute': '´', + 'Dead_Circumflex': '^', + 'Dead_Tilde': '~', + 'Dead_Macron': '¯', + 'Dead_Breve': '˘', + 'Dead_Abovedot': '˙', + 'Dead_Diaeresis': '¨', + 'Dead_Abovering': '˚', + 'Dead_Doubleacute': '˝', + 'Dead_Caron': 'ˇ', + 'Dead_Cedilla': '¸', + 'Dead_Ogonek': '˛', + 'Dead_Iota': 'Iota', + 'Dead_Voiced_Sound': 'Voiced Sound', + 'Dead_Semivoiced_Sound': 'Semivoiced Sound', + 'Dead_Belowdot': 'Belowdot', + 'Dead_Hook': 'Hook', + 'Dead_Horn': 'Horn', + + 'Memo': 'Memo', + 'ToDoList': 'To Do List', + 'Calendar': 'Calendar', + 'ContrastAdjust': 'Contrast Adjust', + 'LaunchG': 'Launch (G)', + 'LaunchH': 'Launch (H)', + + 'MediaLast': 'Media Last', + + 'unknown': 'Unknown', + + # For some keys, we just want a different name + 'Escape': 'Escape', + } + # We now build our real special_names dict from the string mapping above. + # The reason we don't do this directly is that certain Qt versions don't + # have all the keys, so we want to ignore AttributeErrors. + special_names = {} + for k, v in special_names_str.items(): + try: + special_names[getattr(Qt, 'Key_' + k)] = v + except AttributeError: + pass + special_names[0x0] = 'nil' + + if key in special_names: + return special_names[key] + + result = QKeySequence(key).toString() + _check_valid_utf8(result, key) + return result + + +def _modifiers_to_string(modifiers): + """Convert the given Qt::KeyboardModifiers to a string. + + Handles Qt.GroupSwitchModifier because Qt doesn't handle that as a + modifier. + """ + _assert_plain_modifier(modifiers) + if modifiers & Qt.GroupSwitchModifier: + modifiers &= ~Qt.GroupSwitchModifier + result = 'AltGr+' + else: + result = '' + + result += QKeySequence(modifiers).toString() + + _check_valid_utf8(result, modifiers) + return result + + +class KeyParseError(Exception): + + """Raised by _parse_single_key/parse_keystring on parse errors.""" + + def __init__(self, keystr, error): + if keystr is None: + msg = "Could not parse keystring: {}".format(error) + else: + msg = "Could not parse {!r}: {}".format(keystr, error) + super().__init__(msg) + + +def _parse_keystring(keystr): + key = '' + special = False + for c in keystr: + if c == '>': + if special: + yield _parse_special_key(key) + key = '' + special = False + else: + yield '>' + assert not key, key + elif c == '<': + special = True + elif special: + key += c + else: + yield _parse_single_key(c) + if special: + yield '<' + for c in key: + yield _parse_single_key(c) + + +def _parse_special_key(keystr): + """Normalize a keystring like Ctrl-Q to a keystring like Ctrl+Q. + + Args: + keystr: The key combination as a string. + + Return: + The normalized keystring. + """ + keystr = keystr.lower() + replacements = ( + ('control', 'ctrl'), + ('windows', 'meta'), + ('mod1', 'alt'), + ('mod4', 'meta'), + ('less', '<'), + ('greater', '>'), + ) + for (orig, repl) in replacements: + keystr = keystr.replace(orig, repl) + + for mod in ['ctrl', 'meta', 'alt', 'shift', 'num']: + keystr = keystr.replace(mod + '-', mod + '+') + return keystr + + +def _parse_single_key(keystr): + """Get a keystring for QKeySequence for a single key.""" + return 'Shift+' + keystr if keystr.isupper() else keystr + + +@attr.s +class KeyInfo: + + """A key with optional modifiers. + + Attributes: + key: A Qt::Key member. + modifiers: A Qt::KeyboardModifiers enum value. + """ + + key = attr.ib() + modifiers = attr.ib() + + @classmethod + def from_event(cls, e): + return cls(e.key(), e.modifiers()) + + def __str__(self): + """Convert this KeyInfo to a meaningful name. + + Return: + A name of the key (combination) as a string. + """ + key_string = _key_to_string(self.key) + modifiers = int(self.modifiers) + + if self.key in _MODIFIER_MAP: + # Don't return e.g. <Shift+Shift> + modifiers &= ~_MODIFIER_MAP[self.key] + elif _is_printable(self.key): + # "normal" binding + if not key_string: # pragma: no cover + raise ValueError("Got empty string for key 0x{:x}!" + .format(self.key)) + + assert len(key_string) == 1, key_string + if self.modifiers == Qt.ShiftModifier: + assert not is_special(self.key, self.modifiers) + return key_string.upper() + elif self.modifiers == Qt.NoModifier: + assert not is_special(self.key, self.modifiers) + return key_string.lower() + else: + # Use special binding syntax, but <Ctrl-a> instead of <Ctrl-A> + key_string = key_string.lower() + + # "special" binding + assert is_special(self.key, self.modifiers) + modifier_string = _modifiers_to_string(modifiers) + return '<{}{}>'.format(modifier_string, key_string) + + def text(self): + """Get the text which would be displayed when pressing this key.""" + control = { + Qt.Key_Space: ' ', + Qt.Key_Tab: '\t', + Qt.Key_Backspace: '\b', + Qt.Key_Return: '\r', + Qt.Key_Enter: '\r', + Qt.Key_Escape: '\x1b', + } + + if self.key in control: + return control[self.key] + elif not _is_printable(self.key): + return '' + + text = QKeySequence(self.key).toString() + if not self.modifiers & Qt.ShiftModifier: + text = text.lower() + return text + + def to_event(self, typ=QEvent.KeyPress): + """Get a QKeyEvent from this KeyInfo.""" + return QKeyEvent(typ, self.key, self.modifiers, self.text()) + + def to_int(self): + """Get the key as an integer (with key/modifiers).""" + return int(self.key) | int(self.modifiers) + + +class KeySequence: + + """A sequence of key presses. + + This internally uses chained QKeySequence objects and exposes a nicer + interface over it. + + NOTE: While private members of this class are in theory mutable, they must + not be mutated in order to ensure consistent hashing. + + Attributes: + _sequences: A list of QKeySequence + + Class attributes: + _MAX_LEN: The maximum amount of keys in a QKeySequence. + """ + + _MAX_LEN = 4 + + def __init__(self, *keys): + self._sequences = [] + for sub in utils.chunk(keys, self._MAX_LEN): + sequence = QKeySequence(*sub) + self._sequences.append(sequence) + if keys: + assert self + self._validate() + + def __str__(self): + parts = [] + for info in self: + parts.append(str(info)) + return ''.join(parts) + + def __iter__(self): + """Iterate over KeyInfo objects.""" + for key_and_modifiers in self._iter_keys(): + key = int(key_and_modifiers) & ~Qt.KeyboardModifierMask + modifiers = Qt.KeyboardModifiers(int(key_and_modifiers) & + Qt.KeyboardModifierMask) + yield KeyInfo(key=key, modifiers=modifiers) + + def __repr__(self): + return utils.get_repr(self, keys=str(self)) + + def __lt__(self, other): + # pylint: disable=protected-access + return self._sequences < other._sequences + + def __gt__(self, other): + # pylint: disable=protected-access + return self._sequences > other._sequences + + def __le__(self, other): + # pylint: disable=protected-access + return self._sequences <= other._sequences + + def __ge__(self, other): + # pylint: disable=protected-access + return self._sequences >= other._sequences + + def __eq__(self, other): + # pylint: disable=protected-access + return self._sequences == other._sequences + + def __ne__(self, other): + # pylint: disable=protected-access + return self._sequences != other._sequences + + def __hash__(self): + return hash(tuple(self._sequences)) + + def __len__(self): + return sum(len(seq) for seq in self._sequences) + + def __bool__(self): + return bool(self._sequences) + + def __getitem__(self, item): + if isinstance(item, slice): + keys = list(self._iter_keys()) + return self.__class__(*keys[item]) + else: + infos = list(self) + return infos[item] + + def _iter_keys(self): + return itertools.chain.from_iterable(self._sequences) + + def _validate(self, keystr=None): + for info in self: + if info.key < Qt.Key_Space or info.key >= Qt.Key_unknown: + raise KeyParseError(keystr, "Got invalid key!") + + for seq in self._sequences: + if not seq: + raise KeyParseError(keystr, "Got invalid key!") + + def matches(self, other): + """Check whether the given KeySequence matches with this one. + + We store multiple QKeySequences with <= 4 keys each, so we need to + match those pair-wise, and account for an unequal amount of sequences + as well. + """ + # pylint: disable=protected-access + + if len(self._sequences) > len(other._sequences): + # If we entered more sequences than there are in the config, + # there's no way there can be a match. + return QKeySequence.NoMatch + + for entered, configured in zip(self._sequences, other._sequences): + # If we get NoMatch/PartialMatch in a sequence, we can abort there. + match = entered.matches(configured) + if match != QKeySequence.ExactMatch: + return match + + # We checked all common sequences and they had an ExactMatch. + # + # If there's still more sequences configured than entered, that's a + # PartialMatch, as more keypresses can still follow and new sequences + # will appear which we didn't check above. + # + # If there's the same amount of sequences configured and entered, + # that's an EqualMatch. + if len(self._sequences) == len(other._sequences): + return QKeySequence.ExactMatch + elif len(self._sequences) < len(other._sequences): + return QKeySequence.PartialMatch + else: + raise utils.Unreachable("self={!r} other={!r}".format(self, other)) + + def append_event(self, ev): + """Create a new KeySequence object with the given QKeyEvent added.""" + key = ev.key() + modifiers = ev.modifiers() + + _assert_plain_key(key) + _assert_plain_modifier(modifiers) + + if key == 0x0: + raise KeyParseError(None, "Got nil key!") + + # We always remove Qt.GroupSwitchModifier because QKeySequence has no + # way to mention that in a binding anyways... + modifiers &= ~Qt.GroupSwitchModifier + + # We change Qt.Key_Backtab to Key_Tab here because nobody would + # configure "Shift-Backtab" in their config. + if modifiers & Qt.ShiftModifier and key == Qt.Key_Backtab: + key = Qt.Key_Tab + + # We don't care about a shift modifier with symbols (Shift-: should + # match a : binding even though we typed it with a shift on an + # US-keyboard) + # + # However, we *do* care about Shift being involved if we got an + # upper-case letter, as Shift-A should match a Shift-A binding, but not + # an "a" binding. + # + # In addition, Shift also *is* relevant when other modifiers are + # involved. Shift-Ctrl-X should not be equivalent to Ctrl-X. + if (modifiers == Qt.ShiftModifier and + _is_printable(ev.key()) and + not ev.text().isupper()): + modifiers = Qt.KeyboardModifiers() + + # On macOS, swap Ctrl and Meta back + # WORKAROUND for https://bugreports.qt.io/browse/QTBUG-51293 + if utils.is_mac: + if modifiers & Qt.ControlModifier and modifiers & Qt.MetaModifier: + pass + elif modifiers & Qt.ControlModifier: + modifiers &= ~Qt.ControlModifier + modifiers |= Qt.MetaModifier + elif modifiers & Qt.MetaModifier: + modifiers &= ~Qt.MetaModifier + modifiers |= Qt.ControlModifier + + keys = list(self._iter_keys()) + keys.append(key | int(modifiers)) + + return self.__class__(*keys) + + def strip_modifiers(self): + """Strip optional modifiers from keys.""" + modifiers = Qt.KeypadModifier + keys = [key & ~modifiers for key in self._iter_keys()] + return self.__class__(*keys) + + def with_mappings(self, mappings): + """Get a new KeySequence with the given mappings applied.""" + keys = [] + for key in self._iter_keys(): + key_seq = KeySequence(key) + if key_seq in mappings: + new_seq = mappings[key_seq] + assert len(new_seq) == 1 + key = new_seq[0].to_int() + keys.append(key) + return self.__class__(*keys) + + @classmethod + def parse(cls, keystr): + """Parse a keystring like <Ctrl-x> or xyz and return a KeySequence.""" + # pylint: disable=protected-access + new = cls() + strings = list(_parse_keystring(keystr)) + for sub in utils.chunk(strings, cls._MAX_LEN): + sequence = QKeySequence(', '.join(sub)) + new._sequences.append(sequence) + + if keystr: + assert new, keystr + + # pylint: disable=protected-access + new._validate(keystr) + return new diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py index 4e9d78fb0..ffe780333 100644 --- a/qutebrowser/keyinput/modeman.py +++ b/qutebrowser/keyinput/modeman.py @@ -25,7 +25,7 @@ import attr from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QObject, QEvent from PyQt5.QtWidgets import QApplication -from qutebrowser.keyinput import modeparsers, keyparser +from qutebrowser.keyinput import modeparsers from qutebrowser.config import config from qutebrowser.commands import cmdexc, cmdutils from qutebrowser.utils import usertypes, log, objreg, utils @@ -68,24 +68,30 @@ def init(win_id, parent): modeman = ModeManager(win_id, parent) objreg.register('mode-manager', modeman, scope='window', window=win_id) keyparsers = { - KM.normal: modeparsers.NormalKeyParser(win_id, modeman), - KM.hint: modeparsers.HintKeyParser(win_id, modeman), - KM.insert: keyparser.PassthroughKeyParser(win_id, 'insert', modeman), - KM.passthrough: keyparser.PassthroughKeyParser(win_id, 'passthrough', - modeman), - KM.command: keyparser.PassthroughKeyParser(win_id, 'command', modeman), - KM.prompt: keyparser.PassthroughKeyParser(win_id, 'prompt', modeman, - warn=False), - KM.yesno: modeparsers.PromptKeyParser(win_id, modeman), - KM.caret: modeparsers.CaretKeyParser(win_id, modeman), - KM.set_mark: modeparsers.RegisterKeyParser(win_id, KM.set_mark, - modeman), - KM.jump_mark: modeparsers.RegisterKeyParser(win_id, KM.jump_mark, - modeman), - KM.record_macro: modeparsers.RegisterKeyParser(win_id, KM.record_macro, - modeman), - KM.run_macro: modeparsers.RegisterKeyParser(win_id, KM.run_macro, - modeman), + KM.normal: + modeparsers.NormalKeyParser(win_id, modeman), + KM.hint: + modeparsers.HintKeyParser(win_id, modeman), + KM.insert: + modeparsers.PassthroughKeyParser(win_id, 'insert', modeman), + KM.passthrough: + modeparsers.PassthroughKeyParser(win_id, 'passthrough', modeman), + KM.command: + modeparsers.PassthroughKeyParser(win_id, 'command', modeman), + KM.prompt: + modeparsers.PassthroughKeyParser(win_id, 'prompt', modeman), + KM.yesno: + modeparsers.PromptKeyParser(win_id, modeman), + KM.caret: + modeparsers.CaretKeyParser(win_id, modeman), + KM.set_mark: + modeparsers.RegisterKeyParser(win_id, KM.set_mark, modeman), + KM.jump_mark: + modeparsers.RegisterKeyParser(win_id, KM.jump_mark, modeman), + KM.record_macro: + modeparsers.RegisterKeyParser(win_id, KM.record_macro, modeman), + KM.run_macro: + modeparsers.RegisterKeyParser(win_id, KM.run_macro, modeman), } objreg.register('keyparsers', keyparsers, scope='window', window=win_id) modeman.destroyed.connect( @@ -149,11 +155,12 @@ class ModeManager(QObject): def __repr__(self): return utils.get_repr(self, mode=self.mode) - def _eventFilter_keypress(self, event): + def _handle_keypress(self, event, *, dry_run=False): """Handle filtering of KeyPress events. Args: event: The KeyPress to examine. + dry_run: Don't actually handle the key, only filter it. Return: True if event should be filtered, False otherwise. @@ -163,7 +170,7 @@ class ModeManager(QObject): if curmode != usertypes.KeyMode.insert: log.modes.debug("got keypress in mode {} - delegating to " "{}".format(curmode, utils.qualname(parser))) - handled = parser.handle(event) + match = parser.handle(event, dry_run=dry_run) is_non_alnum = ( event.modifiers() not in [Qt.NoModifier, Qt.ShiftModifier] or @@ -171,7 +178,7 @@ class ModeManager(QObject): forward_unbound_keys = config.val.input.forward_unbound_keys - if handled: + if match: filter_this = True elif (parser.passthrough or forward_unbound_keys == 'all' or (forward_unbound_keys == 'auto' and is_non_alnum)): @@ -179,20 +186,20 @@ class ModeManager(QObject): else: filter_this = True - if not filter_this: + if not filter_this and not dry_run: self._releaseevents_to_pass.add(KeyEvent.from_event(event)) if curmode != usertypes.KeyMode.insert: focus_widget = QApplication.instance().focusWidget() - log.modes.debug("handled: {}, forward_unbound_keys: {}, " - "passthrough: {}, is_non_alnum: {} --> " - "filter: {} (focused: {!r})".format( - handled, forward_unbound_keys, - parser.passthrough, is_non_alnum, filter_this, - focus_widget)) + log.modes.debug("match: {}, forward_unbound_keys: {}, " + "passthrough: {}, is_non_alnum: {}, dry_run: {} " + "--> filter: {} (focused: {!r})".format( + match, forward_unbound_keys, + parser.passthrough, is_non_alnum, dry_run, + filter_this, focus_widget)) return filter_this - def _eventFilter_keyrelease(self, event): + def _handle_keyrelease(self, event): """Handle filtering of KeyRelease events. Args: @@ -315,7 +322,7 @@ class ModeManager(QObject): raise ValueError("Can't leave normal mode!") self.leave(self.mode, 'leave current') - def eventFilter(self, event): + def handle_event(self, event): """Filter all events based on the currently set mode. Also calls the real keypress handler. @@ -331,8 +338,10 @@ class ModeManager(QObject): return False handlers = { - QEvent.KeyPress: self._eventFilter_keypress, - QEvent.KeyRelease: self._eventFilter_keyrelease, + QEvent.KeyPress: self._handle_keypress, + QEvent.KeyRelease: self._handle_keyrelease, + QEvent.ShortcutOverride: + functools.partial(self._handle_keypress, dry_run=True), } handler = handlers[event.type()] return handler(event) diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index b739d38a1..270590fff 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -27,10 +27,11 @@ import traceback import enum from PyQt5.QtCore import pyqtSlot, Qt +from PyQt5.QtGui import QKeySequence -from qutebrowser.commands import cmdexc +from qutebrowser.commands import runners, cmdexc from qutebrowser.config import config -from qutebrowser.keyinput import keyparser +from qutebrowser.keyinput import basekeyparser, keyutils from qutebrowser.utils import usertypes, log, message, objreg, utils @@ -38,7 +39,26 @@ STARTCHARS = ":/?" LastPress = enum.Enum('LastPress', ['none', 'filtertext', 'keystring']) -class NormalKeyParser(keyparser.CommandKeyParser): +class CommandKeyParser(basekeyparser.BaseKeyParser): + + """KeyChainParser for command bindings. + + Attributes: + _commandrunner: CommandRunner instance. + """ + + def __init__(self, win_id, parent=None, supports_count=None): + super().__init__(win_id, parent, supports_count) + self._commandrunner = runners.CommandRunner(win_id) + + def execute(self, cmdstr, count=None): + try: + self._commandrunner.run(cmdstr, count) + except cmdexc.Error as e: + message.error(str(e), stack=traceback.format_exc()) + + +class NormalKeyParser(CommandKeyParser): """KeyParser for normal mode with added STARTCHARS detection and more. @@ -47,8 +67,7 @@ class NormalKeyParser(keyparser.CommandKeyParser): """ def __init__(self, win_id, parent=None): - super().__init__(win_id, parent, supports_count=True, - supports_chains=True) + super().__init__(win_id, parent, supports_count=True) self._read_config('normal') self._partial_timer = usertypes.Timer(self, 'partial-match') self._partial_timer.setSingleShot(True) @@ -59,11 +78,13 @@ class NormalKeyParser(keyparser.CommandKeyParser): def __repr__(self): return utils.get_repr(self) - def _handle_single_key(self, e): - """Override _handle_single_key to abort if the key is a startchar. + def handle(self, e, *, dry_run=False): + """Override to abort if the key is a startchar. Args: e: the KeyPressEvent from Qt. + dry_run: Don't actually execute anything, only check whether there + would be a match. Return: A self.Match member. @@ -72,9 +93,11 @@ class NormalKeyParser(keyparser.CommandKeyParser): if self._inhibited: self._debug_log("Ignoring key '{}', because the normal mode is " "currently inhibited.".format(txt)) - return self.Match.none - match = super()._handle_single_key(e) - if match == self.Match.partial: + return QKeySequence.NoMatch + + match = super().handle(e, dry_run=dry_run) + + if match == QKeySequence.PartialMatch and not dry_run: timeout = config.val.input.partial_timeout if timeout != 0: self._partial_timer.setInterval(timeout) @@ -96,9 +119,9 @@ class NormalKeyParser(keyparser.CommandKeyParser): def _clear_partial_match(self): """Clear a partial keystring after a timeout.""" self._debug_log("Clearing partial keystring {}".format( - self._keystring)) - self._keystring = '' - self.keystring_updated.emit(self._keystring) + self._sequence)) + self._sequence = keyutils.KeySequence() + self.keystring_updated.emit(str(self._sequence)) @pyqtSlot() def _clear_inhibited(self): @@ -123,22 +146,48 @@ class NormalKeyParser(keyparser.CommandKeyParser): pass -class PromptKeyParser(keyparser.CommandKeyParser): +class PassthroughKeyParser(CommandKeyParser): + + """KeyChainParser which passes through normal keys. + + Used for insert/passthrough modes. + + Attributes: + _mode: The mode this keyparser is for. + """ + + do_log = False + passthrough = True + + def __init__(self, win_id, mode, parent=None): + """Constructor. + + Args: + mode: The mode this keyparser is for. + parent: Qt parent. + warn: Whether to warn if an ignored key was bound. + """ + super().__init__(win_id, parent) + self._read_config(mode) + self._mode = mode + + def __repr__(self): + return utils.get_repr(self, mode=self._mode) + + +class PromptKeyParser(CommandKeyParser): """KeyParser for yes/no prompts.""" def __init__(self, win_id, parent=None): - super().__init__(win_id, parent, supports_count=False, - supports_chains=True) - # We don't want an extra section for this in the config, so we just - # abuse the prompt section. - self._read_config('prompt') + super().__init__(win_id, parent, supports_count=False) + self._read_config('yesno') def __repr__(self): return utils.get_repr(self) -class HintKeyParser(keyparser.CommandKeyParser): +class HintKeyParser(CommandKeyParser): """KeyChainParser for hints. @@ -148,15 +197,14 @@ class HintKeyParser(keyparser.CommandKeyParser): """ def __init__(self, win_id, parent=None): - super().__init__(win_id, parent, supports_count=False, - supports_chains=True) + super().__init__(win_id, parent, supports_count=False) self._filtertext = '' self._last_press = LastPress.none self._read_config('hint') self.keystring_updated.connect(self.on_keystring_updated) - def _handle_special_key(self, e): - """Override _handle_special_key to handle string filtering. + def _handle_filter_key(self, e): + """Handle keys for string filtering. Return True if the keypress has been handled, and False if not. @@ -164,78 +212,75 @@ class HintKeyParser(keyparser.CommandKeyParser): e: the KeyPressEvent from Qt. Return: - True if event has been handled, False otherwise. + A QKeySequence match. """ - log.keyboard.debug("Got special key 0x{:x} text {}".format( + log.keyboard.debug("Got filter key 0x{:x} text {}".format( e.key(), e.text())) hintmanager = objreg.get('hintmanager', scope='tab', window=self._win_id, tab='current') if e.key() == Qt.Key_Backspace: log.keyboard.debug("Got backspace, mode {}, filtertext '{}', " - "keystring '{}'".format(self._last_press, - self._filtertext, - self._keystring)) + "sequence '{}'".format(self._last_press, + self._filtertext, + self._sequence)) if self._last_press == LastPress.filtertext and self._filtertext: self._filtertext = self._filtertext[:-1] hintmanager.filter_hints(self._filtertext) - return True - elif self._last_press == LastPress.keystring and self._keystring: - self._keystring = self._keystring[:-1] - self.keystring_updated.emit(self._keystring) - if not self._keystring and self._filtertext: + return QKeySequence.ExactMatch + elif self._last_press == LastPress.keystring and self._sequence: + self._sequence = self._sequence[:-1] + self.keystring_updated.emit(str(self._sequence)) + if not self._sequence and self._filtertext: # Switch back to hint filtering mode (this can happen only # in numeric mode after the number has been deleted). hintmanager.filter_hints(self._filtertext) self._last_press = LastPress.filtertext - return True + return QKeySequence.ExactMatch else: - return super()._handle_special_key(e) + return QKeySequence.NoMatch elif hintmanager.current_mode() != 'number': - return super()._handle_special_key(e) + return QKeySequence.NoMatch elif not e.text(): - return super()._handle_special_key(e) + return QKeySequence.NoMatch else: self._filtertext += e.text() hintmanager.filter_hints(self._filtertext) self._last_press = LastPress.filtertext - return True + return QKeySequence.ExactMatch - def handle(self, e): + def handle(self, e, *, dry_run=False): """Handle a new keypress and call the respective handlers. Args: e: the KeyPressEvent from Qt + dry_run: Don't actually execute anything, only check whether there + would be a match. Returns: True if the match has been handled, False otherwise. """ - match = self._handle_single_key(e) - if match == self.Match.partial: - self.keystring_updated.emit(self._keystring) + dry_run_match = super().handle(e, dry_run=True) + if dry_run: + return dry_run_match + + if keyutils.is_special(e.key(), e.modifiers()): + log.keyboard.debug("Got special key, clearing keychain") + self.clear_keystring() + + assert not dry_run + match = super().handle(e) + + if match == QKeySequence.PartialMatch: self._last_press = LastPress.keystring - return True - elif match == self.Match.definitive: + elif match == QKeySequence.ExactMatch: self._last_press = LastPress.none - return True - elif match == self.Match.other: - return None - elif match == self.Match.none: + elif match == QKeySequence.NoMatch: # We couldn't find a keychain so we check if it's a special key. - return self._handle_special_key(e) + return self._handle_filter_key(e) else: raise ValueError("Got invalid match type {}!".format(match)) - def execute(self, cmdstr, keytype, count=None): - """Handle a completed keychain.""" - if not isinstance(keytype, self.Type): - raise TypeError("Type {} is no Type member!".format(keytype)) - if keytype == self.Type.chain: - hintmanager = objreg.get('hintmanager', scope='tab', - window=self._win_id, tab='current') - hintmanager.handle_partial_key(cmdstr) - else: - # execute as command - super().execute(cmdstr, keytype, count) + return match def update_bindings(self, strings, preserve_filter=False): """Update bindings when the hint strings changed. @@ -245,7 +290,9 @@ class HintKeyParser(keyparser.CommandKeyParser): preserve_filter: Whether to keep the current value of `self._filtertext`. """ - self.bindings = {s: s for s in strings} + self._read_config() + self.bindings.update({keyutils.KeySequence.parse(s): + 'follow-hint -s ' + s for s in strings}) if not preserve_filter: self._filtertext = '' @@ -257,19 +304,18 @@ class HintKeyParser(keyparser.CommandKeyParser): hintmanager.handle_partial_key(keystr) -class CaretKeyParser(keyparser.CommandKeyParser): +class CaretKeyParser(CommandKeyParser): """KeyParser for caret mode.""" passthrough = True def __init__(self, win_id, parent=None): - super().__init__(win_id, parent, supports_count=True, - supports_chains=True) + super().__init__(win_id, parent, supports_count=True) self._read_config('caret') -class RegisterKeyParser(keyparser.CommandKeyParser): +class RegisterKeyParser(CommandKeyParser): """KeyParser for modes that record a register key. @@ -279,28 +325,30 @@ class RegisterKeyParser(keyparser.CommandKeyParser): """ def __init__(self, win_id, mode, parent=None): - super().__init__(win_id, parent, supports_count=False, - supports_chains=False) + super().__init__(win_id, parent, supports_count=False) self._mode = mode self._read_config('register') - def handle(self, e): + def handle(self, e, *, dry_run=False): """Override handle to always match the next key and use the register. Args: e: the KeyPressEvent from Qt. + dry_run: Don't actually execute anything, only check whether there + would be a match. Return: True if event has been handled, False otherwise. """ - if super().handle(e): - return True + match = super().handle(e, dry_run=dry_run) + if match or dry_run: + return match - key = e.text() - - if key == '' or utils.keyevent_to_string(e) is None: + if keyutils.is_special(e.key(), e.modifiers()): # this is not a proper register key, let it pass and keep going - return False + return QKeySequence.NoMatch + + key = e.text() tabbed_browser = objreg.get('tabbed-browser', scope='window', window=self._win_id) @@ -322,5 +370,4 @@ class RegisterKeyParser(keyparser.CommandKeyParser): message.error(str(err), stack=traceback.format_exc()) self.request_leave.emit(self._mode, "valid register key", True) - - return True + return QKeySequence.ExactMatch |