Generalize statusbar-attached website overlays

We already had some duplicated logic for completion/keyhint/messageview,
and plan to add prompt overlays too now - so here we refactor related
code to have a list of overlays instead, which are all
resized/positioned by the mainwindow when needed.

This also changes the size management, which gets moved into the
sizeHint of the respective overlay widgets.
This commit is contained in:
Florian Bruhin 2016-09-22 17:04:39 +02:00
parent 57d896e989
commit 750dfd98af
6 changed files with 124 additions and 107 deletions

View File

@ -24,12 +24,12 @@ subclasses to provide completions.
"""
from PyQt5.QtWidgets import QStyle, QTreeView, QSizePolicy
from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QItemSelectionModel
from PyQt5.QtCore import pyqtSlot, pyqtSignal, Qt, QItemSelectionModel, QSize
from qutebrowser.config import config, style
from qutebrowser.completion import completiondelegate
from qutebrowser.completion.models import base
from qutebrowser.utils import utils, usertypes
from qutebrowser.utils import utils, usertypes, objreg
from qutebrowser.commands import cmdexc, cmdutils
@ -49,7 +49,7 @@ class CompletionView(QTreeView):
_active: Whether a selection is active.
Signals:
resize_completion: Emitted when the completion should be resized.
update_geometry: Emitted when the completion should be resized.
selection_changed: Emitted when the completion item selection changes.
"""
@ -102,7 +102,7 @@ class CompletionView(QTreeView):
}
"""
resize_completion = pyqtSignal()
update_geometry = pyqtSignal()
selection_changed = pyqtSignal(str)
def __init__(self, win_id, parent=None):
@ -110,6 +110,7 @@ class CompletionView(QTreeView):
self._win_id = win_id
# FIXME handle new aliases.
# objreg.get('config').changed.connect(self.init_command_completion)
objreg.get('config').changed.connect(self._on_config_changed)
self._column_widths = base.BaseCompletionModel.COLUMN_WIDTHS
self._active = False
@ -117,7 +118,7 @@ class CompletionView(QTreeView):
self._delegate = completiondelegate.CompletionItemDelegate(self)
self.setItemDelegate(self._delegate)
style.set_register_stylesheet(self)
self.setSizePolicy(QSizePolicy.Fixed, QSizePolicy.Minimum)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self.setHeaderHidden(True)
self.setAlternatingRowColors(True)
self.setIndentation(0)
@ -138,6 +139,13 @@ class CompletionView(QTreeView):
def __repr__(self):
return utils.get_repr(self)
@pyqtSlot(str, str)
def _on_config_changed(self, section, option):
if section != 'completion':
return
if option in ['height', 'shrink']:
self.update_geometry.emit()
def _resize_columns(self):
"""Resize the completion columns based on column_widths."""
width = self.size().width()
@ -287,13 +295,12 @@ class CompletionView(QTreeView):
self._column_widths = model.srcmodel.COLUMN_WIDTHS
self._resize_columns()
self.maybe_resize_completion()
self._maybe_update_geometry()
@pyqtSlot()
def maybe_resize_completion(self):
"""Emit the resize_completion signal if the config says so."""
def _maybe_update_geometry(self):
"""Emit the update_geometry signal if the config says so."""
if config.get('completion', 'shrink'):
self.resize_completion.emit()
self.update_geometry.emit()
@pyqtSlot()
def on_clear_completion_selection(self):
@ -304,6 +311,27 @@ class CompletionView(QTreeView):
selmod.clearSelection()
selmod.clearCurrentIndex()
def sizeHint(self):
"""Get the completion size according to the config."""
# Get the configured height/percentage.
confheight = str(config.get('completion', 'height'))
if confheight.endswith('%'):
perc = int(confheight.rstrip('%'))
height = self.window().height() * perc / 100
else:
height = int(confheight)
# Shrink to content size if needed and shrinking is enabled
if config.get('completion', 'shrink'):
contents_height = (
self.viewportSizeHint().height() +
self.horizontalScrollBar().sizeHint().height())
if contents_height <= height:
height = contents_height
else:
contents_height = -1
# The width isn't really relevant as we're expanding anyways.
return QSize(-1, height)
def selectionChanged(self, selected, deselected):
"""Extend selectionChanged to call completers selection_changed."""
if not self._active:
@ -322,7 +350,7 @@ class CompletionView(QTreeView):
def showEvent(self, e):
"""Adjust the completion size and scroll when it's freshly shown."""
self.resize_completion.emit()
self.update_geometry.emit()
scrollbar = self.verticalScrollBar()
if scrollbar is not None:
scrollbar.setValue(scrollbar.minimum())

