diff --git a/.github/workflows/ci.yml b/.github/workflows/ci.yml index c1d092e..70fd2df 100644 --- a/.github/workflows/ci.yml +++ b/.github/workflows/ci.yml @@ -72,7 +72,9 @@ jobs: libgl1 \ libegl1 \ libxkbcommon0 \ - libdbus-1-3 + libdbus-1-3 \ + libfontconfig1 \ + fonts-dejavu-core - run: uv sync --locked --group dev - run: uv run pytest test/ -v diff --git a/src/instrumentserver/apps.py b/src/instrumentserver/apps.py index 0d881d1..d705097 100644 --- a/src/instrumentserver/apps.py +++ b/src/instrumentserver/apps.py @@ -63,16 +63,23 @@ def serverScript() -> None: stationConfig, serverConfig, guiConfig, + shortcutConfig, tempFile, pollingRates, pollingThread, ipAddresses, - ) = None, None, None, None, None, None, None + ) = None, None, None, None, None, None, None, None if configPath != "": # Separates the corresponding settings into the 5 necessary parts - stationConfig, serverConfig, guiConfig, tempFile, pollingRates, ipAddresses = ( - loadConfig(configPath) - ) + ( + stationConfig, + serverConfig, + guiConfig, + shortcutConfig, + tempFile, + pollingRates, + ipAddresses, + ) = loadConfig(configPath) if pollingRates is not None and pollingRates != {}: pollingThread = QtCore.QThread() pollWorker = PollingWorker(pollingRates=pollingRates) @@ -100,8 +107,10 @@ def serverScript() -> None: serverConfig=serverConfig, stationConfig=stationConfig, guiConfig=guiConfig, + shortcutConfig=shortcutConfig, pollingThread=pollingThread, ipAddresses=ipAddresses, + configPath=configPath, ) # Close and delete the temporary files diff --git a/src/instrumentserver/client/proxy.py b/src/instrumentserver/client/proxy.py index 03cd9c2..391542c 100644 --- a/src/instrumentserver/client/proxy.py +++ b/src/instrumentserver/client/proxy.py @@ -859,7 +859,7 @@ def __init__( # Use config.py to parse server config format from instrumentserver.config import loadConfig - _, serverConfig, fullConfig, tempFile, _, _ = loadConfig(config_path) + _, serverConfig, fullConfig, _, tempFile, _, _ = loadConfig(config_path) tempFile.close() # Clean up temp file self.full_config = fullConfig diff --git a/src/instrumentserver/config.py b/src/instrumentserver/config.py index d177c87..f30d874 100644 --- a/src/instrumentserver/config.py +++ b/src/instrumentserver/config.py @@ -18,7 +18,9 @@ GUIFIELD = {"type": "instrumentserver.gui.instruments.GenericInstrument", "kwargs": {}} -def loadConfig(configPath: str | Path) -> tuple[str, dict, dict, IO[bytes], dict, dict]: +def loadConfig( + configPath: str | Path, +) -> tuple[str, dict, dict, dict, IO[bytes], dict, dict]: """ Loads the config for the instrumentserver. From 1 config file it splits the respective fields into 3 different objects: a serverConfig (the configurations for the server), a stationConfig(the qcodes station config file clean @@ -36,6 +38,7 @@ def loadConfig(configPath: str | Path) -> tuple[str, dict, dict, IO[bytes], dict serverConfig: dict = {} # Config for the server guiConfig = {} # Individual gui config of each instrument fullConfig = {} # serverConfig + guiConfig + any unfilled fields. Used for creating instruments from the gui + shortcutConfig = {} # Preferences for keyboard shortcuts pollingRates = {} # Polling rates for each parameter ipAddresses = {} # Dictionary of IP Addresses to send broadcasts to: # externalBroadcast: where to externally send parameter change broadcasts to, formatted like "tcp://address:port" @@ -150,6 +153,11 @@ def loadConfig(configPath: str | Path) -> tuple[str, dict, dict, IO[bytes], dict # Update fullConfig with merged GUI config fullConfig[instrumentName]["gui"] = guiConfig[instrumentName] + # Gets all shortcuts different to REGISTRY defaults from the config file + if "shortcuts" in rawConfig: + shortcutConfig = rawConfig["shortcuts"] + rawConfig.pop("shortcuts") + # Gets all of the broadcasting and listening addresses from the config file if "networking" in rawConfig: addressDict = rawConfig["networking"] @@ -170,4 +178,12 @@ def loadConfig(configPath: str | Path) -> tuple[str, dict, dict, IO[bytes], dict tempFilePath = tempFile.name # You need to return the tempFile itself so that the garbage collector doesn't touch it - return tempFilePath, serverConfig, fullConfig, tempFile, pollingRates, ipAddresses + return ( + tempFilePath, + serverConfig, + fullConfig, + shortcutConfig, + tempFile, + pollingRates, + ipAddresses, + ) diff --git a/src/instrumentserver/gui/base_instrument.py b/src/instrumentserver/gui/base_instrument.py index 5e76af5..2e28793 100644 --- a/src/instrumentserver/gui/base_instrument.py +++ b/src/instrumentserver/gui/base_instrument.py @@ -107,6 +107,7 @@ from typing import Any, Dict, List, Optional, cast from instrumentserver import QtCore, QtGui, QtWidgets +from instrumentserver.gui.shortcuts import KeyboardShortcutManager class ItemBase(QtGui.QStandardItem): @@ -237,6 +238,7 @@ def _matches_any_pattern(name: str, patterns: List[str]) -> bool: :param patterns: List of glob patterns to match against (e.g., 'power_*', '*_frequency') :return: True if name matches any pattern, False otherwise """ + for pattern in patterns: if fnmatch.fnmatch(name, pattern): return True @@ -481,7 +483,7 @@ def filterAcceptsRow( item = parent.child(source_row, 0) # The order in which things get constructed seems to impact this. - # When the application is first starting, the proxy model does not have the trash attribute. + # When the application is first starting, the proxy model does not have the trash attribute. if hasattr(self, "trash"): if self.trash: # Assertion is there to satisfy mypy. item can be None, that is why we check before making the assertion @@ -769,6 +771,7 @@ class InstrumentDisplayBase(QtWidgets.QWidget): :param proxyModelType: The type of proxy model that should be used. :param viewType: The type of view that should be used. :param callSignals: If False, the constructor will not call the method connectSignals + :param shortcutManager: Manager shared across the application so actions can be registered to shortcuts """ def __init__( @@ -780,6 +783,7 @@ def __init__( proxyModelType: type = InstrumentSortFilterProxyModel, viewType: type = InstrumentTreeViewBase, callSignals: bool = True, + shortcutManager: Optional[KeyboardShortcutManager] = None, parent: Optional[QtWidgets.QWidget] = None, **modelKwargs: Any, ) -> None: @@ -795,6 +799,12 @@ def __init__( self.proxyModel = proxyModelType(self.model) self.view = viewType(self.proxyModel) + self.shortcutManager = ( + shortcutManager + if shortcutManager is not None + else KeyboardShortcutManager() + ) + self.layout_ = QtWidgets.QVBoxLayout() self.lineEdit = QtWidgets.QLineEdit(self) @@ -830,6 +840,17 @@ def connectSignals(self) -> None: self.proxyModel.onSortingIndicatorChanged ) + self.shortcutManager.register("jump_filter", self.lineEdit.setFocus, self) + self.shortcutManager.register("star_item", self._starCurrentItem, self) + self.shortcutManager.register("trash_item", self._trashCurrentItem, self) + self.shortcutManager.register("fit_column", self._fitCurrentColumn, self) + self.shortcutManager.register("sort_column", self._sortCurrentColumn, self) + self.shortcutManager.register("refresh_all", self.refreshAll, self) + self.shortcutManager.register("expand_all", self.view.expandAll, self) + self.shortcutManager.register("collapse_all", self.view.collapseAll, self) + self.shortcutManager.register("toggle_star", self._starAction.trigger, self) # type: ignore[union-attr] + self.shortcutManager.register("toggle_trash", self._trashAction.trigger, self) # type: ignore[union-attr] + def makeToolbar(self) -> QtWidgets.QToolBar: """ Creates the toolbar, override to add more buttons to the toolbar. @@ -842,6 +863,7 @@ def makeToolbar(self) -> QtWidgets.QToolBar: "refresh all items from the instrument", ) refreshAction.triggered.connect(lambda x: self.refreshAll()) # type: ignore[union-attr] + self.shortcutManager.register_tooltip("refresh_all", refreshAction) toolbar.addSeparator() @@ -850,26 +872,30 @@ def makeToolbar(self) -> QtWidgets.QToolBar: "expand tree", ) expandAction.triggered.connect(lambda x: self.view.expandAll()) # type: ignore[union-attr] + self.shortcutManager.register_tooltip("expand_all", expandAction) collapseAction = toolbar.addAction( QtGui.QIcon(":/icons/collapse.svg"), "collapse tree", ) collapseAction.triggered.connect(lambda x: self.view.collapseAll()) # type: ignore[union-attr] + self.shortcutManager.register_tooltip("collapse_all", collapseAction) toolbar.addSeparator() - starAction = toolbar.addAction( + self._starAction = toolbar.addAction( QtGui.QIcon(":/icons/star.svg"), "Move Starred items to the top" ) - starAction.setCheckable(True) # type: ignore[union-attr] - starAction.triggered.connect(lambda x: self.promoteStar()) # type: ignore[union-attr] + self._starAction.setCheckable(True) # type: ignore[union-attr] + self._starAction.triggered.connect(lambda x: self.proxyModel.onToggleStar()) # type: ignore[union-attr] + self.shortcutManager.register_tooltip("toggle_star", self._starAction) - trashAction = toolbar.addAction( + self._trashAction = toolbar.addAction( QtGui.QIcon(":/icons/trash-crossed.svg"), "Hide trashed items" ) - trashAction.setCheckable(True) # type: ignore[union-attr] - trashAction.triggered.connect(lambda x: self.hideTrash()) # type: ignore[union-attr] + self._trashAction.setCheckable(True) # type: ignore[union-attr] + self._trashAction.triggered.connect(lambda x: self.proxyModel.onToggleTrash()) # type: ignore[union-attr] + self.shortcutManager.register_tooltip("toggle_trash", self._trashAction) # Debugging tools keep commented for commits. # printAction = toolbar.addAction( @@ -883,16 +909,51 @@ def makeToolbar(self) -> QtWidgets.QToolBar: return toolbar @QtCore.Slot() - def hideTrash(self) -> None: - self.proxyModel.onToggleTrash() + def refreshAll(self) -> None: + self.model.refreshAll() + + def _getCurrentItem(self) -> Optional[ItemBase]: + proxy_index = self.view.currentIndex() + if not proxy_index.isValid(): + return None + source_index = self.proxyModel.mapToSource(proxy_index) + if source_index.column() != 0: + source_index = source_index.sibling(source_index.row(), 0) + item = self.model.itemFromIndex(source_index) + return item if isinstance(item, ItemBase) else None + + def _toggleCurrentItem(self, signal: QtCore.SignalInstance) -> None: + item = self._getCurrentItem() + if item is not None: + self.view.lastSelectedItem = item + signal.emit(item) @QtCore.Slot() - def promoteStar(self) -> None: - self.proxyModel.onToggleStar() + def _starCurrentItem(self) -> None: + self._toggleCurrentItem(self.view.itemStarToggle) @QtCore.Slot() - def refreshAll(self) -> None: - self.model.refreshAll() + def _trashCurrentItem(self) -> None: + self._toggleCurrentItem(self.view.itemTrashToggle) + + @QtCore.Slot() + def _fitCurrentColumn(self) -> None: + col = self.view.currentIndex().column() + self.view.resizeColumnToContents(col if col >= 0 else 0) + + @QtCore.Slot() + def _sortCurrentColumn(self) -> None: + header = self.view.header() + col = self.view.currentIndex().column() + if col < 0: + col = header.sortIndicatorSection() + current_order = header.sortIndicatorOrder() + new_order = ( + QtCore.Qt.SortOrder.AscendingOrder + if current_order == QtCore.Qt.SortOrder.DescendingOrder + else QtCore.Qt.SortOrder.DescendingOrder + ) + header.setSortIndicator(col, new_order) def debuggingMethod(self) -> None: """ diff --git a/src/instrumentserver/gui/instruments.py b/src/instrumentserver/gui/instruments.py index 1a41ae0..b96805a 100644 --- a/src/instrumentserver/gui/instruments.py +++ b/src/instrumentserver/gui/instruments.py @@ -19,7 +19,7 @@ InstrumentTreeViewBase, ItemBase, ) -from .parameters import AnyInputForMethod, ParameterWidget +from .parameters import AnyInput, AnyInputForMethod, ParameterWidget # TODO: all styles set through a global style sheet. # TODO: [maybe] add a column for information on valid input values? @@ -465,6 +465,8 @@ def __init__( if "sub_port" in kwargs: modelKwargs["sub_port"] = kwargs.pop("sub_port") + shortcutManager = kwargs.pop("shortcutManager", None) + super().__init__( instrument=instrument, parent=parent, @@ -473,12 +475,51 @@ def __init__( modelType=ModelParameters, viewType=viewType, callSignals=callSignals, + shortcutManager=shortcutManager, **modelKwargs, ) def connectSignals(self) -> None: super().connectSignals() self.model.itemNewValue.connect(self.view.onItemNewValue) + 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] + ) -> None: + item = self._getCurrentItem() + if item is not None: + widget = self.view.delegate.parameters.get(item.name) + if widget is not None: + callback(widget) + + @QtCore.Slot() + def _refreshCurrentItem(self) -> None: + self._withCurrentParameter(lambda w: w.setWidgetFromParameter()) + + @QtCore.Slot() + def _togglePythonCurrentItem(self) -> None: + self._withCurrentParameter( + lambda w: ( + w.paramWidget.doEval.toggle() + if isinstance(w.paramWidget, AnyInput) + else None + ) + ) + + @QtCore.Slot() + def _focusToParameterValue(self) -> None: + self._withCurrentParameter( + lambda w: ( + w.paramWidget.input.setFocus() + if isinstance(w.paramWidget, AnyInput) + else w.paramWidget.setFocus() + ) + ) # ----------------- Parameters Display Classes - Ending -------------------------------- @@ -615,6 +656,17 @@ def connectSignals(self) -> None: self.parameterCreationError.connect(self.addParam.setError) self.parameterCreated.connect(self.addParam.clear) self.profileManager.indexChanged.connect(self.loadProfile) + self.shortcutManager.register("delete_item", self._deleteCurrentItem, self) + self.shortcutManager.register("clear_add", self.addParam.clear, self) + self.shortcutManager.register("add_item", self.addParam.nameEdit.setFocus, self) + self.shortcutManager.register("load_items", self.loadFromFile, self) + self.shortcutManager.register("save_items", self.saveToFile, self) + + @QtCore.Slot() + def _deleteCurrentItem(self) -> None: + item = self._getCurrentItem() + if item is not None: + self.removeParameter(item.name) def makeToolbar(self) -> QtWidgets.QToolBar: toolbar = super().makeToolbar() @@ -626,12 +678,14 @@ def makeToolbar(self) -> QtWidgets.QToolBar: "Load parameters from file", ) loadParamAction.triggered.connect(lambda x: self.loadFromFile()) # type: ignore[union-attr] + self.shortcutManager.register_tooltip("load_items", loadParamAction) saveParamAction = toolbar.addAction( QtGui.QIcon(":/icons/save.svg"), "Save parameters to file", ) saveParamAction.triggered.connect(lambda x: self.saveToFile()) # type: ignore[union-attr] + self.shortcutManager.register_tooltip("save_items", saveParamAction) return toolbar @@ -767,14 +821,44 @@ def __init__(self, instrument: Any, **kwargs: Any) -> None: if "methods-hide" in kwargs: modelKwargs["itemsHide"] = kwargs.pop("methods-hide") + shortcutManager = kwargs.pop("shortcutManager", None) + super().__init__( instrument=instrument, attr="functions", modelType=MethodsModel, viewType=MethodsTreeView, + shortcutManager=shortcutManager, **modelKwargs, ) + def connectSignals(self) -> None: + super().connectSignals() + 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: + item = self._getCurrentItem() + if item is not None: + widget = self.view.delegate.methods.get(item.name) + if widget is not None: + callback(widget) + + @QtCore.Slot() + def _togglePythonCurrentItem(self) -> None: + self._withCurrentMethod(lambda w: w.anyInput.doEval.toggle()) + + @QtCore.Slot() + def _focusToMethodValue(self) -> None: + self._withCurrentMethod(lambda w: w.anyInput.input.setFocus()) + + @QtCore.Slot() + def _runCurrentMethod(self) -> None: + self._withCurrentMethod(lambda w: w.runFun()) + # ----------------- Methods Display Classes - Ending ----------------------------------- diff --git a/src/instrumentserver/gui/shortcuts.py b/src/instrumentserver/gui/shortcuts.py new file mode 100644 index 0000000..c528a6a --- /dev/null +++ b/src/instrumentserver/gui/shortcuts.py @@ -0,0 +1,406 @@ +import logging +from collections import defaultdict +from typing import Callable, Optional, Union + +import yaml + +from instrumentserver import QtCore, QtGui, QtWidgets + +logger = logging.getLogger(__name__) + + +class KeyboardShortcutManager: + """ + Manages keyboard shortcut mappings for the instrument GUI. + + Holds a registry of named actions with default key sequences and descriptions. + The active mapping starts from defaults and can be customized by the user and + persisted to a JSON file. + + Qt does not poll for key presses — instead, register() hands each mapping entry + to Qt's event system via QShortcut, which fires the associated callback when the + key is pressed. register_tooltip() tracks widgets whose tooltips should display + the current key hint and be updated live when the user rebinds. + """ + + REGISTRY: dict[str, tuple[str, str]] = { + # action_id: (default_key_sequence, description) + "jump_filter": ("Ctrl+F", "Jump cursor to the filter search bar"), + "collapse_all": ("Ctrl+Shift+E", "Collapse all tree nodes"), + "expand_all": ("Ctrl+E", "Expand all tree nodes"), + "toggle_star": ("Ctrl+Shift+A", "Toggle star filter"), + "star_item": ("Ctrl+A", "Star/un-star the selected parameter"), + "toggle_trash": ("Ctrl+Shift+T", "Toggle trash filter"), + "trash_item": ("Ctrl+T", "Trash/un-trash the selected parameter"), + "refresh_all": ("Ctrl+Shift+R", "Refresh all parameters from instrument"), + "refresh_item": ("Ctrl+R", "Refresh the selected parameter"), + "toggle_python": ("Ctrl+P", "Toggle Python eval for selected parameter"), + "delete_item": ("Ctrl+Backspace", "Delete the selected parameter"), + "run_method": ("Ctrl+Return", "Runs the selected method"), + "clear_add": ("Ctrl+Shift+N", "Clear regions of add parameter bar"), + "add_item": ("Ctrl+N", "Jump cursor to the add parameter bar"), + "load_items": ("Ctrl+Shift+O", "Load parameters from JSON file"), + "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: + self.mapping: dict[str, str] = {k: v[0] for k, v in self.REGISTRY.items()} + self._shortcut_map: dict[str, list[QtWidgets.QShortcut]] = defaultdict(list) + self._tooltip_widgets: dict[ + str, list[tuple[Union[QtWidgets.QAction, QtWidgets.QWidget], str]] + ] = defaultdict(list) + + def load_from_dict(self, config: dict[str, str]) -> None: + """Override the current mapping with entries read from serverConfig file.""" + self.mapping.update(config) + + def save(self, path: str) -> None: + """Write the current mapping to the serverConfig file.""" + with open(path, "r") as f: + data = yaml.safe_load(f) or {} + + diffs = {k: v for k, v in self.mapping.items() if v != self.REGISTRY[k][0]} + if diffs: + data["shortcuts"] = diffs + elif "shortcuts" in data: + del data["shortcuts"] + with open(path, "w") as f: + yaml.dump(data, f, indent=2) + + def register_tooltip( + self, + action_id: str, + widget: Optional[Union[QtWidgets.QAction, QtWidgets.QWidget]], + ) -> None: + """Append the current key hint to widget's tooltip and track it for live rebinding.""" + if widget is None: + return + key = self.mapping.get(action_id, "") + if not key: + return + base_tip = widget.toolTip() + widget.setToolTip(f"{base_tip} [{key}]" if base_tip else f"[{key}]") + self._tooltip_widgets[action_id].append((widget, base_tip)) + widget.destroyed.connect( + lambda _, aid=action_id, ref=widget: self._remove_tooltip_widget(aid, ref) + ) + + def _remove_tooltip_widget( + self, action_id: str, widget: Union[QtWidgets.QAction, QtWidgets.QWidget] + ) -> None: + self._tooltip_widgets[action_id] = [ + (w, t) for w, t in self._tooltip_widgets[action_id] if w is not widget + ] + + def register( + self, action_id: str, callback: Callable, widget: QtWidgets.QWidget + ) -> None: + """ + Create a QShortcut for action_id on widget and connect it to callback. + + The shortcut fires when widget or any of its children has focus. + The QShortcut object is retained internally so it is not garbage-collected + and can be updated live via rebind(). + """ + key = self.mapping.get(action_id) + if key: + sc = QtWidgets.QShortcut(QtGui.QKeySequence(key), widget) + sc.setContext(QtCore.Qt.ShortcutContext.WidgetWithChildrenShortcut) + sc.activated.connect(callback) + self._shortcut_map[action_id].append(sc) + sc.destroyed.connect( + lambda _, aid=action_id, ref=sc: ( + self._shortcut_map[aid].remove(ref) + if ref in self._shortcut_map[aid] + else None + ) + ) + + def rebind(self, action_id: str, new_key: str) -> None: + """Update a shortcut immediately. Updates the mapping and the live Qt objects.""" + self.mapping[action_id] = new_key + for sc in self._shortcut_map.get(action_id, []): + sc.setKey(QtGui.QKeySequence(new_key)) + for widget, base_tip in self._tooltip_widgets.get(action_id, []): + widget.setToolTip( + f"{base_tip} [{new_key}]" if base_tip else f"[{new_key}]" + ) + logger.debug(f"Rebound '{action_id}' to '{new_key}'") + + +class ShortcutEditorWidget(QtWidgets.QWidget): + """ + Permanent widget for viewing and editing keyboard shortcuts. + + Intended to be embedded as a tab in the server window. Changes made in the + table are applied live to the manager (and therefore all registered shortcuts) + when 'Save to File' is clicked. Also use 'Save to file' to persist across sessions. + + Each row has a small colored indicator dot in the rightmost column: + - transparent : saved and unique + - orange: unsaved change (widget value differs from manager.mapping) + - yellow: applied to the manager but not yet saved to file + - red : duplicate key sequence shared with another action (takes priority) + + QKeySequenceEdit emits a spurious keySequenceChanged after its finishing timeout + resets the internal recording state. _onEditingFinished blocks that widget's signals + for one event-loop tick (swallowing the revert signal at the source), then restores + the display if the widget actually changed its stored sequence during the block. + """ + + def __init__( + self, + manager: KeyboardShortcutManager, + configPath: str, + parent: Optional[QtWidgets.QWidget] = None, + ) -> None: + super().__init__(parent) + self.manager = manager + self._file_mapping: dict[str, str] = dict(manager.mapping) + # Widgets currently mid _onEditingFinished / _restoreAfterRevert cycle. + # Maps widget → intended key so _applyToManager can read the correct value + # even when the widget's internal state has been temporarily cleared. + self._pending_restores: dict[QtWidgets.QKeySequenceEdit, str] = {} + + self._table = QtWidgets.QTableWidget(len(manager.REGISTRY), 4, self) + self._table.setHorizontalHeaderLabels(["Action", "Description", "Shortcut", ""]) + header = self._table.horizontalHeader() + assert header is not None + header.setSectionResizeMode( + 0, QtWidgets.QHeaderView.ResizeMode.ResizeToContents + ) + header.setSectionResizeMode( + 1, QtWidgets.QHeaderView.ResizeMode.ResizeToContents + ) + header.setSectionResizeMode(2, QtWidgets.QHeaderView.ResizeMode.Stretch) + header.setSectionResizeMode(3, QtWidgets.QHeaderView.ResizeMode.Fixed) + self._table.setColumnWidth(3, 32) + self._table.setSelectionBehavior( + QtWidgets.QAbstractItemView.SelectionBehavior.SelectRows + ) + + self._indicators: list[QtWidgets.QLabel] = [] + self._populateTable() + + btnReset = QtWidgets.QPushButton("Reset to defaults") + btnReset.clicked.connect(self._resetDefaults) + + self._btnCancel = QtWidgets.QPushButton("Cancel") + self._btnCancel.setEnabled(False) + self._btnCancel.clicked.connect(self._cancel) + + self._btnApply = QtWidgets.QPushButton("Apply") + self._btnApply.setEnabled(False) + self._btnApply.clicked.connect(self._apply) + + self._btnSaveFile = QtWidgets.QPushButton("Save to file") + self._btnSaveFile.clicked.connect(self._saveToFile) + if configPath: + self._btnSaveFile.setEnabled(True) + else: + self._btnSaveFile.setEnabled(False) + self._btnSaveFile.setToolTip( + "Start the server with a config file to enable this button" + ) + + btnRow = QtWidgets.QHBoxLayout() + btnRow.addStretch() + btnRow.addWidget(btnReset) + btnRow.addWidget(self._btnCancel) + btnRow.addWidget(self._btnApply) + btnRow.addWidget(self._btnSaveFile) + + layout = QtWidgets.QVBoxLayout() + layout.addWidget(self._table) + layout.addLayout(btnRow) + self.setLayout(layout) + + self.configPath = configPath + + def _populateTable(self) -> None: + self._indicators.clear() + self._table.clearContents() + for row, (action_id, (_, description)) in enumerate( + self.manager.REGISTRY.items() + ): + current = self.manager.mapping.get(action_id, "") + + id_item = QtWidgets.QTableWidgetItem(action_id) + id_item.setFlags(id_item.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable) # type: ignore[arg-type] + + desc_item = QtWidgets.QTableWidgetItem(description) + desc_item.setFlags(desc_item.flags() & ~QtCore.Qt.ItemFlag.ItemIsEditable) # type: ignore[arg-type] + + self._table.setItem(row, 0, id_item) + self._table.setItem(row, 1, desc_item) + + key_edit = QtWidgets.QKeySequenceEdit( + QtGui.QKeySequence(current), self._table + ) + key_edit.keySequenceChanged.connect(self._onUnsavedChange) + key_edit.editingFinished.connect( + lambda w=key_edit: self._onEditingFinished(w) + ) + self._table.setCellWidget(row, 2, key_edit) + + dot = QtWidgets.QLabel() + dot.setFixedSize(20, 20) + dot.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + dot.setStyleSheet( + "QToolTip { color: black; background-color: white;" + " border: 1px solid #cccccc; }" + ) + container = QtWidgets.QWidget() + cl = QtWidgets.QHBoxLayout(container) + cl.addWidget(dot) + cl.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter) + cl.setContentsMargins(0, 0, 0, 0) + self._table.setCellWidget(row, 3, container) + self._indicators.append(dot) + + self._updateAllIndicators() + + def _collectDuplicates(self) -> dict[str, list[str]]: + """Return {key_sequence: [action_ids]} for every key bound to more than one action.""" + seen: dict[str, list[str]] = defaultdict(list) + for row, action_id in enumerate(self.manager.REGISTRY): + widget = self._table.cellWidget(row, 2) + if isinstance(widget, QtWidgets.QKeySequenceEdit): + key = widget.keySequence().toString() + if key: + seen[key].append(action_id) + return {k: v for k, v in seen.items() if len(v) > 1} + + def _updateAllIndicators(self) -> None: + duplicates = self._collectDuplicates() + for row, action_id in enumerate(self.manager.REGISTRY): + if row >= len(self._indicators): + break + dot = self._indicators[row] + widget = self._table.cellWidget(row, 2) + if not isinstance(widget, QtWidgets.QKeySequenceEdit): + continue + current = self._pending_restores.get( + widget, widget.keySequence().toString() + ) + if current in duplicates: + others = [a for a in duplicates[current] if a != action_id] + self._applyIndicator( + dot, "duplicate", f"Duplicate: also bound to {', '.join(others)}" + ) + elif current != self.manager.mapping.get(action_id, ""): + self._applyIndicator(dot, "unsaved", "Unsaved and unapplied changes") + elif current != self._file_mapping.get(action_id, ""): + self._applyIndicator( + dot, "applied", "Changes applied but not saved to file" + ) + else: + self._applyIndicator(dot, "ok", "") + + @staticmethod + def _applyIndicator(dot: QtWidgets.QLabel, state: str, tooltip: str) -> None: + dot.setToolTip(tooltip) + + if state == "ok": + icon = QtGui.QIcon(":/icons/no-alert.svg") + elif state == "unsaved": + icon = QtGui.QIcon(":/icons/orange-alert.svg") + elif state == "applied": + icon = QtGui.QIcon(":/icons/yellow-alert.svg") + else: # duplicate + icon = QtGui.QIcon(":/icons/red-alert.svg") + pix = icon.pixmap(20, 20) + dot.setPixmap(pix) + + def _updateApplyCancelState(self) -> None: + """Enable Apply/Cancel/Save based on pending changes and conflicts.""" + has_pending = False + for row, action_id in enumerate(self.manager.REGISTRY): + widget = self._table.cellWidget(row, 2) + if isinstance(widget, QtWidgets.QKeySequenceEdit): + if widget.keySequence().toString() != self.manager.mapping.get( + action_id, "" + ): + has_pending = True + break + has_conflicts = bool(self._collectDuplicates()) + self._btnApply.setEnabled(has_pending and not has_conflicts) + self._btnCancel.setEnabled(has_pending) + self._btnSaveFile.setEnabled(bool(self.configPath) and not has_conflicts) + + @QtCore.Slot() + def _onUnsavedChange(self) -> None: + self._updateAllIndicators() + self._updateApplyCancelState() + + def _onEditingFinished(self, widget: QtWidgets.QKeySequenceEdit) -> None: + intended = widget.keySequence().toString() + self._pending_restores[widget] = intended + widget.blockSignals(True) + QtCore.QTimer.singleShot(0, lambda: self._restoreAfterRevert(intended, widget)) + + def _restoreAfterRevert( + self, intended: str, widget: QtWidgets.QKeySequenceEdit + ) -> None: + if widget.keySequence().toString() != intended: + widget.setKeySequence(QtGui.QKeySequence(intended)) + widget.blockSignals(False) + self._pending_restores.pop(widget, None) + # No keySequenceChanged fires after unblocking, so refresh state explicitly. + self._updateAllIndicators() + self._updateApplyCancelState() + + def _applyToManager(self) -> None: + """Apply all table values to the live manager.""" + for row, action_id in enumerate(self.manager.REGISTRY): + widget = self._table.cellWidget(row, 2) + if isinstance(widget, QtWidgets.QKeySequenceEdit): + key = self._pending_restores.get( + widget, widget.keySequence().toString() + ) + self.manager.rebind(action_id, key) + + @QtCore.Slot() + def _apply(self) -> None: + self._applyToManager() + self._updateAllIndicators() + self._updateApplyCancelState() + + @QtCore.Slot() + def _cancel(self) -> None: + for row, action_id in enumerate(self.manager.REGISTRY): + widget = self._table.cellWidget(row, 2) + if isinstance(widget, QtWidgets.QKeySequenceEdit): + widget.setKeySequence( + QtGui.QKeySequence(self.manager.mapping.get(action_id, "")) + ) + self._updateAllIndicators() + self._updateApplyCancelState() + + @QtCore.Slot() + def _saveToFile(self) -> None: + self._applyToManager() + if self.configPath: + try: + self.manager.save(self.configPath) + self._file_mapping = dict(self.manager.mapping) + logger.info(f"Saved shortcuts to {self.configPath}") + except Exception as e: + logger.warning(f"Failed to save shortcuts to {self.configPath}: {e}") + self._updateAllIndicators() + self._updateApplyCancelState() + + @QtCore.Slot() + def _resetDefaults(self) -> None: + for row, (action_id, (default_key, _)) in enumerate( + self.manager.REGISTRY.items() + ): + self.manager.rebind(action_id, default_key) + widget = self._table.cellWidget(row, 2) + if isinstance(widget, QtWidgets.QKeySequenceEdit): + widget.setKeySequence(QtGui.QKeySequence(default_key)) + self._updateAllIndicators() + self._updateApplyCancelState() diff --git a/src/instrumentserver/resource.py b/src/instrumentserver/resource.py index d4fd9e9..bdf9e78 100644 --- a/src/instrumentserver/resource.py +++ b/src/instrumentserver/resource.py @@ -2,7 +2,7 @@ # Resource object code # -# Created by: The Resource Compiler for PyQt5 (Qt v5.15.2) +# Created by: The Resource Compiler for PyQt5 (Qt v5.15.14) # # WARNING! All changes made in this file will be lost! @@ -193,6 +193,46 @@ \x34\x2e\x39\x32\x39\x33\x32\x6c\x30\x2c\x30\x7a\x22\x20\x69\x64\ \x3d\x22\x73\x76\x67\x5f\x34\x22\x2f\x3e\x0a\x20\x3c\x2f\x67\x3e\ \x0a\x0a\x3c\x2f\x73\x76\x67\x3e\ +\x00\x00\x02\x54\ +\x3c\ +\x3f\x78\x6d\x6c\x20\x76\x65\x72\x73\x69\x6f\x6e\x3d\x22\x31\x2e\ +\x30\x22\x20\x65\x6e\x63\x6f\x64\x69\x6e\x67\x3d\x22\x55\x54\x46\ +\x2d\x38\x22\x20\x73\x74\x61\x6e\x64\x61\x6c\x6f\x6e\x65\x3d\x22\ +\x6e\x6f\x22\x3f\x3e\x0a\x3c\x73\x76\x67\x0a\x20\x20\x20\x78\x6d\ +\x6c\x6e\x73\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\x2e\ +\x77\x33\x2e\x6f\x72\x67\x2f\x32\x30\x30\x30\x2f\x73\x76\x67\x22\ +\x0a\x20\x20\x20\x69\x64\x3d\x22\x73\x76\x67\x38\x22\x0a\x20\x20\ +\x20\x76\x65\x72\x73\x69\x6f\x6e\x3d\x22\x31\x2e\x31\x22\x0a\x20\ +\x20\x20\x63\x6c\x61\x73\x73\x3d\x22\x66\x65\x61\x74\x68\x65\x72\ +\x20\x66\x65\x61\x74\x68\x65\x72\x2d\x61\x6c\x65\x72\x74\x2d\x6f\ +\x63\x74\x61\x67\x6f\x6e\x22\x0a\x20\x20\x20\x73\x74\x72\x6f\x6b\ +\x65\x2d\x6c\x69\x6e\x65\x6a\x6f\x69\x6e\x3d\x22\x72\x6f\x75\x6e\ +\x64\x22\x0a\x20\x20\x20\x73\x74\x72\x6f\x6b\x65\x2d\x6c\x69\x6e\ +\x65\x63\x61\x70\x3d\x22\x72\x6f\x75\x6e\x64\x22\x0a\x20\x20\x20\ +\x73\x74\x72\x6f\x6b\x65\x2d\x77\x69\x64\x74\x68\x3d\x22\x32\x22\ +\x0a\x20\x20\x20\x73\x74\x72\x6f\x6b\x65\x3d\x22\x63\x75\x72\x72\ +\x65\x6e\x74\x43\x6f\x6c\x6f\x72\x22\x0a\x20\x20\x20\x66\x69\x6c\ +\x6c\x3d\x22\x6e\x6f\x6e\x65\x22\x0a\x20\x20\x20\x76\x69\x65\x77\ +\x42\x6f\x78\x3d\x22\x30\x20\x30\x20\x32\x34\x20\x32\x34\x22\x0a\ +\x20\x20\x20\x68\x65\x69\x67\x68\x74\x3d\x22\x32\x34\x22\x0a\x20\ +\x20\x20\x77\x69\x64\x74\x68\x3d\x22\x32\x34\x22\x3e\x0a\x20\x20\ +\x3c\x70\x6f\x6c\x79\x67\x6f\x6e\x0a\x20\x20\x20\x20\x20\x73\x74\ +\x79\x6c\x65\x3d\x22\x66\x69\x6c\x6c\x3a\x23\x65\x38\x37\x37\x32\ +\x32\x22\x0a\x20\x20\x20\x20\x20\x69\x64\x3d\x22\x70\x6f\x6c\x79\ +\x67\x6f\x6e\x32\x22\x0a\x20\x20\x20\x20\x20\x70\x6f\x69\x6e\x74\ +\x73\x3d\x22\x37\x2e\x38\x36\x20\x32\x20\x31\x36\x2e\x31\x34\x20\ +\x32\x20\x32\x32\x20\x37\x2e\x38\x36\x20\x32\x32\x20\x31\x36\x2e\ +\x31\x34\x20\x31\x36\x2e\x31\x34\x20\x32\x32\x20\x37\x2e\x38\x36\ +\x20\x32\x32\x20\x32\x20\x31\x36\x2e\x31\x34\x20\x32\x20\x37\x2e\ +\x38\x36\x20\x37\x2e\x38\x36\x20\x32\x22\x20\x2f\x3e\x0a\x20\x20\ +\x3c\x6c\x69\x6e\x65\x20\x69\x64\x3d\x22\x6c\x69\x6e\x65\x34\x22\ +\x20\x79\x32\x3d\x22\x31\x32\x22\x20\x78\x32\x3d\x22\x31\x32\x22\ +\x20\x79\x31\x3d\x22\x38\x22\x20\x78\x31\x3d\x22\x31\x32\x22\x20\ +\x2f\x3e\x0a\x20\x20\x3c\x6c\x69\x6e\x65\x20\x69\x64\x3d\x22\x6c\ +\x69\x6e\x65\x36\x22\x20\x79\x32\x3d\x22\x31\x36\x22\x20\x78\x32\ +\x3d\x22\x31\x32\x2e\x30\x31\x22\x20\x79\x31\x3d\x22\x31\x36\x22\ +\x20\x78\x31\x3d\x22\x31\x32\x22\x20\x2f\x3e\x0a\x3c\x2f\x73\x76\ +\x67\x3e\x0a\ \x00\x00\x01\x6c\ \x3c\ \x73\x76\x67\x20\x78\x6d\x6c\x6e\x73\x3d\x22\x68\x74\x74\x70\x3a\ @@ -413,6 +453,96 @@ \x6e\x65\x20\x78\x31\x3d\x22\x31\x35\x22\x20\x79\x31\x3d\x22\x31\ \x32\x22\x20\x78\x32\x3d\x22\x33\x22\x20\x79\x32\x3d\x22\x31\x32\ \x22\x3e\x3c\x2f\x6c\x69\x6e\x65\x3e\x3c\x2f\x73\x76\x67\x3e\ +\x00\x00\x05\x75\ +\x3c\ +\x3f\x78\x6d\x6c\x20\x76\x65\x72\x73\x69\x6f\x6e\x3d\x22\x31\x2e\ +\x30\x22\x20\x65\x6e\x63\x6f\x64\x69\x6e\x67\x3d\x22\x55\x54\x46\ +\x2d\x38\x22\x20\x73\x74\x61\x6e\x64\x61\x6c\x6f\x6e\x65\x3d\x22\ +\x6e\x6f\x22\x3f\x3e\x0a\x3c\x73\x76\x67\x0a\x20\x20\x20\x78\x6d\ +\x6c\x6e\x73\x3a\x64\x63\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x70\ +\x75\x72\x6c\x2e\x6f\x72\x67\x2f\x64\x63\x2f\x65\x6c\x65\x6d\x65\ +\x6e\x74\x73\x2f\x31\x2e\x31\x2f\x22\x0a\x20\x20\x20\x78\x6d\x6c\ +\x6e\x73\x3a\x63\x63\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x63\x72\ +\x65\x61\x74\x69\x76\x65\x63\x6f\x6d\x6d\x6f\x6e\x73\x2e\x6f\x72\ +\x67\x2f\x6e\x73\x23\x22\x0a\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\ +\x72\x64\x66\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\x2e\ +\x77\x33\x2e\x6f\x72\x67\x2f\x31\x39\x39\x39\x2f\x30\x32\x2f\x32\ +\x32\x2d\x72\x64\x66\x2d\x73\x79\x6e\x74\x61\x78\x2d\x6e\x73\x23\ +\x22\x0a\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x73\x76\x67\x3d\x22\ +\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\x2e\x77\x33\x2e\x6f\x72\ +\x67\x2f\x32\x30\x30\x30\x2f\x73\x76\x67\x22\x0a\x20\x20\x20\x78\ +\x6d\x6c\x6e\x73\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x77\x77\x77\ +\x2e\x77\x33\x2e\x6f\x72\x67\x2f\x32\x30\x30\x30\x2f\x73\x76\x67\ +\x22\x0a\x20\x20\x20\x78\x6d\x6c\x6e\x73\x3a\x73\x6f\x64\x69\x70\ +\x6f\x64\x69\x3d\x22\x68\x74\x74\x70\x3a\x2f\x2f\x73\x6f\x64\x69\ +\x70\x6f\x64\x69\x2e\x73\x6f\x75\x72\x63\x65\x66\x6f\x72\x67\x65\ +\x2e\x6e\x65\x74\x2f\x44\x54\x44\x2f\x73\x6f\x64\x69\x70\x6f\x64\ +\x69\x2d\x30\x2e\x30\x2e\x64\x74\x64\x22\x0a\x20\x20\x20\x78\x6d\ +\x6c\x6e\x73\x3a\x69\x6e\x6b\x73\x63\x61\x70\x65\x3d\x22\x68\x74\ +\x74\x70\x3a\x2f\x2f\x77\x77\x77\x2e\x69\x6e\x6b\x73\x63\x61\x70\ +\x65\x2e\x6f\x72\x67\x2f\x6e\x61\x6d\x65\x73\x70\x61\x63\x65\x73\ +\x2f\x69\x6e\x6b\x73\x63\x61\x70\x65\x22\x0a\x20\x20\x20\x69\x6e\ +\x6b\x73\x63\x61\x70\x65\x3a\x76\x65\x72\x73\x69\x6f\x6e\x3d\x22\ +\x31\x2e\x30\x72\x63\x31\x20\x28\x30\x39\x39\x36\x30\x64\x36\x66\ +\x30\x35\x2c\x20\x32\x30\x32\x30\x2d\x30\x34\x2d\x30\x39\x29\x22\ +\x0a\x20\x20\x20\x73\x6f\x64\x69\x70\x6f\x64\x69\x3a\x64\x6f\x63\ +\x6e\x61\x6d\x65\x3d\x22\x61\x6c\x65\x72\x74\x2d\x6f\x63\x74\x61\ +\x67\x6f\x6e\x2d\x79\x65\x6c\x6c\x6f\x77\x2e\x73\x76\x67\x22\x0a\ +\x20\x20\x20\x69\x64\x3d\x22\x73\x76\x67\x38\x22\x0a\x20\x20\x20\ +\x76\x65\x72\x73\x69\x6f\x6e\x3d\x22\x31\x2e\x31\x22\x0a\x20\x20\ +\x20\x63\x6c\x61\x73\x73\x3d\x22\x66\x65\x61\x74\x68\x65\x72\x20\ +\x66\x65\x61\x74\x68\x65\x72\x2d\x61\x6c\x65\x72\x74\x2d\x6f\x63\ +\x74\x61\x67\x6f\x6e\x22\x0a\x20\x20\x20\x73\x74\x72\x6f\x6b\x65\ +\x2d\x6c\x69\x6e\x65\x6a\x6f\x69\x6e\x3d\x22\x72\x6f\x75\x6e\x64\ +\x22\x0a\x20\x20\x20\x73\x74\x72\x6f\x6b\x65\x2d\x6c\x69\x6e\x65\ +\x63\x61\x70\x3d\x22\x72\x6f\x75\x6e\x64\x22\x0a\x20\x20\x20\x73\ +\x74\x72\x6f\x6b\x65\x2d\x77\x69\x64\x74\x68\x3d\x22\x32\x22\x0a\ +\x20\x20\x20\x73\x74\x72\x6f\x6b\x65\x3d\x22\x63\x75\x72\x72\x65\ +\x6e\x74\x43\x6f\x6c\x6f\x72\x22\x0a\x20\x20\x20\x66\x69\x6c\x6c\ +\x3d\x22\x6e\x6f\x6e\x65\x22\x0a\x20\x20\x20\x76\x69\x65\x77\x42\ +\x6f\x78\x3d\x22\x30\x20\x30\x20\x32\x34\x20\x32\x34\x22\x0a\x20\ +\x20\x20\x68\x65\x69\x67\x68\x74\x3d\x22\x32\x34\x22\x0a\x20\x20\ +\x20\x77\x69\x64\x74\x68\x3d\x22\x32\x34\x22\x3e\x0a\x20\x20\x3c\ +\x6d\x65\x74\x61\x64\x61\x74\x61\x0a\x20\x20\x20\x20\x20\x69\x64\ +\x3d\x22\x6d\x65\x74\x61\x64\x61\x74\x61\x31\x34\x22\x3e\x0a\x20\ +\x20\x20\x20\x3c\x72\x64\x66\x3a\x52\x44\x46\x3e\x0a\x20\x20\x20\ +\x20\x20\x20\x3c\x63\x63\x3a\x57\x6f\x72\x6b\x0a\x20\x20\x20\x20\ +\x20\x20\x20\x20\x20\x72\x64\x66\x3a\x61\x62\x6f\x75\x74\x3d\x22\ +\x22\x3e\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x3c\x64\x63\x3a\x66\ +\x6f\x72\x6d\x61\x74\x3e\x69\x6d\x61\x67\x65\x2f\x73\x76\x67\x2b\ +\x78\x6d\x6c\x3c\x2f\x64\x63\x3a\x66\x6f\x72\x6d\x61\x74\x3e\x0a\ +\x20\x20\x20\x20\x20\x20\x20\x20\x3c\x64\x63\x3a\x74\x79\x70\x65\ +\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x20\x72\x64\x66\x3a\ +\x72\x65\x73\x6f\x75\x72\x63\x65\x3d\x22\x68\x74\x74\x70\x3a\x2f\ +\x2f\x70\x75\x72\x6c\x2e\x6f\x72\x67\x2f\x64\x63\x2f\x64\x63\x6d\ +\x69\x74\x79\x70\x65\x2f\x53\x74\x69\x6c\x6c\x49\x6d\x61\x67\x65\ +\x22\x20\x2f\x3e\x0a\x20\x20\x20\x20\x20\x20\x20\x20\x3c\x64\x63\ +\x3a\x74\x69\x74\x6c\x65\x3e\x3c\x2f\x64\x63\x3a\x74\x69\x74\x6c\ +\x65\x3e\x0a\x20\x20\x20\x20\x20\x20\x3c\x2f\x63\x63\x3a\x57\x6f\ +\x72\x6b\x3e\x0a\x20\x20\x20\x20\x3c\x2f\x72\x64\x66\x3a\x52\x44\ +\x46\x3e\x0a\x20\x20\x3c\x2f\x6d\x65\x74\x61\x64\x61\x74\x61\x3e\ +\x0a\x20\x20\x3c\x64\x65\x66\x73\x0a\x20\x20\x20\x20\x20\x69\x64\ +\x3d\x22\x64\x65\x66\x73\x31\x32\x22\x20\x2f\x3e\x0a\x20\x20\x3c\ +\x70\x6f\x6c\x79\x67\x6f\x6e\x0a\x20\x20\x20\x20\x20\x73\x74\x79\ +\x6c\x65\x3d\x22\x66\x69\x6c\x6c\x3a\x23\x46\x46\x44\x37\x30\x30\ +\x22\x0a\x20\x20\x20\x20\x20\x69\x64\x3d\x22\x70\x6f\x6c\x79\x67\ +\x6f\x6e\x32\x22\x0a\x20\x20\x20\x20\x20\x70\x6f\x69\x6e\x74\x73\ +\x3d\x22\x37\x2e\x38\x36\x20\x32\x20\x31\x36\x2e\x31\x34\x20\x32\ +\x20\x32\x32\x20\x37\x2e\x38\x36\x20\x32\x32\x20\x31\x36\x2e\x31\ +\x34\x20\x31\x36\x2e\x31\x34\x20\x32\x32\x20\x37\x2e\x38\x36\x20\ +\x32\x32\x20\x32\x20\x31\x36\x2e\x31\x34\x20\x32\x20\x37\x2e\x38\ +\x36\x20\x37\x2e\x38\x36\x20\x32\x22\x20\x2f\x3e\x0a\x20\x20\x3c\ +\x6c\x69\x6e\x65\x0a\x20\x20\x20\x20\x20\x69\x64\x3d\x22\x6c\x69\ +\x6e\x65\x34\x22\x0a\x20\x20\x20\x20\x20\x79\x32\x3d\x22\x31\x32\ +\x22\x0a\x20\x20\x20\x20\x20\x78\x32\x3d\x22\x31\x32\x22\x0a\x20\ +\x20\x20\x20\x20\x79\x31\x3d\x22\x38\x22\x0a\x20\x20\x20\x20\x20\ +\x78\x31\x3d\x22\x31\x32\x22\x20\x2f\x3e\x0a\x20\x20\x3c\x6c\x69\ +\x6e\x65\x0a\x20\x20\x20\x20\x20\x69\x64\x3d\x22\x6c\x69\x6e\x65\ +\x36\x22\x0a\x20\x20\x20\x20\x20\x79\x32\x3d\x22\x31\x36\x22\x0a\ +\x20\x20\x20\x20\x20\x78\x32\x3d\x22\x31\x32\x2e\x30\x31\x22\x0a\ +\x20\x20\x20\x20\x20\x79\x31\x3d\x22\x31\x36\x22\x0a\x20\x20\x20\ +\x20\x20\x78\x31\x3d\x22\x31\x32\x22\x20\x2f\x3e\x0a\x3c\x2f\x73\ +\x76\x67\x3e\x0a\ \x00\x00\x05\xba\ \x3c\ \x73\x76\x67\x20\x77\x69\x64\x74\x68\x3d\x22\x38\x30\x22\x20\x68\ @@ -1324,6 +1454,10 @@ \x03\xdc\xdd\x87\ \x00\x73\ \x00\x74\x00\x61\x00\x72\x00\x2d\x00\x63\x00\x72\x00\x6f\x00\x73\x00\x73\x00\x65\x00\x64\x00\x2e\x00\x73\x00\x76\x00\x67\ +\x00\x10\ +\x05\x22\x88\x87\ +\x00\x6f\ +\x00\x72\x00\x61\x00\x6e\x00\x67\x00\x65\x00\x2d\x00\x61\x00\x6c\x00\x65\x00\x72\x00\x74\x00\x2e\x00\x73\x00\x76\x00\x67\ \x00\x08\ \x05\x77\x54\xa7\ \x00\x6c\ @@ -1348,6 +1482,10 @@ \x09\xc7\x5a\x27\ \x00\x73\ \x00\x65\x00\x74\x00\x2e\x00\x73\x00\x76\x00\x67\ +\x00\x10\ +\x0a\x44\xfa\x47\ +\x00\x79\ +\x00\x65\x00\x6c\x00\x6c\x00\x6f\x00\x77\x00\x2d\x00\x61\x00\x6c\x00\x65\x00\x72\x00\x74\x00\x2e\x00\x73\x00\x76\x00\x67\ \x00\x08\ \x0a\x85\x55\x87\ \x00\x73\ @@ -1390,70 +1528,76 @@ qt_resource_struct_v1 = b"\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x02\x00\x00\x00\x01\ \x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ -\x00\x00\x00\x18\x00\x02\x00\x00\x00\x12\x00\x00\x00\x03\ +\x00\x00\x00\x18\x00\x02\x00\x00\x00\x14\x00\x00\x00\x03\ \x00\x00\x00\x28\x00\x00\x00\x00\x00\x01\x00\x00\x00\x04\ \x00\x00\x00\x4c\x00\x00\x00\x00\x00\x01\x00\x00\x01\x7d\ \x00\x00\x00\x6a\x00\x00\x00\x00\x00\x01\x00\x00\x03\x21\ \x00\x00\x00\x90\x00\x00\x00\x00\x00\x01\x00\x00\x0a\xfe\ -\x00\x00\x00\xa6\x00\x00\x00\x00\x00\x01\x00\x00\x0c\x6e\ -\x00\x00\x00\xbc\x00\x01\x00\x00\x00\x01\x00\x00\x0d\xa5\ -\x00\x00\x00\xd6\x00\x00\x00\x00\x00\x01\x00\x00\x11\x51\ -\x00\x00\x00\xee\x00\x00\x00\x00\x00\x01\x00\x00\x14\xf0\ -\x00\x00\x01\x04\x00\x00\x00\x00\x00\x01\x00\x00\x16\x7c\ -\x00\x00\x01\x18\x00\x00\x00\x00\x00\x01\x00\x00\x17\xf0\ -\x00\x00\x01\x2e\x00\x00\x00\x00\x00\x01\x00\x00\x1d\xae\ -\x00\x00\x01\x48\x00\x00\x00\x00\x00\x01\x00\x00\x22\x74\ -\x00\x00\x01\x66\x00\x00\x00\x00\x00\x01\x00\x00\x2c\x07\ -\x00\x00\x01\x8a\x00\x00\x00\x00\x00\x01\x00\x00\x33\xa8\ -\x00\x00\x01\xa6\x00\x00\x00\x00\x00\x01\x00\x00\x35\x3c\ -\x00\x00\x01\xc0\x00\x00\x00\x00\x00\x01\x00\x00\x36\xb6\ -\x00\x00\x01\xe0\x00\x00\x00\x00\x00\x01\x00\x00\x3e\x57\ -\x00\x00\x02\x08\x00\x00\x00\x00\x00\x01\x00\x00\x44\xa7\ +\x00\x00\x00\xb6\x00\x00\x00\x00\x00\x01\x00\x00\x0d\x56\ +\x00\x00\x00\xcc\x00\x00\x00\x00\x00\x01\x00\x00\x0e\xc6\ +\x00\x00\x00\xe2\x00\x01\x00\x00\x00\x01\x00\x00\x0f\xfd\ +\x00\x00\x00\xfc\x00\x00\x00\x00\x00\x01\x00\x00\x13\xa9\ +\x00\x00\x01\x14\x00\x00\x00\x00\x00\x01\x00\x00\x17\x48\ +\x00\x00\x01\x2a\x00\x00\x00\x00\x00\x01\x00\x00\x18\xd4\ +\x00\x00\x01\x3e\x00\x00\x00\x00\x00\x01\x00\x00\x1a\x48\ +\x00\x00\x01\x64\x00\x00\x00\x00\x00\x01\x00\x00\x1f\xc1\ +\x00\x00\x01\x7a\x00\x00\x00\x00\x00\x01\x00\x00\x25\x7f\ +\x00\x00\x01\x94\x00\x00\x00\x00\x00\x01\x00\x00\x2a\x45\ +\x00\x00\x01\xb2\x00\x00\x00\x00\x00\x01\x00\x00\x33\xd8\ +\x00\x00\x01\xd6\x00\x00\x00\x00\x00\x01\x00\x00\x3b\x79\ +\x00\x00\x01\xf2\x00\x00\x00\x00\x00\x01\x00\x00\x3d\x0d\ +\x00\x00\x02\x0c\x00\x00\x00\x00\x00\x01\x00\x00\x3e\x87\ +\x00\x00\x02\x2c\x00\x00\x00\x00\x00\x01\x00\x00\x46\x28\ +\x00\x00\x02\x54\x00\x00\x00\x00\x00\x01\x00\x00\x4c\x78\ " qt_resource_struct_v2 = b"\ \x00\x00\x00\x00\x00\x02\x00\x00\x00\x02\x00\x00\x00\x01\ \x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x00\x00\x00\x00\x00\x00\x00\x01\x00\x00\x00\x00\ -\x00\x00\x01\x95\xe8\xaa\xa9\xd5\ -\x00\x00\x00\x18\x00\x02\x00\x00\x00\x12\x00\x00\x00\x03\ +\x00\x00\x01\x9d\xe0\x78\xcb\x77\ +\x00\x00\x00\x18\x00\x02\x00\x00\x00\x14\x00\x00\x00\x03\ \x00\x00\x00\x00\x00\x00\x00\x00\ \x00\x00\x00\x28\x00\x00\x00\x00\x00\x01\x00\x00\x00\x04\ -\x00\x00\x01\x95\xe8\xaa\xa9\xd4\ +\x00\x00\x01\x9d\xe0\x78\xcb\x76\ \x00\x00\x00\x4c\x00\x00\x00\x00\x00\x01\x00\x00\x01\x7d\ -\x00\x00\x01\x95\xe8\xaa\xa9\xd3\ +\x00\x00\x01\x9d\xe0\x78\xcb\x75\ \x00\x00\x00\x6a\x00\x00\x00\x00\x00\x01\x00\x00\x03\x21\ -\x00\x00\x01\x95\xe8\xaa\xa9\xd4\ +\x00\x00\x01\x9d\xe0\x78\xcb\x77\ \x00\x00\x00\x90\x00\x00\x00\x00\x00\x01\x00\x00\x0a\xfe\ -\x00\x00\x01\x95\xe8\xaa\xa9\xd4\ -\x00\x00\x00\xa6\x00\x00\x00\x00\x00\x01\x00\x00\x0c\x6e\ -\x00\x00\x01\x95\xe8\xaa\xa9\xd3\ -\x00\x00\x00\xbc\x00\x01\x00\x00\x00\x01\x00\x00\x0d\xa5\ -\x00\x00\x01\x95\xe8\xaa\xa9\xd3\ -\x00\x00\x00\xd6\x00\x00\x00\x00\x00\x01\x00\x00\x11\x51\ -\x00\x00\x01\x95\xe8\xaa\xa9\xd5\ -\x00\x00\x00\xee\x00\x00\x00\x00\x00\x01\x00\x00\x14\xf0\ -\x00\x00\x01\x95\xe8\xaa\xa9\xd4\ -\x00\x00\x01\x04\x00\x00\x00\x00\x00\x01\x00\x00\x16\x7c\ -\x00\x00\x01\x95\xe8\xaa\xa9\xd4\ -\x00\x00\x01\x18\x00\x00\x00\x00\x00\x01\x00\x00\x17\xf0\ -\x00\x00\x01\x95\xe8\xaa\xa9\xd5\ -\x00\x00\x01\x2e\x00\x00\x00\x00\x00\x01\x00\x00\x1d\xae\ -\x00\x00\x01\x9a\x4b\xc3\x1d\x94\ -\x00\x00\x01\x48\x00\x00\x00\x00\x00\x01\x00\x00\x22\x74\ -\x00\x00\x01\x95\xe8\xaa\xa9\xd3\ -\x00\x00\x01\x66\x00\x00\x00\x00\x00\x01\x00\x00\x2c\x07\ -\x00\x00\x01\x95\xe8\xaa\xa9\xd2\ -\x00\x00\x01\x8a\x00\x00\x00\x00\x00\x01\x00\x00\x33\xa8\ -\x00\x00\x01\x95\xe8\xaa\xa9\xd4\ -\x00\x00\x01\xa6\x00\x00\x00\x00\x00\x01\x00\x00\x35\x3c\ -\x00\x00\x01\x95\xe8\xaa\xa9\xd3\ -\x00\x00\x01\xc0\x00\x00\x00\x00\x00\x01\x00\x00\x36\xb6\ -\x00\x00\x01\x95\xe8\xaa\xa9\xd3\ -\x00\x00\x01\xe0\x00\x00\x00\x00\x00\x01\x00\x00\x3e\x57\ -\x00\x00\x01\x95\xe8\xaa\xa9\xd5\ -\x00\x00\x02\x08\x00\x00\x00\x00\x00\x01\x00\x00\x44\xa7\ -\x00\x00\x01\x95\xe8\xaa\xa9\xd4\ +\x00\x00\x01\x9e\x65\xc8\xf1\x97\ +\x00\x00\x00\xb6\x00\x00\x00\x00\x00\x01\x00\x00\x0d\x56\ +\x00\x00\x01\x9d\xe0\x78\xcb\x76\ +\x00\x00\x00\xcc\x00\x00\x00\x00\x00\x01\x00\x00\x0e\xc6\ +\x00\x00\x01\x9d\xe0\x78\xcb\x75\ +\x00\x00\x00\xe2\x00\x01\x00\x00\x00\x01\x00\x00\x0f\xfd\ +\x00\x00\x01\x9d\xe0\x78\xcb\x76\ +\x00\x00\x00\xfc\x00\x00\x00\x00\x00\x01\x00\x00\x13\xa9\ +\x00\x00\x01\x9d\xe0\x78\xcb\x77\ +\x00\x00\x01\x14\x00\x00\x00\x00\x00\x01\x00\x00\x17\x48\ +\x00\x00\x01\x9d\xe0\x78\xcb\x76\ +\x00\x00\x01\x2a\x00\x00\x00\x00\x00\x01\x00\x00\x18\xd4\ +\x00\x00\x01\x9d\xe0\x78\xcb\x77\ +\x00\x00\x01\x3e\x00\x00\x00\x00\x00\x01\x00\x00\x1a\x48\ +\x00\x00\x01\x9e\x8e\x14\x03\xf8\ +\x00\x00\x01\x64\x00\x00\x00\x00\x00\x01\x00\x00\x1f\xc1\ +\x00\x00\x01\x9d\xe0\x78\xcb\x77\ +\x00\x00\x01\x7a\x00\x00\x00\x00\x00\x01\x00\x00\x25\x7f\ +\x00\x00\x01\x9d\xe0\x78\xcb\x76\ +\x00\x00\x01\x94\x00\x00\x00\x00\x00\x01\x00\x00\x2a\x45\ +\x00\x00\x01\x9d\xe0\x78\xcb\x75\ +\x00\x00\x01\xb2\x00\x00\x00\x00\x00\x01\x00\x00\x33\xd8\ +\x00\x00\x01\x9d\xe0\x78\xcb\x75\ +\x00\x00\x01\xd6\x00\x00\x00\x00\x00\x01\x00\x00\x3b\x79\ +\x00\x00\x01\x9d\xe0\x78\xcb\x76\ +\x00\x00\x01\xf2\x00\x00\x00\x00\x00\x01\x00\x00\x3d\x0d\ +\x00\x00\x01\x9d\xe0\x78\xcb\x75\ +\x00\x00\x02\x0c\x00\x00\x00\x00\x00\x01\x00\x00\x3e\x87\ +\x00\x00\x01\x9d\xe0\x78\xcb\x75\ +\x00\x00\x02\x2c\x00\x00\x00\x00\x00\x01\x00\x00\x46\x28\ +\x00\x00\x01\x9d\xe0\x78\xcb\x77\ +\x00\x00\x02\x54\x00\x00\x00\x00\x00\x01\x00\x00\x4c\x78\ +\x00\x00\x01\x9d\xe0\x78\xcb\x76\ " qt_version = [int(v) for v in QtCore.qVersion().split(".")] diff --git a/src/instrumentserver/resource.qrc b/src/instrumentserver/resource.qrc index 995f218..35d5186 100644 --- a/src/instrumentserver/resource.qrc +++ b/src/instrumentserver/resource.qrc @@ -7,6 +7,7 @@ resource/icons/set.svg resource/icons/alert-octagon.svg resource/icons/alert-octagon-red.svg + resource/icons/alert-octagon-orange.svg resource/icons/python.svg resource/icons/code.svg resource/icons/delete.svg @@ -14,6 +15,7 @@ resource/icons/collapse.svg resource/icons/expand.svg resource/icons/alert-octagon-green.svg + resource/icons/alert-octagon-yellow.svg resource/icons/star.svg resource/icons/star-crossed.svg resource/icons/trash.svg diff --git a/src/instrumentserver/resource/icons/alert-octagon-orange.svg b/src/instrumentserver/resource/icons/alert-octagon-orange.svg new file mode 100644 index 0000000..061888c --- /dev/null +++ b/src/instrumentserver/resource/icons/alert-octagon-orange.svg @@ -0,0 +1,21 @@ + + + + + + diff --git a/src/instrumentserver/resource/icons/alert-octagon-yellow.svg b/src/instrumentserver/resource/icons/alert-octagon-yellow.svg new file mode 100644 index 0000000..15c90c3 --- /dev/null +++ b/src/instrumentserver/resource/icons/alert-octagon-yellow.svg @@ -0,0 +1,53 @@ + + + + + + image/svg+xml + + + + + + + + + + diff --git a/src/instrumentserver/server/application.py b/src/instrumentserver/server/application.py index 83fcc0a..608082b 100644 --- a/src/instrumentserver/server/application.py +++ b/src/instrumentserver/server/application.py @@ -14,6 +14,7 @@ from ..gui.instruments import GenericInstrument from ..gui.misc import BaseDialog, DetachableTabWidget from ..gui.parameters import AnyInputForMethod +from ..gui.shortcuts import KeyboardShortcutManager, ShortcutEditorWidget from .core import InstrumentModuleBluePrint, ParameterBluePrint, StationServer logger = logging.getLogger(__name__) @@ -608,8 +609,11 @@ def __init__( else: self._guiConfig = guiConfig - self.stationServer = None - self.stationServerThread = None + shortcutConfig = serverKwargs.pop("shortcutConfig", {}) + configPath = serverKwargs.pop("configPath", None) + + self.stationServer: Optional[StationServer] = None + self.stationServerThread: Optional[QtCore.QThread] = None self.instrumentTabsOpen: dict[str, GenericInstrument] = {} @@ -659,6 +663,13 @@ def __init__( self.serverStatus = ServerStatus() self.tabs.addUnclosableTab(self.serverStatus, "Server") + self.shortcutManager = KeyboardShortcutManager() + if shortcutConfig: + self.shortcutManager.load_from_dict(shortcutConfig) + + self.shortcutEditor = ShortcutEditorWidget(self.shortcutManager, configPath) + self.tabs.addUnclosableTab(self.shortcutEditor, "Shortcuts") + # Toolbar. self.toolBar = self.addToolBar("Tools") self.toolBar.setIconSize(QtCore.QSize(16, 16)) # type: ignore[union-attr] @@ -715,6 +726,7 @@ def closeEvent(self, event: Optional[QtGui.QCloseEvent]) -> None: if ( hasattr(self, "stationServerThread") and self.stationServerThread is not None + and self.stationServer is not None ): if self.stationServerThread.isRunning(): try: @@ -730,29 +742,33 @@ def closeEvent(self, event: Optional[QtGui.QCloseEvent]) -> None: def startServer(self) -> None: """Start the instrument server in a separate thread.""" - self.stationServer = StationServer(**self._serverKwargs) # type: ignore[assignment] - self.stationServerThread = QtCore.QThread() # type: ignore[assignment] - self.stationServer.moveToThread(self.stationServerThread) # type: ignore[attr-defined] - self.stationServerThread.started.connect(self.stationServer.startServer) # type: ignore[arg-type,attr-defined] - self.stationServer.finished.connect(lambda: self.log("ZMQ server closed.")) # type: ignore[attr-defined] - self.stationServer.finished.connect(self.stationServerThread.quit) # type: ignore[attr-defined] - self.stationServer.finished.connect(self.stationServer.deleteLater) # type: ignore[attr-defined] + self.stationServer = StationServer(**self._serverKwargs) + self.stationServerThread = QtCore.QThread() + self.stationServer.moveToThread(self.stationServerThread) + self.stationServerThread.started.connect(self.stationServer.startServer) # type: ignore[arg-type] + self.stationServer.finished.connect(lambda: self.log("ZMQ server closed.")) + self.stationServer.finished.connect(self.stationServerThread.quit) + self.stationServer.finished.connect(self.stationServer.deleteLater) # Connecting some additional things for messages. - self.stationServer.serverStarted.connect(self.serverStatus.setListeningAddress) # type: ignore[attr-defined] - self.stationServer.serverStarted.connect(self.client.start) # type: ignore[attr-defined] - self.stationServer.serverStarted.connect(self.refreshStationComponents) # type: ignore[attr-defined] - self.stationServer.finished.connect( # type: ignore[attr-defined] + self.stationServer.serverStarted.connect(self.serverStatus.setListeningAddress) + self.stationServer.serverStarted.connect(self.client.start) + self.stationServer.serverStarted.connect(self.refreshStationComponents) + self.stationServer.finished.connect( lambda: self.log("Server thread finished.", LogLevels.info) ) - self.stationServer.messageReceived.connect(self._messageReceived) # type: ignore[attr-defined] - self.stationServer.instrumentCreated.connect(self.addInstrumentToGui) # type: ignore[attr-defined] - self.stationServer.funcCalled.connect(self.onFuncCalled) # type: ignore[attr-defined] + self.stationServer.messageReceived.connect(self._messageReceived) + self.stationServer.instrumentCreated.connect(self.addInstrumentToGui) + self.stationServer.funcCalled.connect(self.onFuncCalled) - self.stationServerThread.start() # type: ignore[attr-defined] + self.stationServerThread.start() def getServerIfRunning(self) -> Optional["StationServer"]: - if self.stationServer is not None and self.stationServerThread.isRunning(): # type: ignore[union-attr] + if ( + self.stationServer is not None + and self.stationServerThread is not None + and self.stationServerThread.isRunning() + ): return self.stationServer else: return None @@ -889,6 +905,8 @@ def addInstrumentTab(self, item: QtWidgets.QTreeWidgetItem, index: int) -> None: kwargs = self._guiConfig[name]["gui"]["kwargs"] kwargs["sub_port"] = kwargs.get("sub_port", self.stationServer.port + 1) # type: ignore[union-attr] + kwargs["shortcutManager"] = self.shortcutManager + insWidget = widgetClass(ins, parent=self, **kwargs) index = self.tabs.addTab(insWidget, ins.name) self.instrumentTabsOpen[ins.name] = insWidget diff --git a/test/pytest/pytest.ini b/test/pytest/pytest.ini index 814cca2..6ce34e2 100644 --- a/test/pytest/pytest.ini +++ b/test/pytest/pytest.ini @@ -1,2 +1,4 @@ [pytest] -qt_api=pyqt5 \ No newline at end of file +qt_api=pyqt5 +markers = + integration: marks tests that spawn subprocesses or require external resources \ No newline at end of file diff --git a/test/pytest/test_apps.py b/test/pytest/test_apps.py new file mode 100644 index 0000000..c2bc744 --- /dev/null +++ b/test/pytest/test_apps.py @@ -0,0 +1,530 @@ +"""Tests for instrumentserver/apps.py entry points. + +Unit tests (no Qt, millisecond-fast) mock at the outermost call boundary: + - instrumentserver.apps.server + - instrumentserver.apps.serverWithGui + - instrumentserver.apps.loadConfig + +Integration tests (marked @pytest.mark.integration) spawn real subprocesses. +Run unit only: pytest test/pytest/test_apps.py -m "not integration" -v +Run integration: pytest test/pytest/test_apps.py -m "integration" -v +""" + +import os +import subprocess +import sys +import tempfile +from pathlib import Path +from unittest.mock import MagicMock, patch + +import pytest + +# --------------------------------------------------------------------------- +# Shared helpers +# --------------------------------------------------------------------------- + +MINIMAL_CONFIG = """\ +instruments: + dummy: + type: instrumentserver.testing.dummy_instruments.generic.DummyInstrumentWithSubmodule +""" + + +@pytest.fixture() +def config_file(tmp_path): + p = tmp_path / "config.yml" + p.write_text(MINIMAL_CONFIG) + return str(p) + + +@pytest.fixture() +def fake_load_config(): + """Patch loadConfig to return a controlled 7-tuple (no polling).""" + tf = tempfile.NamedTemporaryFile(delete=False, suffix=".tmp") + station_file = tempfile.NamedTemporaryFile(delete=False, suffix=".yml", mode="w") + station_file.write("") + station_file.close() + station_path = station_file.name + + def _fake(configPath): + return ( + station_path, # stationConfig + {"instrument1": {}}, # serverConfig + {}, # guiConfig + {}, # shortcutConfig + tf, # tempFile + {}, # pollingRates (empty → no polling thread) + {}, # ipAddresses + ) + + with patch("instrumentserver.apps.loadConfig", side_effect=_fake) as mock_lc: + yield mock_lc + + tf.close() + Path(station_path).unlink(missing_ok=True) + + +@pytest.fixture() +def fake_load_config_polling(): + """Patch loadConfig to return a controlled 7-tuple (with polling rates).""" + tf = tempfile.NamedTemporaryFile(delete=False, suffix=".tmp") + station_file = tempfile.NamedTemporaryFile(delete=False, suffix=".yml", mode="w") + station_file.write("") + station_file.close() + station_path = station_file.name + + def _fake(configPath): + return ( + station_path, + {"instrument1": {}}, + {}, + {}, + tf, + {"dummy/param": 1.0}, # non-empty → polling thread + {}, + ) + + with patch("instrumentserver.apps.loadConfig", side_effect=_fake) as mock_lc: + yield mock_lc + + tf.close() + Path(station_path).unlink(missing_ok=True) + + +# --------------------------------------------------------------------------- +# Phase 1 — serverScript unit tests +# --------------------------------------------------------------------------- + + +def test_server_script_gui_default_no_config(): + """Case 1: default --gui, no -c → serverWithGui called, loadConfig NOT called.""" + sys.argv = ["instrumentserver"] + with ( + patch("instrumentserver.apps.serverWithGui") as mock_gui, + patch("instrumentserver.apps.server") as mock_srv, + patch("instrumentserver.apps.loadConfig") as mock_lc, + ): + from instrumentserver.apps import serverScript + + serverScript() + + mock_gui.assert_called_once() + mock_srv.assert_not_called() + mock_lc.assert_not_called() + + kwargs = mock_gui.call_args.kwargs + assert kwargs["serverConfig"] is None + assert kwargs["stationConfig"] is None + + +def test_server_script_no_gui_no_config(): + """Case 2: --gui False, no -c → server called, loadConfig NOT called.""" + sys.argv = ["instrumentserver", "--gui", "False"] + with ( + patch("instrumentserver.apps.serverWithGui") as mock_gui, + patch("instrumentserver.apps.server") as mock_srv, + patch("instrumentserver.apps.loadConfig") as mock_lc, + ): + from instrumentserver.apps import serverScript + + serverScript() + + mock_srv.assert_called_once() + mock_gui.assert_not_called() + mock_lc.assert_not_called() + + kwargs = mock_srv.call_args.kwargs + assert kwargs["serverConfig"] is None + assert kwargs["stationConfig"] is None + + +def test_server_script_gui_with_config(fake_load_config, config_file): + """Case 3: default --gui, with -c → serverWithGui called with correct kwargs.""" + sys.argv = ["instrumentserver", "-c", config_file] + with ( + patch("instrumentserver.apps.serverWithGui") as mock_gui, + patch("instrumentserver.apps.server") as mock_srv, + ): + from instrumentserver.apps import serverScript + + serverScript() + + fake_load_config.assert_called_once_with(config_file) + mock_gui.assert_called_once() + mock_srv.assert_not_called() + + kwargs = mock_gui.call_args.kwargs + assert kwargs["serverConfig"] == {"instrument1": {}} + assert kwargs["shortcutConfig"] == {} + assert kwargs["configPath"] == config_file + + +def test_server_script_no_gui_with_config(fake_load_config, config_file): + """Case 4: --gui False, with -c → server called WITHOUT shortcutConfig/configPath. + + This test catches the bug where shortcutConfig and configPath were + incorrectly forwarded to server() / startServer(), which does not accept them. + """ + sys.argv = ["instrumentserver", "--gui", "False", "-c", config_file] + with ( + patch("instrumentserver.apps.serverWithGui") as mock_gui, + patch("instrumentserver.apps.server") as mock_srv, + ): + from instrumentserver.apps import serverScript + + serverScript() + + fake_load_config.assert_called_once_with(config_file) + mock_srv.assert_called_once() + mock_gui.assert_not_called() + + kwargs = mock_srv.call_args.kwargs + assert kwargs["serverConfig"] == {"instrument1": {}} + assert "shortcutConfig" not in kwargs, ( + "shortcutConfig must not be passed to server()" + ) + assert "configPath" not in kwargs, "configPath must not be passed to server()" + + +def test_server_script_gui_with_polling(fake_load_config_polling, config_file): + """Case 5: default --gui, with -c and polling rates → PollingWorker instantiated.""" + sys.argv = ["instrumentserver", "-c", config_file] + with ( + patch("instrumentserver.apps.serverWithGui") as mock_gui, + patch("instrumentserver.apps.server") as mock_srv, + patch("instrumentserver.apps.PollingWorker") as mock_pw, + patch("instrumentserver.apps.QtCore.QThread") as mock_qt, + ): + from instrumentserver.apps import serverScript + + serverScript() + + mock_gui.assert_called_once() + mock_srv.assert_not_called() + mock_pw.assert_called_once_with(pollingRates={"dummy/param": 1.0}) + mock_qt.assert_called_once() + + kwargs = mock_gui.call_args.kwargs + assert kwargs["pollingThread"] is not None + + +def test_server_script_no_gui_with_polling(fake_load_config_polling, config_file): + """Case 6: --gui False, with -c and polling rates → PollingWorker instantiated.""" + sys.argv = ["instrumentserver", "--gui", "False", "-c", config_file] + with ( + patch("instrumentserver.apps.serverWithGui") as mock_gui, + patch("instrumentserver.apps.server") as mock_srv, + patch("instrumentserver.apps.PollingWorker") as mock_pw, + patch("instrumentserver.apps.QtCore.QThread") as mock_qt, + ): + from instrumentserver.apps import serverScript + + serverScript() + + mock_srv.assert_called_once() + mock_gui.assert_not_called() + mock_pw.assert_called_once_with(pollingRates={"dummy/param": 1.0}) + mock_qt.assert_called_once() + + kwargs = mock_srv.call_args.kwargs + assert kwargs["pollingThread"] is not None + assert "shortcutConfig" not in kwargs + assert "configPath" not in kwargs + + +@pytest.mark.parametrize("gui", [True, False]) +def test_server_script_passthrough_args(gui): + """Pass-through: --port, --allow_user_shutdown, --listen_at, --init_script arrive unchanged.""" + argv = [ + "instrumentserver", + "--port", + "9999", + "--allow_user_shutdown", + "True", + "--listen_at", + "192.168.1.1", + "--init_script", + "/tmp/init.py", + ] + if not gui: + argv += ["--gui", "False"] + sys.argv = argv + + with ( + patch("instrumentserver.apps.serverWithGui") as mock_gui, + patch("instrumentserver.apps.server") as mock_srv, + patch("instrumentserver.apps.loadConfig"), + ): + from instrumentserver.apps import serverScript + + serverScript() + + mock_fn = mock_gui if gui else mock_srv + mock_fn.assert_called_once() + kwargs = mock_fn.call_args.kwargs + assert kwargs["port"] == "9999" + assert kwargs["addresses"] == ["192.168.1.1"] + assert kwargs["initScript"] == "/tmp/init.py" + # allowUserShutdown is only forwarded on the headless path + if not gui: + assert kwargs["allowUserShutdown"] == "True" + + +# --------------------------------------------------------------------------- +# Phase 2 — clientStationScript and detachedServerScript unit tests +# --------------------------------------------------------------------------- + + +def test_client_station_script_no_config(): + """clientStationScript: no -c → ClientStation called with config_path=None.""" + sys.argv = ["instrumentserver-client-station"] + with ( + patch("instrumentserver.apps.QtWidgets.QApplication") as mock_app, + patch("instrumentserver.apps.ClientStation") as mock_cs, + patch("instrumentserver.apps.ClientStationGui"), + ): + mock_app.return_value.exec_.return_value = 0 + from instrumentserver.apps import clientStationScript + + clientStationScript() + + mock_cs.assert_called_once_with(host="localhost", port=5555, config_path=None) + + +def test_client_station_script_with_config(tmp_path): + """clientStationScript: -c path → ClientStation called with that config_path.""" + cfg = str(tmp_path / "cs.yml") + sys.argv = ["instrumentserver-client-station", "-c", cfg] + with ( + patch("instrumentserver.apps.QtWidgets.QApplication") as mock_app, + patch("instrumentserver.apps.ClientStation") as mock_cs, + patch("instrumentserver.apps.ClientStationGui"), + ): + mock_app.return_value.exec_.return_value = 0 + from instrumentserver.apps import clientStationScript + + clientStationScript() + + mock_cs.assert_called_once_with(host="localhost", port=5555, config_path=cfg) + + +def test_detached_server_script_defaults(): + """detachedServerScript: defaults → DetachedServerGui called with host=localhost, port=5555.""" + sys.argv = ["instrumentserver-detached"] + with ( + patch("instrumentserver.apps.QtWidgets.QApplication") as mock_app, + patch("instrumentserver.apps.DetachedServerGui") as mock_dsg, + ): + mock_app.return_value.exec_.return_value = 0 + mock_dsg.return_value.show = MagicMock() + from instrumentserver.apps import detachedServerScript + + detachedServerScript() + + mock_dsg.assert_called_once_with(host="localhost", port=5555) + + +def test_detached_server_script_custom(): + """detachedServerScript: custom args → DetachedServerGui called with those values.""" + sys.argv = ["instrumentserver-detached", "--host", "10.0.0.1", "--port", "9000"] + with ( + patch("instrumentserver.apps.QtWidgets.QApplication") as mock_app, + patch("instrumentserver.apps.DetachedServerGui") as mock_dsg, + ): + mock_app.return_value.exec_.return_value = 0 + mock_dsg.return_value.show = MagicMock() + from instrumentserver.apps import detachedServerScript + + detachedServerScript() + + mock_dsg.assert_called_once_with( + host="10.0.0.1", port="9000" + ) # string because no type= in argparse + + +# --------------------------------------------------------------------------- +# Phase 3 — parameterManagerScript unit tests +# --------------------------------------------------------------------------- + + +def test_param_manager_script_instrument_exists(): + """parameterManagerScript: instrument exists → get_instrument path taken.""" + sys.argv = ["instrumentserver-param-manager", "--port", "5555"] + mock_pm = MagicMock() + mock_cli = MagicMock() + mock_cli.list_instruments.return_value = ["parameter_manager"] + mock_cli.get_instrument.return_value = mock_pm + + with ( + patch("instrumentserver.apps.QtWidgets.QApplication") as mock_app, + patch("instrumentserver.apps.Client", return_value=mock_cli), + patch("instrumentserver.apps.widgetMainWindow") as mock_wmw, + patch("instrumentserver.apps.ParameterManagerGui") as mock_pmg, + ): + mock_app.return_value.exec_.return_value = 0 + from instrumentserver.apps import parameterManagerScript + + parameterManagerScript() + + mock_cli.get_instrument.assert_called_once_with("parameter_manager") + mock_cli.find_or_create_instrument.assert_not_called() + mock_pmg.assert_called_once_with(mock_pm) + mock_wmw.assert_called_once() + + +def test_param_manager_script_instrument_missing(): + """parameterManagerScript: instrument not found → find_or_create path taken.""" + sys.argv = ["instrumentserver-param-manager", "--port", "5555"] + mock_pm = MagicMock() + mock_cli = MagicMock() + mock_cli.list_instruments.return_value = [] + mock_cli.find_or_create_instrument.return_value = mock_pm + + with ( + patch("instrumentserver.apps.QtWidgets.QApplication") as mock_app, + patch("instrumentserver.apps.Client", return_value=mock_cli), + patch("instrumentserver.apps.widgetMainWindow") as mock_wmw, + patch("instrumentserver.apps.ParameterManagerGui") as mock_pmg, + ): + mock_app.return_value.exec_.return_value = 0 + from instrumentserver.apps import parameterManagerScript + + parameterManagerScript() + + mock_cli.find_or_create_instrument.assert_called_once_with( + "parameter_manager", "instrumentserver.params.ParameterManager" + ) + mock_cli.get_instrument.assert_not_called() + mock_pm.fromFile.assert_called_once() + mock_pm.update.assert_called_once() + mock_pmg.assert_called_once_with(mock_pm) + mock_wmw.assert_called_once() + + +# --------------------------------------------------------------------------- +# Phase 4 — Subprocess integration tests +# --------------------------------------------------------------------------- + + +@pytest.fixture() +def free_port(): + import socket + + with socket.socket() as s: + s.bind(("", 0)) + port = s.getsockname()[1] + yield port + + +@pytest.fixture() +def launch_process(free_port): + procs = [] + + def _launch(args, env_extra=None): + env = os.environ.copy() + env["QT_QPA_PLATFORM"] = "offscreen" + if env_extra: + env.update(env_extra) + proc = subprocess.Popen( + args, + stdout=subprocess.PIPE, + stderr=subprocess.PIPE, + env=env, + ) + procs.append(proc) + return proc + + yield _launch + + for proc in procs: + if proc.poll() is None: + proc.terminate() + try: + proc.wait(timeout=3) + except subprocess.TimeoutExpired: + proc.kill() + + +@pytest.mark.integration +def test_subprocess_headless_no_config(free_port, launch_process): + """instrumentserver --gui False --port starts without crashing.""" + proc = launch_process( + ["instrumentserver", "--gui", "False", "--port", str(free_port)] + ) + # Wait for "Starting server." in stderr within 5 seconds + import select + import time + + deadline = time.monotonic() + 5 + found = False + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + r, _, _ = select.select([proc.stderr], [], [], min(remaining, 0.1)) + if r: + line = proc.stderr.readline().decode(errors="replace") + if "Starting server." in line: + found = True + break + if proc.poll() is not None: + break + assert found, "Server did not emit 'Starting server.' within 5 seconds" + + +@pytest.mark.integration +def test_subprocess_gui_default(free_port, launch_process): + """instrumentserver --port (GUI) stays alive for at least 2 seconds.""" + import time + + proc = launch_process(["instrumentserver", "--port", str(free_port)]) + time.sleep(2) + if proc.poll() is not None: + stderr_out = proc.stderr.read().decode(errors="replace") + pytest.fail( + f"Process exited early (code {proc.returncode}).\nstderr:\n{stderr_out}" + ) + + +@pytest.mark.integration +def test_subprocess_headless_with_config(free_port, launch_process, tmp_path): + """instrumentserver --gui False -c config.yml --port starts correctly.""" + cfg = tmp_path / "config.yml" + cfg.write_text(MINIMAL_CONFIG) + + proc = launch_process( + [ + "instrumentserver", + "--gui", + "False", + "-c", + str(cfg), + "--port", + str(free_port), + ] + ) + + import select + import time + + deadline = time.monotonic() + 5 + found = False + while time.monotonic() < deadline: + remaining = deadline - time.monotonic() + r, _, _ = select.select([proc.stderr], [], [], min(remaining, 0.1)) + if r: + line = proc.stderr.readline().decode(errors="replace") + if "Starting server." in line: + found = True + break + if proc.poll() is not None: + break + assert found, "Server did not emit 'Starting server.' within 5 seconds" + + +@pytest.mark.integration +def test_subprocess_detached_server(free_port, launch_process): + """instrumentserver-detached --port stays alive for at least 2 seconds.""" + import time + + proc = launch_process(["instrumentserver-detached", "--port", str(free_port)]) + time.sleep(2) + assert proc.poll() is None, f"Process exited early: {proc.returncode}" diff --git a/test/pytest/test_config.py b/test/pytest/test_config.py index fdbe570..1a4e6e1 100644 --- a/test/pytest/test_config.py +++ b/test/pytest/test_config.py @@ -27,13 +27,20 @@ def test_minimal_config(tmp_path): type: instrumentserver.testing.dummy_instruments.generic.DummyInstrumentWithSubmodule """, ) - path, serverConfig, fullConfig, tempFile, pollingRates, ipAddresses = loadConfig( - cfg - ) + ( + path, + serverConfig, + fullConfig, + shortcutConfig, + tempFile, + pollingRates, + ipAddresses, + ) = loadConfig(cfg) tempFile.close() assert "my_ins" in serverConfig assert "my_ins" in fullConfig + assert shortcutConfig == {} assert pollingRates == {} assert ipAddresses == {} # returned path is a string @@ -49,7 +56,7 @@ def test_temp_file_is_readable(tmp_path): type: some.Type """, ) - tempFilePath, _, _, tempFile, _, _ = loadConfig(cfg) + tempFilePath, _, _, _, tempFile, _, _ = loadConfig(cfg) tempFile.seek(0) content = tempFile.read() assert len(content) > 0 @@ -70,7 +77,7 @@ def test_initialize_defaults_to_true(tmp_path): type: some.Type """, ) - _, serverConfig, _, tempFile, _, _ = loadConfig(cfg) + _, serverConfig, _, _, tempFile, _, _ = loadConfig(cfg) tempFile.close() assert serverConfig["my_ins"]["initialize"] is True @@ -85,7 +92,7 @@ def test_initialize_explicit_false(tmp_path): initialize: false """, ) - _, serverConfig, _, tempFile, _, _ = loadConfig(cfg) + _, serverConfig, _, _, tempFile, _, _ = loadConfig(cfg) tempFile.close() assert serverConfig["my_ins"]["initialize"] is False @@ -118,7 +125,7 @@ def test_gui_defaults_to_generic_instrument(tmp_path): type: some.Type """, ) - _, _, fullConfig, tempFile, _, _ = loadConfig(cfg) + _, _, fullConfig, _, tempFile, _, _ = loadConfig(cfg) tempFile.close() assert fullConfig["my_ins"]["gui"]["type"] == GUIFIELD["type"] @@ -134,7 +141,7 @@ def test_gui_generic_alias_maps_to_full_path(tmp_path): type: generic """, ) - _, _, fullConfig, tempFile, _, _ = loadConfig(cfg) + _, _, fullConfig, _, tempFile, _, _ = loadConfig(cfg) tempFile.close() assert fullConfig["my_ins"]["gui"]["type"] == GUIFIELD["type"] @@ -187,7 +194,7 @@ def test_polling_rate_parsed(tmp_path): param2: 200 """, ) - _, _, _, tempFile, pollingRates, _ = loadConfig(cfg) + _, _, _, _, tempFile, pollingRates, _ = loadConfig(cfg) tempFile.close() assert pollingRates == {"my_ins.param1": 100, "my_ins.param2": 200} @@ -202,7 +209,7 @@ def test_polling_rate_empty_is_ignored(tmp_path): pollingRate: """, ) - _, _, _, tempFile, pollingRates, _ = loadConfig(cfg) + _, _, _, _, tempFile, pollingRates, _ = loadConfig(cfg) tempFile.close() assert pollingRates == {} @@ -224,7 +231,7 @@ def test_networking_parsed(tmp_path): listeningAddress: 192.168.1.1 """, ) - _, _, _, tempFile, _, ipAddresses = loadConfig(cfg) + _, _, _, _, tempFile, _, ipAddresses = loadConfig(cfg) tempFile.close() assert ipAddresses["externalBroadcast"] == "tcp://192.168.1.1:5556" assert ipAddresses["listeningAddress"] == "192.168.1.1" @@ -239,7 +246,7 @@ def test_no_networking_section_gives_empty_dict(tmp_path): type: some.Type """, ) - _, _, _, tempFile, _, ipAddresses = loadConfig(cfg) + _, _, _, _, tempFile, _, ipAddresses = loadConfig(cfg) tempFile.close() assert ipAddresses == {} @@ -262,7 +269,7 @@ def test_gui_defaults_default_section(tmp_path): - IDN """, ) - _, _, fullConfig, tempFile, _, _ = loadConfig(cfg) + _, _, fullConfig, _, tempFile, _, _ = loadConfig(cfg) tempFile.close() kwargs = fullConfig["my_ins"]["gui"].get("kwargs", {}) assert "parameters-hide" in kwargs @@ -282,7 +289,7 @@ def test_gui_defaults_class_section(tmp_path): - power_level """, ) - _, _, fullConfig, tempFile, _, _ = loadConfig(cfg) + _, _, fullConfig, _, tempFile, _, _ = loadConfig(cfg) tempFile.close() kwargs = fullConfig["my_ins"]["gui"].get("kwargs", {}) assert "parameters-hide" in kwargs @@ -310,9 +317,39 @@ def test_gui_defaults_merging_order(tmp_path): - class_param """, ) - _, _, fullConfig, tempFile, _, _ = loadConfig(cfg) + _, _, fullConfig, _, tempFile, _, _ = loadConfig(cfg) tempFile.close() hide = fullConfig["my_ins"]["gui"]["kwargs"]["parameters-hide"] assert "default_param" in hide assert "class_param" in hide assert "instance_param" in hide + + +def test_shortcuts_parsed(tmp_path): + cfg = _write_config( + tmp_path, + """\ +instruments: + my_ins: + type: some.Type +shortcuts: + jump_filter: "Ctrl+G" +""", + ) + _, _, _, shortcutConfig, tempFile, _, _ = loadConfig(cfg) + tempFile.close() + assert shortcutConfig == {"jump_filter": "Ctrl+G"} + + +def test_no_shortcuts_gives_empty_dict(tmp_path): + cfg = _write_config( + tmp_path, + """\ +instruments: + my_ins: + type: some.Type +""", + ) + _, _, _, shortcutConfig, tempFile, _, _ = loadConfig(cfg) + tempFile.close() + assert shortcutConfig == {} diff --git a/test/pytest/test_shortcuts.py b/test/pytest/test_shortcuts.py new file mode 100644 index 0000000..6cbc3b5 --- /dev/null +++ b/test/pytest/test_shortcuts.py @@ -0,0 +1,300 @@ +"""Tests for KeyboardShortcutManager and ShortcutEditorWidget.""" + +from pathlib import Path + +import pytest +import yaml + +from instrumentserver import QtCore, QtGui, QtWidgets +from instrumentserver.gui.shortcuts import KeyboardShortcutManager, ShortcutEditorWidget + + +def _write_yaml(tmp_path: Path, content: dict) -> Path: + p = tmp_path / "config.yml" + p.write_text(yaml.dump(content)) + return p + + +# --------------------------------------------------------------------------- +# KeyboardShortcutManager tests +# --------------------------------------------------------------------------- + + +def test_rebind_updates_mapping(): + manager = KeyboardShortcutManager() + original = manager.mapping["jump_filter"] + manager.rebind("jump_filter", "Ctrl+G") + assert manager.mapping["jump_filter"] == "Ctrl+G" + assert manager.mapping["jump_filter"] != original + + +def test_save_writes_diffs_to_file(tmp_path): + cfg = _write_yaml(tmp_path, {"port": 8000}) + manager = KeyboardShortcutManager() + manager.rebind("jump_filter", "Ctrl+G") + manager.save(str(cfg)) + + with open(cfg) as f: + data = yaml.safe_load(f) + + assert "shortcuts" in data + assert data["shortcuts"] == {"jump_filter": "Ctrl+G"} + # only the diff, not the full registry + assert "collapse_all" not in data["shortcuts"] + + +def test_save_removes_shortcuts_section_when_all_defaults(tmp_path): + cfg = _write_yaml(tmp_path, {"port": 8000, "shortcuts": {"jump_filter": "Ctrl+G"}}) + manager = KeyboardShortcutManager() + # mapping is at defaults — no diffs + manager.save(str(cfg)) + + with open(cfg) as f: + data = yaml.safe_load(f) + + assert "shortcuts" not in data + assert data["port"] == 8000 + + +def test_load_from_dict_overrides_defaults(): + manager = KeyboardShortcutManager() + default = manager.mapping["jump_filter"] + manager.load_from_dict({"jump_filter": "Ctrl+G"}) + assert manager.mapping["jump_filter"] == "Ctrl+G" + assert manager.mapping["jump_filter"] != default + + +# --------------------------------------------------------------------------- +# ShortcutEditorWidget button-state tests +# --------------------------------------------------------------------------- + + +@pytest.fixture +def manager(): + return KeyboardShortcutManager() + + +@pytest.fixture +def widget_no_path(qtbot, manager): + w = ShortcutEditorWidget(manager, configPath="") + qtbot.addWidget(w) + w.show() + return w + + +@pytest.fixture +def widget_with_path(qtbot, manager, tmp_path): + cfg = _write_yaml(tmp_path, {"port": 8000}) + w = ShortcutEditorWidget(manager, configPath=str(cfg)) + qtbot.addWidget(w) + w.show() + return w, cfg + + +def test_apply_cancel_disabled_at_init(widget_no_path): + w = widget_no_path + assert not w._btnApply.isEnabled() + assert not w._btnCancel.isEnabled() + + +def test_apply_cancel_enabled_after_edit(qtbot, widget_no_path, manager): + w = widget_no_path + # Find the QKeySequenceEdit for row 0 and simulate an edit + key_edit = w._table.cellWidget(0, 2) + assert isinstance(key_edit, QtWidgets.QKeySequenceEdit) + key_edit.setKeySequence(QtGui.QKeySequence("Ctrl+G")) + + assert w._btnApply.isEnabled() + assert w._btnCancel.isEnabled() + + +def test_apply_disables_apply_cancel(qtbot, widget_no_path): + w = widget_no_path + key_edit = w._table.cellWidget(0, 2) + key_edit.setKeySequence(QtGui.QKeySequence("Ctrl+G")) + + assert w._btnApply.isEnabled() + qtbot.mouseClick(w._btnApply, QtCore.Qt.LeftButton) + + assert not w._btnApply.isEnabled() + assert not w._btnCancel.isEnabled() + + +def test_cancel_reverts_table_and_disables(qtbot, widget_no_path, manager): + w = widget_no_path + action_id = list(manager.REGISTRY.keys())[0] + original_key = manager.mapping[action_id] + + key_edit = w._table.cellWidget(0, 2) + key_edit.setKeySequence(QtGui.QKeySequence("Ctrl+G")) + assert w._btnCancel.isEnabled() + + qtbot.mouseClick(w._btnCancel, QtCore.Qt.LeftButton) + + assert key_edit.keySequence().toString() == original_key + assert not w._btnApply.isEnabled() + assert not w._btnCancel.isEnabled() + # manager mapping must NOT have changed + assert manager.mapping[action_id] == original_key + + +def test_save_disabled_without_config_path(widget_no_path): + w = widget_no_path + assert not w._btnSaveFile.isEnabled() + assert "config file" in w._btnSaveFile.toolTip().lower() + + +def test_apply_save_disabled_on_conflict(qtbot, widget_with_path): + w, _ = widget_with_path + # Set rows 0 and 1 to the same key — creates a conflict + key_edit_0 = w._table.cellWidget(0, 2) + key_edit_1 = w._table.cellWidget(1, 2) + key_edit_0.setKeySequence(QtGui.QKeySequence("Ctrl+G")) + key_edit_1.setKeySequence(QtGui.QKeySequence("Ctrl+G")) + + assert not w._btnApply.isEnabled() + assert not w._btnSaveFile.isEnabled() + # Cancel should still be available so the user can undo the conflict + assert w._btnCancel.isEnabled() + + +def test_apply_save_reenabled_when_conflict_resolved(qtbot, widget_with_path): + w, _ = widget_with_path + key_edit_0 = w._table.cellWidget(0, 2) + key_edit_1 = w._table.cellWidget(1, 2) + key_edit_0.setKeySequence(QtGui.QKeySequence("Ctrl+G")) + key_edit_1.setKeySequence(QtGui.QKeySequence("Ctrl+G")) + + assert not w._btnApply.isEnabled() + + # Resolve the conflict by giving row 1 a unique key + key_edit_1.setKeySequence(QtGui.QKeySequence("Ctrl+H")) + + assert w._btnApply.isEnabled() + assert w._btnSaveFile.isEnabled() + + +def test_conflict_indicator_is_red(qtbot, widget_with_path): + """Indicator dots must turn red when a duplicate key exists in the table.""" + w, _ = widget_with_path + key_edit_0 = w._table.cellWidget(0, 2) + key_edit_1 = w._table.cellWidget(1, 2) + key_edit_1.setKeySequence(key_edit_0.keySequence()) + + dot_0 = w._indicators[0] + dot_1 = w._indicators[1] + assert "Duplicate" in dot_0.toolTip() + assert "Duplicate" in dot_1.toolTip() + + +def test_indicator_yellow_after_apply_not_saved(qtbot, widget_with_path, manager): + """After Apply, indicator must be yellow (applied to session, not yet on disk).""" + w, _ = widget_with_path + key_edit = w._table.cellWidget(0, 2) + key_edit.setKeySequence(QtGui.QKeySequence("Ctrl+G")) + + qtbot.mouseClick(w._btnApply, QtCore.Qt.LeftButton) + + assert "applied" in w._indicators[0].toolTip().lower() + assert "not saved" in w._indicators[0].toolTip().lower() + + +def test_indicator_green_after_save(qtbot, widget_with_path): + """After Save, indicator must be green (file matches live state).""" + w, _ = widget_with_path + key_edit = w._table.cellWidget(0, 2) + key_edit.setKeySequence(QtGui.QKeySequence("Ctrl+G")) + + qtbot.mouseClick(w._btnSaveFile, QtCore.Qt.LeftButton) + + assert w._indicators[0].toolTip() == "" + + +def test_restoreAfterRevert_refreshes_indicators_after_spurious_clear( + qtbot, widget_with_path +): + """ + _restoreAfterRevert must call _updateAllIndicators after unblocking signals. + + Real keyboard flow: user types a duplicate key → indicator goes red → the + QKeySequenceEdit's finishing timeout fires a spurious keySequenceChanged("") + BEFORE editingFinished → _onUnsavedChange clears the red indicator (no + duplicate found for "") → editingFinished fires → blockSignals(True) → + _restoreAfterRevert puts the key back (signals blocked) → blockSignals(False). + + Without an explicit refresh inside _restoreAfterRevert, the indicator stays + orange/green even though the widget again shows the duplicate key. + """ + w, _ = widget_with_path + key_edit_0 = w._table.cellWidget(0, 2) + key_edit_1 = w._table.cellWidget(1, 2) + intended_key = key_edit_0.keySequence().toString() + + # Step 1: user types the duplicate key — indicator goes red + key_edit_1.setKeySequence(QtGui.QKeySequence(intended_key)) + assert "Duplicate" in w._indicators[1].toolTip() + + # Step 2: spurious keySequenceChanged("") fires *before* editingFinished, + # incorrectly clearing the indicator back to orange/green + key_edit_1.setKeySequence(QtGui.QKeySequence("")) + assert "Duplicate" not in w._indicators[1].toolTip() # confirm indicator cleared + + # Step 3: editingFinished fires → blockSignals(True) + key_edit_1.blockSignals(True) + + # Step 4: _restoreAfterRevert runs (value was cleared internally, restores it) + w._restoreAfterRevert(intended_key, key_edit_1) + + # Widget must show the intended key + assert key_edit_1.keySequence().toString() == intended_key + # Indicator must be red again — not stay orange from the spurious clear + assert "Duplicate" in w._indicators[1].toolTip() + + +def test_apply_uses_intended_value_when_signals_blocked(qtbot, widget_no_path, manager): + """ + _applyToManager must use the stashed intended value for widgets whose signals + are blocked (mid _onEditingFinished / _restoreAfterRevert cycle). + + Simulates the race: editingFinished fires → signals blocked → widget internally + resets to "" → Apply/Save reads the widget before _restoreAfterRevert runs. + Without the fix, the empty string gets applied to the manager. + """ + w = widget_no_path + action_id = list(manager.REGISTRY.keys())[0] + key_edit = w._table.cellWidget(0, 2) + + # Simulate the state after _onEditingFinished ran but before _restoreAfterRevert: + # widget has been cleared internally and signals are blocked. + intended = "Ctrl+G" + w._pending_restores[key_edit] = intended + key_edit.blockSignals(True) + key_edit.setKeySequence(QtGui.QKeySequence("")) # simulate internal clear + + # Apply reads the widget — without the fix this applies "" to the manager + w._applyToManager() + + assert manager.mapping[action_id] == intended + + +def test_save_applies_and_writes_file(qtbot, widget_with_path, manager): + w, cfg = widget_with_path + action_id = list(manager.REGISTRY.keys())[0] + + key_edit = w._table.cellWidget(0, 2) + key_edit.setKeySequence(QtGui.QKeySequence("Ctrl+G")) + + assert w._btnSaveFile.isEnabled() + qtbot.mouseClick(w._btnSaveFile, QtCore.Qt.LeftButton) + + # Apply/Cancel disabled after save + assert not w._btnApply.isEnabled() + assert not w._btnCancel.isEnabled() + + # File was written with the diff + with open(cfg) as f: + data = yaml.safe_load(f) + assert data.get("shortcuts", {}).get(action_id) == "Ctrl+G" + + # Manager mapping updated + assert manager.mapping[action_id] == "Ctrl+G"