Search code examples
pythonpyqt5clippingqrubberband

How to prevent clip a QRubberband in a certain region?


I am planning to make a small photo cropping software and I encounter this problem where when I moved the QRubberband I made, it is kind of moving past to the borders of the QLabel.

Here is a short gif of the problem

Here is the sample code: (Left-click and drag to make a QRubberband and Right-click to move the QRubberband)

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

class ResizableRubberBand(QWidget):
    def __init__(self, parent=None):
        super(ResizableRubberBand, self).__init__(parent)

        self.draggable = True
        self.dragging_threshold = 5
        self.mousePressPos = None
        self.mouseMovePos = None
        self.borderRadius = 5

        self.setWindowFlags(Qt.SubWindow)
        layout = QHBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.addWidget(
            QSizeGrip(self), 0,
            Qt.AlignLeft | Qt.AlignTop)
        layout.addWidget(
            QSizeGrip(self), 0,
            Qt.AlignRight | Qt.AlignBottom)
        self._band = QRubberBand(
            QRubberBand.Rectangle, self)
        self._band.show()
        self.show()

    def resizeEvent(self, event):
        self._band.resize(self.size())

    def paintEvent(self, event):
        # Get current window size
        window_size = self.size()
        qp = QPainter()
        qp.begin(self)
        qp.setRenderHint(QPainter.Antialiasing, True)
        qp.drawRoundedRect(0, 0, window_size.width(), window_size.height(),
                        self.borderRadius, self.borderRadius)
        qp.end()

    def mousePressEvent(self, event):
        if self.draggable and event.button() == Qt.RightButton:
            self.mousePressPos = event.globalPos()                # global
            self.mouseMovePos = event.globalPos() - self.pos()    # local
        super(ResizableRubberBand, self).mousePressEvent(event)

    def mouseMoveEvent(self, event):
        if self.draggable and event.buttons() & Qt.RightButton:
            globalPos = event.globalPos()
            moved = globalPos - self.mousePressPos
            if moved.manhattanLength() > self.dragging_threshold:
                # Move when user drag window more than dragging_threshold
                diff = globalPos - self.mouseMovePos
                self.move(diff)
                self.mouseMovePos = globalPos - self.pos()
        super(ResizableRubberBand, self).mouseMoveEvent(event)

    def mouseReleaseEvent(self, event):
        if self.mousePressPos is not None:
            if event.button() == Qt.RightButton:
                moved = event.globalPos() - self.mousePressPos
                if moved.manhattanLength() > self.dragging_threshold:
                    # Do not call click event or so on
                    event.ignore()
                self.mousePressPos = None
        super(ResizableRubberBand, self).mouseReleaseEvent(event)

class mQLabel(QLabel):
    def __init__(self, parent=None):
        QLabel.__init__(self, parent)
        self.setContentsMargins(0,0,0,0)
        self.setAlignment(Qt.AlignTop | Qt.AlignLeft)
        self.setSizePolicy(QSizePolicy.MinimumExpanding, QSizePolicy.MinimumExpanding)

    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton: #and (hasattr(self, 'bla')):
            self.first_mouse_location = (event.x(), event.y())
            self.band = ResizableRubberBand(self)
            self.band.setGeometry(event.x(), event.y(), 0, 0)

    def mouseMoveEvent(self, event):
        if event.buttons() & Qt.LeftButton:
            first_mouse_location_x = self.first_mouse_location[0]
            first_mouse_location_y = self.first_mouse_location[1]
            new_x, new_y = event.x(), event.y()
            difference_x = new_x - first_mouse_location_x
            difference_y = new_y - first_mouse_location_y
            self.band.resize(difference_x, difference_y)

class App(QWidget):

    def __init__(self):
        super().__init__()

        ## Set main window attributes
        self.setFixedSize(1000,600)

        # Add Label
        self.label = mQLabel()
        self.label.setStyleSheet("border: 1px solid black;")
        self.label_layout = QHBoxLayout(self)
        self.label_layout.addWidget(self.label)

        self.show()

if __name__ == '__main__':
    app = QApplication(sys.argv)
    ex = App()
    sys.exit(app.exec_())

I can't really think of a simple solution where I can do it. My first instinct is to get the size of the parent, but I don't know what should I do next.


Solution

  • You need to compare the widget geometry based on the parent rectangle.

    Note that you shouldn't use the global position as the tracking should always happen in local coordinates (relative to the parent). Also, as soon as the dragging has started, you should not need to check again the manhattanLength(), and the self.mousePressPos should be cleared in any case on release.

    class ResizableRubberBand(QRubberBand):
        is_dragging = False
        def mousePressEvent(self, event):
            if self.draggable and event.button() == Qt.RightButton:
                self.mousePressPos = event.pos()
            super(ResizableRubberBand, self).mousePressEvent(event)
    
        def mouseMoveEvent(self, event):
            if self.draggable and event.buttons() & Qt.RightButton:
                diff = event.pos() - self.mousePressPos
                if not self.is_dragging:
                    if diff.manhattanLength() > self.dragging_threshold:
                        self.is_dragging = True
                if self.is_dragging:
                    geo = self.geometry()
                    parentRect = self.parent().rect()
                    geo.translate(diff)
                    if not parentRect.contains(geo):
                        if geo.right() > parentRect.right():
                            geo.moveRight(parentRect.right())
                        elif geo.x() < parentRect.x():
                            geo.moveLeft(parentRect.x())
                        if geo.bottom() > parentRect.bottom():
                            geo.moveBottom(parentRect.bottom())
                        elif geo.y() < parentRect.y():
                            geo.moveTop(parentRect.y())
                    self.move(geo.topLeft())
                    self.clearMask()
            super(ResizableRubberBand, self).mouseMoveEvent(event)
    
        def mouseReleaseEvent(self, event):
            if self.mousePressPos is not None:
                if event.button() == Qt.RightButton and self.is_dragging:
                    event.ignore()
                    self.is_dragging = False
                self.mousePressPos = None
            super(ResizableRubberBand, self).mouseReleaseEvent(event)
    

    Also note that you're not actually using QRubberBand in a very good way, also because you're drawing over its border. In any case, a better implementation would directly subclass QRubberBand:

    class ResizableRubberBand(QRubberBand):
        def __init__(self, parent=None):
            super(ResizableRubberBand, self).__init__(QRubberBand.Rectangle, parent)
            self.setAttribute(Qt.WA_TransparentForMouseEvents, False)
    
            self.draggable = True
            self.is_dragging = False
            self.dragging_threshold = 5
            self.mousePressPos = None
            self.borderRadius = 5
    
            self.setWindowFlags(Qt.SubWindow)
            layout = QHBoxLayout(self)
            layout.setContentsMargins(0, 0, 0, 0)
            layout.addWidget(
                QSizeGrip(self), 0,
                Qt.AlignLeft | Qt.AlignTop)
            layout.addWidget(
                QSizeGrip(self), 0,
                Qt.AlignRight | Qt.AlignBottom)
            self.show()
    
        def resizeEvent(self, event):
            self.clearMask()
    
        def paintEvent(self, event):
            super().paintEvent(event)
            qp = QPainter(self)
            qp.setRenderHint(QPainter.Antialiasing)
            qp.translate(.5, .5)
            qp.drawRoundedRect(self.rect().adjusted(0, 0, -1, -1), 
                self.borderRadius, self.borderRadius)
    

    If you used QRubberBand only for aesthetic purposes, you don't need it at all, you can just subclass from QWidget and use the style functions to draw a "fake" rubber band:

        def paintEvent(self, event):
            qp = QPainter(self)
            qp.setRenderHint(QPainter.Antialiasing)
            qp.translate(.5, .5)
            opt = QStyleOptionRubberBand()
            opt.initFrom(self)
            style = self.style()
            style.drawControl(style.CE_RubberBand, opt, qp)
            qp.drawRoundedRect(self.rect().adjusted(0, 0, -1, -1), 
                self.borderRadius, self.borderRadius)