0

I'm trying to select/unselect Qcheckbox by clicking on a label and moving the mouse on other Checkboxes

enter image description here

What I would like is, for example, to click on the '0' and maintaining the mouse clicked and move it down on the '1', '2'... by moving on those checkboxes they must change their value (True to False). I don't understand how to use the mouseMoveEvent.

I made a minimal code to start with

import sys

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


class CheckBox(QCheckBox):
    def __init__(self, *args, **kwargs):
        QCheckBox.__init__(self, *args, **kwargs)
    def mouseMoveEvent(self,event):
        if event.MouseButtonPress == Qt.MouseButton.LeftButton:
            self.setChecked(not self.isChecked())

class MainWindow(QMainWindow):
    def __init__(self, parent=None):
        super(MainWindow, self).__init__()
        self.centralWidget = QWidget()
        self.setCentralWidget(self.centralWidget)
        self.mainHBOX = QVBoxLayout()

        self.CB_list = []
        for i in range(20):
            CB = CheckBox(str(i))
            CB.setChecked(True)
            self.CB_list.append(CB)
            self.mainHBOX.addWidget(CB)

        self.centralWidget.setLayout(self.mainHBOX)


if __name__ == "__main__":
    app = QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec_())
Art
  • 2,836
  • 4
  • 17
  • 34
ymmx
  • 4,769
  • 5
  • 32
  • 64

2 Answers2

1

This will be harder to achieve if you subclass a QCheckBox, instead, I suggest you subclass a frame and override the mouseMoveEvent and add all your checkboxes inside that frame.

Under mouseMoveEvent check if the left mouse is pressed by using event.buttons() == QtCore.Qt.LeftButton. Then get the widget at mouse position using childAt and then check if it's an instance of the QCheckBox, if it is then flip the checked state.

Here is an example code.

import sys
from PyQt5 import QtWidgets, QtGui, QtCore


class CheckBox(QtWidgets.QCheckBox):

    def styleRect(self) -> QtCore.QRect:
        option = QtWidgets.QStyleOptionButton()
        option.initFrom(self)
        rect = self.style().subElementRect(QtWidgets.QStyle.SE_CheckBoxIndicator, option, self)
        content_rect = self.style().subElementRect(QtWidgets.QStyle.SE_CheckBoxContents, option, self)

        return rect.united(content_rect)

    def mousePressEvent(self, event) -> bool:  
        super(CheckBox, self).mousePressEvent(event)

        if event.buttons() == QtCore.Qt.LeftButton and self.styleRect().contains(event.pos()):
                self.setChecked(not self.isChecked())
    
    def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None:
        
        if event.buttons() == QtCore.Qt.LeftButton and self.styleRect().contains(event.pos()):
            return
        
        super(CheckBox, self).mouseMoveEvent(event)


    def mouseReleaseEvent(self, event: QtGui.QMouseEvent) -> None:
       
        if self.styleRect().contains(event.pos()):
            return

        super(CheckBox, self).mouseReleaseEvent(event)
  
class CheckBoxFrame(QtWidgets.QFrame):

    def __init__(self, *args, **kwargs):
        super(CheckBoxFrame, self).__init__(*args, **kwargs)

        self._previous_widget = None

    def mouseMoveEvent(self, event: QtGui.QMouseEvent) -> None:
        
        super(CheckBoxFrame, self).mouseMoveEvent(event)

        if event.buttons() == QtCore.Qt.LeftButton:
            widget = self.childAt(event.pos()) 
            if isinstance(widget, QtWidgets.QCheckBox) and widget != self._previous_widget:
                widget.setChecked(not widget.isChecked())
                self._previous_widget = widget 


class MainWindow(QtWidgets.QMainWindow):
    def __init__(self, parent=None):
        super(MainWindow, self).__init__()
        self.centralWidget = QtWidgets.QWidget()
        self.setCentralWidget(self.centralWidget)
        self.mainVBOX = QtWidgets.QVBoxLayout()
        
        btn_frame = CheckBoxFrame()
        btn_frame.setLayout(QtWidgets.QVBoxLayout())

        for i in range(20):
            CB = CheckBox(str(i))
            CB.setChecked(True)
            btn_frame.layout().addWidget(CB)

        self.mainVBOX.addWidget(btn_frame)
        self.centralWidget.setLayout(self.mainVBOX)
    

if __name__ == "__main__":
    app = QtWidgets.QApplication(sys.argv)
    window = MainWindow()
    window.show()
    sys.exit(app.exec_())
