Question

How can I make it so when a user clicks on, the up or down arrow of a QSpinBox, the value will increase as the cursor is dragging up and the value will decrease if dragging down. I fond this function very useful for users to be able to just click and drag their cursor than to constantly click the errors. Here is reference source code for a spinner made in C# which works the way i would like it to in python. http://www.paulneale.com/tutorials/dotNet/numericUpDown/numericUpDown.htm

import sys
from PySide import QtGui, QtCore


class Wrap_Spinner( QtGui.QSpinBox ):
    def __init__( self, minVal=0, maxVal=100, default=0):
        super( Wrap_Spinner, self ).__init__()
        self.drag_origin = None

        self.setRange( minVal, maxVal )
        self.setValue( default)

    def get_is_dragging( self ):
        # are we the widget that is also the active mouseGrabber?
        return self.mouseGrabber( ) == self

    ### Dragging Handling Methods ################################################
    def do_drag_start( self ):
        # Record position
        # Grab mouse
        self.drag_origin = QtGui.QCursor( ).pos( )
        self.grabMouse( )

    def do_drag_update( self ):
        # Transpose the motion into values as a delta off of the recorded click position
        curPos = QtGui.QCursor( ).pos( )
        offsetVal = self.drag_origin.y( ) - curPos.y( ) 
        self.setValue( offsetVal )
        print offsetVal

    def do_drag_end( self ):
        self.releaseMouse( )
        # Restore position
        # Reset drag origin value
        self.drag_origin = None

    ### Mouse Override Methods ################################################
    def mousePressEvent( self, event ):
        if QtCore.Qt.LeftButton:
            print 'start drag'
            self.do_drag_start( )
        elif self.get_is_dragging( ) and QtCore.Qt.RightButton:
            # Cancel the drag
            self.do_drag_end( )
        else:
            super( Wrap_Spinner, self ).mouseReleaseEvent( event )


    def mouseMoveEvent( self, event ):
        if self.get_is_dragging( ):
            self.do_drag_update( )
        else:
            super( Wrap_Spinner, self ).mouseReleaseEvent( event )


    def mouseReleaseEvent( self, event ):
        if self.get_is_dragging( ) and QtCore.Qt.LeftButton:
            print 'finish drag'
            self.do_drag_end( )
        else:
            super( Wrap_Spinner, self ).mouseReleaseEvent( event )


class Example(QtGui.QWidget ):
    def __init__( self):
        super( Example, self ).__init__( )
        self.initUI( )


    def initUI( self ):
        self.spinFrameCountA = Wrap_Spinner( 2, 50, 40)
        self.spinB = Wrap_Spinner( 0, 100, 10)

        self.positionLabel = QtGui.QLabel( 'POS:' )

        grid = QtGui.QGridLayout( )
        grid.setSpacing( 0 )
        grid.addWidget( self.spinFrameCountA, 0, 0, 1, 1 )
        grid.addWidget( self.spinB, 1, 0, 1, 1 )
        grid.addWidget( self.positionLabel, 2, 0, 1, 1 )
        self.setLayout( grid )
        self.setGeometry( 800, 400, 200, 150 )
        self.setWindowTitle( 'Max Style Spinner' )
        self.setWindowFlags(self.windowFlags() | QtCore.Qt.FramelessWindowHint)
        self.show( )


def main( ):
    app = QtGui.QApplication( sys.argv )
    ex = Example( )
    sys.exit( app.exec_( ) )


if __name__ == '__main__':
    main()
Was it helpful?

Solution 2

This is old, but is still a top hit on Google.

I found a few possibilities online, but none were ideal. My solution was top create a new type of label that 'scrubs' a QSpinBox or QDoubleSpinBox when dragged. Here you go:

////////////////////////////////////////////////////////////////////////////////
// Label for a QSpinBox or QDoubleSpinBox (or derivatives) that scrubs the spinbox value on click-drag
//
// Notes:
//  - Cursor is hidden and cursor position remains fixed during the drag
//  - Holding 'Ctrl' reduces the speed of the scrub
//  - Scrub multipliers are currently hardcoded - may want to make that a parameter in the future
template <typename SpinBoxT, typename ValueT>
class SpinBoxLabel : public QLabel
{
public:
    SpinBoxLabel(const QString& labelText, SpinBoxT& buddy)
        : QLabel(labelText)
        , Buddy(&buddy)
    {
        setBuddy(&buddy);
    }

protected:
    virtual void mouseMoveEvent(QMouseEvent* event) override {
        if (!(event->buttons() & Qt::LeftButton))
            return QLabel::mouseMoveEvent(event);

        if (!IsDragging) {
            StartDragPos = QCursor::pos();
            Value = double(Buddy->value());
            IsDragging = true;
            QApplication::setOverrideCursor(Qt::BlankCursor);
        }
        else {
            int dragDist = QCursor::pos().x() - StartDragPos.x();
            if (dragDist == 0)
                return;

            double dragMultiplier = .25 * Buddy->singleStep();
            if (!(event->modifiers() & Qt::ControlModifier))
                dragMultiplier *= 10.0;

            Value += dragMultiplier * dragDist;

            Buddy->setValue(ValueT(Value));

            QCursor::setPos(StartDragPos);
        }
    }

