diff --git a/src/instrumentserver/apps.py b/src/instrumentserver/apps.py index 0d881d1..b3680e5 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) @@ -89,8 +96,10 @@ def serverScript() -> None: serverConfig=serverConfig, stationConfig=stationConfig, guiConfig=guiConfig, + shortcutConfig=shortcutConfig, pollingThread=pollingThread, ipAddresses=ipAddresses, + configPath=configPath, ) else: serverWithGui( @@ -100,8 +109,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 3c43d74..391542c 100644 --- a/src/instrumentserver/client/proxy.py +++ b/src/instrumentserver/client/proxy.py @@ -18,7 +18,7 @@ import qcodes as qc import zmq from qcodes import Instrument, Parameter -from qcodes.instrument.base import InstrumentBase +from qcodes.instrument import InstrumentBase from instrumentserver import DEFAULT_PORT, QtCore from instrumentserver.helpers import flat_to_nested_dict, flatten_dict, is_flat_dict @@ -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..4057ef1 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,8 @@ 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 """ + if not patterns: + return False for pattern in patterns: if fnmatch.fnmatch(name, pattern): return True @@ -481,7 +484,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 +772,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 +784,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 +800,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 +841,12 @@ 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) + def makeToolbar(self) -> QtWidgets.QToolBar: """ Creates the toolbar, override to add more buttons to the toolbar. @@ -842,6 +859,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.apply_to_action("refresh_all", refreshAction) toolbar.addSeparator() @@ -850,12 +868,14 @@ def makeToolbar(self) -> QtWidgets.QToolBar: "expand tree", ) expandAction.triggered.connect(lambda x: self.view.expandAll()) # type: ignore[union-attr] + self.shortcutManager.apply_to_action("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.apply_to_action("collapse_all", collapseAction) toolbar.addSeparator() @@ -863,13 +883,15 @@ def makeToolbar(self) -> QtWidgets.QToolBar: 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] + starAction.triggered.connect(lambda x: self.proxyModel.onToggleStar()) # type: ignore[union-attr] + self.shortcutManager.apply_to_action("toggle_star", starAction) 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] + trashAction.triggered.connect(lambda x: self.proxyModel.onToggleTrash()) # type: ignore[union-attr] + self.shortcutManager.apply_to_action("toggle_trash", trashAction) # Debugging tools keep commented for commits. # printAction = toolbar.addAction( @@ -883,16 +905,53 @@ def makeToolbar(self) -> QtWidgets.QToolBar: return toolbar @QtCore.Slot() - def hideTrash(self) -> None: - self.proxyModel.onToggleTrash() + def refreshAll(self) -> None: + self.model.refreshAll() @QtCore.Slot() - def promoteStar(self) -> None: - self.proxyModel.onToggleStar() + def _starCurrentItem(self) -> None: + proxy_index = self.view.currentIndex() + if not proxy_index.isValid(): + return + 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) + if isinstance(item, ItemBase): + self.view.lastSelectedItem = item + self.view.itemStarToggle.emit(item) @QtCore.Slot() - def refreshAll(self) -> None: - self.model.refreshAll() + def _trashCurrentItem(self) -> None: + proxy_index = self.view.currentIndex() + if not proxy_index.isValid(): + return + 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) + if isinstance(item, ItemBase): + self.view.lastSelectedItem = item + self.view.itemTrashToggle.emit(item) + + @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..ab387c4 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,9 @@ def __init__( if "sub_port" in kwargs: modelKwargs["sub_port"] = kwargs.pop("sub_port") + shortcutManager = kwargs.pop("shortcutManager", None) + print(shortcutManager) + super().__init__( instrument=instrument, parent=parent, @@ -473,12 +476,64 @@ 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) + + @QtCore.Slot() + def _refreshCurrentItem(self) -> None: + proxy_index = self.view.currentIndex() + if not proxy_index.isValid(): + return + 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) + if isinstance(item, ItemBase): + widget = self.view.delegate.parameters.get(item.name) + if widget is not None: + widget.setWidgetFromParameter() + + @QtCore.Slot() + def _togglePythonCurrentItem(self) -> None: + proxy_index = self.view.currentIndex() + if not proxy_index.isValid(): + return + 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) + if isinstance(item, ItemBase): + widget = self.view.delegate.parameters.get(item.name) + if widget is not None and isinstance(widget.paramWidget, AnyInput): + widget.paramWidget.doEval.toggle() + + @QtCore.Slot() + def _focusToParameterValue(self) -> None: + proxy_index = self.view.currentIndex() + if not proxy_index.isValid(): + return + 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) + if isinstance(item, ItemBase): + widget = self.view.delegate.parameters.get(item.name) + if widget and hasattr(widget, "paramWidget"): + pw = widget.paramWidget + if isinstance(pw, AnyInput): + pw.input.setFocus() + else: + pw.setFocus() # ----------------- Parameters Display Classes - Ending -------------------------------- @@ -615,6 +670,23 @@ 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: + proxy_index = self.view.currentIndex() + if not proxy_index.isValid(): + return + 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) + if isinstance(item, ItemBase): + self.removeParameter(item.name) def makeToolbar(self) -> QtWidgets.QToolBar: toolbar = super().makeToolbar() @@ -626,12 +698,14 @@ def makeToolbar(self) -> QtWidgets.QToolBar: "Load parameters from file", ) loadParamAction.triggered.connect(lambda x: self.loadFromFile()) # type: ignore[union-attr] + self.shortcutManager.apply_to_action("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.apply_to_action("save_items", saveParamAction) return toolbar @@ -767,11 +841,14 @@ 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, ) diff --git a/src/instrumentserver/gui/shortcuts.py b/src/instrumentserver/gui/shortcuts.py new file mode 100644 index 0000000..bfe0231 --- /dev/null +++ b/src/instrumentserver/gui/shortcuts.py @@ -0,0 +1,303 @@ +import logging +import os +from collections import defaultdict +from typing import Callable, Optional + +import yaml + +from instrumentserver import QtCore, QtGui, QtWidgets, getInstrumentserverPath + +_ICON_DIR = getInstrumentserverPath("resource", "icons") + + +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() and apply_to_action() + hand each mapping entry to Qt's event system (QShortcut / QAction.setShortcut), + which fires the associated callback when the key is pressed. + """ + + 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"), + "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+O", "Load parameters from JSON file"), + "save_items": ("Ctrl+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, QtWidgets.QShortcut] = {} + self._action_map: dict[str, QtWidgets.QAction] = {} + + 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 apply_to_action( + self, action_id: str, qaction: Optional[QtWidgets.QAction] + ) -> None: + """Set the shortcut from the current mapping on an existing QAction and retain a reference for live rebinding.""" + if qaction is None: + return + key = self.mapping.get(action_id) + if key: + qaction.setShortcut(QtGui.QKeySequence(key)) + self._action_map[action_id] = qaction + + 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.activated.connect(callback) + self._shortcut_map[action_id] = sc + + 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 + if action_id in self._shortcut_map: + self._shortcut_map[action_id].setKey(QtGui.QKeySequence(new_key)) + if action_id in self._action_map: + self._action_map[action_id].setShortcut(QtGui.QKeySequence(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 is clicked. Use 'Save to file' to persist across sessions. + + Each row has a small colored indicator dot in the rightmost column: + - white : saved and unique + - orange: unsaved change (widget value differs from manager.mapping) + - 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._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) + btnSaveFile = QtWidgets.QPushButton("Save to file") + btnSaveFile.clicked.connect(self._saveToFile) + + btnRow = QtWidgets.QHBoxLayout() + btnRow.addStretch() + btnRow.addWidget(btnReset) + btnRow.addWidget(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 = 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 change") + else: + self._applyIndicator(dot, "ok", "") + + @staticmethod + def _applyIndicator(dot: QtWidgets.QLabel, state: str, tooltip: str) -> None: + dot.setToolTip(tooltip) + if state == "ok": + icon_file = "alert-octagon.svg" + elif state == "unsaved": + icon_file = "alert-octagon-orange.svg" + else: # duplicate + icon_file = "alert-octagon-red.svg" + pix = QtGui.QIcon(os.path.join(_ICON_DIR, icon_file)).pixmap(20, 20) + dot.setPixmap(pix) + + @QtCore.Slot() + def _onUnsavedChange(self) -> None: + self._updateAllIndicators() + + def _onEditingFinished(self, widget: QtWidgets.QKeySequenceEdit) -> None: + intended = widget.keySequence().toString() + 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) + + def _save(self) -> None: + for row, action_id in enumerate(self.manager.REGISTRY): + widget = self._table.cellWidget(row, 2) + if isinstance(widget, QtWidgets.QKeySequenceEdit): + self.manager.rebind(action_id, widget.keySequence().toString()) + self._updateAllIndicators() + + @QtCore.Slot() + def _saveToFile(self) -> None: + self._save() + if self.configPath: + try: + self.manager.save(self.configPath) + logger.info(f"Saved shortcuts to {self.configPath}") + except Exception as e: + logger.warning(f"Failed to save shortcuts to {self.configPath}: {e}") + + @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() 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/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/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 == {}