diff --git a/doc/source/parametertree/parameter.rst b/doc/source/parametertree/parameter.rst index fed39f3d..51835553 100644 --- a/doc/source/parametertree/parameter.rst +++ b/doc/source/parametertree/parameter.rst @@ -3,6 +3,8 @@ Parameter .. autofunction:: pyqtgraph.parametertree.registerParameterType +.. autofunction:: pyqtgraph.parametertree.registerParameterItemType + .. autoclass:: pyqtgraph.parametertree.Parameter :members: diff --git a/doc/source/parametertree/parametertypes.rst b/doc/source/parametertree/parametertypes.rst index b82c1b50..ac46238b 100644 --- a/doc/source/parametertree/parametertypes.rst +++ b/doc/source/parametertree/parametertypes.rst @@ -1,3 +1,7 @@ +.. + This file is auto-generated from pyqtgraph/tools/rebuildPtreeRst.py. Do not modify by hand! Instead, rerun the + generation script with `python pyqtgraph/tools/rebuildPtreeRst.py`. + Built-in Parameter Types ======================== @@ -6,7 +10,22 @@ Built-in Parameter Types Parameters ---------- -.. autoclass:: SimpleParameter +.. autoclass:: ActionParameter + :members: + +.. autoclass:: CalendarParameter + :members: + +.. autoclass:: ColorMapParameter + :members: + +.. autoclass:: ColorParameter + :members: + +.. autoclass:: FileParameter + :members: + +.. autoclass:: FontParameter :members: .. autoclass:: GroupParameter @@ -15,34 +34,43 @@ Parameters .. autoclass:: ListParameter :members: -.. autoclass:: TextParameter - :members: - -.. autoclass:: ActionParameter - :members: - -.. autoclass:: FileParameter - :members: - -.. autoclass:: CalendarParameter +.. autoclass:: PenParameter :members: .. autoclass:: ProgressBarParameter :members: -.. autoclass:: FontParameter - :members: - -.. autoclass:: PenParameter +.. autoclass:: SimpleParameter :members: .. autoclass:: SliderParameter :members: +.. autoclass:: TextParameter + :members: + ParameterItems -------------- -.. autoclass:: WidgetParameterItem +.. autoclass:: ActionParameterItem + :members: + +.. autoclass:: BoolParameterItem + :members: + +.. autoclass:: CalendarParameterItem + :members: + +.. autoclass:: ColorMapParameterItem + :members: + +.. autoclass:: ColorParameterItem + :members: + +.. autoclass:: FileParameterItem + :members: + +.. autoclass:: FontParameterItem :members: .. autoclass:: GroupParameterItem @@ -51,23 +79,20 @@ ParameterItems .. autoclass:: ListParameterItem :members: -.. autoclass:: TextParameterItem - :members: - -.. autoclass:: FileParameterItem - :members: - -.. autoclass:: CalendarParameterItem - :members: - -.. autoclass:: ProgressBarParameterItem - :members: - -.. autoclass:: FontParameterItem +.. autoclass:: NumericParameterItem :members: .. autoclass:: PenParameterItem :members: +.. autoclass:: ProgressBarParameterItem + :members: + .. autoclass:: SliderParameterItem :members: + +.. autoclass:: StrParameterItem + :members: + +.. autoclass:: TextParameterItem + :members: diff --git a/pyqtgraph/parametertree/Parameter.py b/pyqtgraph/parametertree/Parameter.py index 0fa7571e..bc3c2315 100644 --- a/pyqtgraph/parametertree/Parameter.py +++ b/pyqtgraph/parametertree/Parameter.py @@ -9,6 +9,24 @@ import warnings PARAM_TYPES = {} PARAM_NAMES = {} +_PARAM_ITEM_TYPES = {} + +def registerParameterItemType(name, itemCls, parameterCls=None, override=False): + """ + Similar to :func:`registerParameterType`, but works on ParameterItems. This is useful for Parameters where the + `itemClass` does all the heavy lifting, and a redundant Parameter class must be defined just to house `itemClass`. + Instead, use `registerParameterItemType`. If this should belong to a subclass of `Parameter`, specify which one + in `parameterCls`. + """ + global _PARAM_ITEM_TYPES + if name in _PARAM_ITEM_TYPES and not override: + raise Exception("Parameter item type '%s' already exists (use override=True to replace)" % name) + + parameterCls = parameterCls or Parameter + _PARAM_ITEM_TYPES[name] = itemCls + registerParameterType(name, parameterCls, override) + + def registerParameterType(name, cls, override=False): """Register a parameter type in the parametertree system. @@ -67,6 +85,8 @@ class Parameter(QtCore.QObject): """ ## name, type, limits, etc. ## can also carry UI hints (slider vs spinbox, etc.) + + itemClass = None sigValueChanged = QtCore.Signal(object, object) ## self, value emitted when value is finished being edited sigValueChanging = QtCore.Signal(object, object) ## self, value emitted as value is being edited @@ -545,11 +565,10 @@ class Parameter(QtCore.QObject): to display this Parameter. Most subclasses will want to override this function. """ - if hasattr(self, 'itemClass'): - #print "Param:", self, "Make item from itemClass:", self.itemClass - return self.itemClass(self, depth) - else: - return ParameterItem(self, depth=depth) + # Default to user-specified itemClass. If not present, check for a registered item class. Finally, + # revert to ParameterItem if both fail + itemClass = self.itemClass or _PARAM_ITEM_TYPES.get(self.opts['type'], ParameterItem) + return itemClass(self, depth) def addChild(self, child, autoIncrementName=None): diff --git a/pyqtgraph/parametertree/ParameterItem.py b/pyqtgraph/parametertree/ParameterItem.py index cff56484..783493fd 100644 --- a/pyqtgraph/parametertree/ParameterItem.py +++ b/pyqtgraph/parametertree/ParameterItem.py @@ -137,7 +137,7 @@ class ParameterItem(QtGui.QTreeWidgetItem): if self.ignoreNameColumnChange: return try: - newName = self.param.setName(str(self.text(col))) + newName = self.param.setName(self.text(col)) except Exception: self.setText(0, self.param.name()) raise diff --git a/pyqtgraph/parametertree/__init__.py b/pyqtgraph/parametertree/__init__.py index 722410d5..972c3492 100644 --- a/pyqtgraph/parametertree/__init__.py +++ b/pyqtgraph/parametertree/__init__.py @@ -1,5 +1,5 @@ -from .Parameter import Parameter, registerParameterType +from .Parameter import Parameter, registerParameterType, registerParameterItemType from .ParameterTree import ParameterTree from .ParameterItem import ParameterItem from .ParameterSystem import ParameterSystem, SystemSolver -from . import parameterTypes as types \ No newline at end of file +from . import parameterTypes as types diff --git a/pyqtgraph/parametertree/parameterTypes.py b/pyqtgraph/parametertree/parameterTypes.py deleted file mode 100644 index b810948d..00000000 --- a/pyqtgraph/parametertree/parameterTypes.py +++ /dev/null @@ -1,1381 +0,0 @@ -# -*- coding: utf-8 -*- -from ..Qt import QtCore, QtGui, QtWidgets, QT_LIB -from .Parameter import Parameter, registerParameterType -from .ParameterItem import ParameterItem -from ..widgets.SpinBox import SpinBox -from ..widgets.ColorButton import ColorButton -from ..widgets.PenSelectorDialog import PenSelectorDialog -from ..colormap import ColorMap -from .. import icons as icons -from .. import functions as fn -from collections import OrderedDict -import re -import numpy as np -import os - - -class WidgetParameterItem(ParameterItem): - """ - ParameterTree item with: - - * label in second column for displaying value - * simple widget for editing value (displayed instead of label when item is selected) - * button that resets value to default - - ========================== ============================================================= - **Registered Types:** - int Displays a :class:`SpinBox ` in integer - mode. - float Displays a :class:`SpinBox `. - bool Displays a QCheckBox - str Displays a QLineEdit - color Displays a :class:`ColorButton ` - colormap Displays a :class:`GradientWidget ` - ========================== ============================================================= - - This class can be subclassed by overriding makeWidget() to provide a custom widget. - """ - 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 with a display label and default button - w = self.makeWidget() - self.widget = w - self.eventProxy = EventProxy(w, self.widgetEventFilter) - - if self.asSubItem: - self.subItem = QtWidgets.QTreeWidgetItem() - self.subItem.depth = self.depth + 1 - self.subItem.setFlags(QtCore.Qt.ItemFlag.NoItemFlags) - self.addChild(self.subItem) - - self.defaultBtn = self.makeDefaultButton() - - self.displayLabel = QtWidgets.QLabel() - - layout = QtWidgets.QHBoxLayout() - layout.setContentsMargins(0, 0, 0, 0) - layout.setSpacing(2) - if not self.asSubItem: - layout.addWidget(w, 1) - layout.addWidget(self.displayLabel, 1) - layout.addStretch(0) - layout.addWidget(self.defaultBtn) - self.layoutWidget = QtWidgets.QWidget() - self.layoutWidget.setLayout(layout) - - if w.sigChanged is not None: - w.sigChanged.connect(self.widgetValueChanged) - - if hasattr(w, 'sigChanging'): - w.sigChanging.connect(self.widgetValueChanging) - - ## update value shown in widget. - opts = self.param.opts - if opts.get('value', None) is not None: - self.valueChanged(self, opts['value'], force=True) - else: - ## no starting value was given; use whatever the widget has - self.widgetValueChanged() - - self.updateDefaultBtn() - - 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 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: - - ========== ============================================================ - sigChanged a signal that is emitted when the widget's value is changed - value a function that returns the value - setValue a function that sets the value - ========== ============================================================ - - This is a good function to override in subclasses. - """ - opts = self.param.opts - t = opts['type'] - if t in ('int', 'float'): - defs = { - 'value': 0, 'min': None, 'max': None, - 'step': 1.0, 'dec': False, - 'siPrefix': False, 'suffix': '', 'decimals': 3, - } - if t == 'int': - defs['int'] = True - defs['minStep'] = 1.0 - for k in defs: - if k in opts: - defs[k] = opts[k] - if 'limits' in opts: - defs['min'], defs['max'] = opts['limits'] - w = SpinBox() - w.setOpts(**defs) - w.sigChanged = w.sigValueChanged - w.sigChanging = w.sigValueChanging - elif t == 'bool': - w = QtWidgets.QCheckBox() - w.sigChanged = w.toggled - w.value = w.isChecked - w.setValue = w.setChecked - self.hideWidget = False - elif t == 'str': - w = QtWidgets.QLineEdit() - w.setStyleSheet('border: 0px') - w.sigChanged = w.editingFinished - w.value = w.text - w.setValue = w.setText - w.sigChanging = w.textChanged - elif t == 'color': - w = ColorButton() - w.sigChanged = w.sigColorChanged - w.sigChanging = w.sigColorChanging - w.value = w.color - w.setValue = w.setColor - self.hideWidget = False - w.setFlat(True) - 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'" % str(t)) - return w - - def widgetEventFilter(self, obj, ev): - ## filter widget's events - ## catch TAB to change focus - ## catch focusOut to hide editor - if ev.type() == ev.Type.KeyPress: - if ev.key() == QtCore.Qt.Key.Key_Tab: - self.focusNext(forward=True) - return True ## don't let anyone else see this event - elif ev.key() == QtCore.Qt.Key.Key_Backtab: - self.focusNext(forward=False) - return True ## don't let anyone else see this event - - return False - - def makeDefaultButton(self): - defaultBtn = QtWidgets.QPushButton() - defaultBtn.setAutoDefault(False) - defaultBtn.setFixedWidth(20) - defaultBtn.setFixedHeight(20) - defaultBtn.setIcon(icons.getGraphIcon('default')) - defaultBtn.clicked.connect(self.defaultClicked) - return defaultBtn - - def setFocus(self): - self.showEditor() - - def isFocusable(self): - 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) - if force or not fn.eq(val, self.widget.value()): - try: - if self.widget.sigChanged is not None: - self.widget.sigChanged.disconnect(self.widgetValueChanged) - self.param.sigValueChanged.disconnect(self.valueChanged) - self.widget.setValue(val) - self.param.setValue(self.widget.value()) - finally: - if self.widget.sigChanged is not None: - 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.opts['enabled'] and self.param.writable()) - - # hide / show - self.defaultBtn.setVisible(self.param.hasDefault() and not self.param.readonly()) - - def updateDisplayLabel(self, value=None): - """Update the display label to reflect the value of the parameter.""" - if value is None: - value = self.param.value() - opts = self.param.opts - if isinstance(self.widget, QtWidgets.QAbstractSpinBox): - text = self.widget.lineEdit().text() - elif isinstance(self.widget, QtWidgets.QComboBox): - text = self.widget.currentText() - else: - text = str(value) - self.displayLabel.setText(text) - - def widgetValueChanged(self): - ## called when the widget's value has been changed by the user - val = self.widget.value() - newVal = self.param.setValue(val) - - def widgetValueChanging(self, *args): - """ - Called when the widget's value is changing, but not finalized. - For example: editing text before pressing enter or changing focus. - """ - 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)""" - ParameterItem.selected(self, sel) - - if self.widget is None: - return - if sel and self.param.writable(): - self.showEditor() - elif self.hideWidget: - self.hideEditor() - - def showEditor(self): - self.widget.show() - self.displayLabel.hide() - self.widget.setFocus(QtCore.Qt.FocusReason.OtherFocusReason) - if isinstance(self.widget, SpinBox): - self.widget.selectNumber() # select the numerical portion of the text for quick editing - - def hideEditor(self): - self.widget.hide() - self.displayLabel.show() - - def limitsChanged(self, param, limits): - """Called when the parameter's limits have changed""" - ParameterItem.limitsChanged(self, param, limits) - - t = self.param.opts['type'] - if t == 'int' or t == 'float': - self.widget.setOpts(bounds=limits) - else: - return ## don't know what to do with any other types.. - - def defaultChanged(self, param, value): - self.updateDefaultBtn() - - def treeWidgetChanged(self): - """Called when this item is added or removed from a tree.""" - ParameterItem.treeWidgetChanged(self) - - ## add all widgets for this item into the tree - if self.widget is not None: - 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) - - def defaultClicked(self): - self.param.setToDefault() - - def optsChanged(self, param, opts): - """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 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']) - - ## If widget is a SpinBox, pass options straight through - if isinstance(self.widget, SpinBox): - # send only options supported by spinbox - sbOpts = {} - if 'units' in opts and 'suffix' not in opts: - sbOpts['suffix'] = opts['units'] - for k,v in opts.items(): - if k in self.widget.opts: - sbOpts[k] = v - self.widget.setOpts(**sbOpts) - self.updateDisplayLabel() - - -class EventProxy(QtCore.QObject): - def __init__(self, qobj, callback): - QtCore.QObject.__init__(self) - self.callback = callback - qobj.installEventFilter(self) - - def eventFilter(self, obj, ev): - return self.callback(obj, ev) - - -class SimpleParameter(Parameter): - """Parameter representing a single value. - - This parameter is backed by :class:`WidgetParameterItem` to represent the - following parameter names: - - - 'int' - - 'float' - - 'bool' - - 'str' - - 'color' - - 'colormap' - """ - itemClass = WidgetParameterItem - - def __init__(self, *args, **kargs): - """Initialize the parameter. - - This is normally called implicitly through :meth:`Parameter.create`. - The keyword arguments avaialble to :meth:`Parameter.__init__` are - applicable. - """ - 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 fn.mkColor(Parameter.value(self)) - - def saveColorState(self, *args, **kwds): - state = Parameter.saveState(self, *args, **kwds) - state['value'] = fn.colorTuple(self.value()) - return state - - def _interpretValue(self, v): - fn = { - 'int': int, - 'float': float, - 'bool': bool, - 'str': str, - 'color': self._interpColor, - 'colormap': self._interpColormap, - }[self.opts['type']] - return fn(v) - - def _interpColor(self, v): - return fn.mkColor(v) - - def _interpColormap(self, v): - if not isinstance(v, ColorMap): - raise TypeError("Cannot set colormap parameter from object %r" % v) - return v - - -registerParameterType('int', SimpleParameter, override=True) -registerParameterType('float', SimpleParameter, override=True) -registerParameterType('bool', SimpleParameter, override=True) -registerParameterType('str', SimpleParameter, override=True) -registerParameterType('color', SimpleParameter, override=True) -registerParameterType('colormap', SimpleParameter, override=True) - - -class GroupParameterItem(ParameterItem): - """ - 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. - """ - def __init__(self, param, depth): - ParameterItem.__init__(self, param, depth) - self.updateDepth(depth) - - self.addItem = None - if 'addText' in param.opts: - addText = param.opts['addText'] - if 'addList' in param.opts: - self.addWidget = QtWidgets.QComboBox() - self.addWidget.setSizeAdjustPolicy(QtWidgets.QComboBox.SizeAdjustPolicy.AdjustToContents) - self.updateAddList() - self.addWidget.currentIndexChanged.connect(self.addChanged) - else: - self.addWidget = QtWidgets.QPushButton(addText) - self.addWidget.clicked.connect(self.addClicked) - w = QtWidgets.QWidget() - l = QtWidgets.QHBoxLayout() - l.setContentsMargins(0,0,0,0) - w.setLayout(l) - l.addWidget(self.addWidget) - l.addStretch() - self.addWidgetBox = w - self.addItem = QtWidgets.QTreeWidgetItem([]) - self.addItem.setFlags(QtCore.Qt.ItemFlag.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. - if depth == 0: - for c in [0,1]: - self.setBackground(c, QtGui.QBrush(QtGui.QColor(100,100,100))) - self.setForeground(c, QtGui.QBrush(QtGui.QColor(220,220,255))) - font = self.font(c) - font.setBold(True) - font.setPointSize(font.pointSize()+1) - self.setFont(c, font) - else: - for c in [0,1]: - self.setBackground(c, QtGui.QBrush(QtGui.QColor(220,220,220))) - self.setForeground(c, QtGui.QBrush(QtGui.QColor(50,50,50))) - font = self.font(c) - font.setBold(True) - #font.setPointSize(font.pointSize()+1) - self.setFont(c, font) - 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. - """ - self.param.addNew() - - def addChanged(self): - """Called when "add new" combo is changed - The parameter MUST have an 'addNew' method defined. - """ - if self.addWidget.currentIndex() == 0: - return - typ = self.addWidget.currentText() - self.param.addNew(typ) - self.addWidget.setCurrentIndex(0) - - def treeWidgetChanged(self): - ParameterItem.treeWidgetChanged(self) - tw = self.treeWidget() - if tw is None: - return - self.setFirstColumnSpanned(True) - if self.addItem is not None: - tw.setItemWidget(self.addItem, 0, self.addWidgetBox) - self.addItem.setFirstColumnSpanned(True) - - def addChild(self, child): ## make sure added childs are actually inserted before add btn - if self.addItem is not None: - ParameterItem.insertChild(self, self.childCount()-1, child) - else: - ParameterItem.addChild(self, child) - - def optsChanged(self, param, opts): - ParameterItem.optsChanged(self, param, opts) - - 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: - 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. 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 - - sigAddNew = QtCore.Signal(object, object) # self, type - - def addNew(self, typ=None): - """ - This method is called when the user has requested to add a new item to the group. - By default, it emits ``sigAddNew(self, typ)``. - """ - self.sigAddNew.emit(self, typ) - - 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) - - -class ListParameterItem(WidgetParameterItem): - """ - WidgetParameterItem subclass providing comboBox that lets the user select from a list of options. - - """ - def __init__(self, param, depth): - self.targetValue = None - WidgetParameterItem.__init__(self, param, depth) - - def makeWidget(self): - opts = self.param.opts - t = opts['type'] - w = QtWidgets.QComboBox() - w.setMaximumHeight(20) ## set to match height of spin box and line edit - w.sigChanged = w.currentIndexChanged - w.value = self.value - w.setValue = self.setValue - self.widget = w ## needs to be set before limits are changed - self.limitsChanged(self.param, self.param.opts['limits']) - if len(self.forward) > 0: - self.setValue(self.param.value()) - return w - - def value(self): - key = self.widget.currentText() - - return self.forward.get(key, None) - - def setValue(self, val): - self.targetValue = val - if val not in self.reverse[0]: - self.widget.setCurrentIndex(0) - else: - key = self.reverse[1][self.reverse[0].index(val)] - ind = self.widget.findText(key) - self.widget.setCurrentIndex(ind) - - def limitsChanged(self, param, limits): - # set up forward / reverse mappings for name:value - - 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 = self.targetValue - - 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) - - -class ListParameter(Parameter): - """Parameter with a list of acceptable values. - - By default, this parameter is represtented by a :class:`ListParameterItem`, - displaying a combo box to select a value from the list. - - In addition to the generic :class:`~pyqtgraph.parametertree.Parameter` - options, this parameter type accepts a ``limits`` argument specifying the - list of allowed values. ``values`` is an alias and may be used instead. - - The values may generally be of any data type, as long as they can be - represented as a string. If the string representation provided is - undesirable, the values may be given as a dictionary mapping the desired - string representation to the value. - """ - - itemClass = ListParameterItem - - def __init__(self, **opts): - self.forward = OrderedDict() ## {name: value, ...} - self.reverse = ([], []) ## ([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) - self.setLimits(opts['limits']) - - def setLimits(self, limits): - """Change the list of allowed values.""" - self.forward, self.reverse = self.mapping(limits) - - Parameter.setLimits(self, limits) - if len(self.reverse[0]) > 0 and self.value() not in self.reverse[0]: - self.setValue(self.reverse[0][0]) - - @staticmethod - def mapping(limits): - # Return forward and reverse mapping objects given a limit specification - forward = OrderedDict() ## {name: value, ...} - reverse = ([], []) ## ([value, ...], [name, ...]) - if isinstance(limits, dict): - for k, v in limits.items(): - forward[k] = v - reverse[0].append(v) - reverse[1].append(k) - else: - for v in limits: - n = str(v) - forward[n] = v - reverse[0].append(v) - reverse[1].append(n) - return forward, reverse - -registerParameterType('list', ListParameter, override=True) - -class ActionParameterItem(ParameterItem): - """ParameterItem displaying a clickable button.""" - def __init__(self, param, depth): - ParameterItem.__init__(self, param, depth) - self.layoutWidget = QtWidgets.QWidget() - self.layout = QtWidgets.QHBoxLayout() - self.layout.setContentsMargins(0, 0, 0, 0) - self.layoutWidget.setLayout(self.layout) - self.button = QtWidgets.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() - if tree is None: - return - - self.setFirstColumnSpanned(True) - tree.setItemWidget(self, 0, self.layoutWidget) - - def titleChanged(self): - self.button.setText(self.param.title()) - 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() - -class ActionParameter(Parameter): - """Used for displaying a button within the tree. - - ``sigActivated(self)`` is emitted when the button is clicked. - """ - 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): - """ParameterItem displaying a QTextEdit widget.""" - - def makeWidget(self): - self.hideWidget = False - self.asSubItem = True - self.textBox = w = QtWidgets.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) - - -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) - - -def _set_filepicker_kwargs(fileDlg, **kwargs): - """Applies a dict of enum/flag kwarg opts to a file dialog""" - NO_MATCH = object() - - for kk, vv in kwargs.items(): - # Convert string or list representations into true flags - # 'fileMode' -> 'FileMode' - formattedName = kk[0].upper() + kk[1:] - # Edge case: "Options" has enum "Option" - if formattedName == 'Options': - enumCls = fileDlg.Option - else: - enumCls = getattr(fileDlg, formattedName, NO_MATCH) - setFunc = getattr(fileDlg, f'set{formattedName}', NO_MATCH) - if enumCls is NO_MATCH or setFunc is NO_MATCH: - continue - if enumCls is fileDlg.Option: - builder = fileDlg.Option(0) - # This is the only flag enum, all others can only take one value - if isinstance(vv, str): vv = [vv] - for flag in vv: - curVal = getattr(enumCls, flag) - builder |= curVal - # Some Qt implementations turn into ints by this point - outEnum = enumCls(builder) - else: - outEnum = getattr(enumCls, vv) - setFunc(outEnum) - -def popupFilePicker(parent=None, windowTitle='', nameFilter='', directory=None, selectFile=None, relativeTo=None, **kwargs): - """ - Thin wrapper around Qt file picker dialog. Used internally so all options are consistent - among all requests for external file information - - ============== ======================================================== - **Arguments:** - parent Dialog parent - windowTitle Title of dialog window - nameFilter File filter as required by the Qt dialog - directory Where in the file system to open this dialog - selectFile File to preselect - relativeTo Parent directory that, if provided, will be removed from the prefix of all returned paths. So, - if '/my/text/file.txt' was selected, and `relativeTo='/my/text/'`, the return value would be - 'file.txt'. This uses os.path.relpath under the hood, so expect that behavior. - kwargs Any enum value accepted by a QFileDialog and its value. Values can be a string or list of strings, - i.e. fileMode='AnyFile', options=['ShowDirsOnly', 'DontResolveSymlinks'], acceptMode='AcceptSave' - ============== ======================================================== - - """ - fileDlg = QtWidgets.QFileDialog(parent) - _set_filepicker_kwargs(fileDlg, **kwargs) - - fileDlg.setModal(True) - if directory is not None: - fileDlg.setDirectory(directory) - fileDlg.setNameFilter(nameFilter) - if selectFile is not None: - fileDlg.selectFile(selectFile) - - fileDlg.setWindowTitle(windowTitle) - - if fileDlg.exec(): - # Append filter type - singleExtReg = r'(\.\w+)' - # Extensions of type 'myfile.ext.is.multi.part' need to capture repeating pattern of singleExt - suffMatch = re.search(rf'({singleExtReg}+)', fileDlg.selectedNameFilter()) - if suffMatch: - # Strip leading '.' if it exists - ext = suffMatch.group(1) - if ext.startswith('.'): - ext = ext[1:] - fileDlg.setDefaultSuffix(ext) - fList = fileDlg.selectedFiles() - else: - fList = [] - if relativeTo is not None: - fList = [os.path.relpath(file, relativeTo) for file in fList] - # Make consistent to os flavor - fList = [os.path.normpath(file) for file in fList] - if fileDlg.fileMode() == fileDlg.FileMode.ExistingFiles: - return fList - elif len(fList) > 0: - return fList[0] - else: - return None - -class FileParameterItem(WidgetParameterItem): - def __init__(self, param, depth): - self._value = None - # Temporarily consider string during construction - oldType = param.opts.get('type') - param.opts['type'] = 'str' - super().__init__(param, depth) - param.opts['type'] = oldType - - button = QtWidgets.QPushButton('...') - button.setFixedWidth(25) - button.setContentsMargins(0, 0, 0, 0) - button.clicked.connect(self._retrieveFileSelection_gui) - self.layoutWidget.layout().insertWidget(2, button) - self.displayLabel.resizeEvent = self._newResizeEvent - # self.layoutWidget.layout().insertWidget(3, self.defaultBtn) - - def makeWidget(self): - w = super().makeWidget() - w.setValue = self.setValue - w.value = self.value - # Doesn't make much sense to have a 'changing' signal since filepaths should be complete before value - # is emitted - delattr(w, 'sigChanging') - return w - - def _newResizeEvent(self, ev): - ret = type(self.displayLabel).resizeEvent(self.displayLabel, ev) - self.updateDisplayLabel() - return ret - - def setValue(self, value): - self._value = value - self.widget.setText(str(value)) - - def value(self): - return self._value - - def _retrieveFileSelection_gui(self): - curVal = self.param.value() - if isinstance(curVal, list) and len(curVal): - # All files should be from the same directory, in principle - # Since no mechanism exists for preselecting multiple, the most sensible - # thing is to select nothing in the preview dialog - curVal = curVal[0] - if os.path.isfile(curVal): - curVal = os.path.dirname(curVal) - opts = self.param.opts.copy() - useDir = curVal or opts.get('directory') or os.getcwd() - startDir = os.path.abspath(useDir) - if os.path.isfile(startDir): - opts['selectFile'] = os.path.basename(startDir) - startDir = os.path.dirname(startDir) - if os.path.exists(startDir): - opts['directory'] = startDir - opts.setdefault('windowTitle', self.param.title()) - - fname = popupFilePicker(None, **opts) - if not fname: - return - self.param.setValue(fname) - - def updateDefaultBtn(self): - # Override since a readonly label should still allow reverting to default - ## enable/disable default btn - self.defaultBtn.setEnabled( - not self.param.valueIsDefault() and self.param.opts['enabled']) - - # hide / show - self.defaultBtn.setVisible(self.param.hasDefault()) - - def updateDisplayLabel(self, value=None): - lbl = self.displayLabel - if value is None: - value = self.param.value() - value = str(value) - font = lbl.font() - metrics = QtGui.QFontMetricsF(font) - value = metrics.elidedText(value, QtCore.Qt.TextElideMode.ElideLeft, lbl.width()-5) - return super().updateDisplayLabel(value) - -class FileParameter(Parameter): - """ - Interfaces with the myriad of file options available from a QFileDialog. - - Note that the output can either be a single file string or list of files, depending on whether - `fileMode='ExistingFiles'` is specified. - - Note that in all cases, absolute file paths are returned unless `relativeTo` is specified as - elaborated below. - - ============== ======================================================== - **Options:** - parent Dialog parent - winTitle Title of dialog window - nameFilter File filter as required by the Qt dialog - directory Where in the file system to open this dialog - selectFile File to preselect - relativeTo Parent directory that, if provided, will be removed from the prefix of all returned paths. So, - if '/my/text/file.txt' was selected, and `relativeTo='my/text/'`, the return value would be - 'file.txt'. This uses os.path.relpath under the hood, so expect that behavior. - kwargs Any enum value accepted by a QFileDialog and its value. Values can be a string or list of strings, - i.e. fileMode='AnyFile', options=['ShowDirsOnly', 'DontResolveSymlinks'] - ============== ======================================================== - """ - itemClass = FileParameterItem - - def __init__(self, **opts): - opts.setdefault('readonly', True) - super().__init__(**opts) - - -class ProgressBarParameterItem(WidgetParameterItem): - def makeWidget(self): - w = QtWidgets.QProgressBar() - w.setMaximumHeight(20) - w.sigChanged = w.valueChanged - self.hideWidget = False - return w - -class ProgressBarParameter(Parameter): - """ - Displays a progress bar whose value can be set between 0 and 100 - """ - itemClass = ProgressBarParameterItem - -class SliderParameterItem(WidgetParameterItem): - slider: QtWidgets.QSlider - span: np.ndarray - charSpan: np.ndarray - - def __init__(self, param, depth): - # Bind emitter to self to avoid garbage collection - self.emitter = Emitter() - self.sigChanging = self.emitter.sigChanging - self._suffix = None - super().__init__(param, depth) - - def updateDisplayLabel(self, value=None): - if value is None: - value = self.param.value() - value = str(value) - if self._suffix is None: - suffixTxt = '' - else: - suffixTxt = f' {self._suffix}' - self.displayLabel.setText(value + suffixTxt) - - def setSuffix(self, suffix): - self._suffix = suffix - self._updateLabel(self.slider.value()) - - def makeWidget(self): - param = self.param - opts = param.opts - self._suffix = opts.get('suffix') - - self.slider = QtWidgets.QSlider() - self.slider.setOrientation(QtCore.Qt.Orientation.Horizontal) - lbl = QtWidgets.QLabel() - lbl.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft) - - w = QtWidgets.QWidget() - layout = QtWidgets.QHBoxLayout() - w.setLayout(layout) - layout.addWidget(lbl) - layout.addWidget(self.slider) - - def setValue(v): - self.slider.setValue(self.spanToSliderValue(v)) - def getValue(): - return self.span[self.slider.value()].item() - - def vChanged(v): - lbl.setText(self.prettyTextValue(v)) - self.slider.valueChanged.connect(vChanged) - - def onMove(pos): - self.sigChanging.emit(self, self.span[pos].item()) - self.slider.sliderMoved.connect(onMove) - - w.setValue = setValue - w.value = getValue - w.sigChanged = self.slider.valueChanged - w.sigChanging = self.sigChanging - self.optsChanged(param, opts) - return w - - # def updateDisplayLabel(self, value=None): - # self.displayLabel.setText(self.prettyTextValue(value)) - - def spanToSliderValue(self, v): - return int(np.argmin(np.abs(self.span-v))) - - def prettyTextValue(self, v): - if self._suffix is None: - suffixTxt = '' - else: - suffixTxt = f' {self._suffix}' - format_ = self.param.opts.get('format', None) - cspan = self.charSpan - if format_ is None: - format_ = f'{{0:>{cspan.dtype.itemsize}}}{suffixTxt}' - return format_.format(cspan[v].decode()) - - def optsChanged(self, param, opts): - try: - super().optsChanged(param, opts) - except AttributeError as ex: - pass - span = opts.get('span', None) - if span is None: - step = opts.get('step', 1) - start, stop = opts['limits'] - # Add a bit to 'stop' since python slicing excludes the last value - span = np.arange(start, stop+step, step) - precision = opts.get('precision', 2) - if precision is not None: - span = span.round(precision) - self.span = span - self.charSpan = np.char.array(span) - w = self.slider - w.setMinimum(0) - w.setMaximum(len(span)-1) - if 'suffix' in opts: - self.setSuffix(opts['suffix']) - self.slider.valueChanged.emit(self.slider.value()) - - def limitsChanged(self, param, limits): - self.optsChanged(param, dict(limits=limits)) - -class SliderParameter(Parameter): - """ - ============== ======================================================== - **Options** - limits [start, stop] numbers - step: Defaults to 1, the spacing between each slider tick - span: Instead of limits + step, span can be set to specify - the range of slider options (e.g. np.linspace(-pi, pi, 100)) - format: Format string to determine number of decimals to show, etc. - Defaults to display based on span dtype - precision: int number of decimals to keep for float tick spaces - ============== ======================================================== - """ - itemClass = SliderParameterItem - -class FontParameterItem(WidgetParameterItem): - def makeWidget(self): - w = QtWidgets.QFontComboBox() - w.setMaximumHeight(20) - w.sigChanged = w.currentFontChanged - w.value = w.currentFont - w.setValue = w.setCurrentFont - self.hideWidget = False - return w - -class FontParameter(Parameter): - """ - Creates and controls a QFont value. Be careful when selecting options from the font dropdown. since not all - fonts are available on all systems - """ - itemClass = FontParameterItem - - def _interpretValue(self, v): - if isinstance(v, str): - newVal = QtGui.QFont() - if not newVal.fromString(v): - raise ValueError(f'Error parsing font "{v}"') - v = newVal - return v - - def saveState(self, filter=None): - state = super().saveState(filter) - state['value'] = state['value'].toString() - return state - -class CalendarParameterItem(WidgetParameterItem): - def makeWidget(self): - self.asSubItem = True - w = QtWidgets.QCalendarWidget() - w.setMaximumHeight(200) - w.sigChanged = w.selectionChanged - w.value = w.selectedDate - w.setValue = w.setSelectedDate - self.hideWidget = False - self.param.opts.setdefault('default', QtCore.QDate.currentDate()) - return w - -class CalendarParameter(Parameter): - """ - Displays a Qt calendar whose date is specified by a 'format' option. - - ============== ======================================================== - **Options:** - format Format for displaying the date and converting from a string. Can be any value accepted by - `QDate.toString` and `fromString`, or a stringified version of a QDateFormat enum, i.e. 'ISODate', - 'TextDate' (default), etc. - ============== ======================================================== - """ - - itemClass = CalendarParameterItem - - def __init__(self, **opts): - opts.setdefault('format', 'TextDate') - super().__init__(**opts) - - def _interpretFormat(self, fmt=None): - fmt = fmt or self.opts.get('format') - if hasattr(QtCore.Qt.DateFormat, fmt): - fmt = getattr(QtCore.Qt.DateFormat, fmt) - return fmt - - def _interpretValue(self, v): - if isinstance(v, str): - fmt = self._interpretFormat() - if fmt is None: - raise ValueError('Cannot parse date string without a set format') - v = QtCore.QDate.fromString(v, fmt) - return v - - def saveState(self, filter=None): - state = super().saveState(filter) - fmt = self._interpretFormat() - state['value'] = state['value'].toString(fmt) - return state - - -class QtEnumParameter(ListParameter): - def __init__(self, enum, searchObj=QtCore.Qt, **opts): - """ - Constructs a list of allowed enum values from the enum class provided - `searchObj` is only needed for PyQt5 compatibility, where it must be the module holding the enum. - For instance, if making a QtEnumParameter out of QtWidgets.QFileDialog.Option, `searchObj` would - be QtWidgets.QFileDialog - """ - self.enum = enum - self.searchObj = searchObj - opts.setdefault('name', enum.__name__) - self.enumMap = self._getAllowedEnums(enum) - - opts.update(limits=self.formattedLimits()) - super().__init__(**opts) - - def setValue(self, value, blockSignal=None): - if isinstance(value, str): - value = self.enumMap[value] - super().setValue(value, blockSignal) - - def formattedLimits(self): - # Title-cased words without the ending substring for brevity - substringEnd = None - mapping = self.enumMap - shortestName = min(len(name) for name in mapping) - names = list(mapping) - cmpName, *names = names - for ii in range(-1, -shortestName-1, -1): - if any(cmpName[ii] != curName[ii] for curName in names): - substringEnd = ii+1 - break - # Special case of 0: Set to none to avoid null string - if substringEnd == 0: - substringEnd = None - limits = {} - for kk, vv in self.enumMap.items(): - limits[kk[:substringEnd]] = vv - return limits - - def saveState(self, filter=None): - state = super().saveState(filter) - reverseMap = dict(zip(self.enumMap.values(), self.enumMap)) - state['value'] = reverseMap[state['value']] - return state - - def _getAllowedEnums(self, enum): - """Pyside provides a dict for easy evaluation""" - if 'PySide' in QT_LIB: - vals = enum.values - elif 'PyQt5' in QT_LIB: - vals = {} - for key in dir(self.searchObj): - value = getattr(self.searchObj, key) - if isinstance(value, enum): - vals[key] = value - elif 'PyQt6' in QT_LIB: - vals = {e.name: e for e in enum} - else: - raise RuntimeError(f'Cannot find associated enum values for qt lib {QT_LIB}') - # Remove "M" since it's not a real option - vals.pop(f'M{enum.__name__}', None) - return vals - -class PenParameterItem(WidgetParameterItem): - def __init__(self, param, depth): - self.pdialog = PenSelectorDialog(fn.mkPen(param.pen)) - self.pdialog.setModal(True) - self.pdialog.accepted.connect(self.penChangeFinished) - super().__init__(param, depth) - self.displayLabel.paintEvent = self.displayPaintEvent - - def makeWidget(self): - self.button = QtWidgets.QPushButton() - #larger button - self.button.setFixedWidth(100) - self.button.clicked.connect(self.buttonClicked) - self.button.paintEvent = self.buttonPaintEvent - self.button.value = self.value - self.button.setValue = self.setValue - self.button.sigChanged = None - return self.button - - @property - def pen(self): - return self.pdialog.pen - - def value(self): - return self.pen - - def setValue(self, pen): - self.pdialog.updateParamFromPen(self.pdialog.param, pen) - - def updateDisplayLabel(self, value=None): - super().updateDisplayLabel('') - self.displayLabel.update() - self.widget.update() - - def buttonClicked(self): - #open up the pen selector dialog - # Copy in case of rejection - prePen = QtGui.QPen(self.pen) - if self.pdialog.exec() != QtWidgets.QDialog.DialogCode.Accepted: - self.pdialog.updateParamFromPen(self.pdialog.param, prePen) - - def penChangeFinished(self): - self.param.setValue(self.pdialog.pen) - - def penPaintEvent(self, event, item): - # draw item as usual - type(item).paintEvent(item, event) - - path = QtGui.QPainterPath() - displaySize = item.size() - w, h = displaySize.width(), displaySize.height() - # draw a squiggle with the pen - path.moveTo(w * .2, h * .2) - path.lineTo(w * .4, h * .8) - path.cubicTo(w * .5, h * .1, w * .7, h * .1, w * .8, h * .8) - - painter = QtGui.QPainter(item) - painter.setPen(self.pen) - painter.drawPath(path) - painter.end() - - def buttonPaintEvent(self, event): - return self.penPaintEvent(event, self.button) - - def displayPaintEvent(self, event): - return self.penPaintEvent(event, self.displayLabel) - -class PenParameter(Parameter): - """ - Controls the appearance of a QPen value. - - When `saveState` is called, the value is encoded as (color, width, style, capStyle, joinStyle, cosmetic) - - ============== ======================================================== - **Options:** - color pen color, can be any argument accepted by :func:`~pyqtgraph.mkColor` (defaults to black) - width integer width >= 0 (defaults to 1) - style String version of QPenStyle enum, i.e. 'SolidLine' (default), 'DashLine', etc. - capStyle String version of QPenCapStyle enum, i.e. 'SquareCap' (default), 'RoundCap', etc. - joinStyle String version of QPenJoinStyle enum, i.e. 'BevelJoin' (default), 'RoundJoin', etc. - cosmetic Boolean, whether or not the pen is cosmetic (defaults to True) - ============== ======================================================== - """ - - itemClass = PenParameterItem - sigPenChanged = QtCore.Signal(object,object) - - def __init__(self, **opts): - self.pen = fn.mkPen() - self.penOptsParam = PenSelectorDialog.mkParam(self.pen) - super().__init__(**opts) - - def saveState(self, filter=None): - state = super().saveState(filter) - overrideState = self.penOptsParam.saveState(filter)['children'] - state['value'] = tuple(s['value'] for s in overrideState.values()) - return state - - def _interpretValue(self, v): - return self.mkPen(v) - - def setValue(self, value, blockSignal=None): - if not fn.eq(value, self.pen): - value = self.mkPen(value) - PenSelectorDialog.updateParamFromPen(self.penOptsParam, value) - return super().setValue(self.pen, blockSignal) - - def applyOptsToPen(self, **opts): - # Transform opts into a value for the current pen - paramNames = set(opts).intersection(self.penOptsParam.names) - # Value should be overridden by opts - with self.treeChangeBlocker(): - if 'value' in opts: - pen = self.mkPen(opts.pop('value')) - if not fn.eq(pen, self.pen): - PenSelectorDialog.updateParamFromPen(self.penOptsParam, pen) - penOpts = {} - for kk in paramNames: - penOpts[kk] = opts[kk] - self.penOptsParam[kk] = opts[kk] - return penOpts - - def setOpts(self, **opts): - # Transform opts into a value - penOpts = self.applyOptsToPen(**opts) - if penOpts: - self.setValue(self.pen) - return super().setOpts(**opts) - - def mkPen(self, *args, **kwargs): - """Thin wrapper around fn.mkPen which accepts the serialized state from saveState""" - if len(args) == 1 and isinstance(args[0], tuple) and len(args[0]) == len(self.penOptsParam.childs): - opts = dict(zip(self.penOptsParam.names, args[0])) - self.applyOptsToPen(**opts) - args = (self.pen,) - kwargs = {} - return fn.mkPen(*args, **kwargs) - -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) diff --git a/pyqtgraph/parametertree/parameterTypes/__init__.py b/pyqtgraph/parametertree/parameterTypes/__init__.py new file mode 100644 index 00000000..761e5032 --- /dev/null +++ b/pyqtgraph/parametertree/parameterTypes/__init__.py @@ -0,0 +1,37 @@ +from .action import ActionParameter, ActionParameterItem +from .basetypes import WidgetParameterItem, SimpleParameter, GroupParameter, GroupParameterItem +from .bool import BoolParameterItem +from .calendar import CalendarParameter, CalendarParameterItem +from .color import ColorParameter, ColorParameterItem +from .colormap import ColorMapParameter, ColorMapParameterItem +from .file import FileParameter, FileParameterItem +from .font import FontParameter, FontParameterItem +from .list import ListParameter, ListParameterItem +from .numeric import NumericParameterItem +from .pen import PenParameter, PenParameterItem +from .progress import ProgressBarParameter, ProgressBarParameterItem +from .qtenum import QtEnumParameter +from .slider import SliderParameter, SliderParameterItem +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('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) +# qtenum is a bit specific, hold off on registering for now diff --git a/pyqtgraph/parametertree/parameterTypes/action.py b/pyqtgraph/parametertree/parameterTypes/action.py new file mode 100644 index 00000000..dcecd59d --- /dev/null +++ b/pyqtgraph/parametertree/parameterTypes/action.py @@ -0,0 +1,57 @@ +from .. import ParameterItem, Parameter +from ...Qt import QtWidgets, QtCore + + +class ActionParameterItem(ParameterItem): + """ParameterItem displaying a clickable button.""" + def __init__(self, param, depth): + ParameterItem.__init__(self, param, depth) + self.layoutWidget = QtWidgets.QWidget() + self.layout = QtWidgets.QHBoxLayout() + self.layout.setContentsMargins(0, 0, 0, 0) + self.layoutWidget.setLayout(self.layout) + self.button = QtWidgets.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() + if tree is None: + return + + self.setFirstColumnSpanned(True) + tree.setItemWidget(self, 0, self.layoutWidget) + + def titleChanged(self): + self.button.setText(self.param.title()) + 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() + + +class ActionParameter(Parameter): + """Used for displaying a button within the tree. + + ``sigActivated(self)`` is emitted when the button is clicked. + """ + itemClass = ActionParameterItem + sigActivated = QtCore.Signal(object) + + def activate(self): + self.sigActivated.emit(self) + self.emitStateChanged('activated', None) diff --git a/pyqtgraph/parametertree/parameterTypes/basetypes.py b/pyqtgraph/parametertree/parameterTypes/basetypes.py new file mode 100644 index 00000000..f4868763 --- /dev/null +++ b/pyqtgraph/parametertree/parameterTypes/basetypes.py @@ -0,0 +1,421 @@ +import builtins + +from .. import ParameterItem, Parameter +from ... import functions as fn +from ...Qt import QtWidgets, QtCore, QtGui +from ... import icons + +class WidgetParameterItem(ParameterItem): + """ + ParameterTree item with: + + * label in second column for displaying value + * simple widget for editing value (displayed instead of label when item is selected) + * button that resets value to default + + This class can be subclassed by overriding makeWidget() to provide a custom widget. + """ + 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 with a display label and default button + w = self.makeWidget() + self.widget = w + self.eventProxy = EventProxy(w, self.widgetEventFilter) + + if self.asSubItem: + self.subItem = QtWidgets.QTreeWidgetItem() + self.subItem.depth = self.depth + 1 + self.subItem.setFlags(QtCore.Qt.ItemFlag.NoItemFlags) + self.addChild(self.subItem) + + self.defaultBtn = self.makeDefaultButton() + + self.displayLabel = QtWidgets.QLabel() + + layout = QtWidgets.QHBoxLayout() + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(2) + if not self.asSubItem: + layout.addWidget(w, 1) + layout.addWidget(self.displayLabel, 1) + layout.addStretch(0) + layout.addWidget(self.defaultBtn) + self.layoutWidget = QtWidgets.QWidget() + self.layoutWidget.setLayout(layout) + + if w.sigChanged is not None: + w.sigChanged.connect(self.widgetValueChanged) + + if hasattr(w, 'sigChanging'): + w.sigChanging.connect(self.widgetValueChanging) + + ## update value shown in widget. + opts = self.param.opts + if opts.get('value', None) is not None: + self.valueChanged(self, opts['value'], force=True) + else: + ## no starting value was given; use whatever the widget has + self.widgetValueChanged() + + self.updateDefaultBtn() + + 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 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: + + ========== ============================================================ + sigChanged a signal that is emitted when the widget's value is changed + value a function that returns the value + setValue a function that sets the value + ========== ============================================================ + + This function must be overridden by a subclass. + """ + raise NotImplementedError + + def widgetEventFilter(self, obj, ev): + ## filter widget's events + ## catch TAB to change focus + ## catch focusOut to hide editor + if ev.type() == ev.Type.KeyPress: + if ev.key() == QtCore.Qt.Key.Key_Tab: + self.focusNext(forward=True) + return True ## don't let anyone else see this event + elif ev.key() == QtCore.Qt.Key.Key_Backtab: + self.focusNext(forward=False) + return True ## don't let anyone else see this event + + return False + + def makeDefaultButton(self): + defaultBtn = QtWidgets.QPushButton() + defaultBtn.setAutoDefault(False) + defaultBtn.setFixedWidth(20) + defaultBtn.setFixedHeight(20) + defaultBtn.setIcon(icons.getGraphIcon('default')) + defaultBtn.clicked.connect(self.defaultClicked) + return defaultBtn + + def setFocus(self): + self.showEditor() + + def isFocusable(self): + 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) + if force or not fn.eq(val, self.widget.value()): + try: + if self.widget.sigChanged is not None: + self.widget.sigChanged.disconnect(self.widgetValueChanged) + self.param.sigValueChanged.disconnect(self.valueChanged) + self.widget.setValue(val) + self.param.setValue(self.widget.value()) + finally: + if self.widget.sigChanged is not None: + 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.opts['enabled'] and self.param.writable()) + + # hide / show + self.defaultBtn.setVisible(self.param.hasDefault() and not self.param.readonly()) + + def updateDisplayLabel(self, value=None): + """Update the display label to reflect the value of the parameter.""" + if value is None: + value = self.param.value() + self.displayLabel.setText(str(value)) + + def widgetValueChanged(self): + ## called when the widget's value has been changed by the user + val = self.widget.value() + self.param.setValue(val) + + def widgetValueChanging(self, *args): + """ + Called when the widget's value is changing, but not finalized. + For example: editing text before pressing enter or changing focus. + """ + 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)""" + ParameterItem.selected(self, sel) + + if self.widget is None: + return + if sel and self.param.writable(): + self.showEditor() + elif self.hideWidget: + self.hideEditor() + + def showEditor(self): + self.widget.show() + self.displayLabel.hide() + self.widget.setFocus(QtCore.Qt.FocusReason.OtherFocusReason) + + def hideEditor(self): + self.widget.hide() + self.displayLabel.show() + + def limitsChanged(self, param, limits): + """Called when the parameter's limits have changed""" + ParameterItem.limitsChanged(self, param, limits) + + def defaultChanged(self, param, value): + self.updateDefaultBtn() + + def treeWidgetChanged(self): + """Called when this item is added or removed from a tree.""" + ParameterItem.treeWidgetChanged(self) + + ## add all widgets for this item into the tree + if self.widget is not None: + 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) + + def defaultClicked(self): + self.param.setToDefault() + + def optsChanged(self, param, opts): + """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 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']) + + +class EventProxy(QtCore.QObject): + def __init__(self, qobj, callback): + QtCore.QObject.__init__(self) + self.callback = callback + qobj.installEventFilter(self) + + def eventFilter(self, obj, ev): + return self.callback(obj, ev) + + +class SimpleParameter(Parameter): + """ + Parameter representing a single value. + + This parameter is backed by :class:`WidgetParameterItem` to represent the + following parameter names through various subclasses: + + - 'int' + - 'float' + - 'bool' + - 'str' + - 'color' + - 'colormap' + """ + def __init__(self, *args, **kargs): + """ + Initialize the parameter. + + This is normally called implicitly through :meth:`Parameter.create`. + The keyword arguments avaialble to :meth:`Parameter.__init__` are + applicable. + """ + Parameter.__init__(self, *args, **kargs) + + def _interpretValue(self, v): + typ = self.opts['type'] + def _missing_interp(v): + # Assume raw interpretation + return v + # Or: + # raise TypeError(f'No interpreter found for type {typ}') + interpreter = getattr(builtins, typ, _missing_interp) + return interpreter(v) + +class GroupParameterItem(ParameterItem): + """ + 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. + """ + def __init__(self, param, depth): + ParameterItem.__init__(self, param, depth) + self.updateDepth(depth) + + self.addItem = None + if 'addText' in param.opts: + addText = param.opts['addText'] + if 'addList' in param.opts: + self.addWidget = QtWidgets.QComboBox() + self.addWidget.setSizeAdjustPolicy(QtWidgets.QComboBox.SizeAdjustPolicy.AdjustToContents) + self.updateAddList() + self.addWidget.currentIndexChanged.connect(self.addChanged) + else: + self.addWidget = QtWidgets.QPushButton(addText) + self.addWidget.clicked.connect(self.addClicked) + w = QtWidgets.QWidget() + l = QtWidgets.QHBoxLayout() + l.setContentsMargins(0,0,0,0) + w.setLayout(l) + l.addWidget(self.addWidget) + l.addStretch() + self.addWidgetBox = w + self.addItem = QtWidgets.QTreeWidgetItem([]) + self.addItem.setFlags(QtCore.Qt.ItemFlag.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. + if depth == 0: + for c in [0,1]: + self.setBackground(c, QtGui.QBrush(QtGui.QColor(100,100,100))) + self.setForeground(c, QtGui.QBrush(QtGui.QColor(220,220,255))) + font = self.font(c) + font.setBold(True) + font.setPointSize(font.pointSize()+1) + self.setFont(c, font) + else: + for c in [0,1]: + self.setBackground(c, QtGui.QBrush(QtGui.QColor(220,220,220))) + self.setForeground(c, QtGui.QBrush(QtGui.QColor(50,50,50))) + font = self.font(c) + font.setBold(True) + #font.setPointSize(font.pointSize()+1) + self.setFont(c, font) + 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. + """ + self.param.addNew() + + def addChanged(self): + """Called when "add new" combo is changed + The parameter MUST have an 'addNew' method defined. + """ + if self.addWidget.currentIndex() == 0: + return + typ = self.addWidget.currentText() + self.param.addNew(typ) + self.addWidget.setCurrentIndex(0) + + def treeWidgetChanged(self): + ParameterItem.treeWidgetChanged(self) + tw = self.treeWidget() + if tw is None: + return + self.setFirstColumnSpanned(True) + if self.addItem is not None: + tw.setItemWidget(self.addItem, 0, self.addWidgetBox) + self.addItem.setFirstColumnSpanned(True) + + def addChild(self, child): ## make sure added childs are actually inserted before add btn + if self.addItem is not None: + ParameterItem.insertChild(self, self.childCount()-1, child) + else: + ParameterItem.addChild(self, child) + + def optsChanged(self, param, opts): + ParameterItem.optsChanged(self, param, opts) + + 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: + 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. 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 + + sigAddNew = QtCore.Signal(object, object) # self, type + + def addNew(self, typ=None): + """ + This method is called when the user has requested to add a new item to the group. + By default, it emits ``sigAddNew(self, typ)``. + """ + self.sigAddNew.emit(self, typ) + + def setAddList(self, vals): + """Change the list of options available for the user to add to the group.""" + self.setOpts(addList=vals) diff --git a/pyqtgraph/parametertree/parameterTypes/bool.py b/pyqtgraph/parametertree/parameterTypes/bool.py new file mode 100644 index 00000000..26344653 --- /dev/null +++ b/pyqtgraph/parametertree/parameterTypes/bool.py @@ -0,0 +1,15 @@ +from pyqtgraph.Qt import QtWidgets +from pyqtgraph.parametertree.parameterTypes import WidgetParameterItem + + +class BoolParameterItem(WidgetParameterItem): + """ + Registered parameter type which displays a QCheckBox + """ + def makeWidget(self): + w = QtWidgets.QCheckBox() + w.sigChanged = w.toggled + w.value = w.isChecked + w.setValue = w.setChecked + self.hideWidget = False + return w diff --git a/pyqtgraph/parametertree/parameterTypes/calendar.py b/pyqtgraph/parametertree/parameterTypes/calendar.py new file mode 100644 index 00000000..b0b8f374 --- /dev/null +++ b/pyqtgraph/parametertree/parameterTypes/calendar.py @@ -0,0 +1,55 @@ +from ...Qt import QtWidgets, QtCore +from .. import Parameter +from .basetypes import WidgetParameterItem + + +class CalendarParameterItem(WidgetParameterItem): + def makeWidget(self): + self.asSubItem = True + w = QtWidgets.QCalendarWidget() + w.setMaximumHeight(200) + w.sigChanged = w.selectionChanged + w.value = w.selectedDate + w.setValue = w.setSelectedDate + self.hideWidget = False + self.param.opts.setdefault('default', QtCore.QDate.currentDate()) + return w + + +class CalendarParameter(Parameter): + """ + Displays a Qt calendar whose date is specified by a 'format' option. + + ============== ======================================================== + **Options:** + format Format for displaying the date and converting from a string. Can be any value accepted by + `QDate.toString` and `fromString`, or a stringified version of a QDateFormat enum, i.e. 'ISODate', + 'TextDate' (default), etc. + ============== ======================================================== + """ + + itemClass = CalendarParameterItem + + def __init__(self, **opts): + opts.setdefault('format', 'TextDate') + super().__init__(**opts) + + def _interpretFormat(self, fmt=None): + fmt = fmt or self.opts.get('format') + if hasattr(QtCore.Qt.DateFormat, fmt): + fmt = getattr(QtCore.Qt.DateFormat, fmt) + return fmt + + def _interpretValue(self, v): + if isinstance(v, str): + fmt = self._interpretFormat() + if fmt is None: + raise ValueError('Cannot parse date string without a set format') + v = QtCore.QDate.fromString(v, fmt) + return v + + def saveState(self, filter=None): + state = super().saveState(filter) + fmt = self._interpretFormat() + state['value'] = state['value'].toString(fmt) + return state diff --git a/pyqtgraph/parametertree/parameterTypes/color.py b/pyqtgraph/parametertree/parameterTypes/color.py new file mode 100644 index 00000000..86655177 --- /dev/null +++ b/pyqtgraph/parametertree/parameterTypes/color.py @@ -0,0 +1,31 @@ +from ...widgets.ColorButton import ColorButton +from ... import functions as fn +from .basetypes import WidgetParameterItem, SimpleParameter + + +class ColorParameterItem(WidgetParameterItem): + """Registered parameter type which displays a :class:`ColorButton ` """ + def makeWidget(self): + w = ColorButton() + w.sigChanged = w.sigColorChanged + w.sigChanging = w.sigColorChanging + w.value = w.color + w.setValue = w.setColor + self.hideWidget = False + w.setFlat(True) + return w + + +class ColorParameter(SimpleParameter): + itemClass = ColorParameterItem + + def _interpretValue(self, v): + return fn.mkColor(v) + + def value(self): + return fn.mkColor(super().value()) + + def saveState(self, filter=None): + state = super().saveState(filter) + state['value'] = fn.colorTuple(self.value()) + return state diff --git a/pyqtgraph/parametertree/parameterTypes/colormap.py b/pyqtgraph/parametertree/parameterTypes/colormap.py new file mode 100644 index 00000000..1ee43be2 --- /dev/null +++ b/pyqtgraph/parametertree/parameterTypes/colormap.py @@ -0,0 +1,27 @@ +from .basetypes import WidgetParameterItem, SimpleParameter +from ...Qt import QtCore +from ...colormap import ColorMap +from ...widgets.GradientWidget import GradientWidget + + +class ColorMapParameterItem(WidgetParameterItem): + """Registered parameter type which displays a :class:`GradientWidget `""" + def makeWidget(self): + 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 + return w + + +class ColorMapParameter(SimpleParameter): + itemClass = ColorMapParameterItem + + def _interpretValue(self, v): + if v is not None and not isinstance(v, ColorMap): + raise TypeError("Cannot set colormap parameter from object %r" % v) + return v diff --git a/pyqtgraph/parametertree/parameterTypes/file.py b/pyqtgraph/parametertree/parameterTypes/file.py new file mode 100644 index 00000000..d456791d --- /dev/null +++ b/pyqtgraph/parametertree/parameterTypes/file.py @@ -0,0 +1,203 @@ +import os +import re + +from .str import StrParameterItem +from .. import Parameter +from ...Qt import QtWidgets, QtGui, QtCore + + +def _set_filepicker_kwargs(fileDlg, **kwargs): + """Applies a dict of enum/flag kwarg opts to a file dialog""" + NO_MATCH = object() + + for kk, vv in kwargs.items(): + # Convert string or list representations into true flags + # 'fileMode' -> 'FileMode' + formattedName = kk[0].upper() + kk[1:] + # Edge case: "Options" has enum "Option" + if formattedName == 'Options': + enumCls = fileDlg.Option + else: + enumCls = getattr(fileDlg, formattedName, NO_MATCH) + setFunc = getattr(fileDlg, f'set{formattedName}', NO_MATCH) + if enumCls is NO_MATCH or setFunc is NO_MATCH: + continue + if enumCls is fileDlg.Option: + builder = fileDlg.Option(0) + # This is the only flag enum, all others can only take one value + if isinstance(vv, str): vv = [vv] + for flag in vv: + curVal = getattr(enumCls, flag) + builder |= curVal + # Some Qt implementations turn into ints by this point + outEnum = enumCls(builder) + else: + outEnum = getattr(enumCls, vv) + setFunc(outEnum) + + +def popupFilePicker(parent=None, windowTitle='', nameFilter='', directory=None, selectFile=None, relativeTo=None, **kwargs): + """ + Thin wrapper around Qt file picker dialog. Used internally so all options are consistent + among all requests for external file information + + ============== ======================================================== + **Arguments:** + parent Dialog parent + windowTitle Title of dialog window + nameFilter File filter as required by the Qt dialog + directory Where in the file system to open this dialog + selectFile File to preselect + relativeTo Parent directory that, if provided, will be removed from the prefix of all returned paths. So, + if '/my/text/file.txt' was selected, and `relativeTo='/my/text/'`, the return value would be + 'file.txt'. This uses os.path.relpath under the hood, so expect that behavior. + kwargs Any enum value accepted by a QFileDialog and its value. Values can be a string or list of strings, + i.e. fileMode='AnyFile', options=['ShowDirsOnly', 'DontResolveSymlinks'], acceptMode='AcceptSave' + ============== ======================================================== + + """ + fileDlg = QtWidgets.QFileDialog(parent) + _set_filepicker_kwargs(fileDlg, **kwargs) + + fileDlg.setModal(True) + if directory is not None: + fileDlg.setDirectory(directory) + fileDlg.setNameFilter(nameFilter) + if selectFile is not None: + fileDlg.selectFile(selectFile) + + fileDlg.setWindowTitle(windowTitle) + + if fileDlg.exec(): + # Append filter type + singleExtReg = r'(\.\w+)' + # Extensions of type 'myfile.ext.is.multi.part' need to capture repeating pattern of singleExt + suffMatch = re.search(rf'({singleExtReg}+)', fileDlg.selectedNameFilter()) + if suffMatch: + # Strip leading '.' if it exists + ext = suffMatch.group(1) + if ext.startswith('.'): + ext = ext[1:] + fileDlg.setDefaultSuffix(ext) + fList = fileDlg.selectedFiles() + else: + fList = [] + if relativeTo is not None: + fList = [os.path.relpath(file, relativeTo) for file in fList] + # Make consistent to os flavor + fList = [os.path.normpath(file) for file in fList] + if fileDlg.fileMode() == fileDlg.FileMode.ExistingFiles: + return fList + elif len(fList) > 0: + return fList[0] + else: + return None + + +class FileParameterItem(StrParameterItem): + def __init__(self, param, depth): + self._value = None + super().__init__(param, depth) + + button = QtWidgets.QPushButton('...') + button.setFixedWidth(25) + button.setContentsMargins(0, 0, 0, 0) + button.clicked.connect(self._retrieveFileSelection_gui) + self.layoutWidget.layout().insertWidget(2, button) + self.displayLabel.resizeEvent = self._newResizeEvent + # self.layoutWidget.layout().insertWidget(3, self.defaultBtn) + + def makeWidget(self): + w = super().makeWidget() + w.setValue = self.setValue + w.value = self.value + # Doesn't make much sense to have a 'changing' signal since filepaths should be complete before value + # is emitted + delattr(w, 'sigChanging') + return w + + def _newResizeEvent(self, ev): + ret = type(self.displayLabel).resizeEvent(self.displayLabel, ev) + self.updateDisplayLabel() + return ret + + def setValue(self, value): + self._value = value + self.widget.setText(str(value)) + + def value(self): + return self._value + + def _retrieveFileSelection_gui(self): + curVal = self.param.value() + if isinstance(curVal, list) and len(curVal): + # All files should be from the same directory, in principle + # Since no mechanism exists for preselecting multiple, the most sensible + # thing is to select nothing in the preview dialog + curVal = curVal[0] + if os.path.isfile(curVal): + curVal = os.path.dirname(curVal) + opts = self.param.opts.copy() + useDir = curVal or opts.get('directory') or os.getcwd() + startDir = os.path.abspath(useDir) + if os.path.isfile(startDir): + opts['selectFile'] = os.path.basename(startDir) + startDir = os.path.dirname(startDir) + if os.path.exists(startDir): + opts['directory'] = startDir + opts.setdefault('windowTitle', self.param.title()) + + fname = popupFilePicker(None, **opts) + if not fname: + return + self.param.setValue(fname) + + def updateDefaultBtn(self): + # Override since a readonly label should still allow reverting to default + ## enable/disable default btn + self.defaultBtn.setEnabled( + not self.param.valueIsDefault() and self.param.opts['enabled']) + + # hide / show + self.defaultBtn.setVisible(self.param.hasDefault()) + + def updateDisplayLabel(self, value=None): + lbl = self.displayLabel + if value is None: + value = self.param.value() + value = str(value) + font = lbl.font() + metrics = QtGui.QFontMetricsF(font) + value = metrics.elidedText(value, QtCore.Qt.TextElideMode.ElideLeft, lbl.width()-5) + return super().updateDisplayLabel(value) + + +class FileParameter(Parameter): + """ + Interfaces with the myriad of file options available from a QFileDialog. + + Note that the output can either be a single file string or list of files, depending on whether + `fileMode='ExistingFiles'` is specified. + + Note that in all cases, absolute file paths are returned unless `relativeTo` is specified as + elaborated below. + + ============== ======================================================== + **Options:** + parent Dialog parent + winTitle Title of dialog window + nameFilter File filter as required by the Qt dialog + directory Where in the file system to open this dialog + selectFile File to preselect + relativeTo Parent directory that, if provided, will be removed from the prefix of all returned paths. So, + if '/my/text/file.txt' was selected, and `relativeTo='my/text/'`, the return value would be + 'file.txt'. This uses os.path.relpath under the hood, so expect that behavior. + kwargs Any enum value accepted by a QFileDialog and its value. Values can be a string or list of strings, + i.e. fileMode='AnyFile', options=['ShowDirsOnly', 'DontResolveSymlinks'] + ============== ======================================================== + """ + itemClass = FileParameterItem + + def __init__(self, **opts): + opts.setdefault('readonly', True) + super().__init__(**opts) diff --git a/pyqtgraph/parametertree/parameterTypes/font.py b/pyqtgraph/parametertree/parameterTypes/font.py new file mode 100644 index 00000000..01756cdf --- /dev/null +++ b/pyqtgraph/parametertree/parameterTypes/font.py @@ -0,0 +1,40 @@ +from .basetypes import WidgetParameterItem +from .. import Parameter +from ...Qt import QtWidgets, QtGui + + +class FontParameterItem(WidgetParameterItem): + def makeWidget(self): + w = QtWidgets.QFontComboBox() + w.setMaximumHeight(20) + w.sigChanged = w.currentFontChanged + w.value = w.currentFont + w.setValue = w.setCurrentFont + self.hideWidget = False + return w + + def updateDisplayLabel(self, value=None): + if value is None: + value = self.widget.currentText() + super().updateDisplayLabel(value) + + +class FontParameter(Parameter): + """ + Creates and controls a QFont value. Be careful when selecting options from the font dropdown. since not all + fonts are available on all systems + """ + itemClass = FontParameterItem + + def _interpretValue(self, v): + if isinstance(v, str): + newVal = QtGui.QFont() + if not newVal.fromString(v): + raise ValueError(f'Error parsing font "{v}"') + v = newVal + return v + + def saveState(self, filter=None): + state = super().saveState(filter) + state['value'] = state['value'].toString() + return state diff --git a/pyqtgraph/parametertree/parameterTypes/list.py b/pyqtgraph/parametertree/parameterTypes/list.py new file mode 100644 index 00000000..644bd600 --- /dev/null +++ b/pyqtgraph/parametertree/parameterTypes/list.py @@ -0,0 +1,124 @@ +from collections import OrderedDict + +from .basetypes import WidgetParameterItem +from .. import Parameter +from ...Qt import QtWidgets + + +class ListParameterItem(WidgetParameterItem): + """ + WidgetParameterItem subclass providing comboBox that lets the user select from a list of options. + + """ + def __init__(self, param, depth): + self.targetValue = None + WidgetParameterItem.__init__(self, param, depth) + + def makeWidget(self): + w = QtWidgets.QComboBox() + w.setMaximumHeight(20) ## set to match height of spin box and line edit + w.sigChanged = w.currentIndexChanged + w.value = self.value + w.setValue = self.setValue + self.widget = w ## needs to be set before limits are changed + self.limitsChanged(self.param, self.param.opts['limits']) + if len(self.forward) > 0: + self.setValue(self.param.value()) + return w + + def value(self): + key = self.widget.currentText() + + return self.forward.get(key, None) + + def setValue(self, val): + self.targetValue = val + if val not in self.reverse[0]: + self.widget.setCurrentIndex(0) + else: + key = self.reverse[1][self.reverse[0].index(val)] + ind = self.widget.findText(key) + self.widget.setCurrentIndex(ind) + + def limitsChanged(self, param, limits): + # set up forward / reverse mappings for name:value + + 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 = self.targetValue + + 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) + + def updateDisplayLabel(self, value=None): + if value is None: + value = self.widget.currentText() + super().updateDisplayLabel(value) + + +class ListParameter(Parameter): + """Parameter with a list of acceptable values. + + By default, this parameter is represtented by a :class:`ListParameterItem`, + displaying a combo box to select a value from the list. + + In addition to the generic :class:`~pyqtgraph.parametertree.Parameter` + options, this parameter type accepts a ``limits`` argument specifying the + list of allowed values. ``values`` is an alias and may be used instead. + + The values may generally be of any data type, as long as they can be + represented as a string. If the string representation provided is + undesirable, the values may be given as a dictionary mapping the desired + string representation to the value. + """ + + itemClass = ListParameterItem + + def __init__(self, **opts): + self.forward = OrderedDict() ## {name: value, ...} + self.reverse = ([], []) ## ([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) + self.setLimits(opts['limits']) + + def setLimits(self, limits): + """Change the list of allowed values.""" + self.forward, self.reverse = self.mapping(limits) + + Parameter.setLimits(self, limits) + if len(self.reverse[0]) > 0 and self.value() not in self.reverse[0]: + self.setValue(self.reverse[0][0]) + + @staticmethod + def mapping(limits): + # Return forward and reverse mapping objects given a limit specification + forward = OrderedDict() ## {name: value, ...} + reverse = ([], []) ## ([value, ...], [name, ...]) + if isinstance(limits, dict): + for k, v in limits.items(): + forward[k] = v + reverse[0].append(v) + reverse[1].append(k) + else: + for v in limits: + n = str(v) + forward[n] = v + reverse[0].append(v) + reverse[1].append(n) + return forward, reverse + diff --git a/pyqtgraph/parametertree/parameterTypes/numeric.py b/pyqtgraph/parametertree/parameterTypes/numeric.py new file mode 100644 index 00000000..db4fc20f --- /dev/null +++ b/pyqtgraph/parametertree/parameterTypes/numeric.py @@ -0,0 +1,58 @@ +from .basetypes import WidgetParameterItem +from ...widgets.SpinBox import SpinBox + +class NumericParameterItem(WidgetParameterItem): + """ + Subclasses `WidgetParameterItem` to provide the following types: + + ========================== ============================================================= + **Registered Types:** + int Displays a :class:`SpinBox ` in integer + mode. + float Displays a :class:`SpinBox `. + ========================== ============================================================= + """ + def makeWidget(self): + opts = self.param.opts + t = opts['type'] + defs = { + 'value': 0, 'min': None, 'max': None, + 'step': 1.0, 'dec': False, + 'siPrefix': False, 'suffix': '', 'decimals': 3, + } + if t == 'int': + defs['int'] = True + defs['minStep'] = 1.0 + for k in defs: + if k in opts: + defs[k] = opts[k] + if 'limits' in opts: + defs['min'], defs['max'] = opts['limits'] + w = SpinBox() + w.setOpts(**defs) + w.sigChanged = w.sigValueChanged + w.sigChanging = w.sigValueChanging + return w + + def updateDisplayLabel(self, value=None): + if value is None: + value = self.widget.lineEdit().text() + super().updateDisplayLabel(value) + + def showEditor(self): + super().showEditor() + self.widget.selectNumber() # select the numerical portion of the text for quick editing + + def limitsChanged(self, param, limits): + self.widget.setOpts(bounds=limits) + + def optsChanged(self, param, opts): + super().optsChanged(param, opts) + sbOpts = {} + if 'units' in opts and 'suffix' not in opts: + sbOpts['suffix'] = opts['units'] + for k, v in opts.items(): + if k in self.widget.opts: + sbOpts[k] = v + self.widget.setOpts(**sbOpts) + self.updateDisplayLabel() diff --git a/pyqtgraph/parametertree/parameterTypes/pen.py b/pyqtgraph/parametertree/parameterTypes/pen.py new file mode 100644 index 00000000..4c56d1cd --- /dev/null +++ b/pyqtgraph/parametertree/parameterTypes/pen.py @@ -0,0 +1,144 @@ +from .basetypes import WidgetParameterItem +from .. import Parameter +from ... import functions as fn +from ...Qt import QtWidgets, QtGui, QtCore +from ...widgets.PenSelectorDialog import PenSelectorDialog + +class PenParameterItem(WidgetParameterItem): + def __init__(self, param, depth): + self.pdialog = PenSelectorDialog(fn.mkPen(param.pen)) + self.pdialog.setModal(True) + self.pdialog.accepted.connect(self.penChangeFinished) + super().__init__(param, depth) + self.displayLabel.paintEvent = self.displayPaintEvent + + def makeWidget(self): + self.button = QtWidgets.QPushButton() + #larger button + self.button.setFixedWidth(100) + self.button.clicked.connect(self.buttonClicked) + self.button.paintEvent = self.buttonPaintEvent + self.button.value = self.value + self.button.setValue = self.setValue + self.button.sigChanged = None + return self.button + + @property + def pen(self): + return self.pdialog.pen + + def value(self): + return self.pen + + def setValue(self, pen): + self.pdialog.updateParamFromPen(self.pdialog.param, pen) + + def updateDisplayLabel(self, value=None): + super().updateDisplayLabel('') + self.displayLabel.update() + self.widget.update() + + def buttonClicked(self): + #open up the pen selector dialog + # Copy in case of rejection + prePen = QtGui.QPen(self.pen) + if self.pdialog.exec() != QtWidgets.QDialog.DialogCode.Accepted: + self.pdialog.updateParamFromPen(self.pdialog.param, prePen) + + def penChangeFinished(self): + self.param.setValue(self.pdialog.pen) + + def penPaintEvent(self, event, item): + # draw item as usual + type(item).paintEvent(item, event) + + path = QtGui.QPainterPath() + displaySize = item.size() + w, h = displaySize.width(), displaySize.height() + # draw a squiggle with the pen + path.moveTo(w * .2, h * .2) + path.lineTo(w * .4, h * .8) + path.cubicTo(w * .5, h * .1, w * .7, h * .1, w * .8, h * .8) + + painter = QtGui.QPainter(item) + painter.setPen(self.pen) + painter.drawPath(path) + painter.end() + + def buttonPaintEvent(self, event): + return self.penPaintEvent(event, self.button) + + def displayPaintEvent(self, event): + return self.penPaintEvent(event, self.displayLabel) + + +class PenParameter(Parameter): + """ + Controls the appearance of a QPen value. + + When `saveState` is called, the value is encoded as (color, width, style, capStyle, joinStyle, cosmetic) + + ============== ======================================================== + **Options:** + color pen color, can be any argument accepted by :func:`~pyqtgraph.mkColor` (defaults to black) + width integer width >= 0 (defaults to 1) + style String version of QPenStyle enum, i.e. 'SolidLine' (default), 'DashLine', etc. + capStyle String version of QPenCapStyle enum, i.e. 'SquareCap' (default), 'RoundCap', etc. + joinStyle String version of QPenJoinStyle enum, i.e. 'BevelJoin' (default), 'RoundJoin', etc. + cosmetic Boolean, whether or not the pen is cosmetic (defaults to True) + ============== ======================================================== + """ + + itemClass = PenParameterItem + sigPenChanged = QtCore.Signal(object,object) + + def __init__(self, **opts): + self.pen = fn.mkPen() + self.penOptsParam = PenSelectorDialog.mkParam(self.pen) + super().__init__(**opts) + + def saveState(self, filter=None): + state = super().saveState(filter) + overrideState = self.penOptsParam.saveState(filter)['children'] + state['value'] = tuple(s['value'] for s in overrideState.values()) + return state + + def _interpretValue(self, v): + return self.mkPen(v) + + def setValue(self, value, blockSignal=None): + if not fn.eq(value, self.pen): + value = self.mkPen(value) + PenSelectorDialog.updateParamFromPen(self.penOptsParam, value) + return super().setValue(self.pen, blockSignal) + + def applyOptsToPen(self, **opts): + # Transform opts into a value for the current pen + paramNames = set(opts).intersection(self.penOptsParam.names) + # Value should be overridden by opts + with self.treeChangeBlocker(): + if 'value' in opts: + pen = self.mkPen(opts.pop('value')) + if not fn.eq(pen, self.pen): + PenSelectorDialog.updateParamFromPen(self.penOptsParam, pen) + penOpts = {} + for kk in paramNames: + penOpts[kk] = opts[kk] + self.penOptsParam[kk] = opts[kk] + return penOpts + + def setOpts(self, **opts): + # Transform opts into a value + penOpts = self.applyOptsToPen(**opts) + if penOpts: + self.setValue(self.pen) + return super().setOpts(**opts) + + def mkPen(self, *args, **kwargs): + """Thin wrapper around fn.mkPen which accepts the serialized state from saveState""" + if len(args) == 1 and isinstance(args[0], tuple) and len(args[0]) == len(self.penOptsParam.childs): + opts = dict(zip(self.penOptsParam.names, args[0])) + self.applyOptsToPen(**opts) + args = (self.pen,) + kwargs = {} + return fn.mkPen(*args, **kwargs) diff --git a/pyqtgraph/parametertree/parameterTypes/progress.py b/pyqtgraph/parametertree/parameterTypes/progress.py new file mode 100644 index 00000000..aae86ad9 --- /dev/null +++ b/pyqtgraph/parametertree/parameterTypes/progress.py @@ -0,0 +1,19 @@ +from ...Qt import QtWidgets +from .. import Parameter +from .basetypes import WidgetParameterItem + + +class ProgressBarParameterItem(WidgetParameterItem): + def makeWidget(self): + w = QtWidgets.QProgressBar() + w.setMaximumHeight(20) + w.sigChanged = w.valueChanged + self.hideWidget = False + return w + + +class ProgressBarParameter(Parameter): + """ + Displays a progress bar whose value can be set between 0 and 100 + """ + itemClass = ProgressBarParameterItem diff --git a/pyqtgraph/parametertree/parameterTypes/qtenum.py b/pyqtgraph/parametertree/parameterTypes/qtenum.py new file mode 100644 index 00000000..7b0f1687 --- /dev/null +++ b/pyqtgraph/parametertree/parameterTypes/qtenum.py @@ -0,0 +1,67 @@ +from ...Qt import QtCore, QT_LIB +from .list import ListParameter + + +class QtEnumParameter(ListParameter): + def __init__(self, enum, searchObj=QtCore.Qt, **opts): + """ + Constructs a list of allowed enum values from the enum class provided + `searchObj` is only needed for PyQt5 compatibility, where it must be the module holding the enum. + For instance, if making a QtEnumParameter out of QtWidgets.QFileDialog.Option, `searchObj` would + be QtWidgets.QFileDialog + """ + self.enum = enum + self.searchObj = searchObj + opts.setdefault('name', enum.__name__) + self.enumMap = self._getAllowedEnums(enum) + + opts.update(limits=self.formattedLimits()) + super().__init__(**opts) + + def setValue(self, value, blockSignal=None): + if isinstance(value, str): + value = self.enumMap[value] + super().setValue(value, blockSignal) + + def formattedLimits(self): + # Title-cased words without the ending substring for brevity + substringEnd = None + mapping = self.enumMap + shortestName = min(len(name) for name in mapping) + names = list(mapping) + cmpName, *names = names + for ii in range(-1, -shortestName-1, -1): + if any(cmpName[ii] != curName[ii] for curName in names): + substringEnd = ii+1 + break + # Special case of 0: Set to none to avoid null string + if substringEnd == 0: + substringEnd = None + limits = {} + for kk, vv in self.enumMap.items(): + limits[kk[:substringEnd]] = vv + return limits + + def saveState(self, filter=None): + state = super().saveState(filter) + reverseMap = dict(zip(self.enumMap.values(), self.enumMap)) + state['value'] = reverseMap[state['value']] + return state + + def _getAllowedEnums(self, enum): + """Pyside provides a dict for easy evaluation""" + if 'PySide' in QT_LIB: + vals = enum.values + elif 'PyQt5' in QT_LIB: + vals = {} + for key in dir(self.searchObj): + value = getattr(self.searchObj, key) + if isinstance(value, enum): + vals[key] = value + elif 'PyQt6' in QT_LIB: + vals = {e.name: e for e in enum} + else: + raise RuntimeError(f'Cannot find associated enum values for qt lib {QT_LIB}') + # Remove "M" since it's not a real option + vals.pop(f'M{enum.__name__}', None) + return vals diff --git a/pyqtgraph/parametertree/parameterTypes/slider.py b/pyqtgraph/parametertree/parameterTypes/slider.py new file mode 100644 index 00000000..85b70035 --- /dev/null +++ b/pyqtgraph/parametertree/parameterTypes/slider.py @@ -0,0 +1,139 @@ +import numpy as np + +from .basetypes import WidgetParameterItem +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 + charSpan: np.ndarray + + def __init__(self, param, depth): + # Bind emitter to self to avoid garbage collection + self.emitter = Emitter() + self.sigChanging = self.emitter.sigChanging + self._suffix = None + super().__init__(param, depth) + + def updateDisplayLabel(self, value=None): + if value is None: + value = self.param.value() + value = str(value) + if self._suffix is None: + suffixTxt = '' + else: + suffixTxt = f' {self._suffix}' + self.displayLabel.setText(value + suffixTxt) + + def setSuffix(self, suffix): + self._suffix = suffix + self._updateLabel(self.slider.value()) + + def makeWidget(self): + param = self.param + opts = param.opts + self._suffix = opts.get('suffix') + + self.slider = QtWidgets.QSlider() + self.slider.setOrientation(QtCore.Qt.Orientation.Horizontal) + lbl = QtWidgets.QLabel() + lbl.setAlignment(QtCore.Qt.AlignmentFlag.AlignLeft) + + w = QtWidgets.QWidget() + layout = QtWidgets.QHBoxLayout() + w.setLayout(layout) + layout.addWidget(lbl) + layout.addWidget(self.slider) + + def setValue(v): + self.slider.setValue(self.spanToSliderValue(v)) + + def getValue(): + return self.span[self.slider.value()].item() + + def vChanged(v): + lbl.setText(self.prettyTextValue(v)) + + self.slider.valueChanged.connect(vChanged) + + def onMove(pos): + self.sigChanging.emit(self, self.span[pos].item()) + + self.slider.sliderMoved.connect(onMove) + + w.setValue = setValue + w.value = getValue + w.sigChanged = self.slider.valueChanged + w.sigChanging = self.sigChanging + self.optsChanged(param, opts) + return w + + def spanToSliderValue(self, v): + return int(np.argmin(np.abs(self.span - v))) + + def prettyTextValue(self, v): + if self._suffix is None: + suffixTxt = '' + else: + suffixTxt = f' {self._suffix}' + format_ = self.param.opts.get('format', None) + cspan = self.charSpan + if format_ is None: + format_ = f'{{0:>{cspan.dtype.itemsize}}}{suffixTxt}' + return format_.format(cspan[v].decode()) + + def optsChanged(self, param, opts): + try: + super().optsChanged(param, opts) + except AttributeError: + # This may trigger while building the parameter before the widget is fully constructed. + # This is fine, since errors are from the parent scope which will stabilize after the widget is + # constructed anyway + pass + span = opts.get('span', None) + if span is None: + step = opts.get('step', 1) + start, stop = opts['limits'] + # Add a bit to 'stop' since python slicing excludes the last value + span = np.arange(start, stop + step, step) + precision = opts.get('precision', 2) + if precision is not None: + span = span.round(precision) + self.span = span + self.charSpan = np.char.array(span) + w = self.slider + w.setMinimum(0) + w.setMaximum(len(span) - 1) + if 'suffix' in opts: + self.setSuffix(opts['suffix']) + self.slider.valueChanged.emit(self.slider.value()) + + def limitsChanged(self, param, limits): + self.optsChanged(param, dict(limits=limits)) + + +class SliderParameter(Parameter): + """ + ============== ======================================================== + **Options** + limits [start, stop] numbers + step: Defaults to 1, the spacing between each slider tick + span: Instead of limits + step, span can be set to specify + the range of slider options (e.g. np.linspace(-pi, pi, 100)) + format: Format string to determine number of decimals to show, etc. + Defaults to display based on span dtype + precision: int number of decimals to keep for float tick spaces + ============== ======================================================== + """ + itemClass = SliderParameterItem diff --git a/pyqtgraph/parametertree/parameterTypes/str.py b/pyqtgraph/parametertree/parameterTypes/str.py new file mode 100644 index 00000000..8ee87a1a --- /dev/null +++ b/pyqtgraph/parametertree/parameterTypes/str.py @@ -0,0 +1,15 @@ +from pyqtgraph.Qt import QtWidgets +from pyqtgraph.parametertree.parameterTypes import WidgetParameterItem + + +class StrParameterItem(WidgetParameterItem): + """Registered parameter type which displays a QLineEdit""" + + def makeWidget(self): + w = QtWidgets.QLineEdit() + w.setStyleSheet('border: 0px') + w.sigChanged = w.editingFinished + w.value = w.text + w.setValue = w.setText + w.sigChanging = w.textChanged + return w diff --git a/pyqtgraph/parametertree/parameterTypes/text.py b/pyqtgraph/parametertree/parameterTypes/text.py new file mode 100644 index 00000000..6fec5b54 --- /dev/null +++ b/pyqtgraph/parametertree/parameterTypes/text.py @@ -0,0 +1,22 @@ +from .basetypes import WidgetParameterItem +from .. import Parameter +from ...Qt import QtWidgets, QtCore + + +class TextParameterItem(WidgetParameterItem): + """ParameterItem displaying a QTextEdit widget.""" + + def makeWidget(self): + self.hideWidget = False + self.asSubItem = True + self.textBox = w = QtWidgets.QTextEdit() + w.sizeHint = lambda: QtCore.QSize(300, 100) + w.value = 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 diff --git a/pyqtgraph/widgets/PenSelectorDialog.py b/pyqtgraph/widgets/PenSelectorDialog.py index 47a0211d..abaaecc6 100644 --- a/pyqtgraph/widgets/PenSelectorDialog.py +++ b/pyqtgraph/widgets/PenSelectorDialog.py @@ -176,4 +176,4 @@ class PenSelectorDialog(QtWidgets.QDialog): layout.addWidget(self.buttonBoxAcceptCancel) self.setLayout(layout) - self.resize(240, 300) \ No newline at end of file + self.resize(240, 300) diff --git a/tools/rebuildPtreeRst.py b/tools/rebuildPtreeRst.py new file mode 100644 index 00000000..3748fa99 --- /dev/null +++ b/tools/rebuildPtreeRst.py @@ -0,0 +1,48 @@ +import os.path +import textwrap + +from pyqtgraph.parametertree.Parameter import PARAM_TYPES, _PARAM_ITEM_TYPES + + +def mkDocs(typeList): + typeNames = sorted([typ.__name__ for typ in typeList]) + typDocs = [ + f"""\ + .. autoclass:: {name} + :members: + """ + for name in typeNames] + indented = '\n'.join(typDocs) + # There will be two newlines at the end, so remove one + return textwrap.dedent(indented)[:-1] + +types = set(PARAM_TYPES.values()) +items = [typ.itemClass for typ in PARAM_TYPES.values() if typ.itemClass is not None] \ + + [item for item in _PARAM_ITEM_TYPES.values()] +items = set(items) + +doc = f"""\ +.. + This file is auto-generated from pyqtgraph/tools/rebuildPtreeRst.py. Do not modify by hand! Instead, rerun the + generation script with `python pyqtgraph/tools/rebuildPtreeRst.py`. + +Built-in Parameter Types +======================== + +.. currentmodule:: pyqtgraph.parametertree.parameterTypes + +Parameters +---------- + +{mkDocs(types)} + +ParameterItems +-------------- + +{mkDocs(items)} +""" + +here = os.path.dirname(__file__) +rstFilename = os.path.join(here, '..', 'doc', 'source', 'parametertree', 'parametertypes.rst') +with open(rstFilename, 'w') as ofile: + ofile.write(doc)