PyQt spinboxes

This is a follow up post building on the class described here.

Several people brought it to my attention that in Maya most numeric inputs do not support scrolling, this is because they are not QtSpinboxes, but if they would be scrolling anyways a friend of mine also neatly described how they could do more than just increment by one, but instead increment the number the mouse is hovering on. This would be following how numeric boxes work in The Foundry’s Nuke.

So PyQt scrolls boxes on mouse hover, but it scrolls by a set value, all I need to do is determine that step size before changing the value based on the current contents and mouse position.

But first things first, to integrate with Maya clicking on a number results in selecting all text, so this is easy with the class in the previous post. I simply inherit a line edit and on the first click it will selectAll contents and store that it has focus, then on unfocus it will reset that value so it will selectAll on the next click again.

class SelectAllLineEdit(QtGui.QLineEdit):
    def __init__(self):
        QtGui.QLineEdit.__init__(self)
        self.setFocusPolicy(QtCore.Qt.StrongFocus)
        self.focus = False

    def focusOutEvent(self,e ):
        self.focus = False
        QtGui.QLineEdit.focusOutEvent(self, e)
                
    def mousePressEvent(self, e):
        if not self.focus:
            self.focus = True
            self.selectAll()
        else:
            QtGui.QLineEdit.mousePressEvent(self, e)

Then I inherit my infinite spinbox and replace the default lineedit with this custom lineedit. Also I enable tab focus and click focus on the widget so that when pressing TAB I can use the focusInEvent of the spinbox to selectAll contents in the event the user does not click but tabs into the widget.

class HiliteAllSpinBox(InfiniteSpinBox):
    def __init__(self, in_parent=None, in_value=0, in_type=float):
        InfiniteSpinBox.__init__(self, in_parent, in_value, in_type)
        self.setLineEdit( SelectAllLineEdit() )
        self.setText( numberToStr(in_value) )
        self.setFocusPolicy(QtCore.Qt.StrongFocus)
    
    def focusInEvent(self, e):
        self.selectAll();

Now the interesting part kicks in. I extend the line edit some more, tracking mouse position with the mouseMoveEvent and simply storing the x so it can be matched against the text later on. Matching the mousex with the x of each character will give us the character the mouse is over, and because there will only be numbers we can determine the increment value from there on.

class MouseTrackingLineEdit(SelectAllLineEdit):
    def __init__(self):
        SelectAllLineEdit.__init__(self)
        self.setMouseTracking(True)
        self.mousex = 0
        
    def mouseMoveEvent(self, e):
        self.mousex = e.pos().x()

So again I inherit from InfiniteSpinBox, attach a custom lineEdit and I set the focus policy and use the focusInEvent just as with the HiliteAllSpinBox to select all on tabbing.

The interesting stuff happens in stepBy however. I request fontMetrics to get a class that can measure the width of a string with the current font settings of the lineEdit. Then I determine the current size of the number, discarding decimals, because if we have 10.0 the default step size would be 10.

Next I split the string up into separate characters, so I can measure the width of the string for each character, then with this information I know when the mouse cursor is on a character as then for the first time in the loop the string width will be larger than the mouse X. In the loop I continuously decrease the step size so it will be correct as soon as I break out of the loop. This allows to set the step size and call the parent stepBy to complete the scrolling of the right number.

class NukeSpinBox(InfiniteSpinBox):
    def __init__(self, in_parent=None, in_value=0, in_type=float):
        InfiniteSpinBox.__init__(self, in_parent, in_value, in_type)
        self.setLineEdit( MouseTrackingLineEdit() )
        self.setText( numberToStr(in_value) )
        self.setFocusPolicy(QtCore.Qt.StrongFocus)
    
    def focusInEvent(self, e):
        self.selectAll();
        
    def stepBy(self, in_step):
        ln = self.lineEdit()
        m = ln.fontMetrics()
        
        stepsize = 10**( len(ln.text().split('.')[0])-1 )
        
        chars = ln.text().split('')
        str = ''
        for i in range(1, len(chars)-1, 1):
            str += chars[i]
            if chars[i] == '.':
                continue
            x = m.width(str)
            if x > ln.mousex:
                break
            stepsize *= 0.1
        
        self.setSingleStep(stepsize)
        InfiniteSpinBox.stepBy(self, in_step)

At last I will leave you with a test application, I never tried to run Qt within Eclipse before but always ran it from within Maya instead, so a another thing I finally figured out is that I can just test my PyQt code by creating a QApplication and run in Eclipse PyDev.

def main():
    app = QtGui.QApplication(sys.argv)
    w = QtGui.QFrame()
    l = QtGui.QVBoxLayout()
    w.setLayout(l)
    l.addWidget( QtGui.QLabel('Infinite spinbox') )
    l.addWidget( InfiniteSpinBox() )
    l.addWidget( QtGui.QLabel('Select contents on click spinbox') )
    l.addWidget( HiliteAllSpinBox() )
    l.addWidget( QtGui.QLabel('Step determined by mouse position') )
    l.addWidget( NukeSpinBox() )
    w.show()
    app.exec_()
    return app;
app = main()

Full code of the test application with all classes:

from PyQt4 import QtGui, QtCore
import sys


