controlling size of QItemDelegate dynamically - qt

I have a couple of questions regarding the sizes of QItemDelegates in a QListView:
I have a QListView using a QItemDelegate which renders a widget in the delegate's custom paint() method like so:
self.thumbnail = MyCustomWidget()
self.thumbnail.render(painter, QtCore.QPoint(option.rect.x(), option.rect.y()))
This, however, shows the item with a 250x260 image in a QListView, even though the MyCustomWidget().sizeHint() is 250x250 and it's maximumSize() returns 250x250 as well.
I found that the culprit is the QListView's spacing, which I had set to 10. If I set the spacing to 100, I still get the QItemDelegates size of 250x260, but if I just don't use setSpacing() at all it renders as expected at 250x250.
The spacing seems to alter the option.rect that is passed into the paint method, causing the incorrect size.
I do need that spacing, so I'm a bit confused why the QListView's spacing alters the QItemDelegates's size? Is this a bug?
I can work around this by rendering a QPixmap first, then have the painter draw the QPixmap instead of rendering to the painter directly:
self.thumbnail = MyCustomWidget()
pixmap = QtGui.QPixmap(self.thumbnail.size())
self.thumbnail.render(pixmap)
painter.drawPixmap(option.rect.topLeft(), pixmap)
This yields 250x250 images which is what I need, but I don't understand why the first method doesn't render the correct size when I use setSpacing?!
Now, the bigger challenge is how to dynamically scale the size of the QItemDelegate's via a QSlider:
I have a QSlider in the QListView that is supposed to scale the items so the user can chose to see smaller but more items in the current view. I tested the resizing of a standalone instance of MyCustomWidget() and it works just fine.
However, the delegates won't scale as expected. This is my delegate code:
class Delegate(QtGui.QItemDelegate):
def __init__(self, parent = None):
super(Delegate, self).__init__(parent)
self.scaleValue = 100 # size in percent (as returned by QSlider)
def paint(self, painter, option, index):
proxyModel = index.model()
item = proxyModel.sourceModel().itemFromIndex(proxyModel.mapToSource(index))
self.thumbnail = ElementThumbnail(item)
self.thumbnail.scale(self.scaleValue)
pixmap = QtGui.QPixmap(self.thumbnail.size())
self.thumbnail.render(pixmap)
painter.drawPixmap(option.rect.topLeft() * self.scaleValue / 100.0, pixmap)
super(Delegate, self).paint(painter, option, index)
def setScaleValue(self, value):
self.scaleValue = value
def sizeHint(self, option, index):
return ElementThumbnail.thumbSize * self.scaleValue / 100.0
and in the QListView I am using this slot connected to the slider's valueChanges signal:
def scaleThumbnails(self, value):
self.itemDelegate().setScaleValue(value)
self.update()
The result is that the QSlider will crop the QItemDelegates but not scale them, because the QItemDelegate's sizeHint() is only called when the QListView is first shown.
Additionally, I need to make sure that when the widgets are (eventually) scaled down, the layout of QListView is recalculated and more items are fit inside the visible area.
So in a nutshell my questions are:
How can I scale QItemDelegates dynamically inside a QListView?
How can I force the QListView to recalculate it's layout after the delegate size has been changed?
edit: as for issue 2: QAbstractItemView.doItemsLayout seems to do the trick. Still wondering about issue 1 though
Thanks,
frank

Turns out it was the context of my code that was the issue, not the delegate.
I was scaling the widget before rendering it to a pixmap, which of course made the editor scale properly, but not the item when it wasn't in edit state.
So the solution is simply to scale the pixmap after rendering it from the widget, e.g.:
scaledPixmap = pixmap.scaled(pixmap.size() * self.scaleValue / 100.0)
painter.drawPixmap(option.rect.topLeft(), scaledPixmap)

Related

How to avoid over-packing non-srolling Qt layouts?

