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 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 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 as well as some customized parameter types
""" """
import os
import initExample ## Add path to library (just for examples; you do not need this) import initExample ## Add path to library (just for examples; you do not need this)
import pyqtgraph as pg 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") app = pg.mkQApp("Parameter Tree Example")
import pyqtgraph.parametertree.parameterTypes as pTypes import pyqtgraph.parametertree.parameterTypes as pTypes
from pyqtgraph.parametertree import Parameter, ParameterTree, ParameterItem, registerParameterType from pyqtgraph.parametertree import Parameter, ParameterTree
## test subclassing parameters ## test subclassing parameters
@ -62,39 +64,7 @@ class ScalableGroup(pTypes.GroupParameter):
params = [ params = [
{'name': 'Basic parameter data types', 'type': 'group', 'children': [ makeAllParamTypes(),
{'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'},
]},
{'name': 'Save/Restore functionality', 'type': 'group', 'children': [ {'name': 'Save/Restore functionality', 'type': 'group', 'children': [
{'name': 'Save State', 'type': 'action'}, {'name': 'Save State', 'type': 'action'},
{'name': 'Restore State', 'type': 'action', 'children': [ {'name': 'Restore State', 'type': 'action', 'children': [
@ -102,12 +72,6 @@ params = [
{'name': 'Remove extra items', 'type': 'bool', 'value': True}, {'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': 'Custom context menu', 'type': 'group', 'children': [
{'name': 'List contextMenu', 'type': 'float', 'value': 0, 'context': [ {'name': 'List contextMenu', 'type': 'float', 'value': 0, 'context': [
'menu1', 'menu1',

View File

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

View File

@ -516,7 +516,7 @@ class Parameter(QtCore.QObject):
self.setLimits(opts[k]) self.setLimits(opts[k])
elif k == 'default': elif k == 'default':
self.setDefault(opts[k]) 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] self.opts[k] = opts[k]
changed[k] = opts[k] changed[k] = opts[k]

View File

@ -145,7 +145,8 @@ class FileParameterItem(StrParameterItem):
startDir = os.path.dirname(startDir) startDir = os.path.dirname(startDir)
if os.path.exists(startDir): if os.path.exists(startDir):
opts['directory'] = 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) fname = popupFilePicker(None, **opts)
if not fname: if not fname:

View File

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

View File

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