Repository: qutebrowser/qutebrowser

Issue:
Avoid displaying key-hints in some situations
The new feature of key-hints is very useful for commands I don't remember well or didn't know about, but they can get annoying for commands like `gg`, that I type a lot and know by heart.

I have two quick ideas about this, but I haven't reached a conclusion for myself yet, so this issue is meant for discussing ideas - hoping for input from others.
1. There could be a delay for showing the hints. Key chains that I know well, like `gg` will be entered rapidly and should not trigger hints, so a delay of ~500ms might work to avoid showing unnecessary hints while still helping when I'm hesitating.
2. Have a blacklist for key chains that should not trigger hints. If the only chains starting with `g` are `gg` and `gh` (for example) and both `gg` and `gh` are on a blacklist, then don't trigger hints when I press `g`. This scheme has the nice property of being adaptive: When I add another key chain `gt`, the hints will show again when pressing `g` until I have memorized `gt` and blacklist it as well.

Of course, both of these ideas can also be combined.

Discussion and further ideas welcome. And thanks to @rcorre for introducing key-hints.



Discussion:
FWIW having a delay is what I see in other applications (like spacemacs) as well, and I think it makes a lot of sense - if you know a binding you're going to be faster than, say, 500ms - and if you don't, you'll naturally wait a bit trying to remember what to type, and then you get help :wink:


Resolving patch:
diff --git a/qutebrowser/config/config.py b/qutebrowser/config/config.py
--- a/qutebrowser/config/config.py
+++ b/qutebrowser/config/config.py
@@ -341,6 +341,7 @@ class ConfigManager(QObject):
         ('colors', 'tab.indicator.system'): 'tabs.indicator.system',
         ('completion', 'history-length'): 'cmd-history-max-items',
         ('colors', 'downloads.fg'): 'downloads.fg.start',
+        ('ui', 'show-keyhints'): 'keyhint-blacklist',
     }
     DELETED_OPTIONS = [
         ('colors', 'tab.separator'),
@@ -360,6 +361,8 @@ class ConfigManager(QObject):
             _get_value_transformer({'false': '-1', 'true': '1000'}),
         ('general', 'log-javascript-console'):
             _get_value_transformer({'false': 'none', 'true': 'debug'}),
+        ('ui', 'keyhint-blacklist'):
+            _get_value_transformer({'false': '*', 'true': ''}),
     }
 
     changed = pyqtSignal(str, str)
diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py
--- a/qutebrowser/config/configdata.py
+++ b/qutebrowser/config/configdata.py
@@ -355,9 +355,11 @@ def data(readonly=False):
              "Hide the window decoration when using wayland "
              "(requires restart)"),
 
-            ('show-keyhints',
-             SettingValue(typ.Bool(), 'true'),
-             "Show possible keychains based on the current keystring"),
+            ('keyhint-blacklist',
+             SettingValue(typ.List(none_ok=True), ''),
+             "Keychains that shouldn't be shown in the keyhint dialog\n\n"
+             "Globs are supported, so ';*' will blacklist all keychains"
+             "starting with ';'. Use '*' to disable keyhints"),
 
             readonly=readonly
         )),
diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py
--- a/qutebrowser/misc/keyhintwidget.py
+++ b/qutebrowser/misc/keyhintwidget.py
@@ -25,12 +25,13 @@
 """
 
 import html
+import fnmatch
 
 from PyQt5.QtWidgets import QLabel, QSizePolicy
 from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt
 
 from qutebrowser.config import config, style
-from qutebrowser.utils import objreg, utils
+from qutebrowser.utils import objreg, utils, usertypes
 
 
 class KeyHintView(QLabel):
@@ -39,7 +40,6 @@ class KeyHintView(QLabel):
 
     Attributes:
         _win_id: Window ID of parent.
-        _enabled: If False, do not show the window at all
 
     Signals:
         reposition_keyhint: Emitted when this widget should be resized.
@@ -61,23 +61,16 @@ def __init__(self, win_id, parent=None):
         super().__init__(parent)
         self.setTextFormat(Qt.RichText)
         self._win_id = win_id
-        self.set_enabled()
-        cfg = objreg.get('config')
-        cfg.changed.connect(self.set_enabled)
         style.set_register_stylesheet(self)
         self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Minimum)
         self.hide()
+        self._show_timer = usertypes.Timer(self, 'keyhint_show')
+        self._show_timer.setInterval(500)
+        self._show_timer.timeout.connect(self.show)
 
     def __repr__(self):
         return utils.get_repr(self, win_id=self._win_id)
 
-    @config.change_filter('ui', 'show-keyhints')
-    def set_enabled(self):
-        """Update self._enabled when the config changed."""
-        self._enabled = config.get('ui', 'show-keyhints')
-        if not self._enabled:
-            self.hide()
-
     def showEvent(self, e):
         """Adjust the keyhint size when it's freshly shown."""
         self.reposition_keyhint.emit()