A Qt packing layout, such as QVBoxLayout, can pack widgets inside it, such as buttons. In this case, they will be packed vertically as shown in image below:
When we pack too many widgets inside such a layout, and since scrolling is not added by default, the buttons will eventually get squeezed onto each other up to a point that they will overlap, as shown below:
My questions are:
How to tell Qt to not show/pack widgets beyond the available viewing space in the non-scrolling layout?
How to handle the case when the window is resized? I.e. Qt should add/remove widgets accordingly. E.g. if there is extra space available, then perhaps Qt should add some extra widgets that it couldn't add previously.
To be specific: "too many packed widgets" is when the widgets start invading spaces of other widgets, including their inter-widget spacings or margins.
Appendix
Images above are generated by this code below as run in a tile in i3, which is a modified version of this.
from PyQt5 import QtCore, QtWidgets
app = QtWidgets.QApplication([])
widget = QtWidgets.QWidget()
layout = QtWidgets.QVBoxLayout(widget)
for i in range(40):
layout.addWidget(QtWidgets.QPushButton(str(i + 1)))
widget.show()
app.exec_()
When too many widgets are packed:
If the window is tiled, you see them overcrowded as in in the image.
If the window is floating, the window will keep growing until it is no longer fully visible in the monitor.
None of these outcomes are acceptable in my case. My goal is to have Qt only pack as much as will be visible, and add/remove/hide/show dynamically as the window gets resized.
Try this code. It does not rely on QVBoxLayout but it basically does the same as this layout. It hides the child widgets which are outside of the area. There are no partially visible widgets.
from PyQt5 import QtWidgets
class Container(QtWidgets.QWidget):
_spacing = 5
def __init__(self, parent=None):
super().__init__(parent)
y = self._spacing
for i in range(40):
button = QtWidgets.QPushButton("Button" + str(i + 1), self)
button.move(self._spacing, y)
y += button.sizeHint().height() + self._spacing
def resizeEvent(self, event):
super().resizeEvent(event)
for child in self.children():
if isinstance(child, QtWidgets.QWidget):
child.resize(self.width() - 2 * self._spacing, child.height())
child.setVisible(child.geometry().bottom() < self.height())
app = QtWidgets.QApplication([])
w = Container()
w.resize(500, 500)
w.show()
app.exec_()
Note that is in fact does not add nor remove widgets dynamically, this would be much more code and it would probably be very depending on your specific use case. Moreover it feels as a premature optimization. Unless you really need it, do not do it.
UPDATE:
I experimented with the code above and proposed some improvements. I especially wanted to make it responsive to changes in child widgets. The problem is that if the child widget changes it size, the parent container must be re-layouted. The code above does not react in any way. To make it responsive, we need to react to LayoutRequest event. Note that in the code below, I have created three types of buttons - one add a line to itself, other increases font size, and yet another decreases font size.
from PyQt5 import QtCore, QtWidgets
def changeFontSize(increment):
font = QtWidgets.QApplication.font()
font.setPointSize(font.pointSize() + increment)
QtWidgets.QApplication.setFont(font)
class Container(QtWidgets.QWidget):
_spacing = 5
_children = [] # maintains the order of creation unlike children()
def __init__(self, parent=None):
super().__init__(parent)
for i in range(100):
child = QtWidgets.QPushButton(self)
child.installEventFilter(self)
# these are just to test various changes in child widget itself to force relayout
r = i % 3
if r == 0:
text = "New line"
onClicked = lambda state, w=child: w.setText(w.text() + "\nclicked")
elif r == 1:
text = "Bigger font"
onClicked = lambda: changeFontSize(1)
elif r == 2:
text = "Smaller font"
onClicked = lambda: changeFontSize(-1)
child.setText(text)
child.clicked.connect(onClicked)
self._children.append(child)
def resizeEvent(self, event):
super().resizeEvent(event)
self._relayout()
def event(self, event):
if event.type() == QtCore.QEvent.LayoutRequest:
self._relayout()
return super().event(event)
def _relayout(self):
y = self._spacing
for child in self._children:
h = child.sizeHint().height()
child.move(self._spacing, y)
child.resize(self.width() - 2 * self._spacing, h)
y += h + self._spacing
child.setVisible(y < self.height())
app = QtWidgets.QApplication([])
w = Container()
w.resize(500, 500)
w.show()
app.exec_()
This code is satisfactory, however it is not perfect. I have observed that when the container is being re-layouted and some of the child widgets will change its visibility state, re-layouting is called again. This is not needed but I have not discovered how to prevent it.
Maybe there is some better way...

