diff --git a/qutebrowser/keyinput/basekeyparser.py b/qutebrowser/keyinput/basekeyparser.py index 2a79e0240..57fba5998 100644 --- a/qutebrowser/keyinput/basekeyparser.py +++ b/qutebrowser/keyinput/basekeyparser.py @@ -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 diff --git a/qutebrowser/keyinput/modeman.py b/qutebrowser/keyinput/modeman.py index 681deeff6..b2f3652a2 100644 --- a/qutebrowser/keyinput/modeman.py +++ b/qutebrowser/keyinput/modeman.py @@ -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. diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py index f97a28320..3d904baf9 100644 --- a/qutebrowser/keyinput/modeparsers.py +++ b/qutebrowser/keyinput/modeparsers.py @@ -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, *, diff --git a/tests/unit/keyinput/conftest.py b/tests/unit/keyinput/conftest.py index 60bd78fb3..de1c1d8e6 100644 --- a/tests/unit/keyinput/conftest.py +++ b/tests/unit/keyinput/conftest.py @@ -20,12 +20,17 @@ BINDINGS = {'prompt': {'': 'message-info ctrla', '1': 'message-info 1'}, 'command': {'foo': 'message-info bar', '': '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): diff --git a/tests/unit/keyinput/test_basekeyparser.py b/tests/unit/keyinput/test_basekeyparser.py index ec7c225bf..0a85e76e0 100644 --- a/tests/unit/keyinput/test_basekeyparser.py +++ b/tests/unit/keyinput/test_basekeyparser.py @@ -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 diff --git a/tests/unit/keyinput/test_modeman.py b/tests/unit/keyinput/test_modeman.py index 679b3d91e..9532081c2 100644 --- a/tests/unit/keyinput/test_modeman.py +++ b/tests/unit/keyinput/test_modeman.py @@ -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, ''] + diff --git a/tests/unit/keyinput/test_modeparsers.py b/tests/unit/keyinput/test_modeparsers.py index 6a83d614b..42d0f39ce 100644 --- a/tests/unit/keyinput/test_modeparsers.py +++ b/tests/unit/keyinput/test_modeparsers.py @@ -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: