linuxfever
linuxfever

Reputation: 3823

How to paint an outline when hovering over a QListWidget item?

I am trying to paint an outline around a QListWidget item when the mouse is over that item. I've subclassed QStyledItemDelegate and overrode paint to account for the QStyle::State_MouseOver case as follows:

class MyDelegate : public QStyledItemDelegate
{
    Q_OBJECT

public:

    MyDelegate(QObject *parent = nullptr)
        : QStyledItemDelegate(parent){}

    void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override
    {
        QStyledItemDelegate::paint(painter, option, index);
        if(option.state & QStyle::State_MouseOver) painter->drawRect(option.rect);
    }

    ~MyDelegate(){}
};

I then instantiate a QListWidget with some items and enable the Qt::WA_Hover attribute:

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    QListWidget w;
    w.addItems(QStringList{"item1", "item2", "item3", "item4"});
    w.setItemDelegate(new MyDelegate(&w));
    w.viewport()->setAttribute(Qt::WA_Hover);
    w.show();
    return a.exec();
}

Unfortunately, the behaviour is not what I expected. In particular, the outline is painted when I move the mouse over an item, but when I move to another item the outline around the first item is not erased. Instead, it keeps drawing outlines around all the items I move my mouse over and eventually there is an outline around all items. Is this normal? I know that an alternative solution would be to use QStyleSheets but I'd like to understand why the current approach doesn't behave as I expected.

Here is what the widget looks like before a mouse over:

enter image description here

Here it is after hovering over item2:

enter image description here

And then after item3:

enter image description here

I am using Qt 5.15.1 on a MacOS 10.15.6 platform.

EDIT 1:

Based on the answer from scopchanov, to ensure the outline thickness is indeed 1px, I've changed the paint method to this:

void paint(QPainter *painter, const QStyleOptionViewItem &option, const QModelIndex &index) const override
{
    int outlineWidth = 1;
    QPen pen;
    pen.setWidth(outlineWidth);
    painter->setPen(pen);

    QStyledItemDelegate::paint(painter, option, index);
    if(option.state & QStyle::State_MouseOver) {

        int a = round(0.5*(outlineWidth - 1));
        int b = round(-0.5*outlineWidth);

        painter->drawRect(option.rect.adjusted(a, a, b, b));
    }
}

Unfortunately, the behaviour is very similar; here is a screenshot after hovering over all items from top to bottom:

enter image description here

Upvotes: 2

Views: 687

Answers (1)

scopchanov
scopchanov

Reputation: 8399

Cause

QPainter::drawRect draws a rectangle, that is slightly bigger (by exactly one pixel in height and width) than the painted area. The reason for this behavior could be seen in the way QPaintEngine draws a rectangle:

for (int i=0; i<rectCount; ++i) {
    QRectF rf = rects[i];
    QPointF pts[4] = { QPointF(rf.x(), rf.y()),
                       QPointF(rf.x() + rf.width(), rf.y()),
                       QPointF(rf.x() + rf.width(), rf.y() + rf.height()),
                       QPointF(rf.x(), rf.y() + rf.height()) };
    drawPolygon(pts, 4, ConvexMode);
}

QPaintEngine draws a closed polygon, starting at the point (x, y), going to (x + width, y), then to (x + width, y + height) and finally to (x, y + height). This looks intuitive enough, but let's see what happens, if we substitute these variables with real numbers:

Say, we want to draw a 4x2 px rectangle at (0, 0). QPaintEngine would use the following coordinates: (0, 0), (4, 0), (4, 2) and (0, 2). Represented as pixels, the drawing would look like this:

Pixel zoomed rectangle

So, instead of 4x2 px, we end up with a 5x3 px rectangle, i.e. indeed one pixel wider and taller.

You can further prove this by clipping the painter to option.rect before calling drawRect like this:

if (option.state & QStyle::State_MouseOver) {
    painter->setClipRect(option.rect);
    painter->drawRect(option.rect);
}

The result is clipped bottom and right edges of the outline (the very edges, we have expected to be within the painted area):

Clipped outline

In any case, the part of the outline, that falls outside of the painted area, is not repainted correctly, hence the unwanted remains of the previous drawings in the form of lines.

Solution

Reduce the height and the width of the outline, using QRect::adjusted.

You might just write

painter->drawRect(option.rect.adjusted(0, 0, -1, -1));

However, this would work only for an outline, which is 1px thick and the devicePixelRatio is 1, as on a PC. If the border of the outline is thicker than 1px and/or the devicePixelRatio is 2, as on a Mac, of course more of the outline will stick out of the painted area, so you should take that into consideration and adjust the rectangle accordingly, e.g.:

int effectiveOutlineWidth = m_outineWidth*m_devicePixelRatio;
int tl = round(0.5*(effectiveOutlineWidth - 1));
int br = round(-0.5*effectiveOutlineWidth);

painter->drawRect(option.rect.adjusted(tl, tl, br, br));

m_outineWidth and m_devicePixelRatio are class members, representing the desired outline width, resp. the ratio between physical pixels and device-independent pixels for the paint device. Provided that you have created public setter methods for them, you could set their values like this:

auto *delegate = new MyDelegate(&w);

delegate->setOutlineWidth(1);
delegate->setDevicePixelRatio(w.devicePixelRatio());

w.setItemDelegate(delegate);

Example

Here is an example I have written for you to demonstrate how the proposed solution could be implemented:

#include <QApplication>
#include <QStyledItemDelegate>
#include <QListWidget>
#include <QPainter>

class MyDelegate : public QStyledItemDelegate
{
    int m_outineWidth;
    int m_devicePixelRatio;
public:
    
    MyDelegate(QObject *parent = nullptr) :
        QStyledItemDelegate(parent),
        m_outineWidth(1),
        m_devicePixelRatio(1) {
    }
    
    void paint(QPainter *painter, const QStyleOptionViewItem &option,
               const QModelIndex &index) const override {
        QStyledItemDelegate::paint(painter, option, index);
        
        if (option.state & QStyle::State_MouseOver) {
            int effectiveOutlineWidth = m_outineWidth*m_devicePixelRatio;
            int tl = round(0.5*(effectiveOutlineWidth - 1));
            int br = round(-0.5*effectiveOutlineWidth);
            
            painter->setPen(QPen(QBrush(Qt::red), m_outineWidth, Qt::SolidLine,
                                 Qt::SquareCap, Qt::MiterJoin));
            painter->drawRect(option.rect.adjusted(tl, tl, br, br));
        }
    }
    
    void setOutlineWidth(int outineWidth) {
        m_outineWidth = outineWidth;
    }
    
    void setDevicePixelRatio(int devicePixelRatio) {
        m_devicePixelRatio = devicePixelRatio;
    }
};

int main(int argc, char *argv[])
{
    QApplication a(argc, argv);
    QListWidget w;
    auto *delegate = new MyDelegate(&w);
    
    delegate->setOutlineWidth(3);
    delegate->setDevicePixelRatio(w.devicePixelRatio());
    
    w.setItemDelegate(delegate);
    w.addItems(QStringList{"item1", "item2", "item3", "item4"});
    w.viewport()->setAttribute(Qt::WA_Hover);
    w.show();
    
    return a.exec();
}

Result

The provided example produces the following result for 3px thick outline on Windows:

3px thick outline

Upvotes: 3

Related Questions