0

I'm trying to learn PyQt5, and I've got this code:

import sys
from PyQt5.QtWidgets import *
from PyQt5.QtCore import *
from PyQt5.QtGui import *


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        
        self.label = QLabel()
        canvas = QPixmap(400, 300)
        canvas.fill(Qt.white)
        self.label.setPixmap(canvas)

        self.setCentralWidget(self.label)
    

    def mouseMoveEvent(self, e):
        painter = QPainter(self.label.pixmap())
        painter.drawPoint(e.x(), e.y())
        painter.end()
        self.update()


app = QApplication(sys.argv)

window = MainWindow()
window.show()

app.exec()

And I can draw using right click to draw, but when I left click, it drags the window instead of drawing. This even happens when I make the window fullscreen so I can't move it. How can I stop it from dragging the window so it will draw instead?

rbits
  • 5
  • 3

1 Answers1

2

In some configurations (specifically, on Linux, and depending on the window manager settings), dragging the left mouse button on an empty (non interactive) area of a QMainWindow allows dragging the whole window.

To prevent that, the mouse move event has to be accepted by the child widget that receives it.

While this can be achieved with an event filter, it's usually better to use a subclass, and this is even more important whenever the widget has to deal with mouse events it receives, exactly like in this case.

Another aspect that has to be considered is that just updating the QLabel pixmap is not completely sufficient, because it doesn't automatically force its update. Also, since Qt 5.15, QLabel.pixmap() doesn't return a pointer to the pixmap, but rather its copy. This means that you should always keep a local reference to the pixmap for the whole time required to access it (otherwise your program will crash), and then call setPixmap() again with the updated pixmap after "ending" the painter. This will automatically schedule an update of the label.

The above may be a bit confusing if you're not used to languages that allow pointers as arguments, but, in order to clarify how it works, you can consider the pixmap() property similarly to the text() one:

text = self.label.text()
text += 'some other text'

The above will obviously not change the text of the label, most importantly because, in most languages (including Python) strings are always immutable objects, so text += ... actually replaces the text reference with another string object.

To clarify, consider the following:

text1 = text2 = self.label.text()
text1 += 'some other text'
print(text1 == text2)

Which will return False.

Now consider this instead:

list1 = list2 = []
list1 += ['item']
print(list1 == list2)

Which will return True, because list is a mutable type, and in python changing the content of a mutable type will affect any reference to it[1], since they refer to the same object.

Until Qt < 5.15, the pixmap of QLabel behaved similarly to a list, meaning that any painting on the label.pixmap() would actually change the content of the displayed pixmap (requiring label.update() to actually show the change). After Qt 5.15 this is no longer valid, as the returned pixmap behaves similarly to a returned string: altering its contents won't change the label's pixmap.

So, the proper way to update the pixmap is to:

  1. handle the mouse event in the label instance (either by subclassing or using an event filter), and not in a parent;
  2. get the pixmap, keep its reference until painting has completed, and call setPixmap() afterwards (mandatory since Qt 5.15, but also suggested anyway);

Finally, QLabel has an alignment property that, when using a pixmap, is used to set the alignment of the pixmap to the available space that the layout manager provides. The default is left aligned and vertically centered (Qt.AlignLeft|Qt.AlignVCenter).
QLabel also features the scaledContents property, which always scales the pixmap to the current size of the label (not considering the aspect ratio).

The above means one of the following:

  • the pixmap will always be displayed at its actual size, and eventually aligned within its available space;
  • if the scaledContents property is True, the alignment is ignored and the pixmap will be always scaled to the full extent of its available space; whenever that property is True, the resulting pixmap is also cached, so you have to clear its cache every time (at least, with Qt5);
  • if you need to always keep aspect ratio, using QLabel is probably pointless, and you may prefer a plain QWidget that actively draws the pixmap within a paintEvent() override;

Considering the above, here is a possible implementation of the label (ignoring the ratio):

class PaintLabel(QLabel):
    def mouseMoveEvent(self, event):
        pixmap = self.pixmap()
        if pixmap is None:
            return
        pmSize = pixmap.size()
        if not pmSize.isValid():
            return

        pos = event.pos()

        scaled = self.hasScaledContents()
        if scaled:
            # scale the mouse position to the actual pixmap size
            pos = QPoint(
                round(pos.x() * pmSize.width() / self.width()), 
                round(pos.y() * pmSize.height() / self.height())
            )
        else:
            # translate the mouse position depending on the alignment
            alignment = self.alignment()
            dx = dy = 0
            if alignment & Qt.AlignRight:
                dx += pmSize.width() - self.width()
            elif alignment & Qt.AlignHCenter:
                dx += round((pmSize.width() - self.width()) / 2)
            if alignment & Qt.AlignBottom:
                dy += pmSize.height() - self.height()
            elif alignment & Qt.AlignVCenter:
                dy += round((pmSize.height() - self.height()) // 2)
            pos += QPoint(dx, dy)

        painter = QPainter(pixmap)
        painter.drawPoint(pos)
        painter.end()

        # this will also force a scheduled update
        self.setPixmap(pixmap)

        if scaled:
            # force pixmap cache clearing
            self.setScaledContents(False)
            self.setScaledContents(True)

    def minimumSizeHint(self):
        # just for example purposes
        return QSize(10, 10)

And here is an example of its usage:

class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.label = PaintLabel()
        canvas = QPixmap(400, 300)
        canvas.fill(Qt.white)
        self.label.setPixmap(canvas)

        self.hCombo = QComboBox()
        for i, hPos in enumerate(('Left', 'HCenter', 'Right')):
            hAlign = getattr(Qt, 'Align' + hPos)
            self.hCombo.addItem(hPos, hAlign)
            if self.label.alignment() & hAlign:
                self.hCombo.setCurrentIndex(i)

        self.vCombo = QComboBox()
        for i, vPos in enumerate(('Top', 'VCenter', 'Bottom')):
            vAlign = getattr(Qt, 'Align' + vPos)
            self.vCombo.addItem(vPos, vAlign)
            
            if self.label.alignment() & vAlign:
                self.vCombo.setCurrentIndex(i)

        self.scaledChk = QCheckBox('Scaled')

        central = QWidget()
        mainLayout = QVBoxLayout(central)

        panel = QHBoxLayout()
        mainLayout.addLayout(panel)
        panel.addWidget(self.hCombo)
        panel.addWidget(self.vCombo)
        panel.addWidget(self.scaledChk)
        mainLayout.addWidget(self.label)

        self.setCentralWidget(central)

        self.hCombo.currentIndexChanged.connect(self.updateLabel)
        self.vCombo.currentIndexChanged.connect(self.updateLabel)
        self.scaledChk.toggled.connect(self.updateLabel)

    def updateLabel(self):
        self.label.setAlignment(Qt.AlignmentFlag(
            self.hCombo.currentData() | self.vCombo.currentData()
        ))
        self.label.setScaledContents(self.scaledChk.isChecked())


if __name__ == '__main__':
    import sys
    app = QApplication(sys.argv)

    window = MainWindow()
    window.show()

    sys.exit(app.exec())

Note that if you need more advanced control over the pixmap display and painting (including aspect ratio, but also proper zoom capabilities and any possible complex feature), then the common suggestion is to completely ignore QLabel, as said above: either use a basic QWidget, or consider the more complex (but much more powerful) Graphics View Framework. This will also allow proper editing features, as you can add non-destructive editing that will show ("paint") the result without affecting the actual, original object.

[1]: The above is based on the fact that a function or operator can actually mutate the object: the += operator actually calls the __add__ magic method that, in the case of lists, updates the contents of the same list.

musicamante
  • 41,230
  • 6
  • 33
  • 58
  • That code works, but nowhere in the code do I see it accepting the mouse move event. How does it work? That looks like it should have the same problem, but it doesn't for some reason? Also, when I click outside of the widget, I can still drag it, which is probably not what I'd want in an actual application. How could I disable it completely? – rbits Jan 15 '23 at 11:36
  • @rbits I forgot to specify that part, but as the documentation [explains](https://doc.qt.io/qt-5/qevent.html#accepted-prop), by default events are considered accepted, unless the class explicitly ignores it: the default implementation of QWidget calls the empty `mousePressEvent` which makes the event ignored; simply overriding `mousePressEvent` will prevent that, so the event results automatically accepted and therefore not propagated to the parent. About the other question, while related to this, is quite a different problem, and I suggest you to create a separate post for that. – musicamante Jan 15 '23 at 12:41
  • I still don't understand. You didn't override mousePressEvent either? Also why is the other question a different problem? I just want to know how to apply the same solution to the MainWindow instead of the label – rbits Jan 17 '23 at 01:36
  • @rbits By overriding `mousePressEvent` I'm *ignoring* the default behavior (which calls `event.ignore()`), that's why it works: if you add `super().mousePressEvent(event)` in that, you'll see that it will revert to default behavior (moving the window). The other question is a different problem because you specifically asked about not moving the window when using the mouse within the widget. The "click anywhere to move" of QMainWindow is implemented in the platform plugin used by the QApplication, there's no easy way to avoid it (AFAIK) as it depends on how the system is configured. – musicamante Jan 17 '23 at 03:41
  • The only solution I could think of is to use subclasses for *all* top level widgets you use in QMainWindow and ensure you always accept mouse events. The other alternative is to *not* use QMainWindow: unless you need dock widgets and toolbars, you can use a plain QWidget: if you need a menu bar, use the [`setMenuBar()`](https://doc.qt.io/qt-5/qlayout.html#setMenuBar) of QLayout, if you need a status bar add a QStatusBar to the bottom of the layout, override [`event()`](https://doc.qt.io/qt-5/qwidget.html#event) and listen to event of `StatusTip` type. – musicamante Jan 17 '23 at 03:44
  • "By overriding mousePressEvent" you didn't override mousePressEvent. You had a mouseMoveEvent thing, but not a mousePressEvent. – rbits Jan 18 '23 at 05:52
  • @rbits Sorry, my bad, but that's conceptually the same for this purpose (in normal conditions, `mouseMoveEvent` is what actually causes the window to be moved if the event is ignored). Just do as suggested to see the result, and consider to take your time to study the Qt sources. – musicamante Jan 18 '23 at 06:15