diff --git a/CHANGELOG.asciidoc b/CHANGELOG.asciidoc index 99fd043b9..5b4080271 100644 --- a/CHANGELOG.asciidoc +++ b/CHANGELOG.asciidoc @@ -30,6 +30,8 @@ Added - New `--force-color` argument to force colored logging even if stdout is not a terminal - New `:messages` command to show error messages +- New pop-up showing possible keybinding when the first key of a keychain is + pressed. This can be turned off using `:set ui show-keyhints false`. Changed ~~~~~~~ diff --git a/qutebrowser/config/configdata.py b/qutebrowser/config/configdata.py index c0a33d983..0bde65b0c 100644 --- a/qutebrowser/config/configdata.py +++ b/qutebrowser/config/configdata.py @@ -355,6 +355,10 @@ 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"), + readonly=readonly )), @@ -475,7 +479,7 @@ def data(readonly=False): "match, the complete match will be executed after this time."), ('partial-timeout', - SettingValue(typ.Int(minval=0, maxval=MAXVALS['int']), '1000'), + SettingValue(typ.Int(minval=0, maxval=MAXVALS['int']), '5000'), "Timeout for partially typed key bindings.\n\n" "If the current input forms only partial matches, the keystring " "will be cleared after this time."), @@ -1200,6 +1204,18 @@ def data(readonly=False): "Background color for webpages if unset (or empty to use the " "theme's color)"), + ('keyhint.fg', + SettingValue(typ.QssColor(), '#FFFFFF'), + "Text color for the keyhint widget."), + + ('keyhint.fg.suffix', + SettingValue(typ.CssColor(), '#FFFF00'), + "Highlight color for keys to complete the current keychain"), + + ('keyhint.bg', + SettingValue(typ.QssColor(), 'rgba(0, 0, 0, 80%)'), + "Background color of the keyhint widget."), + readonly=readonly )), @@ -1281,6 +1297,10 @@ def data(readonly=False): typ.Int(none_ok=True, minval=1, maxval=MAXVALS['int']), ''), "The default font size for fixed-pitch text."), + ('keyhint', + SettingValue(typ.Font(), DEFAULT_FONT_SIZE + ' ${_monospace}'), + "Font used in the keyhint widget."), + readonly=readonly )), ]) diff --git a/qutebrowser/mainwindow/mainwindow.py b/qutebrowser/mainwindow/mainwindow.py index 533280b40..9be30806b 100644 --- a/qutebrowser/mainwindow/mainwindow.py +++ b/qutebrowser/mainwindow/mainwindow.py @@ -35,7 +35,7 @@ from qutebrowser.mainwindow.statusbar import bar from qutebrowser.completion import completionwidget from qutebrowser.keyinput import modeman from qutebrowser.browser import hints, downloads, downloadview, commands -from qutebrowser.misc import crashsignal +from qutebrowser.misc import crashsignal, keyhintwidget win_id_gen = itertools.count(0) @@ -160,6 +160,8 @@ class MainWindow(QWidget): self._commandrunner = runners.CommandRunner(self.win_id) + self._keyhint = keyhintwidget.KeyHintView(self.win_id, self) + log.init.debug("Initializing modes...") modeman.init(self.win_id, self) @@ -178,6 +180,7 @@ class MainWindow(QWidget): # resizing will fail. Therefore, we use singleShot QTimers to make sure # we defer this until everything else is initialized. QTimer.singleShot(0, self._connect_resize_completion) + QTimer.singleShot(0, self._connect_resize_keyhint) objreg.get('config').changed.connect(self.on_config_changed) if config.get('ui', 'hide-mouse-cursor'): @@ -252,6 +255,11 @@ class MainWindow(QWidget): self._completion.resize_completion.connect(self.resize_completion) self.resize_completion() + def _connect_resize_keyhint(self): + """Connect the reposition_keyhint signal and resize it once.""" + self._keyhint.reposition_keyhint.connect(self.reposition_keyhint) + self.reposition_keyhint() + def _set_default_geometry(self): """Set some sensible default geometry.""" self.setGeometry(QRect(50, 50, 800, 600)) @@ -290,6 +298,11 @@ class MainWindow(QWidget): cmd.returnPressed.connect(tabs.on_cmd_return_pressed) tabs.got_cmd.connect(self._commandrunner.run_safely) + # key hint popup + for mode, parser in keyparsers.items(): + parser.keystring_updated.connect(functools.partial( + self._keyhint.update_keyhint, mode.name)) + # config for obj in keyparsers.values(): key_config.changed.connect(obj.on_keyconfig_changed) @@ -367,6 +380,24 @@ class MainWindow(QWidget): if rect.isValid(): self._completion.setGeometry(rect) + @pyqtSlot() + def reposition_keyhint(self): + """Adjust keyhint according to config.""" + if not self._keyhint.isVisible(): + return + # Shrink the window to the shown text and place it at the bottom left + width = self._keyhint.width() + height = self._keyhint.height() + topleft_y = self.height() - self.status.height() - height + topleft_y = qtutils.check_overflow(topleft_y, 'int', fatal=False) + topleft = QPoint(0, topleft_y) + bottomright = (self.status.geometry().topLeft() + + QPoint(width, 0)) + rect = QRect(topleft, bottomright) + log.misc.debug('keyhint rect: {}'.format(rect)) + if rect.isValid(): + self._keyhint.setGeometry(rect) + @cmdutils.register(instance='main-window', scope='window') @pyqtSlot() def close(self): @@ -394,6 +425,7 @@ class MainWindow(QWidget): """ super().resizeEvent(e) self.resize_completion() + self.reposition_keyhint() self._downloadview.updateGeometry() self.tabbed_browser.tabBar().refresh() diff --git a/qutebrowser/misc/keyhintwidget.py b/qutebrowser/misc/keyhintwidget.py new file mode 100644 index 000000000..9a4d99d95 --- /dev/null +++ b/qutebrowser/misc/keyhintwidget.py @@ -0,0 +1,123 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016 Ryan Roden-Corrent (rcorre) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Small window that pops up to show hints for possible keystrings. + +When a user inputs a key that forms a partial match, this shows a small window +with each possible completion of that keystring and the corresponding command. +It is intended to help discoverability of keybindings. +""" + +import html + +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 + + +class KeyHintView(QLabel): + + """The view showing hints for key bindings based on the current key string. + + 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. + """ + + STYLESHEET = """ + QLabel { + font: {{ font['keyhint'] }}; + color: {{ color['keyhint.fg'] }}; + background-color: {{ color['keyhint.bg'] }}; + padding: 6px; + border-top-right-radius: 6px; + } + """ + + reposition_keyhint = pyqtSignal() + + 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() + + 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() + super().showEvent(e) + + @pyqtSlot(str) + def update_keyhint(self, modename, prefix): + """Show hints for the given prefix (or hide if prefix is empty). + + Args: + prefix: The current partial keystring. + """ + if not prefix or not self._enabled: + self.hide() + return + + self.show() + suffix_color = html.escape(config.get('colors', 'keyhint.fg.suffix')) + + text = '' + keyconf = objreg.get('key-config') + # this is only fired in normal mode + for key, cmd in keyconf.get_bindings_for(modename).items(): + # for now, special keys can't be part of keychains, so ignore them + is_special_binding = key.startswith('<') and key.endswith('>') + if key.startswith(prefix) and not is_special_binding: + text += ( + "" + "{}" + "{}" + "{}" + "" + ).format( + html.escape(prefix), + suffix_color, + html.escape(key[len(prefix):]), + html.escape(cmd) + ) + text = '{}
'.format(text) + + self.setText(text) + self.adjustSize() + self.reposition_keyhint.emit() diff --git a/scripts/dev/check_coverage.py b/scripts/dev/check_coverage.py index 185f91641..beb92b756 100644 --- a/scripts/dev/check_coverage.py +++ b/scripts/dev/check_coverage.py @@ -91,6 +91,8 @@ PERFECT_FILES = [ 'qutebrowser/misc/cmdhistory.py'), ('tests/unit/misc/test_ipc.py', 'qutebrowser/misc/ipc.py'), + ('tests/unit/misc/test_keyhints.py', + 'qutebrowser/misc/keyhintwidget.py'), (None, 'qutebrowser/mainwindow/statusbar/keystring.py'), diff --git a/tests/helpers/stubs.py b/tests/helpers/stubs.py index f414fba74..adaebd168 100644 --- a/tests/helpers/stubs.py +++ b/tests/helpers/stubs.py @@ -385,8 +385,14 @@ class KeyConfigStub: """Stub for the key-config object.""" - def get_bindings_for(self, _section): - return {} + def __init__(self): + self.bindings = {} + + def get_bindings_for(self, section): + return self.bindings.get(section) + + def set_bindings_for(self, section, bindings): + self.bindings[section] = bindings class HostBlockerStub: diff --git a/tests/unit/misc/test_keyhints.py b/tests/unit/misc/test_keyhints.py new file mode 100644 index 000000000..85f6c55ce --- /dev/null +++ b/tests/unit/misc/test_keyhints.py @@ -0,0 +1,112 @@ +# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et: + +# Copyright 2016 Ryan Roden-Corrent (rcorre) +# +# This file is part of qutebrowser. +# +# qutebrowser is free software: you can redistribute it and/or modify +# it under the terms of the GNU General Public License as published by +# the Free Software Foundation, either version 3 of the License, or +# (at your option) any later version. +# +# qutebrowser is distributed in the hope that it will be useful, +# but WITHOUT ANY WARRANTY; without even the implied warranty of +# MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE. See the +# GNU General Public License for more details. +# +# You should have received a copy of the GNU General Public License +# along with qutebrowser. If not, see . + +"""Test the keyhint widget.""" + +from collections import OrderedDict +import pytest + +from qutebrowser.misc.keyhintwidget import KeyHintView + + +def expected_text(*args): + """Helper to format text we expect the KeyHintView to generate. + + Args: + args: One tuple for each row in the expected output. + Tuples are of the form: (prefix, color, suffix, command). + """ + text = '' + for group in args: + print("group = {}".format(group)) + text += ("" + "" + "" + "" + "").format(*group) + + return text + '
{}{}{}
' + + +@pytest.fixture +def keyhint(qtbot, config_stub, key_config_stub): + """Fixture to initialize a KeyHintView.""" + config_stub.data = { + 'colors': { + 'keyhint.fg': 'white', + 'keyhint.fg.suffix': 'yellow', + 'keyhint.bg': 'black' + }, + 'fonts': {'keyhint': 'Comic Sans'}, + 'ui': {'show-keyhints': True}, + } + keyhint = KeyHintView(0, None) + qtbot.add_widget(keyhint) + assert keyhint.text() == '' + return keyhint + + +def test_suggestions(keyhint, key_config_stub): + """Test cursor position based on the prompt.""" + # 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'), + ('a', 'yellow', 'b', 'cmd-ab'), + ('a', 'yellow', 'ba', 'cmd-aba'), + ('a', 'yellow', 'bb', 'cmd-abb')) + + +def test_special_bindings(keyhint, key_config_stub): + """Ensure the a prefix of '<' doesn't suggest special keys.""" + # we want the dict to return sorted items() for reliable testing + key_config_stub.set_bindings_for('normal', OrderedDict([ + ('', 'cmd-ctrla')])) + + keyhint.update_keyhint('normal', '<') + assert keyhint.text() == expected_text( + ('<', 'yellow', 'a', 'cmd-<a'), + ('<', 'yellow', 'b', 'cmd-<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') + key_config_stub.set_bindings_for('normal', OrderedDict([ + ('aa', 'cmd-aa')])) + keyhint.update_keyhint('normal', 'a') + assert keyhint.text() == expected_text(('a', '#ABCDEF', 'a', 'cmd-aa'))