Implemented the nuances for the hint mode.

This commit is contained in:
brightonanc 2022-03-30 16:50:11 -04:00
parent 595cda4896
commit f114aacad2
5 changed files with 365 additions and 96 deletions

View File

@ -18,7 +18,7 @@ handle what we actually think we do.
import itertools
import dataclasses
from typing import Optional, Union, overload, cast
from typing import Optional, Union, overload, cast, Tuple
from collections.abc import Iterator, Iterable, Mapping
from qutebrowser.qt import machinery
@ -504,6 +504,72 @@ class KeyInfo:
return self.key in _MODIFIER_MAP
@dataclasses.dataclass(frozen=True)
class KeyEvent:
"""A small wrapper over a QKeyEvent storing its data.
This is needed because Qt apparently mutates existing events with new data.
It doesn't store the modifiers because they can be different for a key
press/release.
Attributes:
key: A Qt.Key member (QKeyEvent::key).
text: A string (QKeyEvent::text).
"""
key: Qt.Key
text: str
@classmethod
def from_event(cls, event: QKeyEvent) -> 'KeyEvent':
"""Initialize a KeyEvent from a QKeyEvent."""
return cls(Qt.Key(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: KeyInfo
key_info_release: KeyInfo
@classmethod
def from_event_press(cls, event: QKeyEvent) -> 'QueuedKeyEventPair':
"""Initialize a QueuedKeyEventPair from a QKeyEvent and QKeyEvent."""
return cls(KeyEvent.from_event(event), 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 = 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 KeySequence:
"""A sequence of key presses.

View File

@ -30,74 +30,6 @@ PROMPT_MODES = [usertypes.KeyMode.prompt, usertypes.KeyMode.yesno]
ParserDictType = MutableMapping[usertypes.KeyMode, basekeyparser.BaseKeyParser]
@dataclasses.dataclass(frozen=True)
class KeyEvent:
"""A small wrapper over a QKeyEvent storing its data.
This is needed because Qt apparently mutates existing events with new data.
It doesn't store the modifiers because they can be different for a key
press/release.
Attributes:
key: Usually a Qt.Key member, but could be other ints (QKeyEvent::key).
text: A string (QKeyEvent::text).
"""
# int instead of Qt.Key:
# WORKAROUND for https://www.riverbankcomputing.com/pipermail/pyqt/2022-April/044607.html
key: int
text: str
@classmethod
def from_event(cls, event: QKeyEvent) -> '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):
"""Exception raised when we want to leave a mode we're not in."""
@ -305,7 +237,7 @@ class ModeManager(QObject):
self.parsers: ParserDictType = {}
self._prev_mode = usertypes.KeyMode.normal
self.mode = usertypes.KeyMode.normal
self._releaseevents_to_pass: set[KeyEvent] = set()
self._releaseevents_to_pass: set[keyutils.KeyEvent] = set()
# Set after __init__
self.hintmanager = cast(hints.HintManager, None)
# TODO: type hints
@ -337,7 +269,7 @@ class ModeManager(QObject):
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))
keyutils.QueuedKeyEventPair.from_event_press(event))
match = parser.handle(event, dry_run=dry_run)
@ -352,7 +284,7 @@ class ModeManager(QObject):
if not dry_run:
if had_empty_queue:
self._partial_match_events.append(
QueuedKeyEventPair.from_event_press(event))
keyutils.QueuedKeyEventPair.from_event_press(event))
self._start_partial_timer()
elif not had_empty_queue:
filter_this = True
@ -364,7 +296,7 @@ class ModeManager(QObject):
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))
self._releaseevents_to_pass.add(keyutils.KeyEvent.from_event(event))
if curmode != usertypes.KeyMode.insert:
if machinery.IS_QT5: # FIXME:v4 needed for Qt 5 typing
@ -403,7 +335,7 @@ class ModeManager(QObject):
True if event should be filtered, False otherwise.
"""
# handle like matching KeyPress
keyevent = KeyEvent.from_event(event)
keyevent = keyutils.KeyEvent.from_event(event)
if keyevent in self._releaseevents_to_pass:
self._releaseevents_to_pass.remove(keyevent)
filter_this = False
@ -418,14 +350,22 @@ class ModeManager(QObject):
@staticmethod
def _should_forward_event(key_info, parser):
has_modifier = key_info.modifiers not in [
Qt.NoModifier,
Qt.ShiftModifier,
] # type: ignore[comparison-overlap]
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 = 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))
(forward_unbound_keys == 'auto' and is_non_alnum)) and (
not isinstance(parser, modeparsers.HintKeyParser))
@pyqtSlot(usertypes.KeyMode, str)
def forward_partial_match_event(self, mode: usertypes.KeyMode, text: str = None) -> None:
@ -452,7 +392,14 @@ class ModeManager(QObject):
self._releaseevents_to_pass.add(match_event.key_event)
@pyqtSlot(usertypes.KeyMode)
def forward_all_partial_match_events(self, mode: usertypes.KeyMode) -> None:
def forward_all_partial_match_events(self, mode: usertypes.KeyMode, *,
stop_timer: bool = False) -> None:
if stop_timer:
self._stop_partial_timer()
if mode in self.parsers:
parser = self.parsers[mode]
if isinstance(parser, modeparsers.HintKeyParser):
parser.forward_all_partial_match_events(stop_timer=True)
if self._partial_match_events:
while self._partial_match_events:
self.forward_partial_match_event(mode)
@ -476,15 +423,13 @@ class ModeManager(QObject):
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.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()
self._partial_timer.stop()
def register(self, mode: usertypes.KeyMode,
parser: basekeyparser.BaseKeyParser) -> None:
@ -601,9 +546,8 @@ class ModeManager(QObject):
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.forward_all_partial_match_events(self.mode, stop_timer=True)
self.mode = mode
def handle_event(self, event: QEvent) -> bool:

View File

@ -136,11 +136,20 @@ class HintKeyParser(basekeyparser.BaseKeyParser):
commandrunner=commandrunner,
parent=self,
supports_count=False,
allow_partial_timeout=False)
allow_partial_timeout=True)
self._hintmanager = hintmanager
self._filtertext = ''
self._last_press = LastPress.none
self._partial_match_events = []
self.keystring_updated.connect(self._hintmanager.handle_partial_key)
self._command_parser.forward_partial_key.connect(
self.forward_partial_match_event)
self._command_parser.clear_partial_keys.connect(
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)
def _handle_filter_key(self, e: QKeyEvent) -> QKeySequence.SequenceMatch:
"""Handle keys for string filtering."""
@ -180,16 +189,45 @@ class HintKeyParser(basekeyparser.BaseKeyParser):
dry_run: bool = False) -> QKeySequence.SequenceMatch:
"""Handle a new keypress and call the respective handlers."""
if dry_run:
result = self._command_parser.handle(e, dry_run=True)
if result != QKeySequence.SequenceMatch.NoMatch:
return result
return super().handle(e, dry_run=True)
assert not dry_run
if (self._command_parser.handle(e, dry_run=True) !=
QKeySequence.SequenceMatch.NoMatch):
had_empty_queue = not self._partial_match_events
if not had_empty_queue:
self._partial_match_events.append(
keyutils.QueuedKeyEventPair.from_event_press(e))
result = self._command_parser.handle(e)
if result == QKeySequence.SequenceMatch.ExactMatch:
self._stop_partial_timer()
self.clear_partial_match_events()
log.keyboard.debug("Handling key via command parser")
self.clear_keystring()
return self._command_parser.handle(e)
return result
elif result == QKeySequence.SequenceMatch.PartialMatch:
log.keyboard.debug("Handling key via command parser")
if had_empty_queue:
self._partial_match_events.append(
keyutils.QueuedKeyEventPair.from_event_press(e))
self._start_partial_timer()
return result
elif not had_empty_queue:
self._stop_partial_timer()
# It's unclear exactly what the return here should be. The safest
# bet seems to be PartialMatch as it won't clear the unused
# modeman._partial_match_events buffer, which if done could lead to
# an issue if forward_partial were called with an empty buffer. At
# the time of writing this, the behaviors of returning
# ExactMatch/PartialMatch are identical, practically speaking.
return QKeySequence.SequenceMatch.PartialMatch
else:
return self._handle_hint(e)
def _handle_hint(self, e: QKeyEvent) -> QKeySequence.SequenceMatch:
match = super().handle(e)
if match == QKeySequence.SequenceMatch.PartialMatch:
@ -204,6 +242,44 @@ class HintKeyParser(basekeyparser.BaseKeyParser):
return match
@pyqtSlot(str)
def forward_partial_match_event(self, text: str = None) -> None:
# TODO: add debug messages
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
e = match_event.to_events()
assert 1 == len(e)
e = e[0]
self._handle_hint(e)
@pyqtSlot()
def forward_all_partial_match_events(self, *,
stop_timer: bool = False) -> None:
if stop_timer:
self._stop_partial_timer()
if self._partial_match_events:
while self._partial_match_events:
self.forward_partial_match_event()
self._command_parser.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._command_parser.allow_partial_timeout and (timeout != 0):
self._partial_timer.setInterval(timeout)
self._partial_timer.start()
def _stop_partial_timer(self) -> None:
"""Prematurely stop the the partial keystring timer."""
self._partial_timer.stop()
def update_bindings(self, strings: Sequence[str],
preserve_filter: bool = False) -> None:
"""Update bindings when the hint strings changed.

