diff --git a/doc/source/parametertree/parametertypes.rst b/doc/source/parametertree/parametertypes.rst index ac46238b..ef524440 100644 --- a/doc/source/parametertree/parametertypes.rst +++ b/doc/source/parametertree/parametertypes.rst @@ -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: diff --git a/examples/parametertree.py b/examples/parametertree.py index 936e5ba6..d621edd3 100644 --- a/examples/parametertree.py +++ b/examples/parametertree.py @@ -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"}, diff --git a/pyqtgraph/parametertree/parameterTypes/__init__.py b/pyqtgraph/parametertree/parameterTypes/__init__.py index 761e5032..5052d591 100644 --- a/pyqtgraph/parametertree/parameterTypes/__init__.py +++ b/pyqtgraph/parametertree/parameterTypes/__init__.py @@ -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('color', ColorParameter, override=True) -registerParameterType('colormap', ColorMapParameter, 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) +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('pen', PenParameter, override=True) +registerParameterType('progress', ProgressBarParameter, override=True) # qtenum is a bit specific, hold off on registering for now +registerParameterType('slider', SliderParameter, override=True) +registerParameterType('text', TextParameter, override=True) diff --git a/pyqtgraph/parametertree/parameterTypes/basetypes.py b/pyqtgraph/parametertree/parameterTypes/basetypes.py index f4868763..ffc55a37 100644 --- a/pyqtgraph/parametertree/parameterTypes/basetypes.py +++ b/pyqtgraph/parametertree/parameterTypes/basetypes.py @@ -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) diff --git a/pyqtgraph/parametertree/parameterTypes/checklist.py b/pyqtgraph/parametertree/parameterTypes/checklist.py new file mode 100644 index 00000000..88a436f3 --- /dev/null +++ b/pyqtgraph/parametertree/parameterTypes/checklist.py @@ -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) diff --git a/pyqtgraph/parametertree/parameterTypes/slider.py b/pyqtgraph/parametertree/parameterTypes/slider.py index 85b70035..a085b4da 100644 --- a/pyqtgraph/parametertree/parameterTypes/slider.py +++ b/pyqtgraph/parametertree/parameterTypes/slider.py @@ -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