How to make user resizable QWidget inside a QScrollArea?

Requirements:
QScrollArea containing several widgets.
Each widget should be individually resizable by the user (in either horizontal, or vertical, but not both directions).
User resizing of a widget should not change the size of other widgets. It should increase/decrease the area available in the QScrollArea.
Using a QSplitter doesn't help, because the QSplitter remains of fixed width, and resizing any of its splits causes other splits to shrink.
[1] [2] [3]
Surely it can be done by creating a custom widget, adding a visual bar for indicating the draggable area, and listening to a drag event to resize the widget via code. Is there a simpler solution?
I had the same problem. Came up with a nasty hack:
put a QSplitter inside the QScrollArea
store the old sizes of all QSplitter child widgets
when a QSplitterHandle moves (i.e. on SIGNAL splitterMoved() )
Calculate how much the changed child widget has grown/shrunk
Change the min size of the whole QSplitter by that amount
Update my stored size for the changed child widget only
Set the sizes of the QSplitter child widgets to my stored sizes.
It works for me (for now). But it's kludgy, and there are some yucky magic numbers in it to make it work.
So if anyone comes up with a better solution, that would be great!
Anyway - in case anyone finds it useful, Code (in Python3 & PySide2)
import sys
from PySide2.QtCore import Qt
from PySide2.QtWidgets import QWidget, QScrollArea, QSplitter
from PySide2.QtWidgets import QApplication, QMainWindow, QLabel, QFrame
class ScrollSplitter(QScrollArea):
def __init__(self, orientation, parent=None):
super().__init__(parent)
# Orientation = Qt.Horizontal or Qt.Vertical
self.orientation = orientation
# Keep track of all the sizes of all the QSplitter's child widgets BEFORE the latest resizing,
# so that we can reinstate all of them (except the widget that we wanted to resize)
self.old_sizes = []
self._splitter = QSplitter(orientation, self)
# TODO - remove magic number. This is required to avoid zero size on first viewing.
if orientation == Qt.Horizontal :
self._splitter.setMinimumWidth(500)
else :
self._splitter.setMinimumHeight(500)
# In a default QSplitter, the bottom widget doesn't have a drag handle below it.
# So create an empty widget which will always sit at the bottom of the splitter,
# so that all of the user widgets have a handle below them
#
# I tried playing with the max width/height of this bottom widget - but the results were crummy. So gave up.
bottom_widget = QWidget(self)
self._splitter.addWidget(bottom_widget)
# Use the QSplitter.splitterMoved(pos, index) signal, emitted every time the splitter's handle is moved.
# When this signal is emitted, the splitter has already resized all child widgets to keep its total size constant.
self._splitter.splitterMoved.connect(self.resize_splitter)
# Configure the scroll area.
if orientation == Qt.Horizontal :
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
else :
self.setVerticalScrollBarPolicy(Qt.ScrollBarAlwaysOn)
self.setHorizontalScrollBarPolicy(Qt.ScrollBarAlwaysOff)
self.setWidgetResizable(True)
self.setWidget(self._splitter)
# Called every time a splitter handle is moved
# We basically undo the QSplitter resizing of all the other children,
# and resize the QSplitter (using setMinimumHeight() or setMinimumWidth() ) instead.
def resize_splitter(self, pos, index):
# NOTE: index refs the child widget AFTER the moved splitter handle.
# pos is position relative to the top of the splitter, not top of the widget.
# TODO - find a better way to initialise the old_sizes list.
# Ideally whenever we add/remove a widget.
if not self.old_sizes :
self.old_sizes = self._splitter.sizes()
# The 'index' arg references the QWidget below the moved splitter handle.
# We want to change the QWidget above the moved splitter handle, so...
index_above = index - 1
# Careful with the current sizes - QSplitter has already mucked about with the sizes of all other child widgets
current_sizes = self._splitter.sizes()
# The only change in size we are interested in is the size of the widget above the splitter
size_change = current_sizes[index_above] - self.old_sizes[index_above]
# We want to keep the old sizes of all other widgets, and just resize the QWidget above the splitter.
# Update our old_list to hold the sizes we want for all child widgets
self.old_sizes[index_above] = current_sizes[index_above]
# Increase/decrease the(minimum) size of the QSplitter object to accommodate the total new, desired size of all of its child widgets (without resizing most of them)
if self.orientation == Qt.Horizontal :
self._splitter.setMinimumWidth(max(self._splitter.minimumWidth() + size_change, 0))
else :
self._splitter.setMinimumHeight(max(self._splitter.minimumHeight() + size_change, 0))
# and set the sizes of all the child widgets back to their old sizes, now that the QSplitter has grown/shrunk to accommodate them without resizing them
self._splitter.setSizes(self.old_sizes)
#print(self.old_sizes)
# Add a widget at the bottom of the user widgets
def addWidget(self, widget):
self._splitter.insertWidget(self._splitter.count()-1, widget)
# Insert a widget at 'index' in the splitter.
# If the widget is already in the splitter, it will be moved.
# If the index is invalid, widget will be appended to the bottom of the (user) widgets
def insertWidget(self, index, widget):
if index >= 0 and index < (self._splitter.count() - 1) :
self._splitter.insertWidget(index, widget)
self.addWidget(widget)
# Replace a the user widget at 'index' with this widget. Returns the replaced widget
def replaceWidget(self, index, widget):
if index >= 0 and index < (self._splitter.count() - 1) :
return self._splitter.replaceWidget(index, widget)
# Return the number of (user) widgets
def count(self):
return self._splitter.count() - 1
# Return the index of a user widget, or -1 if not found.
def indexOf(self, widget):
return self._splitter.indexOf(widget)
# Return the (user) widget as a given index, or None if index out of range.
def widget(self, index):
if index >= 0 and index < (self._splitter.count() - 1) :
return self._splitter.widget(index)
return None
# Save the splitter's state into a ByteArray.
def saveState(self):
return self._splitter.saveState()
# Restore the splitter's state from a ByteArray
def restoreState(self, s):
return self._splitter.restoreState(s)
class MainWindow(QMainWindow):
def __init__(self):
super().__init__()
self.setWindowTitle("ScrollSplitter Test")
self.resize(640, 400)
self.splitter = ScrollSplitter(Qt.Vertical, self)
self.setCentralWidget(self.splitter)
for color in ["Widget 0", "Widget 1", "Widget 2", "Some other Widget"]:
widget = QLabel(color)
widget.setFrameStyle(QFrame.Panel | QFrame.Raised)
self.splitter.addWidget(widget)
app = QApplication(sys.argv)
window = MainWindow()
window.show()
app.exec_()