View File

@ -25,7 +25,7 @@ import itertools
import functools
from PyQt5.QtCore import pyqtSlot, QRect, QPoint, QTimer, Qt
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QApplication, QSizePolicy
from qutebrowser.commands import runners, cmdutils
from qutebrowser.config import config
@ -125,6 +125,7 @@ class MainWindow(QWidget):
_downloadview: The DownloadView widget.
_vbox: The main QVBoxLayout.
_commandrunner: The main CommandRunner instance.
_overlays: Widgets shown as overlay for the current webpage.
"""
def __init__(self, geometry=None, parent=None):
@ -137,6 +138,7 @@ class MainWindow(QWidget):
super().__init__(parent)
self.setAttribute(Qt.WA_DeleteOnClose)
self._commandrunner = None
self._overlays = []
self.win_id = next(win_id_gen)
self.registry = objreg.ObjectRegistry()
objreg.window_registry[self.win_id] = self
@ -177,7 +179,10 @@ class MainWindow(QWidget):
partial_match=True)
self._keyhint = keyhintwidget.KeyHintView(self.win_id, self)
self._overlays.append((self._keyhint, self._keyhint.update_geometry))
self._messageview = messageview.MessageView(parent=self)
self._overlays.append((self._messageview,
self._messageview.update_geometry))
log.init.debug("Initializing modes...")
modeman.init(self.win_id, self)
@ -196,11 +201,49 @@ class MainWindow(QWidget):
# When we're here the statusbar might not even really exist yet, so
# 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_signals)
QTimer.singleShot(0, self._connect_overlay_signals)
objreg.get('config').changed.connect(self.on_config_changed)
objreg.get("app").new_window.emit(self)
def _update_overlay_geometry(self, widget=None):
"""Reposition/resize the given overlay.
If no widget is given, reposition/resize all overlays.
"""
if widget is None:
for w, _signal in self._overlays:
self._update_overlay_geometry(w)
return
if not widget.isVisible():
return
size_hint = widget.sizeHint()
if widget.sizePolicy().horizontalPolicy() == QSizePolicy.Expanding:
width = self.width()
else:
width = size_hint.width()
status_position = config.get('ui', 'status-position')
if status_position == 'bottom':
top = self.height() - self.status.height() - size_hint.height()
top = qtutils.check_overflow(top, 'int', fatal=False)
topleft = QPoint(0, top)
bottomright = QPoint(width, self.status.geometry().top())
elif status_position == 'top':
topleft = self.status.geometry().bottomLeft()
bottom = self.status.height() + widget.height()
bottom = qtutils.check_overflow(bottom, 'int', fatal=False)
bottomright = QPoint(size_hint.width(), bottom)
else:
raise ValueError("Invalid position {}!".format(status_position))
rect = QRect(topleft, bottomright)
log.misc.debug('new geometry for {!r}: {}'.format(widget, rect))
if rect.isValid():
widget.setGeometry(rect)
def _init_downloadmanager(self):
log.init.debug("Initializing downloads...")
download_manager = downloads.DownloadManager(self.win_id, self)
@ -218,6 +261,8 @@ class MainWindow(QWidget):
completer_obj.on_selection_changed)
objreg.register('completion', self._completion, scope='window',
window=self.win_id)
self._overlays.append((self._completion,
self._completion.update_geometry))
def _init_command_dispatcher(self):
dispatcher = commands.CommandDispatcher(self.win_id,
@ -234,15 +279,15 @@ class MainWindow(QWidget):
@pyqtSlot(str, str)
def on_config_changed(self, section, option):
"""Resize the completion if related config options changed."""
if section == 'completion' and option in ['height', 'shrink']:
self.resize_completion()
elif section == 'ui' and option == 'statusbar-padding':
self.resize_completion()
elif section == 'ui' and option == 'downloads-position':
if section != 'ui':
return
if option == 'statusbar-padding':
self._update_overlay_geometry()
elif option == 'downloads-position':
self._add_widgets()
elif section == 'ui' and option == 'status-position':
elif option == 'status-position':
self._add_widgets()
self.resize_completion()
self._update_overlay_geometry()
def _add_widgets(self):
"""Add or readd all widgets to the VBox."""
@ -303,14 +348,12 @@ class MainWindow(QWidget):
log.init.warning("Error while loading geometry.")
self._set_default_geometry()
def _connect_resize_signals(self):
def _connect_overlay_signals(self):
"""Connect the resize signal and resize everything once."""
self._completion.resize_completion.connect(self.resize_completion)
self._keyhint.reposition_keyhint.connect(self.reposition_keyhint)
self._messageview.reposition.connect(self._reposition_messageview)
self.resize_completion()
self.reposition_keyhint()
self._reposition_messageview()
for widget, signal in self._overlays:
signal.connect(
functools.partial(self._update_overlay_geometry, widget))
self._update_overlay_geometry(widget)
def _set_default_geometry(self):
"""Set some sensible default geometry."""
@ -407,63 +450,6 @@ class MainWindow(QWidget):
raise ValueError("Invalid position {}!".format(status_position))
return QRect(topleft, bottomright)
@pyqtSlot()
def resize_completion(self):
"""Adjust completion according to config."""
if not self._completion.isVisible():
# It doesn't make sense to resize the completion as long as it's
# not shown anyways.
return
# Get the configured height/percentage.
confheight = str(config.get('completion', 'height'))
if confheight.endswith('%'):
perc = int(confheight.rstrip('%'))
height = self.height() * perc / 100
else:
height = int(confheight)
# Shrink to content size if needed and shrinking is enabled
if config.get('completion', 'shrink'):
contents_height = (
self._completion.viewportSizeHint().height() +
self._completion.horizontalScrollBar().sizeHint().height())
if contents_height <= height:
height = contents_height
else:
contents_height = -1
rect = self._get_overlay_position(height)
log.misc.debug('completion rect: {}'.format(rect))
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)
@pyqtSlot()
def _reposition_messageview(self):
"""Position the message view correctly."""
if not self._messageview.isVisible():
return
height = self._messageview.message_height()
rect = self._get_overlay_position(height)
log.misc.debug('messageview rect: {}'.format(rect))
if rect.isValid():
self._messageview.setGeometry(rect)
@cmdutils.register(instance='main-window', scope='window')
@pyqtSlot()
def close(self):
@ -490,8 +476,7 @@ class MainWindow(QWidget):
e: The QResizeEvent
"""
super().resizeEvent(e)
self.resize_completion()
self.reposition_keyhint()
self._update_overlay_geometry()
self._downloadview.updateGeometry()
self.tabbed_browser.tabBar().refresh()