View File

@ -337,8 +337,8 @@ class TestHandle:
(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):
def test_forward_keys(self, config_stub, handle_text, keyparser, qtbot,
seq):
config_stub.val.bindings.commands = {
'normal': {
'fy': 'message-info fy',
@ -400,6 +400,7 @@ class TestHandle:
assert keyparser._count == ''.join(
str(keyutils.KeyInfo(key, Qt.KeyboardModifier.NoModifier)) for key in count_seq
)
assert keyparser._sequence == keyseq('f')
class TestCount:

View File

@ -4,7 +4,9 @@
"""Tests for mode parsers."""
from qutebrowser.qt.core import Qt
from unittest import mock
from qutebrowser.qt.core import Qt, QTimer
from qutebrowser.qt.gui import QKeySequence
import pytest
@ -18,6 +20,16 @@ def commandrunner(stubs):
return stubs.FakeCommandRunner()
@pytest.fixture
def handle_text():
"""Helper function to handle multiple fake keypresses."""
def func(kp, *args):
for key in args:
info = keyutils.KeyInfo(key, Qt.KeyboardModifier.NoModifier)
kp.handle(info.to_event())
return func
class TestsNormalKeyParser:
@pytest.fixture(autouse=True)
@ -128,8 +140,8 @@ class TestHintKeyParser:
steps = [
(Qt.Key.Key_X, QKeySequence.SequenceMatch.PartialMatch, 'x'),
(Qt.Key.Key_A, QKeySequence.SequenceMatch.PartialMatch, ''),
(Qt.Key.Key_B, QKeySequence.SequenceMatch.PartialMatch, ''),
(Qt.Key.Key_A, QKeySequence.SequenceMatch.PartialMatch, 'x'),
(Qt.Key.Key_B, QKeySequence.SequenceMatch.PartialMatch, 'x'),
(Qt.Key.Key_C, QKeySequence.SequenceMatch.ExactMatch, ''),
]
for key, expected_match, keystr in steps:
@ -141,3 +153,173 @@ class TestHintKeyParser:
assert not commandrunner.commands
assert commandrunner.commands == [('message-info abc', None)]
@pytest.mark.parametrize('seq, hint_seq', [
((Qt.Key.Key_F,), None),
((Qt.Key.Key_F,), 'f'),
((Qt.Key.Key_F,), 'fz'),
((Qt.Key.Key_F,), 'fzz'),
((Qt.Key.Key_F,), 'fza'),
((Qt.Key.Key_F, Qt.Key.Key_G), None),
((Qt.Key.Key_F, Qt.Key.Key_G), 'f'),
((Qt.Key.Key_F, Qt.Key.Key_G), 'fg'),
((Qt.Key.Key_F, Qt.Key.Key_G), 'fgz'),
((Qt.Key.Key_F, Qt.Key.Key_G), 'fgzz'),
((Qt.Key.Key_F, Qt.Key.Key_G), 'fgza'),
((Qt.Key.Key_F, Qt.Key.Key_G, Qt.Key.Key_H), None),
((Qt.Key.Key_F, Qt.Key.Key_G, Qt.Key.Key_H), 'f'),
((Qt.Key.Key_F, Qt.Key.Key_G, Qt.Key.Key_H), 'fg'),
((Qt.Key.Key_F, Qt.Key.Key_G, Qt.Key.Key_H), 'fgh'),
((Qt.Key.Key_F, Qt.Key.Key_G, Qt.Key.Key_H), 'fghz'),
((Qt.Key.Key_F, Qt.Key.Key_G, Qt.Key.Key_H), 'fghzz'),
((Qt.Key.Key_F, Qt.Key.Key_G, Qt.Key.Key_H), 'fghza'),
])
def test_forward_keys(self, config_stub, handle_text, keyparser, qtbot,
hintmanager, commandrunner, seq, hint_seq):
command_parser = keyparser._command_parser
config_stub.val.bindings.commands = {
'hint': {
'fy': 'message-info fy',
'fgy': 'message-info fgy',
'fghy': 'message-info fghy',
}
}
if hint_seq is not None:
keyparser.update_bindings([hint_seq, 'zz'])
forward_partial_key = mock.Mock()
command_parser.forward_partial_key.connect(forward_partial_key)
handle_text(keyparser, *seq)
assert not commandrunner.commands
seq = list(seq) + [Qt.Key.Key_Z]
signals = [command_parser.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
]
if hint_seq is not None:
if len(seq) > len(hint_seq):
assert hintmanager.keystr == 'z'
else:
assert hintmanager.keystr == hint_seq[:len(seq)]
else:
assert hintmanager.keystr == ''
@pytest.mark.parametrize('seq, hint_seq, keystr', [
((Qt.Key.Key_F,), None, None),
((Qt.Key.Key_F,), 'f', None),
((Qt.Key.Key_F,), 'fz', None),
((Qt.Key.Key_F,), 'fzz', None),
((Qt.Key.Key_F,), 'fza', None),
((Qt.Key.Key_F, Qt.Key.Key_G), None, None),
((Qt.Key.Key_F, Qt.Key.Key_G), 'f', 'g'),
((Qt.Key.Key_F, Qt.Key.Key_G), 'fg', None),
((Qt.Key.Key_F, Qt.Key.Key_G), 'fgz', None),
((Qt.Key.Key_F, Qt.Key.Key_G), 'fgzz', None),
((Qt.Key.Key_F, Qt.Key.Key_G), 'fgza', None),
((Qt.Key.Key_F, Qt.Key.Key_G, Qt.Key.Key_H), None, None),
((Qt.Key.Key_F, Qt.Key.Key_G, Qt.Key.Key_H), 'f', 'gh'),
((Qt.Key.Key_F, Qt.Key.Key_G, Qt.Key.Key_H), 'fg', 'h'),
((Qt.Key.Key_F, Qt.Key.Key_G, Qt.Key.Key_H), 'fgh', None),
((Qt.Key.Key_F, Qt.Key.Key_G, Qt.Key.Key_H), 'fghz', None),
((Qt.Key.Key_F, Qt.Key.Key_G, Qt.Key.Key_H), 'fghzz', None),
((Qt.Key.Key_F, Qt.Key.Key_G, Qt.Key.Key_H), 'fghza', None),
])
def test_forward_keys_partial(self, config_stub, handle_text, keyparser,
qtbot, hintmanager, commandrunner, seq,
hint_seq, keystr):
command_parser = keyparser._command_parser
config_stub.val.bindings.commands = {
'hint': {
'fy': 'message-info fy',
'fgy': 'message-info fgy',
'fghy': 'message-info fghy',
}
}
if hint_seq is not None:
keyparser.update_bindings([hint_seq, 'gh', 'h'])
forward_partial_key = mock.Mock()
command_parser.forward_partial_key.connect(forward_partial_key)
handle_text(keyparser, *seq)
assert not commandrunner.commands
signals = [command_parser.forward_partial_key] * len(seq)
with qtbot.wait_signals(signals) as blocker:
handle_text(keyparser, Qt.Key.Key_F)
assert forward_partial_key.call_args_list == [
((str(keyutils.KeyInfo(key, Qt.KeyboardModifier.NoModifier)),),) for key in seq
]
assert command_parser._sequence == keyutils.KeySequence.parse('f')
if hint_seq is not None:
if keystr is not None:
assert len(seq) > len(hint_seq)
assert hintmanager.keystr == keystr
else:
assert hintmanager.keystr == hint_seq[:len(seq)]
else:
assert hintmanager.keystr == ''.join(
str(keyutils.KeyInfo(key, Qt.KeyboardModifier.NoModifier)) for key in seq)
@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(self, keyparser, config_stub, qtbot,
hintmanager, commandrunner,
data_sequence):
"""Test partial keychain timeout behavior."""
command_parser = keyparser._command_parser
config_stub.val.bindings.commands = {
'hint': {
'a': 'message-info a',
'ba': 'message-info ba',
'bba': 'message-info bba',
'bbba': 'message-info bbba',
}
}
keyparser.update_bindings(['bbb'])
timeout = 100
config_stub.val.input.partial_timeout = timeout
timer = keyparser._partial_timer
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
keyparser.handle(keyinfo.to_event())
assert timer.isSingleShot()
assert timer.interval() == timeout
assert timer.isActive()
elif behavior == 'timer_inactive':
# Timer should be inactive
keyparser.handle(keyinfo.to_event())
assert not timer.isActive()
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()
keyparser.handle(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 forwarded.
with qtbot.wait_signal(command_parser.keystring_updated) as blocker:
timer.timeout.emit()
assert blocker.args == ['']
assert hintmanager.keystr == ('b' * len(data_sequence))