QGraphicsItem rendering on Apple Retina Display

I'm adding support for the Apple Retina Display to my PyQt5 application. While I successfully managed to render high resolution icons (by adding the #2x suffix to all my .png files and setting the Qt.AA_UseHighDpiPixmaps in my QApplication), I'm having some troubles in rendering high resolutions QGraphicsItem in a QGraphicsScene + QGraphicsView.
In my application, other than loading .png files, I also generate several QPixmap my self (embedding them into a Icon), to build the palette of symbols the user can use to add new shapes to the diagram rendered in the QGraphicsView, i.e:
def icon(cls, width, height, **kwargs):
"""
Returns an icon of this item suitable for the palette.
:type width: int
:type height: int
:rtype: QIcon
"""
icon = QIcon()
for i in (1.0, 2.0):
# CREATE THE PIXMAP
pixmap = QPixmap(width * i, height * i)
pixmap.setDevicePixelRatio(i)
pixmap.fill(Qt.transparent)
# PAINT THE SHAPE
polygon = cls.createPolygon(46, 34)
painter = QPainter(pixmap)
painter.setRenderHint(QPainter.Antialiasing)
painter.setPen(QPen(QColor(0, 0, 0), 1.1, Qt.SolidLine))
painter.setBrush(QColor(252, 252, 252))
painter.translate(width / 2, height / 2)
painter.drawPolygon(polygon)
# PAINT THE TEXT INSIDE THE SHAPE
painter.setFont(Font('Arial', 11, Font.Light))
painter.drawText(polygon.boundingRect(), Qt.AlignCenter, 'role')
painter.end()
# ADD THE PIXMAP TO THE ICON
icon.addPixmap(pixmap)
return icon
Which generate one of the symbols in my Palette (the diamond one).
However when I add elements to my QGraphicsScene, displayed in a QGraphicsView they are rendered in low resolution:
def paint(self, painter, option, widget=None):
"""
Paint the node in the diagram.
:type painter: QPainter
:type option: QStyleOptionGraphicsItem
:type widget: QWidget
"""
painter.setPen(self.pen)
painter.setBrush(self.brush)
painter.drawPolygon(self.polygon)
The text within the shape is rendered correctly, and I'm not painting it myself since it's a QGraphicsTextItem having my QGraphicsItem as parent.
The problem is that while for QPixmap I can set the device pixel ratio, for QGraphicsItem i cannot. Am I missing something?
I'm using PyQt 5.5.1 built against Qt 5.5.1 and SIP 4.18 (not using 5.6 since I'm experiencing several crashes on application startup which I already reported to PyQt devs).
Probably not what you wanted to hear, but Qt added retina support in 5.6.
I'm also struggling with the similar issue in PyQt 5.7.
If you are writing your sub class of QGraphicsItem, try to set render hint to antialias in the paint() method:
def paint(self, painter, option, widget=None):
"""
Paint the node in the diagram.
:type painter: QPainter
:type option: QStyleOptionGraphicsItem
:type widget: QWidget
"""
painter.setPen(self.pen)
painter.setBrush(self.brush)
painter.setRenderHint(QPainter.Antialiasing) # <-- add this line
painter.drawPolygon(self.polygon)
Note, this may not be the best or correct answer.

