Techniquab
Techniquab

Reputation: 903

Make a QSpinBox which requires a double-click to edit

I would like to make a spin-box which only is editable after double-clicking in the digit display area.

My attempt below disables the focus in all cases except when the increment/decrement buttons are pressed.

I want increment/decrement to perform the actions without stealing the focus. I do want the the normal blinking cursor and edit functionality when the text area is double-clicked.

After editing, the widget should release focus when another widget is clicked, or enter is pressed.

from PyQt5 import QtCore, QtGui, QtWidgets
from PyQt5.QtCore import Qt

event_dict = {v: k for k, v in QtCore.QEvent.__dict__.items() if isinstance(v, int)}

noisy_events = [
    'Paint',
    'Show',
    'Move',
    'Resize',
    'DynamicPropertyChange',
    'PolishRequest',
    'Polish',
    'ChildPolished',
    'HoverMove',
    'HoverEnter',
    'HoverLeave',
    'ChildAdded',
    'ChildRemoved',
]

class ClickableSpinBox(QtWidgets.QDoubleSpinBox):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.installEventFilter(self)
        self.setFocusPolicy(Qt.NoFocus)

    def eventFilter(self, a0: 'QObject', a1: 'QEvent') -> bool:
        if a0 is not self:
            return super().eventFilter(a0, a1)

        if a1.type() == QtCore.QEvent.FocusAboutToChange:
            print("intercepted focus about to change")
            return True
        if a1.type() == QtCore.QEvent.FocusIn:
            print("intercepted focus in")
            return True
        if a1.type() == QtCore.QEvent.MouseButtonPress:
            print("intercepted mouse press")
            #return True
        elif a1.type() == QtCore.QEvent.MouseButtonDblClick:
            print("intercepted double click")
            self.setFocus()
        else:
            if a1.type() in event_dict:
                evt_name = event_dict[a1.type()]
                if evt_name not in noisy_events:
                    print(evt_name)
            else:
                pass
                #print(f"Unknown event type {a1.type()}")
        return super().eventFilter(a0, a1)


if __name__ == '__main__':
    app = QtWidgets.QApplication([])
    w = QtWidgets.QWidget()
    l = QtWidgets.QHBoxLayout()
    l.addWidget(ClickableSpinBox())
    l.addWidget(ClickableSpinBox())
    l.addWidget(QtWidgets.QDoubleSpinBox())
    w.setLayout(l)
    w.show()
    app.exec_()

Upvotes: 1

Views: 1362

Answers (2)

ekhumoro
ekhumoro

Reputation: 120578

The demo script below should do everything you want. I have added two extra features: (1) disabling of text selection, and (2) disabling of mouse-wheel increments on the text-box (but not the buttons). If these aren't to your taste, they can easily be adapted or removed (see the comments in the code). The implementation is otherwise very simple, since it does not rely on controlling the focus.

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

class ClickableSpinBox(QDoubleSpinBox):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.setEditingDisabled(True)
        self.lineEdit().installEventFilter(self)
        self.editingFinished.connect(self.setEditingDisabled)

    def editingDisabled(self):
        return self.lineEdit().isReadOnly()

    def setEditingDisabled(self, disable=True):
        self.lineEdit().setReadOnly(disable)
        self.setFocusPolicy(Qt.TabFocus if disable else Qt.WheelFocus)
        # optional: control selection in text-box
        if disable:
            self.clearSelection()
            self.lineEdit().selectionChanged.connect(self.clearSelection)
        else:
            self.lineEdit().selectionChanged.disconnect(self.clearSelection)
            self.lineEdit().selectAll()

    def clearSelection(self):
        self.lineEdit().setSelection(0, 0)

    def eventFilter(self, source, event):
        if (event.type() == QEvent.MouseButtonDblClick and
            source is self.lineEdit() and self.editingDisabled()):
            self.setEditingDisabled(False)
            self.setFocus()
            return True
        return super().eventFilter(source, event)

    # optional: control mouse-wheel events in text-box
    def wheelEvent(self, event):
        if self.editingDisabled():
            self.ensurePolished()
            options = QStyleOptionSpinBox()
            self.initStyleOption(options)
            rect = self.style().subControlRect(
                QStyle.CC_SpinBox, options,
                QStyle.SC_SpinBoxUp, self)
            if event.pos().x() <= rect.left():
                return
        super().wheelEvent(event)

    def keyPressEvent(self, event):
        if not self.editingDisabled():
            super().keyPressEvent(event)

class Window(QWidget):
    def __init__(self):
        super().__init__()
        layout = QHBoxLayout(self)
        self.spinboxA = ClickableSpinBox()
        self.spinboxB = ClickableSpinBox()
        self.spinboxC = QDoubleSpinBox()
        layout.addWidget(self.spinboxA)
        layout.addWidget(self.spinboxB)
        layout.addWidget(self.spinboxC)
        self.setFocusPolicy(Qt.ClickFocus)

