Better parameter tree testing (#1953)

* Allows values to be numpy arrays

* Bugfix: Slider now works when limits didn't change during `optsChanged`

* Improved testing + layout of param tree example

* Also fix numpy-like values in list `setValue`

* use proper hex formatting for  value

* Fix code warnings

* Avoids use of configfile in parametertree

* Avoid shadowing variable names

* Add explanatory comment to `makeAllParamTypes`

* Allow string options to be 'unset' in file, etc. parameters example

* Bugfix: unintunitive option unsetting for file window title

* don't use lambda in signal connect

* Remove unused import
This commit is contained in:
ntjess 2021-09-24 00:06:00 -04:00 committed by GitHub
parent e9d3b6ddd2
commit 8d3e6cbd22
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
8 changed files with 329 additions and 82 deletions

View File

@ -0,0 +1,93 @@
from pyqtgraph.parametertree import Parameter
from pyqtgraph.parametertree.Parameter import PARAM_TYPES
from pyqtgraph.parametertree.parameterTypes import GroupParameter
from ._paramtreecfg import cfg
_encounteredTypes = {'group'}
def makeChild(chType, cfgDict):
_encounteredTypes.add(chType)
param = Parameter.create(name='widget', type=chType)
param.setDefault(param.value())
def setOpt(_param, _val):
# Treat blank strings as "None" to allow 'unsetting' that option
if isinstance(_val, str) and _val == '':
_val = None
param.setOpts(**{_param.name(): _val})
optsChildren = []
metaChildren = []
for optName, optVals in cfgDict.items():
child = Parameter.create(name=optName, **optVals)
if ' ' in optName:
metaChildren.append(child)
else:
optsChildren.append(child)
child.sigValueChanged.connect(setOpt)
# Poplate initial options
for p in optsChildren:
setOpt(p, p.value())
grp = Parameter.create(name=f'Sample {chType.title()}', type='group', children=metaChildren + [param] + optsChildren)
grp.setOpts(expanded=False)
return grp
def makeMetaChild(name, cfgDict):
children = []
for chName, chOpts in cfgDict.items():
if not isinstance(chOpts, dict):
ch = Parameter.create(name=chName, type=chName, value=chOpts)
else:
ch = Parameter.create(name=chName, **chOpts)
_encounteredTypes.add(ch.type())
children.append(ch)
param = Parameter.create(name=name, type='group', children=children)
param.setOpts(expanded=False)
return param
def makeAllParamTypes():
children = []
for name, paramCfg in cfg.items():
if ' ' in name:
children.append(makeMetaChild(name, paramCfg))
else:
children.append(makeChild(name, paramCfg))
params = Parameter.create(name='Example Parameters', type='group', children=children)
# Slider needs minor tweak
sliderGrp = params.child('Sample Slider')
slider = sliderGrp.child('widget')
slider.setOpts(limits=[0, 100])
# Also minor tweak to meta opts
def setOpt(_param, _val):
infoChild.setOpts(**{_param.name(): _val})
meta = params.child('Applies to All Types')
infoChild = meta.child('Extra Information')
for child in meta.children()[1:]:
child.sigValueChanged.connect(setOpt)
def onChange(_param, _val):
if _val == 'Use span':
span = slider.opts.pop('span', None)
slider.setOpts(span=span)
else:
limits = slider.opts.pop('limits', None)
slider.setOpts(limits=limits)
sliderGrp.child('How to Set').sigValueChanged.connect(onChange)
def activate(action):
for ch in params:
if isinstance(ch, GroupParameter):
ch.setOpts(expanded=action.name() == 'Expand All')
for name in 'Collapse', 'Expand':
btn = Parameter.create(name=f'{name} All', type='action')
btn.sigActivated.connect(activate)
params.insertChild(0, btn)
missing = set(PARAM_TYPES).difference(_encounteredTypes)
if missing:
raise RuntimeError(f'{missing} parameters are not represented')
return params

187
examples/_paramtreecfg.py Normal file
View File

@ -0,0 +1,187 @@
import numpy as np
from pyqtgraph.Qt import QtWidgets
from pyqtgraph.parametertree.parameterTypes import QtEnumParameter as enum
dlg = QtWidgets.QFileDialog
cfg = {
'list': {
'limits': {
'type': 'checklist',
'limits': ['a', 'b', 'c']
}
},
'file': {
'acceptMode': {
'type': 'list',
'limits': list(enum(dlg.AcceptMode, dlg).enumMap)
},
'fileMode': {
'type': 'list',
'limits': list(enum(dlg.FileMode, dlg).enumMap)
},
'viewMode': {
'type': 'list',
'limits': list(enum(dlg.ViewMode, dlg).enumMap)
},
'dialogLabel': {
'type': 'list',
'limits': list(enum(dlg.DialogLabel, dlg).enumMap)
},
'relativeTo': {
'type': 'str',
'value': None
},
'directory': {
'type': 'str',
'value': None
},
'windowTitle': {
'type': 'str',
'value': None
},
'nameFilter': {
'type': 'str',
'value': None
}
},
'float': {
'Float Information': {
'type': 'str',
'readonly': True,
'value': 'Note that all options except "finite" also apply to "int" parameters',
},
'step': {
'type': 'float',
'limits': [0, None],
'value': 1,
},
'limits': {
'type': 'list',
'limits': {'[0, None]': [0, None], '[1, 5]': [1, 5]},
},
'suffix': {
'type': 'list',
'limits': ['Hz', 's', 'm'],
},
'siPrefix': {
'type': 'bool',
'value': True
},
'finite': {
'type': 'bool',
'value': True,
},
'dec': {
'type': 'bool',
'value': False,
},
'minStep': {
'type': 'float',
'value': 1.0e-12,
},
},
'checklist': {
'limits': {
'type': 'checklist',
'limits': ['one', 'two', 'three', 'four'],
},
'exclusive': {
'type': 'bool',
'value': False,
}
},
'pen': {
'Pen Information': {
'type': 'str',
'value': 'Click the button to see options',
'readonly': True,
},
},
'slider': {
'step': {
'type': 'float',
'limits': [0, None],
'value': 1, },
'format': {
'type': 'str',
'value': '{0:>3}',
},
'precision': {
'type': 'int',
'value': 2,
'limits': [1, None],
},
'span': {
'type': 'list',
'limits': {'linspace(-pi, pi)': np.linspace(-np.pi, np.pi), 'arange(10)**2': np.arange(10) ** 2},
},
'How to Set': {
'type': 'list',
'limits': ['Use span', 'Use step + limits'],
}
},
'calendar': {
'format': {
'type': 'str',
'value': 'MM DD',
}
},
'Applies to All Types': {
'Extra Information': {
'type': 'text',
'value': 'These apply to all parameters. Watch how this text box is altered by any setting you change.',
'default': 'These apply to all parameters. Watch how this text box is altered by any setting you change.',
'readonly': True,
},
'readonly': {
'type': 'bool',
'value': True,
},
'removable': {
'type': 'bool',
'tip': 'Adds a context menu option to remove this parameter',
'value': False,
},
'visible': {
'type': 'bool',
'value': True,
},
'disabled': {
'type': 'bool',
'value': False,
},
'title': {
'type': 'str',
'value': 'Meta Options',
},
'default': {
'tip': 'The default value that gets set when clicking the arrow in the right column',
'type': 'str',
},
'expanded': {
'type': 'bool',
'value': True,
},
},
'No Extra Options': {
'text': 'Unlike the other parameters shown, these don\'t have extra settable options.\n' \
+ 'Note: "int" *does* have the same options as float, mentioned above',
'int': 10,
'str': 'Hi, world!',
'color': '#fff',
'bool': False,
'colormap': None,
'progress': 50,
'action': None,
'font': 'Inter',
}
}

View File

@ -1,4 +1,3 @@
# -*- coding: utf-8 -*-
"""
This example demonstrates the use of pyqtgraph's parametertree system. This provides
a simple way to generate user interfaces that control sets of parameters. The example
@ -6,17 +5,20 @@ demonstrates a variety of different parameter types (int, float, list, etc.)
as well as some customized parameter types
"""
import os
import initExample ## Add path to library (just for examples; you do not need this)
import pyqtgraph as pg
from pyqtgraph.Qt import QtCore, QtGui
# `makeAllParamTypes` creates several parameters from a dictionary of config specs.
# This contains information about the options for each parameter so they can be directly
# inserted into the example parameter tree. To create your own parameters, simply follow
# the guidelines demonstrated by other parameters created here.
from examples._buildParamTypes import makeAllParamTypes
from pyqtgraph.Qt import QtGui
app = pg.mkQApp("Parameter Tree Example")
import pyqtgraph.parametertree.parameterTypes as pTypes
from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, registerParameterType
from pyqtgraph.parametertree import Parameter, ParameterTree
## test subclassing parameters
@ -62,39 +64,7 @@ class ScalableGroup(pTypes.GroupParameter):
params = [
{'name': 'Basic parameter data types', 'type': 'group', 'children': [
{'name': 'Integer', 'type': 'int', 'value': 10},
{'name': 'Float', 'type': 'float', 'value': 10.5, 'step': 0.1, 'finite': False},
{'name': 'String', 'type': 'str', 'value': "hi", 'tip': 'Well hello'},
{'name': 'Checklist', 'type': 'checklist', 'limits': [1,2,3], 'value': 2},
{'name': 'List', 'type': 'list', 'limits': [1,2,3], 'value': 2},
{'name': 'Named List', 'type': 'list', 'limits': {"one": 1, "two": "twosies", "three": [3,3,3]}, 'value': 2},
{'name': 'Boolean', 'type': 'bool', 'value': True, 'tip': "This is a checkbox"},
{'name': 'Color', 'type': 'color', 'value': "#FF0", 'tip': "This is a color button"},
{'name': 'Gradient', 'type': 'colormap'},
{'name': 'Subgroup', 'type': 'group', 'children': [
{'name': 'Sub-param 1', 'type': 'int', 'value': 10},
{'name': 'Sub-param 2', 'type': 'float', 'value': 1.2e6},
]},
{'name': 'Text Parameter', 'type': 'text', 'value': 'Some text...'},
{'name': 'Action Parameter', 'type': 'action', 'tip': 'Click me'},
]},
{'name': 'Custom Parameter Options', 'type': 'group', 'children': [
{'name': 'Pen', 'type': 'pen', 'value': pg.mkPen(color=(255,0,0), width=2)},
{'name': 'Progress bar', 'type': 'progress', 'value':50, 'limits':(0,100)},
{'name': 'Slider', 'type': 'slider', 'value':50, 'limits':(0,100)},
{'name': 'Font', 'type': 'font', 'value':QtGui.QFont("Inter")},
{'name': 'Calendar', 'type': 'calendar', 'value':QtCore.QDate.currentDate().addMonths(1)},
{'name': 'Open python file', 'type': 'file', 'fileMode': 'ExistingFile', 'nameFilter': 'Python file (*.py);;',
'value': 'parametertree.py', 'relativeTo': os.getcwd(), 'options': ['DontResolveSymlinks']}
]},
{'name': 'Numerical Parameter Options', 'type': 'group', 'children': [
{'name': 'Units + SI prefix', 'type': 'float', 'value': 1.2e-6, 'step': 1e-6, 'siPrefix': True, 'suffix': 'V'},
{'name': 'Limits (min=7;max=15)', 'type': 'int', 'value': 11, 'limits': (7, 15), 'default': -6},
{'name': 'Int suffix', 'type': 'int', 'value': 9, 'suffix': 'V'},
{'name': 'DEC stepping', 'type': 'float', 'value': 1.2e6, 'dec': True, 'step': 1, 'minStep': 1.0e-12, 'siPrefix': True, 'suffix': 'Hz'},
]},
makeAllParamTypes(),
{'name': 'Save/Restore functionality', 'type': 'group', 'children': [
{'name': 'Save State', 'type': 'action'},
{'name': 'Restore State', 'type': 'action', 'children': [
@ -102,12 +72,6 @@ params = [
{'name': 'Remove extra items', 'type': 'bool', 'value': True},
]},
]},
{'name': 'Extra Parameter Options', 'type': 'group', 'children': [
{'name': 'Read-only', 'type': 'float', 'value': 1.2e6, 'siPrefix': True, 'suffix': 'Hz', 'readonly': True},
{'name': 'Disabled', 'type': 'float', 'value': 1.2e6, 'siPrefix': True, 'suffix': 'Hz', 'enabled': False},
{'name': 'Renamable', 'type': 'float', 'value': 1.2e6, 'siPrefix': True, 'suffix': 'Hz', 'renamable': True},
{'name': 'Removable', 'type': 'float', 'value': 1.2e6, 'siPrefix': True, 'suffix': 'Hz', 'removable': True},
]},
{'name': 'Custom context menu', 'type': 'group', 'children': [
{'name': 'List contextMenu', 'type': 'float', 'value': 0, 'context': [
'menu1',

View File

@ -43,7 +43,7 @@ def writeConfigFile(data, fname):
fd.write(s)
def readConfigFile(fname):
def readConfigFile(fname, **scope):
#cwd = os.getcwd()
global GLOBAL_PATH
if GLOBAL_PATH is not None:
@ -52,6 +52,21 @@ def readConfigFile(fname):
fname = fname2
GLOBAL_PATH = os.path.dirname(os.path.abspath(fname))
local = {**scope, **units.allUnits}
local['OrderedDict'] = OrderedDict
local['readConfigFile'] = readConfigFile
local['Point'] = Point
local['QtCore'] = QtCore
local['ColorMap'] = ColorMap
local['datetime'] = datetime
# Needed for reconstructing numpy arrays
local['array'] = numpy.array
for dtype in ['int8', 'uint8',
'int16', 'uint16', 'float16',
'int32', 'uint32', 'float32',
'int64', 'uint64', 'float64']:
local[dtype] = getattr(numpy, dtype)
try:
#os.chdir(newDir) ## bad.
@ -59,7 +74,7 @@ def readConfigFile(fname):
s = fd.read()
s = s.replace("\r\n", "\n")
s = s.replace("\r", "\n")
data = parseString(s)[1]
data = parseString(s, **local)[1]
except ParseError:
sys.exc_info()[1].fileName = fname
raise
@ -93,7 +108,7 @@ def genString(data, indent=''):
s += indent + sk + ': ' + repr(data[k]).replace("\n", "\\\n") + '\n'
return s
def parseString(lines, start=0):
def parseString(lines, start=0, **scope):
data = OrderedDict()
if isinstance(lines, str):
@ -135,33 +150,19 @@ def parseString(lines, start=0):
v = v.strip()
## set up local variables to use for eval
local = units.allUnits.copy()
local['OrderedDict'] = OrderedDict
local['readConfigFile'] = readConfigFile
local['Point'] = Point
local['QtCore'] = QtCore
local['ColorMap'] = ColorMap
local['datetime'] = datetime
# Needed for reconstructing numpy arrays
local['array'] = numpy.array
for dtype in ['int8', 'uint8',
'int16', 'uint16', 'float16',
'int32', 'uint32', 'float32',
'int64', 'uint64', 'float64']:
local[dtype] = getattr(numpy, dtype)
if len(k) < 1:
raise ParseError('Missing name preceding colon', ln+1, l)
if k[0] == '(' and k[-1] == ')': ## If the key looks like a tuple, try evaluating it.
try:
k1 = eval(k, local)
k1 = eval(k, scope)
if type(k1) is tuple:
k = k1
except:
# If tuple conversion fails, keep the string
pass
if re.search(r'\S', v) and v[0] != '#': ## eval the value
try:
val = eval(v, local)
val = eval(v, scope)
except:
ex = sys.exc_info()[1]
raise ParseError("Error evaluating expression '%s': [%s: %s]" % (v, ex.__class__.__name__, str(ex)), (ln+1), l)
@ -171,7 +172,7 @@ def parseString(lines, start=0):
val = {}
else:
#print "Going deeper..", ln+1
(ln, val) = parseString(lines, start=ln+1)
(ln, val) = parseString(lines, start=ln+1, **scope)
data[k] = val
#print k, repr(val)
except ParseError:

View File

@ -516,7 +516,7 @@ class Parameter(QtCore.QObject):
self.setLimits(opts[k])
elif k == 'default':
self.setDefault(opts[k])
elif k not in self.opts or self.opts[k] != opts[k]:
elif k not in self.opts or not fn.eq(self.opts[k], opts[k]):
self.opts[k] = opts[k]
changed[k] = opts[k]

View File

@ -145,7 +145,8 @@ class FileParameterItem(StrParameterItem):
startDir = os.path.dirname(startDir)
if os.path.exists(startDir):
opts['directory'] = startDir
opts.setdefault('windowTitle', self.param.title())
if opts.get('windowTitle') is None:
opts['windowTitle'] = self.param.title()
fname = popupFilePicker(None, **opts)
if not fname:

View File

@ -4,6 +4,7 @@ from collections import OrderedDict
from .basetypes import WidgetParameterItem
from .. import Parameter
from ...Qt import QtWidgets
from ... import functions as fn
class ListParameterItem(WidgetParameterItem):
@ -34,10 +35,12 @@ class ListParameterItem(WidgetParameterItem):
def setValue(self, val):
self.targetValue = val
if val not in self.reverse[0]:
match = [fn.eq(val, limVal) for limVal in self.reverse[0]]
if not any(match):
self.widget.setCurrentIndex(0)
else:
key = self.reverse[1][self.reverse[0].index(val)]
idx = match.index(True)
key = self.reverse[1][idx]
ind = self.widget.findText(key)
self.widget.setCurrentIndex(ind)
@ -104,7 +107,9 @@ class ListParameter(Parameter):
self.forward, self.reverse = self.mapping(limits)
Parameter.setLimits(self, limits)
if len(self.reverse[0]) > 0 and self.value() not in self.reverse[0]:
# 'value in limits' expression will break when reverse contains numpy array
curVal = self.value()
if len(self.reverse[0]) > 0 and not any(fn.eq(curVal, limVal) for limVal in self.reverse[0]):
self.setValue(self.reverse[0][0])
@staticmethod
@ -112,16 +117,11 @@ class ListParameter(Parameter):
# Return forward and reverse mapping objects given a limit specification
forward = OrderedDict() ## {name: value, ...}
reverse = ([], []) ## ([value, ...], [name, ...])
if isinstance(limits, dict):
for k, v in limits.items():
forward[k] = v
reverse[0].append(v)
reverse[1].append(k)
else:
for v in limits:
n = str(v)
forward[n] = v
reverse[0].append(v)
reverse[1].append(n)
if not isinstance(limits, dict):
limits = {str(l): l for l in limits}
for k, v in limits.items():
forward[k] = v
reverse[0].append(v)
reverse[1].append(k)
return forward, reverse

View File

@ -33,6 +33,7 @@ class SliderParameterItem(WidgetParameterItem):
def makeWidget(self):
param = self.param
opts = param.opts
opts.setdefault('limits', [0, 0])
self._suffix = opts.get('suffix')
self.slider = QtWidgets.QSlider()
@ -94,7 +95,7 @@ class SliderParameterItem(WidgetParameterItem):
span = opts.get('span', None)
if span is None:
step = opts.get('step', 1)
start, stop = opts['limits']
start, stop = opts.get('limits', param.opts['limits'])
# Add a bit to 'stop' since python slicing excludes the last value
span = np.arange(start, stop + step, step)
precision = opts.get('precision', 2)