Commit 79e47751 authored by Luke Campagnola's avatar Luke Campagnola
Browse files

Parametertree updates:

  - fixes for saveState / restoreState (better handling of custom parameter classes)
  - added method GroupParameter.setAddList
  - ListParameter now remembers its value even if its list is cleared and rebuilt
  - added ActionParameter (buttons) and TextParameter
parent 32311351
......@@ -3,25 +3,29 @@ import collections, os, weakref, re
from .ParameterItem import ParameterItem
PARAM_TYPES = {}
PARAM_NAMES = {}
def registerParameterType(name, cls, override=False):
global PARAM_TYPES
if name in PARAM_TYPES and not override:
raise Exception("Parameter type '%s' already exists (use override=True to replace)" % name)
PARAM_TYPES[name] = cls
PARAM_NAMES[cls] = name
class Parameter(QtCore.QObject):
"""
Tree of name=value pairs (modifiable or not)
- Value may be integer, float, string, bool, color, or list selection
- Optionally, a custom widget may be specified for a property
- Any number of extra columns may be added for other purposes
- Any values may be reset to a default value
- Parameters may be grouped / nested
- Parameter may be subclassed to provide customized behavior.
A Parameter is the basic unit of data in a parameter tree. Each parameter has
a name, a type, a value, and several other properties that modify the behavior of the
Parameter. Parameters may have parent / child / sibling relationships to construct
organized hierarchies. Parameters generally do not have any inherent GUI or visual
interpretation; instead they manage ParameterItem instances which take care of
display and user interaction.
Note: It is fairly uncommon to use the Parameter class directly; mostly you
will use subclasses which provide specialized type and data handling. The static
pethod Parameter.create(...) is an easy way to generate instances of these subclasses.
For more Parameter types, see ParameterTree.parameterTypes module.
......@@ -88,7 +92,11 @@ class Parameter(QtCore.QObject):
Use registerParameterType() to add new class types.
"""
cls = PARAM_TYPES[opts['type']]
typ = opts.get('type', None)
if typ is None:
cls = Parameter
else:
cls = PARAM_TYPES[opts['type']]
return cls(**opts)
def __init__(self, **opts):
......@@ -102,6 +110,7 @@ class Parameter(QtCore.QObject):
'renamable': False,
'removable': False,
'strictNaming': False, # forces name to be usable as a python variable
#'limits': None, ## This is a bad plan--each parameter type may have a different data type for limits.
}
self.opts.update(opts)
......@@ -120,9 +129,7 @@ class Parameter(QtCore.QObject):
raise Exception("Parameter must have a string name specified in opts.")
self.setName(opts['name'])
for chOpts in self.opts.get('children', []):
#print self, "Add child:", type(chOpts), id(chOpts)
self.addChild(chOpts)
self.addChildren(self.opts.get('children', []))
if 'value' in self.opts and 'default' not in self.opts:
self.opts['default'] = self.opts['value']
......@@ -159,6 +166,22 @@ class Parameter(QtCore.QObject):
def type(self):
return self.opts['type']
def isType(self, typ):
"""
Return True if this parameter type matches the name *typ*.
This can occur either of two ways:
- If self.type() == *typ*
- If this parameter's class is registered with the name *typ*
"""
if self.type() == typ:
return True
global PARAM_TYPES
cls = PARAM_TYPES.get(typ, None)
if cls is None:
raise Exception("Type name '%s' is not registered." % str(typ))
return self.__class__ is cls
def childPath(self, child):
"""
Return the path of parameter names from self to child.
......@@ -175,7 +198,6 @@ class Parameter(QtCore.QObject):
def setValue(self, value, blockSignal=None):
## return the actual value that was set
## (this may be different from the value that was requested)
#print self, "Set value:", value, self.opts['value'], self.opts['value'] == value
try:
if blockSignal is not None:
self.sigValueChanged.disconnect(blockSignal)
......@@ -205,10 +227,13 @@ class Parameter(QtCore.QObject):
The tree state may be restored from this structure using restoreState()
"""
state = self.opts.copy()
state['children'] = [ch.saveState() for ch in self]
state['children'] = collections.OrderedDict([(ch.name(), ch.saveState()) for ch in self])
if state['type'] is None:
global PARAM_NAMES
state['type'] = PARAM_NAMES.get(type(self), None)
return state
def restoreState(self, state, recursive=True, addChildren=True, removeChildren=True):
def restoreState(self, state, recursive=True, addChildren=True, removeChildren=True, blockSignals=True):
"""
Restore the state of this parameter and its children from a structure generated using saveState()
If recursive is True, then attempt to restore the state of child parameters as well.
......@@ -216,55 +241,70 @@ class Parameter(QtCore.QObject):
created if they do not already exist.
If removeChildren is True, then any children which are not referenced in the state object will
be removed.
If blockSignals is True, no signals will be emitted until the tree has been completely restored.
This prevents signal handlers from responding to a partially-rebuilt network.
"""
childState = state.get('children', [])
self.setOpts(**state)
if not recursive:
return
## list of children may be stored either as list or dict.
if isinstance(childState, dict):
childState = childState.values()
ptr = 0 ## pointer to first child that has not been restored yet
foundChilds = set()
#print "==============", self.name()
for ch in childState:
name = ch['name']
typ = ch['type']
#print('child: %s, %s' % (self.name()+'.'+name, typ))
if blockSignals:
self.blockTreeChangeSignal()
## First, see if there is already a child with this name and type
gotChild = False
for i, ch2 in enumerate(self.childs[ptr:]):
#print ch2, ch2.name, ch2.type
if ch2.name() != name or ch2.type() != typ:
continue
gotChild = True
#print " found it"
if i != 0: ## move parameter to next position
self.removeChild(ch2)
self.insertChild(ptr, ch2)
#print " moved to position", ptr
ch2.restoreState(ch, recursive=recursive, addChildren=addChildren, removeChildren=removeChildren)
foundChilds.add(ch2)
break
try:
self.setOpts(**state)
if not gotChild:
if not addChildren:
#print " ignored child"
continue
#print " created new"
ch2 = Parameter.create(**ch)
self.insertChild(ptr, ch2)
foundChilds.add(ch2)
ptr += 1
if not recursive:
return
if removeChildren:
for ch in self:
if ch not in foundChilds:
#print " remove:", ch
self.removeChild(ch)
ptr = 0 ## pointer to first child that has not been restored yet
foundChilds = set()
#print "==============", self.name()
for ch in childState:
name = ch['name']
typ = ch['type']
#print('child: %s, %s' % (self.name()+'.'+name, typ))
## First, see if there is already a child with this name and type
gotChild = False
for i, ch2 in enumerate(self.childs[ptr:]):
#print " ", ch2.name(), ch2.type()
if ch2.name() != name or not ch2.isType(typ):
continue
gotChild = True
#print " found it"
if i != 0: ## move parameter to next position
#self.removeChild(ch2)
self.insertChild(ptr, ch2)
#print " moved to position", ptr
ch2.restoreState(ch, recursive=recursive, addChildren=addChildren, removeChildren=removeChildren)
foundChilds.add(ch2)
break
if not gotChild:
if not addChildren:
#print " ignored child"
continue
#print " created new"
ch2 = Parameter.create(**ch)
self.insertChild(ptr, ch2)
foundChilds.add(ch2)
ptr += 1
if removeChildren:
for ch in self.childs[:]:
if ch not in foundChilds:
#print " remove:", ch
self.removeChild(ch)
finally:
if blockSignals:
self.unblockTreeChangeSignal()
......@@ -362,6 +402,22 @@ class Parameter(QtCore.QObject):
def addChild(self, child):
"""Add another parameter to the end of this parameter's child list."""
return self.insertChild(len(self.childs), child)
def addChildren(self, children):
## If children was specified as dict, then assume keys are the names.
if isinstance(children, dict):
ch2 = []
for name, opts in children.items():
if isinstance(opts, dict) and 'name' not in opts:
opts = opts.copy()
opts['name'] = name
ch2.append(opts)
children = ch2
for chOpts in children:
#print self, "Add child:", type(chOpts), id(chOpts)
self.addChild(chOpts)
def insertChild(self, pos, child):
"""
......@@ -373,7 +429,7 @@ class Parameter(QtCore.QObject):
child = Parameter.create(**child)
name = child.name()
if name in self.names:
if name in self.names and child is not self.names[name]:
if child.opts.get('autoIncrementName', False):
name = self.incrementName(name)
child.setName(name)
......@@ -382,15 +438,16 @@ class Parameter(QtCore.QObject):
if isinstance(pos, Parameter):
pos = self.childs.index(pos)
if child.parent() is not None:
child.remove()
with self.treeChangeBlocker():
if child.parent() is not None:
child.remove()
self.names[name] = child
self.childs.insert(pos, child)
self.names[name] = child
self.childs.insert(pos, child)
child.parentChanged(self)
self.sigChildAdded.emit(self, child, pos)
child.sigTreeStateChanged.connect(self.treeStateChanged)
child.parentChanged(self)
self.sigChildAdded.emit(self, child, pos)
child.sigTreeStateChanged.connect(self.treeStateChanged)
return child
def removeChild(self, child):
......@@ -398,7 +455,6 @@ class Parameter(QtCore.QObject):
name = child.name()
if name not in self.names or self.names[name] is not child:
raise Exception("Parameter %s is not my child; can't remove." % str(child))
del self.names[name]
self.childs.pop(self.childs.index(child))
child.parentChanged(None)
......@@ -414,6 +470,9 @@ class Parameter(QtCore.QObject):
"""Return a list of this parameter's children."""
## warning -- this overrides QObject.children
return self.childs[:]
def hasChildren(self):
return len(self.childs) > 0
def parentChanged(self, parent):
"""This method is called when the parameter's parent has changed.
......@@ -443,7 +502,7 @@ class Parameter(QtCore.QObject):
num = int(num)
while True:
newName = base + ("%%0%dd"%numLen) % num
if newName not in self.childs:
if newName not in self.names:
return newName
num += 1
......
......@@ -8,17 +8,17 @@ import collections, os, weakref, re
class ParameterTree(TreeWidget):
"""Widget used to display or control data from a ParameterSet"""
def __init__(self, parent=None):
def __init__(self, parent=None, showHeader=True):
TreeWidget.__init__(self, parent)
self.setVerticalScrollMode(self.ScrollPerPixel)
self.setHorizontalScrollMode(self.ScrollPerPixel)
self.setAnimated(False)
self.setColumnCount(2)
self.setHeaderLabels(["Parameter", "Value"])
self.setRootIsDecorated(False)
self.setAlternatingRowColors(True)
self.paramSet = None
self.header().setResizeMode(QtGui.QHeaderView.ResizeToContents)
self.setHeaderHidden(not showHeader)
self.itemChanged.connect(self.itemChangedEvent)
self.lastSel = None
self.setRootIsDecorated(False)
......
......@@ -152,7 +152,6 @@ class WidgetParameterItem(ParameterItem):
def valueChanged(self, param, val, force=False):
## called when the parameter's value has changed
ParameterItem.valueChanged(self, param, val)
self.widget.sigChanged.disconnect(self.widgetValueChanged)
try:
if force or val != self.widget.value():
......@@ -273,12 +272,21 @@ class SimpleParameter(Parameter):
def __init__(self, *args, **kargs):
Parameter.__init__(self, *args, **kargs)
## override a few methods for color parameters
if self.opts['type'] == 'color':
self.value = self.colorValue
self.saveState = self.saveColorState
def colorValue(self):
return pg.mkColor(Parameter.value(self))
def saveColorState(self):
state = Parameter.saveState(self)
state['value'] = pg.colorTuple(self.value())
return state
registerParameterType('int', SimpleParameter, override=True)
registerParameterType('float', SimpleParameter, override=True)
registerParameterType('bool', SimpleParameter, override=True)
......@@ -303,9 +311,8 @@ class GroupParameterItem(ParameterItem):
addText = param.opts['addText']
if 'addList' in param.opts:
self.addWidget = QtGui.QComboBox()
self.addWidget.addItem(addText)
for t in param.opts['addList']:
self.addWidget.addItem(t)
self.addWidget.setSizeAdjustPolicy(QtGui.QComboBox.AdjustToContents)
self.updateAddList()
self.addWidget.currentIndexChanged.connect(self.addChanged)
else:
self.addWidget = QtGui.QPushButton(addText)
......@@ -315,7 +322,8 @@ class GroupParameterItem(ParameterItem):
l.setContentsMargins(0,0,0,0)
w.setLayout(l)
l.addWidget(self.addWidget)
l.addItem(QtGui.QSpacerItem(200, 10, QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Minimum))
l.addStretch()
#l.addItem(QtGui.QSpacerItem(200, 10, QtGui.QSizePolicy.Expanding, QtGui.QSizePolicy.Minimum))
self.addWidgetBox = w
self.addItem = QtGui.QTreeWidgetItem([])
self.addItem.setFlags(QtCore.Qt.ItemIsEnabled)
......@@ -370,17 +378,46 @@ class GroupParameterItem(ParameterItem):
ParameterItem.insertChild(self, self.childCount()-1, child)
else:
ParameterItem.addChild(self, child)
def optsChanged(self, param, changed):
if 'addList' in changed:
self.updateAddList()
def updateAddList(self):
self.addWidget.blockSignals(True)
try:
self.addWidget.clear()
self.addWidget.addItem(self.param.opts['addText'])
for t in self.param.opts['addList']:
self.addWidget.addItem(t)
finally:
self.addWidget.blockSignals(False)
class GroupParameter(Parameter):
"""
Group parameters are used mainly as a generic parent item that holds (and groups!) a set
of child parameters. It also provides a simple mechanism for displaying a button or combo
that can be used to add new parameters to the group.
of child parameters.
It also provides a simple mechanism for displaying a button or combo
that can be used to add new parameters to the group. To enable this, the group
must be initialized with the 'addText' option (the text will be displayed on
a button which, when clicked, will cause addNew() to be called). If the 'addList'
option is specified as well, then a dropdown-list of addable items will be displayed
instead of a button.
"""
itemClass = GroupParameterItem
def addNew(self, typ=None):
"""
This method is called when the user has requested to add a new item to the group.
"""
raise Exception("Must override this function in subclass.")
def setAddList(self, vals):
"""Change the list of options available for the user to add to the group."""
self.setOpts(addList=vals)
registerParameterType('group', GroupParameter, override=True)
......@@ -394,8 +431,10 @@ class ListParameterItem(WidgetParameterItem):
"""
def __init__(self, param, depth):
self.targetValue = None
WidgetParameterItem.__init__(self, param, depth)
def makeWidget(self):
opts = self.param.opts
t = opts['type']
......@@ -418,7 +457,8 @@ class ListParameterItem(WidgetParameterItem):
#else:
#return key
#print key, self.forward
return self.forward[key]
return self.forward.get(key, None)
def setValue(self, val):
#vals = self.param.opts['limits']
......@@ -431,6 +471,7 @@ class ListParameterItem(WidgetParameterItem):
#raise Exception("Value '%s' not allowed." % val)
#else:
#key = unicode(val)
self.targetValue = val
if val not in self.reverse:
self.widget.setCurrentIndex(0)
else:
......@@ -440,27 +481,23 @@ class ListParameterItem(WidgetParameterItem):
def limitsChanged(self, param, limits):
# set up forward / reverse mappings for name:value
self.forward = collections.OrderedDict() ## name: value
self.reverse = collections.OrderedDict() ## value: name
if isinstance(limits, dict):
for k, v in limits.items():
self.forward[k] = v
self.reverse[v] = k
else:
for v in limits:
n = asUnicode(v)
self.forward[n] = v
self.reverse[v] = n
#self.forward = collections.OrderedDict([('', None)]) ## name: value
#self.reverse = collections.OrderedDict([(None, '')]) ## value: name
if len(limits) == 0:
limits = [''] ## Can never have an empty list--there is always at least a singhe blank item.
self.forward, self.reverse = ListParameter.mapping(limits)
try:
self.widget.blockSignals(True)
val = asUnicode(self.widget.currentText())
val = self.targetValue #asUnicode(self.widget.currentText())
self.widget.clear()
for k in self.forward:
self.widget.addItem(k)
if k == val:
self.widget.setCurrentIndex(self.widget.count()-1)
self.updateDisplayLabel()
finally:
self.widget.blockSignals(False)
......@@ -472,29 +509,107 @@ class ListParameter(Parameter):
def __init__(self, **opts):
self.forward = collections.OrderedDict() ## name: value
self.reverse = collections.OrderedDict() ## value: name
## Parameter uses 'limits' option to define the set of allowed values
if 'values' in opts:
opts['limits'] = opts['values']
if opts.get('limits', None) is None:
opts['limits'] = []
Parameter.__init__(self, **opts)
def setLimits(self, limits):
self.forward = collections.OrderedDict() ## name: value
self.reverse = collections.OrderedDict() ## value: name
if isinstance(limits, dict):
for k, v in limits.items():
self.forward[k] = v
self.reverse[v] = k
else:
for v in limits:
n = asUnicode(v)
self.forward[n] = v
self.reverse[v] = n
self.forward, self.reverse = self.mapping(limits)
Parameter.setLimits(self, limits)
#print self.name(), self.value(), limits
if self.value() not in self.reverse and len(self.reverse) > 0:
self.setValue(list(self.reverse.keys())[0])
@staticmethod
def mapping(limits):
## Return forward and reverse mapping dictionaries given a limit specification
forward = collections.OrderedDict() ## name: value
reverse = collections.OrderedDict() ## value: name
if isinstance(limits, dict):
for k, v in limits.items():
forward[k] = v
reverse[v] = k
else:
for v in limits:
n = asUnicode(v)
forward[n] = v
reverse[v] = n
return forward, reverse
registerParameterType('list', ListParameter, override=True)
class ActionParameterItem(ParameterItem):
def __init__(self, param, depth):
ParameterItem.__init__(self, param, depth)
self.layoutWidget = QtGui.QWidget()
self.layout = QtGui.QHBoxLayout()
self.layoutWidget.setLayout(self.layout)
self.button = QtGui.QPushButton(param.name())
#self.layout.addSpacing(100)
self.layout.addWidget(self.button)
self.layout.addStretch()
self.button.clicked.connect(self.buttonClicked)
param.sigNameChanged.connect(self.paramRenamed)
self.setText(0, '')
def treeWidgetChanged(self):
ParameterItem.treeWidgetChanged(self)
tree = self.treeWidget()
if tree is None:
return
tree.setFirstItemColumnSpanned(self, True)
tree.setItemWidget(self, 0, self.layoutWidget)
def paramRenamed(self, param, name):
self.button.setText(name)
def buttonClicked(self):
self.param.activate()
class ActionParameter(Parameter):
"""Used for displaying a button within the tree."""
itemClass = ActionParameterItem
sigActivated = QtCore.Signal(object)
def activate(self):
self.sigActivated.emit(self)
self.emitStateChanged('activated', None)
registerParameterType('action', ActionParameter, override=True)
class TextParameterItem(WidgetParameterItem):
def __init__(self, param, depth):
WidgetParameterItem.__init__(self, param, depth)
self.subItem = QtGui.QTreeWidgetItem()
self.addChild(self.subItem)
def treeWidgetChanged(self):
self.treeWidget().setFirstItemColumnSpanned(self.subItem, True)
self.treeWidget().setItemWidget(self.subItem, 0, self.textBox)
self.setExpanded(True)
def makeWidget(self):
self.textBox = QtGui.QTextEdit()
self.textBox.setMaximumHeight(100)
self.textBox.value = lambda: str(self.textBox.toPlainText())
self.textBox.setValue = self.textBox.setPlainText
self.textBox.sigChanged = self.textBox.textChanged
return self.textBox
class TextParameter(Parameter):
"""Editable string; displayed as large text box in the tree."""
itemClass = TextParameterItem
registerParameterType('text', TextParameter, override=True)