if __name__ == '__main__':

    app = QApplication(sys.argv)
    window = Window()
    window.setGeometry(900, 100, 200, 100)
    window.show()
    sys.exit(app.exec_())

Upvotes: 2

Alejandro Condori
Alejandro Condori

Reputation: 864

Edit:

To let the mouse-scroll function and the increase/decrease buttons working

I make the QLineEdit inside of the QDoubleSpinBox to be enabled/disabled when you double click inside it or in the borders of the SpinBox. With this, you can still change the value inside it with the mouse-scroll or with the buttons. Here is your code modified:

class ClickableSpinBox(QtWidgets.QDoubleSpinBox):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.installEventFilter(self)
        self.lineEdit().setEnabled(False)
        self.setFocusPolicy(Qt.NoFocus)

    def eventFilter(self, a0: 'QObject', a1: 'QEvent') -> bool:
        if a0 is not self:
            return super().eventFilter(a0, a1)
        elif a1.type() == QtCore.QEvent.MouseButtonDblClick:
            ## When double clicked inside the Disabled QLineEdit of
            ## the SpinBox, this will enable it and set the focus on it
            self.lineEdit().setEnabled(True)
            self.setFocus()
        elif a1.type() == QtCore.QEvent.FocusOut:
            ## When you lose the focus, e.g. you click on other object
            ## this will diable the QLineEdit
            self.lineEdit().setEnabled(False)
        elif a1.type() == QtCore.QEvent.KeyPress:
            ## When you press the Enter Button (Return) or the 
            ## Key Pad Enter (Enter) you will disable the QLineEdit
            if a1.key() in [QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter]:
                self.lineEdit().setEnabled(False)
        return super().eventFilter(a0, a1)
    
    def stepBy(self, steps):
        ## The reason of this is because if you click two consecutive times 
        ## in any of the two buttons, the object will trigger the DoubleClick
        ## event.
        self.lineEdit().setEnabled(False)
        super().stepBy(steps)
        self.lineEdit().deselect()

The result with the QLineEdit disabled and the buttons enabled:

enter image description here

To let ONLY the mouse-scroll function

You just have to remove the buttons from the code above, using setButtonSymbols().

class ClickableSpinBox(QtWidgets.QDoubleSpinBox):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.installEventFilter(self)
        self.lineEdit().setEnabled(False)
        self.setFocusPolicy(Qt.NoFocus)
        ## Changing button's symbol to 2 means to "delete" the buttons
        self.setButtonSymbols(2)

The result with the buttons "disabled":

enter image description here

Previous Answer (before the Edit)

I have a tricky solution, and it consists of Enable/Disable the customs spin boxes you created. With this, the spinboxes will be enabled (and editable) only when you double-clicked on them, and when you lose focus on them they will be disabled automatically, passing the focus to the enabled SpinBox.

The reason I did that is that when the SpinBox is enabled, the DoubleClick event will only be triggered when you double click on the borders or on the increment/decrement buttons. Disabling them will do the trick because the double click event will be triggered wherever you press inside the SpinBox.

Here is your code with my modifications: (there are comments inside te code to help you understand what I did)

class ClickableSpinBox(QtWidgets.QDoubleSpinBox):
    def __init__(self, *args, **kwargs):
        super().__init__(*args, **kwargs)
        self.installEventFilter(self)
        self.setFocusPolicy(Qt.NoFocus)

    def eventFilter(self, a0: 'QObject', a1: 'QEvent') -> bool:
        if a0 is not self:
            return super().eventFilter(a0, a1)
        elif a1.type() == QtCore.QEvent.MouseButtonDblClick:
            ## When you double click inside the Disabled SpinBox
            ## this will enable it and set the focus on it
            self.setEnabled(True)
            self.setFocus()
        elif a1.type() == QtCore.QEvent.FocusOut:
            ## When you lose the focus, e.g. you click on other object
            ## this will disable the SpinBox
            self.setEnabled(False)
        elif a1.type() == QtCore.QEvent.KeyPress:
            ## When you press the Enter Button (Return) or the 
            ## Key Pad Enter (Enter) you will disable the SpinBox
            if a1.key() in [QtCore.Qt.Key_Return, QtCore.Qt.Key_Enter]:
                self.setEnabled(False)
        return super().eventFilter(a0, a1)


if __name__ == '__main__':
    app = QtWidgets.QApplication([])
    w = QtWidgets.QWidget()
    l = QtWidgets.QHBoxLayout()
    ## I store the SpinBoxes to give the disable property after
    ## generating its instance
    sp1 = ClickableSpinBox()
    sp1.setEnabled(False)
    sp2 = ClickableSpinBox()
    sp2.setEnabled(False)
    sp3 = QtWidgets.QDoubleSpinBox()
    l.addWidget(sp1)
    l.addWidget(sp2)
    l.addWidget(sp3)
    w.setLayout(l)
    w.show()
    app.exec_()

And some screenshots of that code running:

enter image description here enter image description here enter image description here

Upvotes: 2

Related Questions