QLabel doesn't redraw correctly when in a QHBoxLayout with a stretch

I'm having an odd problem with a label not being redrawn correctly when the text is changed, when it's inside a QHBoxLayout with an added stretch.
Consider the following (PyQt) example code:
from PyQt5.QtWidgets import QApplication, QHBoxLayout, QWidget, QLabel
from PyQt5.QtCore import QTimer
def clearlabel():
print("clearing")
lbl.setText("")
lbl2.setText("")
app = QApplication([])
# Widget 1: with stretch
w = QWidget()
w.move(0, 0)
w.resize(100, 20)
w.show()
lbl = QLabel()
lbl.setText("foo")
h = QHBoxLayout(w)
h.addStretch()
h.addWidget(lbl)
# Widget 2: without stretch
w2 = QWidget()
w2.move(0, 40)
w2.resize(100, 20)
w2.show()
lbl2 = QLabel()
lbl2.setText("foo")
h2 = QHBoxLayout(w2)
h2.addWidget(lbl2)
QTimer.singleShot(1000, clearlabel)
app.exec_()
Two widgets are shown, one with a QHBoxLayout with a stretch added, one without:
After 2 seconds, a timer sets both label texts from "foo" to an empty string. In the widget without stretch it works like expected - with the one with, however, the label text doesn't get redrawn:
What's going on there? Is this a Qt bug? Am I missing something?
What I found out so far:
This only seems to happen when setting an empty string, setting a shorter string works fine.
However in my real application, it happens without a stretch added as well.
I have now submitted this as QTBUG-36945.
Thanks to the helpful people in the #qt IRC channel (on Freenode), I figured out doing a repaint after setting the text works around the issue:
class Label(QLabel):
...
def setText(self, text):
super().setText(text)
if not text:
self.repaint()
Still I'd be glad to know if I'm just wrong somewhere, or if I should submit a Qt bug.

QListWidget adjust size to content

Is it possible to adjust QListWidget height and width to it's content?
sizeHint() always returns 256, 192 no matter what its content is.
QListWidgetItem's sizeHint() returns -1, -1, so I can not get content width.
Problem the same as here - http://www.qtcentre.org/threads/31787-QListWidget-width , but there is no solution.
import sys
from PyQt4.QtGui import *
class MainWindow(QWidget):
def __init__(self, parent=None):
QWidget.__init__(self, parent)
list = QListWidget()
list.addItem('111111111111111')
vbox = QVBoxLayout(self)
vbox.addWidget(list)
app = QApplication(sys.argv)
myapp = MainWindow()
myapp.show()
sys.exit(app.exec_())
sizeHint() always returns 256, 192 no
matter what its content is.
Thats because this is the size of the QListWidget, the viewport, not the items. sizeHintForColumn() will give you the max size over all items, so you can resize the widget like this:
list.setMinimumWidth(list.sizeHintForColumn(0))
If you don't want to force minimum width, then subclass and provide this as the size hint instead. E.g.:
class ListWidget(QListWidget):
def sizeHint(self):
s = QSize()
s.setHeight(super(ListWidget,self).sizeHint().height())
s.setWidth(self.sizeHintForColumn(0))
return s
Using takois answer I played around with the sizeHintForColumn or sizeHintForRow and found that you have to add slightly larger numbers, because there might be some style dependent margins still. ekhumoros comment then put me on the right track.
In short the full size of the list widget is:
list.sizeHintForColumn(0) + 2 * list.frameWidth()
list.sizeHintForRow(0) * list.count() + 2 * list.frameWidth())
According to the comment by Violet it may not work in Qt 5.
Also be aware that setting the size to the content, you don't need scrollbars, so I turn them off.
My full example for a QListWidget ajusted to its content size:
from PySide import QtGui, QtCore
app = QtGui.QApplication([])
window = QtGui.QWidget()
layout = QtGui.QVBoxLayout(window)
list = QtGui.QListWidget()
list.addItems(['Winnie Puh', 'Monday', 'Tuesday', 'Minnesota', 'Dracula Calista Flockhart Meningitis', 'Once', '123345', 'Fin'])
list.setHorizontalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
list.setVerticalScrollBarPolicy(QtCore.Qt.ScrollBarAlwaysOff)
list.setFixedSize(list.sizeHintForColumn(0) + 2 * list.frameWidth(), list.sizeHintForRow(0) * list.count() + 2 * list.frameWidth())
layout.addWidget(list)
window.show()
app.exec_()
In order to effectively use sizeHint, you have to override it, at least in c++. In my experience, the default implementations for widgets can be pretty useless when you want a specific behavior. Attempts to force what you want with spacers or layouts end in disaster. If you can derive from QListWidget and override sizeHint, you can iterate through your items and find the longest string, then do some kind of magic to determine how wide it should be.
That's what I'd do, anyway.
First you should get your largest string in the list, that is easy to obtain.
After you get that string, do the following:
QFontMetrics * fm = new QFontMetrics(widget->font());
QRect rect;
rect = fm->boundingRect(string);
rect.width() has the width in pixels of the largest string
rect.height() has it's height.
Set the QListWidget width to that rect's width (plus the margins)
and it's height to that rect's height times the number of items
I didn't test the code, but hope it puts you on the right track
QListWidget *valList;
valList = new QListWidget(this);
valList->setSizePolicy (QSizePolicy(QSizePolicy::Ignored, QSizePolicy::Ignored));
valList->setMinimumSize (QSize(1111, 111));
You need to get the QHeaderView of your QListWidget and adjust its resize mode.
Read this documentation for more information
http://doc.qt.nokia.com/latest/qheaderview.html#ResizeMode-enum

Resources