View File

@ -20,8 +20,8 @@
"""Showing messages above the statusbar."""
from PyQt5.QtCore import pyqtSlot, pyqtSignal, QTimer, Qt
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel
from PyQt5.QtCore import pyqtSlot, pyqtSignal, QTimer, Qt, QSize
from PyQt5.QtWidgets import QWidget, QVBoxLayout, QLabel, QSizePolicy
from qutebrowser.config import config, style
from qutebrowser.utils import usertypes, objreg
@ -71,13 +71,14 @@ class MessageView(QWidget):
"""Widget which stacks error/warning/info messages."""
reposition = pyqtSignal()
update_geometry = pyqtSignal()
def __init__(self, parent=None):
super().__init__(parent)
self._vbox = QVBoxLayout(self)
self._vbox.setContentsMargins(0, 0, 0, 0)
self._vbox.setSpacing(0)
self.setSizePolicy(QSizePolicy.Expanding, QSizePolicy.Fixed)
self._clear_timer = QTimer()
self._clear_timer.timeout.connect(self._clear_messages)
@ -87,15 +88,17 @@ class MessageView(QWidget):
self._last_text = None
self._messages = []
def sizeHint(self):
"""Get the proposed height for the view."""
height = sum(label.sizeHint().height() for label in self._messages)
# The width isn't really relevant as we're expanding anyways.
return QSize(-1, height)
@config.change_filter('ui', 'message-timeout')
def _set_clear_timer_interval(self):
"""Configure self._clear_timer according to the config."""
self._clear_timer.setInterval(config.get('ui', 'message-timeout'))
def message_height(self):
"""Get the total height of all messages."""
return sum(label.sizeHint().height() for label in self._messages)
@pyqtSlot()
def _clear_messages(self):
"""Hide and delete all messages."""
@ -121,4 +124,4 @@ class MessageView(QWidget):
self._messages.append(widget)
self._last_text = text
self.show()
self.reposition.emit()
self.update_geometry.emit()

