diff --git a/src/instrumentserver/gui/base_instrument.py b/src/instrumentserver/gui/base_instrument.py index 2e28793..0ef78c2 100644 --- a/src/instrumentserver/gui/base_instrument.py +++ b/src/instrumentserver/gui/base_instrument.py @@ -534,6 +534,14 @@ class InstrumentTreeViewBase(QtWidgets.QTreeView): #: emitted when this item got its star action triggered. itemStarToggle = QtCore.Signal(ItemBase) + #: Signal() + #: emitted when the user presses Enter, F2, or Right to enter edit mode on the selected parameter. + editCurrentParameter = QtCore.Signal() + + #: Signal() + #: emitted when the user presses Delete to clear the selected parameter's value. + clearCurrentParameter = QtCore.Signal() + def __init__( self, model: QtCore.QAbstractItemModel, @@ -586,6 +594,15 @@ def __init__( self.setContextMenuPolicy(QtCore.Qt.ContextMenuPolicy.CustomContextMenu) self.customContextMenuRequested.connect(self.onContextMenuRequested) + for key in ("Return", "Enter", "F2", "Right"): + sc = QtWidgets.QShortcut(QtGui.QKeySequence(key), self) + sc.setContext(QtCore.Qt.ShortcutContext.WidgetShortcut) + sc.activated.connect(self.editCurrentParameter) + + sc = QtWidgets.QShortcut(QtGui.QKeySequence("Backspace"), self) + sc.setContext(QtCore.Qt.ShortcutContext.WidgetShortcut) + sc.activated.connect(self.clearCurrentParameter) + @QtCore.Slot() def fillCollapsedDict(self, parentItem: Optional[ItemBase] = None) -> None: """ @@ -749,6 +766,15 @@ def onContextMenuRequested(self, pos: QtCore.QPoint) -> None: self.contextMenu.exec_(self.mapToGlobal(pos)) + def focusNextPrevChild(self, next: bool) -> bool: + current = self.currentIndex() + if current.isValid(): + next_idx = self.indexBelow(current) if next else self.indexAbove(current) + if next_idx.isValid(): + self.setCurrentIndex(next_idx) + return True + return super().focusNextPrevChild(next) + @QtCore.Slot() def onStarActionTrigger(self) -> None: self.itemStarToggle.emit(self.lastSelectedItem) diff --git a/src/instrumentserver/gui/instruments.py b/src/instrumentserver/gui/instruments.py index b96805a..9d33f14 100644 --- a/src/instrumentserver/gui/instruments.py +++ b/src/instrumentserver/gui/instruments.py @@ -203,6 +203,10 @@ class MethodDisplay(QtWidgets.QWidget): #: emitted when the widget runs a function and is successful. Emits the return value as a string. runSuccessful = QtCore.Signal(str) + #: Signal() -- + #: emitted when the user commits via Return/Enter, regardless of success or failure + valueCommitted = QtCore.Signal() + def __init__( self, fun: Callable, @@ -254,6 +258,8 @@ def runFun(self) -> None: except Exception as e: self.runFailed.emit(str(e)) logger.warning(f"'{self.fullName}' Raised the following execution: {e}") + finally: + self.valueCommitted.emit() @classmethod def getTooltipFromFun(cls, fun: Callable) -> str: @@ -286,6 +292,7 @@ def __init__(self, parent: Optional[QtCore.QObject] = None) -> None: # Stores as key the name of the item and as value the widget that the delegate creates. # used to keep a reference to the widget. self.parameters: Dict[str, QtWidgets.QWidget] = {} + self.navFilter: Optional["ValueCellNavigationFilter"] = None def createEditor( # type: ignore[override] self, @@ -301,6 +308,16 @@ def createEditor( # type: ignore[override] ret = ParameterWidget(element, widget) self.parameters[item.name] = ret # type: ignore[attr-defined] + ret.valueCommitted.connect(self.parent().setFocus) # type: ignore[union-attr] + + if self.navFilter is not None: + if isinstance(ret.paramWidget, AnyInput): + input_widget = ret.paramWidget.input + else: + input_widget = ret.paramWidget + input_widget.installEventFilter(self.navFilter) + self.navFilter.registerWidget(input_widget, index) + # Try to fetch and display current value immediately # ---- Chao: removed because the constructor of ParameterWidget object already calls parameter get ---- # if element.gettable: @@ -312,6 +329,76 @@ def createEditor( # type: ignore[override] return ret +class ValueCellNavigationFilter(QtCore.QObject): + """Event filter installed on value input widgets to handle Escape, Tab, and Shift+Tab.""" + + def __init__(self, treeView: InstrumentTreeViewBase) -> None: + super().__init__(treeView) + self._treeView = treeView + self._widgetIndex: Dict[QtCore.QObject, QtCore.QPersistentModelIndex] = {} + + def registerWidget(self, widget: QtCore.QObject, index: QtCore.QModelIndex) -> None: + self._widgetIndex[widget] = QtCore.QPersistentModelIndex(index) + + def eventFilter(self, obj: QtCore.QObject, event: QtCore.QEvent) -> bool: # type: ignore[override] + if event.type() == QtCore.QEvent.Type.FocusIn: + if obj in self._widgetIndex: + idx = self._widgetIndex[obj] + if idx.isValid(): + self._treeView.setCurrentIndex(QtCore.QModelIndex(idx)) + return False + + if event.type() == QtCore.QEvent.Type.KeyPress: + assert isinstance(event, QtGui.QKeyEvent) + key = QtGui.QKeySequence(event.key()).toString() + + if key == "Esc": + host = self._findHostWidget(obj) + if isinstance(host, ParameterWidget): + host.setWidgetFromParameter() + elif isinstance(host, MethodDisplay): + host.anyInput.input.clear() + self._treeView.setFocus() + return True + + elif key == "Tab": + self._commitAndMove(obj, 1) + return True + + elif key == "Backtab": + self._commitAndMove(obj, -1) + return True + + return super().eventFilter(obj, event) + + def _findHostWidget( + self, obj: QtCore.QObject + ) -> Optional[Union[ParameterWidget, MethodDisplay]]: + parent = obj.parent() + while parent is not None: + if isinstance(parent, (ParameterWidget, MethodDisplay)): + return parent + parent = parent.parent() + return None + + def _commitAndMove(self, obj: QtCore.QObject, direction: int) -> None: + host = self._findHostWidget(obj) + if isinstance(host, ParameterWidget): + host.setButton.click() + elif isinstance(host, MethodDisplay): + host.runFun() + current = self._treeView.currentIndex() + if current.isValid(): + next_idx = ( + self._treeView.indexBelow(current) + if direction > 0 + else self._treeView.indexAbove(current) + ) + if next_idx.isValid(): + self._treeView.setCurrentIndex(next_idx) + self._treeView.setFocus() + + class ModelParameters(InstrumentModelBase): # : Signal(item, object) : Emitted when an item in the model has received a new value, first object is the item's # name, second object is its new value @@ -424,6 +511,7 @@ def __init__( super().__init__(model, [2], *args, **kwargs) self.delegate = ParameterDelegate(self) + self.delegate.navFilter = ValueCellNavigationFilter(self) self.setItemDelegateForColumn(2, self.delegate) self.setAllDelegatesPersistent() @@ -482,11 +570,14 @@ def __init__( def connectSignals(self) -> None: super().connectSignals() self.model.itemNewValue.connect(self.view.onItemNewValue) + + self.view.editCurrentParameter.connect(self._focusToParameterValue) + self.view.clearCurrentParameter.connect(self._clearCurrentParameter) + self.shortcutManager.register("refresh_item", self._refreshCurrentItem, self) self.shortcutManager.register( "toggle_python", self._togglePythonCurrentItem, self ) - self.shortcutManager.register("edit_value", self._focusToParameterValue, self) def _withCurrentParameter( self, callback: Callable[["ParameterWidget"], None] @@ -521,6 +612,18 @@ def _focusToParameterValue(self) -> None: ) ) + @QtCore.Slot() + def _clearCurrentParameter(self) -> None: + self._withCurrentParameter( + lambda w: ( + w.paramWidget.input.clear() + if isinstance(w.paramWidget, AnyInput) + else w.paramWidget.clear() + if hasattr(w.paramWidget, "clear") + else None + ) + ) + # ----------------- Parameters Display Classes - Ending -------------------------------- @@ -544,6 +647,15 @@ def createEditor( # type: ignore[override] ret = ParameterWidget(parameter=element, parent=widget, additionalWidgets=[rw]) self.parameters[item.name] = ret # type: ignore[attr-defined] + ret.valueCommitted.connect(self.parent().setFocus) # type: ignore[union-attr] + + if self.navFilter is not None: + if isinstance(ret.paramWidget, AnyInput): + input_widget = ret.paramWidget.input + else: + input_widget = ret.paramWidget + input_widget.installEventFilter(self.navFilter) + self.navFilter.registerWidget(input_widget, index) return ret @@ -572,6 +684,7 @@ def __init__( super().__init__(model, [2], *args, **kwargs) self.delegate = ParameterDeleteDelegate(self) + self.delegate.navFilter = ValueCellNavigationFilter(self) self.setItemDelegateForColumn(2, self.delegate) self.setAllDelegatesPersistent() @@ -769,6 +882,7 @@ def __init__(self, parent: Optional[QtCore.QObject] = None) -> None: super().__init__(parent=parent) self.methods: Dict[str, "MethodDisplay"] = {} + self.navFilter: Optional[ValueCellNavigationFilter] = None def createEditor( # type: ignore[override] self, @@ -786,6 +900,12 @@ def createEditor( # type: ignore[override] parent.clearAlertsAction.triggered.connect(ret.alertLabel.clearAlert) # type: ignore[union-attr] self.methods[item.name] = ret # type: ignore[attr-defined] + ret.valueCommitted.connect(self.parent().setFocus) # type: ignore[union-attr] + + if self.navFilter is not None: + ret.anyInput.input.installEventFilter(self.navFilter) + self.navFilter.registerWidget(ret.anyInput.input, index) + return ret @@ -804,6 +924,7 @@ def __init__( self.contextMenu.addAction(self.clearAlertsAction) self.delegate = MethodsDelegate(self) + self.delegate.navFilter = ValueCellNavigationFilter(self) self.setItemDelegateForColumn(1, self.delegate) self.setAllDelegatesPersistent() @@ -834,10 +955,13 @@ def __init__(self, instrument: Any, **kwargs: Any) -> None: def connectSignals(self) -> None: super().connectSignals() + + self.view.editCurrentParameter.connect(self._focusToMethodValue) + self.view.clearCurrentParameter.connect(self._clearCurrentMethod) + self.shortcutManager.register( "toggle_python", self._togglePythonCurrentItem, self ) - self.shortcutManager.register("edit_value", self._focusToMethodValue, self) self.shortcutManager.register("run_method", self._runCurrentMethod, self) def _withCurrentMethod(self, callback: Callable[["MethodDisplay"], None]) -> None: @@ -855,6 +979,10 @@ def _togglePythonCurrentItem(self) -> None: def _focusToMethodValue(self) -> None: self._withCurrentMethod(lambda w: w.anyInput.input.setFocus()) + @QtCore.Slot() + def _clearCurrentMethod(self) -> None: + self._withCurrentMethod(lambda w: w.anyInput.input.clear()) + @QtCore.Slot() def _runCurrentMethod(self) -> None: self._withCurrentMethod(lambda w: w.runFun()) diff --git a/src/instrumentserver/gui/parameters.py b/src/instrumentserver/gui/parameters.py index c1d7f15..fe5f4e8 100644 --- a/src/instrumentserver/gui/parameters.py +++ b/src/instrumentserver/gui/parameters.py @@ -49,6 +49,10 @@ class ParameterWidget(QtWidgets.QWidget): #: Signal(Any) -- _valueFromWidget = QtCore.Signal(object) + #: Signal() -- + #: emitted when the user commits a value via Return/Enter + valueCommitted = QtCore.Signal() + def __init__( self, parameter: Parameter, @@ -181,7 +185,7 @@ def onReturnPressed(self) -> None: """Activates the setButton when the input is selected and enter is pressed.""" self.setButton.click() self.paramWidget.input.deselect() - self.setButton.setFocus() + self.valueCommitted.emit() def setParameter(self, value: Any) -> None: try: diff --git a/src/instrumentserver/gui/shortcuts.py b/src/instrumentserver/gui/shortcuts.py index c528a6a..77c8096 100644 --- a/src/instrumentserver/gui/shortcuts.py +++ b/src/instrumentserver/gui/shortcuts.py @@ -43,7 +43,6 @@ class KeyboardShortcutManager: "save_items": ("Ctrl+Shift+S", "Save parameters to JSON file"), "fit_column": ("Ctrl+Shift+D", "Fits column width"), "sort_column": ("Ctrl+D", "Toggle sorting of selected column"), - "edit_value": ("Right", "Jump cursor to value field for selected parameter"), } def __init__(self) -> None: