Implementing an initial attempt at forwarding keys. Seems proper, but a few checks still need to be done.

Merges needed were:
* qutebrowser/keyinput/basekeyparser.py for the new count precaution (commit 51aa7ab)
* qutebrowser/keyinput/modeparsers.py for a simple syntax restructure (semantically
  identical)
* tests/unit/keyinput/test_basekeyparser.py for new count precaution (commit 51aa7ab)
  unit test
* qutebrowser/keyinput/modeman.py for import changes (commit eb8121f)
This commit is contained in:
brightonanc 2022-03-26 15:22:06 -04:00
parent 205afc28fd
commit 595cda4896
7 changed files with 561 additions and 234 deletions

View File

@ -163,28 +163,33 @@ class BaseKeyParser(QObject):
arg 1: Reason for leaving.
arg 2: Ignore the request if we're not in that mode
"""
#TODO: partial docs
keystring_updated = pyqtSignal(str)
request_leave = pyqtSignal(usertypes.KeyMode, str, bool)
forward_partial_key = pyqtSignal(str)
clear_partial_keys = pyqtSignal()
def __init__(self, *, mode: usertypes.KeyMode,
win_id: int,
parent: QObject = None,
do_log: bool = True,
passthrough: bool = False,
supports_count: bool = True) -> None:
supports_count: bool = True,
allow_partial_timeout: bool = False) -> None:
super().__init__(parent)
self._win_id = win_id
self._sequence = keyutils.KeySequence()
self._pure_sequence = keyutils.KeySequence()
self._count = ''
self._count_keyposs = []
self._mode = mode
self._do_log = do_log
self.passthrough = passthrough
self._supports_count = supports_count
self.allow_partial_timeout = allow_partial_timeout
self.bindings = BindingTrie()
self._read_config()
self._partial_timer = usertypes.Timer(self, 'partial-match')
self._partial_timer.setSingleShot(True)
config.instance.changed.connect(self._on_config_changed)
def __repr__(self) -> str:
@ -192,7 +197,8 @@ class BaseKeyParser(QObject):
win_id=self._win_id,
do_log=self._do_log,
passthrough=self.passthrough,
supports_count=self._supports_count)
supports_count=self._supports_count,
allow_partial_timeout=self.allow_partial_timeout)
def _debug_log(self, msg: str) -> None:
"""Log a message to the debug log if logging is active.
@ -241,19 +247,20 @@ class BaseKeyParser(QObject):
command=None,
sequence=sequence)
def _match_count(self, sequence: keyutils.KeySequence,
dry_run: bool) -> bool:
def _match_count(self, sequence: keyutils.KeySequence, count: str,
keypos: int, dry_run: bool) -> bool:
"""Try to match a key as count."""
if not config.val.input.match_counts:
return False
txt = str(sequence[-1]) # To account for sequences changed above.
if (txt in string.digits and self._supports_count and
not (not self._count and txt == '0')):
not (not count and txt == '0')):
self._debug_log("Trying match as count")
assert len(txt) == 1, txt
if not dry_run:
self._count += txt
self._count_keyposs.append(keypos)
self.keystring_updated.emit(self._count + str(self._sequence))
return True
return False
@ -288,55 +295,111 @@ class BaseKeyParser(QObject):
self._debug_log("Ignoring, only modifier")
return QKeySequence.SequenceMatch.NoMatch
had_empty_queue = (not self._pure_sequence) and (not self._count)
try:
sequence = self._sequence.append_event(e)
pure_sequence = self._pure_sequence.append_event(e)
except keyutils.KeyParseError as ex:
self._debug_log("{} Aborting keychain.".format(ex))
self.clear_keystring()
return QKeySequence.SequenceMatch.NoMatch
result = self._match_key(sequence)
del sequence # Enforce code below to use the modified result.sequence
if result.match_type == QKeySequence.SequenceMatch.NoMatch:
result = self._match_without_modifiers(result.sequence)
if result.match_type == QKeySequence.SequenceMatch.NoMatch:
result = self._match_key_mapping(result.sequence)
if result.match_type == QKeySequence.SequenceMatch.NoMatch:
was_count = self._match_count(result.sequence, dry_run)
if was_count:
self._set_partial_timeout()
return QKeySequence.SequenceMatch.ExactMatch
# Have these shadow variables to have replicable behavior when doing a
# dry_run
count = self._count
count_keyposs = self._count_keyposs.copy()
while pure_sequence:
result = self._match_key(pure_sequence)
# Enforce code below to use the modified result.sequence
if result.match_type == QKeySequence.SequenceMatch.NoMatch:
self._debug_log("No match for '{}'. Attempting without "
"modifiers.".format(result.sequence))
result = self._match_without_modifiers(result.sequence)
if result.match_type == QKeySequence.SequenceMatch.NoMatch:
self._debug_log("No match for '{}'. Attempting with key "
"mappings.".format(result.sequence))
seq_len = len(result.sequence)
result = self._match_key_mapping(result.sequence)
if result.match_type == QKeySequence.SequenceMatch.NoMatch:
# this length check is to ensure that key mappings from the
# _match_key_mapping call that directly convert a single key to
# a numeral character are allowed to be recognized as counts.
# The case where a mapping from a single key to multiple keys
# (including a count) is present is unlikely, and the handling
# of such an event is not obvious, so for now we do not support
# it at all.
if len(result.sequence) == seq_len:
self._debug_log("No match for '{}'. Attempting count "
"match.".format(result.sequence))
was_count = self._match_count(result.sequence, count,
len(self._pure_sequence), dry_run)
if was_count:
self._debug_log("Was a count match.")
return QKeySequence.SequenceMatch.PartialMatch
else:
self._debug_log("No match for '{}'. Mappings expanded "
"the length of the sequence, so no count "
"matching will be attempted.".format(
result.sequence))
if not dry_run:
self._sequence = result.sequence
self._pure_sequence = pure_sequence
# TODO: forwarding debug log message
if result.match_type:
break
else:
# TODO ensure all actual values are shadowed properly for dry_run
if not had_empty_queue:
self._debug_log("No match for '{}'. Will forward first "
"key in the sequence and retry.".format(
result.sequence))
# TODO: empty handling (and find all others and fix)
while count_keyposs and (0 == count_keyposs[0]):
self._debug_log("Hit a queued count key ('{}'). "
"Forwarding.".format(count[0]))
count = count[1:]
count_keyposs.pop(0)
if not dry_run:
self.forward_partial_key.emit(self._count[0])
# TODO: remove all = [1:] and so on with pops instead for non-strings
# TODO: check that matching is unaffected by count changing, e.g. for dry_runs
self._count = self._count[1:]
self._count_keyposs.pop(0)
# TODO: TODO ensure the keystring is updated after this
self._debug_log("Forwarding first key in sequence "
"('{}').".format(str(pure_sequence[0])))
count_keyposs = [x - 1 for x in count_keyposs]
if not dry_run:
self._count_keyposs = [x - 1 for x in self._count_keyposs]
# TODO: TODO ensure there's always a 0th element here
self.forward_partial_key.emit(str(self._pure_sequence[0]))
else:
self._debug_log("No partial keys in queue. Continuing.")
pure_sequence = pure_sequence[1:]
# TODO: TODO check if on next loop a count could've slipped in somehow
if dry_run:
return result.match_type
self._sequence = result.sequence
self._handle_result(info, result)
return result.match_type
def _handle_result(self, info: keyutils.KeyInfo, result: MatchResult) -> None:
"""Handle a final MatchResult from handle()."""
if result.match_type == QKeySequence.SequenceMatch.ExactMatch:
assert result.command is not None
self._debug_log("Definitive match for '{}'.".format(
result.sequence))
try:
count = int(self._count) if self._count else None
flag_do_execute = True
except ValueError as err:
message.error(f"Failed to parse count: {err}",
stack=traceback.format_exc())
self.clear_keystring()
return
flag_do_execute = False
self.clear_partial_keys.emit()
self.clear_keystring()
self.execute(result.command, count)
if flag_do_execute:
self.execute(result.command, count)
elif result.match_type == QKeySequence.SequenceMatch.PartialMatch:
self._debug_log("No match for '{}' (added {})".format(
result.sequence, info))
self.keystring_updated.emit(self._count + str(result.sequence))
self._set_partial_timeout()
elif result.match_type == QKeySequence.SequenceMatch.NoMatch:
self._debug_log("Giving up with '{}', no matches".format(
result.sequence))
@ -367,35 +430,18 @@ class BaseKeyParser(QObject):
"""
raise NotImplementedError
def _set_partial_timeout(self) -> None:
"""Set a timeout to clear a partial keystring."""
timeout = config.val.input.partial_timeout
if timeout != 0:
self._partial_timer.setInterval(timeout)
self._partial_timer.timeout.connect(self._clear_partial_match)
self._partial_timer.start()
@pyqtSlot()
def _clear_partial_match(self) -> None:
"""Clear a partial keystring after a timeout."""
self._debug_log("Clearing partial keystring {}".format(
self._sequence))
if self._count:
self._count = ''
self._sequence = keyutils.KeySequence()
self.keystring_updated.emit(str(self._sequence))
def clear_keystring(self) -> None:
"""Clear the currently entered key sequence."""
if self._count:
self._debug_log("Clearing keystring count (was: {}).".format(
self._count))
self._count = ''
self._count_keyposs = []
# TODO: better handling of clearing managing _pure_sequence length
if self._pure_sequence:
self._pure_sequence = keyutils.KeySequence()
if self._sequence:
self._debug_log("Clearing keystring (was: {}).".format(
self._sequence))
self._sequence = keyutils.KeySequence()
self._count = ''
self.keystring_updated.emit('')
self._partial_timer.stop()
try:
self._partial_timer.timeout.disconnect(self._clear_partial_match)
except TypeError:
# no connections
pass

View File

@ -4,9 +4,11 @@
"""Mode manager (per window) which handles the current keyboard mode."""
from PyQt5.QtWidgets import QApplication
import functools
import dataclasses
from typing import Union, cast
from typing import Union, cast, Tuple
from collections.abc import Mapping, MutableMapping, Callable
from qutebrowser.qt import machinery
@ -14,7 +16,7 @@ from qutebrowser.qt.core import pyqtSlot, pyqtSignal, Qt, QObject, QEvent
from qutebrowser.qt.gui import QKeyEvent, QKeySequence
from qutebrowser.commands import runners
from qutebrowser.keyinput import modeparsers, basekeyparser
from qutebrowser.keyinput import modeparsers, basekeyparser, keyutils
from qutebrowser.config import config
from qutebrowser.api import cmdutils
from qutebrowser.utils import usertypes, log, objreg, utils, qtutils
@ -52,6 +54,49 @@ class KeyEvent:
"""Initialize a KeyEvent from a QKeyEvent."""
return cls(event.key(), event.text())
@dataclasses.dataclass(frozen=False)
class QueuedKeyEventPair:
# TODO: docs
"""A wrapper over a QKeyEvent capable of recreating the event.
This is needed to recreate any queued events when either a timeout occurs
or a match is not completed.
Attributes:
key_event: A KeyEvent member for comparison.
key_info: A keyutils.KeyInfo member for complete event reconstruction
(e.g. with modifiers).
typ: QEvent.KeyPress or QEvent.KeyRelease.
"""
key_event: KeyEvent
key_info_press: keyutils.KeyInfo
key_info_release: keyutils.KeyInfo
@classmethod
def from_event_press(cls, event: QKeyEvent) -> 'QueuedKeyEventPair':
"""Initialize a QueuedKeyEventPair from a QKeyEvent and QKeyEvent."""
return cls(KeyEvent.from_event(event),
keyutils.KeyInfo.from_event(event), None)
def add_event_release(self, event: QKeyEvent) -> bool:
if self.key_event == KeyEvent.from_event(event):
self.key_info_release = keyutils.KeyInfo.from_event(event)
return True
return False
def is_released(self):
return self.key_info_release is not None
def to_events(self) -> Tuple[QKeyEvent]:
"""Get a QKeyEvent from this QueuedEvent."""
if self.key_info_release is None:
return (self.key_info_press.to_event(QEvent.KeyPress),)
else:
return (self.key_info_press.to_event(QEvent.KeyPress),
self.key_info_release.to_event(QEvent.KeyRelease))
class NotInModeError(Exception):
@ -102,7 +147,8 @@ def init(win_id: int, parent: QObject) -> 'ModeManager':
parent=modeman,
passthrough=True,
do_log=log_sensitive_keys,
supports_count=False),
supports_count=False,
allow_partial_timeout=True),
usertypes.KeyMode.passthrough:
modeparsers.CommandKeyParser(
@ -112,7 +158,8 @@ def init(win_id: int, parent: QObject) -> 'ModeManager':
parent=modeman,
passthrough=True,
do_log=log_sensitive_keys,
supports_count=False),
supports_count=False,
allow_partial_timeout=True),
usertypes.KeyMode.command:
modeparsers.CommandKeyParser(
@ -122,7 +169,8 @@ def init(win_id: int, parent: QObject) -> 'ModeManager':
parent=modeman,
passthrough=True,
do_log=log_sensitive_keys,
supports_count=False),
supports_count=False,
allow_partial_timeout=True),
usertypes.KeyMode.prompt:
modeparsers.CommandKeyParser(
@ -132,7 +180,8 @@ def init(win_id: int, parent: QObject) -> 'ModeManager':
parent=modeman,
passthrough=True,
do_log=log_sensitive_keys,
supports_count=False),
supports_count=False,
allow_partial_timeout=True),
usertypes.KeyMode.yesno:
modeparsers.CommandKeyParser(
@ -140,7 +189,8 @@ def init(win_id: int, parent: QObject) -> 'ModeManager':
win_id=win_id,
commandrunner=commandrunner,
parent=modeman,
supports_count=False),
supports_count=False,
allow_partial_timeout=True),
usertypes.KeyMode.caret:
modeparsers.CommandKeyParser(
@ -148,7 +198,8 @@ def init(win_id: int, parent: QObject) -> 'ModeManager':
win_id=win_id,
commandrunner=commandrunner,
parent=modeman,
passthrough=True),
passthrough=True,
allow_partial_timeout=True),
usertypes.KeyMode.set_mark:
modeparsers.RegisterKeyParser(
@ -246,6 +297,7 @@ class ModeManager(QObject):
entered = pyqtSignal(usertypes.KeyMode, int)
left = pyqtSignal(usertypes.KeyMode, usertypes.KeyMode, int)
keystring_updated = pyqtSignal(usertypes.KeyMode, str)
forward_partial_key = pyqtSignal(usertypes.KeyMode, str)
def __init__(self, win_id: int, parent: QObject = None) -> None:
super().__init__(parent)
@ -256,6 +308,11 @@ class ModeManager(QObject):
self._releaseevents_to_pass: set[KeyEvent] = set()
# Set after __init__
self.hintmanager = cast(hints.HintManager, None)
# TODO: type hints
self._partial_match_events = []
self.forward_partial_key.connect(self.forward_partial_match_event)
self._partial_timer = usertypes.Timer(self, 'partial-match')
self._partial_timer.setSingleShot(True)
def __repr__(self) -> str:
return utils.get_repr(self, mode=self.mode)
@ -276,43 +333,64 @@ class ModeManager(QObject):
if curmode != usertypes.KeyMode.insert:
log.modes.debug("got keypress in mode {} - delegating to "
"{}".format(curmode, utils.qualname(parser)))
had_empty_queue = not self._partial_match_events
if (not dry_run) and (not had_empty_queue):
self._partial_match_events.append(
QueuedKeyEventPair.from_event_press(event))
match = parser.handle(event, dry_run=dry_run)
if machinery.IS_QT5: # FIXME:v4 needed for Qt 5 typing
ignored_modifiers = [
cast(Qt.KeyboardModifiers, Qt.KeyboardModifier.NoModifier),
cast(Qt.KeyboardModifiers, Qt.KeyboardModifier.ShiftModifier),
]
else:
ignored_modifiers = [
Qt.KeyboardModifier.NoModifier,
Qt.KeyboardModifier.ShiftModifier,
]
has_modifier = event.modifiers() not in ignored_modifiers
is_non_alnum = has_modifier or not event.text().strip()
forward_unbound_keys = config.cache['input.forward_unbound_keys']
if match != QKeySequence.SequenceMatch.NoMatch:
# TODO: Check dry_run conditions are everywhere
if match == QKeySequence.SequenceMatch.ExactMatch:
filter_this = True
elif (parser.passthrough or forward_unbound_keys == 'all' or
(forward_unbound_keys == 'auto' and is_non_alnum)):
filter_this = False
else:
if not dry_run:
self._stop_partial_timer()
self.clear_partial_match_events()
elif match == QKeySequence.SequenceMatch.PartialMatch:
filter_this = True
if not dry_run:
if had_empty_queue:
self._partial_match_events.append(
QueuedKeyEventPair.from_event_press(event))
self._start_partial_timer()
elif not had_empty_queue:
filter_this = True
if not dry_run:
self._stop_partial_timer()
# TODO: spacing and tabbing and formatting
else:
key_info = keyutils.KeyInfo.from_event(event)
filter_this = not self._should_forward_event(key_info, parser)
if not filter_this and not dry_run:
self._releaseevents_to_pass.add(KeyEvent.from_event(event))
if curmode != usertypes.KeyMode.insert:
if machinery.IS_QT5: # FIXME:v4 needed for Qt 5 typing
ignored_modifiers = [
cast(Qt.KeyboardModifiers, Qt.KeyboardModifier.NoModifier),
cast(Qt.KeyboardModifiers, Qt.KeyboardModifier.ShiftModifier),
]
else:
ignored_modifiers = [
Qt.KeyboardModifier.NoModifier,
Qt.KeyboardModifier.ShiftModifier,
]
has_modifier = event.modifiers() not in ignored_modifiers
is_non_alnum = has_modifier or not event.text().strip()
forward_unbound_keys = config.cache['input.forward_unbound_keys']
key_info = keyutils.KeyInfo.from_event(event)
should_forward_event = self._should_forward_event(key_info, parser)
focus_widget = objects.qapp.focusWidget()
log.modes.debug("match: {}, forward_unbound_keys: {}, "
"passthrough: {}, is_non_alnum: {}, dry_run: {} "
"--> filter: {} (focused: {})".format(
"passthrough: {}, is_non_alnum: {}, "
"should_forward_event: {}, dry_run: {} "
"--> filter: {} (focused: {!r})".format(
match, forward_unbound_keys,
parser.passthrough, is_non_alnum, dry_run,
filter_this, qtutils.qobj_repr(focus_widget)))
parser.passthrough, is_non_alnum,
should_forward_event, dry_run, filter_this,
qtutils.qobj_repr(focus_widget)))
return filter_this
def _handle_keyrelease(self, event: QKeyEvent) -> bool:
@ -330,19 +408,96 @@ class ModeManager(QObject):
self._releaseevents_to_pass.remove(keyevent)
filter_this = False
else:
for match_event in self._partial_match_events[::-1]:
if match_event.add_event_release(event):
break
filter_this = True
if self.mode != usertypes.KeyMode.insert:
log.modes.debug("filter: {}".format(filter_this))
return filter_this
@staticmethod
def _should_forward_event(key_info, parser):
has_modifier = key_info.modifiers not in [
Qt.NoModifier,
Qt.ShiftModifier,
] # type: ignore[comparison-overlap]
is_non_alnum = has_modifier or not key_info.text().strip()
forward_unbound_keys = config.cache['input.forward_unbound_keys']
return (parser.passthrough or forward_unbound_keys == 'all' or
(forward_unbound_keys == 'auto' and is_non_alnum))
@pyqtSlot(usertypes.KeyMode, str)
def forward_partial_match_event(self, mode: usertypes.KeyMode, text: str = None) -> None:
# TODO: add debug messages
#self._debug_log("Clearing partial keystring {}".format(
# self._sequence))
# TODO: Check for transient self.whatever statements (e.g. self.mode) in slots and remove, might not be thread-safe
if mode not in self.parsers:
raise ValueError("Can't forward partial key: No keyparser for "
"mode {}".format(mode))
parser = self.parsers[mode]
if not self._partial_match_events:
# TODO: debug message
return
match_event = self._partial_match_events.pop(0)
# TODO: debug message when text and event.text don't match up, minding text may be None
if self._should_forward_event(match_event.key_info_press, parser):
# TODO: review alternatives
tabbed_browser = objreg.get('tabbed-browser', scope='window', window=QApplication.activeWindow().win_id)
tab = tabbed_browser.widget.currentWidget()
for event_ in match_event.to_events():
tab.send_event(event_)
if not match_event.is_released():
self._releaseevents_to_pass.add(match_event.key_event)
@pyqtSlot(usertypes.KeyMode)
def forward_all_partial_match_events(self, mode: usertypes.KeyMode) -> None:
if self._partial_match_events:
while self._partial_match_events:
self.forward_partial_match_event(mode)
# If mode wasn't in self.parsers, one of the
# self.forward_partial_match_event calls (of which we have at least
# one) would have raised an error, so it is safe to assert
assert mode in self.parsers
self.parsers[mode].clear_keystring()
@pyqtSlot()
def clear_partial_match_events(self) -> None:
self._partial_match_events = []
def _start_partial_timer(self) -> None:
"""Set a timeout to clear a partial keystring."""
timeout = config.val.input.partial_timeout
if self.parsers[self.mode].allow_partial_timeout and (timeout != 0):
self._partial_timer.setInterval(timeout)
# Disconnect existing connections (if any)
try:
self._partial_timer.timeout.disconnect()
except TypeError:
pass
self._partial_timer.timeout.connect(
functools.partial(self.forward_all_partial_match_events, self.mode))
self._partial_timer.start()
def _stop_partial_timer(self) -> None:
"""Prematurely stop the the partial keystring timer."""
timeout = config.val.input.partial_timeout
if self.parsers[self.mode].allow_partial_timeout and (timeout != 0):
self._partial_timer.stop()
def register(self, mode: usertypes.KeyMode,
parser: basekeyparser.BaseKeyParser) -> None:
"""Register a new mode."""
assert parser is not None
self.parsers[mode] = parser
parser.request_leave.connect(self.leave)
# TODO: maybe make keystring_updated a domino of forward_partial_key?
parser.keystring_updated.connect(
functools.partial(self.keystring_updated.emit, mode))
parser.forward_partial_key.connect(
functools.partial(self.forward_partial_key.emit, mode))
parser.clear_partial_keys.connect(self.clear_partial_match_events)
def enter(self, mode: usertypes.KeyMode,
reason: str = None,
@ -381,7 +536,7 @@ class ModeManager(QObject):
else:
self._prev_mode = usertypes.KeyMode.normal
self.mode = mode
self.change_mode(mode)
self.entered.emit(mode, self._win_id)
@cmdutils.register(instance='mode-manager', scope='window')
@ -431,7 +586,7 @@ class ModeManager(QObject):
# leaving a mode implies clearing keychain, see
# https://github.com/qutebrowser/qutebrowser/issues/1805
self.clear_keychain()
self.mode = usertypes.KeyMode.normal
self.change_mode(usertypes.KeyMode.normal)
self.left.emit(mode, self.mode, self._win_id)
if mode in PROMPT_MODES:
self.enter(self._prev_mode,
@ -445,6 +600,12 @@ class ModeManager(QObject):
raise ValueError("Can't leave normal mode!")
self.leave(self.mode, 'leave current')
def change_mode(self, mode: usertypes.KeyMode) -> None:
self._stop_partial_timer()
# catches the case where change of mode is not keys, e.g. mouse click
self.forward_all_partial_match_events(self.mode)
self.mode = mode
def handle_event(self, event: QEvent) -> bool:
"""Filter all events based on the currently set mode.

