From d461bf866f3abd53484e6334e7bc69e7c36470aa Mon Sep 17 00:00:00 2001 From: dlidstrom Date: Mon, 16 Mar 2015 15:30:15 -0600 Subject: [PATCH 01/13] Add wrapping option to SpinBox --- pyqtgraph/widgets/SpinBox.py | 25 +++++++++++++++++++++---- 1 file changed, 21 insertions(+), 4 deletions(-) diff --git a/pyqtgraph/widgets/SpinBox.py b/pyqtgraph/widgets/SpinBox.py index a863cd60..7c3fe256 100644 --- a/pyqtgraph/widgets/SpinBox.py +++ b/pyqtgraph/widgets/SpinBox.py @@ -51,6 +51,7 @@ class SpinBox(QtGui.QAbstractSpinBox): value (float/int) initial value. Default is 0.0. bounds (min,max) Minimum and maximum values allowed in the SpinBox. Either may be None to leave the value unbounded. By default, values are unbounded. + wrapping (bool) If True and both bounds are not None, spin box has circular behavior. suffix (str) suffix (units) to display after the numerical value. By default, suffix is an empty str. siPrefix (bool) If True, then an SI prefix is automatically prepended to the units and the value is scaled accordingly. For example, @@ -81,6 +82,7 @@ class SpinBox(QtGui.QAbstractSpinBox): self.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Preferred) self.opts = { 'bounds': [None, None], + 'wrapping': False, ## Log scaling options #### Log mode is no longer supported. #'step': 0.1, @@ -205,6 +207,14 @@ class SpinBox(QtGui.QAbstractSpinBox): self.opts['bounds'][0] = m if update: self.setValue() + + def wrapping(self): + """Return whether or not the spin box is circular.""" + return self.opts['wrapping'] + + def setWrapping(self, s): + """Set whether spin box is circular. Both bounds must be set for this to have an effect.""" + self.opts['wrapping'] = s def setPrefix(self, p): """Set a string prefix. @@ -282,10 +292,17 @@ class SpinBox(QtGui.QAbstractSpinBox): value = self.value() bounds = self.opts['bounds'] - if bounds[0] is not None and value < bounds[0]: - value = bounds[0] - if bounds[1] is not None and value > bounds[1]: - value = bounds[1] + + if bounds[0] is not None and bounds[1] is not None and self.opts['wrapping']: + # Casting of Decimals to floats required to avoid unexpected behavior of remainder operator + value = float(value) + l, u = float(bounds[0]), float(bounds[1]) + value = (value - l) % (u - l) + l + else: + if bounds[0] is not None and value < bounds[0]: + value = bounds[0] + if bounds[1] is not None and value > bounds[1]: + value = bounds[1] if self.opts['int']: value = int(value) From 92d8c2630b096cea2a214c685eb36c2412b3f653 Mon Sep 17 00:00:00 2001 From: lidstrom83 Date: Sun, 25 Oct 2015 03:55:37 -0600 Subject: [PATCH 02/13] Add spin box wrapping example. --- examples/SpinBox.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/examples/SpinBox.py b/examples/SpinBox.py index 2fa9b161..268bfa72 100644 --- a/examples/SpinBox.py +++ b/examples/SpinBox.py @@ -31,6 +31,8 @@ spins = [ pg.SpinBox(value=1.0, suffix='V', siPrefix=True, dec=True, step=0.5, minStep=0.01)), ("Float with SI-prefixed units,
dec step=1.0, minStep=0.001", pg.SpinBox(value=1.0, suffix='V', siPrefix=True, dec=True, step=1.0, minStep=0.001)), + ("Integer with bounds=[10, 20] and wrapping", + pg.SpinBox(value=10, bounds=[10, 20], int=False, minStep=1, step=1, wrapping=True)), ] From 5322c0233b9e28f3f42d5f3e9d29e1868eea221e Mon Sep 17 00:00:00 2001 From: lidstrom83 Date: Tue, 3 May 2016 12:25:05 -0600 Subject: [PATCH 03/13] Fix bug where int and float parameter limits are not always set. --- pyqtgraph/parametertree/parameterTypes.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/pyqtgraph/parametertree/parameterTypes.py b/pyqtgraph/parametertree/parameterTypes.py index d8a5f1a6..892a228a 100644 --- a/pyqtgraph/parametertree/parameterTypes.py +++ b/pyqtgraph/parametertree/parameterTypes.py @@ -108,7 +108,7 @@ class WidgetParameterItem(ParameterItem): if k in opts: defs[k] = opts[k] if 'limits' in opts: - defs['bounds'] = opts['limits'] + defs['min'], defs['max'] = opts['limits'] w = SpinBox() w.setOpts(**defs) w.sigChanged = w.sigValueChanged From e26fb1f9ded0caaec29579f9860d3d319e71de8e Mon Sep 17 00:00:00 2001 From: Luke Campagnola Date: Tue, 29 Nov 2016 17:45:42 -0800 Subject: [PATCH 04/13] Add first spinbox tests --- pyqtgraph/widgets/tests/test_spinbox.py | 24 ++++++++++++++++++++++++ 1 file changed, 24 insertions(+) create mode 100644 pyqtgraph/widgets/tests/test_spinbox.py diff --git a/pyqtgraph/widgets/tests/test_spinbox.py b/pyqtgraph/widgets/tests/test_spinbox.py new file mode 100644 index 00000000..dcf15cb3 --- /dev/null +++ b/pyqtgraph/widgets/tests/test_spinbox.py @@ -0,0 +1,24 @@ +import pyqtgraph as pg +pg.mkQApp() + +def test_spinbox(): + sb = pg.SpinBox() + assert sb.opts['decimals'] == 3 + assert sb.opts['int'] is False + + # table of test conditions: + # value, text, options + conds = [ + (0, '0', dict(suffix='', siPrefix=False, dec=False, int=False)), + (100, '100', dict()), + (1000000, '1e+06', dict()), + (1000, '1e+03', dict(decimals=2)), + (1000000, '1000000', dict(int=True)), + (12345678955, '12345678955', dict(int=True)), + ] + + for (value, text, opts) in conds: + sb.setOpts(**opts) + sb.setValue(value) + assert sb.value() == value + assert pg.asUnicode(sb.text()) == text From c97c5f51e244ad83691ebf84daf23b842b337462 Mon Sep 17 00:00:00 2001 From: Luke Campagnola Date: Wed, 30 Nov 2016 09:16:56 -0800 Subject: [PATCH 05/13] Add spinbox option for custom formatting --- pyqtgraph/functions.py | 3 +- pyqtgraph/widgets/SpinBox.py | 138 +++++++++++++++++++---------------- 2 files changed, 77 insertions(+), 64 deletions(-) diff --git a/pyqtgraph/functions.py b/pyqtgraph/functions.py index d79c350f..6ec3932f 100644 --- a/pyqtgraph/functions.py +++ b/pyqtgraph/functions.py @@ -36,8 +36,6 @@ SI_PREFIXES = asUnicode('yzafpnµm kMGTPEZY') SI_PREFIXES_ASCII = 'yzafpnum kMGTPEZY' - - def siScale(x, minVal=1e-25, allowUnicode=True): """ Return the recommended scale factor and SI prefix string for x. @@ -76,6 +74,7 @@ def siScale(x, minVal=1e-25, allowUnicode=True): return (p, pref) + def siFormat(x, precision=3, suffix='', space=True, error=None, minVal=1e-25, allowUnicode=True): """ Return the number x formatted in engineering notation with SI prefix. diff --git a/pyqtgraph/widgets/SpinBox.py b/pyqtgraph/widgets/SpinBox.py index a863cd60..50429dee 100644 --- a/pyqtgraph/widgets/SpinBox.py +++ b/pyqtgraph/widgets/SpinBox.py @@ -49,28 +49,9 @@ class SpinBox(QtGui.QAbstractSpinBox): **Arguments:** parent Sets the parent widget for this SpinBox (optional). Default is None. value (float/int) initial value. Default is 0.0. - bounds (min,max) Minimum and maximum values allowed in the SpinBox. - Either may be None to leave the value unbounded. By default, values are unbounded. - suffix (str) suffix (units) to display after the numerical value. By default, suffix is an empty str. - siPrefix (bool) If True, then an SI prefix is automatically prepended - to the units and the value is scaled accordingly. For example, - if value=0.003 and suffix='V', then the SpinBox will display - "300 mV" (but a call to SpinBox.value will still return 0.003). Default is False. - step (float) The size of a single step. This is used when clicking the up/ - down arrows, when rolling the mouse wheel, or when pressing - keyboard arrows while the widget has keyboard focus. Note that - the interpretation of this value is different when specifying - the 'dec' argument. Default is 0.01. - dec (bool) If True, then the step value will be adjusted to match - the current size of the variable (for example, a value of 15 - might step in increments of 1 whereas a value of 1500 would - step in increments of 100). In this case, the 'step' argument - is interpreted *relative* to the current value. The most common - 'step' values when dec=True are 0.1, 0.2, 0.5, and 1.0. Default is False. - 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 - decimals (int) Number of decimal values to display. Default is 2. ============== ======================================================================== + + All keyword arguments are passed to :func:`setOpts`. """ QtGui.QAbstractSpinBox.__init__(self, parent) self.lastValEmitted = None @@ -81,28 +62,15 @@ class SpinBox(QtGui.QAbstractSpinBox): self.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Preferred) self.opts = { 'bounds': [None, None], - - ## Log scaling options #### Log mode is no longer supported. - #'step': 0.1, - #'minStep': 0.001, - #'log': True, - #'dec': False, - - ## decimal scaling option - example - #'step': 0.1, - #'minStep': .001, - #'log': False, - #'dec': True, ## normal arithmetic step 'step': D('0.01'), ## if 'dec' is false, the spinBox steps by 'step' every time ## if 'dec' is True, the step size is relative to the value ## 'step' needs to be an integral divisor of ten, ie 'step'*n=10 for some integer value of n (but only if dec is True) - 'log': False, + 'log': False, # deprecated 'dec': False, ## if true, does decimal stepping. ie from 1-10 it steps by 'step', from 10 to 100 it steps by 10*'step', etc. ## if true, minStep must be set in order to cross zero. - 'int': False, ## Set True to force value to be integer 'suffix': '', @@ -114,6 +82,8 @@ class SpinBox(QtGui.QAbstractSpinBox): 'decimals': 3, + 'format': asUnicode("{scaledValue:.{decimals}g}{suffixGap}{siPrefix}{suffix}"), + } self.decOpts = ['step', 'minStep'] @@ -134,12 +104,47 @@ class SpinBox(QtGui.QAbstractSpinBox): ret = True ## For some reason, spinbox pretends to ignore return key press return ret - ##lots of config options, just gonna stuff 'em all in here rather than do the get/set crap. def setOpts(self, **opts): - """ - Changes the behavior of the SpinBox. Accepts most of the arguments - allowed in :func:`__init__ `. + """Set options affecting the behavior of the SpinBox. + ============== ======================================================================== + **Arguments:** + bounds (min,max) Minimum and maximum values allowed in the SpinBox. + Either may be None to leave the value unbounded. By default, values are + unbounded. + suffix (str) suffix (units) to display after the numerical value. By default, + suffix is an empty str. + siPrefix (bool) If True, then an SI prefix is automatically prepended + to the units and the value is scaled accordingly. For example, + if value=0.003 and suffix='V', then the SpinBox will display + "300 mV" (but a call to SpinBox.value will still return 0.003). Default + is False. + step (float) The size of a single step. This is used when clicking the up/ + down arrows, when rolling the mouse wheel, or when pressing + keyboard arrows while the widget has keyboard focus. Note that + the interpretation of this value is different when specifying + the 'dec' argument. Default is 0.01. + dec (bool) If True, then the step value will be adjusted to match + the current size of the variable (for example, a value of 15 + might step in increments of 1 whereas a value of 1500 would + step in increments of 100). In this case, the 'step' argument + is interpreted *relative* to the current value. The most common + 'step' values when dec=True are 0.1, 0.2, 0.5, and 1.0. Default is + False. + 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 + decimals (int) Number of decimal values to display. Default is 3. + format (str) Formatting string used to generate the text shown. Formatting is + done with ``str.format()`` and makes use of several arguments: + + * *value* - the unscaled value of the spin box + * *suffix* - the suffix string + * *scaledValue* - the scaled value to use when an SI prefix is present + * *siPrefix* - the SI prefix string (if any), or an empty string if + this feature has been disabled + * *suffixGap* - a single space if a suffix is present, or an empty + string otherwise. + ============== ======================================================================== """ #print opts for k in opts: @@ -154,6 +159,8 @@ class SpinBox(QtGui.QAbstractSpinBox): self.opts[k] = D(asUnicode(opts[k])) elif k == 'value': pass ## don't set value until bounds have been set + elif k == 'format': + self.opts[k] = asUnicode(opts[k]) elif k in self.opts: self.opts[k] = opts[k] else: @@ -378,37 +385,44 @@ class SpinBox(QtGui.QAbstractSpinBox): return True def updateText(self, prev=None): - # get the number of decimal places to print - decimals = self.opts.get('decimals') - # temporarily disable validation self.skipValidate = True - - # add a prefix to the units if requested - if self.opts['siPrefix']: - - # special case: if it's zero use the previous prefix - if self.val == 0 and prev is not None: - (s, p) = fn.siScale(prev) - - # NOTE: insert optional format string here? - txt = ("%."+str(decimals)+"g %s%s") % (0, p, self.opts['suffix']) - else: - # NOTE: insert optional format string here as an argument? - txt = fn.siFormat(float(self.val), precision=decimals, suffix=self.opts['suffix']) - - # otherwise, format the string manually - else: - # NOTE: insert optional format string here? - txt = ('%.'+str(decimals)+'g%s') % (self.val , self.opts['suffix']) - + + txt = self.formatText(prev=prev) + # actually set the text self.lineEdit().setText(txt) self.lastText = txt # re-enable the validation self.skipValidate = False - + + def formatText(self, prev=None): + # get the number of decimal places to print + decimals = self.opts['decimals'] if self.opts['int'] is False else 9 + suffix = self.opts['suffix'] + + # format the string + val = float(self.val) + if self.opts['siPrefix']: + # SI prefix was requested, so scale the value accordingly + + if self.val == 0 and prev is not None: + # special case: if it's zero use the previous prefix + (s, p) = fn.siScale(prev) + else: + (s, p) = fn.siScale(val) + parts = {'value': val, 'suffix': suffix, 'decimals': decimals, 'siPrefix': p, 'scaledValue': s*val} + + else: + # no SI prefix requested; scale is 1 + parts = {'value': val, 'suffix': suffix, 'decimals': decimals, 'siPrefix': '', 'scaledValue': val} + + parts['suffixGap'] = '' if (parts['suffix'] == '' and parts['siPrefix'] == '') else ' ' + + format = self.opts['format'] + return format.format(**parts) + def validate(self, strn, pos): if self.skipValidate: ret = QtGui.QValidator.Acceptable From 5ddbb611d1c3e428c0dfda85e105d8e1b727542b Mon Sep 17 00:00:00 2001 From: Luke Campagnola Date: Wed, 30 Nov 2016 09:18:09 -0800 Subject: [PATCH 06/13] spinbox selects only numerical portion of text on focus-in --- pyqtgraph/widgets/SpinBox.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/pyqtgraph/widgets/SpinBox.py b/pyqtgraph/widgets/SpinBox.py index 50429dee..86cbba93 100644 --- a/pyqtgraph/widgets/SpinBox.py +++ b/pyqtgraph/widgets/SpinBox.py @@ -264,6 +264,10 @@ class SpinBox(QtGui.QAbstractSpinBox): return le.setSelection(0, index) + def focusInEvent(self, ev): + super(SpinBox, self).focusInEvent(ev) + self.selectNumber() + def value(self): """ Return the value of this SpinBox. From 6b798ffed856d8b2f7d53544ca4c480b2f7911d5 Mon Sep 17 00:00:00 2001 From: Luke Campagnola Date: Tue, 6 Dec 2016 22:29:22 -0800 Subject: [PATCH 07/13] 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 --- pyqtgraph/functions.py | 56 +++++++++----- pyqtgraph/widgets/SpinBox.py | 143 +++++++++++++++++++++-------------- 2 files changed, 124 insertions(+), 75 deletions(-) diff --git a/pyqtgraph/functions.py b/pyqtgraph/functions.py index 6ec3932f..faa11820 100644 --- a/pyqtgraph/functions.py +++ b/pyqtgraph/functions.py @@ -34,8 +34,13 @@ Colors = { SI_PREFIXES = asUnicode('yzafpnµm 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[+-]?((\d+(\.\d*)?)|(\d*\.\d+))([eE][+-]?\d+)?)\s*((?P[u' + SI_PREFIXES + r']?)(?P\w.*))?$') +INT_REGEX = re.compile(r'(?P[+-]?\d+)\s*(?P[u' + SI_PREFIXES + r']?)(?P.*)$') + def siScale(x, minVal=1e-25, allowUnicode=True): """ 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 = " +/- " 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)) + + +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:: siEval("100 μV") # returns 0.0001 """ + val, siprefix, suffix = siParse(s, regex) + v = typ(val) + return siApply(val, siprefix) + - s = asUnicode(s) - 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) - v = float(m.groups()[0]) - p = m.groups()[6] - #if p not in SI_PREFIXES: - #raise Exception("Can't convert string '%s' to number--unknown prefix." % s) - if p == '': - n = 0 - elif p == 'u': - n = -2 +def siApply(val, siprefix): + """ + """ + n = SI_PREFIX_EXPONENTS[siprefix] if siprefix != '' else 0 + if n > 0: + return val * 10**n + elif n < 0: + # this case makes it possible to use Decimal objects here + return val / 10**-n else: - n = SI_PREFIXES.index(p) - 8 - return v * 1000**n + return val class Color(QtGui.QColor): diff --git a/pyqtgraph/widgets/SpinBox.py b/pyqtgraph/widgets/SpinBox.py index 86cbba93..aafdb7d5 100644 --- a/pyqtgraph/widgets/SpinBox.py +++ b/pyqtgraph/widgets/SpinBox.py @@ -6,10 +6,13 @@ from ..SignalProxy import SignalProxy from .. import functions as fn from math import log from decimal import Decimal as D ## Use decimal to avoid accumulating floating-point errors -from decimal import * +import decimal import weakref + __all__ = ['SpinBox'] + + class SpinBox(QtGui.QAbstractSpinBox): """ **Bases:** QtGui.QAbstractSpinBox @@ -42,7 +45,7 @@ class SpinBox(QtGui.QAbstractSpinBox): valueChanged = QtCore.Signal(object) # (value) for compatibility with QSpinBox sigValueChanged = QtCore.Signal(object) # (self) sigValueChanging = QtCore.Signal(object, object) # (self, value) sent immediately; no delay. - + def __init__(self, parent=None, value=0.0, **kwargs): """ ============== ======================================================================== @@ -60,6 +63,8 @@ class SpinBox(QtGui.QAbstractSpinBox): self.setMinimumWidth(0) self.setMaximumHeight(20) self.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Preferred) + self.errorBox = ErrorBox(self.lineEdit()) + self.opts = { 'bounds': [None, None], @@ -80,7 +85,7 @@ class SpinBox(QtGui.QAbstractSpinBox): 'delayUntilEditFinished': True, ## do not send signals until text editing has finished - 'decimals': 3, + 'decimals': 6, 'format': asUnicode("{scaledValue:.{decimals}g}{suffixGap}{siPrefix}{suffix}"), @@ -97,7 +102,7 @@ class SpinBox(QtGui.QAbstractSpinBox): self.editingFinished.connect(self.editingFinishedEvent) self.proxy = SignalProxy(self.sigValueChanging, slot=self.delayedChange, delay=self.opts['delay']) - + def event(self, ev): ret = QtGui.QAbstractSpinBox.event(self, ev) if ev.type() == QtCore.QEvent.KeyPress and ev.key() == QtCore.Qt.Key_Return: @@ -133,7 +138,7 @@ class SpinBox(QtGui.QAbstractSpinBox): False. 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 - 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 done with ``str.format()`` and makes use of several arguments: @@ -301,7 +306,9 @@ class SpinBox(QtGui.QAbstractSpinBox): if self.opts['int']: value = int(value) - value = D(asUnicode(value)) + if not isinstance(value, D): + value = D(asUnicode(value)) + if value == self.val: return prev = self.val @@ -315,7 +322,6 @@ class SpinBox(QtGui.QAbstractSpinBox): self.emitChanged() return value - def emitChanged(self): self.lastValEmitted = self.val @@ -335,13 +341,9 @@ class SpinBox(QtGui.QAbstractSpinBox): def sizeHint(self): return QtCore.QSize(120, 0) - def stepEnabled(self): return self.StepUpEnabled | self.StepDownEnabled - #def fixup(self, *args): - #print "fixup:", args - def stepBy(self, n): n = D(int(n)) ## n must be integral number of steps. 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] #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. - 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 if 'minStep' in self.opts: 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']: val = D(0) self.setValue(val, delaySignal=True) ## note all steps (arrow buttons, wheel, up/down keys..) emit delayed signals only. - def valueInRange(self, value): bounds = self.opts['bounds'] @@ -403,12 +404,12 @@ class SpinBox(QtGui.QAbstractSpinBox): def formatText(self, prev=None): # 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'] # format the string - val = float(self.val) - if self.opts['siPrefix']: + val = self.value() + if self.opts['siPrefix'] is True and len(self.opts['suffix']) > 0: # SI prefix was requested, so scale the value accordingly 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} 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['suffixGap'] = '' if (parts['suffix'] == '' and parts['siPrefix'] == '') else ' ' - format = self.opts['format'] - return format.format(**parts) + return self.opts['format'].format(**parts) def validate(self, strn, pos): if self.skipValidate: ret = QtGui.QValidator.Acceptable else: try: - ## first make sure we didn't mess with the suffix - suff = self.opts.get('suffix', '') - if len(suff) > 0 and asUnicode(strn)[-len(suff):] != suff: - ret = QtGui.QValidator.Invalid - - ## next see if we actually have an interpretable value + val = self.interpret() + if val is False: + ret = QtGui.QValidator.Intermediate else: - val = self.interpret() - if val is False: - ret = QtGui.QValidator.Intermediate + if self.valueInRange(val): + if not self.opts['delayUntilEditFinished']: + self.setValue(val, update=False) + ret = QtGui.QValidator.Acceptable else: - if self.valueInRange(val): - if not self.opts['delayUntilEditFinished']: - self.setValue(val, update=False) - ret = QtGui.QValidator.Acceptable - else: - ret = QtGui.QValidator.Intermediate + ret = QtGui.QValidator.Intermediate except: + import sys + sys.excepthook(*sys.exc_info()) ret = QtGui.QValidator.Intermediate ## draw / clear border @@ -462,40 +457,46 @@ class SpinBox(QtGui.QAbstractSpinBox): ## since the text will be forced to its previous state anyway self.update() + self.errorBox.setVisible(not self.textValid) + ## support 2 different pyqt APIs. Bleh. if hasattr(QtCore, 'QString'): return (ret, pos) else: return (ret, strn, pos) - def paintEvent(self, ev): - QtGui.QAbstractSpinBox.paintEvent(self, ev) - - ## draw red border if text is invalid - if not self.textValid: - 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 fixup(self, strn): + # fixup is called when the spinbox loses focus with an invalid or intermediate string + self.updateText() + strn.clear() + strn.append(self.lineEdit().text()) 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() - suf = self.opts['suffix'] - if len(suf) > 0: - if strn[-len(suf):] != suf: - return False - #raise Exception("Units are invalid.") - strn = strn[:-len(suf)] + + # tokenize into numerical value, si prefix, and suffix try: - val = fn.siEval(strn) - except: - #sys.excepthook(*sys.exc_info()) - #print "invalid" + val, siprefix, suffix = fn.siParse(strn) + except Exception: 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 def editingFinishedEvent(self): @@ -506,7 +507,7 @@ class SpinBox(QtGui.QAbstractSpinBox): return try: val = self.interpret() - except: + except Exception: return if val is False: @@ -516,3 +517,29 @@ class SpinBox(QtGui.QAbstractSpinBox): #print "no value change:", val, self.val return 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() From 65e9052580fe93130738549dc374621efdc126fa Mon Sep 17 00:00:00 2001 From: Luke Campagnola Date: Tue, 6 Dec 2016 22:56:55 -0800 Subject: [PATCH 08/13] Fix parametertree sending bad options to spinbox --- pyqtgraph/parametertree/parameterTypes.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/pyqtgraph/parametertree/parameterTypes.py b/pyqtgraph/parametertree/parameterTypes.py index 31717481..3c41ffe6 100644 --- a/pyqtgraph/parametertree/parameterTypes.py +++ b/pyqtgraph/parametertree/parameterTypes.py @@ -279,9 +279,14 @@ class WidgetParameterItem(ParameterItem): ## If widget is a SpinBox, pass options straight through if isinstance(self.widget, SpinBox): + # send only options supported by spinbox + sbOpts = {} if 'units' in opts and 'suffix' not in opts: - opts['suffix'] = opts['units'] - self.widget.setOpts(**opts) + sbOpts['suffix'] = opts['units'] + for k,v in opts.items(): + if k in self.widget.opts: + sbOpts[k] = v + self.widget.setOpts(**sbOpts) self.updateDisplayLabel() From 982343627333b348d883099e34d4f782b7ab8df6 Mon Sep 17 00:00:00 2001 From: Luke Campagnola Date: Wed, 7 Dec 2016 17:34:12 -0800 Subject: [PATCH 09/13] Add spinbox option to limit height based on font size --- pyqtgraph/widgets/SpinBox.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/pyqtgraph/widgets/SpinBox.py b/pyqtgraph/widgets/SpinBox.py index aafdb7d5..df7acfcd 100644 --- a/pyqtgraph/widgets/SpinBox.py +++ b/pyqtgraph/widgets/SpinBox.py @@ -61,7 +61,8 @@ class SpinBox(QtGui.QAbstractSpinBox): self.lastText = '' self.textValid = True ## If false, we draw a red border self.setMinimumWidth(0) - self.setMaximumHeight(20) + self._lastFontHeight = None + self.setSizePolicy(QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Preferred) self.errorBox = ErrorBox(self.lineEdit()) @@ -88,7 +89,8 @@ class SpinBox(QtGui.QAbstractSpinBox): 'decimals': 6, 'format': asUnicode("{scaledValue:.{decimals}g}{suffixGap}{siPrefix}{suffix}"), - + + 'compactHeight': True, # manually remove extra margin outside of text } self.decOpts = ['step', 'minStep'] @@ -99,6 +101,7 @@ class SpinBox(QtGui.QAbstractSpinBox): self.setCorrectionMode(self.CorrectToPreviousValue) self.setKeyboardTracking(False) self.setOpts(**kwargs) + self._updateHeight() self.editingFinished.connect(self.editingFinishedEvent) self.proxy = SignalProxy(self.sigValueChanging, slot=self.delayedChange, delay=self.opts['delay']) @@ -149,6 +152,9 @@ class SpinBox(QtGui.QAbstractSpinBox): this feature has been disabled * *suffixGap* - a single space if a suffix is present, or an empty string otherwise. + compactHeight (bool) if True, then set the maximum height of the spinbox based on the + height of its font. This allows more compact packing on platforms with + excessive widget decoration. Default is True. ============== ======================================================================== """ #print opts @@ -518,6 +524,21 @@ class SpinBox(QtGui.QAbstractSpinBox): return self.setValue(val, delaySignal=False) ## allow text update so that values are reformatted pretty-like + def _updateHeight(self): + # SpinBox has very large margins on some platforms; this is a hack to remove those + # margins and allow more compact packing of controls. + if not self.opts['compactHeight']: + self.setMaximumHeight(1e6) + return + h = QtGui.QFontMetrics(self.font()).height() + if self._lastFontHeight != h: + self._lastFontHeight = h + self.setMaximumHeight(h) + + def paintEvent(self, ev): + self._updateHeight() + QtGui.QAbstractSpinBox.paintEvent(self, ev) + class ErrorBox(QtGui.QWidget): """Red outline to draw around lineedit when value is invalid. From f0e26d3add3b943a616b3e392023085214b41c19 Mon Sep 17 00:00:00 2001 From: Luke Campagnola Date: Wed, 7 Dec 2016 17:34:44 -0800 Subject: [PATCH 10/13] Limit lineedit height in parametertree to match spinbox style --- pyqtgraph/parametertree/parameterTypes.py | 1 + 1 file changed, 1 insertion(+) diff --git a/pyqtgraph/parametertree/parameterTypes.py b/pyqtgraph/parametertree/parameterTypes.py index 3c41ffe6..4c6a8486 100644 --- a/pyqtgraph/parametertree/parameterTypes.py +++ b/pyqtgraph/parametertree/parameterTypes.py @@ -122,6 +122,7 @@ class WidgetParameterItem(ParameterItem): self.hideWidget = False elif t == 'str': w = QtGui.QLineEdit() + w.setStyleSheet('border: 0px') w.sigChanged = w.editingFinished w.value = lambda: asUnicode(w.text()) w.setValue = lambda v: w.setText(asUnicode(v)) From e5a17edb4d329e2e0aecd34a93c7d71de3cb768e Mon Sep 17 00:00:00 2001 From: Luke Campagnola Date: Thu, 8 Dec 2016 10:12:45 -0800 Subject: [PATCH 11/13] Add spinbox 'regex' and 'evalFunc' options to complete user-formatting functionality --- examples/SpinBox.py | 9 +++- pyqtgraph/functions.py | 16 +++++-- pyqtgraph/widgets/SpinBox.py | 63 +++++++++++++++---------- pyqtgraph/widgets/tests/test_spinbox.py | 7 ++- 4 files changed, 63 insertions(+), 32 deletions(-) diff --git a/examples/SpinBox.py b/examples/SpinBox.py index 2fa9b161..84c82332 100644 --- a/examples/SpinBox.py +++ b/examples/SpinBox.py @@ -13,7 +13,7 @@ import initExample ## Add path to library (just for examples; you do not need th import pyqtgraph as pg from pyqtgraph.Qt import QtCore, QtGui import numpy as np - +import ast app = QtGui.QApplication([]) @@ -31,6 +31,13 @@ spins = [ pg.SpinBox(value=1.0, suffix='V', siPrefix=True, dec=True, step=0.5, minStep=0.01)), ("Float with SI-prefixed units,
dec step=1.0, minStep=0.001", pg.SpinBox(value=1.0, suffix='V', siPrefix=True, dec=True, step=1.0, minStep=0.001)), + ("Float with custom formatting", + pg.SpinBox(value=23.07, format='${value:0.02f}', + regex='\$?(?P(-?\d+(\.\d+)?)|(-?\.\d+))$')), + ("Int with custom formatting", + pg.SpinBox(value=4567, step=1, int=True, bounds=[0,None], format='0x{value:X}', + regex='(0x)?(?P[0-9a-fA-F]+)$', + evalFunc=lambda s: ast.literal_eval('0x'+s))), ] diff --git a/pyqtgraph/functions.py b/pyqtgraph/functions.py index faa11820..1fd05946 100644 --- a/pyqtgraph/functions.py +++ b/pyqtgraph/functions.py @@ -37,8 +37,8 @@ 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[+-]?((\d+(\.\d*)?)|(\d*\.\d+))([eE][+-]?\d+)?)\s*((?P[u' + SI_PREFIXES + r']?)(?P\w.*))?$') -INT_REGEX = re.compile(r'(?P[+-]?\d+)\s*(?P[u' + SI_PREFIXES + r']?)(?P.*)$') +FLOAT_REGEX = re.compile(r'(?P[+-]?((\d+(\.\d*)?)|(\d*\.\d+))([eE][+-]?\d+)?)\s*((?P[u' + SI_PREFIXES + r']?)(?P\w.*))?$') +INT_REGEX = re.compile(r'(?P[+-]?\d+)\s*(?P[u' + SI_PREFIXES + r']?)(?P.*)$') def siScale(x, minVal=1e-25, allowUnicode=True): @@ -121,8 +121,16 @@ def siParse(s, regex=FLOAT_REGEX): m = regex.match(s) if m is None: raise ValueError('Cannot parse number "%s"' % s) - sip = m.group('siprefix') - suf = m.group('suffix') + try: + sip = m.group('siPrefix') + except IndexError: + sip = '' + + try: + suf = m.group('suffix') + except IndexError: + suf = '' + return m.group('number'), '' if sip is None else sip, '' if suf is None else suf diff --git a/pyqtgraph/widgets/SpinBox.py b/pyqtgraph/widgets/SpinBox.py index df7acfcd..8e81f06d 100644 --- a/pyqtgraph/widgets/SpinBox.py +++ b/pyqtgraph/widgets/SpinBox.py @@ -1,13 +1,14 @@ # -*- coding: utf-8 -*- -from ..Qt import QtGui, QtCore -from ..python2_3 import asUnicode -from ..SignalProxy import SignalProxy - -from .. import functions as fn from math import log from decimal import Decimal as D ## Use decimal to avoid accumulating floating-point errors import decimal import weakref +import re + +from ..Qt import QtGui, QtCore +from ..python2_3 import asUnicode, basestring +from ..SignalProxy import SignalProxy +from .. import functions as fn __all__ = ['SpinBox'] @@ -89,7 +90,9 @@ class SpinBox(QtGui.QAbstractSpinBox): 'decimals': 6, 'format': asUnicode("{scaledValue:.{decimals}g}{suffixGap}{siPrefix}{suffix}"), - + 'regex': fn.FLOAT_REGEX, + 'evalFunc': D, + 'compactHeight': True, # manually remove extra margin outside of text } @@ -152,28 +155,41 @@ class SpinBox(QtGui.QAbstractSpinBox): this feature has been disabled * *suffixGap* - a single space if a suffix is present, or an empty string otherwise. + regex (str or RegexObject) Regular expression used to parse the spinbox text. + May contain the following group names: + + * *number* - matches the numerical portion of the string (mandatory) + * *siPrefix* - matches the SI prefix string + * *suffix* - matches the suffix string + + Default is defined in ``pyqtgraph.functions.FLOAT_REGEX``. + evalFunc (callable) Fucntion that converts a numerical string to a number, + preferrably a Decimal instance. This function handles only the numerical + of the text; it does not have access to the suffix or SI prefix. compactHeight (bool) if True, then set the maximum height of the spinbox based on the height of its font. This allows more compact packing on platforms with excessive widget decoration. Default is True. ============== ======================================================================== """ #print opts - for k in opts: + for k,v in opts.items(): if k == 'bounds': - self.setMinimum(opts[k][0], update=False) - self.setMaximum(opts[k][1], update=False) + self.setMinimum(v[0], update=False) + self.setMaximum(v[1], update=False) elif k == 'min': - self.setMinimum(opts[k], update=False) + self.setMinimum(v, update=False) elif k == 'max': - self.setMaximum(opts[k], update=False) + self.setMaximum(v, update=False) elif k in ['step', 'minStep']: - self.opts[k] = D(asUnicode(opts[k])) + self.opts[k] = D(asUnicode(v)) elif k == 'value': pass ## don't set value until bounds have been set elif k == 'format': - self.opts[k] = asUnicode(opts[k]) + self.opts[k] = asUnicode(v) + elif k == 'regex' and isinstance(v, basestring): + self.opts[k] = re.compile(v) elif k in self.opts: - self.opts[k] = opts[k] + self.opts[k] = v else: raise TypeError("Invalid keyword argument '%s'." % k) if 'value' in opts: @@ -266,14 +282,11 @@ class SpinBox(QtGui.QAbstractSpinBox): """ le = self.lineEdit() text = asUnicode(le.text()) - if self.opts['suffix'] == '': - le.setSelection(0, len(text)) - else: - try: - index = text.index(' ') - except ValueError: - return - le.setSelection(0, index) + m = self.opts['regex'].match(text) + if m is None: + return + s,e = m.start('number'), m.end('number') + le.setSelection(s, e-s) def focusInEvent(self, ev): super(SpinBox, self).focusInEvent(ev) @@ -483,7 +496,7 @@ class SpinBox(QtGui.QAbstractSpinBox): # tokenize into numerical value, si prefix, and suffix try: - val, siprefix, suffix = fn.siParse(strn) + val, siprefix, suffix = fn.siParse(strn, self.opts['regex']) except Exception: return False @@ -492,7 +505,7 @@ class SpinBox(QtGui.QAbstractSpinBox): return False # generate value - val = D(val) + val = self.opts['evalFunc'](val) if self.opts['int']: val = int(fn.siApply(val, siprefix)) else: @@ -504,7 +517,7 @@ class SpinBox(QtGui.QAbstractSpinBox): return False return val - + def editingFinishedEvent(self): """Edit has finished; set value.""" #print "Edit finished." diff --git a/pyqtgraph/widgets/tests/test_spinbox.py b/pyqtgraph/widgets/tests/test_spinbox.py index dcf15cb3..b9fbaeb2 100644 --- a/pyqtgraph/widgets/tests/test_spinbox.py +++ b/pyqtgraph/widgets/tests/test_spinbox.py @@ -1,6 +1,7 @@ import pyqtgraph as pg pg.mkQApp() + def test_spinbox(): sb = pg.SpinBox() assert sb.opts['decimals'] == 3 @@ -13,8 +14,10 @@ def test_spinbox(): (100, '100', dict()), (1000000, '1e+06', dict()), (1000, '1e+03', dict(decimals=2)), - (1000000, '1000000', dict(int=True)), - (12345678955, '12345678955', dict(int=True)), + (1000000, '1e+06', dict(int=True, decimals=6)), + (12345678955, '12345678955', dict(int=True, decimals=100)), + (1.45e-9, '1.45e-9 A', dict(int=False, decimals=6, suffix='A', siPrefix=False)), + (1.45e-9, '1.45 nA', dict(int=False, decimals=6, suffix='A', siPrefix=True)), ] for (value, text, opts) in conds: From cd7683b61db5171d71487832ab5886509a945ac5 Mon Sep 17 00:00:00 2001 From: Luke Campagnola Date: Thu, 8 Dec 2016 10:14:18 -0800 Subject: [PATCH 12/13] Fix unit tests --- pyqtgraph/widgets/tests/test_spinbox.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/pyqtgraph/widgets/tests/test_spinbox.py b/pyqtgraph/widgets/tests/test_spinbox.py index b9fbaeb2..b3934d78 100644 --- a/pyqtgraph/widgets/tests/test_spinbox.py +++ b/pyqtgraph/widgets/tests/test_spinbox.py @@ -4,7 +4,7 @@ pg.mkQApp() def test_spinbox(): sb = pg.SpinBox() - assert sb.opts['decimals'] == 3 + assert sb.opts['decimals'] == 6 assert sb.opts['int'] is False # table of test conditions: @@ -16,7 +16,7 @@ def test_spinbox(): (1000, '1e+03', dict(decimals=2)), (1000000, '1e+06', dict(int=True, decimals=6)), (12345678955, '12345678955', dict(int=True, decimals=100)), - (1.45e-9, '1.45e-9 A', dict(int=False, decimals=6, suffix='A', siPrefix=False)), + (1.45e-9, '1.45e-09 A', dict(int=False, decimals=6, suffix='A', siPrefix=False)), (1.45e-9, '1.45 nA', dict(int=False, decimals=6, suffix='A', siPrefix=True)), ] From de0ee32a2062e8bf342d26600a3492c7b6e8b29c Mon Sep 17 00:00:00 2001 From: Luke Campagnola Date: Fri, 9 Dec 2016 10:20:19 -0800 Subject: [PATCH 13/13] minor doc / test edits --- pyqtgraph/widgets/SpinBox.py | 12 +++++++----- pyqtgraph/widgets/tests/test_spinbox.py | 3 ++- 2 files changed, 9 insertions(+), 6 deletions(-) diff --git a/pyqtgraph/widgets/SpinBox.py b/pyqtgraph/widgets/SpinBox.py index a71bf660..b8066cd7 100644 --- a/pyqtgraph/widgets/SpinBox.py +++ b/pyqtgraph/widgets/SpinBox.py @@ -18,12 +18,14 @@ class SpinBox(QtGui.QAbstractSpinBox): """ **Bases:** QtGui.QAbstractSpinBox - QSpinBox widget on steroids. Allows selection of numerical value, with extra features: + Extension of QSpinBox widget for selection of a numerical value. + Adds many extra features: - - SI prefix notation (eg, automatically display "300 mV" instead of "0.003 V") - - Float values with linear and decimal stepping (1-9, 10-90, 100-900, etc.) - - Option for unbounded values - - Delayed signals (allows multiple rapid changes with only one change signal) + * SI prefix notation (eg, automatically display "300 mV" instead of "0.003 V") + * Float values with linear and decimal stepping (1-9, 10-90, 100-900, etc.) + * Option for unbounded values + * Delayed signals (allows multiple rapid changes with only one change signal) + * Customizable text formatting ============================= ============================================== **Signals:** diff --git a/pyqtgraph/widgets/tests/test_spinbox.py b/pyqtgraph/widgets/tests/test_spinbox.py index b3934d78..10087881 100644 --- a/pyqtgraph/widgets/tests/test_spinbox.py +++ b/pyqtgraph/widgets/tests/test_spinbox.py @@ -2,7 +2,7 @@ import pyqtgraph as pg pg.mkQApp() -def test_spinbox(): +def test_spinbox_formatting(): sb = pg.SpinBox() assert sb.opts['decimals'] == 6 assert sb.opts['int'] is False @@ -18,6 +18,7 @@ def test_spinbox(): (12345678955, '12345678955', dict(int=True, decimals=100)), (1.45e-9, '1.45e-09 A', dict(int=False, decimals=6, suffix='A', siPrefix=False)), (1.45e-9, '1.45 nA', dict(int=False, decimals=6, suffix='A', siPrefix=True)), + (-2500.3427, '$-2500.34', dict(int=False, format='${value:0.02f}')), ] for (value, text, opts) in conds: