Source code for QInstrument.QInstrumentRack

from __future__ import annotations

# TODO: Provide methods to search for instruments by type or
#       identification string.
from pathlib import Path

from qtpy import QtWidgets, QtCore, QtGui
from QInstrument.lib.QInstrumentWidget import QInstrumentWidget
from QInstrument.lib.Configure import Configure
import importlib
import logging


logger = logging.getLogger(__name__)


class _DragHandle(QtWidgets.QLabel):
    '''Grip widget that initiates instrument slot reordering.

    Displays a vertical ellipsis (⋮) and changes the cursor to a
    closed-hand shape while the left button is held.  Emits
    :attr:`dragging` on every mouse-move during a drag so the rack
    can highlight the current drop target, and emits :attr:`dropped`
    on release so the rack can commit the move.

    Signals
    -------
    dragging(QtCore.QPoint)
        Emitted continuously during a left-button drag with the
        current global cursor position.
    dropped(QtCore.QPoint)
        Emitted on left-button release with the global cursor position
        at the moment of release.
    '''

    dropped = QtCore.Signal(QtCore.QPoint)
    dragging = QtCore.Signal(QtCore.QPoint)

    def __init__(self, parent: QtWidgets.QWidget | None = None) -> None:
        super().__init__('\u22ee', parent)
        self.setFixedWidth(14)
        self.setAlignment(QtCore.Qt.AlignmentFlag.AlignCenter)
        self.setCursor(QtCore.Qt.CursorShape.OpenHandCursor)

    def mousePressEvent(self, event: QtCore.QEvent) -> None:
        if event.button() == QtCore.Qt.MouseButton.LeftButton:
            self.setCursor(QtCore.Qt.CursorShape.ClosedHandCursor)
        super().mousePressEvent(event)

    def mouseMoveEvent(self, event: QtCore.QEvent) -> None:
        if event.buttons() & QtCore.Qt.MouseButton.LeftButton:
            self.dragging.emit(QtGui.QCursor.pos())
        super().mouseMoveEvent(event)

    def mouseReleaseEvent(self, event: QtCore.QEvent) -> None:
        self.setCursor(QtCore.Qt.CursorShape.OpenHandCursor)
        if event.button() == QtCore.Qt.MouseButton.LeftButton:
            self.dropped.emit(QtGui.QCursor.pos())
        super().mouseReleaseEvent(event)


class _InstrumentSlot(QtWidgets.QWidget):
    '''Wraps one instrument widget with a drag handle and close button.

    Layout (left to right): ⋮ drag handle | instrument widget.
    The × close button and a drop-target indicator are overlaid
    absolutely so they do not affect the horizontal layout.

    The close button and drag handle are shown only when the slot is
    editable (see :meth:`setEditable`).  The drop-target indicator —
    a 3 px coloured bar across the top of the slot — is shown only
    while another slot is being dragged over this one.

    Signals
    -------
    removeRequested(str)
        Emitted when the × button is clicked, carrying the slot name.
    dropRequested(object, QtCore.QPoint)
        Emitted on drag release, carrying this slot and the global
        drop position.
    hoverRequested(object, QtCore.QPoint)
        Emitted continuously during a drag, carrying this slot and the
        current global cursor position.
    '''

    removeRequested = QtCore.Signal(str)
    dropRequested = QtCore.Signal(object, QtCore.QPoint)
    hoverRequested = QtCore.Signal(object, QtCore.QPoint)

    def __init__(self,
                 name: str,
                 widget: QInstrumentWidget,
                 parent: QtWidgets.QWidget | None = None) -> None:
        super().__init__(parent)
        self._name = name
        self._setupUi(widget)
        self._connectSignals()

    def _setupUi(self, widget: QInstrumentWidget) -> None:
        layout = QtWidgets.QHBoxLayout(self)
        layout.setContentsMargins(0, 0, 0, 0)
        layout.setSpacing(0)
        self._handle = _DragHandle(self)
        layout.addWidget(self._handle)
        layout.addWidget(widget)
        self._closeButton = QtWidgets.QPushButton('\u00d7', self)
        self._closeButton.setFixedSize(18, 18)
        self._closeButton.setFlat(True)
        self._dropIndicator = QtWidgets.QFrame(self)
        self._dropIndicator.setFixedHeight(3)
        self._dropIndicator.setStyleSheet('background: palette(highlight);')
        self._dropIndicator.setVisible(False)

    def _connectSignals(self) -> None:
        self._closeButton.clicked.connect(
            lambda: self.removeRequested.emit(self._name))
        self._handle.dropped.connect(
            lambda pos: self.dropRequested.emit(self, pos))
        self._handle.dragging.connect(
            lambda pos: self.hoverRequested.emit(self, pos))

    def setEditable(self, editable: bool) -> None:
        '''Show or hide the drag handle and close button.

        Parameters
        ----------
        editable : bool
            ``True`` to show edit controls; ``False`` to hide them.
        '''
        self._handle.setVisible(editable)
        self._closeButton.setVisible(editable)

    def setHighlighted(self, highlighted: bool) -> None:
        '''Show or hide the drop-target indicator.

        The indicator is a 3 px bar in the system highlight colour
        across the top of the slot.  It is shown while another slot's
        drag handle is held over this slot and cleared on release.

        Parameters
        ----------
        highlighted : bool
            ``True`` to show the indicator; ``False`` to hide it.
        '''
        self._dropIndicator.setVisible(highlighted)

    def resizeEvent(self, event) -> None:
        super().resizeEvent(event)
        btn = self._closeButton
        btn.move(self.width() - btn.width() - 2, 2)
        btn.raise_()
        self._dropIndicator.resize(self.width(), 3)
        self._dropIndicator.move(0, 0)
        self._dropIndicator.raise_()


