diff --git a/pytec/pytec/gui/view/ctrl_panel.py b/pytec/pytec/gui/view/ctrl_panel.py index c907591..c98af87 100644 --- a/pytec/pytec/gui/view/ctrl_panel.py +++ b/pytec/pytec/gui/view/ctrl_panel.py @@ -4,7 +4,7 @@ from pyqtgraph.parametertree import ( Parameter, registerParameterType, ) -import pytec.gui.view.pin_si_prefix +import pytec.gui.view.lockable_unit class MutexParameter(pTypes.ListParameter): diff --git a/pytec/pytec/gui/view/pin_si_prefix.py b/pytec/pytec/gui/view/lockable_unit.py similarity index 52% rename from pytec/pytec/gui/view/pin_si_prefix.py rename to pytec/pytec/gui/view/lockable_unit.py index 1b0ad3d..78e519f 100644 --- a/pytec/pytec/gui/view/pin_si_prefix.py +++ b/pytec/pytec/gui/view/lockable_unit.py @@ -1,21 +1,84 @@ +from PyQt6.QtCore import QSignalBlocker +from PyQt6.QtGui import QValidator + from pyqtgraph import SpinBox import pyqtgraph.functions as fn from pyqtgraph.parametertree import registerParameterItemType from pyqtgraph.parametertree.parameterTypes import SimpleParameter, NumericParameterItem -class PinSIPrefixSpinBox(SpinBox): +class LockableUnitSpinBox(SpinBox): """ Extension of PyQtGraph's SpinBox widget. Adds: * The "pinSiPrefix" option, where the siPrefix could be fixed to a particular scale instead of as determined by its value. + * The "noUnitEditing" option, where the suffix and pinned siPrefix + of the SpinBox text is fixed and uneditable. """ + def __init__(self, parent=None, value=0.0, **kwargs): + super().__init__(parent, value, **kwargs) + + self.lineEdit().cursorPositionChanged.connect( + self._editor_cursor_position_changed + ) + + def validate(self, strn, pos): + ret, strn, pos = super().validate(strn, pos) + + if self.opts.get("noUnitEditing") is True: + suffix = self.opts["suffix"] + pinned_si_prefix = self.opts.get("pinSiPrefix") + + suffix_edited = not strn.endswith(suffix) + pinned_si_prefix_edited = ( + pinned_si_prefix is not None + and not strn.removesuffix(suffix).endswith(pinned_si_prefix) + ) + + if suffix_edited or pinned_si_prefix_edited: + ret = QValidator.State.Invalid + + return ret, strn, pos + + def _editor_cursor_position_changed(self, oldpos, newpos): + # Called on cursor position change + # Skips over the suffix and pinned SI prefix on cursor navigation if option + # noUnitEditing is enabled. + + # Modified from the original Qt C++ source, + # QAbstractSpinBox::editorCursorPositionChanged. + # Their suffix is different than our suffix; there's no obvious way to set + # theirs here in the derived class since it is private. + + if self.opts.get("noUnitEditing") is True: + edit = self.lineEdit() + if edit.hasSelectedText(): + return # Allow for selecting units, for copy-and-paste + + pinned_si_prefix = self.opts.get("pinSiPrefix") or "" + unit_len = len(pinned_si_prefix) + len(self.opts["suffix"]) + text_len = len(edit.text()) + + pos = -1 + # Cursor in unit + if text_len - unit_len < newpos < text_len: + if oldpos == text_len: + pos = text_len - unit_len + else: + pos = text_len + + if pos != -1: + with QSignalBlocker(edit): + edit.setCursorPosition(pos) + def setOpts(self, **opts): if "pinSiPrefix" in opts: self.opts["pinSiPrefix"] = opts.pop("pinSiPrefix") + if "noUnitEditing" in opts: + self.opts["noUnitEditing"] = opts.pop("noUnitEditing") super().setOpts(**opts) @@ -57,10 +120,10 @@ class PinSIPrefixSpinBox(SpinBox): return self.opts['format'].format(**parts) -class PinSIPrefixNumericParameterItem(NumericParameterItem): +class UnitfulNumericParameterItem(NumericParameterItem): """ Subclasses PyQtGraph's `NumericParameterItem` and uses - PinSIPrefixSpinBox for editing. + UnitfulSpinBox for editing. """ def makeWidget(self): @@ -70,7 +133,7 @@ class PinSIPrefixNumericParameterItem(NumericParameterItem): 'value': 0, 'min': None, 'max': None, 'step': 1.0, 'dec': False, 'siPrefix': False, 'suffix': '', 'decimals': 3, - 'pinSiPrefix': None, + 'pinSiPrefix': None, 'noUnitEditing': False, } if t == 'int': defs['int'] = True @@ -80,7 +143,7 @@ class PinSIPrefixNumericParameterItem(NumericParameterItem): defs[k] = opts[k] if 'limits' in opts: defs['min'], defs['max'] = opts['limits'] - w = PinSIPrefixSpinBox() + w = LockableUnitSpinBox() w.setOpts(**defs) w.sigChanged = w.sigValueChanged w.sigChanging = w.sigValueChanging @@ -88,8 +151,8 @@ class PinSIPrefixNumericParameterItem(NumericParameterItem): registerParameterItemType( - "float", PinSIPrefixNumericParameterItem, SimpleParameter, override=True + "float", UnitfulNumericParameterItem, SimpleParameter, override=True ) registerParameterItemType( - "int", PinSIPrefixNumericParameterItem, SimpleParameter, override=True + "int", UnitfulNumericParameterItem, SimpleParameter, override=True ) diff --git a/pytec/pytec/gui/view/param_tree.json b/pytec/pytec/gui/view/param_tree.json index ee85edd..c9c91e5 100644 --- a/pytec/pytec/gui/view/param_tree.json +++ b/pytec/pytec/gui/view/param_tree.json @@ -4,7 +4,8 @@ "name": "temperature", "title": "Temperature", "type": "float", - "format": "{value:.4f} °C", + "format": "{value:.4f} {suffix}", + "suffix": "°C", "readonly": true, "tip": "The measured temperature at the thermistor" }, @@ -54,6 +55,7 @@ "pinSiPrefix": "m", "suffix": "A", "siPrefix": true, + "noUnitEditing": true, "thermostat:set_param": { "topic": "pwm", "field": "i_set" @@ -71,7 +73,9 @@ -273, 300 ], - "format": "{value:.4f} °C", + "format": "{value:.4f} {suffix}", + "suffix": "°C", + "noUnitEditing": true, "thermostat:set_param": { "topic": "pid", "field": "target" @@ -102,6 +106,7 @@ "siPrefix": true, "pinSiPrefix": "m", "suffix": "A", + "noUnitEditing": true, "thermostat:set_param": { "topic": "pwm", "field": "max_i_pos" @@ -119,6 +124,7 @@ "siPrefix": true, "pinSiPrefix": "m", "suffix": "A", + "noUnitEditing": true, "limits": [ 0, 2 @@ -142,6 +148,7 @@ ], "siPrefix": true, "suffix": "V", + "noUnitEditing": true, "thermostat:set_param": { "topic": "pwm", "field": "max_v" @@ -170,7 +177,9 @@ -100, 100 ], - "format": "{value:.4f} °C", + "format": "{value:.4f} {suffix}", + "suffix": "°C", + "noUnitEditing": true, "thermostat:set_param": { "topic": "s-h", "field": "t0" @@ -185,7 +194,9 @@ "value": 10000, "step": 1, "siPrefix": true, + "pinSiPrefix": "k", "suffix": "Ω", + "noUnitEditing": true, "thermostat:set_param": { "topic": "s-h", "field": "r0" @@ -200,6 +211,7 @@ "value": 3950, "step": 1, "suffix": "K", + "noUnitEditing": true, "decimals": 4, "thermostat:set_param": { "topic": "s-h", @@ -255,6 +267,7 @@ "type": "float", "step": 0.1, "suffix": "Hz", + "noUnitEditing": true, "thermostat:set_param": { "topic": "pid", "field": "ki" @@ -268,6 +281,7 @@ "type": "float", "step": 0.1, "suffix": "s", + "noUnitEditing": true, "thermostat:set_param": { "topic": "pid", "field": "kd" @@ -295,6 +309,7 @@ "siPrefix": true, "pinSiPrefix": "m", "suffix": "A", + "noUnitEditing": true, "thermostat:set_param": { "topic": "pid", "field": "output_min" @@ -315,6 +330,7 @@ "siPrefix": true, "pinSiPrefix": "m", "suffix": "A", + "noUnitEditing": true, "thermostat:set_param": { "topic": "pid", "field": "output_max" @@ -337,7 +353,9 @@ "type": "float", "value": 20, "step": 0.1, - "format": "{value:.4f} °C", + "format": "{value:.4f} {suffix}", + "suffix": "°C", + "noUnitEditing": true, "pid_autotune": "target_temp", "tip": "The target temperature to autotune for" }, @@ -355,6 +373,7 @@ "siPrefix": true, "pinSiPrefix": "m", "suffix": "A", + "noUnitEditing": true, "pid_autotune": "test_current", "tip": "The testing current when autotuning" }, @@ -365,7 +384,9 @@ "value": 1.5, "step": 0.1, "prefix": "±", - "format": "{value:.4f} °C", + "format": "{value:.4f} {suffix}", + "suffix": "°C", + "noUnitEditing": true, "pid_autotune": "temp_swing", "tip": "The temperature swing around the target" }, @@ -375,7 +396,9 @@ "type": "float", "value": 3.0, "step": 0.1, - "format": "{value:.4f} s", + "format": "{value:.4f} {suffix}", + "noUnitEditing": true, + "suffix": "s", "pid_autotune": "lookback", "tip": "Amount of time referenced for tuning" },