class InfiniteSpinBox(QtGui.QAbstractSpinBox):
    def __init__(self, in_parent=None, in_value=0, in_type=float):
        QtGui.QAbstractSpinBox.__init__(self, in_parent)
        self.singlestep = 1
        self.type = in_type
        self.value = self.type(in_value)
        self.setText( numberToStr(in_value) )
        self.basevalue = self.value
    
    def keyPressEvent(self, in_event):
        QtGui.QAbstractSpinBox.keyPressEvent(self, in_event)
        self.updateValue()
        
    def keyReleaseEvent(self, in_event):
        QtGui.QAbstractSpinBox.keyReleaseEvent(self, in_event)
        self.updateValue()
        
    def updateValue(self):
        value = strToNumber(self.text(), self.type)
        if value is not None:
            self.value = value
            return
        elif self.text() != '':
            self.lineEdit().setText( numberToStr(self.value) )
            
    def setSingleStep(self, in_step):
        self.singlestep = in_step
        
    def setType(self, in_type):
        self.type = in_type
    
    def setText(self, in_text):
        self.lineEdit().setText( str(in_text) )
        self.updateValue()

    def stepBy(self, in_step):
        self.value += self.singlestep*in_step
        self.setText( numberToStr(self.value, self.type) )
        
    def setValue(self, in_value):
        self.value = self.type(in_value)
        self.setText( numberToStr(self.value, self.type) )

    def stepEnabled(self):
        return QtGui.QAbstractSpinBox.StepUpEnabled | QtGui.QAbstractSpinBox.StepDownEnabled


class SelectAllLineEdit(QtGui.QLineEdit):
    def __init__(self):
        QtGui.QLineEdit.__init__(self)
        self.setFocusPolicy(QtCore.Qt.StrongFocus)
        self.focus = False

    def focusOutEvent(self,e ):
        self.focus = False
        QtGui.QLineEdit.focusOutEvent(self, e)
                
    def mousePressEvent(self, e):
        if not self.focus:
            self.focus = True
            self.selectAll()
        else:
            QtGui.QLineEdit.mousePressEvent(self, e)


class HiliteAllSpinBox(InfiniteSpinBox):
    def __init__(self, in_parent=None, in_value=0, in_type=float):
        InfiniteSpinBox.__init__(self, in_parent, in_value, in_type)
        self.setLineEdit( SelectAllLineEdit() )
        self.setText( numberToStr(in_value) )
        self.setFocusPolicy(QtCore.Qt.StrongFocus)
    
    def focusInEvent(self, e):
        self.selectAll();


class MouseTrackingLineEdit(SelectAllLineEdit):
    def __init__(self):
        SelectAllLineEdit.__init__(self)
        self.setMouseTracking(True)
        self.mousex = 0
        
    def mouseMoveEvent(self, e):
        self.mousex = e.pos().x()
        
    
class NukeSpinBox(InfiniteSpinBox):
    def __init__(self, in_parent=None, in_value=0, in_type=float):
        InfiniteSpinBox.__init__(self, in_parent, in_value, in_type)
        self.setLineEdit( MouseTrackingLineEdit() )
        self.setText( numberToStr(in_value) )
        self.setFocusPolicy(QtCore.Qt.StrongFocus)
    
    def focusInEvent(self, e):
        self.selectAll();
        
    def stepBy(self, in_step):
        ln = self.lineEdit()
        m = ln.fontMetrics()
        
        stepsize = 10**( len(ln.text().split('.')[0])-1 )
        
        chars = ln.text().split('')
        str = ''
        for i in range(1, len(chars)-1, 1):
            str += chars[i]
            if chars[i] == '.':
                continue
            x = m.width(str)
            if x > ln.mousex:
                break
            stepsize *= 0.1
        
        self.setSingleStep(stepsize)
        InfiniteSpinBox.stepBy(self, in_step)


def numberToStr(in_number, in_type=float):
    out_string = str(in_number)
    out_string = out_string.split('.')
    if in_type in (long, int):
        return out_string[0]
    
    if len(out_string) > 1 and out_string[1]:
        if len(out_string[1]) > 6:
            out_string[1] = out_string[1][0:6]
        return '%s.%s'%(out_string[0],out_string[1])
    
    return '%s.0'%out_string[0]


def strToNumber(in_str, in_type=float):
    segs = str(in_str).split('.')
    if len(segs) in (1,2) and segs[0].isdigit():
        if len(segs) == 1 or not segs[1] or segs[1].isdigit():
            return in_type(in_str)
    return None


def main():
    app = QtGui.QApplication(sys.argv)
    w = QtGui.QFrame()
    l = QtGui.QVBoxLayout()
    w.setLayout(l)
    l.addWidget( QtGui.QLabel('Infinite spinbox') )
    l.addWidget( InfiniteSpinBox() )
    l.addWidget( QtGui.QLabel('Select contents on click spinbox') )
    l.addWidget( HiliteAllSpinBox() )
    l.addWidget( QtGui.QLabel('Step determined by mouse position') )
    l.addWidget( NukeSpinBox() )
    w.show()
    app.exec_()
    return app;
app = main()

Leave a Reply

Your email address will not be published. Required fields are marked *