From 81823768c0123c2be4f74ffd2b9925fe2850d4e9 Mon Sep 17 00:00:00 2001 From: ntjess Date: Fri, 23 Jul 2021 17:40:49 -0400 Subject: [PATCH] feature More parameter item types (#1844) * feature More parameter item types Pen: Pops up a dialouge that allows the user to customize a pen. Setting pen value is not working yet. Progress bar: For indication things. Slider: Easier way to set values that dont require precision. Fonts: Picking font types. Next thing could be a Font dialog. Calendar: For picking dates or intervals Open/save file/files/directory: Pops up an open/save file/directory dialog to select a file/directory. Filter string and caption can be defined too. A PenSelectorDialog widget was created for the pen parameter item too. Also added these parameter items to the example. * PyQt/Side6 compatibility fixup * Revisions from intial PR Co-authored-by: ChristophRose <42769515+ChristophRose@users.noreply.github.com> Update pyqtgraph/widgets/PenSelectorDialog.py Co-authored-by: ChristophRose <42769515+ChristophRose@users.noreply.github.com> Update pyqtgraph/widgets/PenSelectorDialogbox.py Co-authored-by: ChristophRose <42769515+ChristophRose@users.noreply.github.com> Update pyqtgraph/widgets/PenSelectorDialogbox.py Co-authored-by: ChristophRose <42769515+ChristophRose@users.noreply.github.com> Update pyqtgraph/widgets/PenSelectorDialogbox.py Co-authored-by: ChristophRose <42769515+ChristophRose@users.noreply.github.com> Update pyqtgraph/parametertree/parameterTypes.py Co-authored-by: ChristophRose <42769515+ChristophRose@users.noreply.github.com> Update pyqtgraph/parametertree/parameterTypes.py Co-authored-by: ChristophRose <42769515+ChristophRose@users.noreply.github.com> Update pyqtgraph/widgets/PenSelectorDialog.py Co-authored-by: ChristophRose <42769515+ChristophRose@users.noreply.github.com> Update pyqtgraph/widgets/PenSelectorDialogbox.py Co-authored-by: ChristophRose <42769515+ChristophRose@users.noreply.github.com> Update pyqtgraph/widgets/PenSelectorDialogbox.py Co-authored-by: ChristophRose <42769515+ChristophRose@users.noreply.github.com> Update pyqtgraph/widgets/PenSelectorDialogbox.py Co-authored-by: ChristophRose <42769515+ChristophRose@users.noreply.github.com> Update pyqtgraph/widgets/PenSelectorDialogbox.py Co-authored-by: ChristophRose <42769515+ChristophRose@users.noreply.github.com> Apply suggestions from code review Co-authored-by: ChristophRose <42769515+ChristophRose@users.noreply.github.com> * Bugfix: module instead of class import on param tree example * Enrich the slider parameter * Address pijyoi comments on pen style parameter * Different file picker for easier porting * Better organization and formatting, minor refactoring * PyQt6/PySide6 fixup for file dialog * Minor adjustment to file picker * Bugfix: for 'None' sigChanged 'None' is explicitly allowed for a WidgetParameterItem's `sigChanged` value. However, this raises an error on a changed value unless the commit's fix is applied * Calendar works better as sub item * Fixes bugs in pen parameter's dialog + makes it resizable * more bugfixes and recommended changes, lets pen serialize its options * better pen save state * Fixes file parameter qualms * Fixes font parameter qualms * Fixes calendar parameter qualms * Fixes multiply-defined slider optsChanged * Fixes pen parameter qualms * ptree example minor bugfix * Pen dialog bugfixes * File dialog bugfixes / mild improvements * unto ptree save state regression * file fixup * Adds parameter descriptions to docstrings * Improved parameter documentation * adds 'relativeTo' option for file parameter * Less abuse of Qt enums during or-operations * More uniform handling of relative paths * More cleanup of enum setting * better name for window title (matches qt name) * Favor os.path over pathlib * Exposes 'directory', 'windowTitle' to file parameter * Fixup and add comparison to parameter tree state restoration * Exposes "cosmetic" in pen parameter * Indicate defaults in parameter documentation * QtEnumParameter works for enums outside QtCore.Qt * see if altering pytest report fixes ci bug * Cleanup unused import and redundant `self.widget` assignments Co-authored-by: Fekete Imre Co-authored-by: ChristophRose <42769515+ChristophRose@users.noreply.github.com> --- .github/workflows/main.yml | 3 +- doc/source/parametertree/parametertypes.rst | 35 +- examples/parametertree.py | 20 +- pyqtgraph/parametertree/parameterTypes.py | 805 ++++++++++++++++++-- pyqtgraph/widgets/PenSelectorDialog.py | 179 +++++ 5 files changed, 950 insertions(+), 92 deletions(-) create mode 100644 pyqtgraph/widgets/PenSelectorDialog.py diff --git a/.github/workflows/main.yml b/.github/workflows/main.yml index c242102e..ef279e9b 100644 --- a/.github/workflows/main.yml +++ b/.github/workflows/main.yml @@ -101,8 +101,7 @@ jobs: - name: Run Tests run: | mkdir $SCREENSHOT_DIR - pytest tests -v \ - --junitxml pytest.xml + pytest tests -v pytest examples -v shell: bash - name: Upload Screenshots diff --git a/doc/source/parametertree/parametertypes.rst b/doc/source/parametertree/parametertypes.rst index 2ca29b37..b82c1b50 100644 --- a/doc/source/parametertree/parametertypes.rst +++ b/doc/source/parametertree/parametertypes.rst @@ -21,6 +21,24 @@ Parameters .. autoclass:: ActionParameter :members: +.. autoclass:: FileParameter + :members: + +.. autoclass:: CalendarParameter + :members: + +.. autoclass:: ProgressBarParameter + :members: + +.. autoclass:: FontParameter + :members: + +.. autoclass:: PenParameter + :members: + +.. autoclass:: SliderParameter + :members: + ParameterItems -------------- @@ -36,5 +54,20 @@ ParameterItems .. autoclass:: TextParameterItem :members: -.. autoclass:: ActionParameterItem +.. autoclass:: FileParameterItem + :members: + +.. autoclass:: CalendarParameterItem + :members: + +.. autoclass:: ProgressBarParameterItem + :members: + +.. autoclass:: FontParameterItem + :members: + +.. autoclass:: PenParameterItem + :members: + +.. autoclass:: SliderParameterItem :members: diff --git a/examples/parametertree.py b/examples/parametertree.py index 89932ed6..b44b0c9a 100644 --- a/examples/parametertree.py +++ b/examples/parametertree.py @@ -6,7 +6,7 @@ demonstrates a variety of different parameter types (int, float, list, etc.) as well as some customized parameter types """ - +import os import initExample ## Add path to library (just for examples; you do not need this) @@ -78,6 +78,15 @@ params = [ {'name': 'Text Parameter', 'type': 'text', 'value': 'Some text...'}, {'name': 'Action Parameter', 'type': 'action', 'tip': 'Click me'}, ]}, + {'name': 'Custom Parameter Options', 'type': 'group', 'children': [ + {'name': 'Pen', 'type': 'pen', 'value': pg.mkPen(color=(255,0,0), width=2)}, + {'name': 'Progress bar', 'type': 'progress', 'value':50, 'limits':(0,100)}, + {'name': 'Slider', 'type': 'slider', 'value':50, 'limits':(0,100)}, + {'name': 'Font', 'type': 'font', 'value':QtGui.QFont("Inter")}, + {'name': 'Calendar', 'type': 'calendar', 'value':QtCore.QDate.currentDate().addMonths(1)}, + {'name': 'Open python file', 'type': 'file', 'fileMode': 'ExistingFile', 'nameFilter': 'Python file (*.py);;', + 'value': 'parametertree.py', 'relativeTo': os.getcwd(), 'options': ['DontResolveSymlinks']} + ]}, {'name': 'Numerical Parameter Options', 'type': 'group', 'children': [ {'name': 'Units + SI prefix', 'type': 'float', 'value': 1.2e-6, 'step': 1e-6, 'siPrefix': True, 'suffix': 'V'}, {'name': 'Limits (min=7;max=15)', 'type': 'int', 'value': 11, 'limits': (7, 15), 'default': -6}, @@ -145,11 +154,10 @@ for child in p.children(): ch2.sigValueChanging.connect(valueChanging) - def save(): global state state = p.saveState() - + def restore(): global state add = p['Save/Restore functionality', 'Restore State', 'Add missing items'] @@ -175,8 +183,10 @@ layout.addWidget(t2, 1, 1, 1, 1) win.show() ## test save/restore -s = p.saveState() -p.restoreState(s) +state = p.saveState() +p.restoreState(state) +compareState = p.saveState() +assert pg.eq(compareState, state) if __name__ == '__main__': pg.exec() diff --git a/pyqtgraph/parametertree/parameterTypes.py b/pyqtgraph/parametertree/parameterTypes.py index f7783e2a..0b69c217 100644 --- a/pyqtgraph/parametertree/parameterTypes.py +++ b/pyqtgraph/parametertree/parameterTypes.py @@ -1,15 +1,17 @@ -# -*- coding: utf-8 -*- -import os -from ..Qt import QtCore, QtGui +from ..Qt import QtCore, QtGui, QtWidgets, QT_LIB from ..python2_3 import asUnicode 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): @@ -38,30 +40,24 @@ class WidgetParameterItem(ParameterItem): 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 - + ## set this to False to keep the editor widget always visible + # build widget with a display label and default button - w = self.makeWidget() + w = self.makeWidget() self.widget = w self.eventProxy = EventProxy(w, self.widgetEventFilter) if self.asSubItem: - self.subItem = QtGui.QTreeWidgetItem() + self.subItem = QtWidgets.QTreeWidgetItem() self.subItem.depth = self.depth + 1 self.subItem.setFlags(QtCore.Qt.ItemFlag.NoItemFlags) self.addChild(self.subItem) - self.defaultBtn = QtGui.QPushButton() - self.defaultBtn.setAutoDefault(False) - self.defaultBtn.setFixedWidth(20) - self.defaultBtn.setFixedHeight(20) - modDir = os.path.dirname(__file__) - self.defaultBtn.setIcon(icons.getGraphIcon('default')) - self.defaultBtn.clicked.connect(self.defaultClicked) - - self.displayLabel = QtGui.QLabel() - - layout = QtGui.QHBoxLayout() + self.defaultBtn = self.makeDefaultButton() + + self.displayLabel = QtWidgets.QLabel() + + layout = QtWidgets.QHBoxLayout() layout.setContentsMargins(0, 0, 0, 0) layout.setSpacing(2) if not self.asSubItem: @@ -69,15 +65,15 @@ class WidgetParameterItem(ParameterItem): layout.addWidget(self.displayLabel, 1) layout.addStretch(0) layout.addWidget(self.defaultBtn) - self.layoutWidget = QtGui.QWidget() + 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: @@ -87,7 +83,7 @@ class WidgetParameterItem(ParameterItem): self.widgetValueChanged() self.updateDefaultBtn() - + self.optsChanged(self.param, self.param.opts) # set size hints @@ -125,7 +121,7 @@ class WidgetParameterItem(ParameterItem): if t in ('int', 'float'): defs = { 'value': 0, 'min': None, 'max': None, - 'step': 1.0, 'dec': False, + 'step': 1.0, 'dec': False, 'siPrefix': False, 'suffix': '', 'decimals': 3, } if t == 'int': @@ -141,13 +137,13 @@ class WidgetParameterItem(ParameterItem): w.sigChanged = w.sigValueChanged w.sigChanging = w.sigValueChanging elif t == 'bool': - w = QtGui.QCheckBox() + w = QtWidgets.QCheckBox() w.sigChanged = w.toggled w.value = w.isChecked w.setValue = w.setChecked self.hideWidget = False elif t == 'str': - w = QtGui.QLineEdit() + w = QtWidgets.QLineEdit() w.setStyleSheet('border: 0px') w.sigChanged = w.editingFinished w.value = lambda: asUnicode(w.text()) @@ -174,7 +170,7 @@ class WidgetParameterItem(ParameterItem): else: raise Exception("Unknown type '%s'" % asUnicode(t)) return w - + def widgetEventFilter(self, obj, ev): ## filter widget's events ## catch TAB to change focus @@ -186,12 +182,21 @@ class WidgetParameterItem(ParameterItem): 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() @@ -200,21 +205,23 @@ class WidgetParameterItem(ParameterItem): ParameterItem.valueChanged(self, param, val) if force or not fn.eq(val, self.widget.value()): try: - self.widget.sigChanged.disconnect(self.widgetValueChanged) + 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: - self.widget.sigChanged.connect(self.widgetValueChanged) + 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()) @@ -223,9 +230,9 @@ class WidgetParameterItem(ParameterItem): if value is None: value = self.param.value() opts = self.param.opts - if isinstance(self.widget, QtGui.QAbstractSpinBox): + if isinstance(self.widget, QtWidgets.QAbstractSpinBox): text = asUnicode(self.widget.lineEdit().text()) - elif isinstance(self.widget, QtGui.QComboBox): + elif isinstance(self.widget, QtWidgets.QComboBox): text = self.widget.currentText() else: text = asUnicode(value) @@ -242,11 +249,11 @@ class WidgetParameterItem(ParameterItem): 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(): @@ -268,7 +275,7 @@ class WidgetParameterItem(ParameterItem): 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) @@ -281,7 +288,7 @@ class WidgetParameterItem(ParameterItem): 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() @@ -315,7 +322,7 @@ class WidgetParameterItem(ParameterItem): 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 @@ -327,14 +334,14 @@ class WidgetParameterItem(ParameterItem): 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) @@ -362,7 +369,7 @@ class SimpleParameter(Parameter): applicable. """ Parameter.__init__(self, *args, **kargs) - + ## override a few methods for color parameters if self.opts['type'] == 'color': self.value = self.colorValue @@ -370,12 +377,12 @@ class SimpleParameter(Parameter): 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, @@ -386,10 +393,10 @@ class SimpleParameter(Parameter): '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) @@ -412,27 +419,27 @@ class GroupParameterItem(ParameterItem): """ def __init__(self, param, depth): ParameterItem.__init__(self, param, depth) - self.updateDepth(depth) - + self.updateDepth(depth) + self.addItem = None if 'addText' in param.opts: addText = param.opts['addText'] if 'addList' in param.opts: - self.addWidget = QtGui.QComboBox() - self.addWidget.setSizeAdjustPolicy(QtGui.QComboBox.SizeAdjustPolicy.AdjustToContents) + self.addWidget = QtWidgets.QComboBox() + self.addWidget.setSizeAdjustPolicy(QtWidgets.QComboBox.SizeAdjustPolicy.AdjustToContents) self.updateAddList() self.addWidget.currentIndexChanged.connect(self.addChanged) else: - self.addWidget = QtGui.QPushButton(addText) + self.addWidget = QtWidgets.QPushButton(addText) self.addWidget.clicked.connect(self.addClicked) - w = QtGui.QWidget() - l = QtGui.QHBoxLayout() + 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 = QtGui.QTreeWidgetItem([]) + self.addItem = QtWidgets.QTreeWidgetItem([]) self.addItem.setFlags(QtCore.Qt.ItemFlag.ItemIsEnabled) self.addItem.depth = self.depth + 1 ParameterItem.addChild(self, self.addItem) @@ -492,10 +499,10 @@ class GroupParameterItem(ParameterItem): 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() @@ -530,7 +537,7 @@ class GroupParameter(Parameter): instead of a button. """ itemClass = GroupParameterItem - + sigAddNew = QtCore.Signal(object, object) # self, type def addNew(self, typ=None): @@ -539,7 +546,7 @@ class GroupParameter(Parameter): 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) @@ -556,11 +563,11 @@ class ListParameterItem(WidgetParameterItem): def __init__(self, param, depth): self.targetValue = None WidgetParameterItem.__init__(self, param, depth) - + def makeWidget(self): opts = self.param.opts t = opts['type'] - w = QtGui.QComboBox() + w = QtWidgets.QComboBox() w.setMaximumHeight(20) ## set to match height of spin box and line edit w.sigChanged = w.currentIndexChanged w.value = self.value @@ -570,12 +577,12 @@ class ListParameterItem(WidgetParameterItem): if len(self.forward) > 0: self.setValue(self.param.value()) return w - + def value(self): key = asUnicode(self.widget.currentText()) - + return self.forward.get(key, None) - + def setValue(self, val): self.targetValue = val if val not in self.reverse[0]: @@ -587,15 +594,15 @@ class ListParameterItem(WidgetParameterItem): 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 #asUnicode(self.widget.currentText()) - + self.widget.clear() for k in self.forward: self.widget.addItem(k) @@ -627,7 +634,7 @@ class ListParameter(Parameter): 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'] @@ -635,15 +642,15 @@ class ListParameter(Parameter): 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 @@ -664,17 +671,15 @@ class ListParameter(Parameter): 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 = QtGui.QWidget() - self.layout = QtGui.QHBoxLayout() + self.layoutWidget = QtWidgets.QWidget() + self.layout = QtWidgets.QHBoxLayout() self.layout.setContentsMargins(0, 0, 0, 0) self.layoutWidget.setLayout(self.layout) - self.button = QtGui.QPushButton() + self.button = QtWidgets.QPushButton() #self.layout.addSpacing(100) self.layout.addWidget(self.button) self.layout.addStretch() @@ -687,7 +692,7 @@ class ActionParameterItem(ParameterItem): tree = self.treeWidget() if tree is None: return - + self.setFirstColumnSpanned(True) tree.setItemWidget(self, 0, self.layoutWidget) @@ -706,7 +711,7 @@ class ActionParameterItem(ParameterItem): def buttonClicked(self): self.param.activate() - + class ActionParameter(Parameter): """Used for displaying a button within the tree. @@ -714,21 +719,21 @@ class ActionParameter(Parameter): """ 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 = QtGui.QTextEdit() + self.textBox = w = QtWidgets.QTextEdit() w.sizeHint = lambda: QtCore.QSize(300, 100) w.value = lambda: str(w.toPlainText()) w.setValue = w.setPlainText @@ -742,3 +747,635 @@ class TextParameter(Parameter): 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(asUnicode(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 = asUnicode(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 = asUnicode(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/widgets/PenSelectorDialog.py b/pyqtgraph/widgets/PenSelectorDialog.py new file mode 100644 index 00000000..47a0211d --- /dev/null +++ b/pyqtgraph/widgets/PenSelectorDialog.py @@ -0,0 +1,179 @@ +from ..Qt import QtCore, QtGui, QtWidgets +from ..parametertree import Parameter, ParameterTree +from ..functions import mkPen + +import re +from contextlib import ExitStack + +class PenPreviewArea(QtWidgets.QLabel): + def __init__(self, pen): + super().__init__() + self.penLocs = [] + self.lastPos = None + self.pen = pen + + def mousePressEvent(self, ev): + self.penLocs.clear() + super().mousePressEvent(ev) + + def mouseMoveEvent(self, ev): + ret =super().mouseMoveEvent(ev) + if not (ev.buttons() & QtCore.Qt.MouseButton.LeftButton): + return ret + pos = ev.position() if hasattr(ev, 'position') else ev.localPos() + if pos != self.lastPos: + self.penLocs.append(pos) + self.lastPos = QtCore.QPointF(pos) + self.update() + return ret + + def paintEvent(self, *args): + painter = QtGui.QPainter(self) + # draw a squigly line to show what the pen looks like. + if len(self.penLocs) < 1: + path = self.getDefaultPath() + else: + path = QtGui.QPainterPath() + path.moveTo(self.penLocs[0]) + for pos in self.penLocs[1:]: + path.lineTo(pos) + + painter.setPen(self.pen) + painter.drawPath(path) + painter.end() + + def getDefaultPath(self): + w, h = self.width(), self.height() + path = QtGui.QPainterPath() + path.moveTo(w * .2, h * .2) + path.lineTo(w * .8, h * .2) + path.lineTo(w * .2, h * .5) + path.cubicTo(w * .1, h * 1, w * .5, h * .25, w * .8, h * .8) + return path + +class PenSelectorDialog(QtWidgets.QDialog): + def __init__(self, initialPen='k'): + super().__init__() + self.pen = mkPen(initialPen) + self.param = self.mkParam(self.pen) + self.tree = ParameterTree(showHeader=False) + self.tree.setParameters(self.param, showTop=False) + self.setupUi() + self.setModal(True) + + @staticmethod + def mkParam(boundPen=None): + # Import here to avoid cyclic dependency + from ..parametertree.parameterTypes import QtEnumParameter + cs = QtCore.Qt.PenCapStyle + js = QtCore.Qt.PenJoinStyle + ps = QtCore.Qt.PenStyle + param = Parameter.create(name='Params', type='group', children=[ + dict(name='color', type='color', value='k'), + dict(name='width', value=1, type='int', limits=[0, None]), + QtEnumParameter(ps, name='style', value='SolidLine'), + QtEnumParameter(cs, name='capStyle'), + QtEnumParameter(js, name='joinStyle'), + dict(name='cosmetic', type='bool', value=True) + ]) + + for p in param: + name = p.name() + replace = r'\1 \2' + name = re.sub(r'(\w)([A-Z])', replace, name) + name = name.title().strip() + p.setOpts(title=name) + + def setterWrapper(setter): + """Ignores the 'param' argument of sigValueChanged""" + def newSetter(_, value): + return setter(value) + return newSetter + + if boundPen is not None: + PenSelectorDialog.updateParamFromPen(param, boundPen) + for p in param: + setter, setName = PenSelectorDialog._setterForParam(p.name(), boundPen, returnName=True) + # Instead, set the parameter which will signal the old setter + setattr(boundPen, setName, p.setValue) + p.sigValueChanged.connect(setterWrapper(setter)) + # Populate initial value + return param + + @staticmethod + def updatePenFromParam(penOptsParam, pen=None): + if pen is None: + pen = mkPen() + for param in penOptsParam: + setter = PenSelectorDialog._setterForParam(param.name(), pen) + setter(param.value()) + return pen + + def updatePenFromOpts(self, penOpts, pen=None): + if pen is None: + pen = mkPen() + useKeys = set(penOpts).intersection(self.param.names) + for kk in useKeys: + setter = self._setterForParam(kk, pen) + setter(penOpts[kk]) + + @staticmethod + def _setterForParam(paramName, obj, returnName=False): + formatted = paramName[0].upper() + paramName[1:] + setter = getattr(obj, f'set{formatted}') + if returnName: + return setter, formatted + return setter + + @staticmethod + def updateParamFromPen(param, pen): + """ + Applies settings from a pen to either a Parameter or dict. The Parameter or dict must already + be populated with the relevant keys that can be found in `PenSelectorDialog.mkParam`. + """ + stack = ExitStack() + if isinstance(param, Parameter): + names = param.names + # Block changes until all are finalized + stack.enter_context(param.treeChangeBlocker()) + else: + names = param + for opt in names: + # Booleans have different naming convention + if isinstance(param[opt], bool): + attrName = f'is{opt.title()}' + else: + attrName = opt + param[opt] = getattr(pen, attrName)() + stack.close() + + def setupUi(self): + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self.tree) + + self.buttonBoxAcceptCancel = QtWidgets.QDialogButtonBox(self) + self.buttonBoxAcceptCancel.setOrientation(QtCore.Qt.Orientation.Horizontal) + self.buttonBoxAcceptCancel.setStandardButtons( + QtWidgets.QDialogButtonBox.StandardButton.Cancel | QtWidgets.QDialogButtonBox.StandardButton.Ok) + self.buttonBoxAcceptCancel.accepted.connect(self.accept) + self.buttonBoxAcceptCancel.rejected.connect(self.reject) + + self.labelPenPreview = PenPreviewArea(self.pen) + def maybeUpdatePreview(_, changes): + if any('value' in c[1] for c in changes): + self.labelPenPreview.update() + self.param.sigTreeStateChanged.connect(maybeUpdatePreview) + infoLbl = QtWidgets.QLabel('Click and drag below to test the pen') + infoLbl.setAlignment(QtCore.Qt.AlignmentFlag.AlignHCenter) + policy = QtGui.QSizePolicy.Policy + infoLbl.setSizePolicy(policy.Expanding, policy.Fixed) + self.labelPenPreview.setMinimumSize(10,30) + self.tree.setMinimumSize(240, 135) + self.tree.setMaximumHeight(135) + + layout.addWidget(infoLbl) + layout.addWidget(self.labelPenPreview) + layout.addWidget(self.buttonBoxAcceptCancel) + + self.setLayout(layout) + self.resize(240, 300) \ No newline at end of file