From 2956c5f2c3b9c6ab07959831a0069725b83338ab Mon Sep 17 00:00:00 2001 From: brightonanc Date: Wed, 4 May 2022 23:33:13 -0400 Subject: [PATCH] Refined the widget forwarding mechanism for key forwarding. Added documentation, formatting, etc. --- qutebrowser/browser/browsertab.py | 28 ++++- qutebrowser/browser/commands.py | 4 +- qutebrowser/browser/webelem.py | 2 +- qutebrowser/keyinput/basekeyparser.py | 111 ++++++++++++-------- qutebrowser/keyinput/keyutils.py | 11 +- qutebrowser/keyinput/modeman.py | 143 +++++++++++++++++++------- qutebrowser/keyinput/modeparsers.py | 49 ++++++--- tests/unit/keyinput/conftest.py | 1 - 8 files changed, 244 insertions(+), 105 deletions(-) diff --git a/qutebrowser/browser/browsertab.py b/qutebrowser/browser/browsertab.py index 597b8d37c..c4d912705 100644 --- a/qutebrowser/browser/browsertab.py +++ b/qutebrowser/browser/browsertab.py @@ -1101,7 +1101,7 @@ class AbstractTab(QWidget): self._load_status = val self.load_status_changed.emit(val) - def send_event(self, evt: QEvent) -> None: + def post_event(self, evt: QEvent) -> None: """Send the given event to the underlying widget. The event will be sent via QApplication.postEvent. @@ -1122,6 +1122,28 @@ class AbstractTab(QWidget): evt.posted = True # type: ignore[attr-defined] QApplication.postEvent(recipient, evt) + def send_event(self, evt: QEvent) -> bool: + """Send the given event to the underlying widget. + + The event will be sent via QApplication.sendEvent. + Note that a sent event is not deleted after return. + Note that a posted event must not be re-used in any way! + """ + # This only gives us some mild protection against re-using events, but + # it's certainly better than a segfault. + if getattr(evt, 'posted', False): + raise utils.Unreachable("Can't re-use an event which was already " + "posted!") + + recipient = self.private_api.event_target() + if recipient is None: + # https://github.com/qutebrowser/qutebrowser/issues/3888 + log.webview.warning("Unable to find event target!") + return + + evt.posted = True # type: ignore[attr-defined] + return QApplication.sendEvent(recipient, evt) + def navigation_blocked(self) -> bool: """Test if navigation is allowed on the current tab.""" return self.data.pinned and config.val.tabs.pinned.frozen @@ -1253,8 +1275,8 @@ class AbstractTab(QWidget): press_evt = QKeyEvent(QEvent.Type.KeyPress, key, modifier, 0, 0, 0) release_evt = QKeyEvent(QEvent.Type.KeyRelease, key, modifier, 0, 0, 0) - self.send_event(press_evt) - self.send_event(release_evt) + self.post_event(press_evt) + self.post_event(release_evt) def dump_async(self, callback: Callable[[str], None], *, diff --git a/qutebrowser/browser/commands.py b/qutebrowser/browser/commands.py index ebce4b37a..60163ceec 100644 --- a/qutebrowser/browser/commands.py +++ b/qutebrowser/browser/commands.py @@ -1781,8 +1781,8 @@ class CommandDispatcher: QApplication.postEvent(window, release_event) else: tab = self._current_widget() - tab.send_event(press_event) - tab.send_event(release_event) + tab.post_event(press_event) + tab.post_event(release_event) @cmdutils.register(instance='command-dispatcher', scope='window', debug=True, backend=usertypes.Backend.QtWebKit) diff --git a/qutebrowser/browser/webelem.py b/qutebrowser/browser/webelem.py index 82960cc8d..aeeafa43b 100644 --- a/qutebrowser/browser/webelem.py +++ b/qutebrowser/browser/webelem.py @@ -443,7 +443,7 @@ class AbstractWebElement(collections.abc.MutableMapping): # type: ignore[type-a pos = self._mouse_pos() event = QMouseEvent(QEvent.Type.MouseMove, pos, Qt.MouseButton.NoButton, Qt.MouseButton.NoButton, Qt.KeyboardModifier.NoModifier) - self._tab.send_event(event) + self._tab.post_event(event) def right_click(self) -> None: """Simulate a right-click on the element.""" diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 57fba5998..dbcedc008 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -148,12 +148,24 @@ class BaseKeyParser(QObject): bindings: Bound key bindings _mode: The usertypes.KeyMode associated with this keyparser. _win_id: The window ID this keyparser is associated with. + _pure_sequence: The currently entered key sequence (exactly as typed, + no substitutions performed) _sequence: The currently entered key sequence + _count: The currently entered count + _count_keyposs: Locations of count characters in the typed sequence + (self._count[i] was typed before + self._pure_sequence[self._count_keyposs[i]]) _do_log: Whether to log keypresses or not. passthrough: Whether unbound keys should be passed through with this handler. _supports_count: Whether count is supported. - _partial_timer: Timer to clear partial keypresses. + allow_partial_timeout: Whether this key parser allows for partial keys + to be forwarded after a timeout. + allow_forward: Whether this key parser allows for unmatched partial + keys to be forwarded to underlying widgets. + forward_widget_name: Name of the widget to which partial keys are + forwarded. If None, the browser's current widget + is used. Signals: keystring_updated: Emitted when the keystring is updated. @@ -162,8 +174,11 @@ class BaseKeyParser(QObject): arg 0: Mode to leave. arg 1: Reason for leaving. arg 2: Ignore the request if we're not in that mode + forward_partial_key: Emitted when a partial key should be forwarded. + arg: Text expected to be forwarded (used solely + for debug info, default is None). + clear_partial_keys: Emitted to clear recorded partial keys. """ - #TODO: partial docs keystring_updated = pyqtSignal(str) request_leave = pyqtSignal(usertypes.KeyMode, str, bool) @@ -176,11 +191,13 @@ class BaseKeyParser(QObject): do_log: bool = True, passthrough: bool = False, supports_count: bool = True, - allow_partial_timeout: bool = False) -> None: + allow_partial_timeout: bool = False, + allow_forward: bool = True, + forward_widget_name: str = None) -> None: super().__init__(parent) self._win_id = win_id - self._sequence = keyutils.KeySequence() self._pure_sequence = keyutils.KeySequence() + self._sequence = keyutils.KeySequence() self._count = '' self._count_keyposs = [] self._mode = mode @@ -188,6 +205,8 @@ class BaseKeyParser(QObject): self.passthrough = passthrough self._supports_count = supports_count self.allow_partial_timeout = allow_partial_timeout + self.allow_forward = allow_forward + self.forward_widget_name = forward_widget_name self.bindings = BindingTrie() self._read_config() config.instance.changed.connect(self._on_config_changed) @@ -291,9 +310,8 @@ class BaseKeyParser(QObject): self._debug_log(f"Got key: {info!r} (dry_run {dry_run})") - if info.is_modifier_key(): - self._debug_log("Ignoring, only modifier") - return QKeySequence.SequenceMatch.NoMatch + # Modifier keys handled in modeman + assert not keyutils.is_modifier_key(key) had_empty_queue = (not self._pure_sequence) and (not self._count) @@ -304,8 +322,9 @@ class BaseKeyParser(QObject): self.clear_keystring() return QKeySequence.SequenceMatch.NoMatch + flag0 = True # Have these shadow variables to have replicable behavior when doing a - # dry_run + # dry run count = self._count count_keyposs = self._count_keyposs.copy() while pure_sequence: @@ -320,7 +339,8 @@ class BaseKeyParser(QObject): "mappings.".format(result.sequence)) seq_len = len(result.sequence) result = self._match_key_mapping(result.sequence) - if result.match_type == QKeySequence.SequenceMatch.NoMatch: + if (result.match_type == QKeySequence.SequenceMatch.NoMatch) and flag0: + flag0 = False # 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. @@ -342,45 +362,48 @@ class BaseKeyParser(QObject): "matching will be attempted.".format( result.sequence)) if not dry_run: + # Update state variables 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] + assert pure_sequence + if not had_empty_queue: + self._debug_log("No match for '{}'. Will forward first " + "key in the sequence and retry.".format( + result.sequence)) + # Forward all the leading count keys + 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._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 + self.forward_partial_key.emit(self._count[0]) + self._count = self._count[1:] + self._count_keyposs.pop(0) + self._debug_log("Forwarding first key in sequence " + "('{}').".format(str(pure_sequence[0]))) + # Update the count_keyposs to reflect the shortened + # pure_sequence + count_keyposs = [x - 1 for x in count_keyposs] + if not dry_run: + self._count_keyposs = [x - 1 for x in self._count_keyposs] + 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:] + # self._pure_sequence is updated either on next loop in the 'Update + # state variables' block or (if pure_sequence is empty and there is + # no next loop) in the self.clear_keystring call in the NoMatch + # block below if dry_run: return result.match_type + # Each of the three following blocks need to emit + # self.keystring_updated, either directly (as PartialMatch does) or + # indirectly (as ExactMatch and NoMatch do via self.clear_keystring) if result.match_type == QKeySequence.SequenceMatch.ExactMatch: assert result.command is not None self._debug_log("Definitive match for '{}'.".format( @@ -437,11 +460,11 @@ class BaseKeyParser(QObject): 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._pure_sequence should non-empty if and only if self._sequence is + # non-empty, but to be safe both conditions are included below + if self._pure_sequence or self._sequence: self._debug_log("Clearing keystring (was: {}).".format( self._sequence)) + self._pure_sequence = keyutils.KeySequence() self._sequence = keyutils.KeySequence() - self.keystring_updated.emit('') + self.keystring_updated.emit('') diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py index b82875d8a..3fde6830e 100644 --- a/qutebrowser/keyinput/keyutils.py +++ b/qutebrowser/keyinput/keyutils.py @@ -530,7 +530,6 @@ class KeyEvent: @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 @@ -538,9 +537,10 @@ class QueuedKeyEventPair: 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_info_press: A KeyInfo member for complete event reconstruction + (e.g. with modifiers) corresponding to the press event. + key_info_release: A KeyInfo member for complete event reconstruction + (e.g. with modifiers) corresponding to the release event. """ key_event: KeyEvent @@ -553,12 +553,13 @@ class QueuedKeyEventPair: return cls(KeyEvent.from_event(event), KeyInfo.from_event(event), None) def add_event_release(self, event: QKeyEvent) -> bool: + """Attempt to add a release event. Returns True if successful.""" if self.key_event == KeyEvent.from_event(event): self.key_info_release = KeyInfo.from_event(event) return True return False - def is_released(self): + def is_released(self) -> bool: return self.key_info_release is not None def to_events(self) -> Tuple[QKeyEvent]: diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py index b877d8452..841e58a27 100644 --- a/qutebrowser/keyinput/modeman.py +++ b/qutebrowser/keyinput/modeman.py @@ -102,7 +102,9 @@ def init(win_id: int, parent: QObject) -> 'ModeManager': passthrough=True, do_log=log_sensitive_keys, supports_count=False, - allow_partial_timeout=True), + allow_partial_timeout=True, + allow_forward=True, + forward_widget_name='status-command'), usertypes.KeyMode.prompt: modeparsers.CommandKeyParser( @@ -113,7 +115,12 @@ def init(win_id: int, parent: QObject) -> 'ModeManager': passthrough=True, do_log=log_sensitive_keys, supports_count=False, - allow_partial_timeout=True), + # Maybe in the future implement this, but for the time being its + # infeasible as 'prompt-container' is registered as command-only. + # Plus, I imagine the use case for such a thing is quite rare. + allow_forward=False, + forward_widget_name=None, #'prompt-container' + allow_partial_timeout=False), usertypes.KeyMode.yesno: modeparsers.CommandKeyParser( @@ -122,7 +129,10 @@ def init(win_id: int, parent: QObject) -> 'ModeManager': commandrunner=commandrunner, parent=modeman, supports_count=False, - allow_partial_timeout=True), + # Similar story to prompt mode + allow_forward=False, + forward_widget_name=None, + allow_partial_timeout=False), usertypes.KeyMode.caret: modeparsers.CommandKeyParser( @@ -211,6 +221,8 @@ class ModeManager(QObject): _releaseevents_to_pass: A set of KeyEvents where the keyPressEvent was passed through, so the release event should as well. + _partial_timer: The timer which forwards partial keys after no key has + been pressed for a timeout period. Signals: entered: Emitted when a mode is entered. @@ -220,10 +232,15 @@ class ModeManager(QObject): arg1: The mode which has been left. arg2: The new current mode. arg3: The window ID of this mode manager. - keystring_updated: Emitted when the keystring was updated in any mode. - arg 1: The mode in which the keystring has been - updated. - arg 2: The new key string. + keystring_updated: Emitted when the keystring was updated in any mode. + arg1: The mode in which the keystring has been + updated. + arg2: The new key string. + forward_partial_key: Emitted when a partial key should be forwarded. + arg1: The mode in which the partial key was + pressed. + arg2: Text expected to be forwarded (used solely + for debug info, default is None). """ entered = pyqtSignal(usertypes.KeyMode, int) @@ -240,8 +257,7 @@ class ModeManager(QObject): self._releaseevents_to_pass: set[keyutils.KeyEvent] = set() # Set after __init__ self.hintmanager = cast(hints.HintManager, None) - # TODO: type hints - self._partial_match_events = [] + self._partial_match_events: Sequence[keyutils.QueuedKeyEventPair] = [] self.forward_partial_key.connect(self.forward_partial_match_event) self._partial_timer = usertypes.Timer(self, 'partial-match') self._partial_timer.setSingleShot(True) @@ -267,13 +283,24 @@ class ModeManager(QObject): "{}".format(curmode, utils.qualname(parser))) had_empty_queue = not self._partial_match_events - if (not dry_run) and (not had_empty_queue): + if parser.allow_forward and (not dry_run) and (not had_empty_queue): + # Immediately record the event so that parser.handle may forward if + # appropriate from its logic. self._partial_match_events.append( - keyutils.QueuedKeyEventPair.from_event_press(event)) + keyutils.QueuedKeyEventPair.from_event_press(event)) - match = parser.handle(event, dry_run=dry_run) + if keyutils.is_modifier_key(Qt.Key(event.key())): + if curmode != usertypes.KeyMode.insert: + log.modes.debug("Ignoring, only modifier") + if not dry_run: + # Since this is a NoMatch without a call to parser.handle, we + # must manually forward the events + self.forward_all_partial_match_events(self.mode, + stop_timer=True) + match = QKeySequence.NoMatch + else: + match = parser.handle(event, dry_run=dry_run) - # TODO: Check dry_run conditions are everywhere if match == QKeySequence.SequenceMatch.ExactMatch: filter_this = True if not dry_run: @@ -281,19 +308,21 @@ class ModeManager(QObject): self.clear_partial_match_events() elif match == QKeySequence.SequenceMatch.PartialMatch: filter_this = True - if not dry_run: + if parser.allow_forward and (not dry_run): if had_empty_queue: + # Begin recording partial match events self._partial_match_events.append( - keyutils.QueuedKeyEventPair.from_event_press(event)) + keyutils.QueuedKeyEventPair.from_event_press(event)) self._start_partial_timer() elif not had_empty_queue: + # Since partial events were recorded, this event must be filtered. + # Since a NoMatch was found, this event has already been forwarded 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) + filter_this = self._should_filter_event(key_info, parser) if not filter_this and not dry_run: self._releaseevents_to_pass.add(keyutils.KeyEvent.from_event(event)) @@ -313,7 +342,7 @@ class ModeManager(QObject): 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) + should_filter_event = self._should_filter_event(key_info, parser) focus_widget = objects.qapp.focusWidget() log.modes.debug("match: {}, forward_unbound_keys: {}, " "passthrough: {}, is_non_alnum: {}, " @@ -321,7 +350,7 @@ class ModeManager(QObject): "--> filter: {} (focused: {!r})".format( match, forward_unbound_keys, parser.passthrough, is_non_alnum, - should_forward_event, dry_run, filter_this, + should_filter_event, dry_run, filter_this, qtutils.qobj_repr(focus_widget))) return filter_this @@ -340,6 +369,8 @@ class ModeManager(QObject): self._releaseevents_to_pass.remove(keyevent) filter_this = False else: + # Record the releases for partial matches to later forward along + # with the presses for match_event in self._partial_match_events[::-1]: if match_event.add_event_release(event): break @@ -349,7 +380,9 @@ class ModeManager(QObject): return filter_this @staticmethod - def _should_forward_event(key_info, parser): + def _should_filter_event(key_info: keyutils.KeyInfo, + parser: basekeyparser.BaseKeyParser) -> bool: + """Returns True if the event should be filtered, False otherwise.""" if machinery.IS_QT5: # FIXME:v4 needed for Qt 5 typing ignored_modifiers = [ cast(Qt.KeyboardModifiers, Qt.KeyboardModifier.NoModifier), @@ -363,42 +396,79 @@ class ModeManager(QObject): has_modifier = key_info.modifiers not in ignored_modifiers 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)) and ( - not isinstance(parser, modeparsers.HintKeyParser)) + return not (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 + def forward_partial_match_event(self, mode: usertypes.KeyMode, + text: str = None) -> None: + """Forward the oldest partial match event for a given mode + + Args: + mode: The mode from which the forwarded match is. + text: The expected text to be forwarded. Only used for debug + purposes. Default is None. + """ 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 + if parser.allow_forward: + log.modes.warning("Attempting to forward for mode {} " + "(expected text = {}), which should allow " + "forwarding, but there are no events to " + "forward.".format(mode, text)) 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() + if parser.allow_forward and (not + self._should_filter_event(match_event.key_info_press, parser)): + if mode != usertypes.KeyMode.insert: + log.modes.debug("Forwarding partial match event in mode " + "{}.".format(mode)) + text_actual = str(match_event.key_info_press) + if (text is not None) and (text_actual != text): + log.modes.debug("Text mismatch (this is likely benign): " + "'{}' != '{}'".format(text_actual, text)) + # Get the widget to which the event will be forwarded + widget_name = parser.forward_widget_name + if widget_name is None: + # By default, the widget is the current widget of the browser + tabbed_browser = objreg.get('tabbed-browser', scope='window', + window=self._win_id) + tab = tabbed_browser.widget.currentWidget() + if tab is None: + raise cmdutils.CommandError("No WebView available yet!") + send_event = tab.send_event + else: + # When a specific widget is specified, QApplication.sendEvent + # is used + widget = objreg.get(widget_name, scope='window', + window=self._win_id) + send_event = functools.partial(QApplication.sendEvent, widget) for event_ in match_event.to_events(): - tab.send_event(event_) + 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, *, stop_timer: bool = False) -> None: + """Forward all partial match events for a given mode + + Args: + mode: The mode from which the forwarded match is. + stop_timer: If true, stop the partial timer (and any nested timers) + as well. Default is False. + """ + log.modes.debug("Forwarding all partial matches.") if stop_timer: self._stop_partial_timer() if mode in self.parsers: parser = self.parsers[mode] if isinstance(parser, modeparsers.HintKeyParser): + # Call the subparsers analogous function, propagating the timer + # stopping. parser.forward_all_partial_match_events(stop_timer=True) if self._partial_match_events: while self._partial_match_events: @@ -424,7 +494,7 @@ class ModeManager(QObject): except TypeError: pass self._partial_timer.timeout.connect(functools.partial( - self.forward_all_partial_match_events, self.mode)) + self.forward_all_partial_match_events, self.mode)) self._partial_timer.start() def _stop_partial_timer(self) -> None: @@ -437,7 +507,6 @@ class ModeManager(QObject): 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( diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index d6935bbf6..e2e3e2cbf 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -52,11 +52,15 @@ class CommandKeyParser(basekeyparser.BaseKeyParser): do_log: bool = True, passthrough: bool = False, supports_count: bool = True, - allow_partial_timeout: bool = True) -> None: + allow_partial_timeout: bool = True, + allow_forward: bool = True, + forward_widget_name: str = None) -> None: super().__init__(mode=mode, win_id=win_id, parent=parent, do_log=do_log, passthrough=passthrough, supports_count=supports_count, - allow_partial_timeout=allow_partial_timeout) + allow_partial_timeout=allow_partial_timeout, + allow_forward=allow_forward, + forward_widget_name=forward_widget_name) self._commandrunner = commandrunner def execute(self, cmdstr: str, count: int = None) -> None: @@ -120,6 +124,8 @@ class HintKeyParser(basekeyparser.BaseKeyParser): _filtertext: The text to filter with. _hintmanager: The HintManager to use. _last_press: The nature of the last keypress, a LastPress member. + _partial_timer: The timer which forwards partial keys after no key has + been pressed for a timeout period. """ _sequence: keyutils.KeySequence @@ -130,7 +136,7 @@ class HintKeyParser(basekeyparser.BaseKeyParser): parent: QObject = None) -> None: super().__init__(mode=usertypes.KeyMode.hint, win_id=win_id, parent=parent, supports_count=False, - allow_partial_timeout=False) + allow_partial_timeout=False, allow_forward=False) self._command_parser = CommandKeyParser(mode=usertypes.KeyMode.hint, win_id=win_id, commandrunner=commandrunner, @@ -140,16 +146,16 @@ class HintKeyParser(basekeyparser.BaseKeyParser): self._hintmanager = hintmanager self._filtertext = '' self._last_press = LastPress.none - self._partial_match_events = [] + self._partial_match_events: Sequence[keyutils.QueuedKeyEventPair] = [] self.keystring_updated.connect(self._hintmanager.handle_partial_key) self._command_parser.forward_partial_key.connect( - self.forward_partial_match_event) + self.forward_partial_match_event) self._command_parser.clear_partial_keys.connect( - self.clear_partial_match_events) + self.clear_partial_match_events) self._partial_timer = usertypes.Timer(self, 'partial-match') self._partial_timer.setSingleShot(True) self._partial_timer.timeout.connect( - self.forward_all_partial_match_events) + self.forward_all_partial_match_events) def _handle_filter_key(self, e: QKeyEvent) -> QKeySequence.SequenceMatch: """Handle keys for string filtering.""" @@ -198,8 +204,10 @@ class HintKeyParser(basekeyparser.BaseKeyParser): had_empty_queue = not self._partial_match_events if not had_empty_queue: + # Immediately record the event so that parser.handle may forward if + # appropriate from its logic. self._partial_match_events.append( - keyutils.QueuedKeyEventPair.from_event_press(e)) + keyutils.QueuedKeyEventPair.from_event_press(e)) result = self._command_parser.handle(e) if result == QKeySequence.SequenceMatch.ExactMatch: @@ -211,8 +219,9 @@ class HintKeyParser(basekeyparser.BaseKeyParser): elif result == QKeySequence.SequenceMatch.PartialMatch: log.keyboard.debug("Handling key via command parser") if had_empty_queue: + # Begin recording partial match events self._partial_match_events.append( - keyutils.QueuedKeyEventPair.from_event_press(e)) + keyutils.QueuedKeyEventPair.from_event_press(e)) self._start_partial_timer() return result elif not had_empty_queue: @@ -244,12 +253,22 @@ class HintKeyParser(basekeyparser.BaseKeyParser): @pyqtSlot(str) def forward_partial_match_event(self, text: str = None) -> None: - # TODO: add debug messages + """Forward the oldest partial match event + + Args: + text: The expected text to be forwarded. Only used for debug + purposes. Default is None. + """ if not self._partial_match_events: - # TODO: debug message + self._debug_log("Attempting to forward (expected text = {}) but " + "there are no events to forward.".format(text)) 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 + self._debug_log("Forwarding partial match event.") + text_actual = str(match_event.key_info_press) + if (text is not None) and (text_actual != text): + self._debug_log("Text mismatch (this is likely benign): '{}' != " + "'{}'".format(text_actual, text)) e = match_event.to_events() assert 1 == len(e) e = e[0] @@ -258,6 +277,12 @@ class HintKeyParser(basekeyparser.BaseKeyParser): @pyqtSlot() def forward_all_partial_match_events(self, *, stop_timer: bool = False) -> None: + """Forward all partial match events + + Args: + stop_timer: If true, stop the partial timer as well. Default is False. + """ + self._debug_log("Forwarding all partial matches.") if stop_timer: self._stop_partial_timer() if self._partial_match_events: diff --git a/tests/unit/keyinput/conftest.py b/tests/unit/keyinput/conftest.py index de1c1d8e6..a5e5dbd22 100644 --- a/tests/unit/keyinput/conftest.py +++ b/tests/unit/keyinput/conftest.py @@ -30,7 +30,6 @@ MAPPINGS = { 'e': 'd', } -# TODO: ensure multiple-length mappings are safe @pytest.fixture def keyinput_bindings(config_stub, key_config_stub):