Repository: qutebrowser/qutebrowser

Issue:
Pressing shift resets hint keystring
<!-- If this is a bug report, please remember to mention your version info from
`:open qute:version` or `qutebrowser --version` -->


On French keyboards, pressing Shift is needed to get number keys. However, that resets a partial hint string in numbers mode.

Pressing '2':

```
23:06:03 DEBUG    modes      modeman:enter:250 Entering mode KeyMode.hint (reason: HintManager.start)
23:06:03 DEBUG    modes      modeman:_handle_keyrelease:219 filter: True
23:06:04 DEBUG    modes      modeman:_handle_keypress:172 got keypress in mode KeyMode.hint - delegating to <qutebrowser.keyinput.modeparsers.HintKeyParser supports_count=False>
23:06:04 DEBUG    keyboard   basekeyparser:_debug_log:91 Got key: 0x32 / modifiers: 0x0 / text: '2' / dry_run True
23:06:04 DEBUG    modes      modeman:_handle_keypress:199 match: 1, forward_unbound_keys: auto, passthrough: False, is_non_alnum: False, dry_run: True --> filter: True (focused: <PyQt5.QtWidgets.QWidget object at 0x7f185c0fc828>)
23:06:04 DEBUG    modes      modeman:_handle_keypress:172 got keypress in mode KeyMode.hint - delegating to <qutebrowser.keyinput.modeparsers.HintKeyParser supports_count=False>
23:06:04 DEBUG    keyboard   basekeyparser:_debug_log:91 Got key: 0x32 / modifiers: 0x0 / text: '2' / dry_run True
23:06:04 DEBUG    keyboard   basekeyparser:_debug_log:91 Got key: 0x32 / modifiers: 0x0 / text: '2' / dry_run False
23:06:04 DEBUG    keyboard   basekeyparser:_debug_log:91 No match for '2' (added 2)
23:06:04 DEBUG    hints      hints:handle_partial_key:796 Handling new keystring: '2'
23:06:04 DEBUG    modes      modeman:_handle_keypress:199 match: 1, forward_unbound_keys: auto, passthrough: False, is_non_alnum: False, dry_run: False --> filter: True (focused: <PyQt5.QtWidgets.QWidget object at 0x7f185c0fc828>)
23:06:04 DEBUG    modes      modeman:_handle_keyrelease:219 filter: True
```

pressing Shift:

