Reputation: 53
I have a QListView
which uses a model to display custom Alarm
objects as text applying different colors based on the Alarm.level
attribute of each object.
@dataclass
class Alarm:
name: str
level: int
started_at: datetime
class Alarm_Viewer_Model(QAbstractListModel):
def __init__(
self,
alarms: List[Alarm] = None,
alarm_colors: Dict[int, QColor] = None,
*args,
**kwargs
):
super().__init__(*args, **kwargs)
self._alarms = alarms or []
self._alarm_colors = alarm_colors or {}
def data(self, index: QModelIndex, role: int):
alarm = self._alarms[index.row()]
if role == Qt.DisplayRole:
dt = alarm.started_at.strftime('%Y/%m/%d %H:%M:%S')
return f"{dt} - {alarm.level.name} - {alarm.name}"
elif role == Qt.ForegroundRole:
alarm_color = self._alarm_colors.get(alarm.level, Qt.black)
return QBrush(alarm_color)
This works fine until I try load a stylesheet which applies a default text color to all widgets. In this case the stylesheet color overrides whatever color is set by the model (other questions, like this or this, confirm that this is normal Qt behaviour).
My question is: how can I still have control over the style of each list item while a generic style is applied via the stylesheet?
One way I tried to do this (which is also suggested in one of the links) is to have the model set a custom property on the item which could be accessed by the stylesheet with an appropriate selector (e.g. QListView::item[level=1] { color: red; }
). However, I could not find any way to set the property in the list view item: is it even possibile?
Another way that was suggested (but have not yet tried) is via QStyledItemDelegate. However from what I have seen, even if it works, it looks way overkill: is it the only way to solve this?
PS: I tagged this with PySide2 as I am using it, but am more interested in the general Qt behaviour: finding a way to implement it in PySide2 specifically is a secondary problem for now.
Upvotes: 0
Views: 367
Reputation: 48231
Qt Style Sheets are fun and useful, but they also are double-edged swords.
An important thing to always be aware of is that they mostly respect the concept behind CSS, from which they originate.
When a QSS is set, it can possibly, if not completely override the default behavior, similarly to what happens in web browsers.
In the Qt world, this works with a fundamental hierarchy:
Remember that a style will always have the final word on how any aspect of the widget is eventually drawn, as long as you call its functions. That's also why it is wrong to set generic properties on parent/container widgets.
Whenever a QSS affects a widget in some way, it accesses its render rules and eventually reverts back to the upper level of the style hierarchy, but it will always override
If a QAbstractItemDelegate::item
rule is set, this means that it will completely override any underlying QStyle function.
When the paint()
function of QStyledItemDelegate is called, it will just call initStyleOption()
with the given option and index, and then asks the style to paint it. How the item is finally drawn is completely up to the style. Normally, styles follow the Qt.BackgroundRole
(and/or the give option.backgroundBrush
) and Qt.ForegroundRole
, but that's just an assumption: a style may decide to use its own colors and completely ignore the model.
This brings us back to the point: as said above, a QStyleSheetStyle always overrides the underlying QStyle as soon as some of its properties are set.
Setting the color
or background[-color]
properties in a QSS means that those rules are built upon the default styling, which is also valid for subcontrols like ::item
.
Now, for generic widget properties, it's quite easy to access the palette that the QStyleSheetStyle updates, but, unfortunately, there's no direct access to subcontrols rules.
As explained in a comment to the related QTBUG-75191, they don't plan to change the current behavior. That may be annoying, but I completely understand it.
So, how can we work around this?
Well, it depends. As the comment above says:
I explained it in the beginning: QSS can be double-edged swords. If you want to use them, you have to accepts their shortcomings.
As long as you have complete and absolute control over the views, their models and the stylesheet syntax, you can go along with a solution similar to the one you already got (but you should probably get the textRect
from the style subElementRect()
, using SE_ItemViewItemText
, and finally paint with drawItemText
).
Unfortunately, this won't solve all problems.
For example, while the answer linked above may provide a possible solution, it won't work well for a QSS that also sets borders, margins and paddings.
A possible (but bad) work around can be to use a fake view that is used as style target, eventually altering its stylesheet for each item. That's not good, it obviously is a terrible choice if you have lots of items, but it may still be an alternative:
class Delegate(QStyledItemDelegate):
def __init__(self, parent: QAbstractItemView):
super().__init__(parent)
self._fakeView = parent.__class__(parent)
self._fakeView.setObjectName('_fakeCopy')
self._fakeView.hide()
def _makeQssColor(self, data):
if isinstance(data, QColor):
return 'rgba({},{},{},{})'.format(*data.getRgb())
elif isinstance(data, (int, Qt.GlobalColor)):
return 'rgba({},{},{},{})'.format(*QColor(data).getRgb())
elif isinstance(data, QBrush) and data.style():
if data.color().isValid():
return 'rgba({},{},{},{})'.format(*data.color().getRgb())
# TODO: build from data.gradient() if it's not None
# TODO: if not data.texture().isNull(), but probably impossible
def paint(self, qp, opt, index):
qss = []
if index.data(Qt.BackgroundRole):
parsed = self._makeQssColor(index.data(Qt.BackgroundRole))
if parsed:
qss.append('background: {};'.format(parsed))
if index.data(Qt.ForegroundRole):
parsed = self._makeQssColor(index.data(Qt.ForegroundRole))
if parsed:
qss.append(
'color: {0};\n\tselection-color: {0};'.format(parsed))
if not qss:
super().paint(qp, opt, index)
return
self._fakeView.setStyleSheet('#_fakeCopy::item {{\n\t{}\n}}'.format(
'\n\t'.join(qss)))
vopt = QStyleOptionViewItem(opt)
self.initStyleOption(vopt, index)
style = vopt.widget.style()
style.drawControl(style.CE_ItemViewItem, vopt, qp, self._fakeView)
Note that you must create the delegate with the view as its parent (which is what should be always be done, anyway).
In reality, if you want custom painting for each item, QSS is probably not the right choice: it may be easy, but such advanced painting customization isn't compatible with QSS in general. Just use custom delegates, ensure that you properly use the QStyle functions of the option's widget
to get proper geometries and request drawings, and possibly consider to do the whole painting on your own.
Upvotes: 1
Reputation: 53
I got it to work: I am not sure this is the best way to do it or if it has any drawbacks, but it is fine for what I wanted.
I implemented a QStyledItemDelegate
with a custom paint()
method which draws the widget underneath according to the stylesheet and then draws text onto it according to the ForegroundRole
data provided by the model.
class Alarm_Viewer_Style_Item_Delegate(QStyledItemDelegate):
def __init__(self, parent: Optional[QObject] = None):
super().__init__(parent=parent)
def paint(self, painter: QPainter, option: QStyleOptionViewItem, index: QModelIndex):
self.initStyleOption(option, index)
# Draw the widget, but exclude the text.
option.text = ""
style = option.widget.style() if option.widget else QApplication.style()
style.drawControl(QStyle.CE_ItemViewItem, option, painter, option.widget)
# Get text color from model.
if not option.state & QStyle.State_Enabled:
cg = QPalette.Disabled
elif option.state & QStyle.State_Active:
cg = QPalette.Normal
else:
cg = QPalette.Inactive
if option.state & QStyle.State_Selected:
color = option.palette.color(cg, QPalette.HighlightedText)
else:
color = option.palette.color(cg, QPalette.Text)
painter.setPen(color)
# Display text over the empty item.
text = index.data(Qt.DisplayRole)
painter.drawText(option.rect, option.displayAlignment, text)
The only downside is that I cannot fully control the styling from the stylesheet, but have to provide a Alarm.level
-> QColor
map to the model programmatically.
I tried to circumvent the inability to set properties on the list items by doing option.widget.setProperty("alarm-level", alarm.level)
in the paint method, but it did not work, unfortunately.
If anyone has any comment on this solution or ideas on how to control the styling from the stylesheet I'll gladly hear it.
Upvotes: 0