View File

@ -42,7 +42,7 @@ class KeyHintView(QLabel):
_win_id: Window ID of parent.
Signals:
reposition_keyhint: Emitted when this widget should be resized.
update_geometry: Emitted when this widget should be resized/positioned.
"""
STYLESHEET = """
@ -55,7 +55,7 @@ class KeyHintView(QLabel):
}
"""
reposition_keyhint = pyqtSignal()
update_geometry = pyqtSignal()
def __init__(self, win_id, parent=None):
super().__init__(parent)
@ -73,7 +73,7 @@ class KeyHintView(QLabel):
def showEvent(self, e):
"""Adjust the keyhint size when it's freshly shown."""
self.reposition_keyhint.emit()
self.update_geometry.emit()
super().showEvent(e)
@pyqtSlot(str)
@ -126,4 +126,4 @@ class KeyHintView(QLabel):
self.setText(text)
self.adjustSize()
self.reposition_keyhint.emit()
self.update_geometry.emit()

View File

@ -39,6 +39,7 @@ def completionview(qtbot, status_command_stub, config_stub, win_registry,
'scrollbar-padding': 2,
'shrink': False,
'quick-complete': False,
'height': '50%',
},
'colors': {
'completion.fg': QColor(),
@ -87,13 +88,13 @@ def test_set_pattern(completionview):
model.set_pattern.assert_called_with('foo')
def test_maybe_resize_completion(completionview, config_stub, qtbot):
def test_maybe_update_geometry(completionview, config_stub, qtbot):
"""Ensure completion is resized only if shrink is True."""
with qtbot.assertNotEmitted(completionview.resize_completion):
completionview.maybe_resize_completion()
config_stub.data = {'completion': {'shrink': True}}
with qtbot.waitSignal(completionview.resize_completion):
completionview.maybe_resize_completion()
with qtbot.assertNotEmitted(completionview.update_geometry):
completionview._maybe_update_geometry()
config_stub.data['completion']['shrink'] = True
with qtbot.waitSignal(completionview.update_geometry):
completionview._maybe_update_geometry()
@pytest.mark.parametrize('which, tree, expected', [

View File

@ -67,13 +67,13 @@ def test_message_hiding(qtbot, view):
assert not view._messages
def test_message_height(view):
def test_size_hint(view):
"""The message height should increase with more messages."""
view.show_message(usertypes.MessageLevel.info, 'test1')
height1 = view.message_height()
height1 = view.sizeHint().height()
assert height1 > 0
view.show_message(usertypes.MessageLevel.info, 'test2')
height2 = view.message_height()
height2 = view.sizeHint().height()
assert height2 == height1 * 2