```
23:06:06 DEBUG    modes      modeman:_handle_keypress:172 got keypress in mode KeyMode.hint - delegating to <qutebrowser.keyinput.modeparsers.HintKeyParser supports_count=False>
23:06:06 DEBUG    keyboard   basekeyparser:_debug_log:91 Got key: 0x1000020 / modifiers: 0x2000000 / text: '<Shift>' / dry_run True
23:06:06 DEBUG    keyboard   basekeyparser:_debug_log:91 Ignoring, only modifier
23:06:06 DEBUG    modes      modeman:_handle_keypress:199 match: 0, forward_unbound_keys: auto, passthrough: False, is_non_alnum: True, dry_run: True --> filter: False (focused: <PyQt5.QtWidgets.QWidget object at 0x7f185c0fc828>)
23:06:06 DEBUG    modes      modeman:_handle_keypress:172 got keypress in mode KeyMode.hint - delegating to <qutebrowser.keyinput.modeparsers.HintKeyParser supports_count=False>
23:06:06 DEBUG    keyboard   basekeyparser:_debug_log:91 Got key: 0x1000020 / modifiers: 0x2000000 / text: '<Shift>' / dry_run True
23:06:06 DEBUG    keyboard   basekeyparser:_debug_log:91 Ignoring, only modifier
23:06:06 DEBUG    modes      modeman:_handle_keypress:199 match: 0, forward_unbound_keys: auto, passthrough: False, is_non_alnum: True, dry_run: True --> filter: False (focused: <PyQt5.QtWidgets.QWidget object at 0x7f185c0fc828>)
23:06:06 DEBUG    modes      modeman:_handle_keypress:172 got keypress in mode KeyMode.hint - delegating to <qutebrowser.keyinput.modeparsers.HintKeyParser supports_count=False>
23:06:06 DEBUG    keyboard   basekeyparser:_debug_log:91 Got key: 0x1000020 / modifiers: 0x2000000 / text: '<Shift>' / dry_run True
23:06:06 DEBUG    keyboard   basekeyparser:_debug_log:91 Ignoring, only modifier
23:06:06 DEBUG    keyboard   modeparsers:handle:267 Got special key, clearing keychain
23:06:06 DEBUG    keyboard   basekeyparser:_debug_log:91 Clearing keystring (was: 2).
23:06:06 DEBUG    hints      hints:handle_partial_key:796 Handling new keystring: ''
23:06:06 DEBUG    keyboard   basekeyparser:_debug_log:91 Got key: 0x1000020 / modifiers: 0x2000000 / text: '<Shift>' / dry_run False
23:06:06 DEBUG    keyboard   basekeyparser:_debug_log:91 Ignoring, only modifier
23:06:06 DEBUG    keyboard   modeparsers:_handle_filter_key:218 Got filter key 0x1000020 text 
23:06:06 DEBUG    modes      modeman:_handle_keypress:199 match: 0, forward_unbound_keys: auto, passthrough: False, is_non_alnum: True, dry_run: False --> filter: False (focused: <PyQt5.QtWidgets.QWidget object at 0x7f185c0fc828>)
23:06:07 DEBUG    modes      modeman:_handle_keyrelease:219 filter: True
``
Pressing shift resets hint keystring
<!-- If this is a bug report, please remember to mention your version info from
`:open qute:version` or `qutebrowser --version` -->


On French keyboards, pressing Shift is needed to get number keys. However, that resets a partial hint string in numbers mode.

Pressing '2':

```
23:06:03 DEBUG    modes      modeman:enter:250 Entering mode KeyMode.hint (reason: HintManager.start)
23:06:03 DEBUG    modes      modeman:_handle_keyrelease:219 filter: True
23:06:04 DEBUG    modes      modeman:_handle_keypress:172 got keypress in mode KeyMode.hint - delegating to <qutebrowser.keyinput.modeparsers.HintKeyParser supports_count=False>
23:06:04 DEBUG    keyboard   basekeyparser:_debug_log:91 Got key: 0x32 / modifiers: 0x0 / text: '2' / dry_run True
23:06:04 DEBUG    modes      modeman:_handle_keypress:199 match: 1, forward_unbound_keys: auto, passthrough: False, is_non_alnum: False, dry_run: True --> filter: True (focused: <PyQt5.QtWidgets.QWidget object at 0x7f185c0fc828>)
23:06:04 DEBUG    modes      modeman:_handle_keypress:172 got keypress in mode KeyMode.hint - delegating to <qutebrowser.keyinput.modeparsers.HintKeyParser supports_count=False>
23:06:04 DEBUG    keyboard   basekeyparser:_debug_log:91 Got key: 0x32 / modifiers: 0x0 / text: '2' / dry_run True
23:06:04 DEBUG    keyboard   basekeyparser:_debug_log:91 Got key: 0x32 / modifiers: 0x0 / text: '2' / dry_run False
23:06:04 DEBUG    keyboard   basekeyparser:_debug_log:91 No match for '2' (added 2)
23:06:04 DEBUG    hints      hints:handle_partial_key:796 Handling new keystring: '2'
23:06:04 DEBUG    modes      modeman:_handle_keypress:199 match: 1, forward_unbound_keys: auto, passthrough: False, is_non_alnum: False, dry_run: False --> filter: True (focused: <PyQt5.QtWidgets.QWidget object at 0x7f185c0fc828>)
23:06:04 DEBUG    modes      modeman:_handle_keyrelease:219 filter: True
```

pressing Shift:

```
23:06:06 DEBUG    modes      modeman:_handle_keypress:172 got keypress in mode KeyMode.hint - delegating to <qutebrowser.keyinput.modeparsers.HintKeyParser supports_count=False>
23:06:06 DEBUG    keyboard   basekeyparser:_debug_log:91 Got key: 0x1000020 / modifiers: 0x2000000 / text: '<Shift>' / dry_run True
23:06:06 DEBUG    keyboard   basekeyparser:_debug_log:91 Ignoring, only modifier
23:06:06 DEBUG    modes      modeman:_handle_keypress:199 match: 0, forward_unbound_keys: auto, passthrough: False, is_non_alnum: True, dry_run: True --> filter: False (focused: <PyQt5.QtWidgets.QWidget object at 0x7f185c0fc828>)
23:06:06 DEBUG    modes      modeman:_handle_keypress:172 got keypress in mode KeyMode.hint - delegating to <qutebrowser.keyinput.modeparsers.HintKeyParser supports_count=False>
23:06:06 DEBUG    keyboard   basekeyparser:_debug_log:91 Got key: 0x1000020 / modifiers: 0x2000000 / text: '<Shift>' / dry_run True
23:06:06 DEBUG    keyboard   basekeyparser:_debug_log:91 Ignoring, only modifier
23:06:06 DEBUG    modes      modeman:_handle_keypress:199 match: 0, forward_unbound_keys: auto, passthrough: False, is_non_alnum: True, dry_run: True --> filter: False (focused: <PyQt5.QtWidgets.QWidget object at 0x7f185c0fc828>)
23:06:06 DEBUG    modes      modeman:_handle_keypress:172 got keypress in mode KeyMode.hint - delegating to <qutebrowser.keyinput.modeparsers.HintKeyParser supports_count=False>
23:06:06 DEBUG    keyboard   basekeyparser:_debug_log:91 Got key: 0x1000020 / modifiers: 0x2000000 / text: '<Shift>' / dry_run True
23:06:06 DEBUG    keyboard   basekeyparser:_debug_log:91 Ignoring, only modifier
23:06:06 DEBUG    keyboard   modeparsers:handle:267 Got special key, clearing keychain
23:06:06 DEBUG    keyboard   basekeyparser:_debug_log:91 Clearing keystring (was: 2).
23:06:06 DEBUG    hints      hints:handle_partial_key:796 Handling new keystring: ''
23:06:06 DEBUG    keyboard   basekeyparser:_debug_log:91 Got key: 0x1000020 / modifiers: 0x2000000 / text: '<Shift>' / dry_run False
23:06:06 DEBUG    keyboard   basekeyparser:_debug_log:91 Ignoring, only modifier
23:06:06 DEBUG    keyboard   modeparsers:_handle_filter_key:218 Got filter key 0x1000020 text 
23:06:06 DEBUG    modes      modeman:_handle_keypress:199 match: 0, forward_unbound_keys: auto, passthrough: False, is_non_alnum: True, dry_run: False --> filter: False (focused: <PyQt5.QtWidgets.QWidget object at 0x7f185c0fc828>)
23:06:07 DEBUG    modes      modeman:_handle_keyrelease:219 filter: True
``


