Adds checklist parameter (#1952)

* Alphabetize parameter registration

* Implements a checklist parameter

* Several checklist updates
- Correct indentation
- Allow dict-like setting + non-string values
- Fix several bugs associated with 'exclusive' behavior

* Add 'checklist' to param tree example

* Add documentation

* Removes unneeded code

* Forgot to rebuilt RST doc

* `exclusive` checklist uses radio buttons

* better checklist change logic

Co-authored-by: Ogi Moore <ognyan.moore@gmail.com>
This commit is contained in:
ntjess 2021-09-08 00:25:17 -04:00 committed by GitHub
parent 745b04baa5
commit babd037cf7
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
6 changed files with 245 additions and 24 deletions

View File

@ -16,6 +16,9 @@ Parameters
.. autoclass:: CalendarParameter
:members:
.. autoclass:: ChecklistParameter
:members:
.. autoclass:: ColorMapParameter
:members:
@ -61,6 +64,9 @@ ParameterItems
.. autoclass:: CalendarParameterItem
:members:
.. autoclass:: ChecklistParameterItem
:members:
.. autoclass:: ColorMapParameterItem
:members:

View File

@ -66,6 +66,7 @@ params = [
{'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"},

View File

@ -2,6 +2,7 @@ from .action import ActionParameter, ActionParameterItem
from .basetypes import WidgetParameterItem, SimpleParameter, GroupParameter, GroupParameterItem
from .bool import BoolParameterItem
from .calendar import CalendarParameter, CalendarParameterItem
from .checklist import ChecklistParameter, ChecklistParameterItem
from .color import ColorParameter, ColorParameterItem
from .colormap import ColorMapParameter, ColorMapParameterItem
from .file import FileParameter, FileParameterItem
@ -16,22 +17,23 @@ from .str import StrParameterItem
from .text import TextParameter, TextParameterItem
from ..Parameter import registerParameterType, registerParameterItemType
registerParameterItemType('int', NumericParameterItem, SimpleParameter, override=True)
registerParameterItemType('float', NumericParameterItem, SimpleParameter, override=True)
registerParameterItemType('bool', BoolParameterItem, SimpleParameter, override=True)
registerParameterItemType('float', NumericParameterItem, SimpleParameter, override=True)
registerParameterItemType('int', NumericParameterItem, SimpleParameter, override=True)
registerParameterItemType('str', StrParameterItem, SimpleParameter, override=True)
registerParameterType('group', GroupParameter, override=True)
registerParameterType('action', ActionParameter, override=True)
registerParameterType('calendar', CalendarParameter, override=True)
registerParameterType('checklist', ChecklistParameter, override=True)
registerParameterType('color', ColorParameter, override=True)
registerParameterType('colormap', ColorMapParameter, override=True)
registerParameterType('file', FileParameter, override=True)
registerParameterType('font', FontParameter, override=True)
registerParameterType('list', ListParameter, override=True)
registerParameterType('action', ActionParameter, override=True)
registerParameterType('text', TextParameter, override=True)
registerParameterType('pen', PenParameter, override=True)
registerParameterType('progress', ProgressBarParameter, override=True)
registerParameterType('file', FileParameter, override=True)
registerParameterType('slider', SliderParameter, override=True)
registerParameterType('calendar', CalendarParameter, override=True)
registerParameterType('font', FontParameter, override=True)
# qtenum is a bit specific, hold off on registering for now
registerParameterType('slider', SliderParameter, override=True)
registerParameterType('text', TextParameter, override=True)

View File

@ -419,3 +419,10 @@ class GroupParameter(Parameter):
def setAddList(self, vals):
"""Change the list of options available for the user to add to the group."""
self.setOpts(addList=vals)
class Emitter(QtCore.QObject):
"""
WidgetParameterItem is not a QObject, so create a QObject wrapper that items can use for emitting
"""
sigChanging = QtCore.Signal(object, object)
sigChanged = QtCore.Signal(object, object)

View File

@ -0,0 +1,215 @@
from . import BoolParameterItem, SimpleParameter
from .basetypes import GroupParameterItem, GroupParameter, WidgetParameterItem
from .list import ListParameter
from .slider import Emitter
from .. import ParameterItem
from ... import functions as fn
from ...Qt import QtWidgets
class ChecklistParameterItem(GroupParameterItem):
"""
Wraps a :class:`GroupParameterItem` to manage ``bool`` parameter children. Also provides convenience buttons to
select or clear all values at once. Note these conveniences are disabled when ``exclusive`` is *True*.
"""
def __init__(self, param, depth):
self.btnGrp = QtWidgets.QButtonGroup()
self.btnGrp.setExclusive(False)
self._constructMetaBtns()
super().__init__(param, depth)
def _constructMetaBtns(self):
self.metaBtnWidget = QtWidgets.QWidget()
self.metaBtnLayout = lay = QtWidgets.QHBoxLayout(self.metaBtnWidget)
lay.setContentsMargins(0, 0, 0, 0)
lay.setSpacing(2)
self.metaBtns = {}
lay.addStretch(0)
for title in 'Clear', 'Select':
self.metaBtns[title] = btn = QtWidgets.QPushButton(f'{title} All')
self.metaBtnLayout.addWidget(btn)
btn.clicked.connect(getattr(self, f'{title.lower()}AllClicked'))
self.metaBtns['default'] = WidgetParameterItem.makeDefaultButton(self)
self.metaBtnLayout.addWidget(self.metaBtns['default'])
def defaultClicked(self):
self.param.setToDefault()
def treeWidgetChanged(self):
ParameterItem.treeWidgetChanged(self)
tw = self.treeWidget()
if tw is None:
return
tw.setItemWidget(self, 1, self.metaBtnWidget)
def selectAllClicked(self):
self.param.setValue(self.param.reverse[0])
def clearAllClicked(self):
self.param.setValue([])
def insertChild(self, pos, item):
ret = super().insertChild(pos, item)
self.btnGrp.addButton(item.widget)
return ret
def addChild(self, item):
ret = super().addChild(item)
self.btnGrp.addButton(item.widget)
return ret
def takeChild(self, i):
child = super().takeChild(i)
self.btnGrp.removeButton(child.widget)
def optsChanged(self, param, opts):
if 'expanded' in opts:
for btn in self.metaBtns.values():
btn.setVisible(opts['expanded'])
exclusive = opts.get('exclusive', param.opts['exclusive'])
enabled = opts.get('enabled', param.opts['enabled'])
for btn in self.metaBtns.values():
btn.setDisabled(exclusive or (not enabled))
self.btnGrp.setExclusive(exclusive)
def expandedChangedEvent(self, expanded):
for btn in self.metaBtns.values():
btn.setVisible(expanded)
class RadioParameterItem(BoolParameterItem):
"""
Allows radio buttons to function as booleans when `exclusive` is *True*
"""
def __init__(self, param, depth):
self.emitter = Emitter()
super().__init__(param, depth)
def makeWidget(self):
w = QtWidgets.QRadioButton()
w.value = w.isChecked
# Since these are only used during exclusive operations, only fire a signal when "True"
# to avoid a double-fire
w.setValue = w.setChecked
w.sigChanged = self.emitter.sigChanged
w.toggled.connect(self.maybeSigChanged)
self.hideWidget = False
return w
def maybeSigChanged(self, val):
"""
Make sure to only activate on a "true" value, since an exclusive button group fires once to deactivate
the old option and once to activate the new selection
"""
if not val:
return
self.emitter.sigChanged.emit(self, val)
# Proxy around radio/bool type so the correct item class gets instantiated
class BoolOrRadioParameter(SimpleParameter):
def __init__(self, **kargs):
if kargs.get('type') == 'bool':
self.itemClass = BoolParameterItem
else:
self.itemClass = RadioParameterItem
super().__init__(**kargs)
class ChecklistParameter(GroupParameter):
"""
Can be set just like a :class:`ListParameter`, but allows for multiple values to be selected simultaneously.
============== ========================================================
**Options**
exclusive When *False*, any number of options can be selected. The resulting ``value()`` is a list of
all checked values. When *True*, it behaves like a ``list`` type -- only one value can be selected.
If no values are selected and ``exclusive`` is set to *True*, the first available limit is selected.
The return value of an ``exclusive`` checklist is a single value rather than a list with one element.
============== ========================================================
"""
itemClass = ChecklistParameterItem
def __init__(self, **opts):
self.targetValue = None
limits = opts.setdefault('limits', [])
self.forward, self.reverse = ListParameter.mapping(limits)
value = opts.setdefault('value', limits)
opts.setdefault('exclusive', False)
super().__init__(**opts)
# Force 'exclusive' to trigger by making sure value is not the same
self.sigLimitsChanged.connect(self.updateLimits)
self.sigOptionsChanged.connect(self.optsChanged)
if len(limits):
# Since update signal wasn't hooked up until after parameter construction, need to fire manually
self.updateLimits(self, limits)
# Also, value calculation will be incorrect until children are added, so make sure to recompute
self.setValue(value)
def updateLimits(self, _param, limits):
oldOpts = self.names
val = self.opts['value']
# Make sure adding and removing children don't cause tree state changes
self.blockTreeChangeSignal()
self.clearChildren()
self.forward, self.reverse = ListParameter.mapping(limits)
if self.opts.get('exclusive'):
typ = 'radio'
else:
typ = 'bool'
for chName in self.forward:
# Recycle old values if they match the new limits
newVal = bool(oldOpts.get(chName, False))
child = BoolOrRadioParameter(type=typ, name=chName, value=newVal, default=None)
self.addChild(child)
# Prevent child from broadcasting tree state changes, since this is handled by self
child.blockTreeChangeSignal()
child.sigValueChanged.connect(self._onSubParamChange)
# Purge child changes before unblocking
self.treeStateChanges.clear()
self.unblockTreeChangeSignal()
self.setValue(val)
def _onSubParamChange(self, param, value):
if self.opts['exclusive']:
val = self.reverse[0][self.reverse[1].index(param.name())]
return self.setValue(val)
# Interpret value, fire sigValueChanged
return self.setValue(self.value())
def optsChanged(self, param, opts):
if 'exclusive' in opts:
# Force set value to ensure updates
# self.opts['value'] = self._VALUE_UNSET
self.updateLimits(None, self.opts.get('limits', []))
def value(self):
vals = [self.forward[p.name()] for p in self.children() if p.value()]
exclusive = self.opts['exclusive']
if not vals and exclusive:
return None
elif exclusive:
return vals[0]
else:
return vals
def setValue(self, value, blockSignal=None):
self.targetValue = value
exclusive = self.opts['exclusive']
# Will emit at the end, so no problem discarding existing changes
cmpVals = value if isinstance(value, list) else [value]
for ii in range(len(cmpVals)-1, -1, -1):
exists = any(fn.eq(cmpVals[ii], lim) for lim in self.reverse[0])
if not exists:
del cmpVals[ii]
names = [self.reverse[1][self.reverse[0].index(val)] for val in cmpVals]
if exclusive and len(names) > 1:
names = [names[0]]
elif exclusive and not len(names) and len(self.forward):
# An option is required during exclusivity
names = [self.reverse[1][0]]
for chParam in self:
checked = chParam.name() in names
chParam.setValue(checked, self._onSubParamChange)
super().setValue(self.value(), blockSignal)

View File

@ -1,19 +1,9 @@
import numpy as np
from .basetypes import WidgetParameterItem
from .basetypes import WidgetParameterItem, Emitter
from .. import Parameter
from ...Qt import QtCore, QtWidgets
class Emitter(QtCore.QObject):
"""
WidgetParameterItem is not a QObject, and the slider's value needs to be converted before
emitting. So, create an emitter class here that can be used instead
"""
sigChanging = QtCore.Signal(object, object)
sigChanged = QtCore.Signal(object, object)
class SliderParameterItem(WidgetParameterItem):
slider: QtWidgets.QSlider
span: np.ndarray