View File

@ -51,10 +51,12 @@ class CommandKeyParser(basekeyparser.BaseKeyParser):
parent: QObject = None,
do_log: bool = True,
passthrough: bool = False,
supports_count: bool = True) -> None:
supports_count: bool = True,
allow_partial_timeout: bool = True) -> None:
super().__init__(mode=mode, win_id=win_id, parent=parent,
do_log=do_log, passthrough=passthrough,
supports_count=supports_count)
supports_count=supports_count,
allow_partial_timeout=allow_partial_timeout)
self._commandrunner = commandrunner
def execute(self, cmdstr: str, count: int = None) -> None:
@ -66,11 +68,7 @@ class CommandKeyParser(basekeyparser.BaseKeyParser):
class NormalKeyParser(CommandKeyParser):
"""KeyParser for normal mode with added STARTCHARS detection and more.
Attributes:
_partial_timer: Timer to clear partial keypresses.
"""
"""KeyParser for normal mode with added STARTCHARS detection and more."""
_sequence: keyutils.KeySequence
@ -79,9 +77,6 @@ class NormalKeyParser(CommandKeyParser):
parent: QObject = None) -> None:
super().__init__(mode=usertypes.KeyMode.normal, win_id=win_id,
commandrunner=commandrunner, parent=parent)
self._partial_timer = usertypes.Timer(self, 'partial-match')
self._partial_timer.setSingleShot(True)
self._partial_timer.timeout.connect(self._clear_partial_match)
self._inhibited = False
self._inhibited_timer = usertypes.Timer(self, 'normal-inhibited')
self._inhibited_timer.setSingleShot(True)
@ -99,14 +94,7 @@ class NormalKeyParser(CommandKeyParser):
"currently inhibited.".format(txt))
return QKeySequence.SequenceMatch.NoMatch
match = super().handle(e, dry_run=dry_run)
if match == QKeySequence.SequenceMatch.PartialMatch and not dry_run:
timeout = config.val.input.partial_timeout
if timeout != 0:
self._partial_timer.setInterval(timeout)
self._partial_timer.start()
return match
return super().handle(e, dry_run=dry_run)
def set_inhibited_timeout(self, timeout: int) -> None:
"""Ignore keypresses for the given duration."""
@ -117,14 +105,6 @@ class NormalKeyParser(CommandKeyParser):
self._inhibited_timer.setInterval(timeout)
self._inhibited_timer.start()
@pyqtSlot()
def _clear_partial_match(self) -> None:
"""Clear a partial keystring after a timeout."""
self._debug_log("Clearing partial keystring {}".format(
self._sequence))
self._sequence = keyutils.KeySequence()
self.keystring_updated.emit(str(self._sequence))
@pyqtSlot()
def _clear_inhibited(self) -> None:
"""Reset inhibition state after a timeout."""
@ -132,90 +112,6 @@ class NormalKeyParser(CommandKeyParser):
self._inhibited = False
class PassthroughKeyParser(CommandKeyParser):
"""KeyChainParser which passes through normal keys.
Used for insert/passthrough modes.
Attributes:
_mode: The mode this keyparser is for.
_orig_sequence: Current sequence with no key_mappings applied.
"""
do_log = False
passthrough = True
def __init__(self, win_id, mode, parent=None):
"""Constructor.
Args:
mode: The mode this keyparser is for.
parent: Qt parent.
warn: Whether to warn if an ignored key was bound.
"""
super().__init__(win_id, parent)
self._read_config(mode)
self._orig_sequence = keyutils.KeySequence()
self._mode = mode
def __repr__(self):
return utils.get_repr(self, mode=self._mode)
def handle(self, e, *, dry_run=False):
"""Override to pass the chain through on NoMatch.
Args:
e: the KeyPressEvent from Qt.
dry_run: Don't actually execute anything, only check whether there
would be a match.
Return:
A self.Match member.
"""
if (keyutils.is_modifier_key(e.key()) or
getattr(e, "ignore_event", False)):
return QKeySequence.NoMatch
orig_sequence = self._orig_sequence.append_event(e)
match = super().handle(e, dry_run=dry_run)
if not dry_run and match == QKeySequence.PartialMatch:
self._orig_sequence = orig_sequence
if dry_run or len(orig_sequence) == 1 or match != QKeySequence.NoMatch:
return match
self._forward_keystring(orig_sequence)
return QKeySequence.ExactMatch
def _forward_keystring(self, orig_sequence):
window = QApplication.focusWindow()
if window is None:
return
first = True
for keyinfo in orig_sequence:
press_event = keyinfo.to_event(QEvent.KeyPress)
if first:
press_event.ignore_event = True
first = False
release_event = keyinfo.to_event(QEvent.KeyRelease)
QApplication.postEvent(window, press_event)
QApplication.postEvent(window, release_event)
@pyqtSlot()
def clear_partial_match(self):
"""Override to forward the original sequence to browser."""
self._forward_keystring(self._orig_sequence)
self.clear_keystring()
def clear_keystring(self):
"""Override to also clear the original sequence."""
super().clear_keystring()
self._orig_sequence = keyutils.KeySequence()
class HintKeyParser(basekeyparser.BaseKeyParser):
"""KeyChainParser for hints.
@ -233,12 +129,14 @@ class HintKeyParser(basekeyparser.BaseKeyParser):
hintmanager: hints.HintManager,
parent: QObject = None) -> None:
super().__init__(mode=usertypes.KeyMode.hint, win_id=win_id,
parent=parent, supports_count=False)
parent=parent, supports_count=False,
allow_partial_timeout=False)
self._command_parser = CommandKeyParser(mode=usertypes.KeyMode.hint,
win_id=win_id,
commandrunner=commandrunner,
parent=self,
supports_count=False)
supports_count=False,
allow_partial_timeout=False)
self._hintmanager = hintmanager
self._filtertext = ''
self._last_press = LastPress.none
@ -343,7 +241,8 @@ class RegisterKeyParser(CommandKeyParser):
win_id=win_id,
commandrunner=commandrunner,
parent=parent,
supports_count=False)
supports_count=False,
allow_partial_timeout=False)
self._register_mode = mode
def handle(self, e: QKeyEvent, *,

View File

@ -20,12 +20,17 @@ BINDINGS = {'prompt': {'<Ctrl-a>': 'message-info ctrla',
'1': 'message-info 1'},
'command': {'foo': 'message-info bar',
'<Ctrl+X>': 'message-info ctrlx'},
'normal': {'a': 'message-info a', 'ba': 'message-info ba'}}
'normal': {'a': 'message-info a',
'ba': 'message-info ba',
'ccc': 'message-info ccc'}
}
MAPPINGS = {
'x': 'a',
'b': 'a',
'e': 'd',
}
# TODO: ensure multiple-length mappings are safe
@pytest.fixture
def keyinput_bindings(config_stub, key_config_stub):

View File

@ -10,6 +10,7 @@ import sys
from unittest import mock
from qutebrowser.qt.core import Qt
from qutebrowser.qt.gui import QKeySequence
import pytest
from qutebrowser.keyinput import basekeyparser, keyutils
@ -82,6 +83,38 @@ def test_split_count(config_stub, key_config_stub,
assert kp._sequence == keyseq(command)
# NOTE: One may want to change this behavior in the future.
@pytest.mark.parametrize('input_key, final_count, final_match_state', [
('2b1a', 21, QKeySequence.SequenceMatch.ExactMatch),
('1b1a', 11, QKeySequence.SequenceMatch.ExactMatch),
('1b0a', 10, QKeySequence.SequenceMatch.ExactMatch),
('32b1a', 321, QKeySequence.SequenceMatch.ExactMatch),
('3b21a', 321, QKeySequence.SequenceMatch.ExactMatch),
('2c1c', 21, QKeySequence.SequenceMatch.PartialMatch),
('1c1c', 11, QKeySequence.SequenceMatch.PartialMatch),
('1c0c', 10, QKeySequence.SequenceMatch.PartialMatch),
('32c1c', 321, QKeySequence.SequenceMatch.PartialMatch),
('3c21c', 321, QKeySequence.SequenceMatch.PartialMatch),
('2c1cc', 21, QKeySequence.SequenceMatch.ExactMatch),
('2cc1c', 21, QKeySequence.SequenceMatch.ExactMatch),
('c2c1c', 21, QKeySequence.SequenceMatch.ExactMatch),
('3c2c1c', 321, QKeySequence.SequenceMatch.ExactMatch),
])
def test_mixed_count(keyparser, config_stub, input_key, final_count, final_match_state):
for i, info in enumerate(keyseq(input_key)):
result = keyparser.handle(info.to_event())
if (len(input_key) - 1) != i:
assert result == QKeySequence.SequenceMatch.PartialMatch
else:
assert result == final_match_state
if result == QKeySequence.SequenceMatch.ExactMatch:
keyparser.execute.assert_called_once_with(mock.ANY, final_count)
elif result == QKeySequence.SequenceMatch.PartialMatch:
assert keyparser._count == str(final_count)
else:
assert False, 'Not Implemented'
def test_empty_binding(keyparser, config_stub):
"""Make sure setting an empty binding doesn't crash."""
config_stub.val.bindings.commands = {'normal': {'co': ''}}
@ -133,7 +166,7 @@ class TestHandle:
'message-info ctrla', 5)
@pytest.mark.parametrize('keys', [
[(Qt.Key.Key_B, Qt.KeyboardModifier.NoModifier), (Qt.Key.Key_C, Qt.KeyboardModifier.NoModifier)],
[(Qt.Key.Key_B, Qt.KeyboardModifier.NoModifier), (Qt.Key.Key_D, Qt.KeyboardModifier.NoModifier)],
[(Qt.Key.Key_A, Qt.KeyboardModifier.ControlModifier | Qt.KeyboardModifier.AltModifier)],
# Only modifier
[(Qt.Key.Key_Shift, Qt.KeyboardModifier.ShiftModifier)],
@ -256,6 +289,118 @@ class TestHandle:
keyparser.handle(info.to_event())
keyparser.execute.assert_called_once_with('message-info foo', None)
def test_partial_no_execute(self, keyparser, config_stub):
"""Make sure partial matches do not call execute."""
config_stub.val.bindings.commands = {
'normal': {
'abc': 'message-info foo',
}
}
info = keyutils.KeyInfo(Qt.Key.Key_A, Qt.KeyboardModifier.NoModifier)
result = keyparser.handle(info.to_event())
assert result == QKeySequence.SequenceMatch.PartialMatch
assert not keyparser.execute.called
info = keyutils.KeyInfo(Qt.Key.Key_B, Qt.KeyboardModifier.NoModifier)
result = keyparser.handle(info.to_event())
assert result == QKeySequence.SequenceMatch.PartialMatch
assert not keyparser.execute.called
info = keyutils.KeyInfo(Qt.Key.Key_C, Qt.KeyboardModifier.NoModifier)
result = keyparser.handle(info.to_event())
assert result == QKeySequence.SequenceMatch.ExactMatch
assert keyparser.execute.called
def test_partial_retains_sequence(self, keyparser, config_stub):
"""Make sure partial matches retain the sequence."""
config_stub.val.bindings.commands = {
'normal': {
'abc': 'message-info foo',
}
}
info = keyutils.KeyInfo(Qt.Key.Key_A, Qt.KeyboardModifier.NoModifier)
result = keyparser.handle(info.to_event())
assert result == QKeySequence.SequenceMatch.PartialMatch
assert keyparser._sequence == keyseq('a')
info = keyutils.KeyInfo(Qt.Key.Key_B, Qt.KeyboardModifier.NoModifier)
result = keyparser.handle(info.to_event())
assert result == QKeySequence.SequenceMatch.PartialMatch
assert keyparser._sequence == keyseq('ab')
info = keyutils.KeyInfo(Qt.Key.Key_C, Qt.KeyboardModifier.NoModifier)
result = keyparser.handle(info.to_event())
assert result == QKeySequence.SequenceMatch.ExactMatch
assert keyparser._sequence == keyseq('')
@pytest.mark.parametrize('seq', [
(Qt.Key.Key_F,),
(Qt.Key.Key_F, Qt.Key.Key_G),
(Qt.Key.Key_F, Qt.Key.Key_G, Qt.Key.Key_H),
(Qt.Key.Key_2, Qt.Key.Key_F,),
(Qt.Key.Key_2, Qt.Key.Key_F, Qt.Key.Key_G),
(Qt.Key.Key_2, Qt.Key.Key_F, Qt.Key.Key_G, Qt.Key.Key_H),
])
def test_forward_keys(self, config_stub, handle_text, keyparser, seq,
qtbot):
config_stub.val.bindings.commands = {
'normal': {
'fy': 'message-info fy',
'fgy': 'message-info fgy',
'fghy': 'message-info fghy',
}
}
forward_partial_key = mock.Mock()
keyparser.forward_partial_key.connect(forward_partial_key)
handle_text(keyparser, *seq)
keyparser.execute.assert_not_called()
seq = list(seq) + [Qt.Key.Key_Z]
signals = [keyparser.forward_partial_key] * len(seq)
with qtbot.wait_signals(signals) as blocker:
handle_text(keyparser, seq[-1])
assert forward_partial_key.call_args_list == [
((str(keyutils.KeyInfo(key, Qt.KeyboardModifier.NoModifier)),),) for key in seq
]
@pytest.mark.parametrize('seq, count_seq', [
((Qt.Key.Key_F,), ()),
((Qt.Key.Key_F,), (Qt.Key.Key_2,)),
((Qt.Key.Key_F,), (Qt.Key.Key_2, Qt.Key.Key_1)),
((Qt.Key.Key_F, Qt.Key.Key_G), ()),
((Qt.Key.Key_F, Qt.Key.Key_G), (Qt.Key.Key_2,)),
((Qt.Key.Key_F, Qt.Key.Key_G), (Qt.Key.Key_2, Qt.Key.Key_1)),
((Qt.Key.Key_F, Qt.Key.Key_G, Qt.Key.Key_H), ()),
((Qt.Key.Key_F, Qt.Key.Key_G, Qt.Key.Key_H), (Qt.Key.Key_2,)),
((Qt.Key.Key_F, Qt.Key.Key_G, Qt.Key.Key_H), (Qt.Key.Key_2, Qt.Key.Key_1)),
((Qt.Key.Key_2, Qt.Key.Key_F), ()),
((Qt.Key.Key_2, Qt.Key.Key_F), (Qt.Key.Key_2,)),
((Qt.Key.Key_2, Qt.Key.Key_F), (Qt.Key.Key_2, Qt.Key.Key_1)),
((Qt.Key.Key_2, Qt.Key.Key_F, Qt.Key.Key_G), ()),
((Qt.Key.Key_2, Qt.Key.Key_F, Qt.Key.Key_G), (Qt.Key.Key_2,)),
((Qt.Key.Key_2, Qt.Key.Key_F, Qt.Key.Key_G), (Qt.Key.Key_2, Qt.Key.Key_1)),
((Qt.Key.Key_2, Qt.Key.Key_F, Qt.Key.Key_G, Qt.Key.Key_H), ()),
((Qt.Key.Key_2, Qt.Key.Key_F, Qt.Key.Key_G, Qt.Key.Key_H), (Qt.Key.Key_2,)),
((Qt.Key.Key_2, Qt.Key.Key_F, Qt.Key.Key_G, Qt.Key.Key_H), (Qt.Key.Key_2, Qt.Key.Key_1)),
])
def test_forward_keys_partial(self, config_stub, handle_text, keyparser,
seq, count_seq, qtbot):
config_stub.val.bindings.commands = {
'normal': {
'fy': 'message-info fy',
'fgy': 'message-info fgy',
'fghy': 'message-info fghy',
}
}
forward_partial_key = mock.Mock()
keyparser.forward_partial_key.connect(forward_partial_key)
handle_text(keyparser, *seq)
keyparser.execute.assert_not_called()
signals = [keyparser.forward_partial_key] * len(seq)
with qtbot.wait_signals(signals) as blocker:
handle_text(keyparser, *count_seq, Qt.Key.Key_F)
assert forward_partial_key.call_args_list == [
((str(keyutils.KeyInfo(key, Qt.KeyboardModifier.NoModifier)),),) for key in seq
]
assert keyparser._count == ''.join(
str(keyutils.KeyInfo(key, Qt.KeyboardModifier.NoModifier)) for key in count_seq
)
class TestCount:
@ -283,7 +428,7 @@ class TestCount:
def test_count_42_invalid(self, handle_text, prompt_keyparser):
# Invalid call with ccx gets ignored
handle_text(prompt_keyparser,
Qt.Key.Key_4, Qt.Key.Key_2, Qt.Key.Key_C, Qt.Key.Key_C, Qt.Key.Key_X)
Qt.Key.Key_4, Qt.Key.Key_2, Qt.Key.Key_C, Qt.Key.Key_C, Qt.Key.Key_E)
assert not prompt_keyparser.execute.called
assert not prompt_keyparser._sequence
# Valid call with ccc gets the correct count

View File

@ -4,7 +4,7 @@
import pytest
from qutebrowser.qt.core import Qt, QObject, pyqtSignal
from qutebrowser.qt.core import Qt, QObject, pyqtSignal, QTimer
from qutebrowser.qt.gui import QKeyEvent, QKeySequence
from qutebrowser.utils import usertypes
@ -18,10 +18,13 @@ class FakeKeyparser(QObject):
keystring_updated = pyqtSignal(str)
request_leave = pyqtSignal(usertypes.KeyMode, str, bool)
forward_partial_key = pyqtSignal(str)
clear_partial_keys = pyqtSignal()
def __init__(self):
super().__init__()
self.passthrough = False
self.allow_partial_timeout = False
def handle(
self,
@ -54,3 +57,96 @@ def test_non_alphanumeric(key, modifiers, filtered, modeman):
"""Make sure non-alphanumeric keys are passed through correctly."""
evt = keyutils.KeyInfo(key=key, modifiers=modifiers).to_event()
assert modeman.handle_event(evt) == filtered
class FakeKeyparserWithTimeout(QObject):
"""A minimal fake BaseKeyParser for testing partial timeouts."""
keystring_updated = pyqtSignal(str)
request_leave = pyqtSignal(usertypes.KeyMode, str, bool)
forward_partial_key = pyqtSignal(str)
clear_partial_keys = pyqtSignal()
def __init__(self):
super().__init__()
self.passthrough = False
self.allow_partial_timeout = True
self.fake_clear_keystring_called = False
def handle(self, evt, *, dry_run=False):
txt = str(keyutils.KeyInfo.from_event(evt))
if 'a' == txt:
return QKeySequence.SequenceMatch.ExactMatch
elif 'b' == txt:
return QKeySequence.SequenceMatch.PartialMatch
else:
return QKeySequence.SequenceMatch.NoMatch
def clear_keystring(self):
self.fake_clear_keystring_called = True
self.keystring_updated.emit('')
@pytest.fixture
def modeman_with_timeout(mode_manager):
mode_manager.register(usertypes.KeyMode.normal, FakeKeyparserWithTimeout())
return mode_manager
@pytest.mark.parametrize('data_sequence', [
((Qt.Key.Key_A, 'timer_inactive'),),
((Qt.Key.Key_B, 'timer_active'),),
((Qt.Key.Key_C, 'timer_inactive'),),
((Qt.Key.Key_B, 'timer_active'), (Qt.Key.Key_A, 'timer_inactive'),),
((Qt.Key.Key_B, 'timer_active'), (Qt.Key.Key_B, 'timer_reset'),),
((Qt.Key.Key_B, 'timer_active'), (Qt.Key.Key_C, 'timer_inactive'),),
((Qt.Key.Key_B, 'timer_active'), (Qt.Key.Key_B, 'timer_reset'), (Qt.Key.Key_A, 'timer_inactive'),),
((Qt.Key.Key_B, 'timer_active'), (Qt.Key.Key_B, 'timer_reset'), (Qt.Key.Key_B, 'timer_reset'),),
((Qt.Key.Key_B, 'timer_active'), (Qt.Key.Key_B, 'timer_reset'), (Qt.Key.Key_C, 'timer_inactive'),),
])
def test_partial_keychain_timeout(modeman_with_timeout, config_stub, qtbot, data_sequence):
"""Test partial keychain timeout behavior."""
mode = modeman_with_timeout.mode
timeout = 100
config_stub.val.input.partial_timeout = timeout
timer = modeman_with_timeout._partial_timer
parser = modeman_with_timeout.parsers[mode]
assert not timer.isActive()
for key, behavior in data_sequence:
keyinfo = keyutils.KeyInfo(key, Qt.KeyboardModifier.NoModifier)
if behavior == 'timer_active':
# Timer should be active
modeman_with_timeout.handle_event(keyinfo.to_event())
assert timer.isSingleShot()
assert timer.interval() == timeout
assert timer.isActive()
elif behavior == 'timer_inactive':
# Timer should be inactive
modeman_with_timeout.handle_event(keyinfo.to_event())
assert not timer.isActive()
assert not parser.fake_clear_keystring_called
elif behavior == 'timer_reset':
# Timer should be reset after handling the key
half_timer = QTimer()
half_timer.setSingleShot(True)
half_timer.setInterval(timeout//2)
half_timer.start()
# Simulate a half timeout to check for reset
qtbot.wait_signal(half_timer.timeout).wait()
assert (timeout - (timeout//4)) > timer.remainingTime()
modeman_with_timeout.handle_event(keyinfo.to_event())
assert (timeout - (timeout//4)) < timer.remainingTime()
assert timer.isActive()
else:
# Unreachable
assert False
if behavior in ['timer_active', 'timer_reset']:
# Now simulate a timeout and check the keystring has been cleared.
with qtbot.wait_signal(modeman_with_timeout.keystring_updated) as blocker:
timer.timeout.emit()
assert parser.fake_clear_keystring_called
parser.fake_clear_keystring_called = False
assert blocker.args == [mode, '']

View File

@ -42,31 +42,6 @@ class TestsNormalKeyParser:
assert commandrunner.commands == [('message-info ba', None)]
assert not keyparser._sequence
def test_partial_keychain_timeout(self, keyparser, config_stub,
qtbot, commandrunner):
"""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(keyutils.KeyInfo(Qt.Key.Key_B, Qt.KeyboardModifier.NoModifier).to_event())
assert timer.isSingleShot()
assert timer.interval() == 100
assert timer.isActive()
assert not commandrunner.commands
assert keyparser._sequence == keyutils.KeySequence.parse('b')
# Now simulate a timeout and check the keystring has been cleared.
with qtbot.wait_signal(keyparser.keystring_updated) as blocker:
timer.timeout.emit()
assert not commandrunner.commands
assert not keyparser._sequence
assert blocker.args == ['']
class TestHintKeyParser: