Complex widget as an item delegate
-
I want to show a complex widget consisting from two labels and a button for each item in the
QListView
, something like in the mockup below
There is no need to edit items, but button should be clickable.I subclassed
QStyledItemDelegate
and managed to show singleline texts and button. But now I need to change button style (hovered, pressed, normal) and handle clicks, also it would be nice to somehow hower active item.As I understand, to adjust button style I need to track mouse position inside the item and depending on it repaint delegate with different button style. Similarly with handling clicks. So I'm wondering is there any other way to use existing widget as a delegate?
Using
setItemWidget(…)
is not an option, as the number of items in the view can be big. -
You could theoretically track the mouse and set/remove item widget of the single item that is currently under the cursor. It would take care of painting various states and clicking and you would paint the other items with a delegate, but it's a burden to keep both the widget and the delegate look exactly the same and I would say the same amount of work, if not more.
I think overriding editorEvent and handling move/press/release event is not that hard and the right call here. Should be just a couple of lines.
-
Thanks, @Chris-Kawa. I tried to follow your suggestion and now my delegate can handle mouse clicks on the button. However, I'm stuck with changing button state, maybe you can guide me in the right direction?
Here is my code
class MyDelegate(QStyledItemDelegate): def __init__(self): super(MyDelegate, self).__init__() self.btnRect = None def sizeHint(self, option, index): fm = QFontMetrics(option.font) return QSize(150, fm.height() * 4 + fm.leading()) def paint(self, painter, option, index): data = index.data(Qt.UserRole + 1) nameFont = QFont(option.font) nameFont.setWeight(QFont.Weight.Bold) fm = QFontMetrics(nameFont) padding = fm.lineSpacing() // 2 nameRect = QRect(option.rect) nameRect.setLeft(nameRect.left() + padding) nameRect.setTop(nameRect.top() + padding) nameRect.setRight(nameRect.right() - padding) nameRect.setHeight(fm.lineSpacing()) button = QStyleOptionButton() button.text = "Add" button.state = QStyle.State_Enabled textWidth = fm.width(button.text) self.btnRect = QRect(option.rect) self.btnRect.setLeft(nameRect.right() - textWidth * 2) self.btnRect.setTop(nameRect.bottom() + padding) self.btnRect.setRight(nameRect.right() - padding) self.btnRect.setHeight(fm.lineSpacing() * 2) button.rect = self.btnRect borderRect = QRect(option.rect.marginsRemoved(QMargins(4, 4, 4, 4))) painter.save() pen = painter.pen() if option.state & QStyle.State_MouseOver: pp = QPen(option.palette.highlight().color()) pp.setWidth(2) painter.setPen(pp) painter.drawRect(borderRect) painter.setPen(pen) # draw contents painter.setFont(nameFont) elided_text = fm.elidedText(data.title, Qt.ElideRight, nameRect.width()) painter.drawText(nameRect, Qt.AlignLeading, elided_text) QApplication.style().drawControl(QStyle.CE_PushButton, button, painter) painter.restore() def editorEvent(self, event, model, option, index): if self.btnRect.contains(event.pos()): if (event.type() == QEvent.MouseButtonPress): # change button style to QStyle.State_Sunken pass elif (event.type() == QEvent.MouseButtonRelease): # change button style to QStyle.State_Raised self.do_something() else: # change button style to QStyle.State_HasFocus? pass else: # set button state to normal QStyle.State_Enabled pass return super(MyDelegate, self).editorEvent(event, model, option, index) def do_something(self): # do something when button clicked pass
It is not clear to me what is the best way to pass button state to the
paint()
method. I tried to assing it to the member variable, but it does not work this way reliably, button state changes only sporadically. -
I tried to assign it to the member variable, but it does not work this way reliably
If you want to store the state of a button you have to do it separately for each index. You can do that for example through a custom user role like
Qt.UserRole + 2
. You would then read that out in the paint method.Another way is to store the state of the mouse buttons and cursor position to a member variable of the delegate and in the paint determine what the state of particular index button is based on that information.
In any case make absolutely sure first that given event causes repaint at all. If not you'll have to trigger an update on the widget so that paint is called.
-
Thanks, this helps a lot! Setting button style in a custom user role in the
editorEvent
partially does the trick. In theeditorEvent()
I doif (event.type() == QEvent.MouseButtonPress): model.setData(QStyle.State_Sunken, Qt.UserRole + 2)
and in the
paint()
button = QStyleOptionButton() button.state = index.data(Qt.UserRole + 2)
The problem is that the first click on the item does not change button state. Only every second click makes changes in the sunken/raised state. Also hovering cursor over the button does not make it look active (having a focus).
I tried to debug it and it looks like the first
MouseButtonPress
event is not propagated to the delegate. Probably the same happens with the mouse hovering. Any hits what can be missed/wrong? -
Do you have mouse tracking enabled on your view widget? Without that widgets get move events only when a button is pressed.
As for the first button press - it works for me ok. Something must be swallowing it in your code.
-
Yes, I have mouse tracking enabled.
Maybe my description of the issue was not very clear, sorry. When I click for the first time on the button, it does not change its style to
QStyle.State_Sunken
and then back toQStyle.State_Raised
on release, this happens only on the every second click. Also button does not change its style on mouse hover.Here is a minimal example
import sys from PyQt5.QtGui import * from PyQt5.QtCore import * from PyQt5.QtWidgets import * class MyDelegate(QStyledItemDelegate): def __init__(self): super(MyDelegate, self).__init__() self.buttonRect = None def sizeHint(self, option, index): fm = QFontMetrics(option.font) return QSize(150, fm.height() * 5 + fm.leading()) def paint(self, painter, option, index): name = index.data(Qt.DisplayRole) description = index.data(Qt.UserRole + 1) btnStyle = QStyle.State(index.data(Qt.UserRole + 2)) nameFont = QFont(option.font) nameFont.setWeight(QFont.Weight.Bold) fm = QFontMetrics(nameFont) padding = fm.lineSpacing() // 2 nameRect = QRect(option.rect) nameRect.setLeft(nameRect.left() + padding) nameRect.setTop(nameRect.top() + padding) nameRect.setRight(nameRect.right() - padding) nameRect.setHeight(fm.lineSpacing()) descrRect = QRect(option.rect) descrRect.setLeft(descrRect.left() + padding) descrRect.setTop(nameRect.bottom()) descrRect.setRight(descrRect.right() - padding) descrRect.setHeight(fm.lineSpacing()) btnText = "Add" textWidth = fm.width(btnText) self.btnRect = QRect(option.rect) self.btnRect.setLeft(descrRect.right() - textWidth * 2) self.btnRect.setTop(descrRect.bottom() + padding) self.btnRect.setRight(descrRect.right() - padding) self.btnRect.setHeight(fm.lineSpacing() * 2) borderRect = QRect(option.rect.marginsRemoved(QMargins(4, 4, 4, 4))) painter.save() pen = painter.pen() if option.state & QStyle.State_MouseOver: pp = QPen(option.palette.highlight().color()) pp.setWidth(2) painter.setPen(pp) painter.drawRect(borderRect) painter.setPen(pen) painter.setFont(nameFont) elided_text = fm.elidedText(name, Qt.ElideRight, nameRect.width()) painter.drawText(nameRect, Qt.AlignLeading, elided_text) painter.setFont(option.font) fm = QFontMetrics(QFont(option.font)) elided_text = fm.elidedText(description, Qt.ElideRight, descrRect.width()) painter.drawText(descrRect, Qt.AlignLeading, elided_text) button = QStyleOptionButton() button.text = btnText button.state = btnStyle button.rect = self.btnRect QApplication.style().drawControl(QStyle.CE_PushButton, button, painter) painter.restore() def editorEvent(self, event, model, option, index): if self.btnRect.contains(event.pos()): if event.type() == QEvent.MouseButtonPress: model.setData(index, QStyle.State_Sunken, Qt.UserRole + 2) elif event.type() == QEvent.MouseButtonRelease: model.setData(index, QStyle.State_Raised, Qt.UserRole + 2) self.do_something() else: model.setData(index, QStyle.State_Enabled | QStyle.State_HasFocus, Qt.UserRole + 2) else: model.setData(index, QStyle.State_Enabled, Qt.UserRole + 2) return super(MyDelegate, self).editorEvent(event, model, option, index) def do_something(self): print("button clicked") if __name__ == "__main__": app = QApplication(sys.argv) model = QStandardItemModel() for i in range(10): item = QStandardItem() item.setData(f"Item {i}", Qt.DisplayRole) item.setData("Item description, can be very long", Qt.UserRole + 1) item.setData(QStyle.State_Enabled, Qt.UserRole + 2) model.appendRow(item) listView = QListView() listView.setMouseTracking(True) listView.setItemDelegate(MyDelegate()) listView.setModel(model) listView.show() app.exec()