diff --git a/doc/help/commands.asciidoc b/doc/help/commands.asciidoc index 7c8ea1688..845e75336 100644 --- a/doc/help/commands.asciidoc +++ b/doc/help/commands.asciidoc @@ -154,7 +154,8 @@ Bind a key to a command. If no command is given, show the current binding for the given key. Using :bind without any arguments opens a page showing all keybindings. ==== positional arguments -* +'key'+: The keychain or special key (inside `<...>`) to bind. +* +'key'+: The keychain to bind. Examples of valid keychains are `gC`, `` or `a`. + * +'command'+: The command to execute, with optional args. ==== optional arguments @@ -1317,7 +1318,8 @@ Syntax: +:unbind [*--mode* 'mode'] 'key'+ Unbind a keychain. ==== positional arguments -* +'key'+: The keychain or special key (inside <...>) to unbind. +* +'key'+: The keychain to unbind. See the help for `:bind` for the correct syntax for keychains. + ==== optional arguments * +*-m*+, +*--mode*+: A mode to unbind the key in (default: `normal`). See `:help bindings.commands` for the available modes. @@ -1498,13 +1500,16 @@ Drop selection and keep selection mode enabled. [[follow-hint]] === follow-hint -Syntax: +:follow-hint ['keystring']+ +Syntax: +:follow-hint [*--select*] ['keystring']+ Follow a hint. ==== positional arguments * +'keystring'+: The hint to follow. +==== optional arguments +* +*-s*+, +*--select*+: Only select the given hint, don't necessarily follow it. + [[leave-mode]] === leave-mode Leave the mode we're currently in. diff --git a/doc/help/settings.asciidoc b/doc/help/settings.asciidoc index a9e7becd2..216718de1 100644 --- a/doc/help/settings.asciidoc +++ b/doc/help/settings.asciidoc @@ -322,7 +322,7 @@ While it's possible to add bindings with this setting, it's recommended to use ` This setting is a dictionary containing mode names and dictionaries mapping keys to commands: `{mode: {key: command}}` If you want to map a key to another key, check the `bindings.key_mappings` setting instead. -For special keys (can't be part of a keychain), enclose them in `<`...`>`. For modifiers, you can use either `-` or `+` as delimiters, and these names: +For modifiers, you can use either `-` or `+` as delimiters, and these names: * Control: `Control`, `Ctrl` @@ -358,11 +358,8 @@ The following modes are available: * prompt: Entered when there's a prompt to display, like for download locations or when invoked from JavaScript. - + - You can bind normal keys in this mode, but they will be only active when - a yes/no-prompt is asked. For other prompt modes, you can only bind - special keys. +* yesno: Entered when there's a yes/no prompt displayed. * caret: Entered when pressing the `v` mode, used to select text using the keyboard. @@ -642,11 +639,17 @@ Default: * +pass:[<Shift-Tab>]+: +pass:[prompt-item-focus prev]+ * +pass:[<Tab>]+: +pass:[prompt-item-focus next]+ * +pass:[<Up>]+: +pass:[prompt-item-focus prev]+ -* +pass:[n]+: +pass:[prompt-accept no]+ -* +pass:[y]+: +pass:[prompt-accept yes]+ - +pass:[register]+: * +pass:[<Escape>]+: +pass:[leave-mode]+ +- +pass:[yesno]+: + +* +pass:[<Alt-Shift-Y>]+: +pass:[prompt-yank --sel]+ +* +pass:[<Alt-Y>]+: +pass:[prompt-yank]+ +* +pass:[<Escape>]+: +pass:[leave-mode]+ +* +pass:[<Return>]+: +pass:[prompt-accept]+ +* +pass:[n]+: +pass:[prompt-accept no]+ +* +pass:[y]+: +pass:[prompt-accept yes]+ [[bindings.key_mappings]] === bindings.key_mappings diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index 6c2e3da8c..b84bde7a8 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -27,14 +27,13 @@ import typing from PyQt5.QtWidgets import QApplication, QTabBar, QDialog from PyQt5.QtCore import pyqtSlot, Qt, QUrl, QEvent, QUrlQuery -from PyQt5.QtGui import QKeyEvent from PyQt5.QtPrintSupport import QPrintDialog, QPrintPreviewDialog from qutebrowser.commands import userscripts, cmdexc, cmdutils, runners from qutebrowser.config import config, configdata from qutebrowser.browser import (urlmarks, browsertab, inspector, navigate, webelem, downloads) -from qutebrowser.keyinput import modeman +from qutebrowser.keyinput import modeman, keyutils from qutebrowser.utils import (message, usertypes, log, qtutils, urlutils, objreg, utils, standarddir) from qutebrowser.utils.usertypes import KeyMode @@ -2112,15 +2111,13 @@ class CommandDispatcher: global_: If given, the keys are sent to the qutebrowser UI. """ try: - keyinfos = utils.parse_keystring(keystring) - except utils.KeyParseError as e: + sequence = keyutils.KeySequence.parse(keystring) + except keyutils.KeyParseError as e: raise cmdexc.CommandError(str(e)) - for keyinfo in keyinfos: - press_event = QKeyEvent(QEvent.KeyPress, keyinfo.key, - keyinfo.modifiers, keyinfo.text) - release_event = QKeyEvent(QEvent.KeyRelease, keyinfo.key, - keyinfo.modifiers, keyinfo.text) + for keyinfo in sequence: + press_event = keyinfo.to_event(QEvent.KeyPress) + release_event = keyinfo.to_event(QEvent.KeyRelease) if global_: window = QApplication.focusWindow() diff --git a/qutebrowser/browser/hints.py b/qutebrowser/browser/hints.py index 0390d5d1f..48a0193e6 100644 --- a/qutebrowser/browser/hints.py +++ b/qutebrowser/browser/hints.py @@ -909,20 +909,27 @@ class HintManager(QObject): @cmdutils.register(instance='hintmanager', scope='tab', modes=[usertypes.KeyMode.hint]) - def follow_hint(self, keystring=None): + def follow_hint(self, select=False, keystring=None): """Follow a hint. Args: + select: Only select the given hint, don't necessarily follow it. keystring: The hint to follow, or None. """ if keystring is None: if self._context.to_follow is None: raise cmdexc.CommandError("No hint to follow") + elif select: + raise cmdexc.CommandError("Can't use --select without hint.") else: keystring = self._context.to_follow elif keystring not in self._context.labels: raise cmdexc.CommandError("No hint {}!".format(keystring)) - self._fire(keystring) + + if select: + self.handle_partial_key(keystring) + else: + self._fire(keystring) @pyqtSlot(usertypes.KeyMode) def on_mode_left(self, mode): diff --git a/qutebrowser/completion/models/configmodel.py b/qutebrowser/completion/models/configmodel.py index b9ad6f626..877de62b7 100644 --- a/qutebrowser/completion/models/configmodel.py +++ b/qutebrowser/completion/models/configmodel.py @@ -22,6 +22,7 @@ from qutebrowser.config import configdata, configexc from qutebrowser.completion.models import completionmodel, listcategory, util from qutebrowser.commands import runners, cmdexc +from qutebrowser.keyinput import keyutils def option(*, info): @@ -73,16 +74,16 @@ def value(optname, *_values, info): return model -def bind(key, *, info): - """A CompletionModel filled with all bindable commands and descriptions. - - Args: - key: the key being bound. - """ - model = completionmodel.CompletionModel(column_widths=(20, 60, 20)) +def _bind_current_default(key, info): + """Get current/default data for the given key.""" data = [] + try: + seq = keyutils.KeySequence.parse(key) + except keyutils.KeyParseError as e: + data.append(('', str(e), key)) + return data - cmd_text = info.keyconf.get_command(key, 'normal') + cmd_text = info.keyconf.get_command(seq, 'normal') if cmd_text: parser = runners.CommandParser() try: @@ -92,12 +93,24 @@ def bind(key, *, info): else: data.append((cmd_text, '(Current) {}'.format(cmd.desc), key)) - cmd_text = info.keyconf.get_command(key, 'normal', default=True) + cmd_text = info.keyconf.get_command(seq, 'normal', default=True) if cmd_text: parser = runners.CommandParser() cmd = parser.parse(cmd_text).cmd data.append((cmd_text, '(Default) {}'.format(cmd.desc), key)) + return data + + +def bind(key, *, info): + """A CompletionModel filled with all bindable commands and descriptions. + + Args: + key: the key being bound. + """ + model = completionmodel.CompletionModel(column_widths=(20, 60, 20)) + data = _bind_current_default(key, info) + if data: model.add_category(listcategory.ListCategory("Current/Default", data)) diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py index 33e04a90d..eb2a81594 100644 --- a/qutebrowser/config/config.py +++ b/qutebrowser/config/config.py @@ -28,6 +28,7 @@ from PyQt5.QtCore import pyqtSignal, pyqtSlot, QObject from qutebrowser.config import configdata, configexc, configutils from qutebrowser.utils import utils, log, jinja from qutebrowser.misc import objects +from qutebrowser.keyinput import keyutils # An easy way to access the config from other code via config.val.foo val = None @@ -135,14 +136,12 @@ class KeyConfig: def __init__(self, config): self._config = config - def _prepare(self, key, mode): - """Make sure the given mode exists and normalize the key.""" + def _validate(self, key, mode): + """Validate the given key and mode.""" + # Catch old usage of this code + assert isinstance(key, keyutils.KeySequence), key if mode not in configdata.DATA['bindings.default'].default: raise configexc.KeybindingError("Invalid mode {}!".format(mode)) - if utils.is_special_key(key): - # , , and should be considered equivalent - return utils.normalize_keystr(key) - return key def get_bindings_for(self, mode): """Get the combined bindings for the given mode.""" @@ -158,20 +157,20 @@ class KeyConfig: """Get a dict of commands to a list of bindings for the mode.""" cmd_to_keys = {} bindings = self.get_bindings_for(mode) - for key, full_cmd in sorted(bindings.items()): + for seq, full_cmd in sorted(bindings.items()): for cmd in full_cmd.split(';;'): cmd = cmd.strip() cmd_to_keys.setdefault(cmd, []) - # put special bindings last - if utils.is_special_key(key): - cmd_to_keys[cmd].append(key) + # Put bindings involving modifiers last + if any(info.modifiers for info in seq): + cmd_to_keys[cmd].append(str(seq)) else: - cmd_to_keys[cmd].insert(0, key) + cmd_to_keys[cmd].insert(0, str(seq)) return cmd_to_keys def get_command(self, key, mode, default=False): """Get the command for a given key (or None).""" - key = self._prepare(key, mode) + self._validate(key, mode) if default: bindings = dict(val.bindings.default[mode]) else: @@ -185,23 +184,23 @@ class KeyConfig: "Can't add binding '{}' with empty command in {} " 'mode'.format(key, mode)) - key = self._prepare(key, mode) + self._validate(key, mode) log.keyboard.vdebug("Adding binding {} -> {} in mode {}.".format( key, command, mode)) bindings = self._config.get_mutable_obj('bindings.commands') if mode not in bindings: bindings[mode] = {} - bindings[mode][key] = command + bindings[mode][str(key)] = command self._config.update_mutables(save_yaml=save_yaml) def bind_default(self, key, *, mode='normal', save_yaml=False): """Restore a default keybinding.""" - key = self._prepare(key, mode) + self._validate(key, mode) bindings_commands = self._config.get_mutable_obj('bindings.commands') try: - del bindings_commands[mode][key] + del bindings_commands[mode][str(key)] except KeyError: raise configexc.KeybindingError( "Can't find binding '{}' in {} mode".format(key, mode)) @@ -209,18 +208,18 @@ class KeyConfig: def unbind(self, key, *, mode='normal', save_yaml=False): """Unbind the given key in the given mode.""" - key = self._prepare(key, mode) + self._validate(key, mode) bindings_commands = self._config.get_mutable_obj('bindings.commands') if val.bindings.commands[mode].get(key, None) is not None: # In custom bindings -> remove it - del bindings_commands[mode][key] + del bindings_commands[mode][str(key)] elif key in val.bindings.default[mode]: # In default bindings -> shadow it with None if mode not in bindings_commands: bindings_commands[mode] = {} - bindings_commands[mode][key] = None + bindings_commands[mode][str(key)] = None else: raise configexc.KeybindingError( "Can't find binding '{}' in {} mode".format(key, mode)) diff --git a/qutebrowser/config/configcommands.py b/qutebrowser/config/configcommands.py index fb8bb9fe5..792eacaf0 100644 --- a/qutebrowser/config/configcommands.py +++ b/qutebrowser/config/configcommands.py @@ -26,9 +26,10 @@ from PyQt5.QtCore import QUrl from qutebrowser.commands import cmdexc, cmdutils from qutebrowser.completion.models import configmodel -from qutebrowser.utils import objreg, utils, message, standarddir, urlmatch +from qutebrowser.utils import objreg, message, standarddir, urlmatch from qutebrowser.config import configtypes, configexc, configfiles, configdata from qutebrowser.misc import editor +from qutebrowser.keyinput import keyutils class ConfigCommands: @@ -58,6 +59,13 @@ class ConfigCommands: raise cmdexc.CommandError("Error while parsing {}: {}" .format(pattern, str(e))) + def _parse_key(self, key): + """Parse a key argument.""" + try: + return keyutils.KeySequence.parse(key) + except keyutils.KeyParseError as e: + raise cmdexc.CommandError(str(e)) + def _print_value(self, option, pattern): """Print the value of the given option.""" with self._handle_config_error(): @@ -129,7 +137,8 @@ class ConfigCommands: Using :bind without any arguments opens a page showing all keybindings. Args: - key: The keychain or special key (inside `<...>`) to bind. + key: The keychain to bind. Examples of valid keychains are `gC`, + `` or `a`. command: The command to execute, with optional args. mode: A comma-separated list of modes to bind the key in (default: `normal`). See `:help bindings.commands` for the @@ -142,42 +151,42 @@ class ConfigCommands: tabbed_browser.openurl(QUrl('qute://bindings'), newtab=True) return + seq = self._parse_key(key) + if command is None: if default: # :bind --default: Restore default with self._handle_config_error(): - self._keyconfig.bind_default(key, mode=mode, + self._keyconfig.bind_default(seq, mode=mode, save_yaml=True) return # No --default -> print binding - if utils.is_special_key(key): - # self._keyconfig.get_command does this, but we also need it - # normalized for the output below - key = utils.normalize_keystr(key) with self._handle_config_error(): - cmd = self._keyconfig.get_command(key, mode) + cmd = self._keyconfig.get_command(seq, mode) if cmd is None: - message.info("{} is unbound in {} mode".format(key, mode)) + message.info("{} is unbound in {} mode".format(seq, mode)) else: message.info("{} is bound to '{}' in {} mode".format( - key, cmd, mode)) + seq, cmd, mode)) return with self._handle_config_error(): - self._keyconfig.bind(key, command, mode=mode, save_yaml=True) + self._keyconfig.bind(seq, command, mode=mode, save_yaml=True) @cmdutils.register(instance='config-commands') def unbind(self, key, *, mode='normal'): """Unbind a keychain. Args: - key: The keychain or special key (inside <...>) to unbind. + key: The keychain to unbind. See the help for `:bind` for the + correct syntax for keychains. mode: A mode to unbind the key in (default: `normal`). See `:help bindings.commands` for the available modes. """ with self._handle_config_error(): - self._keyconfig.unbind(key, mode=mode, save_yaml=True) + self._keyconfig.unbind(self._parse_key(key), mode=mode, + save_yaml=True) @cmdutils.register(instance='config-commands', star_args_optional=True) @cmdutils.argument('option', completion=configmodel.option) diff --git a/qutebrowser/config/configdata.yml b/qutebrowser/config/configdata.yml index d23682db8..dc730bcb3 100644 --- a/qutebrowser/config/configdata.yml +++ b/qutebrowser/config/configdata.yml @@ -2379,8 +2379,6 @@ bindings.default: : leave-mode prompt: : prompt-accept - y: prompt-accept yes - n: prompt-accept no : prompt-open-download : prompt-item-focus prev : prompt-item-focus prev @@ -2403,6 +2401,13 @@ bindings.default: : rl-backward-delete-char : rl-yank : leave-mode + yesno: + : prompt-accept + y: prompt-accept yes + n: prompt-accept no + : prompt-yank + : prompt-yank --sel + : leave-mode caret: v: toggle-selection : toggle-selection @@ -2438,7 +2443,7 @@ bindings.default: none_ok: true keytype: String # section name fixed_keys: ['normal', 'insert', 'hint', 'passthrough', 'command', - 'prompt', 'caret', 'register'] + 'prompt', 'yesno', 'caret', 'register'] valtype: name: Dict none_ok: true @@ -2462,7 +2467,7 @@ bindings.commands: none_ok: true keytype: String # section name fixed_keys: ['normal', 'insert', 'hint', 'passthrough', 'command', - 'prompt', 'caret', 'register'] + 'prompt', 'yesno', 'caret', 'register'] valtype: name: Dict none_ok: true @@ -2485,7 +2490,6 @@ bindings.commands: If you want to map a key to another key, check the `bindings.key_mappings` setting instead. - For special keys (can't be part of a keychain), enclose them in `<`...`>`. For modifiers, you can use either `-` or `+` as delimiters, and these names: @@ -2534,10 +2538,8 @@ bindings.commands: * prompt: Entered when there's a prompt to display, like for download locations or when invoked from JavaScript. - + - You can bind normal keys in this mode, but they will be only active when - a yes/no-prompt is asked. For other prompt modes, you can only bind - special keys. + + * yesno: Entered when there's a yes/no prompt displayed. * caret: Entered when pressing the `v` mode, used to select text using the keyboard. diff --git a/qutebrowser/config/configfiles.py b/qutebrowser/config/configfiles.py index ba43e5015..05ed23e60 100644 --- a/qutebrowser/config/configfiles.py +++ b/qutebrowser/config/configfiles.py @@ -33,6 +33,7 @@ from PyQt5.QtCore import pyqtSignal, QObject, QSettings import qutebrowser from qutebrowser.config import configexc, config, configdata, configutils +from qutebrowser.keyinput import keyutils from qutebrowser.utils import standarddir, utils, qtutils, log, urlmatch @@ -332,6 +333,9 @@ class ConfigAPI: except urlmatch.ParseError as e: text = "While {} '{}' and parsing pattern".format(action, name) self.errors.append(configexc.ConfigErrorDesc(text, e)) + except keyutils.KeyParseError as e: + text = "While {} '{}' and parsing key".format(action, name) + self.errors.append(configexc.ConfigErrorDesc(text, e)) def finalize(self): """Do work which needs to be done after reading config.py.""" @@ -357,12 +361,14 @@ class ConfigAPI: def bind(self, key, command, mode='normal'): """Bind a key to a command, with an optional key mode.""" with self._handle_error('binding', key): - self._keyconfig.bind(key, command, mode=mode) + seq = keyutils.KeySequence.parse(key) + self._keyconfig.bind(seq, command, mode=mode) def unbind(self, key, mode='normal'): """Unbind a key from a command, with an optional key mode.""" with self._handle_error('unbinding', key): - self._keyconfig.unbind(key, mode=mode) + seq = keyutils.KeySequence.parse(key) + self._keyconfig.unbind(seq, mode=mode) def source(self, filename): """Read the given config file from disk.""" diff --git a/qutebrowser/config/configtypes.py b/qutebrowser/config/configtypes.py index 26998d510..14855bf03 100644 --- a/qutebrowser/config/configtypes.py +++ b/qutebrowser/config/configtypes.py @@ -62,6 +62,7 @@ from PyQt5.QtWidgets import QTabWidget, QTabBar from qutebrowser.commands import cmdutils from qutebrowser.config import configexc from qutebrowser.utils import standarddir, utils, qtutils, urlutils +from qutebrowser.keyinput import keyutils SYSTEM_PROXY = object() # Return value for Proxy type @@ -1651,6 +1652,8 @@ class Key(BaseType): self._basic_py_validation(value, str) if not value: return None - if utils.is_special_key(value): - value = '<{}>'.format(utils.normalize_keystr(value[1:-1])) - return value + + try: + return keyutils.KeySequence.parse(value) + except keyutils.KeyParseError as e: + raise configexc.ValidationError(value, str(e)) diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 89120f922..901e96b55 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 (). _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,34 @@ class BaseKeyParser(QObject): if self.do_log: log.keyboard.debug(message) - def _handle_special_key(self, e): - """Handle a new keypress with special keys (). - - Return True if the keypress has been handled, and False if not. + def _match_key(self, sequence): + """Try to match a given keystring with any bound keychain. Args: - e: the KeyPressEvent from Qt. + sequence: The command string to find. Return: - True if event has been handled, False otherwise. + 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. """ - binding = utils.keyevent_to_string(e) - if binding is None: - self._debug_log("Ignoring only-modifier keyeevent.") - return False + assert sequence + assert not isinstance(sequence, str) + result = QKeySequence.NoMatch - 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 + 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 - 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 + return (result, None) - def _split_count(self, keystring): - """Get count and command from the current keystring. - - Args: - keystring: The key string to split. - - Return: - A (count, command) tuple. - """ - 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). + def handle(self, e): + """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 @@ -172,108 +125,52 @@ class BaseKeyParser(QObject): e: the KeyPressEvent from Qt. Return: - A self.Match member. + A QKeySequence match. """ - txt = e.text() key = e.key() + txt = str(keyutils.KeyInfo.from_event(e)) self._debug_log("Got key: 0x{:x} / text: '{}'".format(key, txt)) - if len(txt) == 1: - category = unicodedata.category(txt) - is_control_char = (category == 'Cc') - else: - is_control_char = False + if keyutils.is_modifier_key(key): + self._debug_log("Ignoring, only modifier") + return QKeySequence.NoMatch - if (not txt) or is_control_char: - self._debug_log("Ignoring, no text char") - return self.Match.none + if (txt.isdigit() and self._supports_count and not + (not self._count and txt == '0')): + assert len(txt) == 1, txt + self._count += txt + return QKeySequence.ExactMatch - count, cmd_input = self._split_count(self._keystring + txt) - match, binding = self._match_key(cmd_input) - if match == self.Match.none: + self._sequence = self._sequence.append_event(e) + match, binding = self._match_key(self._sequence) + if match == QKeySequence.NoMatch: mappings = config.val.bindings.key_mappings - mapped = mappings.get(txt, None) + mapped = mappings.get(self._sequence, 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._debug_log("Mapped {} -> {}".format( + self._sequence, mapped)) + match, binding = self._match_key(mapped) + self._sequence = mapped - self._keystring += txt - if match == self.Match.definitive: + if match == QKeySequence.ExactMatch: self._debug_log("Definitive match for '{}'.".format( - self._keystring)) + self._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: + self._sequence, txt)) + self.keystring_updated.emit(self._count + str(self._sequence)) + elif match == QKeySequence.NoMatch: self._debug_log("Giving up with '{}', no matches".format( - self._keystring)) + self._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 - @config.change_filter('bindings') def _on_config_changed(self): self._read_config() @@ -295,37 +192,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 index 8ae27412e..0ce123bfc 100644 --- a/qutebrowser/keyinput/keyparser.py +++ b/qutebrowser/keyinput/keyparser.py @@ -34,12 +34,11 @@ class CommandKeyParser(BaseKeyParser): _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) + 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, _keytype, count=None): + def execute(self, cmdstr, count=None): try: self._commandrunner.run(cmdstr, count) except cmdexc.Error as e: @@ -59,7 +58,7 @@ class PassthroughKeyParser(CommandKeyParser): do_log = False passthrough = True - def __init__(self, win_id, mode, parent=None, warn=True): + def __init__(self, win_id, mode, parent=None): """Constructor. Args: @@ -67,11 +66,9 @@ class PassthroughKeyParser(CommandKeyParser): 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 + super().__init__(win_id, parent) self._read_config(mode) self._mode = mode def __repr__(self): - return utils.get_repr(self, mode=self._mode, - warn=self._warn_on_keychains) + return utils.get_repr(self, mode=self._mode) diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py new file mode 100644 index 000000000..9a2c31572 --- /dev/null +++ b/qutebrowser/keyinput/keyutils.py @@ -0,0 +1,455 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2014-2018 Florian Bruhin (The Compiler) +# +# 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 . + +"""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 is_printable(key): + return key <= 0xff and key != Qt.Key_Space + + +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. + """ + return key in _MODIFIER_MAP + + +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. + """ + 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 + + if key in special_names: + return special_names[key] + + return QKeySequence(key).toString() + + +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']: + 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. + modifiers &= ~_MODIFIER_MAP[self.key] + elif is_printable(self.key): + # "normal" binding + assert len(key_string) == 1, key_string + if self.modifiers == Qt.ShiftModifier: + return key_string.upper() + elif self.modifiers == Qt.NoModifier: + return key_string.lower() + else: + # Use special binding syntax, but instead of + key_string = key_string.lower() + + # "special" binding + modifier_string = QKeySequence(modifiers).toString() + 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()) + + +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.""" + modifier_mask = int(Qt.ShiftModifier | Qt.ControlModifier | + Qt.AltModifier | Qt.MetaModifier | + Qt.KeypadModifier | Qt.GroupSwitchModifier) + for key in self._iter_keys(): + yield KeyInfo( + key=int(key) & ~modifier_mask, + modifiers=Qt.KeyboardModifiers(int(key) & modifier_mask)) + + 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: + assert Qt.Key_Space <= info.key <= Qt.Key_unknown, info.key + if info.key == Qt.Key_unknown: + raise KeyParseError(keystr, "Got unknown 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. + + We need to do some sophisticated checking of modifiers here: + + 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. + + We also change Qt.Key_Backtab to Key_Tab here because nobody would + configure "Shift-Backtab" in their config. + """ + key = ev.key() + modifiers = ev.modifiers() + + if modifiers & Qt.ShiftModifier and key == Qt.Key_Backtab: + key = Qt.Key_Tab + + if (modifiers == Qt.ShiftModifier and + is_printable(ev.key()) and + not ev.text().isupper()): + modifiers = Qt.KeyboardModifiers() + + keys = list(self._iter_keys()) + keys.append(key | int(modifiers)) + + return self.__class__(*keys) + + @classmethod + def parse(cls, keystr): + """Parse a keystring like 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 e32830f50..94d76832d 100644 --- a/qutebrowser/keyinput/modeman.py +++ b/qutebrowser/keyinput/modeman.py @@ -71,8 +71,7 @@ def init(win_id, parent): 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.prompt: keyparser.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, @@ -158,7 +157,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) is_non_alnum = ( event.modifiers() not in [Qt.NoModifier, Qt.ShiftModifier] or @@ -166,7 +165,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,10 +178,10 @@ class ModeManager(QObject): if curmode != usertypes.KeyMode.insert: focus_widget = QApplication.instance().focusWidget() - log.modes.debug("handled: {}, forward_unbound_keys: {}, " + log.modes.debug("match: {}, forward_unbound_keys: {}, " "passthrough: {}, is_non_alnum: {} --> " "filter: {} (focused: {!r})".format( - handled, forward_unbound_keys, + match, forward_unbound_keys, parser.passthrough, is_non_alnum, filter_this, focus_widget)) return filter_this diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index b739d38a1..169232e01 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.config import config -from qutebrowser.keyinput import keyparser +from qutebrowser.keyinput import keyparser, keyutils from qutebrowser.utils import usertypes, log, message, objreg, utils @@ -47,8 +48,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,8 +59,8 @@ 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): + """Override to abort if the key is a startchar. Args: e: the KeyPressEvent from Qt. @@ -72,9 +72,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) + + if match == QKeySequence.PartialMatch: timeout = config.val.input.partial_timeout if timeout != 0: self._partial_timer.setInterval(timeout) @@ -96,9 +98,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): @@ -128,11 +130,8 @@ class PromptKeyParser(keyparser.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) @@ -148,15 +147,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,41 +162,41 @@ 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): """Handle a new keypress and call the respective handlers. @@ -209,33 +207,18 @@ class HintKeyParser(keyparser.CommandKeyParser): 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) + 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 +228,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 = '' @@ -264,8 +249,7 @@ class CaretKeyParser(keyparser.CommandKeyParser): 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') @@ -279,8 +263,7 @@ 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') @@ -293,15 +276,16 @@ class RegisterKeyParser(keyparser.CommandKeyParser): Return: True if event has been handled, False otherwise. """ - if super().handle(e): - return True + match = super().handle(e) + if match: + return match + + if not keyutils.is_printable(e.key()): + # this is not a proper register key, let it pass and keep going + return QKeySequence.NoMatch key = e.text() - if key == '' or utils.keyevent_to_string(e) is None: - # this is not a proper register key, let it pass and keep going - return False - tabbed_browser = objreg.get('tabbed-browser', scope='window', window=self._win_id) macro_recorder = objreg.get('macro-recorder') @@ -322,5 +306,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 diff --git a/qutebrowser/mainwindow/prompt.py b/qutebrowser/mainwindow/prompt.py index 931d32654..90415b261 100644 --- a/qutebrowser/mainwindow/prompt.py +++ b/qutebrowser/mainwindow/prompt.py @@ -507,8 +507,8 @@ class _BasePrompt(QWidget): self._key_grid = QGridLayout() self._key_grid.setVerticalSpacing(0) - # The bindings are all in the 'prompt' mode, even for yesno prompts - all_bindings = config.key_instance.get_reverse_bindings_for('prompt') + all_bindings = config.key_instance.get_reverse_bindings_for( + self.KEY_MODE.name) labels = [] for cmd, text in self._allowed_commands(): diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py index fad58da2d..11446aa40 100644 --- a/qutebrowser/misc/keyhintwidget.py +++ b/qutebrowser/misc/keyhintwidget.py @@ -34,6 +34,7 @@ from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt from qutebrowser.config import config from qutebrowser.utils import utils, usertypes from qutebrowser.commands import cmdutils +from qutebrowser.keyinput import keyutils class KeyHintView(QLabel): @@ -105,9 +106,8 @@ class KeyHintView(QLabel): bindings_dict = config.key_instance.get_bindings_for(modename) bindings = [(k, v) for (k, v) in sorted(bindings_dict.items()) - if k.startswith(prefix) and - not utils.is_special_key(k) and - not blacklisted(k) and + if keyutils.KeySequence.parse(prefix).matches(k) and + not blacklisted(str(k)) and (takes_count(v) or not countstr)] if not bindings: @@ -120,7 +120,7 @@ class KeyHintView(QLabel): suffix_color = html.escape(config.val.colors.keyhint.suffix.fg) text = '' - for key, cmd in bindings: + for seq, cmd in bindings: text += ( "" "{}" @@ -130,7 +130,7 @@ class KeyHintView(QLabel): ).format( html.escape(prefix), suffix_color, - html.escape(key[len(prefix):]), + html.escape(str(seq[len(prefix):])), html.escape(cmd) ) text = '{}
'.format(text) diff --git a/qutebrowser/misc/miscwidgets.py b/qutebrowser/misc/miscwidgets.py index 2e868e27c..0e3def2f9 100644 --- a/qutebrowser/misc/miscwidgets.py +++ b/qutebrowser/misc/miscwidgets.py @@ -293,8 +293,6 @@ class FullscreenNotification(QLabel): bindings = all_bindings.get('fullscreen --leave') if bindings: key = bindings[0] - if utils.is_special_key(key): - key = key.strip('<>').capitalize() self.setText("Press {} to exit fullscreen.".format(key)) else: self.setText("Page is now fullscreen.") diff --git a/qutebrowser/utils/utils.py b/qutebrowser/utils/utils.py index 079866920..f03d42844 100644 --- a/qutebrowser/utils/utils.py +++ b/qutebrowser/utils/utils.py @@ -26,7 +26,6 @@ import re import sys import enum import json -import collections import datetime import traceback import functools @@ -34,9 +33,8 @@ import contextlib import socket import shlex -import attr -from PyQt5.QtCore import Qt, QUrl -from PyQt5.QtGui import QKeySequence, QColor, QClipboard, QDesktopServices +from PyQt5.QtCore import QUrl +from PyQt5.QtGui import QColor, QClipboard, QDesktopServices from PyQt5.QtWidgets import QApplication import pkg_resources import yaml @@ -48,7 +46,7 @@ except ImportError: # pragma: no cover YAML_C_EXT = False import qutebrowser -from qutebrowser.utils import qtutils, log, debug +from qutebrowser.utils import qtutils, log fake_clipboard = None @@ -285,263 +283,6 @@ def format_size(size, base=1024, suffix=''): return '{:.02f}{}{}'.format(size, prefixes[-1], suffix) -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. - """ - 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. - 'Key_Blue': 'Blue', - 'Key_Calendar': 'Calendar', - 'Key_ChannelDown': 'Channel Down', - 'Key_ChannelUp': 'Channel Up', - 'Key_ContrastAdjust': 'Contrast Adjust', - 'Key_Dead_Abovedot': '˙', - 'Key_Dead_Abovering': '˚', - 'Key_Dead_Acute': '´', - 'Key_Dead_Belowdot': 'Belowdot', - 'Key_Dead_Breve': '˘', - 'Key_Dead_Caron': 'ˇ', - 'Key_Dead_Cedilla': '¸', - 'Key_Dead_Circumflex': '^', - 'Key_Dead_Diaeresis': '¨', - 'Key_Dead_Doubleacute': '˝', - 'Key_Dead_Grave': '`', - 'Key_Dead_Hook': 'Hook', - 'Key_Dead_Horn': 'Horn', - 'Key_Dead_Iota': 'Iota', - 'Key_Dead_Macron': '¯', - 'Key_Dead_Ogonek': '˛', - 'Key_Dead_Semivoiced_Sound': 'Semivoiced Sound', - 'Key_Dead_Tilde': '~', - 'Key_Dead_Voiced_Sound': 'Voiced Sound', - 'Key_Exit': 'Exit', - 'Key_Green': 'Green', - 'Key_Guide': 'Guide', - 'Key_Info': 'Info', - 'Key_LaunchG': 'LaunchG', - 'Key_LaunchH': 'LaunchH', - 'Key_MediaLast': 'MediaLast', - 'Key_Memo': 'Memo', - 'Key_MicMute': 'Mic Mute', - 'Key_Mode_switch': 'Mode switch', - 'Key_Multi_key': 'Multi key', - 'Key_PowerDown': 'Power Down', - 'Key_Red': 'Red', - 'Key_Settings': 'Settings', - 'Key_SingleCandidate': 'Single Candidate', - 'Key_ToDoList': 'Todo List', - 'Key_TouchpadOff': 'Touchpad Off', - 'Key_TouchpadOn': 'Touchpad On', - 'Key_TouchpadToggle': 'Touchpad toggle', - 'Key_Yellow': 'Yellow', - 'Key_Alt': 'Alt', - 'Key_AltGr': 'AltGr', - 'Key_Control': 'Control', - 'Key_Direction_L': 'Direction L', - 'Key_Direction_R': 'Direction R', - 'Key_Hyper_L': 'Hyper L', - 'Key_Hyper_R': 'Hyper R', - 'Key_Meta': 'Meta', - 'Key_Shift': 'Shift', - 'Key_Super_L': 'Super L', - 'Key_Super_R': 'Super R', - 'Key_unknown': 'Unknown', - } - # 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, k)] = v - except AttributeError: - pass - # Now we check if the key is any special one - if not, we use - # QKeySequence::toString. - try: - return special_names[key] - except KeyError: - name = QKeySequence(key).toString() - morphings = { - 'Backtab': 'Tab', - 'Esc': 'Escape', - } - if name in morphings: - return morphings[name] - else: - return name - - -def keyevent_to_string(e): - """Convert a QKeyEvent to a meaningful name. - - Args: - e: A QKeyEvent. - - Return: - A name of the key (combination) as a string or - None if only modifiers are pressed.. - """ - if is_mac: - # Qt swaps Ctrl/Meta on macOS, so we switch it back here so the user - # can use it in the config as expected. See: - # https://github.com/qutebrowser/qutebrowser/issues/110 - # http://doc.qt.io/qt-5.4/osx-issues.html#special-keys - modmask2str = collections.OrderedDict([ - (Qt.MetaModifier, 'Ctrl'), - (Qt.AltModifier, 'Alt'), - (Qt.ControlModifier, 'Meta'), - (Qt.ShiftModifier, 'Shift'), - ]) - else: - modmask2str = collections.OrderedDict([ - (Qt.ControlModifier, 'Ctrl'), - (Qt.AltModifier, 'Alt'), - (Qt.MetaModifier, 'Meta'), - (Qt.ShiftModifier, 'Shift'), - ]) - modifiers = (Qt.Key_Control, Qt.Key_Alt, Qt.Key_Shift, Qt.Key_Meta, - Qt.Key_AltGr, Qt.Key_Super_L, Qt.Key_Super_R, Qt.Key_Hyper_L, - Qt.Key_Hyper_R, Qt.Key_Direction_L, Qt.Key_Direction_R) - if e.key() in modifiers: - # Only modifier pressed - return None - mod = e.modifiers() - parts = [] - for (mask, s) in modmask2str.items(): - if mod & mask and s not in parts: - parts.append(s) - parts.append(key_to_string(e.key())) - return normalize_keystr('+'.join(parts)) - - -@attr.s(repr=False) -class KeyInfo: - - """Stores information about a key, like used in a QKeyEvent. - - Attributes: - key: Qt::Key - modifiers: Qt::KeyboardModifiers - text: str - """ - - key = attr.ib() - modifiers = attr.ib() - text = attr.ib() - - def __repr__(self): - if self.modifiers is None: - modifiers = None - else: - #modifiers = qflags_key(Qt, self.modifiers) - modifiers = hex(int(self.modifiers)) - return get_repr(self, constructor=True, - key=debug.qenum_key(Qt, self.key), - modifiers=modifiers, text=self.text) - - -class KeyParseError(Exception): - - """Raised by _parse_single_key/parse_keystring on parse errors.""" - - def __init__(self, keystr, error): - super().__init__("Could not parse {!r}: {}".format(keystr, error)) - - -def is_special_key(keystr): - """True if keystr is a 'special' keystring (e.g. or ).""" - return keystr.startswith('<') and keystr.endswith('>') - - -def _parse_single_key(keystr): - """Convert a single key string to a (Qt.Key, Qt.Modifiers, text) tuple.""" - if is_special_key(keystr): - # Special key - keystr = keystr[1:-1] - elif len(keystr) == 1: - # vim-like key - pass - else: - raise KeyParseError(keystr, "Expecting either a single key or a " - " like keybinding.") - - seq = QKeySequence(normalize_keystr(keystr), QKeySequence.PortableText) - if len(seq) != 1: - raise KeyParseError(keystr, "Got {} keys instead of 1.".format( - len(seq))) - result = seq[0] - - if result == Qt.Key_unknown: - raise KeyParseError(keystr, "Got unknown key.") - - modifier_mask = int(Qt.ShiftModifier | Qt.ControlModifier | - Qt.AltModifier | Qt.MetaModifier | Qt.KeypadModifier | - Qt.GroupSwitchModifier) - assert Qt.Key_unknown & ~modifier_mask == Qt.Key_unknown - - modifiers = result & modifier_mask - key = result & ~modifier_mask - - if len(keystr) == 1 and keystr.isupper(): - modifiers |= Qt.ShiftModifier - - assert key != 0, key - key = Qt.Key(key) - modifiers = Qt.KeyboardModifiers(modifiers) - - # Let's hope this is accurate... - if len(keystr) == 1 and not modifiers: - text = keystr - elif len(keystr) == 1 and modifiers == Qt.ShiftModifier: - text = keystr.upper() - else: - text = '' - - return KeyInfo(key, modifiers, text) - - -def parse_keystring(keystr): - """Parse a keystring like or xyz and return a KeyInfo list.""" - if is_special_key(keystr): - return [_parse_single_key(keystr)] - else: - return [_parse_single_key(char) for char in keystr] - - -def normalize_keystr(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'), - ) - for (orig, repl) in replacements: - keystr = keystr.replace(orig, repl) - for mod in ['ctrl', 'meta', 'alt', 'shift']: - keystr = keystr.replace(mod + '-', mod + '+') - return keystr - - class FakeIOStream(io.TextIOBase): """A fake file-like stream which calls a function for write-calls.""" @@ -915,3 +656,14 @@ def yaml_dump(data, f=None): return None else: return yaml_data.decode('utf-8') + + +def chunk(elems, n): + """Yield successive n-sized chunks from elems. + + If elems % n != 0, the last chunk will be smaller. + """ + if n < 1: + raise ValueError("n needs to be at least 1!") + for i in range(0, len(elems), n): + yield elems[i:i + n] diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index 09702eac4..32c5afc49 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -86,6 +86,8 @@ PERFECT_FILES = [ ('tests/unit/keyinput/test_basekeyparser.py', 'keyinput/basekeyparser.py'), + ('tests/unit/keyinput/test_keyutils.py', + 'keyinput/keyutils.py'), ('tests/unit/misc/test_autoupdate.py', 'misc/autoupdate.py'), diff --git a/scripts/keytester.py b/scripts/keytester.py index 80260f6bf..ee5eb347c 100644 --- a/scripts/keytester.py +++ b/scripts/keytester.py @@ -25,7 +25,7 @@ Use python3 -m scripts.keytester to launch it. from PyQt5.QtWidgets import QApplication, QWidget, QLabel, QHBoxLayout -from qutebrowser.utils import utils +from qutebrowser.keyinput import keyutils class KeyWidget(QWidget): @@ -41,7 +41,7 @@ class KeyWidget(QWidget): def keyPressEvent(self, e): """Show pressed keys.""" lines = [ - str(utils.keyevent_to_string(e)), + str(keyutils.KeyInfo.from_event(e)), '', 'key: 0x{:x}'.format(int(e.key())), 'modifiers: 0x{:x}'.format(int(e.modifiers())), diff --git a/tests/end2end/features/hints.feature b/tests/end2end/features/hints.feature index eb6a24df9..a2ac468d0 100644 --- a/tests/end2end/features/hints.feature +++ b/tests/end2end/features/hints.feature @@ -338,7 +338,7 @@ Feature: Using hints And I set hints.auto_follow to unique-match And I set hints.auto_follow_timeout to 0 And I hint with args "all" - And I press the keys "ten pos" + And I press the keys "ten p" Then data/numbers/11.txt should be loaded Scenario: Scattering is ignored with number hints diff --git a/tests/end2end/features/keyinput.feature b/tests/end2end/features/keyinput.feature index ee5b667e8..1337a48d3 100644 --- a/tests/end2end/features/keyinput.feature +++ b/tests/end2end/features/keyinput.feature @@ -33,14 +33,13 @@ Feature: Keyboard input Scenario: Forwarding special keys When I open data/keyinput/log.html And I set input.forward_unbound_keys to auto - And I press the key "x" - And I press the key "" + And I press the keys "," # Then the javascript message "key press: 112" should be logged And the javascript message "key release: 112" should be logged - # x - And the javascript message "key press: 88" should not be logged - And the javascript message "key release: 88" should not be logged + # , + And the javascript message "key press: 188" should not be logged + And the javascript message "key release: 188" should not be logged Scenario: Forwarding no keys When I open data/keyinput/log.html @@ -54,7 +53,7 @@ Feature: Keyboard input Scenario: :fake-key with an unparsable key When I run :fake-key - Then the error "Could not parse 'blub': Got unknown key." should be shown + Then the error "Could not parse '': Got unknown key!" should be shown Scenario: :fake-key sending key to the website When I open data/keyinput/log.html diff --git a/tests/helpers/fixtures.py b/tests/helpers/fixtures.py index 193a40a8a..d30514f83 100644 --- a/tests/helpers/fixtures.py +++ b/tests/helpers/fixtures.py @@ -35,8 +35,7 @@ import types import attr import pytest import py.path # pylint: disable=no-name-in-module -from PyQt5.QtCore import QEvent, QSize, Qt -from PyQt5.QtGui import QKeyEvent +from PyQt5.QtCore import QSize, Qt from PyQt5.QtWidgets import QWidget, QHBoxLayout, QVBoxLayout from PyQt5.QtNetwork import QNetworkCookieJar @@ -354,21 +353,6 @@ def webframe(webpage): return webpage.mainFrame() -@pytest.fixture -def fake_keyevent_factory(): - """Fixture that when called will return a mock instance of a QKeyEvent.""" - def fake_keyevent(key, modifiers=0, text='', typ=QEvent.KeyPress): - """Generate a new fake QKeyPressEvent.""" - evtmock = unittest.mock.create_autospec(QKeyEvent, instance=True) - evtmock.key.return_value = key - evtmock.modifiers.return_value = modifiers - evtmock.text.return_value = text - evtmock.type.return_value = typ - return evtmock - - return fake_keyevent - - @pytest.fixture def cookiejar_and_cache(stubs): """Fixture providing a fake cookie jar and cache.""" diff --git a/tests/unit/completion/test_models.py b/tests/unit/completion/test_models.py index 9e355289f..e9f8cab57 100644 --- a/tests/unit/completion/test_models.py +++ b/tests/unit/completion/test_models.py @@ -100,13 +100,13 @@ def configdata_stub(config_stub, monkeypatch, configdata_init): typ=configtypes.Dict( keytype=configtypes.String(), valtype=configtypes.Dict( - keytype=configtypes.String(), + keytype=configtypes.Key(), valtype=configtypes.Command(), ), ), default={ 'normal': collections.OrderedDict([ - ('', 'quit'), + ('', 'quit'), ('d', 'tab-close'), ]) }, @@ -118,13 +118,13 @@ def configdata_stub(config_stub, monkeypatch, configdata_init): typ=configtypes.Dict( keytype=configtypes.String(), valtype=configtypes.Dict( - keytype=configtypes.String(), + keytype=configtypes.Key(), valtype=configtypes.Command(), ), ), default={ 'normal': collections.OrderedDict([ - ('', 'quit'), + ('', 'quit'), ('ZQ', 'quit'), ('I', 'invalid'), ('d', 'scroll down'), @@ -223,7 +223,7 @@ def test_command_completion(qtmodeltester, cmdutils_stub, configdata_stub, "Commands": [ ('open', 'open a url', ''), ('q', "Alias for 'quit'", ''), - ('quit', 'quit qutebrowser', 'ZQ, '), + ('quit', 'quit qutebrowser', 'ZQ, '), ('tab-close', 'Close the current tab.', ''), ] }) @@ -248,7 +248,7 @@ def test_help_completion(qtmodeltester, cmdutils_stub, key_config_stub, _check_completions(model, { "Commands": [ (':open', 'open a url', ''), - (':quit', 'quit qutebrowser', 'ZQ, '), + (':quit', 'quit qutebrowser', 'ZQ, '), (':scroll', 'Scroll the current tab in the given direction.', ''), (':tab-close', 'Close the current tab.', ''), ], @@ -653,10 +653,10 @@ def test_setting_option_completion(qtmodeltester, config_stub, "Options": [ ('aliases', 'Aliases for commands.', '{"q": "quit"}'), ('bindings.commands', 'Default keybindings', ( - '{"normal": {"": "quit", "ZQ": "quit", ' + '{"normal": {"": "quit", "ZQ": "quit", ' '"I": "invalid", "d": "scroll down"}}')), ('bindings.default', 'Default keybindings', - '{"normal": {"": "quit", "d": "tab-close"}}'), + '{"normal": {"": "quit", "d": "tab-close"}}'), ('content.javascript.enabled', 'Enable/Disable JavaScript', 'true'), ] @@ -739,7 +739,7 @@ def test_bind_completion(qtmodeltester, cmdutils_stub, config_stub, "Commands": [ ('open', 'open a url', ''), ('q', "Alias for 'quit'", ''), - ('quit', 'quit qutebrowser', 'ZQ, '), + ('quit', 'quit qutebrowser', 'ZQ, '), ('scroll', 'Scroll the current tab in the given direction.', ''), ('tab-close', 'Close the current tab.', ''), ], @@ -759,7 +759,7 @@ def test_bind_completion_invalid(cmdutils_stub, config_stub, key_config_stub, "Commands": [ ('open', 'open a url', ''), ('q', "Alias for 'quit'", ''), - ('quit', 'quit qutebrowser', 'ZQ, '), + ('quit', 'quit qutebrowser', 'ZQ, '), ('scroll', 'Scroll the current tab in the given direction.', ''), ('tab-close', 'Close the current tab.', ''), ], @@ -778,7 +778,7 @@ def test_bind_completion_no_binding(qtmodeltester, cmdutils_stub, config_stub, "Commands": [ ('open', 'open a url', ''), ('q', "Alias for 'quit'", ''), - ('quit', 'quit qutebrowser', 'ZQ, '), + ('quit', 'quit qutebrowser', 'ZQ, '), ('scroll', 'Scroll the current tab in the given direction.', ''), ('tab-close', 'Close the current tab.', ''), ], @@ -800,7 +800,7 @@ def test_bind_completion_changed(cmdutils_stub, config_stub, key_config_stub, "Commands": [ ('open', 'open a url', ''), ('q', "Alias for 'quit'", ''), - ('quit', 'quit qutebrowser', 'ZQ, '), + ('quit', 'quit qutebrowser', 'ZQ, '), ('scroll', 'Scroll the current tab in the given direction.', ''), ('tab-close', 'Close the current tab.', ''), ], diff --git a/tests/unit/config/test_config.py b/tests/unit/config/test_config.py index 8a33cd393..40e82ba4b 100644 --- a/tests/unit/config/test_config.py +++ b/tests/unit/config/test_config.py @@ -18,7 +18,6 @@ """Tests for qutebrowser.config.config.""" -import copy import types import unittest.mock @@ -29,6 +28,7 @@ from PyQt5.QtGui import QColor from qutebrowser.config import config, configdata, configexc, configutils from qutebrowser.utils import usertypes, urlmatch from qutebrowser.misc import objects +from qutebrowser.keyinput import keyutils @pytest.fixture(autouse=True) @@ -38,6 +38,11 @@ def configdata_init(): configdata.init() +# Alias because we need this a lot in here. +def keyseq(s): + return keyutils.KeySequence.parse(s) + + class TestChangeFilter: @pytest.fixture(autouse=True) @@ -98,47 +103,60 @@ class TestKeyConfig: """Get a dict with no bindings.""" return {'normal': {}} - @pytest.mark.parametrize('key, expected', [ - ('A', 'A'), - ('', ''), - ]) - def test_prepare_valid(self, key_config_stub, key, expected): - """Make sure prepare normalizes the key.""" - assert key_config_stub._prepare(key, 'normal') == expected - - def test_prepare_invalid(self, key_config_stub): - """Make sure prepare checks the mode.""" + def test_validate_invalid_mode(self, key_config_stub): with pytest.raises(configexc.KeybindingError): - assert key_config_stub._prepare('x', 'abnormal') + assert key_config_stub._validate(keyseq('x'), 'abnormal') + + def test_validate_invalid_type(self, key_config_stub): + with pytest.raises(AssertionError): + assert key_config_stub._validate('x', 'normal') @pytest.mark.parametrize('commands, expected', [ # Unbinding default key - ({'a': None}, {'b': 'message-info bar'}), + ({'a': None}, {keyseq('b'): 'message-info bar'}), # Additional binding ({'c': 'message-info baz'}, - {'a': 'message-info foo', 'b': 'message-info bar', - 'c': 'message-info baz'}), + {keyseq('a'): 'message-info foo', + keyseq('b'): 'message-info bar', + keyseq('c'): 'message-info baz'}), # Unbinding unknown key - ({'x': None}, {'a': 'message-info foo', 'b': 'message-info bar'}), + ({'x': None}, {keyseq('a'): 'message-info foo', + keyseq('b'): 'message-info bar'}), ]) def test_get_bindings_for_and_get_command(self, key_config_stub, config_stub, commands, expected): - orig_default_bindings = {'normal': {'a': 'message-info foo', - 'b': 'message-info bar'}, - 'insert': {}, - 'hint': {}, - 'passthrough': {}, - 'command': {}, - 'prompt': {}, - 'caret': {}, - 'register': {}} - config_stub.val.bindings.default = copy.deepcopy(orig_default_bindings) + orig_default_bindings = { + 'normal': {'a': 'message-info foo', + 'b': 'message-info bar'}, + 'insert': {}, + 'hint': {}, + 'passthrough': {}, + 'command': {}, + 'prompt': {}, + 'caret': {}, + 'register': {}, + 'yesno': {} + } + expected_default_bindings = { + 'normal': {keyseq('a'): 'message-info foo', + keyseq('b'): 'message-info bar'}, + 'insert': {}, + 'hint': {}, + 'passthrough': {}, + 'command': {}, + 'prompt': {}, + 'caret': {}, + 'register': {}, + 'yesno': {} + } + + config_stub.val.bindings.default = orig_default_bindings config_stub.val.bindings.commands = {'normal': commands} bindings = key_config_stub.get_bindings_for('normal') # Make sure the code creates a copy and doesn't modify the setting - assert config_stub.val.bindings.default == orig_default_bindings + assert config_stub.val.bindings.default == expected_default_bindings assert bindings == expected for key, command in expected.items(): assert key_config_stub.get_command(key, 'normal') == command @@ -147,15 +165,18 @@ class TestKeyConfig: no_bindings): config_stub.val.bindings.default = no_bindings config_stub.val.bindings.commands = no_bindings - assert key_config_stub.get_command('foobar', 'normal') is None + command = key_config_stub.get_command(keyseq('foobar'), + 'normal') + assert command is None def test_get_command_default(self, key_config_stub, config_stub): config_stub.val.bindings.default = { 'normal': {'x': 'message-info default'}} config_stub.val.bindings.commands = { 'normal': {'x': 'message-info custom'}} - cmd = 'message-info default' - assert key_config_stub.get_command('x', 'normal', default=True) == cmd + command = key_config_stub.get_command(keyseq('x'), 'normal', + default=True) + assert command == 'message-info default' @pytest.mark.parametrize('bindings, expected', [ # Simple @@ -164,9 +185,9 @@ class TestKeyConfig: # Multiple bindings ({'a': 'message-info foo', 'b': 'message-info foo'}, {'message-info foo': ['b', 'a']}), - # With special keys (should be listed last and normalized) - ({'a': 'message-info foo', '': 'message-info foo'}, - {'message-info foo': ['a', '']}), + # With modifier keys (should be listed last and normalized) + ({'a': 'message-info foo', '': 'message-info foo'}, + {'message-info foo': ['a', '']}), # Chained command ({'a': 'message-info foo ;; message-info bar'}, {'message-info foo': ['a'], 'message-info bar': ['a']}), @@ -179,11 +200,14 @@ class TestKeyConfig: @pytest.mark.parametrize('key', ['a', '', 'b']) def test_bind_duplicate(self, key_config_stub, config_stub, key): + seq = keyseq(key) config_stub.val.bindings.default = {'normal': {'a': 'nop', '': 'nop'}} config_stub.val.bindings.commands = {'normal': {'b': 'nop'}} - key_config_stub.bind(key, 'message-info foo', mode='normal') - assert key_config_stub.get_command(key, 'normal') == 'message-info foo' + key_config_stub.bind(seq, 'message-info foo', mode='normal') + + command = key_config_stub.get_command(seq, 'normal') + assert command == 'message-info foo' @pytest.mark.parametrize('mode', ['normal', 'caret']) @pytest.mark.parametrize('command', [ @@ -194,13 +218,14 @@ class TestKeyConfig: mode, command): config_stub.val.bindings.default = no_bindings config_stub.val.bindings.commands = no_bindings + seq = keyseq('a') with qtbot.wait_signal(config_stub.changed): - key_config_stub.bind('a', command, mode=mode) + key_config_stub.bind(seq, command, mode=mode) - assert config_stub.val.bindings.commands[mode]['a'] == command - assert key_config_stub.get_bindings_for(mode)['a'] == command - assert key_config_stub.get_command('a', mode) == command + assert config_stub.val.bindings.commands[mode][seq] == command + assert key_config_stub.get_bindings_for(mode)[seq] == command + assert key_config_stub.get_command(seq, mode) == command def test_bind_mode_changing(self, key_config_stub, config_stub, no_bindings): @@ -210,7 +235,8 @@ class TestKeyConfig: """ config_stub.val.bindings.default = no_bindings config_stub.val.bindings.commands = no_bindings - key_config_stub.bind('a', 'set-cmd-text :nop ;; rl-beginning-of-line', + key_config_stub.bind(keyseq('a'), + 'set-cmd-text :nop ;; rl-beginning-of-line', mode='normal') def test_bind_default(self, key_config_stub, config_stub): @@ -219,11 +245,15 @@ class TestKeyConfig: bound_cmd = 'message-info bound' config_stub.val.bindings.default = {'normal': {'a': default_cmd}} config_stub.val.bindings.commands = {'normal': {'a': bound_cmd}} - assert key_config_stub.get_command('a', mode='normal') == bound_cmd + seq = keyseq('a') - key_config_stub.bind_default('a', mode='normal') + command = key_config_stub.get_command(seq, mode='normal') + assert command == bound_cmd - assert key_config_stub.get_command('a', mode='normal') == default_cmd + key_config_stub.bind_default(seq, mode='normal') + + command = key_config_stub.get_command(keyseq('a'), mode='normal') + assert command == default_cmd def test_bind_default_unbound(self, key_config_stub, config_stub, no_bindings): @@ -232,42 +262,51 @@ class TestKeyConfig: config_stub.val.bindings.commands = no_bindings with pytest.raises(configexc.KeybindingError, match="Can't find binding 'foobar' in normal mode"): - key_config_stub.bind_default('foobar', mode='normal') + key_config_stub.bind_default(keyseq('foobar'), mode='normal') - @pytest.mark.parametrize('key, normalized', [ - ('a', 'a'), # default bindings - ('b', 'b'), # custom bindings - ('', '') + @pytest.mark.parametrize('key', [ + 'a', # default bindings + 'b', # custom bindings + '', ]) @pytest.mark.parametrize('mode', ['normal', 'caret', 'prompt']) def test_unbind(self, key_config_stub, config_stub, qtbot, - key, normalized, mode): + key, mode): default_bindings = { 'normal': {'a': 'nop', '': 'nop'}, 'caret': {'a': 'nop', '': 'nop'}, # prompt: a mode which isn't in bindings.commands yet 'prompt': {'a': 'nop', 'b': 'nop', '': 'nop'}, } - old_default_bindings = copy.deepcopy(default_bindings) + expected_default_bindings = { + 'normal': {keyseq('a'): 'nop', keyseq(''): 'nop'}, + 'caret': {keyseq('a'): 'nop', keyseq(''): 'nop'}, + # prompt: a mode which isn't in bindings.commands yet + 'prompt': {keyseq('a'): 'nop', + keyseq('b'): 'nop', + keyseq(''): 'nop'}, + } + config_stub.val.bindings.default = default_bindings config_stub.val.bindings.commands = { 'normal': {'b': 'nop'}, 'caret': {'b': 'nop'}, } + seq = keyseq(key) with qtbot.wait_signal(config_stub.changed): - key_config_stub.unbind(key, mode=mode) + key_config_stub.unbind(seq, mode=mode) - assert key_config_stub.get_command(key, mode) is None + assert key_config_stub.get_command(seq, mode) is None mode_bindings = config_stub.val.bindings.commands[mode] if key == 'b' and mode != 'prompt': # Custom binding - assert normalized not in mode_bindings + assert seq not in mode_bindings else: default_bindings = config_stub.val.bindings.default - assert default_bindings[mode] == old_default_bindings[mode] - assert mode_bindings[normalized] is None + assert default_bindings[mode] == expected_default_bindings[mode] + assert mode_bindings[seq] is None def test_unbind_unbound(self, key_config_stub, config_stub, no_bindings): """Try unbinding a key which is not bound.""" @@ -275,7 +314,7 @@ class TestKeyConfig: config_stub.val.bindings.commands = no_bindings with pytest.raises(configexc.KeybindingError, match="Can't find binding 'foobar' in normal mode"): - key_config_stub.unbind('foobar', mode='normal') + key_config_stub.unbind(keyseq('foobar'), mode='normal') def test_unbound_twice(self, key_config_stub, config_stub, no_bindings): """Try unbinding an already-unbound default key. @@ -287,17 +326,18 @@ class TestKeyConfig: """ config_stub.val.bindings.default = {'normal': {'a': 'nop'}} config_stub.val.bindings.commands = no_bindings + seq = keyseq('a') - key_config_stub.unbind('a') - assert key_config_stub.get_command('a', mode='normal') is None - key_config_stub.unbind('a') - assert key_config_stub.get_command('a', mode='normal') is None + key_config_stub.unbind(seq) + assert key_config_stub.get_command(seq, mode='normal') is None + key_config_stub.unbind(seq) + assert key_config_stub.get_command(seq, mode='normal') is None def test_empty_command(self, key_config_stub): """Try binding a key to an empty command.""" message = "Can't add binding 'x' with empty command in normal mode" with pytest.raises(configexc.KeybindingError, match=message): - key_config_stub.bind('x', ' ', mode='normal') + key_config_stub.bind(keyseq('x'), ' ', mode='normal') class TestConfig: diff --git a/tests/unit/config/test_configcommands.py b/tests/unit/config/test_configcommands.py index cafc1ac31..a74b446d1 100644 --- a/tests/unit/config/test_configcommands.py +++ b/tests/unit/config/test_configcommands.py @@ -19,6 +19,7 @@ """Tests for qutebrowser.config.configcommands.""" import logging +import functools import unittest.mock import pytest @@ -27,9 +28,15 @@ from PyQt5.QtCore import QUrl from qutebrowser.config import configcommands, configutils from qutebrowser.commands import cmdexc from qutebrowser.utils import usertypes, urlmatch +from qutebrowser.keyinput import keyutils from qutebrowser.misc import objects +# Alias because we need this a lot in here. +def keyseq(s): + return keyutils.KeySequence.parse(s) + + @pytest.fixture def commands(config_stub, key_config_stub): return configcommands.ConfigCommands(config_stub, key_config_stub) @@ -415,7 +422,7 @@ class TestWritePy: def test_custom(self, commands, config_stub, key_config_stub, tmpdir): confpy = tmpdir / 'config.py' config_stub.val.content.javascript.enabled = True - key_config_stub.bind(',x', 'message-info foo', mode='normal') + key_config_stub.bind(keyseq(',x'), 'message-info foo', mode='normal') commands.config_write_py(str(confpy)) @@ -496,7 +503,7 @@ class TestBind: config_stub.val.bindings.commands = no_bindings commands.bind(0, 'a', command) - assert key_config_stub.get_command('a', 'normal') == command + assert key_config_stub.get_command(keyseq('a'), 'normal') == command yaml_bindings = yaml_value('bindings.commands')['normal'] assert yaml_bindings['a'] == command @@ -509,7 +516,7 @@ class TestBind: ('c', 'normal', "c is bound to 'message-info c' in normal mode"), # Special key ('', 'normal', - " is bound to 'message-info C-x' in normal mode"), + " is bound to 'message-info C-x' in normal mode"), # unbound ('x', 'normal', "x is unbound in normal mode"), # non-default mode @@ -537,23 +544,45 @@ class TestBind: msg = message_mock.getmsg(usertypes.MessageLevel.info) assert msg.text == expected - def test_bind_invalid_mode(self, commands): - """Run ':bind --mode=wrongmode a nop'. + @pytest.mark.parametrize('command, args, kwargs, expected', [ + # :bind --mode=wrongmode a nop + ('bind', ['a', 'nop'], {'mode': 'wrongmode'}, + 'Invalid mode wrongmode!'), + # :bind --mode=wrongmode a + ('bind', ['a'], {'mode': 'wrongmode'}, + 'Invalid mode wrongmode!'), + # :bind --default --mode=wrongmode a + ('bind', ['a'], {'mode': 'wrongmode', 'default': True}, + 'Invalid mode wrongmode!'), + # :bind --default foobar + ('bind', ['foobar'], {'default': True}, + "Can't find binding 'foobar' in normal mode"), + # :bind nop + ('bind', ['', 'nop'], {}, + "Could not parse '': Got unknown key!"), + # :unbind foobar + ('unbind', ['foobar'], {}, + "Can't find binding 'foobar' in normal mode"), + # :unbind --mode=wrongmode x + ('unbind', ['x'], {'mode': 'wrongmode'}, + 'Invalid mode wrongmode!'), + # :unbind + ('unbind', [''], {}, + "Could not parse '': Got unknown key!"), + ]) + def test_bind_invalid(self, commands, + command, args, kwargs, expected): + """Run various wrong :bind/:unbind invocations. Should show an error. """ - with pytest.raises(cmdexc.CommandError, - match='Invalid mode wrongmode!'): - commands.bind(0, 'a', 'nop', mode='wrongmode') + if command == 'bind': + func = functools.partial(commands.bind, 0) + elif command == 'unbind': + func = commands.unbind - def test_bind_print_invalid_mode(self, commands): - """Run ':bind --mode=wrongmode a'. - - Should show an error. - """ - with pytest.raises(cmdexc.CommandError, - match='Invalid mode wrongmode!'): - commands.bind(0, 'a', mode='wrongmode') + with pytest.raises(cmdexc.CommandError, match=expected): + func(*args, **kwargs) @pytest.mark.parametrize('key', ['a', 'b', '']) def test_bind_duplicate(self, commands, config_stub, key_config_stub, key): @@ -569,7 +598,8 @@ class TestBind: } commands.bind(0, key, 'message-info foo', mode='normal') - assert key_config_stub.get_command(key, 'normal') == 'message-info foo' + command = key_config_stub.get_command(keyseq(key), 'normal') + assert command == 'message-info foo' def test_bind_none(self, commands, config_stub): config_stub.val.bindings.commands = None @@ -581,23 +611,13 @@ class TestBind: bound_cmd = 'message-info bound' config_stub.val.bindings.default = {'normal': {'a': default_cmd}} config_stub.val.bindings.commands = {'normal': {'a': bound_cmd}} - assert key_config_stub.get_command('a', mode='normal') == bound_cmd + command = key_config_stub.get_command(keyseq('a'), mode='normal') + assert command == bound_cmd commands.bind(0, 'a', mode='normal', default=True) - assert key_config_stub.get_command('a', mode='normal') == default_cmd - - @pytest.mark.parametrize('key, mode, expected', [ - ('foobar', 'normal', "Can't find binding 'foobar' in normal mode"), - ('x', 'wrongmode', "Invalid mode wrongmode!"), - ]) - def test_bind_default_invalid(self, commands, key, mode, expected): - """Run ':bind --default foobar' / ':bind --default x wrongmode'. - - Should show an error. - """ - with pytest.raises(cmdexc.CommandError, match=expected): - commands.bind(0, key, mode=mode, default=True) + command = key_config_stub.get_command(keyseq('a'), mode='normal') + assert command == default_cmd def test_unbind_none(self, commands, config_stub): config_stub.val.bindings.commands = None @@ -607,7 +627,7 @@ class TestBind: ('a', 'a'), # default bindings ('b', 'b'), # custom bindings ('c', 'c'), # :bind then :unbind - ('', '') # normalized special binding + ('', '') # normalized special binding ]) def test_unbind(self, commands, key_config_stub, config_stub, yaml_value, key, normalized): @@ -624,7 +644,7 @@ class TestBind: commands.bind(0, key, 'nop') commands.unbind(key) - assert key_config_stub.get_command(key, 'normal') is None + assert key_config_stub.get_command(keyseq(key), 'normal') is None yaml_bindings = yaml_value('bindings.commands')['normal'] if key in 'bc': @@ -632,15 +652,3 @@ class TestBind: assert normalized not in yaml_bindings else: assert yaml_bindings[normalized] is None - - @pytest.mark.parametrize('key, mode, expected', [ - ('foobar', 'normal', "Can't find binding 'foobar' in normal mode"), - ('x', 'wrongmode', "Invalid mode wrongmode!"), - ]) - def test_unbind_invalid(self, commands, key, mode, expected): - """Run ':unbind foobar' / ':unbind x wrongmode'. - - Should show an error. - """ - with pytest.raises(cmdexc.CommandError, match=expected): - commands.unbind(key, mode=mode) diff --git a/tests/unit/config/test_configfiles.py b/tests/unit/config/test_configfiles.py index 728dbb794..37b565374 100644 --- a/tests/unit/config/test_configfiles.py +++ b/tests/unit/config/test_configfiles.py @@ -29,6 +29,7 @@ from PyQt5.QtCore import QSettings from qutebrowser.config import (config, configfiles, configexc, configdata, configtypes) from qutebrowser.utils import utils, usertypes, urlmatch +from qutebrowser.keyinput import keyutils @pytest.fixture(autouse=True) @@ -607,7 +608,7 @@ class TestConfigPy: @pytest.mark.parametrize('line, key, mode', [ ('config.unbind("o")', 'o', 'normal'), - ('config.unbind("y", mode="prompt")', 'y', 'prompt'), + ('config.unbind("y", mode="yesno")', 'y', 'yesno'), ]) def test_unbind(self, confpy, line, key, mode): confpy.write(line) @@ -699,6 +700,20 @@ class TestConfigPy: message = "'ConfigAPI' object has no attribute 'val'" assert str(error.exception) == message + @pytest.mark.parametrize('line', [ + 'config.bind("", "nop")', + 'config.bind("\U00010000", "nop")', + 'config.unbind("")', + 'config.unbind("\U00010000")', + ]) + def test_invalid_keys(self, confpy, line): + confpy.write(line) + error = confpy.read(error=True) + assert error.text.endswith("and parsing key") + assert isinstance(error.exception, keyutils.KeyParseError) + assert str(error.exception).startswith("Could not parse") + assert str(error.exception).endswith("Got unknown key!") + @pytest.mark.parametrize('line', ["c.foo = 42", "config.set('foo', 42)"]) def test_config_error(self, confpy, line): confpy.write(line) diff --git a/tests/unit/config/test_configtypes.py b/tests/unit/config/test_configtypes.py index 81a7d53e1..c64891e5a 100644 --- a/tests/unit/config/test_configtypes.py +++ b/tests/unit/config/test_configtypes.py @@ -37,6 +37,7 @@ from PyQt5.QtNetwork import QNetworkProxy from qutebrowser.config import configtypes, configexc from qutebrowser.utils import debug, utils, qtutils from qutebrowser.browser.network import pac +from qutebrowser.keyinput import keyutils from tests.helpers import utils as testutils @@ -2058,12 +2059,16 @@ class TestKey: return configtypes.Key @pytest.mark.parametrize('val, expected', [ - ('gC', 'gC'), - ('', '') + ('gC', keyutils.KeySequence.parse('gC')), + ('', keyutils.KeySequence.parse('')), ]) def test_to_py_valid(self, klass, val, expected): assert klass().to_py(val) == expected + def test_to_py_invalid(self, klass): + with pytest.raises(configexc.ValidationError): + klass().to_py('\U00010000') + @pytest.mark.parametrize('first, second, equal', [ (re.compile('foo'), RegexEq('foo'), True), diff --git a/tests/unit/keyinput/conftest.py b/tests/unit/keyinput/conftest.py index 0713c5d26..96d07f744 100644 --- a/tests/unit/keyinput/conftest.py +++ b/tests/unit/keyinput/conftest.py @@ -21,19 +21,23 @@ import pytest +from PyQt5.QtCore import QEvent, Qt +from PyQt5.QtGui import QKeyEvent + +from qutebrowser.keyinput import keyutils + BINDINGS = {'prompt': {'': 'message-info ctrla', 'a': 'message-info a', 'ba': 'message-info ba', 'ax': 'message-info ax', 'ccc': 'message-info ccc', + 'yY': 'yank -s', '0': 'message-info 0'}, 'command': {'foo': 'message-info bar', '': 'message-info ctrlx'}, 'normal': {'a': 'message-info a', 'ba': 'message-info ba'}} MAPPINGS = { - '': 'a', - '': '', 'x': 'a', 'b': 'a', } @@ -45,3 +49,14 @@ def keyinput_bindings(config_stub, key_config_stub): config_stub.val.bindings.default = {} config_stub.val.bindings.commands = dict(BINDINGS) config_stub.val.bindings.key_mappings = dict(MAPPINGS) + + +@pytest.fixture +def fake_keyevent(): + """Fixture that when called will return a mock instance of a QKeyEvent.""" + def func(key, modifiers=Qt.NoModifier, typ=QEvent.KeyPress): + """Generate a new fake QKeyPressEvent.""" + text = keyutils.KeyInfo(key, modifiers).text() + return QKeyEvent(QKeyEvent.KeyPress, key, modifiers, text) + + return func diff --git a/tests/unit/keyinput/key_data.py b/tests/unit/keyinput/key_data.py new file mode 100644 index 000000000..2f13c8b5e --- /dev/null +++ b/tests/unit/keyinput/key_data.py @@ -0,0 +1,588 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2018 Florian Bruhin (The Compiler) +# +# 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 . + +# pylint: disable=line-too-long + + +"""Data used by test_keyutils.py to test all keys.""" + + +import attr +from PyQt5.QtCore import Qt + + +@attr.s +class Key: + + """A key with expected values. + + Attributes: + attribute: The name of the Qt::Key attribute ('Foo' -> Qt.Key_Foo) + name: The name returned by str(KeyInfo) with that key. + text: The text returned by KeyInfo.text(). + uppertext: The text returned by KeyInfo.text() with shift. + member: Filled by the test fixture, the numeric value. + """ + + attribute = attr.ib() + name = attr.ib(None) + text = attr.ib('') + uppertext = attr.ib('') + member = attr.ib(None) + qtest = attr.ib(True) + + def __attrs_post_init__(self): + self.member = getattr(Qt, 'Key_' + self.attribute, None) + if self.name is None: + self.name = self.attribute + + +# From enum Key in qt5/qtbase/src/corelib/global/qnamespace.h +KEYS = [ + ### misc keys + Key('Escape', text='\x1b', uppertext='\x1b'), + Key('Tab', text='\t', uppertext='\t'), + Key('Backtab', qtest=False), # Qt assumes VT (vertical tab) + Key('Backspace', text='\b', uppertext='\b'), + Key('Return', text='\r', uppertext='\r'), + Key('Enter', text='\r', uppertext='\r'), + Key('Insert', 'Ins'), + Key('Delete', 'Del'), + Key('Pause'), + Key('Print'), # print screen + Key('SysReq'), + Key('Clear'), + ### cursor movement + Key('Home'), + Key('End'), + Key('Left'), + Key('Up'), + Key('Right'), + Key('Down'), + Key('PageUp', 'PgUp'), + Key('PageDown', 'PgDown'), + ### modifiers + Key('Shift'), + Key('Control'), + Key('Meta'), + Key('Alt'), + Key('CapsLock'), + Key('NumLock'), + Key('ScrollLock'), + ### function keys + Key('F1'), + Key('F2'), + Key('F3'), + Key('F4'), + Key('F5'), + Key('F6'), + Key('F7'), + Key('F8'), + Key('F9'), + Key('F10'), + Key('F11'), + Key('F12'), + Key('F13'), + Key('F14'), + Key('F15'), + Key('F16'), + Key('F17'), + Key('F18'), + Key('F19'), + Key('F20'), + Key('F21'), + Key('F22'), + Key('F23'), + Key('F24'), + # F25 .. F35 only on X11 + Key('F25'), + Key('F26'), + Key('F27'), + Key('F28'), + Key('F29'), + Key('F30'), + Key('F31'), + Key('F32'), + Key('F33'), + Key('F34'), + Key('F35'), + ### extra keys + Key('Super_L', 'Super L'), + Key('Super_R', 'Super R'), + Key('Menu'), + Key('Hyper_L', 'Hyper L'), + Key('Hyper_R', 'Hyper R'), + Key('Help'), + Key('Direction_L', 'Direction L'), + Key('Direction_R', 'Direction R'), + ### 7 bit printable ASCII + Key('Space', text=' ', uppertext=' '), + Key('Any', 'Space', text=' ', uppertext=' '), # Same value + Key('Exclam', '!', text='!', uppertext='!'), + Key('QuoteDbl', '"', text='"', uppertext='"'), + Key('NumberSign', '#', text='#', uppertext='#'), + Key('Dollar', '$', text='$', uppertext='$'), + Key('Percent', '%', text='%', uppertext='%'), + Key('Ampersand', '&', text='&', uppertext='&'), + Key('Apostrophe', "'", text="'", uppertext="'"), + Key('ParenLeft', '(', text='(', uppertext='('), + Key('ParenRight', ')', text=')', uppertext=')'), + Key('Asterisk', '*', text='*', uppertext='*'), + Key('Plus', '+', text='+', uppertext='+'), + Key('Comma', ',', text=',', uppertext=','), + Key('Minus', '-', text='-', uppertext='-'), + Key('Period', '.', text='.', uppertext='.'), + Key('Slash', '/', text='/', uppertext='/'), + Key('0', text='0', uppertext='0'), + Key('1', text='1', uppertext='1'), + Key('2', text='2', uppertext='2'), + Key('3', text='3', uppertext='3'), + Key('4', text='4', uppertext='4'), + Key('5', text='5', uppertext='5'), + Key('6', text='6', uppertext='6'), + Key('7', text='7', uppertext='7'), + Key('8', text='8', uppertext='8'), + Key('9', text='9', uppertext='9'), + Key('Colon', ':', text=':', uppertext=':'), + Key('Semicolon', ';', text=';', uppertext=';'), + Key('Less', '<', text='<', uppertext='<'), + Key('Equal', '=', text='=', uppertext='='), + Key('Greater', '>', text='>', uppertext='>'), + Key('Question', '?', text='?', uppertext='?'), + Key('At', '@', text='@', uppertext='@'), + Key('A', text='a', uppertext='A'), + Key('B', text='b', uppertext='B'), + Key('C', text='c', uppertext='C'), + Key('D', text='d', uppertext='D'), + Key('E', text='e', uppertext='E'), + Key('F', text='f', uppertext='F'), + Key('G', text='g', uppertext='G'), + Key('H', text='h', uppertext='H'), + Key('I', text='i', uppertext='I'), + Key('J', text='j', uppertext='J'), + Key('K', text='k', uppertext='K'), + Key('L', text='l', uppertext='L'), + Key('M', text='m', uppertext='M'), + Key('N', text='n', uppertext='N'), + Key('O', text='o', uppertext='O'), + Key('P', text='p', uppertext='P'), + Key('Q', text='q', uppertext='Q'), + Key('R', text='r', uppertext='R'), + Key('S', text='s', uppertext='S'), + Key('T', text='t', uppertext='T'), + Key('U', text='u', uppertext='U'), + Key('V', text='v', uppertext='V'), + Key('W', text='w', uppertext='W'), + Key('X', text='x', uppertext='X'), + Key('Y', text='y', uppertext='Y'), + Key('Z', text='z', uppertext='Z'), + Key('BracketLeft', '[', text='[', uppertext='['), + Key('Backslash', '\\', text='\\', uppertext='\\'), + Key('BracketRight', ']', text=']', uppertext=']'), + Key('AsciiCircum', '^', text='^', uppertext='^'), + Key('Underscore', '_', text='_', uppertext='_'), + Key('QuoteLeft', '`', text='`', uppertext='`'), + Key('BraceLeft', '{', text='{', uppertext='{'), + Key('Bar', '|', text='|', uppertext='|'), + Key('BraceRight', '}', text='}', uppertext='}'), + Key('AsciiTilde', '~', text='~', uppertext='~'), + + Key('nobreakspace', ' ', text=' ', uppertext=' '), + Key('exclamdown', '¡', text='¡', uppertext='¡'), + Key('cent', '¢', text='¢', uppertext='¢'), + Key('sterling', '£', text='£', uppertext='£'), + Key('currency', '¤', text='¤', uppertext='¤'), + Key('yen', '¥', text='¥', uppertext='¥'), + Key('brokenbar', '¦', text='¦', uppertext='¦'), + Key('section', '§', text='§', uppertext='§'), + Key('diaeresis', '¨', text='¨', uppertext='¨'), + Key('copyright', '©', text='©', uppertext='©'), + Key('ordfeminine', 'ª', text='ª', uppertext='ª'), + Key('guillemotleft', '«', text='«', uppertext='«'), + Key('notsign', '¬', text='¬', uppertext='¬'), + Key('hyphen', '­', text='­', uppertext='­'), + Key('registered', '®', text='®', uppertext='®'), + Key('macron', '¯', text='¯', uppertext='¯'), + Key('degree', '°', text='°', uppertext='°'), + Key('plusminus', '±', text='±', uppertext='±'), + Key('twosuperior', '²', text='²', uppertext='²'), + Key('threesuperior', '³', text='³', uppertext='³'), + Key('acute', '´', text='´', uppertext='´'), + Key('mu', 'Μ', text='μ', uppertext='Μ', qtest=False), # Qt assumes U+00B5 instead of U+03BC + Key('paragraph', '¶', text='¶', uppertext='¶'), + Key('periodcentered', '·', text='·', uppertext='·'), + Key('cedilla', '¸', text='¸', uppertext='¸'), + Key('onesuperior', '¹', text='¹', uppertext='¹'), + Key('masculine', 'º', text='º', uppertext='º'), + Key('guillemotright', '»', text='»', uppertext='»'), + Key('onequarter', '¼', text='¼', uppertext='¼'), + Key('onehalf', '½', text='½', uppertext='½'), + Key('threequarters', '¾', text='¾', uppertext='¾'), + Key('questiondown', '¿', text='¿', uppertext='¿'), + Key('Agrave', 'À', text='à', uppertext='À'), + Key('Aacute', 'Á', text='á', uppertext='Á'), + Key('Acircumflex', 'Â', text='â', uppertext='Â'), + Key('Atilde', 'Ã', text='ã', uppertext='Ã'), + Key('Adiaeresis', 'Ä', text='ä', uppertext='Ä'), + Key('Aring', 'Å', text='å', uppertext='Å'), + Key('AE', 'Æ', text='æ', uppertext='Æ'), + Key('Ccedilla', 'Ç', text='ç', uppertext='Ç'), + Key('Egrave', 'È', text='è', uppertext='È'), + Key('Eacute', 'É', text='é', uppertext='É'), + Key('Ecircumflex', 'Ê', text='ê', uppertext='Ê'), + Key('Ediaeresis', 'Ë', text='ë', uppertext='Ë'), + Key('Igrave', 'Ì', text='ì', uppertext='Ì'), + Key('Iacute', 'Í', text='í', uppertext='Í'), + Key('Icircumflex', 'Î', text='î', uppertext='Î'), + Key('Idiaeresis', 'Ï', text='ï', uppertext='Ï'), + Key('ETH', 'Ð', text='ð', uppertext='Ð'), + Key('Ntilde', 'Ñ', text='ñ', uppertext='Ñ'), + Key('Ograve', 'Ò', text='ò', uppertext='Ò'), + Key('Oacute', 'Ó', text='ó', uppertext='Ó'), + Key('Ocircumflex', 'Ô', text='ô', uppertext='Ô'), + Key('Otilde', 'Õ', text='õ', uppertext='Õ'), + Key('Odiaeresis', 'Ö', text='ö', uppertext='Ö'), + Key('multiply', '×', text='×', uppertext='×'), + Key('Ooblique', 'Ø', text='ø', uppertext='Ø'), + Key('Ugrave', 'Ù', text='ù', uppertext='Ù'), + Key('Uacute', 'Ú', text='ú', uppertext='Ú'), + Key('Ucircumflex', 'Û', text='û', uppertext='Û'), + Key('Udiaeresis', 'Ü', text='ü', uppertext='Ü'), + Key('Yacute', 'Ý', text='ý', uppertext='Ý'), + Key('THORN', 'Þ', text='þ', uppertext='Þ'), + Key('ssharp', 'ß', text='ß', uppertext='ß'), + Key('division', '÷', text='÷', uppertext='÷'), + Key('ydiaeresis', 'Ÿ', text='ÿ', uppertext='Ÿ'), + + ### International input method support (X keycode - 0xEE00, the + ### definition follows Qt/Embedded 2.3.7) Only interesting if + ### you are writing your own input method + + ### International & multi-key character composition + Key('AltGr', qtest=False), + Key('Multi_key', 'Multi key', qtest=False), # Multi-key character compose + Key('Codeinput', 'Code input', qtest=False), + Key('SingleCandidate', 'Single Candidate', qtest=False), + Key('MultipleCandidate', 'Multiple Candidate', qtest=False), + Key('PreviousCandidate', 'Previous Candidate', qtest=False), + + ### Misc Functions + Key('Mode_switch', 'Mode switch', qtest=False), # Character set switch + # Key('script_switch'), # Alias for mode_switch + + ### Japanese keyboard support + Key('Kanji', qtest=False), # Kanji, Kanji convert + Key('Muhenkan', qtest=False), # Cancel Conversion + # Key('Henkan_Mode', qtest=False), # Start/Stop Conversion + Key('Henkan', qtest=False), # Alias for Henkan_Mode + Key('Romaji', qtest=False), # to Romaji + Key('Hiragana', qtest=False), # to Hiragana + Key('Katakana', qtest=False), # to Katakana + Key('Hiragana_Katakana', 'Hiragana Katakana', qtest=False), # Hiragana/Katakana toggle + Key('Zenkaku', qtest=False), # to Zenkaku + Key('Hankaku', qtest=False), # to Hankaku + Key('Zenkaku_Hankaku', 'Zenkaku Hankaku', qtest=False), # Zenkaku/Hankaku toggle + Key('Touroku', qtest=False), # Add to Dictionary + Key('Massyo', qtest=False), # Delete from Dictionary + Key('Kana_Lock', 'Kana Lock', qtest=False), + Key('Kana_Shift', 'Kana Shift', qtest=False), + Key('Eisu_Shift', 'Eisu Shift', qtest=False), # Alphanumeric Shift + Key('Eisu_toggle', 'Eisu toggle', qtest=False), # Alphanumeric toggle + # Key('Kanji_Bangou', qtest=False), # Codeinput + # Key('Zen_Koho', qtest=False), # Multiple/All Candidate(s) + # Key('Mae_Koho', qtest=False), # Previous Candidate + + ### Korean keyboard support + ### + ### In fact, many users from Korea need only 2 keys, Key_Hangul and + ### Key_Hangul_Hanja. But rest of the keys are good for future. + + Key('Hangul', qtest=False), # Hangul start/stop(toggle), + Key('Hangul_Start', 'Hangul Start', qtest=False), # Hangul start + Key('Hangul_End', 'Hangul End', qtest=False), # Hangul end, English start + Key('Hangul_Hanja', 'Hangul Hanja', qtest=False), # Start Hangul->Hanja Conversion + Key('Hangul_Jamo', 'Hangul Jamo', qtest=False), # Hangul Jamo mode + Key('Hangul_Romaja', 'Hangul Romaja', qtest=False), # Hangul Romaja mode + # Key('Hangul_Codeinput', 'Hangul Codeinput', qtest=False),# Hangul code input mode + Key('Hangul_Jeonja', 'Hangul Jeonja', qtest=False), # Jeonja mode + Key('Hangul_Banja', 'Hangul Banja', qtest=False), # Banja mode + Key('Hangul_PreHanja', 'Hangul PreHanja', qtest=False), # Pre Hanja conversion + Key('Hangul_PostHanja', 'Hangul PostHanja', qtest=False), # Post Hanja conversion + # Key('Hangul_SingleCandidate', 'Hangul SingleCandidate', qtest=False), # Single candidate + # Key('Hangul_MultipleCandidate', 'Hangul MultipleCandidate', qtest=False), # Multiple candidate + # Key('Hangul_PreviousCandidate', 'Hangul PreviousCandidate', qtest=False), # Previous candidate + Key('Hangul_Special', 'Hangul Special', qtest=False), # Special symbols + # Key('Hangul_switch', 'Hangul switch', qtest=False), # Alias for mode_switch + + # dead keys (X keycode - 0xED00 to avoid the conflict, qtest=False), + Key('Dead_Grave', '`', qtest=False), + Key('Dead_Acute', '´', qtest=False), + Key('Dead_Circumflex', '^', qtest=False), + Key('Dead_Tilde', '~', qtest=False), + Key('Dead_Macron', '¯', qtest=False), + Key('Dead_Breve', '˘', qtest=False), + Key('Dead_Abovedot', '˙', qtest=False), + Key('Dead_Diaeresis', '¨', qtest=False), + Key('Dead_Abovering', '˚', qtest=False), + Key('Dead_Doubleacute', '˝', qtest=False), + Key('Dead_Caron', 'ˇ', qtest=False), + Key('Dead_Cedilla', '¸', qtest=False), + Key('Dead_Ogonek', '˛', qtest=False), + Key('Dead_Iota', 'Iota', qtest=False), + Key('Dead_Voiced_Sound', 'Voiced Sound', qtest=False), + Key('Dead_Semivoiced_Sound', 'Semivoiced Sound', qtest=False), + Key('Dead_Belowdot', 'Belowdot', qtest=False), + Key('Dead_Hook', 'Hook', qtest=False), + Key('Dead_Horn', 'Horn', qtest=False), + + # Not in Qt 5.10, so data may be wrong! + Key('Dead_Stroke', qtest=False), + Key('Dead_Abovecomma', qtest=False), + Key('Dead_Abovereversedcomma', qtest=False), + Key('Dead_Doublegrave', qtest=False), + Key('Dead_Belowring', qtest=False), + Key('Dead_Belowmacron', qtest=False), + Key('Dead_Belowcircumflex', qtest=False), + Key('Dead_Belowtilde', qtest=False), + Key('Dead_Belowbreve', qtest=False), + Key('Dead_Belowdiaeresis', qtest=False), + Key('Dead_Invertedbreve', qtest=False), + Key('Dead_Belowcomma', qtest=False), + Key('Dead_Currency', qtest=False), + Key('Dead_a', qtest=False), + Key('Dead_A', qtest=False), + Key('Dead_e', qtest=False), + Key('Dead_E', qtest=False), + Key('Dead_i', qtest=False), + Key('Dead_I', qtest=False), + Key('Dead_o', qtest=False), + Key('Dead_O', qtest=False), + Key('Dead_u', qtest=False), + Key('Dead_U', qtest=False), + Key('Dead_Small_Schwa', qtest=False), + Key('Dead_Capital_Schwa', qtest=False), + Key('Dead_Greek', qtest=False), + Key('Dead_Lowline', qtest=False), + Key('Dead_Aboveverticalline', qtest=False), + Key('Dead_Belowverticalline', qtest=False), + Key('Dead_Longsolidusoverlay', qtest=False), + + ### multimedia/internet keys - ignored by default - see QKeyEvent c'tor + Key('Back'), + Key('Forward'), + Key('Stop'), + Key('Refresh'), + Key('VolumeDown', 'Volume Down'), + Key('VolumeMute', 'Volume Mute'), + Key('VolumeUp', 'Volume Up'), + Key('BassBoost', 'Bass Boost'), + Key('BassUp', 'Bass Up'), + Key('BassDown', 'Bass Down'), + Key('TrebleUp', 'Treble Up'), + Key('TrebleDown', 'Treble Down'), + Key('MediaPlay', 'Media Play'), + Key('MediaStop', 'Media Stop'), + Key('MediaPrevious', 'Media Previous'), + Key('MediaNext', 'Media Next'), + Key('MediaRecord', 'Media Record'), + Key('MediaPause', 'Media Pause', qtest=False), + Key('MediaTogglePlayPause', 'Toggle Media Play/Pause', qtest=False), + Key('HomePage', 'Home Page'), + Key('Favorites'), + Key('Search'), + Key('Standby'), + + Key('OpenUrl', 'Open URL'), + Key('LaunchMail', 'Launch Mail'), + Key('LaunchMedia', 'Launch Media'), + Key('Launch0', 'Launch (0)'), + Key('Launch1', 'Launch (1)'), + Key('Launch2', 'Launch (2)'), + Key('Launch3', 'Launch (3)'), + Key('Launch4', 'Launch (4)'), + Key('Launch5', 'Launch (5)'), + Key('Launch6', 'Launch (6)'), + Key('Launch7', 'Launch (7)'), + Key('Launch8', 'Launch (8)'), + Key('Launch9', 'Launch (9)'), + Key('LaunchA', 'Launch (A)'), + Key('LaunchB', 'Launch (B)'), + Key('LaunchC', 'Launch (C)'), + Key('LaunchD', 'Launch (D)'), + Key('LaunchE', 'Launch (E)'), + Key('LaunchF', 'Launch (F)'), + Key('MonBrightnessUp', 'Monitor Brightness Up', qtest=False), + Key('MonBrightnessDown', 'Monitor Brightness Down', qtest=False), + Key('KeyboardLightOnOff', 'Keyboard Light On/Off', qtest=False), + Key('KeyboardBrightnessUp', 'Keyboard Brightness Up', qtest=False), + Key('KeyboardBrightnessDown', 'Keyboard Brightness Down', qtest=False), + Key('PowerOff', 'Power Off', qtest=False), + Key('WakeUp', 'Wake Up', qtest=False), + Key('Eject', qtest=False), + Key('ScreenSaver', 'Screensaver', qtest=False), + Key('WWW', qtest=False), + Key('Memo', 'Memo', qtest=False), + Key('LightBulb', qtest=False), + Key('Shop', qtest=False), + Key('History', qtest=False), + Key('AddFavorite', 'Add Favorite', qtest=False), + Key('HotLinks', 'Hot Links', qtest=False), + Key('BrightnessAdjust', 'Adjust Brightness', qtest=False), + Key('Finance', qtest=False), + Key('Community', qtest=False), + Key('AudioRewind', 'Media Rewind', qtest=False), + Key('BackForward', 'Back Forward', qtest=False), + Key('ApplicationLeft', 'Application Left', qtest=False), + Key('ApplicationRight', 'Application Right', qtest=False), + Key('Book', qtest=False), + Key('CD', qtest=False), + Key('Calculator', qtest=False), + Key('ToDoList', 'To Do List', qtest=False), + Key('ClearGrab', 'Clear Grab', qtest=False), + Key('Close', qtest=False), + Key('Copy', qtest=False), + Key('Cut', qtest=False), + Key('Display', qtest=False), # Output switch key + Key('DOS', qtest=False), + Key('Documents', qtest=False), + Key('Excel', 'Spreadsheet', qtest=False), + Key('Explorer', 'Browser', qtest=False), + Key('Game', qtest=False), + Key('Go', qtest=False), + Key('iTouch', qtest=False), + Key('LogOff', 'Logoff', qtest=False), + Key('Market', qtest=False), + Key('Meeting', qtest=False), + Key('MenuKB', 'Keyboard Menu', qtest=False), + Key('MenuPB', 'Menu PB', qtest=False), + Key('MySites', 'My Sites', qtest=False), + Key('News', qtest=False), + Key('OfficeHome', 'Home Office', qtest=False), + Key('Option', qtest=False), + Key('Paste', qtest=False), + Key('Phone', qtest=False), + Key('Calendar', qtest=False), + Key('Reply', qtest=False), + Key('Reload', qtest=False), + Key('RotateWindows', 'Rotate Windows', qtest=False), + Key('RotationPB', 'Rotation PB', qtest=False), + Key('RotationKB', 'Rotation KB', qtest=False), + Key('Save', qtest=False), + Key('Send', qtest=False), + Key('Spell', 'Spellchecker', qtest=False), + Key('SplitScreen', 'Split Screen', qtest=False), + Key('Support', qtest=False), + Key('TaskPane', 'Task Panel', qtest=False), + Key('Terminal', qtest=False), + Key('Tools', qtest=False), + Key('Travel', qtest=False), + Key('Video', qtest=False), + Key('Word', 'Word Processor', qtest=False), + Key('Xfer', 'XFer', qtest=False), + Key('ZoomIn', 'Zoom In', qtest=False), + Key('ZoomOut', 'Zoom Out', qtest=False), + Key('Away', qtest=False), + Key('Messenger', qtest=False), + Key('WebCam', qtest=False), + Key('MailForward', 'Mail Forward', qtest=False), + Key('Pictures', qtest=False), + Key('Music', qtest=False), + Key('Battery', qtest=False), + Key('Bluetooth', qtest=False), + Key('WLAN', 'Wireless', qtest=False), + Key('UWB', 'Ultra Wide Band', qtest=False), + Key('AudioForward', 'Media Fast Forward', qtest=False), + Key('AudioRepeat', 'Audio Repeat', qtest=False), # Toggle repeat mode + Key('AudioRandomPlay', 'Audio Random Play', qtest=False), # Toggle shuffle mode + Key('Subtitle', qtest=False), + Key('AudioCycleTrack', 'Audio Cycle Track', qtest=False), + Key('Time', qtest=False), + Key('Hibernate', qtest=False), + Key('View', qtest=False), + Key('TopMenu', 'Top Menu', qtest=False), + Key('PowerDown', 'Power Down', qtest=False), + Key('Suspend', qtest=False), + Key('ContrastAdjust', 'Contrast Adjust', qtest=False), + + Key('LaunchG', 'Launch (G)', qtest=False), + Key('LaunchH', 'Launch (H)', qtest=False), + + Key('TouchpadToggle', 'Touchpad Toggle', qtest=False), + Key('TouchpadOn', 'Touchpad On', qtest=False), + Key('TouchpadOff', 'Touchpad Off', qtest=False), + + Key('MicMute', 'Microphone Mute', qtest=False), + + Key('Red', qtest=False), + Key('Green', qtest=False), + Key('Yellow', qtest=False), + Key('Blue', qtest=False), + + Key('ChannelUp', 'Channel Up', qtest=False), + Key('ChannelDown', 'Channel Down', qtest=False), + + Key('Guide', qtest=False), + Key('Info', qtest=False), + Key('Settings', qtest=False), + + Key('MicVolumeUp', 'Microphone Volume Up', qtest=False), + Key('MicVolumeDown', 'Microphone Volume Down', qtest=False), + + Key('New', qtest=False), + Key('Open', qtest=False), + Key('Find', qtest=False), + Key('Undo', qtest=False), + Key('Redo', qtest=False), + + Key('MediaLast', 'Media Last', qtest=False), + + ### Keypad navigation keys + Key('Select', qtest=False), + Key('Yes', qtest=False), + Key('No', qtest=False), + + ### Newer misc keys + Key('Cancel', qtest=False), + Key('Printer', qtest=False), + Key('Execute', qtest=False), + Key('Sleep', qtest=False), + Key('Play', qtest=False), # Not the same as Key_MediaPlay + Key('Zoom', qtest=False), + # Key('Jisho', qtest=False), # IME: Dictionary key + # Key('Oyayubi_Left', qtest=False), # IME: Left Oyayubi key + # Key('Oyayubi_Right', qtest=False), # IME: Right Oyayubi key + Key('Exit', qtest=False), + + # Device keys + Key('Context1', qtest=False), + Key('Context2', qtest=False), + Key('Context3', qtest=False), + Key('Context4', qtest=False), + Key('Call', qtest=False), # set absolute state to in a call (do not toggle state) + Key('Hangup', qtest=False), # set absolute state to hang up (do not toggle state) + Key('Flip', qtest=False), + Key('ToggleCallHangup', 'Toggle Call/Hangup', qtest=False), # a toggle key for answering, or hanging up, based on current call state + Key('VoiceDial', 'Voice Dial', qtest=False), + Key('LastNumberRedial', 'Last Number Redial', qtest=False), + + Key('Camera', 'Camera Shutter', qtest=False), + Key('CameraFocus', 'Camera Focus', qtest=False), + + Key('unknown', 'Unknown', qtest=False), +] diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py index 423076bdd..9ed996922 100644 --- a/tests/unit/keyinput/test_basekeyparser.py +++ b/tests/unit/keyinput/test_basekeyparser.py @@ -19,51 +19,41 @@ """Tests for BaseKeyParser.""" -import logging from unittest import mock from PyQt5.QtCore import Qt import pytest -from qutebrowser.keyinput import basekeyparser +from qutebrowser.keyinput import basekeyparser, keyutils from qutebrowser.utils import utils +# Alias because we need this a lot in here. +def keyseq(s): + return keyutils.KeySequence.parse(s) + + @pytest.fixture def keyparser(key_config_stub): """Fixture providing a BaseKeyParser supporting count/chains.""" - kp = basekeyparser.BaseKeyParser( - 0, supports_count=True, supports_chains=True) + kp = basekeyparser.BaseKeyParser(0, supports_count=True) kp.execute = mock.Mock() yield kp @pytest.fixture -def handle_text(fake_keyevent_factory, keyparser): +def handle_text(fake_keyevent, keyparser): """Helper function to handle multiple fake keypresses. Automatically uses the keyparser of the current test via the keyparser fixture. """ def func(*args): - for enumval, text in args: - keyparser.handle(fake_keyevent_factory(enumval, text=text)) + for enumval in args: + keyparser.handle(fake_keyevent(enumval)) return func -@pytest.mark.parametrize('count, chains, count_expected, chains_expected', [ - (True, False, True, False), - (False, True, False, True), - (None, True, True, True), -]) -def test_supports_args(config_stub, count, chains, count_expected, - chains_expected): - kp = basekeyparser.BaseKeyParser( - 0, supports_count=count, supports_chains=chains) - assert kp._supports_count == count_expected - assert kp._supports_chains == chains_expected - - class TestDebugLog: """Make sure _debug_log only logs when do_log is set.""" @@ -80,18 +70,25 @@ class TestDebugLog: assert not caplog.records -@pytest.mark.parametrize('input_key, supports_count, expected', [ +@pytest.mark.parametrize('input_key, supports_count, count, command', [ # (input_key, supports_count, expected) - ('10', True, (10, '')), - ('10foo', True, (10, 'foo')), - ('-1foo', True, (None, '-1foo')), - ('10e4foo', True, (10, 'e4foo')), - ('foo', True, (None, 'foo')), - ('10foo', False, (None, '10foo')), + ('10', True, '10', ''), + ('10g', True, '10', 'g'), + ('10e4g', True, '4', 'g'), + ('g', True, '', 'g'), + ('0', True, '', ''), + ('10g', False, '', 'g'), ]) -def test_split_count(config_stub, input_key, supports_count, expected): +def test_split_count(config_stub, key_config_stub, + input_key, supports_count, count, command): kp = basekeyparser.BaseKeyParser(0, supports_count=supports_count) - assert kp._split_count(input_key) == expected + kp._read_config('normal') + + for info in keyseq(input_key): + kp.handle(info.to_event()) + + assert kp._count == count + assert kp._sequence == keyseq(command) @pytest.mark.usefixtures('keyinput_bindings') @@ -106,18 +103,18 @@ class TestReadConfig: """Test reading config with _modename set.""" keyparser._modename = 'normal' keyparser._read_config() - assert 'a' in keyparser.bindings + assert keyseq('a') in keyparser.bindings def test_read_config_valid(self, keyparser): """Test reading config.""" keyparser._read_config('prompt') - assert 'ccc' in keyparser.bindings - assert 'ctrl+a' in keyparser.special_bindings + assert keyseq('ccc') in keyparser.bindings + assert keyseq('') in keyparser.bindings keyparser._read_config('command') - assert 'ccc' not in keyparser.bindings - assert 'ctrl+a' not in keyparser.special_bindings - assert 'foo' in keyparser.bindings - assert 'ctrl+x' in keyparser.special_bindings + assert keyseq('ccc') not in keyparser.bindings + assert keyseq('') not in keyparser.bindings + assert keyseq('foo') in keyparser.bindings + assert keyseq('') in keyparser.bindings def test_read_config_modename_none(self, keyparser): assert keyparser._modename is None @@ -134,140 +131,97 @@ class TestReadConfig: mode, changed_mode, expected): keyparser._read_config(mode) # Sanity checks - assert 'a' in keyparser.bindings - assert 'new' not in keyparser.bindings + assert keyseq('a') in keyparser.bindings + assert keyseq('new') not in keyparser.bindings - key_config_stub.bind('new', 'message-info new', mode=changed_mode) + key_config_stub.bind(keyseq('new'), 'message-info new', + mode=changed_mode) - assert 'a' in keyparser.bindings - assert ('new' in keyparser.bindings) == expected - - @pytest.mark.parametrize('warn_on_keychains', [True, False]) - def test_warn_on_keychains(self, caplog, warn_on_keychains): - """Test _warn_on_keychains.""" - kp = basekeyparser.BaseKeyParser( - 0, supports_count=False, supports_chains=False) - kp._warn_on_keychains = warn_on_keychains - - with caplog.at_level(logging.WARNING): - kp._read_config('normal') - - assert bool(caplog.records) == warn_on_keychains + assert keyseq('a') in keyparser.bindings + assert (keyseq('new') in keyparser.bindings) == expected -class TestSpecialKeys: - - """Check execute() with special keys.""" +class TestHandle: @pytest.fixture(autouse=True) def read_config(self, keyinput_bindings, keyparser): keyparser._read_config('prompt') - def test_valid_key(self, fake_keyevent_factory, keyparser): + def test_valid_key(self, fake_keyevent, keyparser): modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier - keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier)) - keyparser.handle(fake_keyevent_factory(Qt.Key_X, modifier)) - keyparser.execute.assert_called_once_with( - 'message-info ctrla', keyparser.Type.special, None) + keyparser.handle(fake_keyevent(Qt.Key_A, modifier)) + keyparser.handle(fake_keyevent(Qt.Key_X, modifier)) + keyparser.execute.assert_called_once_with('message-info ctrla', None) + assert not keyparser._sequence - def test_valid_key_count(self, fake_keyevent_factory, keyparser): + def test_valid_key_count(self, fake_keyevent, keyparser): modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier - keyparser.handle(fake_keyevent_factory(5, text='5')) - keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier, text='A')) - keyparser.execute.assert_called_once_with( - 'message-info ctrla', keyparser.Type.special, 5) + keyparser.handle(fake_keyevent(Qt.Key_5)) + keyparser.handle(fake_keyevent(Qt.Key_A, modifier)) + keyparser.execute.assert_called_once_with('message-info ctrla', 5) - def test_invalid_key(self, fake_keyevent_factory, keyparser): - keyparser.handle(fake_keyevent_factory( - Qt.Key_A, (Qt.ControlModifier | Qt.AltModifier))) + @pytest.mark.parametrize('keys', [ + [(Qt.Key_B, Qt.NoModifier), (Qt.Key_C, Qt.NoModifier)], + [(Qt.Key_A, Qt.ControlModifier | Qt.AltModifier)], + # Only modifier + [(Qt.Key_Shift, Qt.ShiftModifier)], + ]) + def test_invalid_keys(self, fake_keyevent, keyparser, keys): + for key, modifiers in keys: + keyparser.handle(fake_keyevent(key, modifiers)) assert not keyparser.execute.called - - def test_keychain(self, fake_keyevent_factory, keyparser): - keyparser.handle(fake_keyevent_factory(Qt.Key_B)) - keyparser.handle(fake_keyevent_factory(Qt.Key_A)) - assert not keyparser.execute.called - - def test_no_binding(self, monkeypatch, fake_keyevent_factory, keyparser): - monkeypatch.setattr(utils, 'keyevent_to_string', lambda binding: None) - keyparser.handle(fake_keyevent_factory(Qt.Key_A, Qt.NoModifier)) - assert not keyparser.execute.called - - def test_mapping(self, config_stub, fake_keyevent_factory, keyparser): - modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier - - keyparser.handle(fake_keyevent_factory(Qt.Key_B, modifier)) - keyparser.execute.assert_called_once_with( - 'message-info ctrla', keyparser.Type.special, None) - - def test_binding_and_mapping(self, config_stub, fake_keyevent_factory, - keyparser): - """with a conflicting binding/mapping, the binding should win.""" - modifier = Qt.MetaModifier if utils.is_mac else Qt.ControlModifier - - keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier)) - keyparser.execute.assert_called_once_with( - 'message-info ctrla', keyparser.Type.special, None) - - -class TestKeyChain: - - """Test execute() with keychain support.""" - - @pytest.fixture(autouse=True) - def read_config(self, keyinput_bindings, keyparser): - keyparser._read_config('prompt') - - def test_valid_special_key(self, fake_keyevent_factory, keyparser): - if utils.is_mac: - modifier = Qt.MetaModifier - else: - modifier = Qt.ControlModifier - keyparser.handle(fake_keyevent_factory(Qt.Key_A, modifier)) - keyparser.handle(fake_keyevent_factory(Qt.Key_X, modifier)) - keyparser.execute.assert_called_once_with( - 'message-info ctrla', keyparser.Type.special, None) - assert keyparser._keystring == '' - - def test_invalid_special_key(self, fake_keyevent_factory, keyparser): - keyparser.handle(fake_keyevent_factory( - Qt.Key_A, (Qt.ControlModifier | Qt.AltModifier))) - assert not keyparser.execute.called - assert keyparser._keystring == '' + assert not keyparser._sequence def test_valid_keychain(self, handle_text, keyparser): # Press 'x' which is ignored because of no match - handle_text((Qt.Key_X, 'x'), + handle_text(Qt.Key_X, # Then start the real chain - (Qt.Key_B, 'b'), (Qt.Key_A, 'a')) - keyparser.execute.assert_called_with( - 'message-info ba', keyparser.Type.chain, None) - assert keyparser._keystring == '' + Qt.Key_B, Qt.Key_A) + keyparser.execute.assert_called_with('message-info ba', None) + assert not keyparser._sequence def test_0_press(self, handle_text, keyparser): - handle_text((Qt.Key_0, '0')) - keyparser.execute.assert_called_once_with( - 'message-info 0', keyparser.Type.chain, None) - assert keyparser._keystring == '' + handle_text(Qt.Key_0) + keyparser.execute.assert_called_once_with('message-info 0', None) + assert not keyparser._sequence - def test_ambiguous_keychain(self, handle_text, keyparser): - handle_text((Qt.Key_A, 'a')) - assert keyparser.execute.called - - def test_invalid_keychain(self, handle_text, keyparser): - handle_text((Qt.Key_B, 'b')) - handle_text((Qt.Key_C, 'c')) - assert keyparser._keystring == '' + def test_umlauts(self, handle_text, keyparser, config_stub): + config_stub.val.bindings.commands = {'normal': {'ü': 'message-info ü'}} + keyparser._read_config('normal') + handle_text(Qt.Key_Udiaeresis) + keyparser.execute.assert_called_once_with('message-info ü', None) def test_mapping(self, config_stub, handle_text, keyparser): - handle_text((Qt.Key_X, 'x')) - keyparser.execute.assert_called_once_with( - 'message-info a', keyparser.Type.chain, None) + handle_text(Qt.Key_X) + keyparser.execute.assert_called_once_with('message-info a', None) def test_binding_and_mapping(self, config_stub, handle_text, keyparser): """with a conflicting binding/mapping, the binding should win.""" - handle_text((Qt.Key_B, 'b')) + handle_text(Qt.Key_B) assert not keyparser.execute.called + def test_binding_with_shift(self, keyparser, fake_keyevent): + """Simulate a binding which involves shift.""" + for key, modifiers in [(Qt.Key_Y, Qt.NoModifier), + (Qt.Key_Shift, Qt.ShiftModifier), + (Qt.Key_Y, Qt.ShiftModifier)]: + keyparser.handle(fake_keyevent(key, modifiers)) + + keyparser.execute.assert_called_once_with('yank -s', None) + + def test_partial_before_full_match(self, keyparser, fake_keyevent, + config_stub): + """Make sure full matches always take precedence over partial ones.""" + config_stub.val.bindings.commands = { + 'normal': { + 'ab': 'message-info bar', + 'a': 'message-info foo' + } + } + keyparser._read_config('normal') + keyparser.handle(fake_keyevent(Qt.Key_A)) + keyparser.execute.assert_called_once_with('message-info foo', None) + class TestCount: @@ -279,42 +233,45 @@ class TestCount: def test_no_count(self, handle_text, keyparser): """Test with no count added.""" - handle_text((Qt.Key_B, 'b'), (Qt.Key_A, 'a')) - keyparser.execute.assert_called_once_with( - 'message-info ba', keyparser.Type.chain, None) - assert keyparser._keystring == '' + handle_text(Qt.Key_B, Qt.Key_A) + keyparser.execute.assert_called_once_with('message-info ba', None) + assert not keyparser._sequence def test_count_0(self, handle_text, keyparser): - handle_text((Qt.Key_0, '0'), (Qt.Key_B, 'b'), (Qt.Key_A, 'a')) - calls = [mock.call('message-info 0', keyparser.Type.chain, None), - mock.call('message-info ba', keyparser.Type.chain, None)] + handle_text(Qt.Key_0, Qt.Key_B, Qt.Key_A) + calls = [mock.call('message-info 0', None), + mock.call('message-info ba', None)] keyparser.execute.assert_has_calls(calls) - assert keyparser._keystring == '' + assert not keyparser._sequence def test_count_42(self, handle_text, keyparser): - handle_text((Qt.Key_4, '4'), (Qt.Key_2, '2'), (Qt.Key_B, 'b'), - (Qt.Key_A, 'a')) - keyparser.execute.assert_called_once_with( - 'message-info ba', keyparser.Type.chain, 42) - assert keyparser._keystring == '' + handle_text(Qt.Key_4, Qt.Key_2, Qt.Key_B, Qt.Key_A) + keyparser.execute.assert_called_once_with('message-info ba', 42) + assert not keyparser._sequence def test_count_42_invalid(self, handle_text, keyparser): # Invalid call with ccx gets ignored - handle_text((Qt.Key_4, '4'), (Qt.Key_2, '2'), (Qt.Key_C, 'c'), - (Qt.Key_C, 'c'), (Qt.Key_X, 'x')) + handle_text(Qt.Key_4, Qt.Key_2, Qt.Key_C, Qt.Key_C, Qt.Key_X) assert not keyparser.execute.called - assert keyparser._keystring == '' + assert not keyparser._sequence # Valid call with ccc gets the correct count - handle_text((Qt.Key_6, '2'), (Qt.Key_2, '3'), (Qt.Key_C, 'c'), - (Qt.Key_C, 'c'), (Qt.Key_C, 'c')) - keyparser.execute.assert_called_once_with( - 'message-info ccc', keyparser.Type.chain, 23) - assert keyparser._keystring == '' + handle_text(Qt.Key_2, Qt.Key_3, Qt.Key_C, Qt.Key_C, Qt.Key_C) + keyparser.execute.assert_called_once_with('message-info ccc', 23) + assert not keyparser._sequence def test_clear_keystring(qtbot, keyparser): """Test that the keystring is cleared and the signal is emitted.""" - keyparser._keystring = 'test' + keyparser._sequence = keyseq('test') + keyparser._count = '23' with qtbot.waitSignal(keyparser.keystring_updated): keyparser.clear_keystring() - assert keyparser._keystring == '' + assert not keyparser._sequence + assert not keyparser._count + + +def test_clear_keystring_empty(qtbot, keyparser): + """Test that no signal is emitted when clearing an empty keystring..""" + keyparser._sequence = keyseq('') + with qtbot.assert_not_emitted(keyparser.keystring_updated): + keyparser.clear_keystring() diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py new file mode 100644 index 000000000..88710766b --- /dev/null +++ b/tests/unit/keyinput/test_keyutils.py @@ -0,0 +1,422 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2014-2018 Florian Bruhin (The Compiler) +# +# 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 . + +import operator + +import pytest +from PyQt5.QtCore import Qt, QEvent, pyqtSignal +from PyQt5.QtGui import QKeyEvent, QKeySequence +from PyQt5.QtWidgets import QWidget + +from tests.unit.keyinput import key_data +from qutebrowser.keyinput import keyutils + + +@pytest.fixture(params=key_data.KEYS, ids=lambda k: k.attribute) +def qt_key(request): + """Get all existing keys from key_data.py. + + Keys which don't exist with this Qt version result in skipped tests. + """ + key = request.param + if key.member is None: + pytest.skip("Did not find key {}".format(key.attribute)) + return key + + +@pytest.fixture(params=[key for key in key_data.KEYS if key.qtest], + ids=lambda k: k.attribute) +def qtest_key(request): + """Get keys from key_data.py which can be used with QTest.""" + return request.param + + +def test_key_data(): + """Make sure all possible keys are in key_data.KEYS.""" + key_names = {name[len("Key_"):] + for name, value in sorted(vars(Qt).items()) + if isinstance(value, Qt.Key)} + key_data_names = {key.attribute for key in sorted(key_data.KEYS)} + diff = key_names - key_data_names + assert not diff + + +class KeyTesterWidget(QWidget): + + """Widget to get the text of QKeyPressEvents. + + This is done so we can check QTest::keyToAscii (qasciikey.cpp) as we can't + call that directly, only via QTest::keyPress. + """ + + got_text = pyqtSignal() + + def __init__(self, parent=None): + super().__init__(parent) + self.text = None + + def keyPressEvent(self, e): + self.text = e.text() + self.got_text.emit() + + +class TestKeyInfoText: + + @pytest.mark.parametrize('upper', [False, True]) + def test_text(self, qt_key, upper): + """Test KeyInfo.text() with all possible keys. + + See key_data.py for inputs and expected values. + """ + modifiers = Qt.ShiftModifier if upper else Qt.KeyboardModifiers() + info = keyutils.KeyInfo(qt_key.member, modifiers=modifiers) + expected = qt_key.uppertext if upper else qt_key.text + assert info.text() == expected + + @pytest.fixture + def key_tester(self, qtbot): + w = KeyTesterWidget() + qtbot.add_widget(w) + return w + + def test_text_qtest(self, qtest_key, qtbot, key_tester): + """Make sure KeyInfo.text() lines up with QTest::keyToAscii. + + See key_data.py for inputs and expected values. + """ + with qtbot.wait_signal(key_tester.got_text): + qtbot.keyPress(key_tester, qtest_key.member) + + info = keyutils.KeyInfo(qtest_key.member, + modifiers=Qt.KeyboardModifiers()) + assert info.text() == key_tester.text.lower() + + +class TestKeyToString: + + def test_to_string(self, qt_key): + assert keyutils._key_to_string(qt_key.member) == qt_key.name + + def test_missing(self, monkeypatch): + monkeypatch.delattr(keyutils.Qt, 'Key_AltGr') + # We don't want to test the key which is actually missing - we only + # want to know if the mapping still behaves properly. + assert keyutils._key_to_string(Qt.Key_A) == 'A' + + +@pytest.mark.parametrize('key, modifiers, expected', [ + (Qt.Key_A, Qt.NoModifier, 'a'), + (Qt.Key_A, Qt.ShiftModifier, 'A'), + + (Qt.Key_Space, Qt.NoModifier, ''), + (Qt.Key_Space, Qt.ShiftModifier, ''), + (Qt.Key_Tab, Qt.ShiftModifier, ''), + (Qt.Key_A, Qt.ControlModifier, ''), + (Qt.Key_A, Qt.ControlModifier | Qt.ShiftModifier, ''), + (Qt.Key_A, + Qt.ControlModifier | Qt.AltModifier | Qt.MetaModifier | Qt.ShiftModifier, + ''), + + (Qt.Key_Shift, Qt.ShiftModifier, ''), + (Qt.Key_Shift, Qt.ShiftModifier | Qt.ControlModifier, ''), +]) +def test_key_info_str(key, modifiers, expected): + assert str(keyutils.KeyInfo(key, modifiers)) == expected + + +@pytest.mark.parametrize('keystr, expected', [ + ('foo', "Could not parse 'foo': error"), + (None, "Could not parse keystring: error"), +]) +def test_key_parse_error(keystr, expected): + exc = keyutils.KeyParseError(keystr, "error") + assert str(exc) == expected + + +@pytest.mark.parametrize('keystr, parts', [ + ('a', ['a']), + ('ab', ['a', 'b']), + ('a<', ['a', '<']), + ('a>', ['a', '>']), + ('a', ['>', 'a']), + ('aA', ['a', 'Shift+A']), + ('ab', ['a', 'ctrl+a', 'b']), + ('a', ['ctrl+a', 'a']), + ('a', ['a', 'ctrl+a']), +]) +def test_parse_keystr(keystr, parts): + assert list(keyutils._parse_keystring(keystr)) == parts + + +class TestKeySequence: + + def test_init(self): + seq = keyutils.KeySequence(Qt.Key_A, Qt.Key_B, Qt.Key_C, Qt.Key_D, + Qt.Key_E) + assert len(seq._sequences) == 2 + assert len(seq._sequences[0]) == 4 + assert len(seq._sequences[1]) == 1 + + def test_init_empty(self): + seq = keyutils.KeySequence() + assert not seq + + def test_init_unknown(self): + with pytest.raises(keyutils.KeyParseError): + keyutils.KeySequence(Qt.Key_unknown) + + def test_init_invalid(self): + with pytest.raises(AssertionError): + keyutils.KeySequence(-1) + + @pytest.mark.parametrize('orig, normalized', [ + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('', ''), + ('b', 'ab'), + ]) + def test_str_normalization(self, orig, normalized): + assert str(keyutils.KeySequence.parse(orig)) == normalized + + def test_iter(self): + seq = keyutils.KeySequence(Qt.Key_A | Qt.ControlModifier, + Qt.Key_B | Qt.ShiftModifier, + Qt.Key_C, + Qt.Key_D, + Qt.Key_E) + expected = [keyutils.KeyInfo(Qt.Key_A, Qt.ControlModifier), + keyutils.KeyInfo(Qt.Key_B, Qt.ShiftModifier), + keyutils.KeyInfo(Qt.Key_C, Qt.NoModifier), + keyutils.KeyInfo(Qt.Key_D, Qt.NoModifier), + keyutils.KeyInfo(Qt.Key_E, Qt.NoModifier)] + assert list(seq) == expected + + def test_repr(self): + seq = keyutils.KeySequence(Qt.Key_A | Qt.ControlModifier, + Qt.Key_B | Qt.ShiftModifier) + assert repr(seq) == ("") + + @pytest.mark.parametrize('sequences, expected', [ + (['a', ''], ['', 'a']), + (['abcdf', 'abcd', 'abcde'], ['abcd', 'abcde', 'abcdf']), + ]) + def test_sorting(self, sequences, expected): + result = sorted(keyutils.KeySequence.parse(seq) for seq in sequences) + expected_result = [keyutils.KeySequence.parse(seq) for seq in expected] + assert result == expected_result + + @pytest.mark.parametrize('seq1, seq2, op, result', [ + ('a', 'a', operator.eq, True), + ('a', '', operator.eq, True), + ('a', '', operator.eq, False), + ('a', 'b', operator.lt, True), + ('a', 'b', operator.le, True), + ]) + def test_operators(self, seq1, seq2, op, result): + seq1 = keyutils.KeySequence.parse(seq1) + seq2 = keyutils.KeySequence.parse(seq2) + assert op(seq1, seq2) == result + + opposite = { + operator.lt: operator.ge, + operator.gt: operator.le, + operator.le: operator.gt, + operator.ge: operator.lt, + operator.eq: operator.ne, + operator.ne: operator.eq, + } + assert opposite[op](seq1, seq2) != result + + @pytest.mark.parametrize('seq1, seq2, equal', [ + ('a', 'a', True), + ('a', 'A', False), + ('a', '', True), + ('abcd', 'abcde', False), + ]) + def test_hash(self, seq1, seq2, equal): + seq1 = keyutils.KeySequence.parse(seq1) + seq2 = keyutils.KeySequence.parse(seq2) + assert (hash(seq1) == hash(seq2)) == equal + + @pytest.mark.parametrize('seq, length', [ + ('', 0), + ('a', 1), + ('A', 1), + ('', 1), + ('abcde', 5) + ]) + def test_len(self, seq, length): + assert len(keyutils.KeySequence.parse(seq)) == length + + def test_bool(self): + seq1 = keyutils.KeySequence.parse('abcd') + seq2 = keyutils.KeySequence() + assert seq1 + assert not seq2 + + def test_getitem(self): + seq = keyutils.KeySequence.parse('ab') + expected = keyutils.KeyInfo(Qt.Key_B, Qt.NoModifier) + assert seq[1] == expected + + def test_getitem_slice(self): + s1 = 'abcdef' + s2 = 'de' + seq = keyutils.KeySequence.parse(s1) + expected = keyutils.KeySequence.parse(s2) + assert s1[3:5] == s2 + assert seq[3:5] == expected + + @pytest.mark.parametrize('entered, configured, expected', [ + # config: abcd + ('abc', 'abcd', QKeySequence.PartialMatch), + ('abcd', 'abcd', QKeySequence.ExactMatch), + ('ax', 'abcd', QKeySequence.NoMatch), + ('abcdef', 'abcd', QKeySequence.NoMatch), + + # config: abcd ef + ('abc', 'abcdef', QKeySequence.PartialMatch), + ('abcde', 'abcdef', QKeySequence.PartialMatch), + ('abcd', 'abcdef', QKeySequence.PartialMatch), + ('abcdx', 'abcdef', QKeySequence.NoMatch), + ('ax', 'abcdef', QKeySequence.NoMatch), + ('abcdefg', 'abcdef', QKeySequence.NoMatch), + ('abcdef', 'abcdef', QKeySequence.ExactMatch), + + # other examples + ('ab', 'a', QKeySequence.NoMatch), + + # empty strings + ('', '', QKeySequence.ExactMatch), + ('', 'a', QKeySequence.PartialMatch), + ('a', '', QKeySequence.NoMatch), + ]) + def test_matches(self, entered, configured, expected): + entered = keyutils.KeySequence.parse(entered) + configured = keyutils.KeySequence.parse(configured) + assert entered.matches(configured) == expected + + @pytest.mark.parametrize('old, key, modifiers, text, expected', [ + ('a', Qt.Key_B, Qt.NoModifier, 'b', 'ab'), + ('a', Qt.Key_B, Qt.ShiftModifier, 'B', 'aB'), + ('a', Qt.Key_B, Qt.ControlModifier | Qt.ShiftModifier, 'B', + 'a'), + + # Modifier stripping with symbols + ('', Qt.Key_Colon, Qt.NoModifier, ':', ':'), + ('', Qt.Key_Colon, Qt.ShiftModifier, ':', ':'), + ('', Qt.Key_Colon, Qt.ControlModifier | Qt.ShiftModifier, ':', + ''), + + # Handling of Backtab + ('', Qt.Key_Backtab, Qt.NoModifier, '', ''), + ('', Qt.Key_Backtab, Qt.ShiftModifier, '', ''), + ('', Qt.Key_Backtab, Qt.ControlModifier | Qt.ShiftModifier, '', + ''), + ]) + def test_append_event(self, old, key, modifiers, text, expected): + seq = keyutils.KeySequence.parse(old) + event = QKeyEvent(QKeyEvent.KeyPress, key, modifiers, text) + new = seq.append_event(event) + assert new == keyutils.KeySequence.parse(expected) + + @pytest.mark.parametrize('keystr, expected', [ + ('', keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X)), + ('', keyutils.KeySequence(Qt.MetaModifier | Qt.Key_X)), + ('', + keyutils.KeySequence(Qt.ControlModifier | Qt.AltModifier | Qt.Key_Y)), + ('x', keyutils.KeySequence(Qt.Key_X)), + ('X', keyutils.KeySequence(Qt.ShiftModifier | Qt.Key_X)), + ('', keyutils.KeySequence(Qt.Key_Escape)), + ('xyz', keyutils.KeySequence(Qt.Key_X, Qt.Key_Y, Qt.Key_Z)), + ('', + keyutils.KeySequence(Qt.ControlModifier | Qt.Key_X, + Qt.MetaModifier | Qt.Key_Y)), + + ('>', keyutils.KeySequence(Qt.Key_Greater)), + ('<', keyutils.KeySequence(Qt.Key_Less)), + ('a>', keyutils.KeySequence(Qt.Key_A, Qt.Key_Greater)), + ('a<', keyutils.KeySequence(Qt.Key_A, Qt.Key_Less)), + ('>a', keyutils.KeySequence(Qt.Key_Greater, Qt.Key_A)), + ('', + keyutils.KeySequence(Qt.Key_Greater | Qt.AltModifier)), + ('', + keyutils.KeySequence(Qt.Key_Less | Qt.AltModifier)), + + ('', keyutils.KeyParseError), + ('>', keyutils.KeyParseError), + ('', keyutils.KeyParseError), + ('\U00010000', keyutils.KeyParseError), + ]) + def test_parse(self, keystr, expected): + if expected is keyutils.KeyParseError: + with pytest.raises(keyutils.KeyParseError): + keyutils.KeySequence.parse(keystr) + else: + assert keyutils.KeySequence.parse(keystr) == expected + + +def test_key_info_from_event(): + ev = QKeyEvent(QEvent.KeyPress, Qt.Key_A, Qt.ShiftModifier, 'A') + info = keyutils.KeyInfo.from_event(ev) + assert info.key == Qt.Key_A + assert info.modifiers == Qt.ShiftModifier + + +def test_key_info_to_event(): + info = keyutils.KeyInfo(Qt.Key_A, Qt.ShiftModifier) + ev = info.to_event() + assert ev.key() == Qt.Key_A + assert ev.modifiers() == Qt.ShiftModifier + assert ev.text() == 'A' + + +@pytest.mark.parametrize('key, printable', [ + (Qt.Key_Control, False), + (Qt.Key_Escape, False), + (Qt.Key_Tab, False), + (Qt.Key_Backtab, False), + (Qt.Key_Backspace, False), + (Qt.Key_Return, False), + (Qt.Key_Enter, False), + (Qt.Key_Space, False), + (Qt.Key_X | Qt.ControlModifier, False), # Wrong usage + + (Qt.Key_ydiaeresis, True), + (Qt.Key_X, True), +]) +def test_is_printable(key, printable): + assert keyutils.is_printable(key) == printable + + +@pytest.mark.parametrize('key, ismodifier', [ + (Qt.Key_Control, True), + (Qt.Key_X, False), + (Qt.Key_Super_L, False), # Modifier but not in _MODIFIER_MAP +]) +def test_is_modifier_key(key, ismodifier): + assert keyutils.is_modifier_key(key) == ismodifier diff --git a/tests/unit/keyinput/test_modeman.py b/tests/unit/keyinput/test_modeman.py index 221b675be..de9671961 100644 --- a/tests/unit/keyinput/test_modeman.py +++ b/tests/unit/keyinput/test_modeman.py @@ -44,15 +44,14 @@ def modeman(mode_manager): return mode_manager -@pytest.mark.parametrize('key, modifiers, text, filtered', [ - (Qt.Key_A, Qt.NoModifier, 'a', True), - (Qt.Key_Up, Qt.NoModifier, '', False), +@pytest.mark.parametrize('key, modifiers, filtered', [ + (Qt.Key_A, Qt.NoModifier, True), + (Qt.Key_Up, Qt.NoModifier, False), # https://github.com/qutebrowser/qutebrowser/issues/1207 - (Qt.Key_A, Qt.ShiftModifier, 'A', True), - (Qt.Key_A, Qt.ShiftModifier | Qt.ControlModifier, 'x', False), + (Qt.Key_A, Qt.ShiftModifier, True), + (Qt.Key_A, Qt.ShiftModifier | Qt.ControlModifier, False), ]) -def test_non_alphanumeric(key, modifiers, text, filtered, - fake_keyevent_factory, modeman): +def test_non_alphanumeric(key, modifiers, filtered, fake_keyevent, modeman): """Make sure non-alphanumeric keys are passed through correctly.""" - evt = fake_keyevent_factory(key=key, modifiers=modifiers, text=text) + evt = fake_keyevent(key=key, modifiers=modifiers) assert modeman.eventFilter(evt) == filtered diff --git a/tests/unit/keyinput/test_modeparsers.py b/tests/unit/keyinput/test_modeparsers.py index ade8c15cc..c3be9d70f 100644 --- a/tests/unit/keyinput/test_modeparsers.py +++ b/tests/unit/keyinput/test_modeparsers.py @@ -25,7 +25,7 @@ from PyQt5.QtCore import Qt import pytest -from qutebrowser.keyinput import modeparsers +from qutebrowser.keyinput import modeparsers, keyutils class TestsNormalKeyParser: @@ -49,36 +49,35 @@ class TestsNormalKeyParser: kp.execute = mock.Mock() return kp - def test_keychain(self, keyparser, fake_keyevent_factory): + def test_keychain(self, keyparser, fake_keyevent): """Test valid keychain.""" # Press 'x' which is ignored because of no match - keyparser.handle(fake_keyevent_factory(Qt.Key_X, text='x')) + keyparser.handle(fake_keyevent(Qt.Key_X)) # Then start the real chain - keyparser.handle(fake_keyevent_factory(Qt.Key_B, text='b')) - keyparser.handle(fake_keyevent_factory(Qt.Key_A, text='a')) - keyparser.execute.assert_called_with( - 'message-info ba', keyparser.Type.chain, None) - assert keyparser._keystring == '' + keyparser.handle(fake_keyevent(Qt.Key_B)) + keyparser.handle(fake_keyevent(Qt.Key_A)) + keyparser.execute.assert_called_with('message-info ba', None) + assert not keyparser._sequence def test_partial_keychain_timeout(self, keyparser, config_stub, - fake_keyevent_factory): + fake_keyevent): """Test partial keychain timeout.""" config_stub.val.input.partial_timeout = 100 timer = keyparser._partial_timer assert not timer.isActive() # Press 'b' for a partial match. # Then we check if the timer has been set up correctly - keyparser.handle(fake_keyevent_factory(Qt.Key_B, text='b')) + keyparser.handle(fake_keyevent(Qt.Key_B)) assert timer.isSingleShot() assert timer.interval() == 100 assert timer.isActive() assert not keyparser.execute.called - assert keyparser._keystring == 'b' + assert keyparser._sequence == keyutils.KeySequence.parse('b') # Now simulate a timeout and check the keystring has been cleared. keystring_updated_mock = mock.Mock() keyparser.keystring_updated.connect(keystring_updated_mock) timer.timeout.emit() assert not keyparser.execute.called - assert keyparser._keystring == '' + assert not keyparser._sequence keystring_updated_mock.assert_called_once_with('') diff --git a/tests/unit/misc/test_miscwidgets.py b/tests/unit/misc/test_miscwidgets.py index ccf3b5593..d6b099418 100644 --- a/tests/unit/misc/test_miscwidgets.py +++ b/tests/unit/misc/test_miscwidgets.py @@ -106,7 +106,7 @@ class TestFullscreenNotification: @pytest.mark.parametrize('bindings, text', [ ({'': 'fullscreen --leave'}, - "Press Escape to exit fullscreen."), + "Press to exit fullscreen."), ({'': 'fullscreen'}, "Page is now fullscreen."), ({'a': 'fullscreen --leave'}, "Press a to exit fullscreen."), ({}, "Page is now fullscreen."), diff --git a/tests/unit/utils/test_utils.py b/tests/unit/utils/test_utils.py index 4b8c50813..b2eef0237 100644 --- a/tests/unit/utils/test_utils.py +++ b/tests/unit/utils/test_utils.py @@ -30,7 +30,7 @@ import re import shlex import attr -from PyQt5.QtCore import Qt, QUrl +from PyQt5.QtCore import QUrl from PyQt5.QtGui import QColor, QClipboard import pytest @@ -297,134 +297,6 @@ class TestFormatSize: assert utils.format_size(size, base=1000) == out -class TestKeyToString: - - """Test key_to_string.""" - - @pytest.mark.parametrize('key, expected', [ - (Qt.Key_Blue, 'Blue'), - (Qt.Key_Backtab, 'Tab'), - (Qt.Key_Escape, 'Escape'), - (Qt.Key_A, 'A'), - (Qt.Key_degree, '°'), - (Qt.Key_Meta, 'Meta'), - ]) - def test_normal(self, key, expected): - """Test a special key where QKeyEvent::toString works incorrectly.""" - assert utils.key_to_string(key) == expected - - def test_missing(self, monkeypatch): - """Test with a missing key.""" - monkeypatch.delattr(utils.Qt, 'Key_Blue') - # We don't want to test the key which is actually missing - we only - # want to know if the mapping still behaves properly. - assert utils.key_to_string(Qt.Key_A) == 'A' - - def test_all(self): - """Make sure there's some sensible output for all keys.""" - for name, value in sorted(vars(Qt).items()): - if not isinstance(value, Qt.Key): - continue - print(name) - string = utils.key_to_string(value) - assert string - string.encode('utf-8') # make sure it's encodable - - -class TestKeyEventToString: - - """Test keyevent_to_string.""" - - def test_only_control(self, fake_keyevent_factory): - """Test keyeevent when only control is pressed.""" - evt = fake_keyevent_factory(key=Qt.Key_Control, - modifiers=Qt.ControlModifier) - assert utils.keyevent_to_string(evt) is None - - def test_only_hyper_l(self, fake_keyevent_factory): - """Test keyeevent when only Hyper_L is pressed.""" - evt = fake_keyevent_factory(key=Qt.Key_Hyper_L, - modifiers=Qt.MetaModifier) - assert utils.keyevent_to_string(evt) is None - - def test_only_key(self, fake_keyevent_factory): - """Test with a simple key pressed.""" - evt = fake_keyevent_factory(key=Qt.Key_A) - assert utils.keyevent_to_string(evt) == 'a' - - def test_key_and_modifier(self, fake_keyevent_factory): - """Test with key and modifier pressed.""" - evt = fake_keyevent_factory(key=Qt.Key_A, modifiers=Qt.ControlModifier) - expected = 'meta+a' if utils.is_mac else 'ctrl+a' - assert utils.keyevent_to_string(evt) == expected - - def test_key_and_modifiers(self, fake_keyevent_factory): - """Test with key and multiple modifiers pressed.""" - evt = fake_keyevent_factory( - key=Qt.Key_A, modifiers=(Qt.ControlModifier | Qt.AltModifier | - Qt.MetaModifier | Qt.ShiftModifier)) - assert utils.keyevent_to_string(evt) == 'ctrl+alt+meta+shift+a' - - @pytest.mark.fake_os('mac') - def test_mac(self, fake_keyevent_factory): - """Test with a simulated mac.""" - evt = fake_keyevent_factory(key=Qt.Key_A, modifiers=Qt.ControlModifier) - assert utils.keyevent_to_string(evt) == 'meta+a' - - -@pytest.mark.parametrize('keystr, expected', [ - ('', utils.KeyInfo(Qt.Key_X, Qt.ControlModifier, '')), - ('', utils.KeyInfo(Qt.Key_X, Qt.MetaModifier, '')), - ('', - utils.KeyInfo(Qt.Key_Y, Qt.ControlModifier | Qt.AltModifier, '')), - ('x', utils.KeyInfo(Qt.Key_X, Qt.NoModifier, 'x')), - ('X', utils.KeyInfo(Qt.Key_X, Qt.ShiftModifier, 'X')), - ('', utils.KeyInfo(Qt.Key_Escape, Qt.NoModifier, '')), - - ('foobar', utils.KeyParseError), - ('x, y', utils.KeyParseError), - ('xyz', utils.KeyParseError), - ('Escape', utils.KeyParseError), - (', ', utils.KeyParseError), -]) -def test_parse_single_key(keystr, expected): - if expected is utils.KeyParseError: - with pytest.raises(utils.KeyParseError): - utils._parse_single_key(keystr) - else: - assert utils._parse_single_key(keystr) == expected - - -@pytest.mark.parametrize('keystr, expected', [ - ('', [utils.KeyInfo(Qt.Key_X, Qt.ControlModifier, '')]), - ('x', [utils.KeyInfo(Qt.Key_X, Qt.NoModifier, 'x')]), - ('xy', [utils.KeyInfo(Qt.Key_X, Qt.NoModifier, 'x'), - utils.KeyInfo(Qt.Key_Y, Qt.NoModifier, 'y')]), - - ('', utils.KeyParseError), -]) -def test_parse_keystring(keystr, expected): - if expected is utils.KeyParseError: - with pytest.raises(utils.KeyParseError): - utils.parse_keystring(keystr) - else: - assert utils.parse_keystring(keystr) == expected - - -@pytest.mark.parametrize('orig, repl', [ - ('Control+x', 'ctrl+x'), - ('Windows+x', 'meta+x'), - ('Mod1+x', 'alt+x'), - ('Mod4+x', 'meta+x'), - ('Control--', 'ctrl+-'), - ('Windows++', 'meta++'), - ('ctrl-x', 'ctrl+x'), - ('control+x', 'ctrl+x') -]) -def test_normalize_keystr(orig, repl): - assert utils.normalize_keystr(orig) == repl - - class TestFakeIOStream: """Test FakeIOStream.""" @@ -832,22 +704,6 @@ class TestGetSetClipboard: utils.get_clipboard(fallback=True) -@pytest.mark.parametrize('keystr, expected', [ - ('', True), - ('', True), - ('', True), - ('x', False), - ('X', False), - ('', True), - ('foobar', False), - ('foo>', False), - ('