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 @@
+
+
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"