Fix multiple spinbox problems:
- fixed bug with exponents disappearing after edit - fixed parsing of values with junk after suffix - fixed red border - reverted default decimals to 6 - make suffix editable (but show red border if it's wrong) - revert invalid text on focus lost - siPrefix without suffix is no longer allowed - let user set arbitrary format string
This commit is contained in:
parent
5ddbb611d1
commit
6b798ffed8
@ -34,8 +34,13 @@ Colors = {
|
|||||||
|
|
||||||
SI_PREFIXES = asUnicode('yzafpnµm kMGTPEZY')
|
SI_PREFIXES = asUnicode('yzafpnµm kMGTPEZY')
|
||||||
SI_PREFIXES_ASCII = 'yzafpnum kMGTPEZY'
|
SI_PREFIXES_ASCII = 'yzafpnum kMGTPEZY'
|
||||||
|
SI_PREFIX_EXPONENTS = dict([(SI_PREFIXES[i], (i-8)*3) for i in range(len(SI_PREFIXES))])
|
||||||
|
SI_PREFIX_EXPONENTS['u'] = -6
|
||||||
|
|
||||||
|
FLOAT_REGEX = re.compile(r'(?P<number>[+-]?((\d+(\.\d*)?)|(\d*\.\d+))([eE][+-]?\d+)?)\s*((?P<siprefix>[u' + SI_PREFIXES + r']?)(?P<suffix>\w.*))?$')
|
||||||
|
INT_REGEX = re.compile(r'(?P<number>[+-]?\d+)\s*(?P<siprefix>[u' + SI_PREFIXES + r']?)(?P<suffix>.*)$')
|
||||||
|
|
||||||
|
|
||||||
def siScale(x, minVal=1e-25, allowUnicode=True):
|
def siScale(x, minVal=1e-25, allowUnicode=True):
|
||||||
"""
|
"""
|
||||||
Return the recommended scale factor and SI prefix string for x.
|
Return the recommended scale factor and SI prefix string for x.
|
||||||
@ -103,31 +108,48 @@ def siFormat(x, precision=3, suffix='', space=True, error=None, minVal=1e-25, al
|
|||||||
plusminus = " +/- "
|
plusminus = " +/- "
|
||||||
fmt = "%." + str(precision) + "g%s%s%s%s"
|
fmt = "%." + str(precision) + "g%s%s%s%s"
|
||||||
return fmt % (x*p, pref, suffix, plusminus, siFormat(error, precision=precision, suffix=suffix, space=space, minVal=minVal))
|
return fmt % (x*p, pref, suffix, plusminus, siFormat(error, precision=precision, suffix=suffix, space=space, minVal=minVal))
|
||||||
|
|
||||||
|
|
||||||
|
def siParse(s, regex=FLOAT_REGEX):
|
||||||
|
"""Convert a value written in SI notation to a tuple (number, si_prefix, suffix).
|
||||||
|
|
||||||
def siEval(s):
|
Example::
|
||||||
|
|
||||||
|
siParse('100 μV") # returns ('100', 'μ', 'V')
|
||||||
"""
|
"""
|
||||||
Convert a value written in SI notation to its equivalent prefixless value
|
s = asUnicode(s)
|
||||||
|
m = regex.match(s)
|
||||||
|
if m is None:
|
||||||
|
raise ValueError('Cannot parse number "%s"' % s)
|
||||||
|
sip = m.group('siprefix')
|
||||||
|
suf = m.group('suffix')
|
||||||
|
return m.group('number'), '' if sip is None else sip, '' if suf is None else suf
|
||||||
|
|
||||||
|
|
||||||
|
def siEval(s, typ=float, regex=FLOAT_REGEX):
|
||||||
|
"""
|
||||||
|
Convert a value written in SI notation to its equivalent prefixless value.
|
||||||
|
|
||||||
Example::
|
Example::
|
||||||
|
|
||||||
siEval("100 μV") # returns 0.0001
|
siEval("100 μV") # returns 0.0001
|
||||||
"""
|
"""
|
||||||
|
val, siprefix, suffix = siParse(s, regex)
|
||||||
|
v = typ(val)
|
||||||
|
return siApply(val, siprefix)
|
||||||
|
|
||||||
|
|
||||||
s = asUnicode(s)
|
def siApply(val, siprefix):
|
||||||
m = re.match(r'(-?((\d+(\.\d*)?)|(\.\d+))([eE]-?\d+)?)\s*([u' + SI_PREFIXES + r']?).*$', s)
|
"""
|
||||||
if m is None:
|
"""
|
||||||
raise Exception("Can't convert string '%s' to number." % s)
|
n = SI_PREFIX_EXPONENTS[siprefix] if siprefix != '' else 0
|
||||||
v = float(m.groups()[0])
|
if n > 0:
|
||||||
p = m.groups()[6]
|
return val * 10**n
|
||||||
#if p not in SI_PREFIXES:
|
elif n < 0:
|
||||||
#raise Exception("Can't convert string '%s' to number--unknown prefix." % s)
|
# this case makes it possible to use Decimal objects here
|
||||||
if p == '':
|
return val / 10**-n
|
||||||
n = 0
|
|
||||||
elif p == 'u':
|
|
||||||
n = -2
|
|
||||||
else:
|
else:
|
||||||
n = SI_PREFIXES.index(p) - 8
|
return val
|
||||||
return v * 1000**n
|
|
||||||
|
|
||||||
|
|
||||||
class Color(QtGui.QColor):
|
class Color(QtGui.QColor):
|
||||||
|
@ -6,10 +6,13 @@ from ..SignalProxy import SignalProxy
|
|||||||
from .. import functions as fn
|
from .. import functions as fn
|
||||||
from math import log
|
from math import log
|
||||||
from decimal import Decimal as D ## Use decimal to avoid accumulating floating-point errors
|
from decimal import Decimal as D ## Use decimal to avoid accumulating floating-point errors
|
||||||
from decimal import *
|
import decimal
|
||||||
import weakref
|
import weakref
|
||||||
|
|
||||||
|
|
||||||
__all__ = ['SpinBox']
|
__all__ = ['SpinBox']
|
||||||
|
|
||||||
|
|
||||||
class SpinBox(QtGui.QAbstractSpinBox):
|
class SpinBox(QtGui.QAbstractSpinBox):
|
||||||
"""
|
"""
|
||||||
**Bases:** QtGui.QAbstractSpinBox
|
**Bases:** QtGui.QAbstractSpinBox
|
||||||
@ -42,7 +45,7 @@ class SpinBox(QtGui.QAbstractSpinBox):
|
|||||||
valueChanged = QtCore.Signal(object) # (value) for compatibility with QSpinBox
|
valueChanged = QtCore.Signal(object) # (value) for compatibility with QSpinBox
|
||||||
sigValueChanged = QtCore.Signal(object) # (self)
|
sigValueChanged = QtCore.Signal(object) # (self)
|
||||||
sigValueChanging = QtCore.Signal(object, object) # (self, value) sent immediately; no delay.
|
sigValueChanging = QtCore.Signal(object, object) # (self, value) sent immediately; no delay.
|
||||||
|
|
||||||
def __init__(self, parent=None, value=0.0, **kwargs):
|
def __init__(self, parent=None, value=0.0, **kwargs):
|
||||||
"""
|
"""
|
||||||
============== ========================================================================
|
============== ========================================================================
|
||||||
@ -60,6 +63,8 @@ class SpinBox(QtGui.QAbstractSpinBox):
|
|||||||
self.setMinimumWidth(0)
|
self.setMinimumWidth(0)
|
||||||
self.setMaximumHeight(20)
|
self.setMaximumHeight(20)
|
||||||
self.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Preferred)
|
self.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Preferred)
|
||||||
|
self.errorBox = ErrorBox(self.lineEdit())
|
||||||
|
|
||||||
self.opts = {
|
self.opts = {
|
||||||
'bounds': [None, None],
|
'bounds': [None, None],
|
||||||
|
|
||||||
@ -80,7 +85,7 @@ class SpinBox(QtGui.QAbstractSpinBox):
|
|||||||
|
|
||||||
'delayUntilEditFinished': True, ## do not send signals until text editing has finished
|
'delayUntilEditFinished': True, ## do not send signals until text editing has finished
|
||||||
|
|
||||||
'decimals': 3,
|
'decimals': 6,
|
||||||
|
|
||||||
'format': asUnicode("{scaledValue:.{decimals}g}{suffixGap}{siPrefix}{suffix}"),
|
'format': asUnicode("{scaledValue:.{decimals}g}{suffixGap}{siPrefix}{suffix}"),
|
||||||
|
|
||||||
@ -97,7 +102,7 @@ class SpinBox(QtGui.QAbstractSpinBox):
|
|||||||
|
|
||||||
self.editingFinished.connect(self.editingFinishedEvent)
|
self.editingFinished.connect(self.editingFinishedEvent)
|
||||||
self.proxy = SignalProxy(self.sigValueChanging, slot=self.delayedChange, delay=self.opts['delay'])
|
self.proxy = SignalProxy(self.sigValueChanging, slot=self.delayedChange, delay=self.opts['delay'])
|
||||||
|
|
||||||
def event(self, ev):
|
def event(self, ev):
|
||||||
ret = QtGui.QAbstractSpinBox.event(self, ev)
|
ret = QtGui.QAbstractSpinBox.event(self, ev)
|
||||||
if ev.type() == QtCore.QEvent.KeyPress and ev.key() == QtCore.Qt.Key_Return:
|
if ev.type() == QtCore.QEvent.KeyPress and ev.key() == QtCore.Qt.Key_Return:
|
||||||
@ -133,7 +138,7 @@ class SpinBox(QtGui.QAbstractSpinBox):
|
|||||||
False.
|
False.
|
||||||
minStep (float) When dec=True, this specifies the minimum allowable step size.
|
minStep (float) When dec=True, this specifies the minimum allowable step size.
|
||||||
int (bool) if True, the value is forced to integer type. Default is False
|
int (bool) if True, the value is forced to integer type. Default is False
|
||||||
decimals (int) Number of decimal values to display. Default is 3.
|
decimals (int) Number of decimal values to display. Default is 6.
|
||||||
format (str) Formatting string used to generate the text shown. Formatting is
|
format (str) Formatting string used to generate the text shown. Formatting is
|
||||||
done with ``str.format()`` and makes use of several arguments:
|
done with ``str.format()`` and makes use of several arguments:
|
||||||
|
|
||||||
@ -301,7 +306,9 @@ class SpinBox(QtGui.QAbstractSpinBox):
|
|||||||
if self.opts['int']:
|
if self.opts['int']:
|
||||||
value = int(value)
|
value = int(value)
|
||||||
|
|
||||||
value = D(asUnicode(value))
|
if not isinstance(value, D):
|
||||||
|
value = D(asUnicode(value))
|
||||||
|
|
||||||
if value == self.val:
|
if value == self.val:
|
||||||
return
|
return
|
||||||
prev = self.val
|
prev = self.val
|
||||||
@ -315,7 +322,6 @@ class SpinBox(QtGui.QAbstractSpinBox):
|
|||||||
self.emitChanged()
|
self.emitChanged()
|
||||||
|
|
||||||
return value
|
return value
|
||||||
|
|
||||||
|
|
||||||
def emitChanged(self):
|
def emitChanged(self):
|
||||||
self.lastValEmitted = self.val
|
self.lastValEmitted = self.val
|
||||||
@ -335,13 +341,9 @@ class SpinBox(QtGui.QAbstractSpinBox):
|
|||||||
def sizeHint(self):
|
def sizeHint(self):
|
||||||
return QtCore.QSize(120, 0)
|
return QtCore.QSize(120, 0)
|
||||||
|
|
||||||
|
|
||||||
def stepEnabled(self):
|
def stepEnabled(self):
|
||||||
return self.StepUpEnabled | self.StepDownEnabled
|
return self.StepUpEnabled | self.StepDownEnabled
|
||||||
|
|
||||||
#def fixup(self, *args):
|
|
||||||
#print "fixup:", args
|
|
||||||
|
|
||||||
def stepBy(self, n):
|
def stepBy(self, n):
|
||||||
n = D(int(n)) ## n must be integral number of steps.
|
n = D(int(n)) ## n must be integral number of steps.
|
||||||
s = [D(-1), D(1)][n >= 0] ## determine sign of step
|
s = [D(-1), D(1)][n >= 0] ## determine sign of step
|
||||||
@ -363,7 +365,7 @@ class SpinBox(QtGui.QAbstractSpinBox):
|
|||||||
vs = [D(-1), D(1)][val >= 0]
|
vs = [D(-1), D(1)][val >= 0]
|
||||||
#exp = D(int(abs(val*(D('1.01')**(s*vs))).log10()))
|
#exp = D(int(abs(val*(D('1.01')**(s*vs))).log10()))
|
||||||
fudge = D('1.01')**(s*vs) ## fudge factor. at some places, the step size depends on the step sign.
|
fudge = D('1.01')**(s*vs) ## fudge factor. at some places, the step size depends on the step sign.
|
||||||
exp = abs(val * fudge).log10().quantize(1, ROUND_FLOOR)
|
exp = abs(val * fudge).log10().quantize(1, decimal.ROUND_FLOOR)
|
||||||
step = self.opts['step'] * D(10)**exp
|
step = self.opts['step'] * D(10)**exp
|
||||||
if 'minStep' in self.opts:
|
if 'minStep' in self.opts:
|
||||||
step = max(step, self.opts['minStep'])
|
step = max(step, self.opts['minStep'])
|
||||||
@ -375,7 +377,6 @@ class SpinBox(QtGui.QAbstractSpinBox):
|
|||||||
if 'minStep' in self.opts and abs(val) < self.opts['minStep']:
|
if 'minStep' in self.opts and abs(val) < self.opts['minStep']:
|
||||||
val = D(0)
|
val = D(0)
|
||||||
self.setValue(val, delaySignal=True) ## note all steps (arrow buttons, wheel, up/down keys..) emit delayed signals only.
|
self.setValue(val, delaySignal=True) ## note all steps (arrow buttons, wheel, up/down keys..) emit delayed signals only.
|
||||||
|
|
||||||
|
|
||||||
def valueInRange(self, value):
|
def valueInRange(self, value):
|
||||||
bounds = self.opts['bounds']
|
bounds = self.opts['bounds']
|
||||||
@ -403,12 +404,12 @@ class SpinBox(QtGui.QAbstractSpinBox):
|
|||||||
|
|
||||||
def formatText(self, prev=None):
|
def formatText(self, prev=None):
|
||||||
# get the number of decimal places to print
|
# get the number of decimal places to print
|
||||||
decimals = self.opts['decimals'] if self.opts['int'] is False else 9
|
decimals = self.opts['decimals']
|
||||||
suffix = self.opts['suffix']
|
suffix = self.opts['suffix']
|
||||||
|
|
||||||
# format the string
|
# format the string
|
||||||
val = float(self.val)
|
val = self.value()
|
||||||
if self.opts['siPrefix']:
|
if self.opts['siPrefix'] is True and len(self.opts['suffix']) > 0:
|
||||||
# SI prefix was requested, so scale the value accordingly
|
# SI prefix was requested, so scale the value accordingly
|
||||||
|
|
||||||
if self.val == 0 and prev is not None:
|
if self.val == 0 and prev is not None:
|
||||||
@ -419,38 +420,32 @@ class SpinBox(QtGui.QAbstractSpinBox):
|
|||||||
parts = {'value': val, 'suffix': suffix, 'decimals': decimals, 'siPrefix': p, 'scaledValue': s*val}
|
parts = {'value': val, 'suffix': suffix, 'decimals': decimals, 'siPrefix': p, 'scaledValue': s*val}
|
||||||
|
|
||||||
else:
|
else:
|
||||||
# no SI prefix requested; scale is 1
|
# no SI prefix /suffix requested; scale is 1
|
||||||
parts = {'value': val, 'suffix': suffix, 'decimals': decimals, 'siPrefix': '', 'scaledValue': val}
|
parts = {'value': val, 'suffix': suffix, 'decimals': decimals, 'siPrefix': '', 'scaledValue': val}
|
||||||
|
|
||||||
parts['suffixGap'] = '' if (parts['suffix'] == '' and parts['siPrefix'] == '') else ' '
|
parts['suffixGap'] = '' if (parts['suffix'] == '' and parts['siPrefix'] == '') else ' '
|
||||||
|
|
||||||
format = self.opts['format']
|
return self.opts['format'].format(**parts)
|
||||||
return format.format(**parts)
|
|
||||||
|
|
||||||
def validate(self, strn, pos):
|
def validate(self, strn, pos):
|
||||||
if self.skipValidate:
|
if self.skipValidate:
|
||||||
ret = QtGui.QValidator.Acceptable
|
ret = QtGui.QValidator.Acceptable
|
||||||
else:
|
else:
|
||||||
try:
|
try:
|
||||||
## first make sure we didn't mess with the suffix
|
val = self.interpret()
|
||||||
suff = self.opts.get('suffix', '')
|
if val is False:
|
||||||
if len(suff) > 0 and asUnicode(strn)[-len(suff):] != suff:
|
ret = QtGui.QValidator.Intermediate
|
||||||
ret = QtGui.QValidator.Invalid
|
|
||||||
|
|
||||||
## next see if we actually have an interpretable value
|
|
||||||
else:
|
else:
|
||||||
val = self.interpret()
|
if self.valueInRange(val):
|
||||||
if val is False:
|
if not self.opts['delayUntilEditFinished']:
|
||||||
ret = QtGui.QValidator.Intermediate
|
self.setValue(val, update=False)
|
||||||
|
ret = QtGui.QValidator.Acceptable
|
||||||
else:
|
else:
|
||||||
if self.valueInRange(val):
|
ret = QtGui.QValidator.Intermediate
|
||||||
if not self.opts['delayUntilEditFinished']:
|
|
||||||
self.setValue(val, update=False)
|
|
||||||
ret = QtGui.QValidator.Acceptable
|
|
||||||
else:
|
|
||||||
ret = QtGui.QValidator.Intermediate
|
|
||||||
|
|
||||||
except:
|
except:
|
||||||
|
import sys
|
||||||
|
sys.excepthook(*sys.exc_info())
|
||||||
ret = QtGui.QValidator.Intermediate
|
ret = QtGui.QValidator.Intermediate
|
||||||
|
|
||||||
## draw / clear border
|
## draw / clear border
|
||||||
@ -462,40 +457,46 @@ class SpinBox(QtGui.QAbstractSpinBox):
|
|||||||
## since the text will be forced to its previous state anyway
|
## since the text will be forced to its previous state anyway
|
||||||
self.update()
|
self.update()
|
||||||
|
|
||||||
|
self.errorBox.setVisible(not self.textValid)
|
||||||
|
|
||||||
## support 2 different pyqt APIs. Bleh.
|
## support 2 different pyqt APIs. Bleh.
|
||||||
if hasattr(QtCore, 'QString'):
|
if hasattr(QtCore, 'QString'):
|
||||||
return (ret, pos)
|
return (ret, pos)
|
||||||
else:
|
else:
|
||||||
return (ret, strn, pos)
|
return (ret, strn, pos)
|
||||||
|
|
||||||
def paintEvent(self, ev):
|
def fixup(self, strn):
|
||||||
QtGui.QAbstractSpinBox.paintEvent(self, ev)
|
# fixup is called when the spinbox loses focus with an invalid or intermediate string
|
||||||
|
self.updateText()
|
||||||
## draw red border if text is invalid
|
strn.clear()
|
||||||
if not self.textValid:
|
strn.append(self.lineEdit().text())
|
||||||
p = QtGui.QPainter(self)
|
|
||||||
p.setRenderHint(p.Antialiasing)
|
|
||||||
p.setPen(fn.mkPen((200,50,50), width=2))
|
|
||||||
p.drawRoundedRect(self.rect().adjusted(2, 2, -2, -2), 4, 4)
|
|
||||||
p.end()
|
|
||||||
|
|
||||||
|
|
||||||
def interpret(self):
|
def interpret(self):
|
||||||
"""Return value of text. Return False if text is invalid, raise exception if text is intermediate"""
|
"""Return value of text or False if text is invalid."""
|
||||||
strn = self.lineEdit().text()
|
strn = self.lineEdit().text()
|
||||||
suf = self.opts['suffix']
|
|
||||||
if len(suf) > 0:
|
# tokenize into numerical value, si prefix, and suffix
|
||||||
if strn[-len(suf):] != suf:
|
|
||||||
return False
|
|
||||||
#raise Exception("Units are invalid.")
|
|
||||||
strn = strn[:-len(suf)]
|
|
||||||
try:
|
try:
|
||||||
val = fn.siEval(strn)
|
val, siprefix, suffix = fn.siParse(strn)
|
||||||
except:
|
except Exception:
|
||||||
#sys.excepthook(*sys.exc_info())
|
|
||||||
#print "invalid"
|
|
||||||
return False
|
return False
|
||||||
#print val
|
|
||||||
|
# check suffix
|
||||||
|
if suffix != self.opts['suffix'] or (suffix == '' and siprefix != ''):
|
||||||
|
return False
|
||||||
|
|
||||||
|
# generate value
|
||||||
|
val = D(val)
|
||||||
|
if self.opts['int']:
|
||||||
|
val = int(fn.siApply(val, siprefix))
|
||||||
|
else:
|
||||||
|
try:
|
||||||
|
val = fn.siApply(val, siprefix)
|
||||||
|
except Exception:
|
||||||
|
import sys
|
||||||
|
sys.excepthook(*sys.exc_info())
|
||||||
|
return False
|
||||||
|
|
||||||
return val
|
return val
|
||||||
|
|
||||||
def editingFinishedEvent(self):
|
def editingFinishedEvent(self):
|
||||||
@ -506,7 +507,7 @@ class SpinBox(QtGui.QAbstractSpinBox):
|
|||||||
return
|
return
|
||||||
try:
|
try:
|
||||||
val = self.interpret()
|
val = self.interpret()
|
||||||
except:
|
except Exception:
|
||||||
return
|
return
|
||||||
|
|
||||||
if val is False:
|
if val is False:
|
||||||
@ -516,3 +517,29 @@ class SpinBox(QtGui.QAbstractSpinBox):
|
|||||||
#print "no value change:", val, self.val
|
#print "no value change:", val, self.val
|
||||||
return
|
return
|
||||||
self.setValue(val, delaySignal=False) ## allow text update so that values are reformatted pretty-like
|
self.setValue(val, delaySignal=False) ## allow text update so that values are reformatted pretty-like
|
||||||
|
|
||||||
|
|
||||||
|
class ErrorBox(QtGui.QWidget):
|
||||||
|
"""Red outline to draw around lineedit when value is invalid.
|
||||||
|
(for some reason, setting border from stylesheet does not work)
|
||||||
|
"""
|
||||||
|
def __init__(self, parent):
|
||||||
|
QtGui.QWidget.__init__(self, parent)
|
||||||
|
parent.installEventFilter(self)
|
||||||
|
self.setAttribute(QtCore.Qt.WA_TransparentForMouseEvents)
|
||||||
|
self._resize()
|
||||||
|
self.setVisible(False)
|
||||||
|
|
||||||
|
def eventFilter(self, obj, ev):
|
||||||
|
if ev.type() == QtCore.QEvent.Resize:
|
||||||
|
self._resize()
|
||||||
|
return False
|
||||||
|
|
||||||
|
def _resize(self):
|
||||||
|
self.setGeometry(0, 0, self.parent().width(), self.parent().height())
|
||||||
|
|
||||||
|
def paintEvent(self, ev):
|
||||||
|
p = QtGui.QPainter(self)
|
||||||
|
p.setPen(fn.mkPen(color='r', width=2))
|
||||||
|
p.drawRect(self.rect())
|
||||||
|
p.end()
|
||||||
|
Loading…
Reference in New Issue
Block a user