Discussion:

Resolving patch:
diff --git a/qutebrowser/keyinput/keyutils.py b/qutebrowser/keyinput/keyutils.py
--- a/qutebrowser/keyinput/keyutils.py
+++ b/qutebrowser/keyinput/keyutils.py
@@ -53,15 +53,29 @@ def _is_printable(key):
     return key <= 0xff and key not in [Qt.Key_Space, 0x0]
 
 
-def is_special(key, modifiers):
-    """Check whether this key requires special key syntax."""
+def is_special_hint_mode(key, modifiers):
+    """Check whether this key should clear the keychain in hint mode.
+
+    When we press "s<Escape>", we don't want <Escape> to be handled as part of
+    a key chain in hint mode.
+    """
     _assert_plain_key(key)
     _assert_plain_modifier(modifiers)
+    if is_modifier_key(key):
+        return False
     return not (_is_printable(key) and
                 modifiers in [Qt.ShiftModifier, Qt.NoModifier,
                               Qt.KeypadModifier])
 
 
+def is_special(key, modifiers):
+    """Check whether this key requires special key syntax."""
+    _assert_plain_key(key)
+    _assert_plain_modifier(modifiers)
+    return not (_is_printable(key) and
+                modifiers in [Qt.ShiftModifier, Qt.NoModifier])
+
+
 def is_modifier_key(key):
     """Test whether the given key is a modifier.
 
@@ -337,8 +351,7 @@ def __str__(self):
                 key_string = key_string.lower()
 
         # "special" binding
-        assert (is_special(self.key, self.modifiers) or
-                self.modifiers == Qt.KeypadModifier)
+        assert is_special(self.key, self.modifiers)
         modifier_string = _modifiers_to_string(modifiers)
         return '<{}{}>'.format(modifier_string, key_string)
 
diff --git a/qutebrowser/keyinput/modeparsers.py b/qutebrowser/keyinput/modeparsers.py
--- a/qutebrowser/keyinput/modeparsers.py
+++ b/qutebrowser/keyinput/modeparsers.py
@@ -246,7 +246,7 @@ def handle(self, e, *, dry_run=False):
         if dry_run:
             return super().handle(e, dry_run=True)
 
-        if keyutils.is_special(e.key(), e.modifiers()):
+        if keyutils.is_special_hint_mode(e.key(), e.modifiers()):
             log.keyboard.debug("Got special key, clearing keychain")
             self.clear_keystring()
 


Test patch:
diff --git a/tests/unit/keyinput/test_keyutils.py b/tests/unit/keyinput/test_keyutils.py
--- a/tests/unit/keyinput/test_keyutils.py
+++ b/tests/unit/keyinput/test_keyutils.py
@@ -520,8 +520,35 @@ def test_is_printable(key, printable):
     (Qt.Key_Escape, Qt.ControlModifier, True),
     (Qt.Key_X, Qt.ControlModifier, True),
     (Qt.Key_X, Qt.NoModifier, False),
+    (Qt.Key_2, Qt.NoModifier, False),
+
+    # Keypad should not reset hint keychain - see #3735
     (Qt.Key_2, Qt.KeypadModifier, False),
+
+    # Modifiers should not reset hint keychain - see #4264
+    (Qt.Key_Shift, Qt.ShiftModifier, False),
+    (Qt.Key_Control, Qt.ControlModifier, False),
+    (Qt.Key_Alt, Qt.AltModifier, False),
+    (Qt.Key_Meta, Qt.MetaModifier, False),
+    (Qt.Key_Mode_switch, Qt.GroupSwitchModifier, False),
+])
+def test_is_special_hint_mode(key, modifiers, special):
+    assert keyutils.is_special_hint_mode(key, modifiers) == special
+
+
+@pytest.mark.parametrize('key, modifiers, special', [
+    (Qt.Key_Escape, Qt.NoModifier, True),
+    (Qt.Key_Escape, Qt.ShiftModifier, True),
+    (Qt.Key_Escape, Qt.ControlModifier, True),
+    (Qt.Key_X, Qt.ControlModifier, True),
+    (Qt.Key_X, Qt.NoModifier, False),
+    (Qt.Key_2, Qt.KeypadModifier, True),
     (Qt.Key_2, Qt.NoModifier, False),
+    (Qt.Key_Shift, Qt.ShiftModifier, True),
+    (Qt.Key_Control, Qt.ControlModifier, True),
+    (Qt.Key_Alt, Qt.AltModifier, True),
+    (Qt.Key_Meta, Qt.MetaModifier, True),
+    (Qt.Key_Mode_switch, Qt.GroupSwitchModifier, True),
 ])
 def test_is_special(key, modifiers, special):
     assert keyutils.is_special(key, modifiers) == special