@@ -90,19 +83,29 @@ def update_keyhint(self, modename, prefix):
         Args:
             prefix: The current partial keystring.
         """
-        if not prefix or not self._enabled:
+        if not prefix:
+            self._show_timer.stop()
             self.hide()
             return
 
+        blacklist = config.get('ui', 'keyhint-blacklist') or []
         keyconf = objreg.get('key-config')
+
+        def blacklisted(keychain):
+            return any(fnmatch.fnmatchcase(keychain, glob)
+                       for glob in blacklist)
+
         bindings = [(k, v) for (k, v)
                     in keyconf.get_bindings_for(modename).items()
-                    if k.startswith(prefix) and not utils.is_special_key(k)]
+                    if k.startswith(prefix) and not utils.is_special_key(k) and
+                    not blacklisted(k)]
 
         if not bindings:
+            self._show_timer.stop()
             return
 
-        self.show()
+        # delay so a quickly typed keychain doesn't display hints
+        self._show_timer.start()
         suffix_color = html.escape(config.get('colors', 'keyhint.fg.suffix'))
 
         text = ''


Test patch:
diff --git a/tests/unit/misc/test_keyhints.py b/tests/unit/misc/test_keyhints.py
--- a/tests/unit/misc/test_keyhints.py
+++ b/tests/unit/misc/test_keyhints.py
@@ -34,7 +34,6 @@ def expected_text(*args):
     """
     text = '<table>'
     for group in args:
-        print("group = {}".format(group))
         text += ("<tr>"
                  "<td>{}</td>"
                  "<td style='color: {}'>{}</td>"
@@ -54,7 +53,7 @@ def keyhint(qtbot, config_stub, key_config_stub):
             'keyhint.bg': 'black'
         },
         'fonts': {'keyhint': 'Comic Sans'},
-        'ui': {'show-keyhints': True},
+        'ui': {'keyhint-blacklist': ''},
     }
     keyhint = KeyHintView(0, None)
     qtbot.add_widget(keyhint)
@@ -63,7 +62,7 @@ def keyhint(qtbot, config_stub, key_config_stub):
 
 
 def test_suggestions(keyhint, key_config_stub):
-    """Test cursor position based on the prompt."""
+    """Test that keyhints are shown based on a prefix."""
     # we want the dict to return sorted items() for reliable testing
     key_config_stub.set_bindings_for('normal', OrderedDict([
         ('aa', 'cmd-aa'),
@@ -95,14 +94,6 @@ def test_special_bindings(keyhint, key_config_stub):
         ('&lt;', 'yellow', 'b', 'cmd-&lt;b'))
 
 
-def test_disable(keyhint, config_stub):
-    """Ensure the widget isn't visible if disabled."""
-    config_stub.set('ui', 'show-keyhints', False)
-    keyhint.update_keyhint('normal', 'a')
-    assert not keyhint.text()
-    assert not keyhint.isVisible()
-
-
 def test_color_switch(keyhint, config_stub, key_config_stub):
     """Ensure the the keyhint suffix color can be updated at runtime."""
     config_stub.set('colors', 'keyhint.fg.suffix', '#ABCDEF')
@@ -118,4 +109,37 @@ def test_no_matches(keyhint, key_config_stub):
         ('aa', 'cmd-aa'),
         ('ab', 'cmd-ab')]))
     keyhint.update_keyhint('normal', 'z')
+    assert not keyhint.text()
     assert not keyhint.isVisible()
+
+
+def test_blacklist(keyhint, config_stub, key_config_stub):
+    """Test that blacklisted keychaints aren't hinted."""
+    config_stub.set('ui', 'keyhint-blacklist', ['ab*'])
+    # we want the dict to return sorted items() for reliable testing
+    key_config_stub.set_bindings_for('normal', OrderedDict([
+        ('aa', 'cmd-aa'),
+        ('ab', 'cmd-ab'),
+        ('aba', 'cmd-aba'),
+        ('abb', 'cmd-abb'),
+        ('xd', 'cmd-xd'),
+        ('xe', 'cmd-xe')]))
+
+    keyhint.update_keyhint('normal', 'a')
+    assert keyhint.text() == expected_text(('a', 'yellow', 'a', 'cmd-aa'))
+
+
+def test_blacklist_all(keyhint, config_stub, key_config_stub):
+    """Test that setting the blacklist to * disables keyhints."""
+    config_stub.set('ui', 'keyhint-blacklist', ['*'])
+    # we want the dict to return sorted items() for reliable testing
+    key_config_stub.set_bindings_for('normal', OrderedDict([
+        ('aa', 'cmd-aa'),
+        ('ab', 'cmd-ab'),
+        ('aba', 'cmd-aba'),
+        ('abb', 'cmd-abb'),
+        ('xd', 'cmd-xd'),
+        ('xe', 'cmd-xe')]))
+
+    keyhint.update_keyhint('normal', 'a')
+    assert not keyhint.text()