Merge pull request #1469 from lidstrom83/ptree

Various improvements to parametertree
This commit is contained in:
Ogi Moore 2021-01-31 22:11:33 -08:00 committed by GitHub
commit 9da7b8f7c1
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
11 changed files with 226 additions and 112 deletions

View File

@ -64,8 +64,8 @@ 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},
{'name': 'String', 'type': 'str', 'value': "hi"},
{'name': 'Float', 'type': 'float', 'value': 10.5, 'step': 0.1, 'finite': False},
{'name': 'String', 'type': 'str', 'value': "hi", 'tip': 'Well hello'},
{'name': 'List', 'type': 'list', 'values': [1,2,3], 'value': 2},
{'name': 'Named List', 'type': 'list', 'values': {"one": 1, "two": "twosies", "three": [3,3,3]}, 'value': 2},
{'name': 'Boolean', 'type': 'bool', 'value': True, 'tip': "This is a checkbox"},
@ -76,7 +76,7 @@ params = [
{'name': 'Sub-param 2', 'type': 'float', 'value': 1.2e6},
]},
{'name': 'Text Parameter', 'type': 'text', 'value': 'Some text...'},
{'name': 'Action Parameter', 'type': 'action'},
{'name': 'Action Parameter', 'type': 'action', 'tip': 'Click me'},
]},
{'name': 'Numerical Parameter Options', 'type': 'group', 'children': [
{'name': 'Units + SI prefix', 'type': 'float', 'value': 1.2e-6, 'step': 1e-6, 'siPrefix': True, 'suffix': 'V'},
@ -93,6 +93,7 @@ params = [
]},
{'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},
]},
@ -107,7 +108,7 @@ params = [
}},
]},
ComplexParameter(name='Custom parameter group (reciprocal values)'),
ScalableGroup(name="Expandable Parameter Group", children=[
ScalableGroup(name="Expandable Parameter Group", tip='Click to add children', children=[
{'name': 'ScalableParam 1', 'type': 'str', 'value': "default param 1"},
{'name': 'ScalableParam 2', 'type': 'str', 'value': "default param 2"},
]),
@ -171,7 +172,6 @@ layout.addWidget(QtGui.QLabel("These are two views of the same data. They should
layout.addWidget(t, 1, 0, 1, 1)
layout.addWidget(t2, 1, 1, 1, 1)
win.show()
win.resize(800,800)
## test save/restore
s = p.saveState()

View File

@ -395,7 +395,7 @@ if QT_LIB == PYSIDE6:
if QT_LIB == PYQT6:
# module.Class.EnumClass.Enum -> module.Class.Enum
def promote_enums(module):
class_names = [x for x in dir(module) if x[0] == 'Q']
class_names = [x for x in dir(module) if x.startswith('Q')]
for class_name in class_names:
klass = getattr(module, class_name)
if not isinstance(klass, sip.wrappertype):

View File

@ -1,7 +1,7 @@
import numpy as np
from .Qt import QtGui, QtCore
from .python2_3 import basestring
from .functions import mkColor
from .functions import mkColor, eq
from os import path, listdir
import collections
@ -178,7 +178,6 @@ def _get_from_colorcet(name):
return cm
class ColorMap(object):
"""
A ColorMap defines a relationship between a scalar value and a range of colors.
@ -443,3 +442,8 @@ class ColorMap(object):
pos = repr(self.pos).replace('\n', '')
color = repr(self.color).replace('\n', '')
return "ColorMap(%s, %s)" % (pos, color)
def __eq__(self, other):
if other is None:
return False
return eq(self.pos, other.pos) and eq(self.color, other.color)

View File

@ -425,18 +425,25 @@ def eq(a, b):
This function has some important differences from the == operator:
1. Returns True if a IS b, even if a==b still evaluates to False, such as with nan values.
2. Tests for equivalence using ==, but silently ignores some common exceptions that can occur
1. Returns True if a IS b, even if a==b still evaluates to False.
2. While a is b will catch the case with np.nan values, special handling is done for distinct
float('nan') instances using np.isnan.
3. Tests for equivalence using ==, but silently ignores some common exceptions that can occur
(AtrtibuteError, ValueError).
3. When comparing arrays, returns False if the array shapes are not the same.
4. When comparing arrays of the same shape, returns True only if all elements are equal (whereas
4. When comparing arrays, returns False if the array shapes are not the same.
5. When comparing arrays of the same shape, returns True only if all elements are equal (whereas
the == operator would return a boolean array).
5. Collections (dict, list, etc.) must have the same type to be considered equal. One
consequence is that comparing a dict to an OrderedDict will always return False.
6. Collections (dict, list, etc.) must have the same type to be considered equal. One
consequence is that comparing a dict to an OrderedDict will always return False.
"""
if a is b:
return True
# The above catches np.nan, but not float('nan')
if isinstance(a, float) and isinstance(b, float):
if np.isnan(a) and np.isnan(b):
return True
# Avoid comparing large arrays against scalars; this is expensive and we know it should return False.
aIsArr = isinstance(a, (np.ndarray, MetaArray))
bIsArr = isinstance(b, (np.ndarray, MetaArray))

View File

@ -1,4 +1,5 @@
# -*- coding: utf-8 -*-
from .. import functions as fn
from ..Qt import QtGui, QtCore
import os, weakref, re
from ..pgcollections import OrderedDict
@ -288,15 +289,15 @@ class Parameter(QtCore.QObject):
if blockSignal is not None:
self.sigValueChanged.disconnect(blockSignal)
value = self._interpretValue(value)
if self.opts['value'] == value:
if fn.eq(self.opts['value'], value):
return value
self.opts['value'] = value
self.sigValueChanged.emit(self, value)
self.sigValueChanged.emit(self, value) # value might change after signal is received by tree item
finally:
if blockSignal is not None:
self.sigValueChanged.connect(blockSignal)
return value
return self.opts['value']
def _interpretValue(self, v):
return v

View File

@ -1,4 +1,4 @@
from ..Qt import QtGui, QtCore
from ..Qt import QtGui, QtCore, QT_LIB
from ..python2_3 import asUnicode
import os, weakref, re
@ -161,6 +161,18 @@ class ParameterItem(QtGui.QTreeWidgetItem):
def titleChanged(self):
# called when the user-visble title has changed (either opts['title'], or name if title is None)
self.setText(0, self.param.title())
fm = QtGui.QFontMetrics(self.font(0))
if QT_LIB == 'PyQt6':
# PyQt6 doesn't allow or-ing of different enum types
# so we need to take its value property
textFlags = QtCore.Qt.TextSingleLine.value
else:
textFlags = QtCore.Qt.TextSingleLine
size = fm.size(textFlags, self.text(0))
size.setHeight(int(size.height() * 1.35))
size.setWidth(int(size.width() * 1.15))
self.setSizeHint(0, size)
def limitsChanged(self, param, limits):
"""Called when the parameter's limits have changed"""
@ -177,14 +189,8 @@ class ParameterItem(QtGui.QTreeWidgetItem):
self.setHidden(not opts['visible'])
if 'expanded' in opts:
if self.param.opts['syncExpanded']:
if self.isExpanded() != opts['expanded']:
self.setExpanded(opts['expanded'])
if 'syncExpanded' in opts:
if opts['syncExpanded']:
if self.isExpanded() != self.param.opts['expanded']:
self.setExpanded(self.param.opts['expanded'])
if self.isExpanded() != opts['expanded']:
self.setExpanded(opts['expanded'])
if 'title' in opts:
self.titleChanged()

View File

@ -159,6 +159,36 @@ class ParameterTree(TreeWidget):
sel[0].selected(True)
return super().selectionChanged(*args)
def wheelEvent(self, ev):
self.clearSelection()
return super().wheelEvent(ev)
# commented out due to being unreliable
# def wheelEvent(self, ev):
# self.clearSelection()
# return super().wheelEvent(ev)
def sizeHint(self):
w, h = 0, 0
ind = self.indentation()
for x in self.listAllItems():
if x.isHidden():
continue
try:
depth = x.depth
except AttributeError:
depth = 0
s0 = x.sizeHint(0)
s1 = x.sizeHint(1)
w = max(w, depth * ind + max(0, s0.width()) + max(0, s1.width()))
h += max(0, s0.height(), s1.height())
# typ = x.param.opts['type'] if isinstance(x, ParameterItem) else x
# print(typ, depth * ind, (s0.width(), s0.height()), (s1.width(), s1.height()), (w, h))
# todo: find out if this alternative can be made to work (currently fails when color or colormap are present)
# print('custom', (w, h))
# w = self.sizeHintForColumn(0) + self.sizeHintForColumn(1)
# h = self.viewportSizeHint().height()
# print('alternative', (w, h))
if not self.header().isHidden():
h += self.header().height()
return QtCore.QSize(w, h)

View File

@ -1,4 +1,4 @@
# -*- coding: utf-8 -*-
import os
from ..Qt import QtCore, QtGui
from ..python2_3 import asUnicode
from .Parameter import Parameter, registerParameterType
@ -8,7 +8,6 @@ from ..widgets.ColorButton import ColorButton
from ..colormap import ColorMap
from .. import pixmaps as pixmaps
from .. import functions as fn
import os, sys
from ..pgcollections import OrderedDict
@ -35,15 +34,22 @@ class WidgetParameterItem(ParameterItem):
"""
def __init__(self, param, depth):
ParameterItem.__init__(self, param, depth)
self.asSubItem = False # place in a child item's column 0 instead of column 1
self.hideWidget = True ## hide edit widget, replace with label when not selected
## set this to False to keep the editor widget always visible
## build widget into column 1 with a display label and default button.
# build widget with a display label and default button
w = self.makeWidget()
self.widget = w
self.eventProxy = EventProxy(w, self.widgetEventFilter)
if self.asSubItem:
self.subItem = QtGui.QTreeWidgetItem()
self.subItem.depth = self.depth + 1
self.subItem.setFlags(QtCore.Qt.NoItemFlags)
self.addChild(self.subItem)
self.defaultBtn = QtGui.QPushButton()
self.defaultBtn.setAutoDefault(False)
self.defaultBtn.setFixedWidth(20)
@ -57,8 +63,10 @@ class WidgetParameterItem(ParameterItem):
layout = QtGui.QHBoxLayout()
layout.setContentsMargins(0, 0, 0, 0)
layout.setSpacing(2)
layout.addWidget(w)
layout.addWidget(self.displayLabel)
if not self.asSubItem:
layout.addWidget(w, 1)
layout.addWidget(self.displayLabel, 1)
layout.addStretch(0)
layout.addWidget(self.defaultBtn)
self.layoutWidget = QtGui.QWidget()
self.layoutWidget.setLayout(layout)
@ -81,9 +89,26 @@ class WidgetParameterItem(ParameterItem):
self.optsChanged(self.param, self.param.opts)
# set size hints
sw = self.widget.sizeHint()
sb = self.defaultBtn.sizeHint()
# shrink row heights a bit for more compact look
sw.setHeight(int(sw.height() * 0.9))
sb.setHeight(int(sb.height() * 0.9))
if self.asSubItem:
self.setSizeHint(1, sb)
self.subItem.setSizeHint(0, sw)
else:
w = sw.width() + sb.width()
h = max(sw.height(), sb.height())
self.setSizeHint(1, QtCore.QSize(w, h))
def makeWidget(self):
"""
Return a single widget that should be placed in the second tree column.
Return a single widget whose position in the tree is determined by the
value of self.asSubItem. If True, it will be placed in the second tree
column, and if False, the first tree column of a child item.
The widget must be given three attributes:
========== ============================================================
@ -120,7 +145,6 @@ class WidgetParameterItem(ParameterItem):
w.sigChanged = w.toggled
w.value = w.isChecked
w.setValue = w.setChecked
w.setEnabled(not opts.get('readonly', False))
self.hideWidget = False
elif t == 'str':
w = QtGui.QLineEdit()
@ -137,15 +161,16 @@ class WidgetParameterItem(ParameterItem):
w.setValue = w.setColor
self.hideWidget = False
w.setFlat(True)
w.setEnabled(not opts.get('readonly', False))
elif t == 'colormap':
from ..widgets.GradientWidget import GradientWidget ## need this here to avoid import loop
w = GradientWidget(orientation='bottom')
w.sizeHint = lambda: QtCore.QSize(300, 35)
w.sigChanged = w.sigGradientChangeFinished
w.sigChanging = w.sigGradientChanged
w.value = w.colorMap
w.setValue = w.setColorMap
self.hideWidget = False
self.asSubItem = True
else:
raise Exception("Unknown type '%s'" % asUnicode(t))
return w
@ -168,23 +193,27 @@ class WidgetParameterItem(ParameterItem):
self.showEditor()
def isFocusable(self):
return self.param.writable()
return self.param.opts['visible'] and self.param.opts['enabled'] and self.param.writable()
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():
if force or not fn.eq(val, self.widget.value()):
try:
self.widget.sigChanged.disconnect(self.widgetValueChanged)
self.param.sigValueChanged.disconnect(self.valueChanged)
self.widget.setValue(val)
self.updateDisplayLabel(val) ## always make sure label is updated, even if values match!
finally:
self.widget.sigChanged.connect(self.widgetValueChanged)
self.param.setValue(self.widget.value())
finally:
self.widget.sigChanged.connect(self.widgetValueChanged)
self.param.sigValueChanged.connect(self.valueChanged)
self.updateDisplayLabel() ## always make sure label is updated, even if values match!
self.updateDefaultBtn()
def updateDefaultBtn(self):
## enable/disable default btn
self.defaultBtn.setEnabled(not self.param.valueIsDefault() and self.param.writable())
self.defaultBtn.setEnabled(
not self.param.valueIsDefault() and self.param.opts['enabled'] and self.param.writable())
# hide / show
self.defaultBtn.setVisible(self.param.hasDefault() and not self.param.readonly())
@ -212,9 +241,7 @@ class WidgetParameterItem(ParameterItem):
Called when the widget's value is changing, but not finalized.
For example: editing text before pressing enter or changing focus.
"""
# This is a bit sketchy: assume the last argument of each signal is
# the value..
self.param.sigValueChanging.emit(self.param, args[-1])
self.param.sigValueChanging.emit(self.param, self.widget.value())
def selected(self, sel):
"""Called when this item has been selected (sel=True) OR deselected (sel=False)"""
@ -260,9 +287,12 @@ class WidgetParameterItem(ParameterItem):
tree = self.treeWidget()
if tree is None:
return
if self.asSubItem:
self.subItem.setFirstColumnSpanned(True)
tree.setItemWidget(self.subItem, 0, self.widget)
tree.setItemWidget(self, 1, self.layoutWidget)
self.displayLabel.hide()
self.selected(False)
self.selected(False)
def defaultClicked(self):
self.param.setToDefault()
@ -271,12 +301,18 @@ class WidgetParameterItem(ParameterItem):
"""Called when any options are changed that are not
name, value, default, or limits"""
ParameterItem.optsChanged(self, param, opts)
if 'enabled' in opts:
self.updateDefaultBtn()
self.widget.setEnabled(opts['enabled'])
if 'readonly' in opts:
self.updateDefaultBtn()
if isinstance(self.widget, (QtGui.QCheckBox,ColorButton)):
self.widget.setEnabled(not opts['readonly'])
if hasattr(self.widget, 'setReadOnly'):
self.widget.setReadOnly(opts['readonly'])
else:
self.widget.setEnabled(self.param.opts['enabled'] and not opts['readonly'])
if 'tip' in opts:
self.widget.setToolTip(opts['tip'])
@ -331,11 +367,6 @@ class SimpleParameter(Parameter):
if self.opts['type'] == 'color':
self.value = self.colorValue
self.saveState = self.saveColorState
def setValue(self, value, blockSignal=None):
if self.opts['type'] == 'int':
value = int(value)
Parameter.setValue(self, value, blockSignal)
def colorValue(self):
return fn.mkColor(Parameter.value(self))
@ -403,8 +434,12 @@ class GroupParameterItem(ParameterItem):
self.addWidgetBox = w
self.addItem = QtGui.QTreeWidgetItem([])
self.addItem.setFlags(QtCore.Qt.ItemIsEnabled)
self.addItem.depth = self.depth + 1
ParameterItem.addChild(self, self.addItem)
self.addItem.setSizeHint(0, self.addWidgetBox.sizeHint())
self.optsChanged(self.param, self.param.opts)
def updateDepth(self, depth):
## Change item's appearance based on its depth in the tree
## This allows highest-level groups to be displayed more prominently.
@ -416,7 +451,6 @@ class GroupParameterItem(ParameterItem):
font.setBold(True)
font.setPointSize(font.pointSize()+1)
self.setFont(c, font)
self.setSizeHint(0, QtCore.QSize(0, 25))
else:
for c in [0,1]:
self.setBackground(c, QtGui.QBrush(QtGui.QColor(220,220,220)))
@ -425,8 +459,8 @@ class GroupParameterItem(ParameterItem):
font.setBold(True)
#font.setPointSize(font.pointSize()+1)
self.setFont(c, font)
self.setSizeHint(0, QtCore.QSize(0, 20))
self.titleChanged() # sets the size hint for column 0 which is based on the new font
def addClicked(self):
"""Called when "add new" button is clicked
The parameter MUST have an 'addNew' method defined.
@ -464,7 +498,14 @@ class GroupParameterItem(ParameterItem):
if 'addList' in opts:
self.updateAddList()
if hasattr(self, 'addWidget'):
if 'enabled' in opts:
self.addWidget.setEnabled(opts['enabled'])
if 'tip' in opts:
self.addWidget.setToolTip(opts['tip'])
def updateAddList(self):
self.addWidget.blockSignals(True)
try:
@ -633,12 +674,14 @@ class ActionParameterItem(ParameterItem):
self.layout = QtGui.QHBoxLayout()
self.layout.setContentsMargins(0, 0, 0, 0)
self.layoutWidget.setLayout(self.layout)
self.button = QtGui.QPushButton(param.title())
self.button = QtGui.QPushButton()
#self.layout.addSpacing(100)
self.layout.addWidget(self.button)
self.layout.addStretch()
self.button.clicked.connect(self.buttonClicked)
self.titleChanged()
self.optsChanged(self.param, self.param.opts)
def treeWidgetChanged(self):
ParameterItem.treeWidgetChanged(self)
tree = self.treeWidget()
@ -650,8 +693,17 @@ class ActionParameterItem(ParameterItem):
def titleChanged(self):
self.button.setText(self.param.title())
ParameterItem.titleChanged(self)
self.setSizeHint(0, self.button.sizeHint())
def optsChanged(self, param, opts):
ParameterItem.optsChanged(self, param, opts)
if 'enabled' in opts:
self.button.setEnabled(opts['enabled'])
if 'tip' in opts:
self.button.setToolTip(opts['tip'])
def buttonClicked(self):
self.param.activate()
@ -672,39 +724,21 @@ registerParameterType('action', ActionParameter, override=True)
class TextParameterItem(WidgetParameterItem):
"""ParameterItem displaying a QTextEdit widget."""
def __init__(self, param, depth):
WidgetParameterItem.__init__(self, param, depth)
self.hideWidget = False
self.subItem = QtGui.QTreeWidgetItem()
self.addChild(self.subItem)
def treeWidgetChanged(self):
## TODO: fix so that superclass method can be called
## (WidgetParameter should just natively support this style)
#WidgetParameterItem.treeWidgetChanged(self)
tw = self.treeWidget()
if tw is None:
return
self.subItem.setFirstColumnSpanned(True)
tw.setItemWidget(self.subItem, 0, self.textBox)
# for now, these are copied from ParameterItem.treeWidgetChanged
self.setHidden(not self.param.opts.get('visible', True))
self.setExpanded(self.param.opts.get('expanded', True))
def makeWidget(self):
self.textBox = QtGui.QTextEdit()
self.textBox.setMaximumHeight(100)
self.textBox.setReadOnly(self.param.opts.get('readonly', False))
self.textBox.value = lambda: str(self.textBox.toPlainText())
self.textBox.setValue = self.textBox.setPlainText
self.textBox.sigChanged = self.textBox.textChanged
return self.textBox
self.hideWidget = False
self.asSubItem = True
self.textBox = w = QtGui.QTextEdit()
w.sizeHint = lambda: QtCore.QSize(300, 100)
w.value = lambda: str(w.toPlainText())
w.setValue = w.setPlainText
w.sigChanged = w.textChanged
return w
class TextParameter(Parameter):
"""Editable string, displayed as large text box in the tree."""
itemClass = TextParameterItem
registerParameterType('text', TextParameter, override=True)

View File

@ -127,8 +127,41 @@ def check_param_types(param, types, map_func, init, objs, keys):
raise Exception("Setting %s parameter value to %r should have raised an exception." % (param, v))
def test_limits_enforcement():
p = pt.Parameter.create(name='params', type='group', children=[
dict(name='float', type='float', limits=[0, 1]),
dict(name='int', type='int', bounds=[0, 1]),
dict(name='list', type='list', values=['x', 'y']),
dict(name='dict', type='list', values={'x': 1, 'y': 2}),
])
t = pt.ParameterTree()
t.setParameters(p)
for k, vin, vout in [('float', -1, 0),
('float', 2, 1),
('int', -1, 0),
('int', 2, 1),
('list', 'w', 'x'),
('dict', 'w', 1)]:
p[k] = vin
assert p[k] == vout
def test_data_race():
# Ensure widgets don't override user setting of param values whether
# they connect the signal before or after it's added to a tree
p = pt.Parameter.create(name='int', type='int', value=0)
t = pt.ParameterTree()
def override():
p.setValue(1)
p.sigValueChanged.connect(override)
t.setParameters(p)
pi = next(iter(p.items))
assert pi.param is p
pi.widget.setValue(2)
assert p.value() == pi.widget.value() == 1
p.sigValueChanged.disconnect(override)
p.sigValueChanged.connect(override)
pi.widget.setValue(2)
assert p.value() == pi.widget.value() == 1

View File

@ -360,10 +360,9 @@ class SpinBox(QtGui.QAbstractSpinBox):
if not isinstance(value, D):
value = D(asUnicode(value))
changed = value != self.val
prev = self.val
self.val = value
prev, self.val = self.val, value
changed = not fn.eq(value, prev) # use fn.eq to handle nan
if update and (changed or not bounded):
self.updateText(prev=prev)
@ -381,7 +380,7 @@ class SpinBox(QtGui.QAbstractSpinBox):
def delayedChange(self):
try:
if self.val != self.lastValEmitted:
if not fn.eq(self.val, self.lastValEmitted): # use fn.eq to handle nan
self.emitChanged()
except RuntimeError:
pass ## This can happen if we try to handle a delayed signal after someone else has already deleted the underlying C++ object.

View File

@ -132,7 +132,7 @@ class TreeWidget(QtGui.QTreeWidget):
def listAllItems(self, item=None):
items = []
if item != None:
if item is not None:
items.append(item)
else:
item = self.invisibleRootItem()