Merge branch 'keystring' of https://github.com/rcorre/qutebrowser into rcorre-keystring
This commit is contained in:
commit
d0af80fbd5
@ -355,6 +355,10 @@ def data(readonly=False):
|
|||||||
"Hide the window decoration when using wayland "
|
"Hide the window decoration when using wayland "
|
||||||
"(requires restart)"),
|
"(requires restart)"),
|
||||||
|
|
||||||
|
('show-keyhints',
|
||||||
|
SettingValue(typ.Bool(), 'true'),
|
||||||
|
"Show possible keychains based on the current keystring"),
|
||||||
|
|
||||||
readonly=readonly
|
readonly=readonly
|
||||||
)),
|
)),
|
||||||
|
|
||||||
@ -475,7 +479,7 @@ def data(readonly=False):
|
|||||||
"match, the complete match will be executed after this time."),
|
"match, the complete match will be executed after this time."),
|
||||||
|
|
||||||
('partial-timeout',
|
('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"
|
"Timeout for partially typed key bindings.\n\n"
|
||||||
"If the current input forms only partial matches, the keystring "
|
"If the current input forms only partial matches, the keystring "
|
||||||
"will be cleared after this time."),
|
"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 "
|
"Background color for webpages if unset (or empty to use the "
|
||||||
"theme's color)"),
|
"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
|
readonly=readonly
|
||||||
)),
|
)),
|
||||||
|
|
||||||
@ -1281,6 +1297,10 @@ def data(readonly=False):
|
|||||||
typ.Int(none_ok=True, minval=1, maxval=MAXVALS['int']), ''),
|
typ.Int(none_ok=True, minval=1, maxval=MAXVALS['int']), ''),
|
||||||
"The default font size for fixed-pitch text."),
|
"The default font size for fixed-pitch text."),
|
||||||
|
|
||||||
|
('keyhint',
|
||||||
|
SettingValue(typ.Font(), DEFAULT_FONT_SIZE + ' ${_monospace}'),
|
||||||
|
"Font used in the keyhint widget."),
|
||||||
|
|
||||||
readonly=readonly
|
readonly=readonly
|
||||||
)),
|
)),
|
||||||
])
|
])
|
||||||
|
@ -35,7 +35,7 @@ from qutebrowser.mainwindow.statusbar import bar
|
|||||||
from qutebrowser.completion import completionwidget
|
from qutebrowser.completion import completionwidget
|
||||||
from qutebrowser.keyinput import modeman
|
from qutebrowser.keyinput import modeman
|
||||||
from qutebrowser.browser import hints, downloads, downloadview, commands
|
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)
|
win_id_gen = itertools.count(0)
|
||||||
@ -160,6 +160,8 @@ class MainWindow(QWidget):
|
|||||||
|
|
||||||
self._commandrunner = runners.CommandRunner(self.win_id)
|
self._commandrunner = runners.CommandRunner(self.win_id)
|
||||||
|
|
||||||
|
self._keyhint = keyhintwidget.KeyHintView(self.win_id, self)
|
||||||
|
|
||||||
log.init.debug("Initializing modes...")
|
log.init.debug("Initializing modes...")
|
||||||
modeman.init(self.win_id, self)
|
modeman.init(self.win_id, self)
|
||||||
|
|
||||||
@ -178,6 +180,7 @@ class MainWindow(QWidget):
|
|||||||
# resizing will fail. Therefore, we use singleShot QTimers to make sure
|
# resizing will fail. Therefore, we use singleShot QTimers to make sure
|
||||||
# we defer this until everything else is initialized.
|
# we defer this until everything else is initialized.
|
||||||
QTimer.singleShot(0, self._connect_resize_completion)
|
QTimer.singleShot(0, self._connect_resize_completion)
|
||||||
|
QTimer.singleShot(0, self._connect_resize_keyhint)
|
||||||
objreg.get('config').changed.connect(self.on_config_changed)
|
objreg.get('config').changed.connect(self.on_config_changed)
|
||||||
|
|
||||||
if config.get('ui', 'hide-mouse-cursor'):
|
if config.get('ui', 'hide-mouse-cursor'):
|
||||||
@ -252,6 +255,11 @@ class MainWindow(QWidget):
|
|||||||
self._completion.resize_completion.connect(self.resize_completion)
|
self._completion.resize_completion.connect(self.resize_completion)
|
||||||
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):
|
def _set_default_geometry(self):
|
||||||
"""Set some sensible default geometry."""
|
"""Set some sensible default geometry."""
|
||||||
self.setGeometry(QRect(50, 50, 800, 600))
|
self.setGeometry(QRect(50, 50, 800, 600))
|
||||||
@ -290,6 +298,11 @@ class MainWindow(QWidget):
|
|||||||
cmd.returnPressed.connect(tabs.on_cmd_return_pressed)
|
cmd.returnPressed.connect(tabs.on_cmd_return_pressed)
|
||||||
tabs.got_cmd.connect(self._commandrunner.run_safely)
|
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
|
# config
|
||||||
for obj in keyparsers.values():
|
for obj in keyparsers.values():
|
||||||
key_config.changed.connect(obj.on_keyconfig_changed)
|
key_config.changed.connect(obj.on_keyconfig_changed)
|
||||||
@ -367,6 +380,24 @@ class MainWindow(QWidget):
|
|||||||
if rect.isValid():
|
if rect.isValid():
|
||||||
self._completion.setGeometry(rect)
|
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')
|
@cmdutils.register(instance='main-window', scope='window')
|
||||||
@pyqtSlot()
|
@pyqtSlot()
|
||||||
def close(self):
|
def close(self):
|
||||||
@ -394,6 +425,7 @@ class MainWindow(QWidget):
|
|||||||
"""
|
"""
|
||||||
super().resizeEvent(e)
|
super().resizeEvent(e)
|
||||||
self.resize_completion()
|
self.resize_completion()
|
||||||
|
self.reposition_keyhint()
|
||||||
self._downloadview.updateGeometry()
|
self._downloadview.updateGeometry()
|
||||||
self.tabbed_browser.tabBar().refresh()
|
self.tabbed_browser.tabBar().refresh()
|
||||||
|
|
||||||
|
122
qutebrowser/misc/keyhintwidget.py
Normal file
122
qutebrowser/misc/keyhintwidget.py
Normal file
@ -0,0 +1,122 @@
|
|||||||
|
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
|
||||||
|
|
||||||
|
# Copyright 2016 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
|
||||||
|
#
|
||||||
|
# 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""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
|
||||||
|
|
||||||
|
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._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 += (
|
||||||
|
"<tr>"
|
||||||
|
"<td>{}</td>"
|
||||||
|
"<td style='color: {}'>{}</td>"
|
||||||
|
"<td style='padding-left: 2ex'>{}</td>"
|
||||||
|
"</tr>"
|
||||||
|
).format(
|
||||||
|
html.escape(prefix),
|
||||||
|
suffix_color,
|
||||||
|
html.escape(key[len(prefix):]),
|
||||||
|
html.escape(cmd)
|
||||||
|
)
|
||||||
|
text = '<table>{}</table>'.format(text)
|
||||||
|
|
||||||
|
self.setText(text)
|
||||||
|
self.adjustSize()
|
||||||
|
self.reposition_keyhint.emit()
|
@ -138,6 +138,8 @@ PERFECT_FILES = [
|
|||||||
'qutebrowser/utils/error.py'),
|
'qutebrowser/utils/error.py'),
|
||||||
('tests/unit/utils/test_typing.py',
|
('tests/unit/utils/test_typing.py',
|
||||||
'qutebrowser/utils/typing.py'),
|
'qutebrowser/utils/typing.py'),
|
||||||
|
('tests/unit/utils/test_keyhints.py',
|
||||||
|
'qutebrowser/misc/keyhintwidget.py'),
|
||||||
]
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@ -385,8 +385,14 @@ class KeyConfigStub:
|
|||||||
|
|
||||||
"""Stub for the key-config object."""
|
"""Stub for the key-config object."""
|
||||||
|
|
||||||
def get_bindings_for(self, _section):
|
def __init__(self):
|
||||||
return {}
|
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:
|
class HostBlockerStub:
|
||||||
|
112
tests/unit/misc/test_keyhints.py
Normal file
112
tests/unit/misc/test_keyhints.py
Normal file
@ -0,0 +1,112 @@
|
|||||||
|
# vim: ft=python fileencoding=utf-8 sts=4 sw=4 et:
|
||||||
|
|
||||||
|
# Copyright 2016 Ryan Roden-Corrent (rcorre) <ryan@rcorre.net>
|
||||||
|
#
|
||||||
|
# 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 <http://www.gnu.org/licenses/>.
|
||||||
|
|
||||||
|
"""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 = '<table>'
|
||||||
|
for group in args:
|
||||||
|
print("group = {}".format(group))
|
||||||
|
text += ("<tr>"
|
||||||
|
"<td>{}</td>"
|
||||||
|
"<td style='color: {}'>{}</td>"
|
||||||
|
"<td style='padding-left: 2ex'>{}</td>"
|
||||||
|
"</tr>").format(*group)
|
||||||
|
|
||||||
|
return text + '</table>'
|
||||||
|
|
||||||
|
|
||||||
|
@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([
|
||||||
|
('<a', 'cmd-<a'),
|
||||||
|
('<b', 'cmd-<b'),
|
||||||
|
('<ctrl-a>', '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'))
|
Loading…
Reference in New Issue
Block a user