    virtual void mouseReleaseEvent(QMouseEvent* event) override {
        if (!IsDragging || event->button() != Qt::LeftButton)
            return QLabel::mouseReleaseEvent(event);

        IsDragging = false;
        QApplication::restoreOverrideCursor();
    }

private:
    SpinBoxT* Buddy;
    bool IsDragging = false;
    QPoint StartDragPos;
    double Value = 0.0;
};

typedef SpinBoxLabel<QDoubleSpinBox, double> DoubleSpinBoxLabel;
typedef SpinBoxLabel<QSpinBox, int> IntSpinBoxLabel;

OTHER TIPS

I'm a big fan of your plugins so I'm happy I can answer this one for you! I assume you are coding a Max plug-in in pyside, because that's exactly what I was doing when I ran into the same problem (I like the Max default "scrubby" spinners too).

The solution is actually pretty simple, you just have to do it manually. I subclassed the QSpinBox and captured the mouse event, using it to calculate the y position relative to when you first start clicking on the widget. Here's the code, this is pyside2 because as of 3DS Max and Maya 2018 that's what Autodesk is using:

from PySide2 import QtWidgets, QtGui, QtCore
import MaxPlus

class SampleUI(QtWidgets.QDialog):

    def __init__(self, parent=MaxPlus.GetQMaxMainWindow()):
        super(SampleUI, self).__init__(parent)

        self.setWindowTitle("Max-style spinner")
        self.initUI()
        MaxPlus.CUI.DisableAccelerators()

    def initUI(self):

        mainLayout = QtWidgets.QHBoxLayout()

        lbl1 = QtWidgets.QLabel("Test Spinner:")
        self.spinner = SuperSpinner(self)
        #self.spinner = QtWidgets.QSpinBox()        -- here's the old version
        self.spinner.setMaximum(99999)

        mainLayout.addWidget(lbl1)
        mainLayout.addWidget(self.spinner)

        self.setLayout(mainLayout)


    def closeEvent(self, e):
        MaxPlus.CUI.EnableAccelerators()

class SuperSpinner(QtWidgets.QSpinBox):
    def __init__(self, parent):
        super(SuperSpinner, self).__init__(parent)

        self.mouseStartPosY = 0
        self.startValue = 0

    def mousePressEvent(self, e):
        super(SuperSpinner, self).mousePressEvent(e)
        self.mouseStartPosY = e.pos().y()
        self.startValue = self.value()

    def mouseMoveEvent(self, e):
        self.setCursor(QtCore.Qt.SizeVerCursor)

        multiplier = .5
        valueOffset = int((self.mouseStartPosY - e.pos().y()) * multiplier)
        print valueOffset
        self.setValue(self.startValue + valueOffset)

    def mouseReleaseEvent(self, e):
        super(SuperSpinner, self).mouseReleaseEvent(e)
        self.unsetCursor()


if __name__ == "__main__":

    try:
        ui.close()
    except:
        pass

    ui = SampleUI()
    ui.show()

I ran into the same issue and unfortunately the solutions I found only work when you click and drag from the arrows or the spinbox's border. But most users would want to drag from the actual text field, so doing this wasn't intuitive.

Instead you can subclass a QLineEdit to get the proper behavior. When you click it, it'll save its current value so that when the user drags it gets the mouse position's delta and applies that back onto the spinbox.

Here's a full example I'm using myself. Sorry though, it's in Maya's attribute style instead of Max's, so you click and drag the middle-mouse button to set the value. With some tweaking you can easily get it to work exactly like Max's:

from PySide2 import QtCore
from PySide2 import QtGui
from PySide2 import QtWidgets