class _InstrumentPicker(QtWidgets.QDialog):
    '''Dialog for selecting an instrument to add to the rack.'''

    def __init__(self,
                 instruments: list[str],
                 parent: QtWidgets.QWidget | None = None) -> None:
        super().__init__(parent)
        self._setupUi(instruments)
        self._connectSignals()

    def _setupUi(self, instruments: list[str]) -> None:
        self.setWindowTitle('Add Instrument')
        layout = QtWidgets.QVBoxLayout(self)
        self._list = QtWidgets.QListWidget()
        self._list.addItems(instruments)
        layout.addWidget(self._list)
        ok = QtWidgets.QDialogButtonBox.StandardButton.Ok
        cancel = QtWidgets.QDialogButtonBox.StandardButton.Cancel
        self._buttons = QtWidgets.QDialogButtonBox(ok | cancel)
        layout.addWidget(self._buttons)

    def _connectSignals(self) -> None:
        self._list.itemDoubleClicked.connect(self.accept)
        self._buttons.accepted.connect(self.accept)
        self._buttons.rejected.connect(self.reject)

    def selected(self) -> str | None:
        '''Return the selected instrument name, or ``None``.'''
        items = self._list.selectedItems()
        return items[0].text() if items else None


[docs] class QInstrumentRack(QtWidgets.QWidget): '''A widget that holds multiple instrument widgets in a vertical layout. The instrument list is persisted to ``~/.QInstrument/QInstrumentRack.json`` via :class:`Configure`. On first show, if no instruments were supplied at construction, the saved list is restored. On close, the current list is saved. When :attr:`editable` is ``True`` (the default), the rack provides: - An "Add instrument…" toolbar button that opens a picker dialog listing all instruments found under ``instruments/``. - A × close button overlaid on each slot to remove that instrument. - A ⋮ drag handle on each slot. Dragging highlights the target slot with a coloured bar and moves the dragged slot to that position on release. Set :attr:`editable` to ``False`` to hide all of the above, for example when embedding the rack in an application where the instrument set should be fixed. Parameters ---------- parent : QWidget | None Parent widget. Default: ``None``. instruments : list[str] | None Instrument names to load on construction. Each name is the bare instrument name without the ``Q`` prefix or ``Widget`` suffix (e.g. ``'DS345'``). Default: ``None`` (empty rack). editable : bool If ``False``, the toolbar, drag handles, and close buttons are all hidden. Default: ``True``. fake : bool If ``True``, all instruments — including those added later via the "Add instrument…" dialog — use fake devices instead of probing for real hardware. Default: ``False``. ''' def __init__(self, parent: QtWidgets.QWidget | None = None, instruments: list[str] | None = None, editable: bool = True, fake: bool = False) -> None: super().__init__(parent) self._configure = Configure() self._shown = False self._editable = editable self._fake = fake self._setupUi() self.addInstrumentsByNames(instruments, fake=fake) def _setupUi(self) -> None: outer = QtWidgets.QVBoxLayout(self) outer.setContentsMargins(0, 0, 0, 0) outer.setSpacing(0) self._toolbar = self._makeToolbar() self._toolbar.setVisible(self._editable) outer.addWidget(self._toolbar) self._slots = QtWidgets.QVBoxLayout() self._slots.setContentsMargins(0, 0, 0, 0) self._slots.setSpacing(0) outer.addLayout(self._slots) outer.addStretch() def _makeToolbar(self) -> QtWidgets.QWidget: bar = QtWidgets.QWidget() layout = QtWidgets.QHBoxLayout(bar) layout.setContentsMargins(4, 4, 4, 0) btn = QtWidgets.QPushButton('Add instrument\u2026') btn.clicked.connect(self._addInstrumentDialog) layout.addWidget(btn) layout.addStretch() return bar def _slotAt(self, index: int) -> '_InstrumentSlot | None': item = self._slots.itemAt(index) return item.widget() if item else None def _iterSlots(self): for i in range(self._slots.count()): if slot := self._slotAt(i): yield slot @property def settings(self) -> dict: '''dict: instrument list as ``{'instruments': [...]}``. Getting returns instrument names in their current display order, preserving any reordering done by dragging. Setting clears the rack and reloads from the supplied dict. ''' names = [slot._name for slot in self._iterSlots()] return {'instruments': names} @settings.setter def settings(self, settings: dict) -> None: self.clearInstruments() self.addInstrumentsByNames(settings.get('instruments', [])) @property def editable(self) -> bool: '''bool: whether the user can add, remove, or reorder instruments. When ``False``, the toolbar, drag handles, and close buttons are all hidden. Defaults to ``True``. ''' return self._editable @editable.setter def editable(self, value: bool) -> None: self._editable = value self._toolbar.setVisible(value) for slot in self._iterSlots(): slot.setEditable(value)
[docs] def addInstrument(self, instrument: QInstrumentWidget, name: str = '') -> None: '''Add an instrument widget instance to the rack. Parameters ---------- instrument : QInstrumentWidget The instrument widget to add. name : str Display name. Derived from the widget class name if omitted. ''' if not name: cls_name = type(instrument).__name__ name = (cls_name .removeprefix('Q') .removesuffix('Widget')) slot = _InstrumentSlot(name, instrument, self) slot.removeRequested.connect(self._removeInstrument) slot.dropRequested.connect(self._moveSlot) slot.hoverRequested.connect(self._hoverSlot) slot.setEditable(self._editable) self._slots.addWidget(slot) self.adjustSize()
[docs] def addInstruments(self, instruments: list[QInstrumentWidget] ) -> None: '''Add multiple instrument widget instances to the rack. Parameters ---------- instruments : list[QInstrumentWidget] Instrument widget instances to add. ''' for instrument in instruments: self.addInstrument(instrument)
[docs] def addInstrumentByName(self, name: str, fake: bool = False) -> None: '''Add an instrument widget by its bare instrument name. Searches manufacturer subdirectories under ``instruments/`` for a package named ``name`` that contains a ``widget.py``, then instantiates ``Q<name>Widget``. Logs a warning and does nothing if the instrument or widget class cannot be found. Parameters ---------- name : str Bare instrument name without the ``Q`` prefix or ``Widget`` suffix (e.g. ``'DS345'``). fake : bool If ``True``, instantiate the widget with its fake device (from the sibling ``fake.py``) instead of probing for real hardware. The widget will be fully enabled. Falls back to normal instantiation if no fake is available. Default: ``False``. ''' modulename = self._findInstrumentModule(name) if modulename is None: logger.warning(f"Instrument '{name}' not found.") return widgetname = f'Q{name}Widget' try: mod = importlib.import_module(modulename) cls = getattr(mod, widgetname) if fake: fake_cls = cls._fakeCls() if fake_cls is not None: instrument = cls(device=fake_cls()) else: logger.warning( f"No fake available for '{name}'; loading normally.") instrument = cls() else: instrument = cls() except (ModuleNotFoundError, AttributeError) as e: logger.warning( f"Error loading instrument '{name}': {e}") return self.addInstrument(instrument, name)
[docs] def addInstrumentsByNames(self, names: list[str] | None, fake: bool = False) -> None: '''Add multiple instruments by their bare names. Parameters ---------- names : list[str] | None Bare instrument names to load. ``None`` is treated as an empty list. fake : bool Passed to :meth:`addInstrumentByName` for each name. Default: ``False``. ''' for name in (names or []): self.addInstrumentByName(name, fake=fake)
[docs] def clearInstruments(self) -> None: '''Remove and schedule deletion of all instrument widgets.''' while self._slots.count(): item = self._slots.takeAt(0) if item.widget(): item.widget().deleteLater()
@classmethod def _instrumentPaths(cls): '''Yield ``(manufacturer, instrument_name)`` for all widget packages. Scans ``instruments/`` two levels deep for subdirectories that contain a ``widget.py``. ''' instruments_dir = Path(__file__).parent / 'instruments' for mfr in instruments_dir.iterdir(): if not mfr.is_dir() or mfr.name.startswith('_'): continue for inst in mfr.iterdir(): if inst.is_dir() and (inst / 'widget.py').exists(): yield mfr.name, inst.name
[docs] @classmethod def availableInstruments(cls) -> list[str]: '''Return names of all instruments that have a widget module. Returns ------- list[str] Sorted list of bare instrument names. ''' return sorted(name for _, name in cls._instrumentPaths())
@classmethod def _findInstrumentModule(cls, name: str) -> str | None: '''Resolve a bare instrument name to its full module path. Parameters ---------- name : str Bare instrument name (e.g. ``'DS345'``). Returns ------- str | None Dotted module path for the widget module, or ``None`` if not found. ''' return next( (f'QInstrument.instruments.{mfr}.{name}.widget' for mfr, inst in cls._instrumentPaths() if inst == name), None) def _removeInstrument(self, name: str) -> None: for i in range(self._slots.count()): if (slot := self._slotAt(i)) is not None and slot._name == name: self._slots.takeAt(i).widget().deleteLater() self.adjustSize() break def _hoverSlot(self, slot: '_InstrumentSlot', hover_pos: QtCore.QPoint) -> None: '''Highlight the slot under the cursor during a drag. Connected to each slot's :attr:`hoverRequested` signal. Hit-tests all slot geometries against *hover_pos* and calls :meth:`_InstrumentSlot.setHighlighted` accordingly, skipping the slot being dragged. Parameters ---------- slot : _InstrumentSlot The slot whose drag handle is being held. hover_pos : QtCore.QPoint Current global cursor position. ''' local_pos = self.mapFromGlobal(hover_pos) target = next( (w for w in self._iterSlots() if w.geometry().contains(local_pos)), None) for s in self._iterSlots(): s.setHighlighted(s is target and s is not slot) def _moveSlot(self, slot: '_InstrumentSlot', drop_pos: QtCore.QPoint) -> None: '''Move *slot* to the position of the slot under the drop point. Connected to each slot's :attr:`dropRequested` signal. Clears all highlights, then hit-tests slot geometries against *drop_pos*. If a different slot is found at that position, removes *slot* from the layout and inserts it at the target's index using :meth:`QVBoxLayout.removeWidget` / :meth:`QVBoxLayout.insertWidget` — no ownership transfer or reparenting occurs. Parameters ---------- slot : _InstrumentSlot The slot to move. drop_pos : QtCore.QPoint Global cursor position at the moment of release. ''' for s in self._iterSlots(): s.setHighlighted(False) local_pos = self.mapFromGlobal(drop_pos) target = next( (w for w in self._iterSlots() if w.geometry().contains(local_pos)), None) if target is None or target is slot: return target_index = self._slots.indexOf(target) self._slots.removeWidget(slot) self._slots.insertWidget(target_index, slot) def _addInstrumentDialog(self) -> None: available = self.availableInstruments() if not available: return picker = _InstrumentPicker(available, self) DD = QtWidgets.QDialog.DialogCode if picker.exec() == DD.Accepted: name = picker.selected() if name: self.addInstrumentByName(name, fake=self._fake)
[docs] def showEvent(self, event) -> None: '''Restore the instrument list on first show. On the first show, if no instruments were loaded at construction, calls :meth:`Configure.restore` to reload the previously saved instrument list. ''' if not self._shown: self._shown = True if not self._slots.count(): self._configure.restore(self) super().showEvent(event)
[docs] def closeEvent(self, event) -> None: '''Save the instrument list when the widget is closed. Persists the current instrument list to ``~/.QInstrument/QInstrumentRack.json``. Only saves if the widget was previously shown, so test widgets closed during teardown do not overwrite saved configuration. ''' if self._shown: self._configure.save(self) super().closeEvent(event)
[docs] @classmethod def example(cls) -> None: '''Display a rack populated with example instruments. Accepts ``-f`` / ``--fake`` on the command line to load fake devices instead of probing for real hardware. ''' import sys import argparse from qtpy.QtWidgets import QApplication parser = argparse.ArgumentParser() parser.add_argument('-f', '--fake', action='store_true', help='use fake instruments') args, _ = parser.parse_known_args() app = QApplication.instance() or QApplication(sys.argv) rack = cls(instruments='Proscan DS345 SR830'.split(), fake=args.fake) rack.show() sys.exit(app.exec())
if __name__ == '__main__': QInstrumentRack.example() __all__ = ['QInstrumentRack']