Art
  • 2,836
  • 4
  • 17
  • 34
  • Nice! it is almost what I would like. Just, the Checkbox I clicked on doesn't change its value. Is it possible to inverse the value if I pressed the left button and leave the checkbox? – ymmx Oct 14 '21 at 12:00
  • @ymmx sorry, I didn't understand that, can you explain that again? – Art Oct 14 '21 at 12:04
  • Yes, my apology. When I click on the '0' label and move my mouse down (with the button still pressed) over '1', '2'... it inverses the '1', '2' and so on checkboxes but the '0' checkbox does not changed. – ymmx Oct 14 '21 at 12:08
  • @ymmx hmm. It looks like it only happens when the mouse is pressed over the check box and not over the label. I'll look into it when I am free. – Art Oct 14 '21 at 12:25
  • @ymmx can you try using this check box instead of QCheckBox, [here is the code](https://pastebin.com/mXYGpddd). – Art Oct 14 '21 at 18:51
  • @ymmx I have updated the answer. Don't use the code from the link. – Art Oct 15 '21 at 03:05
1

There are two problems in your code.
First of all, when a widget receives a mouse button press, it normally becomes the "mouse grabber", and from that moment on only that widget will receive mouse move events until the button is released.
Then you didn't properly check the buttons: event.MouseButtonPress is a constant that indicates a mouse press event, Qt.MouseButton.LeftButton is another constant indicating the left button of the mouse; you practically compared two different constants, which would have the same result as in doing if 2 == 1:.

A mouseMoveEvent will always return event.type() == event.MouseMove, so there's no need to check for it, but for the current buttons: for mouse press and release events, the button that causes the event is returned by event.button(), while, for move events, the currently pressed buttons are returned by event.buttons() (plural). Remember this difference, because event.button() (singular) will always return NoButton for a mouse move events (which is pretty obvious if you think about it, since the event is not generated by a button).

With that in mind, what you could do is to check the child widgets of the parent corresponding to the mouse position mapped to it.

In order to achieve this, you have to ensure that the mouse button has been actually pressed on the label (using style functions) and set an instance attribute as a flag to know if the movement should be tracked for other siblings. Note that in the following example I also used another flag to check for children that are direct descendants of the parent: if that flag is set, only siblings of the first pressed checkbox will be toggled, if another CheckBox instance is found but its parent is a child of the first one, no action is performed.

class CheckBox(QCheckBox):
    maybeDrag = False
    directChildren = True
    def mousePressEvent(self, event):
        if event.button() == Qt.LeftButton:
            opt = QStyleOptionButton()
            self.initStyleOption(opt)
            rect = self.style().subElementRect(QStyle.SE_CheckBoxContents, opt, self)
            if event.pos() in rect:
                self.setChecked(not self.isChecked())
                self.maybeDrag = True
                # we do *not* want to call the default behavior here
                return
        super().mousePressEvent(event)

    def mouseMoveEvent(self, event):
        if self.maybeDrag:
            parent = self.parent()
            child = parent.childAt(self.mapToParent(event.pos()))
            if isinstance(child, CheckBox) and child != self:
                # with the directChildren flag set, we will only toggle CheckBox
                # instances that are direct children of this (siblings),
                # otherwise we toggle them anyway
                if not self.directChildren or child.parent() == parent:
                    child.setChecked(self.isChecked())
        super().mouseMoveEvent(event)

    def mouseReleaseEvent(self, event):
        if self.maybeDrag:
            self.maybeDrag = False
            self.setDown(False)
        else:
            super().mouseReleaseEvent(event)

The only drawback with this implementation is that it won't work if you need to do this for items that are children of other parents (they share a common ancestor, but not the same parent).

In that case, you need to install an event filter for all check boxes for which you need this behavior and implement the above functions accordingly, with the difference that you need to use the top level window to map mouse coordinates (self.mapTo(self.window(), event.pos())) and use that window's childAt().

Finally, consider that SE_CheckBoxContents returns the full area of the contents, even if the shown text is smaller than the available space; the default behavior with most styles is to react to click events only when done inside the actual shown contents (the icon and/or the bounding rect of the text). If you want to revert to the default behavior, you need to construct another rectangle for SE_CheckBoxClickRect (which is the whole clickable area including the indicator) and check if the mouse is within the intersected rectangle of both:

    contents = self.style().subElementRect(QStyle.SE_CheckBoxContents, opt, self)
    click = self.style().subElementRect(QStyle.SE_CheckBoxClickRect, opt, self)
    if event.pos() in (contents & click):
        # ...

The & binary operator for QRects works in the same way as bitwise operators, in their logical sense: it only returns a rectangle that is contained by both source rectangles. It's literal function is intersected().

musicamante
  • 41,230
  • 6
  • 33
  • 58