class CustomSpinBox(QtWidgets.QLineEdit):

    """
    Tries to mimic behavior from Maya's internal slider that's found in the channel box.
    """

    IntSpinBox = 0
    DoubleSpinBox = 1

    def __init__(self, spinbox_type, value=0, parent=None):
        super(CustomSpinBox, self).__init__(parent)

        self.setToolTip(
            "Hold and drag middle mouse button to adjust the value\n"
            "(Hold CTRL or SHIFT change rate)")

        if spinbox_type == CustomSpinBox.IntSpinBox:
            self.setValidator(QtGui.QIntValidator(parent=self))
        else:
            self.setValidator(QtGui.QDoubleValidator(parent=self))

        self.spinbox_type = spinbox_type
        self.min = None
        self.max = None
        self.steps = 1
        self.value_at_press = None
        self.pos_at_press = None

        self.setValue(value)

    def wheelEvent(self, event):
        super(CustomSpinBox, self).wheelEvent(event)

        steps_mult = self.getStepsMultiplier(event)

        if event.delta() > 0:
            self.setValue(self.value() + self.steps * steps_mult)
        else:
            self.setValue(self.value() - self.steps * steps_mult)

    def mousePressEvent(self, event):
        if event.buttons() == QtCore.Qt.MiddleButton:
            self.value_at_press = self.value()
            self.pos_at_press = event.pos()
            self.setCursor(QtGui.QCursor(QtCore.Qt.SizeHorCursor))
        else:
            super(CustomSpinBox, self).mousePressEvent(event)
            self.selectAll()

    def mouseReleaseEvent(self, event):
        if event.button() == QtCore.Qt.MiddleButton:
            self.value_at_press = None
            self.pos_at_press = None
            self.setCursor(QtGui.QCursor(QtCore.Qt.IBeamCursor))
            return

        super(CustomSpinBox, self).mouseReleaseEvent(event)

    def mouseMoveEvent(self, event):
        if event.buttons() != QtCore.Qt.MiddleButton:
            return

        if self.pos_at_press is None:
            return

        steps_mult = self.getStepsMultiplier(event)

        delta = event.pos().x() - self.pos_at_press.x()
        delta /= 6  # Make movement less sensitive.
        delta *= self.steps * steps_mult

        value = self.value_at_press + delta
        self.setValue(value)

        super(CustomSpinBox, self).mouseMoveEvent(event)

    def getStepsMultiplier(self, event):
        steps_mult = 1

        if event.modifiers() == QtCore.Qt.CTRL:
            steps_mult = 10
        elif event.modifiers() == QtCore.Qt.SHIFT:
            steps_mult = 0.1

        return steps_mult

    def setMinimum(self, value):
        self.min = value

    def setMaximum(self, value):
        self.max = value

    def setSteps(self, steps):
        if self.spinbox_type == CustomSpinBox.IntSpinBox:
            self.steps = max(steps, 1)
        else:
            self.steps = steps

    def value(self):
        if self.spinbox_type == CustomSpinBox.IntSpinBox:
            return int(self.text())
        else:
            return float(self.text())

    def setValue(self, value):
        if self.min is not None:
            value = max(value, self.min)

        if self.max is not None:
            value = min(value, self.max)

        if self.spinbox_type == CustomSpinBox.IntSpinBox:
            self.setText(str(int(value)))
        else:
            self.setText(str(float(value)))


class MyTool(QtWidgets.QWidget):

    """
    Example of how to use the spinbox.
    """

    def __init__(self, parent=None):
        super(MyTool, self).__init__(parent)

        self.setWindowTitle("Custom spinboxes")
        self.resize(300, 150)

        self.int_spinbox = CustomSpinBox(CustomSpinBox.IntSpinBox, parent=self)
        self.int_spinbox.setMinimum(-50)
        self.int_spinbox.setMaximum(100)

        self.float_spinbox = CustomSpinBox(CustomSpinBox.DoubleSpinBox, parent=self)
        self.float_spinbox.setSteps(0.1)

        self.main_layout = QtWidgets.QVBoxLayout()
        self.main_layout.addWidget(self.int_spinbox)
        self.main_layout.addWidget(self.float_spinbox)
        self.setLayout(self.main_layout)


# Run the tool.
global tool_instance
tool_instance = MyTool()
tool_instance.show()

I tried to make the functions match Qt's native spinBox. I didn't need it in my case, but it would be easy to add a signal when the value changes on release. It would also be easy to take it to the next level like Houdini's sliders so that the steps rate can change depending on where the mouse is vertically. Bah, maybe for a rainy day though :).

Here's what this features right now:

  • Can do both integer or double spinboxes
  • Click then drag middle-mouse button to set the value
  • While dragging, hold ctrl to increase the rate or hold shift to slow the rate
  • You can still type in the value like normal
  • You can also change the value by scrolling the mouse wheel (holding ctrl and shift changes rate)

Example

The speed of the spinbox increment can be changed with QAbstractSpinBox.setAccelerated:

    self.spinFrameCountA.setAccelerated(True)

With this enabled, the spinbox value will change more quickly the longer the mouse button is held down.

Licensed under: CC-BY-SA with attribution
Not affiliated with StackOverflow
scroll top