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 (commit51aa7ab) * qutebrowser/keyinput/modeparsers.py for a simple syntax restructure (semantically identical) * tests/unit/keyinput/test_basekeyparser.py for new count precaution (commit51aa7ab) unit test * qutebrowser/keyinput/modeman.py for import changes (commiteb8121f)
This commit is contained in:
parent
205afc28fd
commit
595cda4896
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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.
|
||||
|
||||
|
|
|
|||
|
|
@ -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, *,
|
||||
|
|
|
|||
|
|
@ -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):
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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, '']
|
||||
|
||||
|
|
|
|||
|
|
@ -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:
|
||||
|
||||
|
|
|
|||
Loading…
Reference in New Issue