4

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()
JokerMartini
  • 5,674
  • 9
  • 83
  • 193
  • For me, this already works with a standard spin-box. That is, if I click on the up button the values continue increasing while the mouse-button is held down. I can then drag onto the down button to make the values go in the opposite direction. – ekhumoro Jan 04 '14 at 22:02
  • It goes so painstakingly slow. I wanted to make the spinner increase faster based on the position the cursor was drug...up or down. Many 3d applications work like this. – JokerMartini Jan 04 '14 at 23:02
  • There's a simple fix for that: see my answer. – ekhumoro Jan 05 '14 at 00:38
  • Sweeeeeet!!!!!! ctrl V ctrl V – Nestor Colt Oct 31 '17 at 16:15

4 Answers4

4

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

Green Cell
  • 4,677
  • 2
  • 18
  • 49
3

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()
Spencer
  • 1,931
  • 1
  • 21
  • 44
  • SuperSpinner is great, but it seems like the buttons stop working the regular way. Am I doing something wrong, or is there a way to fix this? I want to have both the regular behaviour when pressing the up/down-buttons, and the scrubbing functionality. – Filip S. Jun 23 '18 at 11:31
  • Thanks @FilipS. For some reason I had `self.setSingleStep(0)` which broke this functionality. Can't remember why now. But anyway I fixed that, and super'd the mouse press and release events which will bring back the expected functionality. I'm not able to test this code right now so just let me know if there are any problems, but that should do it! (And don't forget to up-vote if this helped you out!) – Spencer Jun 23 '18 at 18:26
  • Thanks for the quick response! The new version seems to work as intended. I'll definitely be replacing all my spinboxes with SuperSpinBoxes from now on :) – Filip S. Jun 25 '18 at 06:46
  • @FilipS. Haha sweet! Qt is a lot of fun, I'm really enjoying it. – Spencer Jun 25 '18 at 18:39
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;
Alex Goldberg
  • 975
  • 11
  • 14
-1

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.

ekhumoro
  • 115,249
  • 20
  • 229
  • 336
  • Could you show me how to do a mouse click and drag event for the sake of example. I can show you how I want the spinner to react if that helps. – JokerMartini Jan 05 '14 at 16:19
  • I have a tool written in c# that works the way I want my spinner to work in python. You can download it and check it out here. http://jokermartini.com/2011/11/23/spacer/ – JokerMartini Jan 05 '14 at 16:32
  • @JokerMartini. That's a Windows program, and there doesn't seem to be any source code. And in any case, what's wrong with my solution: it does more or less the same thing, doesn't it? – ekhumoro Jan 05 '14 at 20:33
  • I'm just trying to design the UI to function like many of the programs I'm use to use, which all work like this. You method may work but it wasn't exactly what I was after. Here is the source code for a c# version. I just do not know how to convert it to a python spinner? Could you help me do that? http://www.paulneale.com/tutorials/dotNet/numericUpDown/numericUpDown.htm – JokerMartini Jan 05 '14 at 21:12
  • 1
    @JokerMartini. Yes, I can help - but I'm not going to write all your code for you. You should at least make an effort to research things and include an attempted solution in your question. What you're asking for is probably doable, not trivial (which is why I gave the answer I did). – ekhumoro Jan 06 '14 at 18:53
  • 1
    I updated the code above with what I have so far. The spinner value doesn't quite work in terms of offseting the value local from where it was at the time of clicking but check it out. – JokerMartini Jan 08 '14 at 23:50
  • @JokerMartini. Firstly, well done for giving this a go. However, I'm really struggling to see the benefit of this. How is it an improvement over scrolling with the nouse-wheel? Scrolling can also be accelerated by holding down the Ctrl key. Is dragging also supposed to be accelerated somehow? I tried running your Spacer program on WinXP to get an idea of how it works, but it just crashes without giving any useful error report. – ekhumoro Jan 09 '14 at 04:12
  • It should run on XP just fine. It was written in c#. It's the way a lot of spinners work in the programs I use such as Maya, 3ds Max, XSI, Autocad, Zbrush, Nuke. It is the way I'm used to spinners working. – JokerMartini Jan 09 '14 at 14:09
  • @JokerMartini. Maybe you're using the wrong toolkit. If you want dotnet-style gui behaviour, then use dotnet. I think the complexity of this task is way beyond what is reasonable to ask for in a single SO question. To give you some further hints: you should use a QTimer to update the value, and adjust its interval depending on how far the mouse is from the spin-buttons. You should also look at QStyle.hitTestComplexControl to get info about the buttons. – ekhumoro Jan 10 '14 at 01:27