diff --git a/bec_widgets/applications/main_app.py b/bec_widgets/applications/main_app.py
index e238599d..11c24bcf 100644
--- a/bec_widgets/applications/main_app.py
+++ b/bec_widgets/applications/main_app.py
@@ -1,5 +1,7 @@
from bec_widgets.applications.startup_profiler import startup_profiler # isort: skip
+import os
+
from bec_qthemes import material_icon
from qtpy.QtCore import QTimer
from qtpy.QtGui import QAction # type: ignore
@@ -94,6 +96,7 @@ class BECMainApp(BECMainWindow):
widget=self.device_manager,
mini_text="DM",
)
+ self._add_device_views()
if is_experimental_features_enabled():
self.developer_view = DeveloperView(self)
self.add_view(
@@ -149,6 +152,47 @@ class BECMainApp(BECMainWindow):
def add_dark_mode_item(self, id: str = "dark_mode", position: int | None = None):
return self.sidebar.add_dark_mode_item(id=id, position=position)
+ def _add_device_views(self):
+ """Add the Devices and Config views when BEC_DEVICE_VIEWS is "qml" or "qwidget".
+
+ The Device Manager stays as it is; the two views are an opt-in preview of the split.
+ """
+ flavor = os.environ.get("BEC_DEVICE_VIEWS", "").strip().lower()
+ if flavor not in ("qml", "qwidget"):
+ return
+ if flavor == "qml":
+ from bec_widgets.applications.views.devices_views.devices_qml import (
+ DeviceConfigViewQML as ConfigView,
+ )
+ from bec_widgets.applications.views.devices_views.devices_qml import (
+ DevicesViewQML as DevicesView,
+ )
+ else:
+ from bec_widgets.applications.views.devices_views.devices_qwidget import (
+ DeviceConfigViewQWidget as ConfigView,
+ )
+ from bec_widgets.applications.views.devices_views.devices_qwidget import (
+ DevicesViewQWidget as DevicesView,
+ )
+ self.devices_view = DevicesView(parent=self)
+ self.devices_view.browser.open_in_workspace.connect(self._open_device_in_workspace)
+ self.device_config_view = ConfigView(parent=self)
+ self.add_view(icon="memory", title="Devices", widget=self.devices_view, mini_text="Devices")
+ self.add_view(
+ icon="tune", title="Device Config", widget=self.device_config_view, mini_text="Config"
+ )
+
+ def _open_device_in_workspace(self, widget_name: str, device: str):
+ """Open ``device`` in a new dock of the workspace and switch to it."""
+ widget = self.dock_area.dock_area.new(widget_name)
+ if widget_name == "PositionerBox":
+ widget.set_positioner(device)
+ elif widget_name == "Image":
+ widget.image(device=device)
+ elif widget_name == "Waveform":
+ widget.plot(device_y=device)
+ self.set_current("dock_area")
+
def add_view(
self,
*,
diff --git a/bec_widgets/applications/views/devices_views/__init__.py b/bec_widgets/applications/views/devices_views/__init__.py
new file mode 100644
index 00000000..fe834e5c
--- /dev/null
+++ b/bec_widgets/applications/views/devices_views/__init__.py
@@ -0,0 +1,9 @@
+"""The device manager split into two views, as proposed in the bec-app UX audit.
+
+* **Devices** (everyone): search, read and move devices of the running session.
+* **Config** (staff): edit a copy of the session config, review exactly what differs from the
+ running session and apply only those changes.
+
+Both views exist as a QML and a QWidget implementation on top of the same controllers in
+:mod:`devices_core`. The legacy :class:`DeviceManagerView` is kept untouched.
+"""
diff --git a/bec_widgets/applications/views/devices_views/compare_devices_views.py b/bec_widgets/applications/views/devices_views/compare_devices_views.py
new file mode 100644
index 00000000..6910ca12
--- /dev/null
+++ b/bec_widgets/applications/views/devices_views/compare_devices_views.py
@@ -0,0 +1,286 @@
+"""Side-by-side demo, screenshots and timings of the Devices and Config views.
+
+Runs on a fake BEC session (fakeredis) holding the 215 devices of BEC's ``demo_config.yaml``, so it
+needs no running BEC::
+
+ python -m bec_widgets.applications.views.devices_views.compare_devices_views --view qml
+ python -m bec_widgets.applications.views.devices_views.compare_devices_views \
+ --screenshots ./shots --theme dark
+ python -m bec_widgets.applications.views.devices_views.compare_devices_views --bench
+
+``--view`` is ``qml``, ``qwidget`` or ``legacy`` (today's Device Manager). Requires the ``dev``
+extra (fakeredis).
+"""
+
+from __future__ import annotations
+
+import argparse
+import json
+import math
+import os
+import resource
+import sys
+import time
+from pathlib import Path
+from unittest import mock
+
+from bec_lib.bec_yaml_loader import yaml_load
+from bec_lib.device import ReadoutPriority
+from bec_lib.endpoints import MessageEndpoints
+from bec_lib.messages import DeviceMessage, ScanStatusMessage
+
+DEMO_CONFIG = Path(__file__).resolve().parents[5] / "bec" / "bec_lib" / "bec_lib" / "configs"
+
+
+def _demo_config_path() -> Path:
+ try:
+ import bec_lib.configs # pylint: disable=import-outside-toplevel
+
+ return Path(bec_lib.configs.__file__).parent / "demo_config.yaml"
+ except ImportError: # pragma: no cover
+ return DEMO_CONFIG / "demo_config.yaml"
+
+
+def fake_session():
+ """Return a BEC dispatcher whose client holds the demo config devices on fakeredis."""
+ from bec_widgets.tests.fake_devices import ( # pylint: disable=import-outside-toplevel
+ FakeDevice,
+ FakePositioner,
+ )
+ from bec_widgets.tests.utils import mock_client # pylint: disable=import-outside-toplevel
+ from bec_widgets.utils import bec_dispatcher as dispatcher_module # pylint: disable=C0415
+
+ with mock.patch.object(dispatcher_module, "BECClient", mock_client):
+ dispatcher = dispatcher_module.BECDispatcher()
+ client = dispatcher.client
+ devices = client.device_manager.devices
+ for name in list(devices):
+ devices.pop(name, None)
+ config = yaml_load(str(_demo_config_path()))
+ for i, (name, cfg) in enumerate(config.items()):
+ cfg = dict(cfg)
+ cfg["deviceConfig"] = cfg.get("deviceConfig") or {}
+ cls = str(cfg.get("deviceClass", ""))
+ priority = ReadoutPriority(cfg.get("readoutPriority", "baseline"))
+ if "Positioner" in cls or "Motor" in cls:
+ limits = cfg["deviceConfig"].get("limits", [-50, 50])
+ dev = FakePositioner(name, limits=limits, read_value=round(math.sin(i) * 5, 3))
+ dev._readout_priority = priority # pylint: disable=protected-access
+ else:
+ dev = FakeDevice(name, readout_priority=priority)
+ dev._config = {"name": name, **cfg} # pylint: disable=protected-access
+ devices[name] = dev
+ value = round(math.sin(i) * 5, 3) if isinstance(dev, FakePositioner) else 1000 + i * 7.5
+ client.connector.set_and_publish(
+ MessageEndpoints.device_readback(name),
+ DeviceMessage(signals={name: {"value": value}}, metadata={}),
+ )
+ return dispatcher
+
+
+def demo_edits(editor) -> None:
+ """The three edits of the audit mockup, a device that cannot connect, a scan running."""
+ work = editor.work
+ if "samx" in work:
+ editor.set_field("samx", "readoutPriority", "monitored")
+ if "eiger" in work:
+ editor.set_field("eiger", "enabled", False)
+ editor.add_device("newmot", "ophyd.EpicsMotor", "X05LA-ES-PH:Z")
+ editor.set_field("newmot", "description", "Pinhole Z (new)")
+ editor.add_tag("newmot", "user motors")
+ if "bpm5i" in work:
+ editor.status["bpm5i"] = "unreach"
+ editor.messages["bpm5i"] = (
+ "Cannot connect — readback timed out after 5 s (IOC X05LA-BPM5 not responding)"
+ )
+ editor.undo_stack.clear()
+ editor.select("samx")
+
+
+def build(view: str, page: str, client):
+ """Create one view: ``page`` is ``devices`` or ``config``."""
+ # pylint: disable=import-outside-toplevel
+ if view == "legacy":
+ from bec_widgets.applications.views.device_manager_view.device_manager_widget import (
+ DeviceManagerWidget,
+ )
+
+ widget = DeviceManagerWidget(client=client)
+ widget._load_config_clicked() # pylint: disable=protected-access
+ return widget
+ if view == "qml":
+ from bec_widgets.applications.views.devices_views.devices_qml import (
+ DeviceConfigViewQML as ConfigView,
+ )
+ from bec_widgets.applications.views.devices_views.devices_qml import (
+ DevicesViewQML as DevicesView,
+ )
+ else:
+ from bec_widgets.applications.views.devices_views.devices_qwidget import (
+ DeviceConfigViewQWidget as ConfigView,
+ )
+ from bec_widgets.applications.views.devices_views.devices_qwidget import (
+ DevicesViewQWidget as DevicesView,
+ )
+ if page == "devices":
+ widget = DevicesView(client=client)
+ widget.browser.select("samx")
+ return widget
+ widget = ConfigView(client=client)
+ demo_edits(widget.editor)
+ return widget
+
+
+def _settle(app, ms: int = 300) -> None:
+ end = time.perf_counter() + ms / 1000
+ while time.perf_counter() < end:
+ app.processEvents()
+ time.sleep(0.01)
+
+
+def screenshots(app, client, out: Path, theme: str) -> None:
+ """Grab every view in one theme into ``out``."""
+ from bec_qthemes import apply_theme # pylint: disable=import-outside-toplevel
+
+ apply_theme(theme)
+ out.mkdir(parents=True, exist_ok=True)
+ jobs = [("legacy", "config")] + [
+ (v, p) for v in ("qml", "qwidget") for p in ("devices", "config")
+ ]
+ for view, page in jobs:
+ widget = build(view, page, client)
+ widget.resize(1500, 900)
+ widget.show()
+ _settle(app, 900)
+ widget.grab().save(str(out / f"{theme}_{view}_{page}.png"))
+ if view != "legacy" and page == "config":
+ sheet = widget.open_review()
+ _settle(app, 500)
+ grab_sheet(widget, sheet).save(str(out / f"{theme}_{view}_review.png"))
+ close_sheet(widget, sheet)
+ widget.editor.set_query("sam")
+ widget.editor.toggle_facet("st", "changed", True)
+ _settle(app, 400)
+ widget.grab().save(str(out / f"{theme}_{view}_config_filtered.png"))
+ if view != "legacy" and page == "devices":
+ widget.browser.set_kind("monitor")
+ widget.browser.select("bpm4i")
+ for k in range(60):
+ widget.browser.on_readback(
+ {"signals": {"bpm4i": {"value": 1000 + 40 * math.sin(k / 6) + k}}}, {}
+ )
+ _settle(app, 600)
+ widget.grab().save(str(out / f"{theme}_{view}_devices_monitor.png"))
+ widget.close()
+ widget.deleteLater()
+ _settle(app, 200)
+
+
+def grab_sheet(widget, sheet):
+ """Picture of a sheet: QWidget dialogs grab themselves, QML popups are inside the view."""
+ if hasattr(sheet, "grab"):
+ return sheet.grab()
+ return widget.grab()
+
+
+def close_sheet(widget, sheet) -> None:
+ """Close a sheet opened with ``open_review``."""
+ if hasattr(sheet, "reject"):
+ sheet.reject()
+ else:
+ widget.close_sheet()
+
+
+def bench(app, client) -> dict:
+ """First show, second instance, edit-to-repaint and filter timings plus memory."""
+ results = {}
+ for view in ("legacy", "qml", "qwidget"):
+ for page in ("devices", "config") if view != "legacy" else ("config",):
+ rss0 = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
+ t0 = time.perf_counter()
+ widget = build(view, page, client)
+ widget.resize(1500, 900)
+ widget.show()
+ app.processEvents()
+ first = (time.perf_counter() - t0) * 1000
+ _settle(app, 300)
+ rss1 = resource.getrusage(resource.RUSAGE_SELF).ru_maxrss / 1024
+ t0 = time.perf_counter()
+ second = build(view, page, client)
+ second.resize(1500, 900)
+ second.show()
+ app.processEvents()
+ again = (time.perf_counter() - t0) * 1000
+ entry = {
+ "first_show_ms": round(first, 1),
+ "second_show_ms": round(again, 1),
+ "rss_growth_mb": round(rss1 - rss0, 1),
+ }
+ if view != "legacy" and page == "config":
+ editor = widget.editor
+ t0 = time.perf_counter()
+ for k in range(20):
+ editor.set_field("samy", "description", f"edit {k}")
+ app.processEvents()
+ entry["edit_ms"] = round((time.perf_counter() - t0) * 1000 / 20, 2)
+ t0 = time.perf_counter()
+ for q in ("s", "sa", "sam", "samx", ""):
+ editor.set_query(q)
+ app.processEvents()
+ entry["filter_ms"] = round((time.perf_counter() - t0) * 1000 / 5, 2)
+ if view != "legacy" and page == "devices":
+ browser = widget.browser
+ browser.set_kind("any")
+ app.processEvents()
+ t0 = time.perf_counter()
+ for k in range(50):
+ for name in list(browser.values)[:150]:
+ browser.on_readback({"signals": {name: {"value": k}}}, {})
+ browser._flush_values() # pylint: disable=protected-access
+ app.processEvents()
+ entry["refresh_150_values_ms"] = round((time.perf_counter() - t0) * 1000 / 50, 2)
+ results[f"{view}/{page}"] = entry
+ for w in (widget, second):
+ w.close()
+ w.deleteLater()
+ _settle(app, 200)
+ return results
+
+
+def main() -> None:
+ """Command-line entry point."""
+ parser = argparse.ArgumentParser(description=__doc__.splitlines()[0])
+ parser.add_argument("--view", choices=["qml", "qwidget", "legacy"], default="qml")
+ parser.add_argument("--page", choices=["devices", "config"], default="config")
+ parser.add_argument("--theme", choices=["light", "dark"], default="dark")
+ parser.add_argument("--screenshots", type=Path)
+ parser.add_argument("--bench", action="store_true")
+ args = parser.parse_args()
+
+ os.environ.setdefault("OPHYD_CONTROL_LAYER", "dummy")
+ from bec_qthemes import apply_theme # pylint: disable=import-outside-toplevel
+ from qtpy.QtWidgets import QApplication # pylint: disable=import-outside-toplevel
+
+ app = QApplication.instance() or QApplication(sys.argv)
+ apply_theme(args.theme)
+ dispatcher = fake_session()
+ client = dispatcher.client
+ client.connector.set_and_publish(
+ MessageEndpoints.scan_status(),
+ ScanStatusMessage(scan_id="x", status="closed", info={}, scan_number=1285),
+ )
+ if args.screenshots:
+ for theme in ("light", "dark"):
+ screenshots(app, client, args.screenshots, theme)
+ if args.bench:
+ print(json.dumps(bench(app, client), indent=2))
+ if args.screenshots or args.bench:
+ os._exit(0) # pylint: disable=protected-access
+ widget = build(args.view, args.page, client)
+ widget.resize(1500, 900)
+ widget.show()
+ sys.exit(app.exec())
+
+
+if __name__ == "__main__": # pragma: no cover
+ main()
diff --git a/bec_widgets/applications/views/devices_views/devices_core.py b/bec_widgets/applications/views/devices_views/devices_core.py
new file mode 100644
index 00000000..da62c8e7
--- /dev/null
+++ b/bec_widgets/applications/views/devices_views/devices_core.py
@@ -0,0 +1,1237 @@
+"""Toolkit-independent state of the two device views.
+
+:class:`ConfigEditor` drives the *Config* view: it keeps the running session config and an edited
+copy, computes the field-level difference between them, tests devices and applies only the
+differences, device by device. :class:`DeviceBrowser` drives the read-only *Devices* view: device
+search by kind and live values. The QML and QWidget views only render what these controllers
+expose and call their methods, so both look and behave the same.
+"""
+
+from __future__ import annotations
+
+import copy
+import importlib
+import inspect
+from collections import deque
+from dataclasses import dataclass, field
+from pathlib import Path
+from typing import Any
+
+import yaml
+from bec_lib import config_helper
+from bec_lib.bec_yaml_loader import yaml_load
+from bec_lib.device import Positioner
+from bec_lib.endpoints import MessageEndpoints
+from bec_lib.logger import bec_logger
+from qtpy.QtCore import QObject, QRunnable, QThreadPool, QTimer, Signal
+
+logger = bec_logger.logger
+
+READOUT_LABELS = {
+ "monitored": "Monitored · every point",
+ "baseline": "Baseline · once per scan",
+ "async": "Async · detector-paced",
+ "continuous": "Continuous",
+ "on_request": "On request",
+}
+READOUT_HINT = (
+ "When BEC reads it: at every scan point, once per scan, or when the detector says so."
+)
+ON_FAILURE_LABELS = {"retry": "Retry once", "buffer": "Use last value", "raise": "Fail immediately"}
+
+# status key -> (label, tone, material icon). Tones: ok, neutral, stale, busy, warn, err.
+STATUS = {
+ "session": ("Valid · in session", "neutral", "check"),
+ "valid": ("Valid", "ok", "check"),
+ "unchecked": ("Not checked", "stale", "help"),
+ "testing": ("Testing…", "busy", "sync"),
+ "unreach": ("Unreachable", "err", "power_off"),
+ "invalid": ("Invalid config", "err", "error"),
+}
+PROBLEM_STATES = ("unreach", "invalid")
+CHANGE_LABELS = {"A": "Added", "M": "Modified", "D": "Removed"}
+CHANGE_TONES = {"A": "ok", "M": "warn", "D": "err"}
+
+# Keys the server can update in place; changing anything else needs remove + add.
+UPDATABLE = set(config_helper._ConfigConstants.UPDATABLE) # pylint: disable=protected-access
+EDITABLE_FIELDS = ("readoutPriority", "enabled", "onFailure", "description", "readOnly")
+DEFAULTS = {
+ "enabled": True,
+ "readOnly": False,
+ "softwareTrigger": False,
+ "onFailure": "retry",
+ "description": "",
+ "deviceTags": [],
+ "deviceConfig": {},
+}
+DEVICE_CLASSES = ["ophyd.EpicsMotor", "ophyd.EpicsSignal", "ophyd.EpicsSignalRO"] + [
+ f"ophyd_devices.{name}" for name in ("SimPositioner", "SimMonitor", "SimCamera")
+]
+NEW_NAME_RULE = "Lower case, letters, digits and _ (starting with a letter)"
+
+
+# --------------------------------------------------------------------------------------- helpers
+def normalize_config(name: str, cfg: dict) -> dict:
+ """Return a deep copy of ``cfg`` with defaults filled in and tags as a sorted list.
+
+ Unknown keys such as ``needs`` or ``connectionTimeout`` are kept as they are.
+ """
+ out = copy.deepcopy(dict(cfg))
+ out["name"] = name
+ for key, value in DEFAULTS.items():
+ if out.get(key) is None:
+ out[key] = copy.deepcopy(value)
+ out["deviceTags"] = sorted(str(t) for t in (out.get("deviceTags") or []))
+ out["deviceConfig"] = dict(out.get("deviceConfig") or {})
+ return out
+
+
+def configs_by_name(configs: list[dict] | dict) -> dict[str, dict]:
+ """Turn a config list or a ``{name: cfg}`` mapping into normalised ``{name: cfg}``."""
+ if isinstance(configs, dict):
+ items = list(configs.items())
+ else:
+ items = [(cfg.get("name"), cfg) for cfg in configs]
+ return {name: normalize_config(name, cfg) for name, cfg in items if name}
+
+
+def short_class(device_class: str) -> str:
+ """``ophyd_devices.sim.SimPositioner`` -> ``SimPositioner``."""
+ return str(device_class or "").rsplit(".", 1)[-1]
+
+
+def value_text(value: Any) -> str:
+ """Compact, stable text of a config value for tables and diffs."""
+ if isinstance(value, str):
+ return value
+ return (
+ yaml.safe_dump(value, default_flow_style=True, sort_keys=False)
+ .strip()
+ .removesuffix("\n...")
+ )
+
+
+def parse_value(text: str) -> Any:
+ """Parse a value typed into a config field the way YAML would (``1,5`` reads as 1.5)."""
+ text = text.strip()
+ if not text:
+ return None
+ candidate = text.replace(",", ".") if _looks_like_decimal_comma(text) else text
+ try:
+ return yaml.safe_load(candidate)
+ except yaml.YAMLError:
+ return text
+
+
+def _looks_like_decimal_comma(text: str) -> bool:
+ head, sep, tail = text.lstrip("-").partition(",")
+ return bool(sep) and head.isdigit() and tail.isdigit()
+
+
+def diff_device(old: dict, new: dict) -> list[tuple[str, str, str]]:
+ """Field-level difference ``[(field, old_text, new_text)]`` of two normalised configs.
+
+ ``deviceConfig`` is compared key by key (``deviceConfig.limits``); every other key, including
+ keys the editor does not know, is compared as a whole.
+ """
+ fields: list[tuple[str, str, str]] = []
+ keys = [k for k in dict.fromkeys([*old, *new]) if k != "name"]
+ for key in keys:
+ if key == "deviceConfig":
+ a, b = old.get(key) or {}, new.get(key) or {}
+ for sub in dict.fromkeys([*a, *b]):
+ if a.get(sub, _MISSING) != b.get(sub, _MISSING):
+ fields.append(
+ (f"deviceConfig.{sub}", _text_or_dash(a, sub), _text_or_dash(b, sub))
+ )
+ elif old.get(key, _MISSING) != new.get(key, _MISSING):
+ fields.append((key, _text_or_dash(old, key), _text_or_dash(new, key)))
+ return fields
+
+
+_MISSING = object()
+
+
+def _text_or_dash(data: dict, key: str) -> str:
+ return value_text(data[key]) if key in data else "—"
+
+
+@dataclass
+class Change:
+ """One device that differs between the running session and the edited copy."""
+
+ kind: str # "A" added, "M" modified, "D" removed
+ name: str
+ fields: list[tuple[str, str, str]] = field(default_factory=list)
+
+
+def diff_configs(session: dict[str, dict], work: dict[str, dict]) -> list[Change]:
+ """All differences between ``session`` and ``work``, in the order of the edited copy."""
+ changes: list[Change] = []
+ for name, cfg in work.items():
+ old = session.get(name)
+ if old is None:
+ changes.append(Change("A", name))
+ continue
+ fields = diff_device(old, cfg)
+ if fields:
+ changes.append(Change("M", name, fields))
+ changes.extend(Change("D", name) for name in session if name not in work)
+ return changes
+
+
+def plan_requests(change: Change, session: dict[str, dict], work: dict[str, dict]) -> list:
+ """Config requests ``[(action, {name: cfg})]`` that apply one change to the session.
+
+ Modifications of updatable keys are sent as one ``update`` with only the changed keys; a
+ changed device class, a removed ``deviceConfig`` key or any other key re-creates the device.
+ """
+
+ def payload(cfg: dict) -> dict:
+ return {k: copy.deepcopy(v) for k, v in cfg.items() if k != "name"}
+
+ name = change.name
+ if change.kind == "A":
+ return [("add", {name: payload(work[name])})]
+ if change.kind == "D":
+ return [("remove", {name: {}})]
+ old, new = session[name], work[name]
+ top_keys = list(dict.fromkeys(f.split(".", 1)[0] for f, _, _ in change.fields))
+ removed_sub = set(old.get("deviceConfig", {})) - set(new.get("deviceConfig", {}))
+ if removed_sub or any(key not in UPDATABLE for key in top_keys):
+ return [("remove", {name: {}}), ("add", {name: payload(new)})]
+ return [("update", {name: {key: copy.deepcopy(new.get(key)) for key in top_keys}})]
+
+
+def device_yaml(cfg: dict) -> str:
+ """YAML block of one device, without keys that only repeat defaults."""
+ body = {
+ k: v
+ for k, v in cfg.items()
+ if k != "name" and not (k in DEFAULTS and v == DEFAULTS[k] and k != "enabled")
+ }
+ return yaml.safe_dump({cfg["name"]: body}, sort_keys=False, default_flow_style=False)
+
+
+def class_docstring(device_class: str) -> str:
+ """First paragraphs of the device class docstring, or a short note if it cannot load."""
+ module_name, _, cls_name = str(device_class).rpartition(".")
+ try:
+ cls = getattr(importlib.import_module(module_name), cls_name)
+ except Exception: # pylint: disable=broad-except
+ return f"The class {device_class} could not be imported here, so no documentation."
+ return inspect.getdoc(cls) or f"{cls_name} has no docstring."
+
+
+# --------------------------------------------------------------------------------------- workers
+class _WorkerSignals(QObject):
+ progress = Signal(str, str, str) # device name, state, message
+ finished = Signal(int, int) # applied, failed
+ tested = Signal(str, str, str) # device name, status key, message
+ done = Signal(object) # generic result
+
+
+class ApplyWorker(QRunnable):
+ """Apply a list of changes to the running session, one device at a time."""
+
+ def __init__(self, helper, requests: list[tuple[str, list]]):
+ super().__init__()
+ self.helper = helper
+ self.requests = requests
+ self.signals = _WorkerSignals()
+
+ def run(self): # pragma: no cover - needs a running BEC
+ applied = failed = 0
+ for name, steps in self.requests:
+ self.signals.progress.emit(name, "running", "")
+ try:
+ for action, config in steps:
+ self.helper.send_config_request(
+ action=action, config=config, wait_for_response=True
+ )
+ except Exception as exc: # pylint: disable=broad-except
+ failed += 1
+ self.signals.progress.emit(name, "failed", str(exc).strip().splitlines()[-1])
+ else:
+ applied += 1
+ self.signals.progress.emit(name, "done", "")
+ self.signals.finished.emit(applied, failed)
+
+
+class TestWorker(QRunnable):
+ """Run the ophyd static device test (config + connection) for one device config."""
+
+ def __init__(self, cfg: dict, timeout: float = 5.0):
+ super().__init__()
+ self.cfg = cfg
+ self.timeout = timeout
+ self.signals = _WorkerSignals()
+
+ def run(self): # pragma: no cover - needs ophyd_devices and hardware or simulation
+ name = self.cfg["name"]
+ try:
+ from ophyd_devices.utils.static_device_test import StaticDeviceTest
+ except ImportError:
+ self.signals.tested.emit(name, "unchecked", "ophyd_devices is not installed here")
+ return
+ try:
+ body = {k: v for k, v in self.cfg.items() if k != "name"}
+ tester = StaticDeviceTest(config_dict={name: body})
+ results = tester.run_with_list_output(
+ connect=True, force_connect=False, timeout_per_device=self.timeout
+ )
+ result = results[0]
+ if not result.config_is_valid:
+ self.signals.tested.emit(name, "invalid", result.message or "")
+ elif not result.success:
+ self.signals.tested.emit(name, "unreach", result.message or "")
+ else:
+ self.signals.tested.emit(name, "valid", "")
+ except Exception as exc: # pylint: disable=broad-except
+ self.signals.tested.emit(name, "invalid", str(exc))
+
+
+class CallWorker(QRunnable):
+ """Run ``fn`` off the GUI thread and deliver its result (or exception) via ``signals.done``."""
+
+ def __init__(self, fn):
+ super().__init__()
+ self.fn = fn
+ self.signals = _WorkerSignals()
+
+ def run(self):
+ try:
+ result = self.fn()
+ except Exception as exc: # pylint: disable=broad-except
+ result = exc
+ self.signals.done.emit(result)
+
+
+# ----------------------------------------------------------------------------------- Config view
+class ConfigEditor(QObject):
+ """State of the *Config* view: an edited copy of the session config, reviewed and applied.
+
+ Args:
+ client: The BEC client.
+ dispatcher: The BEC dispatcher used for the scan-status subscription.
+ parent: Parent QObject.
+ """
+
+ changed = Signal()
+ toast = Signal(str, str, bool) # message, tone, offers undo
+ apply_progress = Signal(str, str, str) # device, state (waiting/running/done/failed), message
+ apply_finished = Signal(int, int)
+
+ def __init__(self, client, dispatcher=None, parent: QObject | None = None):
+ super().__init__(parent)
+ self.client = client
+ self.dispatcher = dispatcher
+ self.session: dict[str, dict] = {}
+ self.work: dict[str, dict] = {}
+ self.status: dict[str, str] = {}
+ self.messages: dict[str, str] = {}
+ self.source = "running session"
+ self.selected: str | None = None
+ self.query = ""
+ self.facets: dict[str, set[str]] = {"st": set(), "rp": set(), "tag": set()}
+ self.undo_stack: list[tuple[dict, dict]] = []
+ self.scan_running = False
+ self.scan_number: int | None = None
+ self.applying = False
+ self.apply_rows: list[dict] = []
+ self.apply_summary = ""
+ self._changes: list[Change] | None = None
+ self._workers: list[QRunnable] = []
+ self._helper = None
+ if dispatcher is not None:
+ dispatcher.connect_slot(self.on_scan_status, MessageEndpoints.scan_status())
+ msg = client.connector.get(MessageEndpoints.scan_status())
+ if msg is not None:
+ self.on_scan_status(msg.content, msg.metadata)
+
+ # ------------------------------------------------------------------ loading and saving
+ def load_session(self) -> None:
+ """Start a fresh edited copy from the running session."""
+ configs = self.client.device_manager._get_redis_device_config() or []
+ self.session = configs_by_name(configs)
+ self.work = copy.deepcopy(self.session)
+ self.status = {name: "session" for name in self.work}
+ self.messages = {}
+ self.source = "running session"
+ self.undo_stack.clear()
+ if self.selected not in self.work:
+ self.selected = next(iter(self.work), None)
+ self._touch()
+
+ def refresh_session(self) -> None:
+ """Re-read the running session but keep the edited copy (e.g. after applying)."""
+ configs = self.client.device_manager._get_redis_device_config() or []
+ self.session = configs_by_name(configs)
+ self._restatus()
+ self._touch()
+
+ def open_file(self, path: str | Path) -> None:
+ """Edit the devices of a YAML config file; the diff stays against the running session."""
+ loaded = yaml_load(str(path)) or {}
+ self._push_undo()
+ self.work = configs_by_name(loaded)
+ self.source = Path(path).name
+ self._restatus(reset=True)
+ if self.selected not in self.work:
+ self.selected = next(iter(self.work), None)
+ self._touch()
+ self.toast.emit(f"Opened {self.source} — {len(self.work)} devices", "neutral", True)
+
+ def save_file(self, path: str | Path) -> None:
+ """Write the edited copy to ``path`` as YAML."""
+ data = {
+ name: {k: v for k, v in cfg.items() if k != "name"} for name, cfg in self.work.items()
+ }
+ Path(path).write_text(yaml.safe_dump(data, sort_keys=False), encoding="utf-8")
+ self.toast.emit(f"Saved {len(data)} devices to {Path(path).name}", "ok", False)
+
+ # ------------------------------------------------------------------ derived state
+ def changes(self) -> list[Change]:
+ """Cached differences between session and edited copy."""
+ if self._changes is None:
+ self._changes = diff_configs(self.session, self.work)
+ return self._changes
+
+ def change_kinds(self) -> dict[str, str]:
+ """``{device: "A" | "M" | "D"}``."""
+ return {c.name: c.kind for c in self.changes()}
+
+ def status_of(self, name: str) -> tuple[str, str, str]:
+ """``(label, tone, icon)`` of a device's status."""
+ return STATUS[self.status.get(name, "unchecked")]
+
+ def matches(self, cfg: dict, kinds: dict[str, str]) -> bool:
+ """True if ``cfg`` passes the search text and the facet filters."""
+ q = self.query.strip().lower()
+ if q:
+ hay = " ".join(
+ [
+ cfg["name"],
+ str(cfg.get("deviceClass", "")),
+ " ".join(cfg.get("deviceTags", [])),
+ str(cfg.get("deviceConfig", {}).get("prefix", "")),
+ str(cfg.get("description", "")),
+ ]
+ ).lower()
+ if q not in hay:
+ return False
+ if self.facets["rp"] and cfg.get("readoutPriority") not in self.facets["rp"]:
+ return False
+ if self.facets["tag"] and not set(cfg.get("deviceTags", [])) & self.facets["tag"]:
+ return False
+ if self.facets["st"]:
+ st = self.status.get(cfg["name"])
+ ok = (
+ ("changed" in self.facets["st"] and cfg["name"] in kinds)
+ or ("problem" in self.facets["st"] and st in PROBLEM_STATES)
+ or ("unchecked" in self.facets["st"] and st == "unchecked")
+ )
+ if not ok:
+ return False
+ return True
+
+ def rows(self) -> list[dict]:
+ """Table rows of the devices that pass the filters."""
+ kinds = self.change_kinds()
+ out = []
+ for name, cfg in self.work.items():
+ if not self.matches(cfg, kinds):
+ continue
+ label, tone, icon = self.status_of(name)
+ old = self.session.get(name)
+ rp_was = ""
+ if old is not None and old.get("readoutPriority") != cfg.get("readoutPriority"):
+ rp_was = f"was {old.get('readoutPriority')}"
+ out.append(
+ {
+ "name": name,
+ "deviceClass": short_class(cfg.get("deviceClass")),
+ "readout": str(cfg.get("readoutPriority", "")),
+ "readoutWas": rp_was,
+ "on": bool(cfg.get("enabled", True)),
+ "tags": ", ".join(cfg.get("deviceTags", [])),
+ "change": kinds.get(name, ""),
+ "changeLabel": CHANGE_LABELS.get(kinds.get(name, ""), ""),
+ "statusText": label,
+ "statusTone": tone,
+ "statusIcon": icon,
+ "selected": name == self.selected,
+ }
+ )
+ return out
+
+ def filters_active(self) -> bool:
+ """True while a search text or facet narrows the table."""
+ return bool(self.query.strip() or any(self.facets.values()))
+
+ def facet_sections(self) -> list[dict]:
+ """Facet groups for the filter column.
+
+ Returns ``[{title, key, items: [{value, label, count, checked}]}]``.
+ """
+ all_cfg = list(self.work.values())
+ problems = sum(1 for n in self.work if self.status.get(n) in PROBLEM_STATES)
+ unchecked = sum(1 for n in self.work if self.status.get(n) == "unchecked")
+ status_items = [
+ ("changed", "Changed vs session", len(self.changes())),
+ ("problem", "Problems", problems),
+ ("unchecked", "Not checked yet", unchecked),
+ ]
+ readouts: dict[str, int] = {}
+ tags: dict[str, int] = {}
+ for cfg in all_cfg:
+ rp = str(cfg.get("readoutPriority", ""))
+ readouts[rp] = readouts.get(rp, 0) + 1
+ for tag in cfg.get("deviceTags", []):
+ tags[tag] = tags.get(tag, 0) + 1
+ rp_items = [(k, k, readouts[k]) for k in READOUT_LABELS if readouts.get(k)]
+ rp_items += [(k, k, n) for k, n in readouts.items() if k not in READOUT_LABELS]
+ tag_items = [(t, t, n) for t, n in sorted(tags.items(), key=lambda kv: (-kv[1], kv[0]))]
+
+ def section(title, key, items):
+ return {
+ "title": title,
+ "key": key,
+ "items": [
+ {"value": v, "label": l, "count": n, "checked": v in self.facets[key]}
+ for v, l, n in items
+ ],
+ }
+
+ return [
+ section("Status", "st", status_items),
+ section("Readout priority", "rp", rp_items),
+ section("Tags", "tag", tag_items),
+ ]
+
+ def problems(self) -> list[dict]:
+ """Devices with a problem or not checked yet, for the Problems panel."""
+ out = []
+ for name in self.work:
+ st = self.status.get(name)
+ if st in PROBLEM_STATES or st == "unchecked":
+ message = self.messages.get(name) or (
+ "Connection not tested yet" if st == "unchecked" else STATUS[st][0]
+ )
+ out.append({"name": name, "state": st, "message": message})
+ return out
+
+ def problem_counts(self) -> tuple[int, int]:
+ """``(problems, not checked)``."""
+ probs = self.problems()
+ return (
+ sum(1 for p in probs if p["state"] in PROBLEM_STATES),
+ sum(1 for p in probs if p["state"] == "unchecked"),
+ )
+
+ def bar_state(self) -> dict:
+ """Session bar: what is being edited and how far it is from the session."""
+ n = len(self.changes())
+ return {
+ "sessionCount": len(self.session),
+ "source": self.source,
+ "changeCount": n,
+ "changeText": (
+ f"{n} change{'s' if n != 1 else ''} vs session" if n else "Same as session"
+ ),
+ "reviewText": f"Review changes ({n})" if n else "Review changes",
+ "scanRunning": self.scan_running,
+ "scanText": (
+ f"Scan {self.scan_number} is running" if self.scan_number else "A scan is running"
+ ),
+ "canUndo": bool(self.undo_stack),
+ "hasSelection": self.selected in self.work,
+ }
+
+ def detail(self) -> dict:
+ """Inspector content for the selected device (``{"name": ""}`` when none)."""
+ name = self.selected
+ cfg = self.work.get(name) if name else None
+ if cfg is None:
+ return {"name": ""}
+ old = self.session.get(name)
+ label, tone, icon = self.status_of(name)
+
+ def was(key: str, labels: dict | None = None) -> str:
+ if old is None or old.get(key) == cfg.get(key):
+ return ""
+ value = old.get(key)
+ text = (labels or {}).get(value, value_text(value))
+ return f"In session: {text}"
+
+ old_dc = (old or {}).get("deviceConfig", {})
+ dev_cfg = [
+ {
+ "key": key,
+ "value": value_text(value),
+ "was": (
+ f"In session: {value_text(old_dc[key])}"
+ if old is not None and key in old_dc and old_dc[key] != value
+ else ("New key" if old is not None and key not in old_dc else "")
+ ),
+ }
+ for key, value in cfg.get("deviceConfig", {}).items()
+ ]
+ known = {"name", "deviceClass", "deviceTags", "deviceConfig", *EDITABLE_FIELDS}
+ extra = [
+ {"key": k, "value": value_text(v)}
+ for k, v in cfg.items()
+ if k not in known and k not in ("softwareTrigger",)
+ ]
+ return {
+ "name": name,
+ "deviceClass": str(cfg.get("deviceClass", "")),
+ "shortClass": short_class(cfg.get("deviceClass")),
+ "statusText": label,
+ "statusTone": tone,
+ "statusIcon": icon,
+ "statusMessage": self.messages.get(name, ""),
+ "isNew": old is None,
+ "isModified": old is not None and old != cfg,
+ "readout": str(cfg.get("readoutPriority", "")),
+ "readoutWas": was("readoutPriority", READOUT_LABELS),
+ "enabled": bool(cfg.get("enabled", True)),
+ "enabledWas": was("enabled"),
+ "onFailure": str(cfg.get("onFailure", "retry")),
+ "onFailureWas": was("onFailure", ON_FAILURE_LABELS),
+ "readOnly": bool(cfg.get("readOnly", False)),
+ "readOnlyWas": was("readOnly"),
+ "description": str(cfg.get("description") or ""),
+ "descriptionWas": was("description"),
+ "tags": list(cfg.get("deviceTags", [])),
+ "tagsWas": was("deviceTags"),
+ "deviceConfig": dev_cfg,
+ "extra": extra,
+ "yaml": device_yaml(cfg),
+ }
+
+ def review(self) -> dict:
+ """Content of the review sheet."""
+ groups = []
+ for kind, title in (("A", "Added"), ("M", "Modified"), ("D", "Removed")):
+ items = []
+ for c in self.changes():
+ if c.kind != kind:
+ continue
+ cfg = self.work.get(c.name) or self.session.get(c.name) or {}
+ label, tone, icon = self.status_of(c.name) if c.name in self.work else ("", "", "")
+ items.append(
+ {
+ "name": c.name,
+ "deviceClass": short_class(cfg.get("deviceClass")),
+ "statusText": label,
+ "statusTone": tone,
+ "statusIcon": icon,
+ "fields": [{"field": f, "old": a, "new": b} for f, a, b in c.fields],
+ }
+ )
+ if items:
+ groups.append({"title": f"{title} ({len(items)})", "kind": kind, "items": items})
+ n = len(self.changes())
+ touched = sum(1 for c in self.changes() if c.kind != "A")
+ unchecked = sum(
+ 1 for c in self.changes() if c.kind != "D" and self.status.get(c.name) == "unchecked"
+ )
+ return {
+ "groups": groups,
+ "count": n,
+ "unchecked": unchecked,
+ "uncheckedText": (
+ f"{unchecked} changed device{'s' if unchecked != 1 else ''} not tested yet"
+ if unchecked
+ else ""
+ ),
+ "footText": f"{n} change{'s' if n != 1 else ''} · "
+ f"{len(self.session) - touched} devices untouched",
+ "applyText": f"Apply {n} change{'s' if n != 1 else ''}",
+ "scanRunning": self.scan_running,
+ }
+
+ # ------------------------------------------------------------------ filters and selection
+ def set_query(self, text: str) -> None:
+ """Filter the table by name, class, tag, PV prefix or description."""
+ if text != self.query:
+ self.query = text
+ self.changed.emit()
+
+ def toggle_facet(self, key: str, value: str, checked: bool) -> None:
+ """Add or remove one facet filter."""
+ (self.facets[key].add if checked else self.facets[key].discard)(value)
+ self.changed.emit()
+
+ def clear_filters(self) -> None:
+ """Remove the search text and all facet filters."""
+ self.query = ""
+ self.facets = {k: set() for k in self.facets}
+ self.changed.emit()
+
+ def select(self, name: str) -> None:
+ """Select a device for the inspector."""
+ if name in self.work and name != self.selected:
+ self.selected = name
+ self.changed.emit()
+
+ # ------------------------------------------------------------------ editing
+ def set_field(self, name: str, key: str, value: Any) -> None:
+ """Change one top-level key of a device in the edited copy."""
+ cfg = self.work.get(name)
+ if cfg is None or cfg.get(key) == value:
+ return
+ self._push_undo()
+ cfg[key] = value
+ self._mark_edited(name)
+ self._touch()
+
+ def set_config_value(self, name: str, key: str, text: str) -> None:
+ """Change one ``deviceConfig`` entry; ``text`` is parsed like YAML."""
+ cfg = self.work.get(name)
+ if cfg is None:
+ return
+ value = parse_value(text)
+ if cfg["deviceConfig"].get(key) == value:
+ return
+ self._push_undo()
+ cfg["deviceConfig"][key] = value
+ self._mark_edited(name)
+ self._touch()
+
+ def add_tag(self, name: str, tag: str) -> None:
+ """Add a tag to a device."""
+ tag = tag.strip()
+ cfg = self.work.get(name)
+ if cfg is None or not tag or tag in cfg["deviceTags"]:
+ return
+ self.set_field(name, "deviceTags", sorted([*cfg["deviceTags"], tag]))
+
+ def remove_tag(self, name: str, tag: str) -> None:
+ """Remove a tag from a device."""
+ cfg = self.work.get(name)
+ if cfg is not None and tag in cfg["deviceTags"]:
+ self.set_field(name, "deviceTags", [t for t in cfg["deviceTags"] if t != tag])
+
+ def validate_new_name(self, name: str) -> str:
+ """Error message for a new device name, or ``""`` if it can be used."""
+ name = name.strip()
+ if not name:
+ return "Enter a name"
+ if not (name[0].isalpha() and name.islower() and name.replace("_", "").isalnum()):
+ return f"Use {NEW_NAME_RULE[0].lower()}{NEW_NAME_RULE[1:]}"
+ if name in self.work:
+ return f"{name} already exists — pick another name"
+ return ""
+
+ def add_device(self, name: str, device_class: str, prefix: str = "") -> str:
+ """Add a device to the edited copy; returns an error message or ``""``."""
+ error = self.validate_new_name(name)
+ if error:
+ return error
+ name = name.strip()
+ self._push_undo()
+ dev_cfg = {"prefix": prefix.strip()} if prefix.strip() else {}
+ self.work[name] = normalize_config(
+ name,
+ {"deviceClass": device_class, "readoutPriority": "baseline", "deviceConfig": dev_cfg},
+ )
+ self.status[name] = "unchecked"
+ self.selected = name
+ self._touch()
+ return ""
+
+ def remove(self, name: str | None = None) -> None:
+ """Remove a device from the edited copy (Undo restores it)."""
+ name = name or self.selected
+ if name not in self.work:
+ return
+ self._push_undo()
+ keys = list(self.work)
+ index = keys.index(name)
+ del self.work[name]
+ rest = list(self.work)
+ self.selected = rest[min(index, len(rest) - 1)] if rest else None
+ self._touch()
+ self.toast.emit(
+ f"Removed {name} from the edited copy — the session is unchanged until you apply",
+ "neutral",
+ True,
+ )
+
+ def revert(self, name: str | None = None) -> None:
+ """Reset a device to its session config."""
+ name = name or self.selected
+ if name not in self.session or self.work.get(name) == self.session[name]:
+ return
+ self._push_undo()
+ self.work[name] = copy.deepcopy(self.session[name])
+ self.status[name] = "session"
+ self.messages.pop(name, None)
+ self._touch()
+
+ def undo(self) -> None:
+ """Undo the last edit."""
+ if not self.undo_stack:
+ return
+ self.work, self.status = self.undo_stack.pop()
+ if self.selected not in self.work:
+ self.selected = next(iter(self.work), None)
+ self._touch()
+ self.toast.emit("Undone", "neutral", False)
+
+ # ------------------------------------------------------------------ testing
+ def test(self, name: str | None = None) -> None:
+ """Test config and connection of one device without blocking the GUI."""
+ name = name or self.selected
+ cfg = self.work.get(name)
+ if cfg is None:
+ return
+ self.status[name] = "testing"
+ self.messages.pop(name, None)
+ worker = TestWorker(copy.deepcopy(cfg))
+ worker.signals.tested.connect(self._on_tested)
+ self._start(worker)
+ self.changed.emit()
+
+ def test_all(self) -> None:
+ """Test every device of the edited copy."""
+ for name in list(self.work):
+ self.test(name)
+
+ def _on_tested(self, name: str, status: str, message: str) -> None:
+ if name not in self.work:
+ return
+ unchanged = self.work[name] == self.session.get(name)
+ self.status[name] = "session" if status == "valid" and unchanged else status
+ if message:
+ self.messages[name] = message.strip()
+ else:
+ self.messages.pop(name, None)
+ tone = "err" if status in PROBLEM_STATES else "ok"
+ text = f"{name}: connection OK" if status == "valid" else f"{name}: {STATUS[status][0]}"
+ self.toast.emit(text, tone, False)
+ self._touch()
+
+ # ------------------------------------------------------------------ applying
+ def on_scan_status(self, content: dict, _metadata: dict | None = None) -> None:
+ """Lock applying while a scan is open or paused."""
+ status = content.get("status")
+ self.scan_running = status in ("open", "paused")
+ self.scan_number = content.get("scan_number") if self.scan_running else None
+ self.changed.emit()
+
+ def can_apply(self) -> bool:
+ """True if there is something to apply and nothing blocks it."""
+ return bool(self.changes()) and not self.scan_running and not self.applying
+
+ def apply(self) -> bool:
+ """Apply all changes, device by device, off the GUI thread."""
+ if not self.can_apply():
+ return False
+ requests = [(c.name, plan_requests(c, self.session, self.work)) for c in self.changes()]
+ self.applying = True
+ self.apply_summary = ""
+ kinds = {"A": "add", "M": "update", "D": "remove"}
+ self.apply_rows = [
+ {"name": c.name, "action": kinds[c.kind], "state": "waiting", "message": ""}
+ for c in self.changes()
+ ]
+ worker = ApplyWorker(self._config_helper(), requests)
+ worker.signals.progress.connect(self._on_apply_progress)
+ worker.signals.finished.connect(self._on_apply_finished)
+ self._start(worker)
+ self.changed.emit()
+ return True
+
+ def _config_helper(self):
+ # A separate helper: the client's own one would block other config traffic.
+ if self._helper is None:
+ self._helper = config_helper.ConfigHelper(self.client.connector)
+ return self._helper
+
+ def _on_apply_progress(self, name: str, state: str, message: str) -> None:
+ for row in self.apply_rows:
+ if row["name"] == name:
+ row["state"], row["message"] = state, message
+ if state == "failed":
+ if name in self.work:
+ self.status[name] = "invalid"
+ self.messages[name] = message
+ self.apply_progress.emit(name, state, message)
+ self.changed.emit()
+
+ def _on_apply_finished(self, applied: int, failed: int) -> None:
+ self.applying = False
+ total = applied + failed
+ self.apply_summary = f"{applied} of {total} changes applied"
+ try:
+ self.refresh_session()
+ except Exception as exc: # pylint: disable=broad-except
+ logger.warning(f"Could not re-read the session after applying: {exc}")
+ self.apply_finished.emit(applied, failed)
+ self.changed.emit()
+
+ def clear_session(self, confirmation: str) -> str:
+ """Remove every device from the running session. Returns an error or ``""``."""
+ if confirmation.strip() != "CLEAR":
+ return "Type CLEAR to confirm"
+ if self.scan_running:
+ return "Clearing is not possible during a scan"
+ worker = CallWorker(self.client.config.reset_config)
+ worker.signals.done.connect(self._on_cleared)
+ self._start(worker)
+ return ""
+
+ def _on_cleared(self, result) -> None:
+ if isinstance(result, Exception):
+ self.toast.emit(f"Clearing the session failed: {result}", "err", False)
+ return
+ self.refresh_session()
+ self.toast.emit("The running session is now empty", "warn", False)
+
+ # ------------------------------------------------------------------ internals
+ def _start(self, worker: QRunnable) -> None:
+ worker.setAutoDelete(False)
+ self._workers.append(worker)
+ worker.signals.finished.connect(lambda *_: self._drop(worker))
+ worker.signals.tested.connect(lambda *_: self._drop(worker))
+ worker.signals.done.connect(lambda *_: self._drop(worker))
+ QThreadPool.globalInstance().start(worker)
+
+ def _drop(self, worker: QRunnable) -> None:
+ if worker in self._workers:
+ self._workers.remove(worker)
+
+ def _push_undo(self) -> None:
+ self.undo_stack.append((copy.deepcopy(self.work), dict(self.status)))
+ del self.undo_stack[:-100]
+
+ def _mark_edited(self, name: str) -> None:
+ if self.work[name] == self.session.get(name):
+ self.status[name] = "session"
+ elif self.status.get(name) not in PROBLEM_STATES:
+ self.status[name] = "unchecked"
+
+ def _restatus(self, reset: bool = False) -> None:
+ for name, cfg in self.work.items():
+ same = cfg == self.session.get(name)
+ if same:
+ self.status[name] = "session"
+ elif reset or self.status.get(name) in (None, "session"):
+ self.status[name] = "unchecked"
+ for name in list(self.status):
+ if name not in self.work:
+ del self.status[name]
+
+ def _touch(self) -> None:
+ self._changes = None
+ self.changed.emit()
+
+ def cleanup(self) -> None:
+ """Disconnect the scan-status subscription."""
+ if self.dispatcher is not None:
+ self.dispatcher.disconnect_slot(self.on_scan_status, MessageEndpoints.scan_status())
+
+
+# ---------------------------------------------------------------------------------- Devices view
+KINDS = [
+ ("positioner", "Motors"),
+ ("monitor", "Monitors"),
+ ("detector", "Detectors"),
+ ("any", "All"),
+]
+PREFERRED = ["samx", "samy", "samz", "mokev", "idgap", "bpm4i", "bpm3a", "diode", "eiger"]
+MAX_ROWS = 150
+
+
+def device_kind(device) -> str:
+ """Classify a session device as ``positioner``, ``detector`` or ``monitor``."""
+ if isinstance(device, Positioner):
+ return "positioner"
+ priority = str(getattr(device, "readout_priority", "") or "").lower()
+ tags = {str(t).lower() for t in (getattr(device, "_config", {}) or {}).get("deviceTags", [])}
+ if priority.endswith("async") or "detector" in tags:
+ return "detector"
+ return "monitor"
+
+
+def readback_value(name: str, content: dict | None) -> Any:
+ """The main value of a device readback message (its own signal, else the first one)."""
+ signals = (content or {}).get("signals") or {}
+ entry = signals.get(name)
+ if entry is None and signals:
+ entry = next(iter(signals.values()))
+ return (entry or {}).get("value")
+
+
+def format_value(value: Any, precision: int | None, units: str = "") -> str:
+ """Readable value with the device precision and unit; arrays show their shape."""
+ if value is None:
+ return "—"
+ if hasattr(value, "shape") and getattr(value, "shape", ()):
+ return f"array {tuple(value.shape)}"
+ if isinstance(value, (list, tuple)):
+ return f"list [{len(value)}]"
+ if isinstance(value, bool):
+ text = str(value)
+ elif isinstance(value, (int, float)):
+ text = f"{value:.{precision}f}" if isinstance(precision, int) else f"{value:.6g}"
+ text = text.replace("-", "−", 1) if text.startswith("-") else text
+ else:
+ text = str(value)
+ return f"{text} {units}".strip()
+
+
+class DeviceBrowser(QObject):
+ """State of the *Devices* view: find a device by kind and text, see it live.
+
+ Args:
+ client: The BEC client.
+ dispatcher: The BEC dispatcher for readback subscriptions.
+ parent: Parent QObject.
+ """
+
+ changed = Signal()
+ values_changed = Signal()
+ spark_changed = Signal()
+ open_in_workspace = Signal(str, str) # widget class name, device
+
+ def __init__(self, client, dispatcher=None, parent: QObject | None = None):
+ super().__init__(parent)
+ self.client = client
+ self.dispatcher = dispatcher
+ self.kind = "positioner"
+ self.query = ""
+ self.selected: str | None = None
+ self.values: dict[str, Any] = {}
+ self.spark: deque = deque(maxlen=90)
+ self._subscribed: list[str] = []
+ self._dirty = False
+ self._throttle = QTimer(self)
+ self._throttle.setInterval(250)
+ self._throttle.timeout.connect(self._flush_values)
+ self._throttle.start()
+ self._workers: list[QRunnable] = []
+ self._visible: list[dict] = []
+ self._total = 0
+
+ # ------------------------------------------------------------------ data
+ def _devices(self) -> dict:
+ try:
+ return dict(self.client.device_manager.devices.items())
+ except AttributeError:
+ return {}
+
+ def _info(self, name: str, device) -> dict:
+ info = getattr(device, "_info", {}) or {}
+ describe = (info.get("describe") or {}).get(name, {})
+ cfg = getattr(device, "_config", {}) or {}
+ precision = getattr(device, "precision", None)
+ if not isinstance(precision, int):
+ precision = (
+ describe.get("precision") if isinstance(describe.get("precision"), int) else None
+ )
+ return {
+ "units": str(describe.get("units") or describe.get("egu") or ""),
+ "precision": precision,
+ "description": str(cfg.get("description") or ""),
+ "deviceClass": short_class(cfg.get("deviceClass", type(device).__name__)),
+ "tags": sorted(str(t) for t in cfg.get("deviceTags", []) or []),
+ "readout": str(cfg.get("readoutPriority", "")),
+ "enabled": bool(cfg.get("enabled", True)),
+ }
+
+ def kind_counts(self) -> dict[str, int]:
+ """Number of devices per kind, for the kind switch."""
+ counts = {k: 0 for k, _ in KINDS}
+ for device in self._devices().values():
+ counts[device_kind(device)] += 1
+ counts["any"] += 1
+ return counts
+
+ def rows(self) -> list[dict]:
+ """Up to :data:`MAX_ROWS` devices matching kind and search, preferred names first."""
+ q = self.query.strip().lower()
+ out = []
+ for name, device in self._devices().items():
+ kind = device_kind(device)
+ if self.kind not in ("any", kind):
+ continue
+ info = self._info(name, device)
+ if (
+ q
+ and q
+ not in " ".join(
+ [name, info["description"], info["deviceClass"], " ".join(info["tags"])]
+ ).lower()
+ ):
+ continue
+ what = info["description"] or " · ".join(
+ x for x in (info["deviceClass"], ", ".join(info["tags"]) or info["readout"]) if x
+ )
+ out.append(
+ {
+ "name": name,
+ "what": what,
+ "kind": kind,
+ "valueText": format_value(
+ self.values.get(name), info["precision"], info["units"]
+ ),
+ "selected": name == self.selected,
+ }
+ )
+ pref = {n: i for i, n in enumerate(PREFERRED)}
+ out.sort(key=lambda r: (pref.get(r["name"], len(pref)), r["name"]))
+ self._total = len(out)
+ self._visible = out[:MAX_ROWS]
+ self._resubscribe([r["name"] for r in self._visible])
+ return self._visible
+
+ def shown_text(self) -> str:
+ """``"150 of 212 shown"``."""
+ total = self._total
+ return f"{len(self._visible)} of {total} shown"
+
+ def detail(self) -> dict:
+ """Detail panel of the selected device."""
+ name = self.selected
+ device = self._devices().get(name) if name else None
+ if device is None:
+ return {"name": ""}
+ info = self._info(name, device)
+ kind = device_kind(device)
+ facts = [
+ ("Class", info["deviceClass"]),
+ ("Readout", info["readout"]),
+ ("Tags", ", ".join(info["tags"]) or "—"),
+ ]
+ if not info["enabled"]:
+ facts.append(("Enabled", "No — disabled in the session"))
+ return {
+ "name": name,
+ "kind": kind,
+ "description": info["description"] or info["deviceClass"],
+ "valueText": format_value(self.values.get(name), info["precision"]),
+ "units": info["units"],
+ "facts": [{"label": k, "value": v} for k, v in facts],
+ "actionText": {
+ "positioner": f"Add {name} to the workspace",
+ "detector": "Open the live image in the workspace",
+ }.get(kind, f"Plot {name} in the workspace"),
+ }
+
+ def spark_points(self) -> list[float]:
+ """Recent numeric values of the selected monitor (oldest first)."""
+ return [float(v) for v in self.spark]
+
+ # ------------------------------------------------------------------ interaction
+ def set_kind(self, kind: str) -> None:
+ """Show only one kind of device."""
+ if kind != self.kind:
+ self.kind = kind
+ self.changed.emit()
+
+ def set_query(self, text: str) -> None:
+ """Filter by name, description, class or tag."""
+ if text != self.query:
+ self.query = text
+ self.changed.emit()
+
+ def select(self, name: str) -> None:
+ """Show a device in the detail panel."""
+ if name == self.selected:
+ return
+ self.selected = name
+ self.spark.clear()
+ value = self.values.get(name)
+ if isinstance(value, (int, float)) and not isinstance(value, bool):
+ self.spark.append(value)
+ self.changed.emit()
+ self.spark_changed.emit()
+
+ def request_open(self) -> None:
+ """Ask the app to open a widget for the selected device in the workspace."""
+ device = self._devices().get(self.selected) if self.selected else None
+ if device is None:
+ return
+ widget = {"positioner": "PositionerBox", "detector": "Image"}.get(
+ device_kind(device), "Waveform"
+ )
+ self.open_in_workspace.emit(widget, self.selected)
+
+ # ------------------------------------------------------------------ live values
+ def _resubscribe(self, names: list[str]) -> None:
+ if self.dispatcher is None or names == self._subscribed:
+ return
+ old = [n for n in self._subscribed if n not in names]
+ new = [n for n in names if n not in self._subscribed]
+ if old:
+ self.dispatcher.disconnect_slot(
+ self.on_readback, [MessageEndpoints.device_readback(n) for n in old]
+ )
+ if new:
+ self.dispatcher.connect_slot(
+ self.on_readback, [MessageEndpoints.device_readback(n) for n in new]
+ )
+ missing = [n for n in new if n not in self.values]
+ if missing:
+ self._fetch_initial(missing)
+ self._subscribed = list(names)
+
+ def _fetch_initial(self, names: list[str]) -> None:
+ connector = self.client.connector
+
+ def read():
+ out = {}
+ for n in names:
+ msg = connector.get(MessageEndpoints.device_readback(n))
+ if msg is not None:
+ out[n] = readback_value(n, msg.content)
+ return out
+
+ worker = CallWorker(read)
+ worker.setAutoDelete(False)
+ worker.signals.done.connect(self._on_initial)
+ self._workers.append(worker)
+ QThreadPool.globalInstance().start(worker)
+
+ def _on_initial(self, result) -> None:
+ self._workers = [w for w in self._workers if w.signals is not self.sender()]
+ if isinstance(result, dict):
+ for name, value in result.items():
+ self.values.setdefault(name, value)
+ self._dirty = True
+
+ def on_readback(self, content: dict, metadata: dict) -> None:
+ """Store a readback; the views refresh at most every 250 ms."""
+ name = (metadata or {}).get("device")
+ signals = (content or {}).get("signals") or {}
+ if name is None:
+ name = next((n for n in self._subscribed if n in signals), None)
+ if name is None and signals:
+ first = next(iter(signals))
+ name = next((n for n in self._subscribed if first.startswith(f"{n}_")), first)
+ if name is None:
+ return
+ value = readback_value(name, content)
+ self.values[name] = value
+ if name == self.selected and isinstance(value, (int, float)):
+ self.spark.append(value)
+ self.spark_changed.emit()
+ self._dirty = True
+
+ def _flush_values(self) -> None:
+ if self._dirty:
+ self._dirty = False
+ self.values_changed.emit()
+
+ def cleanup(self) -> None:
+ """Stop the refresh timer and drop all readback subscriptions."""
+ self._throttle.stop()
+ if self.dispatcher is not None and self._subscribed:
+ self.dispatcher.disconnect_slot(
+ self.on_readback, [MessageEndpoints.device_readback(n) for n in self._subscribed]
+ )
+ self._subscribed = []
diff --git a/bec_widgets/applications/views/devices_views/devices_qml.py b/bec_widgets/applications/views/devices_views/devices_qml.py
new file mode 100644
index 00000000..2bcd7208
--- /dev/null
+++ b/bec_widgets/applications/views/devices_views/devices_qml.py
@@ -0,0 +1,427 @@
+"""The *Devices* and *Config* views rendered with Qt Quick (QML).
+
+QML twin of :mod:`devices_qwidget`: the scenes in ``qml/`` render the controllers of
+:mod:`devices_core` through small backend objects.
+"""
+
+from __future__ import annotations
+
+from pathlib import Path
+
+from qtpy.QtCore import Property, QObject, QUrl, Signal, Slot
+from qtpy.QtWidgets import QFileDialog, QVBoxLayout, QWidget
+
+from bec_widgets.applications.views.devices_views.devices_core import (
+ DEVICE_CLASSES,
+ KINDS,
+ NEW_NAME_RULE,
+ ON_FAILURE_LABELS,
+ READOUT_HINT,
+ READOUT_LABELS,
+ ConfigEditor,
+ DeviceBrowser,
+ class_docstring,
+)
+from bec_widgets.utils.bec_widget import BECWidget
+from bec_widgets.utils.error_popups import SafeSlot
+from bec_widgets.utils.quick import DictListModel, create_quick_widget, release_quick_widget
+from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_qml import (
+ QML_FILE as POSITIONER_QML,
+)
+from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_qml import (
+ PositionerBackend,
+ PositionerBoxQML,
+)
+
+QML_DIR = Path(__file__).parent / "qml"
+CONFIG_ROLES = [
+ "name",
+ "deviceClass",
+ "readout",
+ "readoutWas",
+ "on",
+ "tags",
+ "change",
+ "changeLabel",
+ "statusText",
+ "statusTone",
+ "statusIcon",
+ "selected",
+]
+DEVICE_ROLES = ["name", "what", "kind", "valueText", "selected"]
+
+
+# ----------------------------------------------------------------------------------- Devices view
+class _SceneMotor(PositionerBoxQML):
+ """Positioner box whose card is drawn inside another QML scene instead of its own view."""
+
+ def _init_view(self) -> None:
+ self.backend = PositionerBackend(self)
+ self.view = None
+
+ def apply_theme(self, theme: str): # pylint: disable=unused-argument
+ return
+
+ def cleanup(self):
+ super(PositionerBoxQML, self).cleanup() # pylint: disable=bad-super-call
+
+
+class DevicesBackend(QObject):
+ """Bridge between :class:`DeviceBrowser` and ``DevicesView.qml``."""
+
+ changed = Signal()
+ values_changed = Signal()
+ spark_changed = Signal()
+ motor_changed = Signal()
+
+ def __init__(self, browser: DeviceBrowser, parent: QObject):
+ super().__init__(parent)
+ self.browser = browser
+ self._rows = DictListModel(DEVICE_ROLES, self)
+ self._detail: dict = {}
+ self._motor = None
+ browser.changed.connect(self.refresh)
+ browser.values_changed.connect(self._refresh_values)
+ browser.spark_changed.connect(self.spark_changed)
+
+ def refresh(self) -> None:
+ """Re-read rows and the detail panel."""
+ rows = self.browser.rows()
+ if self.browser.selected is None and rows:
+ self.browser.selected = rows[0]["name"]
+ rows = self.browser.rows()
+ self._rows.set_items(rows)
+ self._detail = self.browser.detail()
+ self.changed.emit()
+ self.values_changed.emit()
+
+ def _refresh_values(self) -> None:
+ self._rows.set_items(self.browser.rows())
+ self._detail = self.browser.detail()
+ self.values_changed.emit()
+
+ def set_motor(self, motor) -> None:
+ """Positioner box shown for motors."""
+ self._motor = motor
+ self.motor_changed.emit()
+
+ # pylint: disable=invalid-name, missing-function-docstring
+ @Slot(str)
+ def select(self, name: str) -> None:
+ self.browser.select(name)
+
+ @Slot(str)
+ def setKind(self, kind: str) -> None:
+ self.browser.set_kind(kind)
+
+ @Slot(str)
+ def setQuery(self, text: str) -> None:
+ self.browser.set_query(text)
+
+ @Slot()
+ def openInWorkspace(self) -> None:
+ self.browser.request_open()
+
+ rows = Property(QObject, lambda self: self._rows, constant=True)
+ detail = Property("QVariantMap", lambda self: self._detail, notify=values_changed)
+ detailName = Property(str, lambda self: self._detail.get("name", ""), notify=changed)
+ kind = Property(str, lambda self: self.browser.kind, notify=changed)
+ kinds = Property(
+ "QVariantList", lambda self: [{"key": k, "label": l} for k, l in KINDS], constant=True
+ )
+ shownText = Property(str, lambda self: self.browser.shown_text(), notify=changed)
+ selectedIndex = Property(
+ int,
+ lambda self: next((i for i, r in enumerate(self._rows.items) if r["selected"]), -1),
+ notify=changed,
+ )
+ spark = Property("QVariantList", lambda self: self.browser.spark_points(), notify=spark_changed)
+ motor = Property(
+ QObject, lambda self: self._motor.backend if self._motor else None, notify=motor_changed
+ )
+ motorSource = Property(
+ QUrl, lambda self: QUrl.fromLocalFile(str(POSITIONER_QML)), constant=True
+ )
+
+
+class DevicesViewQML(BECWidget, QWidget):
+ """Devices for everyone: find a device, see its live value and move it (QML version)."""
+
+ RPC = False
+ PLUGIN = False
+
+ def __init__(self, parent=None, client=None, **kwargs):
+ super().__init__(parent=parent, client=client, **kwargs)
+ self.get_bec_shortcuts()
+ self.browser = DeviceBrowser(self.client, self.bec_dispatcher, self)
+ self.open_in_workspace = self.browser.open_in_workspace
+ self.backend = DevicesBackend(self.browser, self)
+ self._motor = None
+ self.browser.changed.connect(self._sync_motor)
+ self.backend.refresh()
+ self._sync_motor()
+ lay = QVBoxLayout(self)
+ lay.setContentsMargins(0, 0, 0, 0)
+ self.view = create_quick_widget(
+ self, QML_DIR / "DevicesView.qml", {"backend": self.backend}
+ )
+ lay.addWidget(self.view)
+
+ def _sync_motor(self) -> None:
+ detail = self.browser.detail()
+ if detail.get("kind") != "positioner":
+ return
+ name = detail["name"]
+ if self._motor is None:
+ self._motor = _SceneMotor(parent=self, device=name, client=self.client)
+ self._motor.hide_device_selection = True
+ self._motor.hide()
+ self.backend.set_motor(self._motor)
+ elif self._motor.device != name:
+ self._motor.set_positioner(name)
+
+ @SafeSlot(str)
+ def apply_theme(self, theme: str):
+ if getattr(self, "view", None) is not None:
+ self.view.setClearColor(self.palette().window().color())
+
+ def cleanup(self):
+ release_quick_widget(self.view)
+ self.browser.cleanup()
+ if self._motor is not None:
+ self._motor.close()
+ self._motor.deleteLater()
+ super().cleanup()
+
+
+# ------------------------------------------------------------------------------------ Config view
+class ConfigBackend(QObject):
+ """Bridge between :class:`ConfigEditor` and ``DeviceConfigView.qml``."""
+
+ changed = Signal()
+ detail_changed = Signal()
+ facets_changed = Signal()
+ toast = Signal(str, str, bool)
+ tab_changed = Signal()
+
+ def __init__(self, editor: ConfigEditor, owner: QWidget):
+ super().__init__(owner)
+ self.editor = editor
+ self._owner = owner
+ self._rows = DictListModel(CONFIG_ROLES, self)
+ self._detail: dict = {}
+ self._facets: list = []
+ self._tab = "form"
+ editor.changed.connect(self.refresh)
+ editor.toast.connect(self.toast)
+
+ def refresh(self) -> None:
+ """Re-read everything the scene shows."""
+ self._rows.set_items(self.editor.rows())
+ detail = self.editor.detail()
+ if detail != self._detail:
+ self._detail = detail
+ self.detail_changed.emit()
+ facets = self.editor.facet_sections()
+ if facets != self._facets:
+ self._facets = facets
+ self.facets_changed.emit()
+ self.changed.emit()
+
+ # pylint: disable=invalid-name, missing-function-docstring
+ @Slot(str)
+ def select(self, name: str) -> None:
+ self.editor.select(name)
+
+ @Slot(int)
+ def selectRelative(self, step: int) -> None:
+ names = [r["name"] for r in self._rows.items]
+ if not names:
+ return
+ index = names.index(self.editor.selected) if self.editor.selected in names else -1
+ self.editor.select(names[max(0, min(len(names) - 1, index + step))])
+
+ @Slot(str)
+ def setQuery(self, text: str) -> None:
+ self.editor.set_query(text)
+
+ @Slot(str, str, bool)
+ def toggleFacet(self, key: str, value: str, checked: bool) -> None:
+ self.editor.toggle_facet(key, value, checked)
+
+ @Slot()
+ def clearFilters(self) -> None:
+ self.editor.clear_filters()
+
+ @Slot(str, str, "QVariant")
+ def setField(self, name: str, key: str, value) -> None:
+ self.editor.set_field(name, key, value)
+
+ @Slot(str, str, str)
+ def setConfigValue(self, name: str, key: str, text: str) -> None:
+ self.editor.set_config_value(name, key, text)
+
+ @Slot(str, str)
+ def addTag(self, name: str, tag: str) -> None:
+ self.editor.add_tag(name, tag)
+
+ @Slot(str, str)
+ def removeTag(self, name: str, tag: str) -> None:
+ self.editor.remove_tag(name, tag)
+
+ @Slot(str, result=str)
+ def validateNewName(self, name: str) -> str:
+ return self.editor.validate_new_name(name)
+
+ @Slot(str, str, str, result=str)
+ def addDevice(self, name: str, device_class: str, prefix: str) -> str:
+ return self.editor.add_device(name, device_class, prefix)
+
+ @Slot()
+ def remove(self) -> None:
+ self.editor.remove()
+
+ @Slot()
+ def revert(self) -> None:
+ self.editor.revert()
+
+ @Slot()
+ def undo(self) -> None:
+ self.editor.undo()
+
+ @Slot()
+ def test(self) -> None:
+ self.editor.test()
+
+ @Slot()
+ def testAll(self) -> None:
+ self.editor.test_all()
+
+ @Slot()
+ def loadSession(self) -> None:
+ self.editor.load_session()
+
+ @Slot()
+ def openFile(self) -> None:
+ path, _ = QFileDialog.getOpenFileName(
+ self._owner, "Open device config", "", "YAML (*.yaml *.yml)"
+ )
+ if path:
+ self.editor.open_file(path)
+
+ @Slot()
+ def saveFile(self) -> None:
+ path, _ = QFileDialog.getSaveFileName(
+ self._owner, "Save device config", "", "YAML (*.yaml)"
+ )
+ if path:
+ self.editor.save_file(path)
+
+ @Slot(result=bool)
+ def apply(self) -> bool:
+ return self.editor.apply()
+
+ @Slot(str, result=str)
+ def clearSession(self, text: str) -> str:
+ return self.editor.clear_session(text)
+
+ @Slot(str)
+ def setTab(self, tab: str) -> None:
+ if tab != self._tab:
+ self._tab = tab
+ self.tab_changed.emit()
+
+ @Slot(str, result=str)
+ def docs(self, device_class: str) -> str:
+ return class_docstring(device_class)
+
+ rows = Property(QObject, lambda self: self._rows, constant=True)
+ bar = Property("QVariantMap", lambda self: self.editor.bar_state(), notify=changed)
+ detail = Property("QVariantMap", lambda self: self._detail, notify=detail_changed)
+ facets = Property("QVariantList", lambda self: self._facets, notify=facets_changed)
+ problems = Property("QVariantList", lambda self: self.editor.problems(), notify=changed)
+ problemCount = Property(int, lambda self: self.editor.problem_counts()[0], notify=changed)
+ uncheckedCount = Property(int, lambda self: self.editor.problem_counts()[1], notify=changed)
+ selectedIndex = Property(
+ int,
+ lambda self: next((i for i, r in enumerate(self._rows.items) if r["selected"]), -1),
+ notify=changed,
+ )
+ showingText = Property(
+ str,
+ lambda self: f"Showing {self._rows.rowCount()} of {len(self.editor.work)} devices",
+ notify=changed,
+ )
+ filtersActive = Property(bool, lambda self: self.editor.filters_active(), notify=changed)
+ review = Property("QVariantMap", lambda self: self.editor.review(), notify=changed)
+ applying = Property(bool, lambda self: self.editor.applying, notify=changed)
+ applyRows = Property("QVariantList", lambda self: self.editor.apply_rows, notify=changed)
+ applySummary = Property(str, lambda self: self.editor.apply_summary, notify=changed)
+ canApply = Property(bool, lambda self: self.editor.can_apply(), notify=changed)
+ sessionCount = Property(int, lambda self: len(self.editor.session), notify=changed)
+ workCount = Property(int, lambda self: len(self.editor.work), notify=changed)
+ tab = Property(str, lambda self: self._tab, notify=tab_changed)
+ readoutKeys = Property("QStringList", lambda self: list(READOUT_LABELS), constant=True)
+ readoutLabels = Property(
+ "QStringList", lambda self: list(READOUT_LABELS.values()), constant=True
+ )
+ readoutHint = Property(str, lambda self: READOUT_HINT, constant=True)
+ failureKeys = Property("QStringList", lambda self: list(ON_FAILURE_LABELS), constant=True)
+ failureLabels = Property(
+ "QStringList",
+ lambda self: [f"{v} ({k})" for k, v in ON_FAILURE_LABELS.items()],
+ constant=True,
+ )
+ deviceClasses = Property("QStringList", lambda self: DEVICE_CLASSES, constant=True)
+ nameRule = Property(str, lambda self: NEW_NAME_RULE, constant=True)
+
+
+class DeviceConfigViewQML(BECWidget, QWidget):
+ """Config for staff: edit a copy of the session config, review, then apply (QML version)."""
+
+ RPC = False
+ PLUGIN = False
+
+ def __init__(self, parent=None, client=None, load_session: bool = True, **kwargs):
+ super().__init__(parent=parent, client=client, **kwargs)
+ self.get_bec_shortcuts()
+ self.editor = ConfigEditor(self.client, self.bec_dispatcher, self)
+ self.backend = ConfigBackend(self.editor, self)
+ if load_session:
+ self.editor.load_session()
+ self.backend.refresh()
+ lay = QVBoxLayout(self)
+ lay.setContentsMargins(0, 0, 0, 0)
+ self.view = create_quick_widget(
+ self, QML_DIR / "DeviceConfigView.qml", {"backend": self.backend}
+ )
+ lay.addWidget(self.view)
+
+ def open_review(self):
+ """Open the review sheet (also reachable from the session bar)."""
+ self.view.rootObject().openReview()
+ return self.view.rootObject()
+
+ def open_add_device(self):
+ """Open the add-device sheet."""
+ self.view.rootObject().openAdd()
+ return self.view.rootObject()
+
+ def open_clear_session(self):
+ """Open the danger-zone sheet."""
+ self.view.rootObject().openClear()
+ return self.view.rootObject()
+
+ def close_sheet(self) -> None:
+ """Close whichever sheet is open."""
+ self.view.rootObject().closeSheets()
+
+ @SafeSlot(str)
+ def apply_theme(self, theme: str):
+ if getattr(self, "view", None) is not None:
+ self.view.setClearColor(self.palette().window().color())
+
+ def cleanup(self):
+ release_quick_widget(self.view)
+ self.editor.cleanup()
+ super().cleanup()
diff --git a/bec_widgets/applications/views/devices_views/devices_qwidget.py b/bec_widgets/applications/views/devices_views/devices_qwidget.py
new file mode 100644
index 00000000..2d5dbe00
--- /dev/null
+++ b/bec_widgets/applications/views/devices_views/devices_qwidget.py
@@ -0,0 +1,1795 @@
+"""The *Devices* and *Config* views in plain QWidgets.
+
+QWidget twin of :mod:`devices_qml`: both render the controllers of :mod:`devices_core` with the
+same layout, words and colours, so they can be compared side by side.
+"""
+
+from __future__ import annotations
+
+from bec_qthemes import material_icon
+from qtpy.QtCore import QAbstractTableModel, QModelIndex, QRectF, QSize, Qt, QTimer, Signal
+from qtpy.QtGui import QColor, QFont, QFontMetrics, QPainter, QPainterPath, QPen
+from qtpy.QtWidgets import (
+ QAbstractItemView,
+ QButtonGroup,
+ QCheckBox,
+ QComboBox,
+ QDialog,
+ QFileDialog,
+ QFrame,
+ QGridLayout,
+ QHBoxLayout,
+ QHeaderView,
+ QLabel,
+ QLineEdit,
+ QMenu,
+ QPlainTextEdit,
+ QPushButton,
+ QScrollArea,
+ QSizePolicy,
+ QStackedWidget,
+ QStyle,
+ QStyledItemDelegate,
+ QTableView,
+ QToolButton,
+ QVBoxLayout,
+ QWidget,
+)
+
+from bec_widgets.applications.views.devices_views.devices_core import (
+ DEVICE_CLASSES,
+ KINDS,
+ NEW_NAME_RULE,
+ ON_FAILURE_LABELS,
+ READOUT_HINT,
+ READOUT_LABELS,
+ ConfigEditor,
+ DeviceBrowser,
+ class_docstring,
+)
+from bec_widgets.utils.bec_widget import BECWidget
+from bec_widgets.utils.error_popups import SafeSlot
+from bec_widgets.utils.quick.host import ThemeTokens
+from bec_widgets.utils.ux_kit import TextButton, ToggleSwitch, field_qss, rgba
+
+MONO = "JetBrains Mono, Menlo, DejaVu Sans Mono, monospace"
+
+
+# ------------------------------------------------------------------------------- shared pieces
+def tone_color(tokens: ThemeTokens, tone: str) -> QColor:
+ """Colour of a state tone; only problems and work in progress are coloured."""
+ return {
+ "ok": tokens.success,
+ "busy": tokens.primary,
+ "warn": tokens.warning,
+ "err": tokens.danger,
+ "stale": tokens.fg_subtle,
+ }.get(tone, tokens.fg_muted)
+
+
+def paint_chip(painter: QPainter, rect: QRectF, text: str, tone: str, icon: str, tokens) -> float:
+ """Paint a state chip (icon + word on a soft tint) at the left of ``rect``; returns its width."""
+ color = tone_color(tokens, tone)
+ font = QFont(painter.font())
+ font.setPixelSize(11)
+ font.setWeight(QFont.Weight.DemiBold)
+ width = QFontMetrics(font).horizontalAdvance(text) + (30 if icon else 16)
+ height = 20.0
+ chip = QRectF(rect.left(), rect.center().y() - height / 2, width, height)
+ painter.save()
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ painter.setPen(Qt.PenStyle.NoPen)
+ soft = tokens.soft(color, 0.18 if tone != "neutral" else 0.1)
+ painter.setBrush(soft)
+ painter.drawRoundedRect(chip, height / 2, height / 2)
+ x = chip.left() + 8
+ if icon:
+ pix = material_icon(icon, size=(28, 28), color=color, convert_to_pixmap=True)
+ painter.drawPixmap(QRectF(x, chip.top() + 3, 14, 14).toRect(), pix)
+ x += 17
+ painter.setFont(font)
+ painter.setPen(color)
+ painter.drawText(QRectF(x, chip.top(), width, height), Qt.AlignmentFlag.AlignVCenter, text)
+ painter.restore()
+ return width
+
+
+class Chip(QWidget):
+ """State chip: icon and word on a soft tint, matching ``BecUi`` chips in the QML views."""
+
+ def __init__(self, text: str = "", tone: str = "neutral", icon: str = "", parent=None):
+ super().__init__(parent)
+ self._text, self._tone, self._icon = text, tone, icon
+ self.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
+
+ def set_chip(self, text: str, tone: str = "neutral", icon: str = "") -> None:
+ """Change text, tone and icon."""
+ if (text, tone, icon) != (self._text, self._tone, self._icon):
+ self._text, self._tone, self._icon = text, tone, icon
+ self.updateGeometry()
+ self.update()
+
+ def text(self) -> str:
+ """Chip text."""
+ return self._text
+
+ def sizeHint(self) -> QSize: # pylint: disable=invalid-name
+ font = QFont(self.font())
+ font.setPixelSize(11)
+ font.setWeight(QFont.Weight.DemiBold)
+ return QSize(
+ QFontMetrics(font).horizontalAdvance(self._text) + (30 if self._icon else 16), 22
+ )
+
+ def paintEvent(self, _event): # pylint: disable=invalid-name
+ painter = QPainter(self)
+ paint_chip(painter, QRectF(self.rect()), self._text, self._tone, self._icon, ThemeTokens())
+
+
+def label(text: str = "", role: str = "", parent=None) -> QLabel:
+ """QLabel with a style role (muted, faint, upper, title, mono, big)."""
+ widget = QLabel(text, parent)
+ if role:
+ widget.setProperty("role", role)
+ return widget
+
+
+def clear_layout(layout) -> None:
+ """Remove and delete every item of ``layout``, hiding widgets at once."""
+ while layout.count():
+ item = layout.takeAt(0)
+ widget = item.widget()
+ if widget is not None:
+ widget.hide()
+ widget.deleteLater()
+ elif item.layout() is not None:
+ clear_layout(item.layout())
+
+
+def hline() -> QFrame:
+ """Thin separator line."""
+ line = QFrame()
+ line.setProperty("role", "rule")
+ line.setFixedHeight(1)
+ return line
+
+
+def view_qss(t: ThemeTokens) -> str:
+ """Stylesheet shared by both views."""
+ return field_qss(t) + f"""
+ QWidget#DevicesRoot, QWidget#ConfigRoot {{ background: {t.bg.name()}; }}
+ QWidget[pane="card"] {{ background: {t.card.name()}; }}
+ QWidget[pane="bg"] {{ background: {t.bg.name()}; }}
+ QWidget[pane="bar"] {{ background: {t.card.name()};
+ border-bottom: 1px solid {t.border.name()}; }}
+ QFrame[role="rule"] {{ background: {t.border.name()}; border: none; }}
+ QLabel {{ color: {t.fg.name()}; font-size: 13px; background: transparent; }}
+ QLabel[role="muted"] {{ color: {t.fg_muted.name()}; font-size: 12px; }}
+ QLabel[role="faint"] {{ color: {t.fg_subtle.name()}; font-size: 11px; }}
+ QLabel[role="upper"] {{ color: {t.fg_subtle.name()}; font-size: 11px; font-weight: 700;
+ letter-spacing: 0.5px; }}
+ QLabel[role="title"] {{ font-size: 14px; font-weight: 700; font-family: {MONO}; }}
+ QLabel[role="mono"] {{ font-family: {MONO}; font-size: 12px; }}
+ QLabel[role="big"] {{ font-size: 28px; font-weight: 600; font-family: {MONO}; }}
+ QLabel[role="was"] {{ color: {t.warning.name()}; font-size: 11px; }}
+ QLabel[role="err"] {{ color: {t.danger.name()}; font-size: 12px; }}
+ QLabel[role="link"] {{ color: {t.primary.name()}; font-size: 12px; }}
+ QToolButton[tb="ghost"] {{ color: {t.fg.name()}; background: transparent; border: none;
+ border-radius: 6px; padding: 4px 8px; font-size: 12px; }}
+ QToolButton[tb="ghost"]:hover {{ background: {t.hover.name()}; }}
+ QToolButton[tb="ghost"]:pressed {{ background: {t.pressed.name()}; }}
+ QToolButton[tb="ghost"]:disabled {{ color: {t.fg_subtle.name()}; }}
+ QToolButton::menu-indicator {{ image: none; width: 0; }}
+ QPushButton[seg="true"] {{ background: transparent; color: {t.fg_muted.name()};
+ border: none; border-radius: 5px; padding: 3px 10px; font-size: 12px; }}
+ QPushButton[seg="true"]:checked {{ background: {t.card.name()}; color: {t.fg.name()};
+ font-weight: 600; }}
+ QFrame[role="segbox"] {{ background: {t.track.name()}; border-radius: 7px; }}
+ QPushButton[role="linkbtn"] {{ color: {t.primary.name()}; background: transparent;
+ border: none; font-size: 12px; padding: 0; }}
+ QPushButton[role="problem"] {{ text-align: left; background: transparent; border: none;
+ border-radius: 4px; padding: 3px 4px; color: {t.fg.name()}; font-size: 12px; }}
+ QPushButton[role="problem"]:hover {{ background: {t.hover.name()}; }}
+ QCheckBox {{ color: {t.fg.name()}; font-size: 12px; spacing: 6px; }}
+ QTableView {{ background: {t.card.name()}; color: {t.fg.name()}; border: none;
+ gridline-color: transparent; font-size: 12px;
+ selection-background-color: {t.soft(t.primary, 0.18).name()};
+ selection-color: {t.fg.name()}; }}
+ QTableView::item {{ border-bottom: 1px solid {t.soft(t.border, 0.6).name()};
+ padding: 0 6px; }}
+ QTableView::item:selected {{ background: {t.soft(t.primary, 0.18).name()};
+ color: {t.fg.name()}; }}
+ QHeaderView::section {{ background: {t.card.name()}; color: {t.fg_muted.name()};
+ border: none; border-bottom: 1px solid {t.border.name()}; padding: 4px 6px;
+ font-size: 11px; font-weight: 700; }}
+ QTabBar::tab {{ background: transparent; color: {t.fg_muted.name()}; padding: 6px 12px;
+ border: none; border-bottom: 2px solid transparent; font-size: 12px; }}
+ QTabBar::tab:selected {{ color: {t.fg.name()}; border-bottom: 2px solid
+ {t.primary.name()}; font-weight: 600; }}
+ QPlainTextEdit {{ background: {t.field.name()}; color: {t.fg.name()};
+ border: 1px solid {t.border.name()}; border-radius: 6px;
+ font-family: {MONO}; font-size: 12px; }}
+ QScrollArea {{ border: none; background: transparent; }}
+ QFrame[role="banner-warn"] {{ background: {t.soft(t.warning, 0.16).name()};
+ border: 1px solid {rgba(t.warning, 0.5)}; border-radius: 8px; }}
+ QFrame[role="banner-err"] {{ background: {t.soft(t.danger, 0.14).name()};
+ border: 1px solid {rgba(t.danger, 0.5)}; border-radius: 8px; }}
+ QFrame[role="banner-ok"] {{ background: {t.soft(t.success, 0.14).name()};
+ border: 1px solid {rgba(t.success, 0.5)}; border-radius: 8px; }}
+ QFrame[role="diffcard"] {{ background: {t.card.name()}; border: 1px solid
+ {t.border.name()}; border-radius: 7px; }}
+ QFrame[role="toast"] {{ background: {t.fg.name()}; border-radius: 8px; }}
+ QFrame[role="toast"] QLabel {{ color: {t.bg.name()}; font-size: 12px; }}
+ QFrame[role="toast"] QPushButton {{ color: {t.primary.lighter(140).name() if t.dark
+ else t.primary.lighter(160).name()}; background: transparent; border: none;
+ font-weight: 700; font-size: 12px; }}
+ QMenu {{ background: {t.card.name()}; color: {t.fg.name()}; border: 1px solid
+ {t.border.name()}; border-radius: 8px; padding: 4px; }}
+ QMenu::item {{ padding: 6px 18px 6px 10px; border-radius: 5px; }}
+ QMenu::item:selected {{ background: {t.hover.name()}; }}
+ QMenu::section {{ color: {t.fg_subtle.name()}; font-size: 11px; font-weight: 700;
+ padding: 6px 10px 2px; }}
+ """
+
+
+def tool(text: str, icon: str, tip: str = "") -> QToolButton:
+ """Ghost toolbar button with icon and text."""
+ btn = QToolButton()
+ btn.setProperty("tb", "ghost")
+ btn.setText(text)
+ btn.setProperty("icon_name", icon)
+ btn.setToolButtonStyle(
+ Qt.ToolButtonStyle.ToolButtonTextBesideIcon
+ if text
+ else Qt.ToolButtonStyle.ToolButtonIconOnly
+ )
+ btn.setIconSize(QSize(16, 16))
+ btn.setToolTip(tip or text)
+ btn.setAccessibleName(text or tip)
+ btn.setCursor(Qt.CursorShape.PointingHandCursor)
+ return btn
+
+
+def retheme_icons(root: QWidget, tokens: ThemeTokens) -> None:
+ """Re-tint every ghost tool button icon below ``root``."""
+ for btn in root.findChildren(QToolButton):
+ name = btn.property("icon_name")
+ if name:
+ btn.setIcon(
+ material_icon(name, size=(32, 32), color=tokens.fg_muted, convert_to_pixmap=False)
+ )
+ for child in root.findChildren(QWidget):
+ refresh = getattr(child, "refresh_theme", None)
+ if callable(refresh):
+ refresh(tokens)
+
+
+class Banner(QFrame):
+ """Inline banner (warn, err, ok) with icon, title and text."""
+
+ def __init__(self, tone: str = "warn", parent=None):
+ super().__init__(parent)
+ self.setProperty("role", f"banner-{tone}")
+ self._tone = tone
+ lay = QHBoxLayout(self)
+ lay.setContentsMargins(10, 8, 10, 8)
+ lay.setSpacing(10)
+ self.icon = QLabel()
+ self.icon.setFixedSize(18, 18)
+ lay.addWidget(self.icon, 0, Qt.AlignmentFlag.AlignTop)
+ col = QVBoxLayout()
+ col.setSpacing(2)
+ self.title = label("")
+ self.title.setStyleSheet("font-weight: 700;")
+ self.text = label("", "muted")
+ self.text.setWordWrap(True)
+ col.addWidget(self.title)
+ col.addWidget(self.text)
+ lay.addLayout(col, 1)
+ self._icon_name = "lock"
+
+ def set_content(self, icon: str, title: str, text: str) -> None:
+ """Set icon, bold title and body text."""
+ self._icon_name = icon
+ self.title.setText(title)
+ self.text.setText(text)
+ self.text.setVisible(bool(text))
+ self.refresh_theme(ThemeTokens())
+
+ def refresh_theme(self, tokens: ThemeTokens) -> None:
+ """Re-tint the icon."""
+ color = tone_color(tokens, self._tone)
+ self.icon.setPixmap(material_icon(self._icon_name, size=(18, 18), color=color))
+
+
+class Toasts(QWidget):
+ """Bottom-right stack of short messages; one may offer Undo."""
+
+ undo_requested = Signal()
+
+ def __init__(self, parent: QWidget):
+ super().__init__(parent)
+ self.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, False)
+ self._lay = QVBoxLayout(self)
+ self._lay.setContentsMargins(0, 0, 0, 0)
+ self._lay.setSpacing(6)
+ self.hide()
+
+ def show_toast(self, message: str, tone: str = "neutral", undo: bool = False) -> None:
+ """Show a message for five seconds."""
+ frame = QFrame(self)
+ frame.setProperty("role", "toast")
+ row = QHBoxLayout(frame)
+ row.setContentsMargins(12, 8, 8, 8)
+ text = label(message)
+ text.setWordWrap(True)
+ text.setMaximumWidth(380)
+ row.addWidget(text, 1)
+ if undo:
+ btn = QPushButton("Undo")
+ btn.setCursor(Qt.CursorShape.PointingHandCursor)
+ btn.clicked.connect(self.undo_requested.emit)
+ btn.clicked.connect(frame.deleteLater)
+ row.addWidget(btn)
+ self._lay.addWidget(frame)
+ while self._lay.count() > 3:
+ old = self._lay.takeAt(0).widget()
+ if old is not None:
+ old.deleteLater()
+ QTimer.singleShot(5000, frame, frame.deleteLater)
+ QTimer.singleShot(5050, self, self._reposition)
+ self.show()
+ self.raise_()
+ self._reposition()
+
+ def _reposition(self) -> None:
+ parent = self.parentWidget()
+ if self._lay.count() == 0 or parent is None:
+ self.hide()
+ return
+ self.adjustSize()
+ self.move(parent.width() - self.width() - 16, parent.height() - self.height() - 16)
+
+
+class Sheet(QDialog):
+ """Window-modal sheet with a title, a sub line, a scrolling body and a footer."""
+
+ def __init__(self, parent: QWidget, title: str, sub: str = "", wide: bool = False):
+ super().__init__(parent)
+ self.setWindowTitle(title)
+ self.setModal(True)
+ self.setMinimumWidth(640 if wide else 420)
+ tokens = ThemeTokens()
+ self.setStyleSheet(view_qss(tokens) + f"QDialog {{ background: {tokens.card.name()}; }}")
+ outer = QVBoxLayout(self)
+ outer.setContentsMargins(20, 18, 20, 16)
+ outer.setSpacing(12)
+ head = label(title)
+ head.setStyleSheet("font-size: 16px; font-weight: 700;")
+ outer.addWidget(head)
+ if sub:
+ sub_label = label(sub, "muted")
+ sub_label.setWordWrap(True)
+ sub_label.setTextFormat(Qt.TextFormat.RichText)
+ outer.addWidget(sub_label)
+ self.body = QVBoxLayout()
+ self.body.setSpacing(8)
+ body_widget = QWidget()
+ body_widget.setLayout(self.body)
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setWidget(body_widget)
+ scroll.setSizeAdjustPolicy(QScrollArea.SizeAdjustPolicy.AdjustToContents)
+ scroll.setMaximumHeight(460)
+ outer.addWidget(scroll, 1)
+ self.foot = QHBoxLayout()
+ self.foot.setSpacing(8)
+ outer.addLayout(self.foot)
+
+ def clear_body(self) -> None:
+ """Remove all body widgets."""
+ clear_layout(self.body)
+
+ def clear_foot(self) -> None:
+ """Remove all footer widgets."""
+ clear_layout(self.foot)
+
+
+class RowsModel(QAbstractTableModel):
+ """Table model over a list of row dictionaries.
+
+ Args:
+ columns: ``[(key, header)]``.
+ check_key: Column key shown as a checkbox (toggling emits :attr:`toggled`).
+ """
+
+ toggled = Signal(str, bool)
+
+ def __init__(self, columns: list[tuple[str, str]], check_key: str = "", parent=None):
+ super().__init__(parent)
+ self.columns = columns
+ self.check_key = check_key
+ self.rows: list[dict] = []
+
+ def set_rows(self, rows: list[dict]) -> None:
+ """Replace the rows; same-length updates only repaint."""
+ if len(rows) == len(self.rows) and [r["name"] for r in rows] == [
+ r["name"] for r in self.rows
+ ]:
+ self.rows = rows
+ if rows:
+ self.dataChanged.emit(
+ self.index(0, 0), self.index(len(rows) - 1, len(self.columns) - 1)
+ )
+ return
+ self.beginResetModel()
+ self.rows = rows
+ self.endResetModel()
+
+ # pylint: disable=invalid-name, missing-function-docstring
+ def rowCount(self, parent=QModelIndex()):
+ return 0 if parent.isValid() else len(self.rows)
+
+ def columnCount(self, parent=QModelIndex()):
+ return 0 if parent.isValid() else len(self.columns)
+
+ def headerData(self, section, orientation, role=Qt.ItemDataRole.DisplayRole):
+ if orientation == Qt.Orientation.Horizontal and role == Qt.ItemDataRole.DisplayRole:
+ return self.columns[section][1]
+ return None
+
+ def data(self, index, role=Qt.ItemDataRole.DisplayRole):
+ if not index.isValid():
+ return None
+ row = self.rows[index.row()]
+ key = self.columns[index.column()][0]
+ if key == self.check_key:
+ if role == Qt.ItemDataRole.CheckStateRole:
+ return Qt.CheckState.Checked if row.get(key) else Qt.CheckState.Unchecked
+ return None
+ if role == Qt.ItemDataRole.DisplayRole:
+ return str(row.get(key, ""))
+ if role == Qt.ItemDataRole.UserRole:
+ return row
+ if role == Qt.ItemDataRole.ToolTipRole and key == "change":
+ return row.get("changeLabel") or None
+ return None
+
+ def flags(self, index):
+ base = Qt.ItemFlag.ItemIsEnabled | Qt.ItemFlag.ItemIsSelectable
+ if self.columns[index.column()][0] == self.check_key:
+ base |= Qt.ItemFlag.ItemIsUserCheckable
+ return base
+
+ def setData(self, index, value, role=Qt.ItemDataRole.EditRole):
+ key = self.columns[index.column()][0]
+ if key == self.check_key and role == Qt.ItemDataRole.CheckStateRole:
+ checked = value in (Qt.CheckState.Checked, Qt.CheckState.Checked.value, 2, True)
+ self.toggled.emit(self.rows[index.row()]["name"], checked)
+ return True
+ return False
+
+
+class RowDelegate(QStyledItemDelegate):
+ """Paints change marks, status chips, bold mono names and right-aligned values."""
+
+ def paint(self, painter, option, index):
+ row = index.data(Qt.ItemDataRole.UserRole)
+ key = index.model().columns[index.column()][0]
+ tokens = ThemeTokens()
+ if row is None or key not in (
+ "change",
+ "status",
+ "name",
+ "readout",
+ "valueText",
+ "what",
+ "on",
+ ):
+ super().paint(painter, option, index)
+ return
+ painter.save()
+ if option.state & QStyle.StateFlag.State_Selected:
+ painter.fillRect(option.rect, tokens.soft(tokens.primary, 0.18))
+ elif option.state & QStyle.StateFlag.State_MouseOver:
+ painter.fillRect(option.rect, tokens.hover)
+ rect = QRectF(option.rect.adjusted(8, 0, -8, 0))
+ if key == "change" and row.get("change"):
+ font = QFont(MONO.split(",")[0])
+ font.setPixelSize(12)
+ font.setWeight(QFont.Weight.Bold)
+ painter.setFont(font)
+ painter.setPen(tone_color(tokens, {"A": "ok", "M": "warn", "D": "err"}[row["change"]]))
+ painter.drawText(rect, Qt.AlignmentFlag.AlignVCenter, row["change"])
+ elif key == "status":
+ paint_chip(
+ painter, rect, row["statusText"], row["statusTone"], row["statusIcon"], tokens
+ )
+ elif key == "name":
+ font = QFont(MONO.split(",")[0])
+ font.setPixelSize(12)
+ font.setWeight(QFont.Weight.DemiBold)
+ painter.setFont(font)
+ painter.setPen(tokens.fg)
+ painter.drawText(rect, Qt.AlignmentFlag.AlignVCenter, row["name"])
+ elif key == "readout":
+ painter.setPen(tokens.fg)
+ text = row["readout"]
+ painter.drawText(rect, Qt.AlignmentFlag.AlignVCenter, text)
+ if row.get("readoutWas"):
+ dx = painter.fontMetrics().horizontalAdvance(text + " ")
+ small = QFont(painter.font())
+ small.setPixelSize(11)
+ painter.setFont(small)
+ painter.setPen(tokens.fg_subtle)
+ painter.drawText(
+ rect.adjusted(dx, 0, 0, 0), Qt.AlignmentFlag.AlignVCenter, row["readoutWas"]
+ )
+ elif key == "what":
+ painter.setPen(tokens.fg_muted)
+ text = painter.fontMetrics().elidedText(
+ row["what"], Qt.TextElideMode.ElideRight, int(rect.width())
+ )
+ painter.drawText(rect, Qt.AlignmentFlag.AlignVCenter, text)
+ elif key == "on":
+ box = QRectF(rect.left(), rect.center().y() - 7.5, 15, 15)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ painter.setPen(tokens.primary if row["on"] else tokens.border)
+ painter.setBrush(tokens.primary if row["on"] else tokens.field)
+ painter.drawRoundedRect(box, 4, 4)
+ if row["on"]:
+ pix = material_icon("check", size=(24, 24), color=tokens.on_primary)
+ painter.drawPixmap(box.adjusted(1.5, 1.5, -1.5, -1.5).toRect(), pix)
+ elif key == "valueText":
+ font = QFont(MONO.split(",")[0])
+ font.setPixelSize(12)
+ painter.setFont(font)
+ painter.setPen(tokens.fg)
+ painter.drawText(
+ rect, Qt.AlignmentFlag.AlignVCenter | Qt.AlignmentFlag.AlignRight, row["valueText"]
+ )
+ painter.restore()
+
+ def editorEvent(self, event, model, option, index): # pylint: disable=invalid-name
+ """Toggle the checkbox column with one click."""
+ if (
+ model.columns[index.column()][0] == model.check_key
+ and event.type() == event.Type.MouseButtonRelease
+ ):
+ row = model.rows[index.row()]
+ model.toggled.emit(row["name"], not row[model.check_key])
+ return True
+ return False
+
+
+def make_table(model: RowsModel, widths: dict[str, int]) -> QTableView:
+ """Read-only table with row selection, the row delegate and fixed column widths."""
+ table = QTableView()
+ table.setModel(model)
+ table.setItemDelegate(RowDelegate(table))
+ table.setSelectionBehavior(QAbstractItemView.SelectionBehavior.SelectRows)
+ table.setSelectionMode(QAbstractItemView.SelectionMode.SingleSelection)
+ table.setEditTriggers(QAbstractItemView.EditTrigger.NoEditTriggers)
+ table.verticalHeader().hide()
+ table.verticalHeader().setDefaultSectionSize(32)
+ table.setShowGrid(False)
+ table.setMouseTracking(True)
+ table.setWordWrap(False)
+ header = table.horizontalHeader()
+ header.setDefaultAlignment(Qt.AlignmentFlag.AlignLeft | Qt.AlignmentFlag.AlignVCenter)
+ header.setHighlightSections(False)
+ for col, (key, _) in enumerate(model.columns):
+ if key in widths:
+ header.setSectionResizeMode(col, QHeaderView.ResizeMode.Fixed)
+ table.setColumnWidth(col, widths[key])
+ else:
+ header.setSectionResizeMode(col, QHeaderView.ResizeMode.Stretch)
+ # Keep the selected row in view when the viewport changes height (first show, panels
+ # appearing below the table).
+ table.verticalScrollBar().rangeChanged.connect(
+ lambda *_: table.currentIndex().isValid() and table.scrollTo(table.currentIndex())
+ )
+ return table
+
+
+def select_row(table: QTableView, model: RowsModel, name: str | None) -> None:
+ """Select the row of ``name`` without emitting a new selection round-trip."""
+ for i, row in enumerate(model.rows):
+ if row["name"] == name:
+ if table.currentIndex().row() != i:
+ table.blockSignals(True)
+ table.selectionModel().blockSignals(True)
+ table.selectRow(i)
+ table.selectionModel().blockSignals(False)
+ table.blockSignals(False)
+ table.scrollTo(model.index(i, 0))
+ return
+ table.clearSelection()
+
+
+class Segmented(QFrame):
+ """Segmented control of checkable buttons."""
+
+ changed = Signal(str)
+
+ def __init__(self, options: list[tuple[str, str]], value: str, parent=None):
+ super().__init__(parent)
+ self.setProperty("role", "segbox")
+ lay = QHBoxLayout(self)
+ lay.setContentsMargins(3, 3, 3, 3)
+ lay.setSpacing(2)
+ self.group = QButtonGroup(self)
+ self.buttons: dict[str, QPushButton] = {}
+ for key, text in options:
+ btn = QPushButton(text)
+ btn.setProperty("seg", "true")
+ btn.setCheckable(True)
+ btn.setChecked(key == value)
+ btn.setCursor(Qt.CursorShape.PointingHandCursor)
+ btn.clicked.connect(lambda _=False, k=key: self.changed.emit(k))
+ self.group.addButton(btn)
+ self.buttons[key] = btn
+ lay.addWidget(btn)
+
+
+class Sparkline(QWidget):
+ """Tiny live line of recent values."""
+
+ def __init__(self, parent=None):
+ super().__init__(parent)
+ self.points: list[float] = []
+ self.setMinimumHeight(70)
+ self.setMaximumWidth(380)
+
+ def set_points(self, points: list[float]) -> None:
+ """Replace the values and repaint."""
+ self.points = points
+ self.update()
+
+ def paintEvent(self, _event): # pylint: disable=invalid-name
+ if len(self.points) < 2:
+ return
+ painter = QPainter(self)
+ painter.setRenderHint(QPainter.RenderHint.Antialiasing)
+ lo, hi = min(self.points), max(self.points)
+ span = (hi - lo) or 1.0
+ w, h = self.width(), self.height()
+ path = QPainterPath()
+ for i, v in enumerate(self.points):
+ x = i / 89 * w
+ y = h - 5 - (v - lo) / span * (h - 10)
+ if i:
+ path.lineTo(x, y)
+ else:
+ path.moveTo(x, y)
+ painter.setPen(QPen(ThemeTokens().primary, 1.6))
+ painter.drawPath(path)
+
+
+# ----------------------------------------------------------------------------------- Devices view
+class DevicesViewQWidget(BECWidget, QWidget):
+ """Devices for everyone: find a device, see its live value and move it (QWidget version)."""
+
+ RPC = False
+ PLUGIN = False
+
+ def __init__(self, parent=None, client=None, **kwargs):
+ super().__init__(parent=parent, client=client, **kwargs)
+ self.get_bec_shortcuts()
+ self.setObjectName("DevicesRoot")
+ self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
+ self.browser = DeviceBrowser(self.client, self.bec_dispatcher, self)
+ self.open_in_workspace = self.browser.open_in_workspace
+ self._motor = None
+ self._build()
+ self.browser.changed.connect(self._sync)
+ self.browser.values_changed.connect(self._sync_values)
+ self.browser.spark_changed.connect(self._sync_spark)
+ self.apply_theme("")
+ self._sync()
+
+ def _build(self) -> None:
+ root = QVBoxLayout(self)
+ root.setContentsMargins(0, 0, 0, 0)
+ root.setSpacing(0)
+ bar = QWidget()
+ bar.setProperty("pane", "bar")
+ bar.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
+ row = QHBoxLayout(bar)
+ row.setContentsMargins(12, 8, 12, 8)
+ row.setSpacing(10)
+ self.title_icon = QLabel()
+ row.addWidget(self.title_icon)
+ title = label("Devices")
+ title.setStyleSheet("font-weight: 700;")
+ row.addWidget(title)
+ self.search = QLineEdit()
+ self.search.setPlaceholderText("Search devices by name, tag or description")
+ self.search.setAccessibleName("Search devices")
+ self.search.setFixedWidth(300)
+ self._debounce = QTimer(self, singleShot=True, interval=120)
+ self._debounce.timeout.connect(lambda: self.browser.set_query(self.search.text()))
+ self.search.textChanged.connect(lambda _: self._debounce.start())
+ row.addWidget(self.search)
+ self.kinds = Segmented(KINDS, self.browser.kind)
+ self.kinds.changed.connect(self.browser.set_kind)
+ row.addWidget(self.kinds)
+ row.addStretch(1)
+ lock = Chip("Read-only configuration", "neutral", "lock")
+ lock.setToolTip("Editing the device session is in Config (staff)")
+ row.addWidget(lock)
+ root.addWidget(bar)
+
+ body = QHBoxLayout()
+ body.setSpacing(0)
+ left = QWidget()
+ left.setProperty("pane", "card")
+ left.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
+ left_lay = QVBoxLayout(left)
+ left_lay.setContentsMargins(0, 0, 0, 0)
+ left_lay.setSpacing(0)
+ self.shown = label("", "muted")
+ self.shown.setContentsMargins(10, 6, 10, 6)
+ left_lay.addWidget(self.shown)
+ self.model = RowsModel([("name", "Name"), ("what", "What it is"), ("valueText", "Value")])
+ self.table = make_table(self.model, {"name": 150, "valueText": 140})
+ self.table.selectionModel().currentRowChanged.connect(self._on_row)
+ left_lay.addWidget(self.table, 1)
+ body.addWidget(left, 1)
+ body.addWidget(self._vline())
+
+ self.detail = QWidget()
+ self.detail.setProperty("pane", "bg")
+ self.detail.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
+ detail_scroll = QScrollArea()
+ detail_scroll.setWidgetResizable(True)
+ detail_scroll.setWidget(self.detail)
+ outer = QHBoxLayout(self.detail)
+ outer.setContentsMargins(16, 12, 16, 12)
+ column = QWidget()
+ column.setMaximumWidth(440)
+ column.setMinimumWidth(360)
+ outer.addStretch(1)
+ outer.addWidget(column, 3)
+ outer.addStretch(1)
+ d = QVBoxLayout(column)
+ d.setContentsMargins(0, 0, 0, 0)
+ d.setSpacing(8)
+ self._column = column
+ self.head = QWidget()
+ head = QVBoxLayout(self.head)
+ head.setContentsMargins(0, 0, 0, 0)
+ head.setSpacing(4)
+ self.d_name = label("", "title")
+ self.d_name.setStyleSheet("font-size: 15px;")
+ self.d_desc = label("", "muted")
+ self.d_desc.setWordWrap(True)
+ self.d_value = label("", "big")
+ self.spark = Sparkline()
+ self.spark_note = label("last ~20 s, live", "faint")
+ for w in (self.d_name, self.d_desc, self.d_value, self.spark, self.spark_note):
+ head.addWidget(w)
+ d.addWidget(self.head)
+ self.motor_holder = QVBoxLayout()
+ d.addLayout(self.motor_holder)
+ self.facts = QGridLayout()
+ self.facts.setHorizontalSpacing(12)
+ self.facts.setVerticalSpacing(3)
+ self.facts.setColumnStretch(1, 1)
+ d.addLayout(self.facts)
+ self.action = TextButton("", "neutral", "add")
+ self.action.clicked.connect(self.browser.request_open)
+ d.addWidget(self.action, 0, Qt.AlignmentFlag.AlignLeft)
+ self.empty = label("Select a device to see it here.", "muted")
+ d.addWidget(self.empty)
+ d.addStretch(1)
+ body.addWidget(detail_scroll, 1)
+ root.addLayout(body, 1)
+
+ @staticmethod
+ def _vline() -> QFrame:
+ line = QFrame()
+ line.setProperty("role", "rule")
+ line.setFixedWidth(1)
+ return line
+
+ def _on_row(self, current: QModelIndex, _previous: QModelIndex) -> None:
+ if current.isValid():
+ self.browser.select(self.model.rows[current.row()]["name"])
+
+ @SafeSlot()
+ def _sync(self) -> None:
+ rows = self.browser.rows()
+ if self.browser.selected is None and rows:
+ self.browser.selected = rows[0]["name"]
+ rows = self.browser.rows()
+ self.model.set_rows(rows)
+ select_row(self.table, self.model, self.browser.selected)
+ self.shown.setText(self.browser.shown_text())
+ for key, btn in self.kinds.buttons.items():
+ btn.setChecked(key == self.browser.kind)
+ self._sync_detail()
+
+ @SafeSlot()
+ def _sync_values(self) -> None:
+ self.model.set_rows(self.browser.rows())
+ detail = self.browser.detail()
+ if detail.get("name") and detail["kind"] != "positioner":
+ self.d_value.setText(self._value_html(detail))
+
+ @staticmethod
+ def _value_html(detail: dict) -> str:
+ unit = detail.get("units", "")
+ return f"{detail['valueText']} {unit}"
+
+ @SafeSlot()
+ def _sync_spark(self) -> None:
+ self.spark.set_points(self.browser.spark_points())
+
+ def _sync_detail(self) -> None:
+ detail = self.browser.detail()
+ has = bool(detail.get("name"))
+ self.empty.setVisible(not has)
+ self.head.setVisible(has)
+ self.action.setVisible(has)
+ clear_layout(self.facts)
+ if not has:
+ self._show_motor(None)
+ return
+ kind = detail["kind"]
+ self._show_motor(detail["name"] if kind == "positioner" else None)
+ self.d_name.setText(detail["name"])
+ self.d_desc.setText(detail["description"])
+ self.d_name.setVisible(kind != "positioner")
+ monitor = kind == "monitor"
+ self.d_value.setVisible(monitor)
+ self.spark.setVisible(monitor)
+ self.spark_note.setVisible(monitor)
+ self.d_value.setText(self._value_html(detail))
+ self.spark.set_points(self.browser.spark_points())
+ for i, fact in enumerate(detail["facts"]):
+ self.facts.addWidget(label(fact["label"], "muted"), i, 0)
+ self.facts.addWidget(label(fact["value"]), i, 1)
+ self.action.setText(detail["actionText"])
+ self.action._icon_name = {"detector": "image", "monitor": "show_chart"}.get(kind, "add")
+ self.action.set_variant("primary" if kind == "detector" else "neutral")
+
+ def _show_motor(self, name: str | None) -> None:
+ if name is None:
+ if self._motor is not None:
+ self._motor.hide()
+ return
+ if self._motor is None:
+ from bec_widgets.widgets.control.device_control.positioner_box.positioner_box_qwidget import ( # pylint: disable=import-outside-toplevel
+ PositionerBoxQWidget,
+ )
+
+ self._motor = PositionerBoxQWidget(parent=self._column, device=name, client=self.client)
+ self._motor.hide_device_selection = True
+ self._motor.setSizePolicy(QSizePolicy.Policy.Preferred, QSizePolicy.Policy.Maximum)
+ self.motor_holder.addWidget(self._motor)
+ elif self._motor.device != name:
+ self._motor.set_positioner(name)
+ self._motor.show()
+
+ @SafeSlot(str)
+ def apply_theme(self, theme: str):
+ tokens = ThemeTokens()
+ self.setStyleSheet(view_qss(tokens))
+ self.title_icon.setPixmap(material_icon("memory", size=(18, 18), color=tokens.fg_muted))
+ retheme_icons(self, tokens)
+ self.update()
+
+ def cleanup(self):
+ self.browser.cleanup()
+ if self._motor is not None:
+ self._motor.close()
+ self._motor.deleteLater()
+ super().cleanup()
+
+
+# ------------------------------------------------------------------------------------ Config view
+class DeviceConfigViewQWidget(BECWidget, QWidget):
+ """Config for staff: edit a copy of the session config, review, then apply (QWidget version)."""
+
+ RPC = False
+ PLUGIN = False
+
+ def __init__(self, parent=None, client=None, load_session: bool = True, **kwargs):
+ super().__init__(parent=parent, client=client, **kwargs)
+ self.get_bec_shortcuts()
+ self.setObjectName("ConfigRoot")
+ self.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
+ self.editor = ConfigEditor(self.client, self.bec_dispatcher, self)
+ self._tab = "form"
+ self._last_detail: dict | None = None
+ self._sheet: Sheet | None = None
+ self._build()
+ self.toasts = Toasts(self)
+ self.toasts.undo_requested.connect(self.editor.undo)
+ self.editor.changed.connect(self._schedule_sync)
+ self.editor.toast.connect(self.toasts.show_toast)
+ self.editor.apply_progress.connect(lambda *_: self._sync_apply_sheet())
+ self.editor.apply_finished.connect(lambda *_: self._sync_apply_sheet(final=True))
+ self._sync_timer = QTimer(self, singleShot=True, interval=0)
+ self._sync_timer.timeout.connect(self._sync)
+ self.apply_theme("")
+ if load_session:
+ self.editor.load_session()
+ self._sync()
+
+ # ------------------------------------------------------------------ layout
+ def _build(self) -> None:
+ root = QVBoxLayout(self)
+ root.setContentsMargins(0, 0, 0, 0)
+ root.setSpacing(0)
+ root.addWidget(self._build_bar())
+ root.addWidget(self._build_toolbar())
+ body = QHBoxLayout()
+ body.setSpacing(0)
+ body.addWidget(self._build_filters())
+ body.addWidget(DevicesViewQWidget._vline())
+ center = QVBoxLayout()
+ center.setSpacing(0)
+ center.addWidget(self._build_table(), 1)
+ center.addWidget(hline())
+ center.addWidget(self._build_problems())
+ center_w = QWidget()
+ center_w.setLayout(center)
+ body.addWidget(center_w, 1)
+ body.addWidget(DevicesViewQWidget._vline())
+ body.addWidget(self._build_inspector())
+ root.addLayout(body, 1)
+
+ def _build_bar(self) -> QWidget:
+ bar = QWidget()
+ bar.setProperty("pane", "bar")
+ bar.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
+ col = QVBoxLayout(bar)
+ col.setContentsMargins(12, 8, 12, 8)
+ col.setSpacing(8)
+ row = QHBoxLayout()
+ row.setSpacing(8)
+ self.dot = QLabel()
+ self.dot.setFixedSize(9, 9)
+ row.addWidget(self.dot)
+ title = label("Running session")
+ title.setStyleSheet("font-weight: 700;")
+ row.addWidget(title)
+ self.session_count = label("", "muted")
+ row.addWidget(self.session_count)
+ row.addSpacing(10)
+ row.addWidget(self._vline_short())
+ row.addSpacing(10)
+ self.file_icon = QLabel()
+ row.addWidget(self.file_icon)
+ row.addWidget(label("Editing a copy from", "muted"))
+ self.source = label("", "mono")
+ self.source.setStyleSheet("font-weight: 700;")
+ row.addWidget(self.source)
+ self.change_chip = Chip()
+ row.addWidget(self.change_chip)
+ row.addStretch(1)
+ self.review_btn = TextButton("Review changes", "primary", "difference")
+ self.review_btn.clicked.connect(self.open_review)
+ row.addWidget(self.review_btn)
+ col.addLayout(row)
+ self.scan_banner = Banner("warn")
+ col.addWidget(self.scan_banner)
+ return bar
+
+ @staticmethod
+ def _vline_short() -> QFrame:
+ line = QFrame()
+ line.setProperty("role", "rule")
+ line.setFixedSize(1, 18)
+ return line
+
+ def _build_toolbar(self) -> QWidget:
+ bar = QWidget()
+ bar.setProperty("pane", "bar")
+ bar.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
+ row = QHBoxLayout(bar)
+ row.setContentsMargins(8, 4, 8, 4)
+ row.setSpacing(2)
+ self.open_btn = tool("Open…", "folder_open")
+ menu = QMenu(self.open_btn)
+ menu.addAction("Open YAML file…", self.open_file_dialog)
+ menu.addSeparator()
+ menu.addAction("Start from the running session", self.editor.load_session)
+ self.open_btn.setMenu(menu)
+ self.open_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
+ self.save_btn = tool("Save", "save", "Save the edited copy as YAML (Ctrl+S)")
+ self.save_btn.clicked.connect(self.save_file_dialog)
+ self.add_btn = tool("Add device", "add")
+ self.add_btn.clicked.connect(self.open_add_device)
+ self.remove_btn = tool("Remove", "delete")
+ self.remove_btn.clicked.connect(lambda: self.editor.remove())
+ self.test_btn = tool(
+ "Test connection", "power", "Tests the selected device; to test everything use ⋯"
+ )
+ self.test_btn.clicked.connect(lambda: self.editor.test())
+ self.undo_btn = tool("", "undo", "Undo (Ctrl+Z)")
+ self.undo_btn.clicked.connect(self.editor.undo)
+ self.more_btn = tool("", "more_horiz", "More")
+ self.more_menu = QMenu(self.more_btn)
+ self.more_btn.setMenu(self.more_menu)
+ self.more_btn.setPopupMode(QToolButton.ToolButtonPopupMode.InstantPopup)
+ self.more_menu.aboutToShow.connect(self._fill_more_menu)
+ for w in (self.open_btn, self.save_btn):
+ row.addWidget(w)
+ row.addSpacing(6)
+ row.addWidget(self._vline_short())
+ row.addSpacing(6)
+ for w in (self.add_btn, self.remove_btn, self.test_btn):
+ row.addWidget(w)
+ row.addSpacing(6)
+ row.addWidget(self._vline_short())
+ row.addSpacing(6)
+ row.addWidget(self.undo_btn)
+ row.addStretch(1)
+ row.addWidget(self.more_btn)
+ return bar
+
+ def _fill_more_menu(self) -> None:
+ self.more_menu.clear()
+ self.more_menu.addAction(f"Test all {len(self.editor.work)} devices", self.editor.test_all)
+ self.more_menu.addAction("Reload from running session", self.editor.load_session)
+ self.more_menu.addSection("Danger zone")
+ clear = self.more_menu.addAction("Clear the running session…", self.open_clear_session)
+ clear.setIcon(material_icon("warning", size=(16, 16), color=ThemeTokens().danger))
+
+ def _build_filters(self) -> QWidget:
+ pane = QWidget()
+ pane.setProperty("pane", "bg")
+ pane.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
+ pane.setFixedWidth(220)
+ scroll = QScrollArea()
+ scroll.setWidgetResizable(True)
+ scroll.setFixedWidth(220)
+ scroll.setWidget(pane)
+ self.filters = QVBoxLayout(pane)
+ self.filters.setContentsMargins(10, 10, 10, 10)
+ self.filters.setSpacing(4)
+ self.filters.addWidget(label("SEARCH", "upper"))
+ self.search = QLineEdit()
+ self.search.setPlaceholderText("Name, class, tag or PV prefix")
+ self.search.setAccessibleName("Search devices")
+ self._debounce = QTimer(self, singleShot=True, interval=120)
+ self._debounce.timeout.connect(lambda: self.editor.set_query(self.search.text()))
+ self.search.textChanged.connect(lambda _: self._debounce.start())
+ self.filters.addWidget(self.search)
+ self.facet_box = QVBoxLayout()
+ self.facet_box.setSpacing(3)
+ self.filters.addLayout(self.facet_box)
+ self.filters.addStretch(1)
+ self._facet_key = None
+ return scroll
+
+ def _build_table(self) -> QWidget:
+ pane = QWidget()
+ pane.setProperty("pane", "card")
+ pane.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
+ lay = QVBoxLayout(pane)
+ lay.setContentsMargins(0, 0, 0, 0)
+ lay.setSpacing(0)
+ head = QHBoxLayout()
+ head.setContentsMargins(10, 6, 10, 6)
+ self.showing = label("", "muted")
+ head.addWidget(self.showing)
+ head.addStretch(1)
+ self.clear_filters_btn = QPushButton("Clear filters")
+ self.clear_filters_btn.setProperty("role", "linkbtn")
+ self.clear_filters_btn.setCursor(Qt.CursorShape.PointingHandCursor)
+ self.clear_filters_btn.clicked.connect(self._clear_filters)
+ head.addWidget(self.clear_filters_btn)
+ lay.addLayout(head)
+ self.model = RowsModel(
+ [
+ ("change", "Δ"),
+ ("status", "Status"),
+ ("name", "Name"),
+ ("deviceClass", "Class"),
+ ("readout", "Readout"),
+ ("on", "On"),
+ ("tags", "Tags"),
+ ],
+ check_key="on",
+ )
+ self.model.toggled.connect(lambda name, on: self.editor.set_field(name, "enabled", on))
+ self.table = make_table(
+ self.model,
+ {
+ "change": 30,
+ "status": 150,
+ "name": 130,
+ "deviceClass": 130,
+ "readout": 150,
+ "on": 40,
+ },
+ )
+ self.table.model().headerData # noqa: B018 - keep a reference for tooltips
+ self.table.selectionModel().currentRowChanged.connect(self._on_row)
+ self.table.installEventFilter(self)
+ lay.addWidget(self.table, 1)
+ return pane
+
+ def _build_problems(self) -> QWidget:
+ pane = QWidget()
+ pane.setProperty("pane", "bg")
+ pane.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
+ lay = QVBoxLayout(pane)
+ lay.setContentsMargins(10, 6, 10, 8)
+ lay.setSpacing(2)
+ head = QHBoxLayout()
+ head.setSpacing(6)
+ title = label("Problems")
+ title.setStyleSheet("font-weight: 700; font-size: 12px;")
+ head.addWidget(title)
+ self.problem_chip = Chip("0", "err")
+ head.addWidget(self.problem_chip)
+ self.unchecked_label = label("", "muted")
+ head.addWidget(self.unchecked_label)
+ head.addStretch(1)
+ lay.addLayout(head)
+ self.problem_list = QVBoxLayout()
+ self.problem_list.setSpacing(1)
+ lay.addLayout(self.problem_list)
+ pane.setMaximumHeight(150)
+ return pane
+
+ def _build_inspector(self) -> QWidget:
+ pane = QWidget()
+ pane.setProperty("pane", "bg")
+ pane.setAttribute(Qt.WidgetAttribute.WA_StyledBackground, True)
+ pane.setFixedWidth(340)
+ lay = QVBoxLayout(pane)
+ lay.setContentsMargins(0, 0, 0, 0)
+ lay.setSpacing(0)
+ head = QWidget()
+ h = QVBoxLayout(head)
+ h.setContentsMargins(12, 10, 12, 8)
+ h.setSpacing(5)
+ top = QHBoxLayout()
+ self.i_name = label("", "title")
+ self.i_class = label("", "muted")
+ self.revert_btn = TextButton("Revert", "neutral", "undo")
+ self.revert_btn.setMinimumHeight(26)
+ self.revert_btn.clicked.connect(lambda: self.editor.revert())
+ top.addWidget(self.i_name)
+ top.addWidget(self.i_class)
+ top.addStretch(1)
+ top.addWidget(self.revert_btn)
+ h.addLayout(top)
+ chips = QHBoxLayout()
+ chips.setSpacing(6)
+ self.i_status = Chip()
+ self.i_new = Chip("New — not in session", "ok", "add")
+ chips.addWidget(self.i_status)
+ chips.addWidget(self.i_new)
+ chips.addStretch(1)
+ h.addLayout(chips)
+ self.i_status_msg = label("", "err")
+ self.i_status_msg.setWordWrap(True)
+ h.addWidget(self.i_status_msg)
+ lay.addWidget(head)
+ tabs = QHBoxLayout()
+ tabs.setContentsMargins(12, 0, 12, 0)
+ self.tab_buttons = {}
+ for key, text in (("form", "Form"), ("yaml", "YAML"), ("docs", "Docs")):
+ btn = QPushButton(text)
+ btn.setCheckable(True)
+ btn.setProperty("seg", "true")
+ btn.setCursor(Qt.CursorShape.PointingHandCursor)
+ btn.clicked.connect(lambda _=False, k=key: self._set_tab(k))
+ self.tab_buttons[key] = btn
+ tabs.addWidget(btn)
+ tabs.addStretch(1)
+ tab_box = QFrame()
+ tab_box.setLayout(tabs)
+ lay.addWidget(tab_box)
+ lay.addWidget(hline())
+ self.stack = QStackedWidget()
+ self.form = QWidget()
+ self.form_lay = QVBoxLayout(self.form)
+ self.form_lay.setContentsMargins(12, 12, 12, 12)
+ self.form_lay.setSpacing(10)
+ form_scroll = QScrollArea()
+ form_scroll.setWidgetResizable(True)
+ form_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff)
+ form_scroll.setWidget(self.form)
+ self.yaml_view = QPlainTextEdit()
+ self.yaml_view.setReadOnly(True)
+ yaml_page = QWidget()
+ y = QVBoxLayout(yaml_page)
+ y.setContentsMargins(12, 12, 12, 12)
+ y.addWidget(self.yaml_view, 1)
+ note = label(
+ "Saved as plain YAML; only keys that differ from defaults are listed.", "faint"
+ )
+ note.setWordWrap(True)
+ y.addWidget(note)
+ self.docs_view = label("", "muted")
+ self.docs_view.setWordWrap(True)
+ self.docs_view.setAlignment(Qt.AlignmentFlag.AlignTop)
+ self.docs_view.setTextInteractionFlags(Qt.TextInteractionFlag.TextSelectableByMouse)
+ docs_scroll = QScrollArea()
+ docs_scroll.setWidgetResizable(True)
+ docs_page = QWidget()
+ dp = QVBoxLayout(docs_page)
+ dp.setContentsMargins(12, 12, 12, 12)
+ self.docs_title = label("")
+ self.docs_title.setStyleSheet("font-weight: 700;")
+ dp.addWidget(self.docs_title)
+ dp.addWidget(self.docs_view, 1)
+ docs_scroll.setWidget(docs_page)
+ self.empty_inspector = label("Select a device to see and edit its configuration.", "muted")
+ self.empty_inspector.setContentsMargins(20, 20, 20, 20)
+ self.empty_inspector.setAlignment(Qt.AlignmentFlag.AlignTop)
+ self.empty_inspector.setWordWrap(True)
+ for page in (form_scroll, yaml_page, docs_scroll, self.empty_inspector):
+ self.stack.addWidget(page)
+ lay.addWidget(self.stack, 1)
+ self.inspector_head = head
+ self.inspector_tabs = tab_box
+ return pane
+
+ # ------------------------------------------------------------------ syncing
+ def _schedule_sync(self) -> None:
+ self._sync_timer.start()
+
+ @SafeSlot()
+ def _sync(self) -> None:
+ bar = self.editor.bar_state()
+ tokens = ThemeTokens()
+ self.session_count.setText(f"{bar['sessionCount']} devices · connected")
+ self.source.setText(bar["source"])
+ if bar["changeCount"]:
+ self.change_chip.set_chip(bar["changeText"], "warn")
+ else:
+ self.change_chip.set_chip(bar["changeText"], "ok", "check")
+ self.review_btn.setText(bar["reviewText"])
+ self.review_btn.setEnabled(bool(bar["changeCount"]))
+ self.review_btn.setToolTip(
+ "You can review now; applying waits until the scan finishes"
+ if bar["scanRunning"]
+ else "See exactly what will change in the running session, then apply"
+ )
+ self.scan_banner.setVisible(bar["scanRunning"])
+ if bar["scanRunning"]:
+ self.scan_banner.set_content(
+ "lock",
+ bar["scanText"],
+ "You can keep editing and reviewing. Applying to the session is locked until the "
+ "scan ends.",
+ )
+ self.dot.setStyleSheet(f"background: {tokens.success.name()}; border-radius: 4px;")
+ self.undo_btn.setEnabled(bar["canUndo"])
+ self.remove_btn.setEnabled(bar["hasSelection"])
+ self.test_btn.setEnabled(bar["hasSelection"])
+ rows = self.editor.rows()
+ self.model.set_rows(rows)
+ select_row(self.table, self.model, self.editor.selected)
+ self.showing.setText(f"Showing {len(rows)} of {len(self.editor.work)} devices")
+ self.clear_filters_btn.setVisible(self.editor.filters_active())
+ self._sync_facets()
+ self._sync_problems()
+ self._sync_inspector()
+ if self._sheet is not None and self._sheet.property("kind") == "review":
+ self._fill_review(self._sheet)
+
+ def _sync_facets(self) -> None:
+ sections = self.editor.facet_sections()
+ key = repr(sections)
+ if key == self._facet_key:
+ return
+ self._facet_key = key
+ clear_layout(self.facet_box)
+ for section in sections:
+ if not section["items"]:
+ continue
+ spacer = label(section["title"].upper(), "upper")
+ spacer.setContentsMargins(0, 10, 0, 2)
+ self.facet_box.addWidget(spacer)
+ for item in section["items"]:
+ row = QWidget()
+ r = QHBoxLayout(row)
+ r.setContentsMargins(0, 0, 0, 0)
+ box = QCheckBox(item["label"])
+ box.setChecked(item["checked"])
+ box.toggled.connect(
+ lambda on, k=section["key"], v=item["value"]: self.editor.toggle_facet(k, v, on)
+ )
+ r.addWidget(box, 1)
+ count = label(str(item["count"]), "faint")
+ count.setStyleSheet(f"font-family: {MONO};")
+ r.addWidget(count)
+ self.facet_box.addWidget(row)
+
+ def _sync_problems(self) -> None:
+ problems = self.editor.problems()
+ n_err, n_unchecked = self.editor.problem_counts()
+ self.problem_chip.set_chip(str(n_err), "err" if n_err else "neutral")
+ self.unchecked_label.setText(f"· {n_unchecked} not checked")
+ clear_layout(self.problem_list)
+ tokens = ThemeTokens()
+ for p in problems[:4]:
+ btn = QPushButton()
+ btn.setProperty("role", "problem")
+ btn.setCursor(Qt.CursorShape.PointingHandCursor)
+ r = QHBoxLayout(btn)
+ r.setContentsMargins(4, 2, 4, 2)
+ r.setSpacing(8)
+ icon = QLabel()
+ unchecked = p["state"] == "unchecked"
+ icon.setPixmap(
+ material_icon(
+ "help" if unchecked else "warning",
+ size=(14, 14),
+ color=tokens.fg_subtle if unchecked else tokens.danger,
+ )
+ )
+ r.addWidget(icon)
+ name = label(p["name"], "mono")
+ name.setStyleSheet("font-weight: 700;")
+ r.addWidget(name)
+ msg = label(p["message"], "muted")
+ r.addWidget(msg, 1)
+ r.addWidget(label("Select", "link"))
+ btn.setMinimumHeight(24)
+ btn.clicked.connect(lambda _=False, n=p["name"]: self.editor.select(n))
+ self.problem_list.addWidget(btn)
+ if len(problems) > 4:
+ self.problem_list.addWidget(label(f"and {len(problems) - 4} more", "faint"))
+
+ def _sync_inspector(self) -> None:
+ detail = self.editor.detail()
+ if detail == self._last_detail:
+ return
+ self._last_detail = detail
+ has = bool(detail.get("name"))
+ self.inspector_head.setVisible(has)
+ self.inspector_tabs.setVisible(has)
+ if not has:
+ self.stack.setCurrentWidget(self.empty_inspector)
+ return
+ self.i_name.setText(detail["name"])
+ self.i_class.setText(detail["shortClass"])
+ self.revert_btn.setVisible(detail["isModified"])
+ self.i_status.set_chip(detail["statusText"], detail["statusTone"], detail["statusIcon"])
+ self.i_new.setVisible(detail["isNew"])
+ self.i_status_msg.setText(detail["statusMessage"])
+ self.i_status_msg.setVisible(bool(detail["statusMessage"]))
+ for key, btn in self.tab_buttons.items():
+ btn.setChecked(key == self._tab)
+ self.yaml_view.setPlainText(detail["yaml"])
+ if self._tab == "docs":
+ self.docs_title.setText(detail["shortClass"])
+ self.docs_view.setText(class_docstring(detail["deviceClass"]))
+ self._fill_form(detail)
+ self.stack.setCurrentIndex({"form": 0, "yaml": 1, "docs": 2}[self._tab])
+
+ def _set_tab(self, key: str) -> None:
+ self._tab = key
+ self._last_detail = None
+ self._sync_inspector()
+
+ def _fill_form(self, d: dict) -> None:
+ lay = self.form_lay
+ clear_layout(lay)
+ name = d["name"]
+
+ def was(text: str) -> None:
+ if text:
+ note = label(f"↳ {text}", "was")
+ note.setWordWrap(True)
+ lay.addWidget(note)
+
+ def field_label(text: str) -> QLabel:
+ lbl = label(text)
+ lbl.setStyleSheet("font-weight: 600; font-size: 12px;")
+ return lbl
+
+ lay.addWidget(field_label("Readout priority"))
+ rp = QComboBox()
+ rp.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
+ rp.setMinimumContentsLength(10)
+ for key, text in READOUT_LABELS.items():
+ rp.addItem(text, key)
+ if rp.findData(d["readout"]) < 0:
+ rp.addItem(d["readout"], d["readout"])
+ rp.setCurrentIndex(rp.findData(d["readout"]))
+ rp.activated.connect(
+ lambda i: self.editor.set_field(name, "readoutPriority", rp.itemData(i))
+ )
+ lay.addWidget(rp)
+ hint = label(READOUT_HINT, "faint")
+ hint.setWordWrap(True)
+ lay.addWidget(hint)
+ was(d["readoutWas"])
+
+ row = QHBoxLayout()
+ col = QVBoxLayout()
+ col.setSpacing(0)
+ col.addWidget(field_label("Enabled"))
+ note = label("Disabled devices stay in the file but are not loaded", "faint")
+ note.setWordWrap(True)
+ col.addWidget(note)
+ row.addLayout(col, 1)
+ switch = ToggleSwitch()
+ switch.setFixedWidth(42)
+ switch.setAccessibleName("Enabled")
+ switch.setChecked(d["enabled"])
+ switch.toggled.connect(lambda on: self.editor.set_field(name, "enabled", on))
+ row.addWidget(switch)
+ holder = QWidget()
+ holder.setLayout(row)
+ lay.addWidget(holder)
+ was(d["enabledWas"])
+
+ lay.addWidget(field_label("If reading fails"))
+ of = QComboBox()
+ of.setSizeAdjustPolicy(QComboBox.SizeAdjustPolicy.AdjustToMinimumContentsLengthWithIcon)
+ of.setMinimumContentsLength(10)
+ for key, text in ON_FAILURE_LABELS.items():
+ of.addItem(f"{text} ({key})", key)
+ of.setCurrentIndex(max(0, of.findData(d["onFailure"])))
+ of.activated.connect(lambda i: self.editor.set_field(name, "onFailure", of.itemData(i)))
+ lay.addWidget(of)
+ was(d["onFailureWas"])
+
+ lay.addWidget(field_label("Description"))
+ desc = QLineEdit(d["description"])
+ desc.setPlaceholderText("What this device is, for colleagues")
+ desc.editingFinished.connect(
+ lambda: self.editor.set_field(name, "description", desc.text())
+ )
+ lay.addWidget(desc)
+ was(d["descriptionWas"])
+
+ lay.addWidget(label("TAGS", "upper"))
+ tags = QHBoxLayout()
+ tags.setSpacing(4)
+ for tag in d["tags"]:
+ chip = QToolButton()
+ chip.setProperty("tb", "ghost")
+ chip.setText(f"{tag} ✕")
+ chip.setToolTip(f"Remove the tag {tag}")
+ chip.clicked.connect(lambda _=False, t=tag: self.editor.remove_tag(name, t))
+ tags.addWidget(chip)
+ add_tag = QLineEdit()
+ add_tag.setPlaceholderText("Add tag")
+ add_tag.setFixedWidth(90)
+ add_tag.returnPressed.connect(lambda: self.editor.add_tag(name, add_tag.text()))
+ tags.addWidget(add_tag)
+ tags.addStretch(1)
+ tags_w = QWidget()
+ tags_w.setLayout(tags)
+ lay.addWidget(tags_w)
+ was(d["tagsWas"])
+
+ lay.addWidget(label("DEVICE CONFIG", "upper"))
+ grid = QGridLayout()
+ grid.setHorizontalSpacing(8)
+ grid.setVerticalSpacing(4)
+ for i, item in enumerate(d["deviceConfig"]):
+ key_label = label(item["key"], "mono")
+ key_label.setProperty("role", "muted")
+ grid.addWidget(key_label, 2 * i, 0)
+ edit = QLineEdit(item["value"])
+ edit.setStyleSheet(f"font-family: {MONO}; font-size: 12px; min-height: 26px;")
+ edit.editingFinished.connect(
+ lambda e=edit, k=item["key"]: self.editor.set_config_value(name, k, e.text())
+ )
+ grid.addWidget(edit, 2 * i, 1)
+ if item["was"]:
+ grid.addWidget(label(f"↳ {item['was']}", "was"), 2 * i + 1, 1)
+ if not d["deviceConfig"]:
+ grid.addWidget(label("No deviceConfig keys", "faint"), 0, 0, 1, 2)
+ grid_w = QWidget()
+ grid_w.setLayout(grid)
+ lay.addWidget(grid_w)
+ if d["extra"]:
+ lay.addWidget(label("OTHER KEYS · KEPT AS THEY ARE", "upper"))
+ for item in d["extra"]:
+ lay.addWidget(label(f"{item['key']}: {item['value']}", "mono"))
+
+ test_row = QHBoxLayout()
+ test = TextButton("Test connection", "neutral", "power")
+ test.clicked.connect(lambda: self.editor.test(name))
+ test_row.addWidget(test)
+ note = label("Result appears here, no dialog", "faint")
+ note.setWordWrap(True)
+ test_row.addWidget(note, 1)
+ test_w = QWidget()
+ test_w.setLayout(test_row)
+ lay.addWidget(test_w)
+ lay.addStretch(1)
+ retheme_icons(self.form, ThemeTokens())
+
+ # ------------------------------------------------------------------ interaction
+ def _on_row(self, current: QModelIndex, _previous: QModelIndex) -> None:
+ if current.isValid():
+ self.editor.select(self.model.rows[current.row()]["name"])
+
+ def eventFilter(self, obj, event): # pylint: disable=invalid-name
+ if obj is self.table and event.type() == event.Type.KeyPress:
+ if event.key() == Qt.Key.Key_Delete:
+ self.editor.remove()
+ return True
+ if event.key() == Qt.Key.Key_Space and self.editor.selected:
+ cfg = self.editor.work[self.editor.selected]
+ self.editor.set_field(cfg["name"], "enabled", not cfg["enabled"])
+ return True
+ return super().eventFilter(obj, event)
+
+ def keyPressEvent(self, event): # pylint: disable=invalid-name
+ if event.modifiers() & Qt.KeyboardModifier.ControlModifier:
+ if event.key() == Qt.Key.Key_Z:
+ self.editor.undo()
+ return
+ if event.key() == Qt.Key.Key_S:
+ self.save_file_dialog()
+ return
+ super().keyPressEvent(event)
+
+ def _clear_filters(self) -> None:
+ self.search.blockSignals(True)
+ self.search.clear()
+ self.search.blockSignals(False)
+ self.editor.clear_filters()
+
+ @SafeSlot()
+ def open_file_dialog(self) -> None:
+ """Pick a YAML config file to edit."""
+ path, _ = QFileDialog.getOpenFileName(self, "Open device config", "", "YAML (*.yaml *.yml)")
+ if path:
+ self.editor.open_file(path)
+
+ @SafeSlot()
+ def save_file_dialog(self) -> None:
+ """Save the edited copy as YAML."""
+ path, _ = QFileDialog.getSaveFileName(self, "Save device config", "", "YAML (*.yaml)")
+ if path:
+ self.editor.save_file(path)
+
+ @SafeSlot()
+ def open_add_device(self) -> Sheet:
+ """Sheet to add a device to the edited copy."""
+ sheet = Sheet(
+ self, "Add device", "Added to the edited copy. Test it, then review and apply."
+ )
+ name = QLineEdit()
+ name.setPlaceholderText("e.g. pinz2")
+ error = label(NEW_NAME_RULE, "faint")
+ cls = QComboBox()
+ cls.setEditable(True)
+ cls.addItems(DEVICE_CLASSES)
+ prefix = QLineEdit()
+ prefix.setPlaceholderText("X05LA-ES-PH:Z2")
+ for text, widget in (("Name", name), (None, error), ("Class", cls), ("PV prefix", prefix)):
+ if text:
+ sheet.body.addWidget(label(text))
+ sheet.body.addWidget(widget)
+ sheet.foot.addStretch(1)
+ cancel = TextButton("Cancel")
+ cancel.clicked.connect(sheet.reject)
+ ok = TextButton("Add device", "primary")
+
+ def add():
+ message = self.editor.add_device(name.text(), cls.currentText(), prefix.text())
+ if message:
+ error.setText(message)
+ error.setProperty("role", "err")
+ error.style().unpolish(error)
+ error.style().polish(error)
+ return
+ sheet.accept()
+
+ ok.clicked.connect(add)
+ name.returnPressed.connect(add)
+ sheet.foot.addWidget(cancel)
+ sheet.foot.addWidget(ok)
+ sheet.setProperty("kind", "add")
+ self._show_sheet(sheet)
+ name.setFocus()
+ return sheet
+
+ @SafeSlot()
+ def open_review(self) -> Sheet:
+ """Sheet listing every difference to the running session, with Apply."""
+ sheet = Sheet(
+ self,
+ "Apply changes to the running session",
+ "These are the only differences between your edited copy and what BEC runs now. "
+ "Other devices are left exactly as they are.",
+ wide=True,
+ )
+ sheet.setProperty("kind", "review")
+ self._fill_review(sheet)
+ self._show_sheet(sheet)
+ return sheet
+
+ def _fill_review(self, sheet: Sheet) -> None:
+ review = self.editor.review()
+ sheet.clear_body()
+ sheet.clear_foot()
+ tokens = ThemeTokens()
+ for group in review["groups"]:
+ sheet.body.addWidget(label(group["title"].upper(), "upper"))
+ for item in group["items"]:
+ card = QFrame()
+ card.setProperty("role", "diffcard")
+ c = QVBoxLayout(card)
+ c.setContentsMargins(9, 7, 9, 7)
+ c.setSpacing(3)
+ head = QHBoxLayout()
+ name = label(item["name"], "mono")
+ name.setStyleSheet("font-weight: 700;")
+ head.addWidget(name)
+ head.addWidget(label(item["deviceClass"], "muted"))
+ head.addStretch(1)
+ if item["statusText"]:
+ head.addWidget(Chip(item["statusText"], item["statusTone"], item["statusIcon"]))
+ c.addLayout(head)
+ for f in item["fields"]:
+ line = label(
+ f"{f['field']}: "
+ f"{f['old']} → "
+ f"{f['new']}",
+ "mono",
+ )
+ line.setTextFormat(Qt.TextFormat.RichText)
+ line.setWordWrap(True)
+ c.addWidget(line)
+ sheet.body.addWidget(card)
+ if review["uncheckedText"]:
+ banner = Banner("warn")
+ banner.set_content(
+ "warning",
+ review["uncheckedText"],
+ "Each is loaded on its own; if one cannot connect it is reported and the others "
+ "are not affected.",
+ )
+ sheet.body.addWidget(banner)
+ if review["scanRunning"]:
+ banner = Banner("err")
+ banner.set_content(
+ "lock", self.editor.bar_state()["scanText"], "Applying waits until it finishes."
+ )
+ sheet.body.addWidget(banner)
+ sheet.body.addStretch(1)
+ sheet.foot.addWidget(label(review["footText"], "muted"), 1)
+ cancel = TextButton("Cancel")
+ cancel.clicked.connect(sheet.reject)
+ apply = TextButton(review["applyText"], "primary")
+ apply.setEnabled(self.editor.can_apply())
+ apply.clicked.connect(lambda: self._start_apply(sheet))
+ sheet.foot.addWidget(cancel)
+ sheet.foot.addWidget(apply)
+
+ def _start_apply(self, sheet: Sheet) -> None:
+ if self.editor.apply():
+ sheet.setProperty("kind", "apply")
+ self._sync_apply_sheet()
+
+ def _sync_apply_sheet(self, final: bool = False) -> None:
+ sheet = self._sheet
+ if sheet is None or sheet.property("kind") != "apply":
+ return
+ sheet.clear_body()
+ sheet.clear_foot()
+ rows = self.editor.apply_rows
+ if final or not self.editor.applying:
+ failed = sum(1 for r in rows if r["state"] == "failed")
+ banner = Banner("warn" if failed else "ok")
+ banner.set_content(
+ "warning" if failed else "check_circle",
+ self.editor.apply_summary,
+ (
+ "The devices marked Failed were not changed; see the Problems panel. "
+ "Everything else is live."
+ if failed
+ else "The running session now matches your copy."
+ ),
+ )
+ sheet.body.addWidget(banner)
+ states = {
+ "waiting": ("Waiting", "neutral", "schedule"),
+ "running": ("Initialising…", "busy", "sync"),
+ "done": ("Done", "ok", "check"),
+ "failed": ("Failed", "err", "warning"),
+ }
+ for r in rows:
+ line = QHBoxLayout()
+ name = label(r["name"], "mono")
+ name.setStyleSheet("font-weight: 700;")
+ name.setFixedWidth(140)
+ line.addWidget(name)
+ line.addWidget(label(r["action"], "muted"), 1)
+ if r["message"]:
+ msg = label(r["message"], "err")
+ msg.setWordWrap(True)
+ line.addWidget(msg, 2)
+ line.addWidget(Chip(*states[r["state"]]))
+ holder = QWidget()
+ holder.setLayout(line)
+ sheet.body.addWidget(holder)
+ sheet.body.addStretch(1)
+ sheet.foot.addStretch(1)
+ done = TextButton("Done", "primary")
+ done.setEnabled(not self.editor.applying)
+ done.clicked.connect(sheet.accept)
+ sheet.foot.addWidget(done)
+
+ @SafeSlot()
+ def open_clear_session(self) -> Sheet:
+ """Danger-zone sheet: type CLEAR to remove every device from the session."""
+ n = len(self.editor.session)
+ sheet = Sheet(
+ self,
+ "Clear the running session?",
+ f"Removes all {n} devices from BEC for everybody using this beamline. Plots, "
+ "scans and motors stop working until a config is loaded again.",
+ )
+ if self.editor.scan_running:
+ banner = Banner("err")
+ banner.set_content(
+ "lock", "A scan is running", "Clearing is not possible during a scan."
+ )
+ sheet.body.addWidget(banner)
+ sheet.body.addWidget(label("Type CLEAR to confirm"))
+ confirm = QLineEdit()
+ confirm.setPlaceholderText("Type CLEAR")
+ sheet.body.addWidget(confirm)
+ sheet.foot.addStretch(1)
+ cancel = TextButton("Cancel")
+ cancel.clicked.connect(sheet.reject)
+ go = TextButton(f"Remove all {n} devices", "danger")
+ go.setEnabled(False)
+ confirm.textChanged.connect(
+ lambda t: go.setEnabled(t.strip() == "CLEAR" and not self.editor.scan_running)
+ )
+ go.clicked.connect(
+ lambda: (not self.editor.clear_session(confirm.text())) and sheet.accept()
+ )
+ sheet.foot.addWidget(cancel)
+ sheet.foot.addWidget(go)
+ sheet.setProperty("kind", "clear")
+ self._show_sheet(sheet)
+ return sheet
+
+ def _show_sheet(self, sheet: Sheet) -> None:
+ if self._sheet is not None:
+ self._sheet.close()
+ self._sheet = sheet
+ sheet.finished.connect(lambda _: self._forget_sheet(sheet))
+ retheme_icons(sheet, ThemeTokens())
+ sheet.open()
+
+ def _forget_sheet(self, sheet: Sheet) -> None:
+ if self._sheet is sheet:
+ self._sheet = None
+ sheet.deleteLater()
+
+ def resizeEvent(self, event): # pylint: disable=invalid-name
+ super().resizeEvent(event)
+ self.toasts._reposition() # pylint: disable=protected-access
+
+ @SafeSlot(str)
+ def apply_theme(self, theme: str):
+ tokens = ThemeTokens()
+ self.setStyleSheet(view_qss(tokens))
+ self.file_icon.setPixmap(material_icon("description", size=(16, 16), color=tokens.fg_muted))
+ self._facet_key = None
+ self._last_detail = None
+ retheme_icons(self, tokens)
+ if hasattr(self, "editor"):
+ self._sync()
+
+ def cleanup(self):
+ self.editor.cleanup()
+ if self._sheet is not None:
+ self._sheet.close()
+ super().cleanup()
diff --git a/bec_widgets/applications/views/devices_views/qml/Banner.qml b/bec_widgets/applications/views/devices_views/qml/Banner.qml
new file mode 100644
index 00000000..89cfb581
--- /dev/null
+++ b/bec_widgets/applications/views/devices_views/qml/Banner.qml
@@ -0,0 +1,41 @@
+import QtQuick
+import QtQuick.Layouts
+import BecUi
+import "Tones.js" as Tones
+
+// Inline banner with icon, bold title and body text.
+Rectangle {
+ id: root
+ property string tone: "warn"
+ property string iconName: "lock"
+ property string title: ""
+ property string text: ""
+ readonly property color toneColor: Tones.color(theme, tone)
+
+ implicitHeight: col.implicitHeight + 16
+ radius: 8
+ color: Tones.soft(theme, toneColor, tone === "err" ? 0.14 : 0.16)
+ border.color: Qt.rgba(toneColor.r, toneColor.g, toneColor.b, 0.5)
+
+ RowLayout {
+ anchors.fill: parent
+ anchors.margins: 8
+ anchors.leftMargin: 10
+ spacing: 10
+ Icon { name: root.iconName; size: 18; color: root.toneColor; Layout.alignment: Qt.AlignTop }
+ ColumnLayout {
+ id: col
+ spacing: 2
+ Layout.fillWidth: true
+ Text { text: root.title; color: theme.fg; font.pixelSize: 13; font.weight: Font.Bold }
+ Text {
+ visible: root.text !== ""
+ text: root.text
+ color: theme.fgMuted
+ font.pixelSize: 12
+ wrapMode: Text.WordWrap
+ Layout.fillWidth: true
+ }
+ }
+ }
+}
diff --git a/bec_widgets/applications/views/devices_views/qml/Chip.qml b/bec_widgets/applications/views/devices_views/qml/Chip.qml
new file mode 100644
index 00000000..fef24e57
--- /dev/null
+++ b/bec_widgets/applications/views/devices_views/qml/Chip.qml
@@ -0,0 +1,38 @@
+import QtQuick
+import BecUi
+import "Tones.js" as Tones
+
+// State chip: icon and word on a soft tint, the same chip the QWidget view paints.
+Rectangle {
+ id: root
+ property string text: ""
+ property string tone: "neutral"
+ property string iconName: ""
+ readonly property color toneColor: Tones.color(theme, tone)
+
+ implicitHeight: 20
+ implicitWidth: label.implicitWidth + (iconName !== "" ? 30 : 16)
+ radius: height / 2
+ color: Tones.soft(theme, toneColor, tone === "neutral" ? 0.1 : 0.18)
+
+ Icon {
+ id: icon
+ visible: root.iconName !== ""
+ name: root.iconName
+ size: 14
+ color: root.toneColor
+ anchors.left: parent.left
+ anchors.leftMargin: 8
+ anchors.verticalCenter: parent.verticalCenter
+ }
+ Text {
+ id: label
+ text: root.text
+ color: root.toneColor
+ font.pixelSize: 11
+ font.weight: Font.DemiBold
+ anchors.left: root.iconName !== "" ? icon.right : parent.left
+ anchors.leftMargin: root.iconName !== "" ? 3 : 8
+ anchors.verticalCenter: parent.verticalCenter
+ }
+}
diff --git a/bec_widgets/applications/views/devices_views/qml/DeviceConfigView.qml b/bec_widgets/applications/views/devices_views/qml/DeviceConfigView.qml
new file mode 100644
index 00000000..25f11834
--- /dev/null
+++ b/bec_widgets/applications/views/devices_views/qml/DeviceConfigView.qml
@@ -0,0 +1,881 @@
+import QtQuick
+import QtQuick.Controls.Basic
+import QtQuick.Layouts
+import BecUi
+import "Tones.js" as Tones
+
+// Config for staff: edit a copy of the session config, see what differs, review, then apply.
+// backend: ConfigBackend.
+Rectangle {
+ id: root
+ required property QtObject backend
+ readonly property var bar: backend.bar
+ readonly property var d: backend.detail
+ readonly property string mono: "JetBrains Mono, Menlo, DejaVu Sans Mono"
+
+ readonly property bool reviewOpen: reviewSheet.opened
+ function openReview() { addSheet.close(); clearSheet.close(); reviewSheet.applyMode = false; reviewSheet.open() }
+ function openAdd() { addSheet.open() }
+ function openClear() { clearSheet.open() }
+ function closeSheets() { reviewSheet.close(); addSheet.close(); clearSheet.close() }
+
+ color: theme.bg
+ focus: true
+ Keys.onPressed: (event) => {
+ if (event.modifiers & Qt.ControlModifier && event.key === Qt.Key_Z) { backend.undo(); event.accepted = true }
+ else if (event.modifiers & Qt.ControlModifier && event.key === Qt.Key_S) { backend.saveFile(); event.accepted = true }
+ }
+
+ component Upper: Text {
+ color: theme.fgSubtle
+ font.pixelSize: 11
+ font.weight: Font.Bold
+ font.letterSpacing: 0.5
+ font.capitalization: Font.AllUppercase
+ }
+ component VRule: Rectangle { implicitWidth: 1; implicitHeight: 18; color: theme.border }
+ component Was: Text {
+ visible: text !== ""
+ color: theme.warning
+ font.pixelSize: 11
+ wrapMode: Text.WordWrap
+ Layout.fillWidth: true
+ }
+ component FieldLabel: Text { color: theme.fg; font.pixelSize: 12; font.weight: Font.DemiBold }
+
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 0
+
+ // ---- session bar ---------------------------------------------------------------------
+ Rectangle {
+ Layout.fillWidth: true
+ implicitHeight: barCol.implicitHeight + 16
+ color: theme.card
+ Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: theme.border }
+ ColumnLayout {
+ id: barCol
+ anchors.fill: parent
+ anchors.margins: 8
+ anchors.leftMargin: 12
+ anchors.rightMargin: 12
+ spacing: 8
+ RowLayout {
+ spacing: 8
+ Rectangle { implicitWidth: 9; implicitHeight: 9; radius: 4.5; color: theme.success }
+ Text { text: "Running session"; color: theme.fg; font.pixelSize: 13; font.weight: Font.Bold }
+ Text { text: root.bar.sessionCount + " devices · connected"; color: theme.fgMuted; font.pixelSize: 12 }
+ Item { implicitWidth: 10 }
+ VRule {}
+ Item { implicitWidth: 10 }
+ Icon { name: "description"; size: 16; color: theme.fgMuted }
+ Text { text: "Editing a copy from"; color: theme.fgMuted; font.pixelSize: 12 }
+ Text { text: root.bar.source; color: theme.fg; font.family: root.mono; font.pixelSize: 12; font.weight: Font.Bold }
+ Chip {
+ text: root.bar.changeText
+ tone: root.bar.changeCount > 0 ? "warn" : "ok"
+ iconName: root.bar.changeCount > 0 ? "" : "check"
+ }
+ Item { Layout.fillWidth: true }
+ TextButton {
+ text: root.bar.reviewText
+ variant: "primary"
+ iconName: "difference"
+ enabled: root.bar.changeCount > 0
+ tip: root.bar.scanRunning ? "You can review now; applying waits until the scan finishes"
+ : "See exactly what will change in the running session, then apply"
+ onClicked: root.openReview()
+ }
+ }
+ Banner {
+ visible: root.bar.scanRunning
+ Layout.fillWidth: true
+ tone: "warn"
+ iconName: "lock"
+ title: root.bar.scanText
+ text: "You can keep editing and reviewing. Applying to the session is locked until the scan ends."
+ }
+ }
+ }
+
+ // ---- toolbar -------------------------------------------------------------------------
+ Rectangle {
+ Layout.fillWidth: true
+ implicitHeight: 38
+ color: theme.card
+ Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: theme.border }
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: 8
+ anchors.rightMargin: 8
+ spacing: 2
+ GhostButton {
+ id: openBtn
+ text: "Open…"
+ iconName: "folder_open"
+ onClicked: openMenu.popup(openBtn, 0, openBtn.height + 2)
+ Menu {
+ id: openMenu
+ MenuItem { text: "Open YAML file…"; onTriggered: root.backend.openFile() }
+ MenuSeparator {}
+ MenuItem { text: "Start from the running session"; onTriggered: root.backend.loadSession() }
+ }
+ }
+ GhostButton { text: "Save"; iconName: "save"; tip: "Save the edited copy as YAML (Ctrl+S)"; onClicked: root.backend.saveFile() }
+ Item { implicitWidth: 6 }
+ VRule {}
+ Item { implicitWidth: 6 }
+ GhostButton { text: "Add device"; iconName: "add"; onClicked: root.openAdd() }
+ GhostButton { text: "Remove"; iconName: "delete"; enabled: root.bar.hasSelection; onClicked: root.backend.remove() }
+ GhostButton { text: "Test connection"; iconName: "power"; enabled: root.bar.hasSelection; tip: "Tests the selected device; to test everything use ⋯"; onClicked: root.backend.test() }
+ Item { implicitWidth: 6 }
+ VRule {}
+ Item { implicitWidth: 6 }
+ GhostButton { iconName: "undo"; tip: "Undo (Ctrl+Z)"; enabled: root.bar.canUndo; onClicked: root.backend.undo() }
+ Item { Layout.fillWidth: true }
+ GhostButton {
+ id: moreBtn
+ iconName: "more_horiz"
+ tip: "More"
+ onClicked: moreMenu.popup(moreBtn, moreBtn.width - moreMenu.width, moreBtn.height + 2)
+ Menu {
+ id: moreMenu
+ MenuItem { text: "Test all " + root.backend.workCount + " devices"; onTriggered: root.backend.testAll() }
+ MenuItem { text: "Reload from running session"; onTriggered: root.backend.loadSession() }
+ MenuSeparator {}
+ MenuItem { text: "Danger zone"; enabled: false }
+ MenuItem {
+ text: "Clear the running session…"
+ onTriggered: root.openClear()
+ contentItem: Text { text: parent.text; color: theme.danger; font.pixelSize: 13; leftPadding: 4 }
+ }
+ }
+ }
+ }
+ }
+
+ // ---- body ----------------------------------------------------------------------------
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ spacing: 0
+
+ // filters
+ ScrollView {
+ Layout.preferredWidth: 220
+ Layout.fillHeight: true
+ contentWidth: availableWidth
+ clip: true
+ background: Rectangle { color: theme.bg }
+ ColumnLayout {
+ width: 200
+ x: 10
+ spacing: 4
+ Item { implicitHeight: 6 }
+ Upper { text: "Search" }
+ InputField {
+ id: search
+ Layout.fillWidth: true
+ placeholderText: "Name, class, tag or PV prefix"
+ Accessible.name: "Search devices"
+ onTextChanged: searchDebounce.restart()
+ Timer { id: searchDebounce; interval: 120; onTriggered: root.backend.setQuery(search.text) }
+ Connections {
+ target: root.backend
+ function onChanged() { if (!root.backend.filtersActive && search.text !== "") search.text = "" }
+ }
+ }
+ Repeater {
+ model: root.backend.facets
+ delegate: ColumnLayout {
+ id: section
+ required property var modelData
+ visible: modelData.items.length > 0
+ spacing: 3
+ Layout.fillWidth: true
+ Upper { text: section.modelData.title; Layout.topMargin: 10; Layout.bottomMargin: 2 }
+ Repeater {
+ model: section.modelData.items
+ delegate: RowLayout {
+ id: facet
+ required property var modelData
+ Layout.fillWidth: true
+ TickBox {
+ text: facet.modelData.label
+ checked: facet.modelData.checked
+ Layout.fillWidth: true
+ onToggled: root.backend.toggleFacet(section.modelData.key, facet.modelData.value, checked)
+ }
+ Text { text: facet.modelData.count; color: theme.fgSubtle; font.family: root.mono; font.pixelSize: 11 }
+ }
+ }
+ }
+ }
+ Item { implicitHeight: 10 }
+ }
+ }
+ Rectangle { Layout.fillHeight: true; implicitWidth: 1; color: theme.border }
+
+ // table + problems
+ ColumnLayout {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ spacing: 0
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ color: theme.card
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 0
+ RowLayout {
+ Layout.margins: 10
+ Layout.topMargin: 6
+ Layout.bottomMargin: 6
+ Text { text: root.backend.showingText; color: theme.fgMuted; font.pixelSize: 12 }
+ Item { Layout.fillWidth: true }
+ Text {
+ visible: root.backend.filtersActive
+ text: "Clear filters"
+ color: theme.primary
+ font.pixelSize: 12
+ TapHandler { onTapped: root.backend.clearFilters() }
+ HoverHandler { cursorShape: Qt.PointingHandCursor }
+ }
+ }
+ Rectangle {
+ Layout.fillWidth: true
+ implicitHeight: 26
+ color: theme.card
+ Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: theme.border }
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: 8
+ spacing: 0
+ Repeater {
+ model: [["Δ", 30], ["Status", 150], ["Name", 130], ["Class", 130], ["Readout", 150], ["On", 40], ["Tags", -1]]
+ delegate: Text {
+ required property var modelData
+ text: modelData[0]
+ color: theme.fgMuted
+ font.pixelSize: 11
+ font.weight: Font.Bold
+ Layout.preferredWidth: modelData[1] > 0 ? modelData[1] : -1
+ Layout.fillWidth: modelData[1] < 0
+ }
+ }
+ }
+ }
+ ListView {
+ id: table
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ clip: true
+ model: root.backend.rows
+ boundsBehavior: Flickable.StopAtBounds
+ ScrollBar.vertical: ScrollBar {}
+ activeFocusOnTab: true
+ Connections {
+ target: root.backend
+ function onChanged() { if (root.backend.selectedIndex >= 0) table.positionViewAtIndex(root.backend.selectedIndex, ListView.Contain) }
+ }
+ Component.onCompleted: if (root.backend.selectedIndex >= 0) positionViewAtIndex(root.backend.selectedIndex, ListView.Contain)
+ Keys.onUpPressed: root.backend.selectRelative(-1)
+ Keys.onDownPressed: root.backend.selectRelative(1)
+ Keys.onDeletePressed: root.backend.remove()
+ Keys.onSpacePressed: if (root.d.name) root.backend.setField(root.d.name, "enabled", !root.d.enabled)
+ delegate: Rectangle {
+ id: row
+ required property int index
+ required property string name
+ required property string deviceClass
+ required property string readout
+ required property string readoutWas
+ required property bool on
+ required property string tags
+ required property string change
+ required property string changeLabel
+ required property string statusText
+ required property string statusTone
+ required property string statusIcon
+ required property bool selected
+ width: ListView.view.width
+ height: 32
+ color: selected ? Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.18)
+ : rowHover.hovered ? theme.hover : "transparent"
+ Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: theme.border; opacity: 0.6 }
+ HoverHandler { id: rowHover }
+ TapHandler { onTapped: { table.forceActiveFocus(); root.backend.select(row.name) } }
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: 8
+ spacing: 0
+ Text {
+ text: row.change
+ color: Tones.color(theme, row.change === "A" ? "ok" : row.change === "D" ? "err" : "warn")
+ font.family: root.mono
+ font.pixelSize: 12
+ font.weight: Font.Bold
+ Layout.preferredWidth: 30
+ ToolTip.visible: row.changeLabel !== "" && changeHover.hovered
+ ToolTip.text: row.changeLabel
+ HoverHandler { id: changeHover }
+ }
+ Item {
+ Layout.preferredWidth: 150
+ implicitHeight: 20
+ Chip { text: row.statusText; tone: row.statusTone; iconName: row.statusIcon; anchors.verticalCenter: parent.verticalCenter }
+ }
+ Text { text: row.name; color: theme.fg; font.family: root.mono; font.pixelSize: 12; font.weight: Font.DemiBold; elide: Text.ElideRight; Layout.preferredWidth: 130 }
+ Text { text: row.deviceClass; color: theme.fg; font.pixelSize: 12; elide: Text.ElideRight; Layout.preferredWidth: 130; rightPadding: 6 }
+ Text {
+ textFormat: Text.StyledText
+ text: row.readout + (row.readoutWas !== "" ? " " + row.readoutWas + "" : "")
+ color: theme.fg
+ font.pixelSize: 12
+ elide: Text.ElideRight
+ Layout.preferredWidth: 150
+ }
+ Item {
+ Layout.preferredWidth: 40
+ implicitHeight: 20
+ TickBox {
+ anchors.verticalCenter: parent.verticalCenter
+ checked: row.on
+ Accessible.name: "Enable " + row.name
+ onToggled: root.backend.setField(row.name, "enabled", checked)
+ }
+ }
+ Text { text: row.tags; color: theme.fg; font.pixelSize: 12; elide: Text.ElideRight; Layout.fillWidth: true; rightPadding: 8 }
+ }
+ }
+ }
+ }
+ }
+ Rectangle { Layout.fillWidth: true; implicitHeight: 1; color: theme.border }
+ // problems
+ Rectangle {
+ Layout.fillWidth: true
+ implicitHeight: probCol.implicitHeight + 14
+ color: theme.bg
+ ColumnLayout {
+ id: probCol
+ anchors.left: parent.left
+ anchors.right: parent.right
+ anchors.top: parent.top
+ anchors.margins: 10
+ anchors.topMargin: 6
+ spacing: 2
+ RowLayout {
+ spacing: 6
+ Text { text: "Problems"; color: theme.fg; font.pixelSize: 12; font.weight: Font.Bold }
+ Chip { text: String(root.backend.problemCount); tone: root.backend.problemCount > 0 ? "err" : "neutral" }
+ Text { text: "· " + root.backend.uncheckedCount + " not checked"; color: theme.fgMuted; font.pixelSize: 12 }
+ }
+ Repeater {
+ model: root.backend.problems.slice(0, 4)
+ delegate: Rectangle {
+ id: prob
+ required property var modelData
+ Layout.fillWidth: true
+ implicitHeight: 24
+ radius: 4
+ color: probHover.hovered ? theme.hover : "transparent"
+ HoverHandler { id: probHover; cursorShape: Qt.PointingHandCursor }
+ TapHandler { onTapped: root.backend.select(prob.modelData.name) }
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: 4
+ anchors.rightMargin: 4
+ spacing: 8
+ Icon {
+ name: prob.modelData.state === "unchecked" ? "help" : "warning"
+ size: 14
+ color: prob.modelData.state === "unchecked" ? theme.fgSubtle : theme.danger
+ }
+ Text { text: prob.modelData.name; color: theme.fg; font.family: root.mono; font.pixelSize: 12; font.weight: Font.Bold }
+ Text { text: prob.modelData.message; color: theme.fgMuted; font.pixelSize: 12; elide: Text.ElideRight; Layout.fillWidth: true }
+ Text { text: "Select"; color: theme.primary; font.pixelSize: 12 }
+ }
+ }
+ }
+ Text {
+ visible: root.backend.problems.length > 4
+ text: "and " + (root.backend.problems.length - 4) + " more"
+ color: theme.fgSubtle
+ font.pixelSize: 11
+ }
+ }
+ }
+ }
+ Rectangle { Layout.fillHeight: true; implicitWidth: 1; color: theme.border }
+
+ // inspector
+ Rectangle {
+ Layout.preferredWidth: 340
+ Layout.fillHeight: true
+ color: theme.bg
+ Text {
+ visible: !root.d.name
+ anchors.fill: parent
+ anchors.margins: 20
+ text: "Select a device to see and edit its configuration."
+ color: theme.fgMuted
+ font.pixelSize: 12
+ wrapMode: Text.WordWrap
+ }
+ ColumnLayout {
+ visible: !!root.d.name
+ anchors.fill: parent
+ spacing: 0
+ ColumnLayout {
+ Layout.margins: 12
+ Layout.topMargin: 10
+ Layout.bottomMargin: 8
+ spacing: 5
+ RowLayout {
+ Layout.fillWidth: true
+ Text { text: root.d.name || ""; color: theme.fg; font.family: root.mono; font.pixelSize: 14; font.weight: Font.Bold }
+ Text { text: root.d.shortClass || ""; color: theme.fgMuted; font.pixelSize: 12 }
+ Item { Layout.fillWidth: true }
+ TextButton { visible: !!root.d.isModified; text: "Revert"; iconName: "undo"; implicitHeight: 26; onClicked: root.backend.revert() }
+ }
+ RowLayout {
+ spacing: 6
+ Chip { text: root.d.statusText || ""; tone: root.d.statusTone || "neutral"; iconName: root.d.statusIcon || "" }
+ Chip { visible: !!root.d.isNew; text: "New — not in session"; tone: "ok"; iconName: "add" }
+ }
+ Text {
+ visible: !!root.d.statusMessage
+ text: root.d.statusMessage || ""
+ color: theme.danger
+ font.pixelSize: 12
+ wrapMode: Text.WordWrap
+ Layout.fillWidth: true
+ }
+ }
+ Segmented {
+ Layout.leftMargin: 12
+ model: [{ key: "form", label: "Form" }, { key: "yaml", label: "YAML" }, { key: "docs", label: "Docs" }]
+ current: root.backend.tab
+ onPicked: (key) => root.backend.setTab(key)
+ }
+ Rectangle { Layout.fillWidth: true; Layout.topMargin: 6; implicitHeight: 1; color: theme.border }
+
+ StackLayout {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ currentIndex: root.backend.tab === "yaml" ? 1 : root.backend.tab === "docs" ? 2 : 0
+
+ // ---- form
+ ScrollView {
+ id: formScroll
+ contentWidth: availableWidth
+ clip: true
+ ColumnLayout {
+ width: formScroll.availableWidth - 24
+ x: 12
+ spacing: 6
+ Item { implicitHeight: 6 }
+ FieldLabel { text: "Readout priority" }
+ SelectField {
+ Layout.fillWidth: true
+ model: root.backend.readoutLabels
+ currentIndex: root.backend.readoutKeys.indexOf(root.d.readout || "")
+ onActivated: (i) => root.backend.setField(root.d.name, "readoutPriority", root.backend.readoutKeys[i])
+ }
+ Text { text: root.backend.readoutHint; color: theme.fgSubtle; font.pixelSize: 11; wrapMode: Text.WordWrap; Layout.fillWidth: true }
+ Was { text: root.d.readoutWas ? "↳ " + root.d.readoutWas : "" }
+ Item { implicitHeight: 4 }
+ RowLayout {
+ Layout.fillWidth: true
+ ColumnLayout {
+ spacing: 0
+ Layout.fillWidth: true
+ FieldLabel { text: "Enabled" }
+ Text { text: "Disabled devices stay in the file but are not loaded"; color: theme.fgSubtle; font.pixelSize: 11; wrapMode: Text.WordWrap; Layout.fillWidth: true }
+ }
+ SwitchField {
+ checked: !!root.d.enabled
+ onToggled: root.backend.setField(root.d.name, "enabled", checked)
+ }
+ }
+ Was { text: root.d.enabledWas ? "↳ " + root.d.enabledWas : "" }
+ Item { implicitHeight: 4 }
+ FieldLabel { text: "If reading fails" }
+ SelectField {
+ Layout.fillWidth: true
+ model: root.backend.failureLabels
+ currentIndex: Math.max(0, root.backend.failureKeys.indexOf(root.d.onFailure || "retry"))
+ onActivated: (i) => root.backend.setField(root.d.name, "onFailure", root.backend.failureKeys[i])
+ }
+ Was { text: root.d.onFailureWas ? "↳ " + root.d.onFailureWas : "" }
+ Item { implicitHeight: 4 }
+ FieldLabel { text: "Description" }
+ InputField {
+ id: desc
+ Layout.fillWidth: true
+ text: root.d.description || ""
+ placeholderText: "What this device is, for colleagues"
+ onEditingFinished: root.backend.setField(root.d.name, "description", text)
+ }
+ Was { text: root.d.descriptionWas ? "↳ " + root.d.descriptionWas : "" }
+ Upper { text: "Tags"; Layout.topMargin: 6 }
+ Flow {
+ Layout.fillWidth: true
+ spacing: 4
+ Repeater {
+ model: root.d.tags || []
+ delegate: GhostButton {
+ required property string modelData
+ text: modelData + " ✕"
+ tip: "Remove the tag " + modelData
+ onClicked: root.backend.removeTag(root.d.name, modelData)
+ }
+ }
+ InputField {
+ width: 90
+ placeholderText: "Add tag"
+ onAccepted: { root.backend.addTag(root.d.name, text); text = "" }
+ }
+ }
+ Was { text: root.d.tagsWas ? "↳ " + root.d.tagsWas : "" }
+ Upper { text: "Device config"; Layout.topMargin: 6 }
+ Text { visible: (root.d.deviceConfig || []).length === 0; text: "No deviceConfig keys"; color: theme.fgSubtle; font.pixelSize: 11 }
+ Repeater {
+ model: root.d.deviceConfig || []
+ delegate: ColumnLayout {
+ id: cfgRow
+ required property var modelData
+ Layout.fillWidth: true
+ spacing: 2
+ RowLayout {
+ Layout.fillWidth: true
+ Text { text: cfgRow.modelData.key; color: theme.fgMuted; font.family: root.mono; font.pixelSize: 12; Layout.preferredWidth: 110; elide: Text.ElideRight }
+ InputField {
+ Layout.fillWidth: true
+ implicitHeight: 28
+ font.family: root.mono
+ font.pixelSize: 12
+ text: cfgRow.modelData.value
+ onEditingFinished: root.backend.setConfigValue(root.d.name, cfgRow.modelData.key, text)
+ }
+ }
+ Was { text: cfgRow.modelData.was ? "↳ " + cfgRow.modelData.was : ""; leftPadding: 114 }
+ }
+ }
+ Upper { visible: (root.d.extra || []).length > 0; text: "Other keys · kept as they are"; Layout.topMargin: 6 }
+ Repeater {
+ model: root.d.extra || []
+ delegate: Text {
+ required property var modelData
+ text: modelData.key + ": " + modelData.value
+ color: theme.fg
+ font.family: root.mono
+ font.pixelSize: 12
+ wrapMode: Text.WrapAnywhere
+ Layout.fillWidth: true
+ }
+ }
+ RowLayout {
+ Layout.topMargin: 6
+ Layout.fillWidth: true
+ TextButton { text: "Test connection"; iconName: "power"; onClicked: root.backend.test() }
+ Text { text: "Result appears here, no dialog"; color: theme.fgSubtle; font.pixelSize: 11; wrapMode: Text.WordWrap; Layout.fillWidth: true }
+ }
+ Item { implicitHeight: 12 }
+ }
+ }
+ // ---- yaml
+ ColumnLayout {
+ spacing: 6
+ ScrollView {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.margins: 12
+ Layout.bottomMargin: 0
+ background: Rectangle { color: theme.field; radius: 6; border.color: theme.border }
+ TextArea {
+ readOnly: true
+ text: root.d.yaml || ""
+ color: theme.fg
+ font.family: root.mono
+ font.pixelSize: 12
+ selectByMouse: true
+ background: null
+ }
+ }
+ Text {
+ text: "Saved as plain YAML; only keys that differ from defaults are listed."
+ color: theme.fgSubtle
+ font.pixelSize: 11
+ wrapMode: Text.WordWrap
+ Layout.fillWidth: true
+ Layout.margins: 12
+ Layout.topMargin: 0
+ }
+ }
+ // ---- docs
+ ScrollView {
+ id: docsScroll
+ contentWidth: availableWidth
+ clip: true
+ ColumnLayout {
+ width: docsScroll.availableWidth - 24
+ x: 12
+ y: 12
+ spacing: 4
+ Text { text: root.d.shortClass || ""; color: theme.fg; font.pixelSize: 13; font.weight: Font.Bold }
+ Text {
+ text: root.backend.tab === "docs" && root.d.deviceClass ? root.backend.docs(root.d.deviceClass) : ""
+ color: theme.fgMuted
+ font.pixelSize: 12
+ wrapMode: Text.WordWrap
+ Layout.fillWidth: true
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+
+ // ---- toasts -------------------------------------------------------------------------------
+ ListModel { id: toastModel }
+ Connections {
+ target: root.backend
+ function onToast(message, tone, undo) {
+ toastModel.append({ message: message, tone: tone, undo: undo, stamp: Date.now() })
+ while (toastModel.count > 3) toastModel.remove(0)
+ }
+ }
+ Timer {
+ interval: 250
+ running: toastModel.count > 0
+ repeat: true
+ onTriggered: {
+ for (let i = toastModel.count - 1; i >= 0; i--)
+ if (Date.now() - toastModel.get(i).stamp > 5000) toastModel.remove(i)
+ }
+ }
+ Column {
+ anchors.right: parent.right
+ anchors.bottom: parent.bottom
+ anchors.margins: 16
+ spacing: 6
+ Repeater {
+ model: toastModel
+ delegate: Rectangle {
+ id: toast
+ required property string message
+ required property bool undo
+ required property int index
+ width: Math.min(440, toastRow.implicitWidth + 24)
+ height: toastRow.implicitHeight + 16
+ radius: 8
+ color: theme.fg
+ RowLayout {
+ id: toastRow
+ anchors.fill: parent
+ anchors.margins: 8
+ anchors.leftMargin: 12
+ Text { text: toast.message; color: theme.bg; font.pixelSize: 12; wrapMode: Text.WordWrap; Layout.maximumWidth: 380 }
+ Text {
+ visible: toast.undo
+ text: "Undo"
+ color: Qt.lighter(theme.primary, theme.dark ? 1.4 : 1.6)
+ font.pixelSize: 12
+ font.weight: Font.Bold
+ TapHandler { onTapped: { root.backend.undo(); toastModel.remove(toast.index) } }
+ }
+ }
+ }
+ }
+ }
+
+ // ---- sheets -------------------------------------------------------------------------------
+ Sheet {
+ id: reviewSheet
+ property bool applyMode: false
+ readonly property var review: root.backend.review
+ parent: Overlay.overlay
+ wide: true
+ title: "Apply changes to the running session"
+ sub: applyMode ? "" : "These are the only differences between your edited copy and what BEC runs now. Other devices are left exactly as they are."
+
+ // review
+ Repeater {
+ model: reviewSheet.applyMode ? [] : reviewSheet.review.groups
+ delegate: ColumnLayout {
+ id: group
+ required property var modelData
+ Layout.fillWidth: true
+ spacing: 6
+ Upper { text: group.modelData.title }
+ Repeater {
+ model: group.modelData.items
+ delegate: Rectangle {
+ id: card
+ required property var modelData
+ Layout.fillWidth: true
+ implicitHeight: cardCol.implicitHeight + 14
+ radius: 7
+ color: theme.card
+ border.color: theme.border
+ ColumnLayout {
+ id: cardCol
+ anchors.fill: parent
+ anchors.margins: 7
+ anchors.leftMargin: 9
+ anchors.rightMargin: 9
+ spacing: 3
+ RowLayout {
+ Layout.fillWidth: true
+ Text { text: card.modelData.name; color: theme.fg; font.family: root.mono; font.pixelSize: 12; font.weight: Font.Bold }
+ Text { text: card.modelData.deviceClass; color: theme.fgMuted; font.pixelSize: 12 }
+ Item { Layout.fillWidth: true }
+ Chip { visible: card.modelData.statusText !== ""; text: card.modelData.statusText; tone: card.modelData.statusTone; iconName: card.modelData.statusIcon }
+ }
+ Repeater {
+ model: card.modelData.fields
+ delegate: Text {
+ required property var modelData
+ textFormat: Text.StyledText
+ text: "" + modelData.field + ": " + modelData.old + " → " + modelData.new + ""
+ font.family: root.mono
+ font.pixelSize: 12
+ color: theme.fg
+ wrapMode: Text.WrapAnywhere
+ Layout.fillWidth: true
+ }
+ }
+ }
+ }
+ }
+ }
+ }
+ Banner {
+ visible: !reviewSheet.applyMode && reviewSheet.review.uncheckedText !== ""
+ Layout.fillWidth: true
+ tone: "warn"
+ iconName: "warning"
+ title: reviewSheet.review.uncheckedText
+ text: "Each is loaded on its own; if one cannot connect it is reported and the others are not affected."
+ }
+ Banner {
+ visible: !reviewSheet.applyMode && reviewSheet.review.scanRunning
+ Layout.fillWidth: true
+ tone: "err"
+ iconName: "lock"
+ title: root.bar.scanText
+ text: "Applying waits until it finishes."
+ }
+ // apply progress
+ Banner {
+ readonly property bool failed: root.backend.applyRows.some((r) => r.state === "failed")
+ visible: reviewSheet.applyMode && !root.backend.applying && root.backend.applySummary !== ""
+ Layout.fillWidth: true
+ tone: failed ? "warn" : "ok"
+ iconName: failed ? "warning" : "check_circle"
+ title: root.backend.applySummary
+ text: failed ? "The devices marked Failed were not changed; see the Problems panel. Everything else is live."
+ : "The running session now matches your copy."
+ }
+ Repeater {
+ model: reviewSheet.applyMode ? root.backend.applyRows : []
+ delegate: RowLayout {
+ id: applyRow
+ required property var modelData
+ readonly property var st: ({
+ waiting: ["Waiting", "neutral", "schedule"], running: ["Initialising…", "busy", "sync"],
+ done: ["Done", "ok", "check"], failed: ["Failed", "err", "warning"]
+ })[modelData.state]
+ Layout.fillWidth: true
+ Text { text: applyRow.modelData.name; color: theme.fg; font.family: root.mono; font.pixelSize: 12; font.weight: Font.Bold; Layout.preferredWidth: 140 }
+ Text { text: applyRow.modelData.action; color: theme.fgMuted; font.pixelSize: 12; Layout.fillWidth: true }
+ Text { visible: applyRow.modelData.message !== ""; text: applyRow.modelData.message; color: theme.danger; font.pixelSize: 12; wrapMode: Text.WordWrap; Layout.fillWidth: true }
+ Chip { text: applyRow.st[0]; tone: applyRow.st[1]; iconName: applyRow.st[2] }
+ }
+ }
+
+ footer: [
+ Text {
+ visible: !reviewSheet.applyMode
+ text: reviewSheet.review.footText
+ color: theme.fgMuted
+ font.pixelSize: 12
+ Layout.fillWidth: true
+ },
+ Item { visible: reviewSheet.applyMode; Layout.fillWidth: true },
+ TextButton { visible: !reviewSheet.applyMode; text: "Cancel"; onClicked: reviewSheet.close() },
+ TextButton {
+ visible: !reviewSheet.applyMode
+ text: reviewSheet.review.applyText
+ variant: "primary"
+ enabled: root.backend.canApply
+ onClicked: if (root.backend.apply()) reviewSheet.applyMode = true
+ },
+ TextButton {
+ visible: reviewSheet.applyMode
+ text: "Done"
+ variant: "primary"
+ enabled: !root.backend.applying
+ onClicked: reviewSheet.close()
+ }
+ ]
+ }
+
+ Sheet {
+ id: addSheet
+ parent: Overlay.overlay
+ title: "Add device"
+ sub: "Added to the edited copy. Test it, then review and apply."
+ onOpened: { newName.text = ""; newPrefix.text = ""; addError.text = root.backend.nameRule; addError.color = theme.fgSubtle; newName.forceActiveFocus() }
+ function submit() {
+ const message = root.backend.addDevice(newName.text, newClass.editText, newPrefix.text)
+ if (message !== "") { addError.text = message; addError.color = theme.danger; newName.invalid = true; return }
+ addSheet.close()
+ }
+ FieldLabel { text: "Name" }
+ InputField { id: newName; Layout.fillWidth: true; placeholderText: "e.g. pinz2"; onTextEdited: invalid = false; onAccepted: addSheet.submit() }
+ Text { id: addError; color: theme.fgSubtle; font.pixelSize: 11; wrapMode: Text.WordWrap; Layout.fillWidth: true }
+ FieldLabel { text: "Class" }
+ SelectField { id: newClass; Layout.fillWidth: true; editable: true; model: root.backend.deviceClasses }
+ FieldLabel { text: "PV prefix" }
+ InputField { id: newPrefix; Layout.fillWidth: true; placeholderText: "X05LA-ES-PH:Z2"; onAccepted: addSheet.submit() }
+ footer: [
+ Item { Layout.fillWidth: true },
+ TextButton { text: "Cancel"; onClicked: addSheet.close() },
+ TextButton { text: "Add device"; variant: "primary"; onClicked: addSheet.submit() }
+ ]
+ }
+
+ Sheet {
+ id: clearSheet
+ parent: Overlay.overlay
+ title: "Clear the running session?"
+ sub: "Removes all " + root.backend.sessionCount + " devices from BEC for everybody using this beamline. Plots, scans and motors stop working until a config is loaded again."
+ onOpened: { confirmField.text = ""; confirmField.forceActiveFocus() }
+ Banner {
+ visible: root.bar.scanRunning
+ Layout.fillWidth: true
+ tone: "err"
+ iconName: "lock"
+ title: "A scan is running"
+ text: "Clearing is not possible during a scan."
+ }
+ FieldLabel { text: "Type CLEAR to confirm" }
+ InputField { id: confirmField; Layout.fillWidth: true; placeholderText: "Type CLEAR" }
+ footer: [
+ Item { Layout.fillWidth: true },
+ TextButton { text: "Cancel"; onClicked: clearSheet.close() },
+ TextButton {
+ text: "Remove all " + root.backend.sessionCount + " devices"
+ variant: "danger"
+ enabled: confirmField.text.trim() === "CLEAR" && !root.bar.scanRunning
+ onClicked: if (root.backend.clearSession(confirmField.text) === "") clearSheet.close()
+ }
+ ]
+ }
+}
diff --git a/bec_widgets/applications/views/devices_views/qml/DevicesView.qml b/bec_widgets/applications/views/devices_views/qml/DevicesView.qml
new file mode 100644
index 00000000..d052a416
--- /dev/null
+++ b/bec_widgets/applications/views/devices_views/qml/DevicesView.qml
@@ -0,0 +1,258 @@
+import QtQuick
+import QtQuick.Controls.Basic
+import QtQuick.Layouts
+import BecUi
+
+// Devices for everyone: find a device by kind and text, see it live, move motors.
+// backend: DevicesBackend.
+Rectangle {
+ id: root
+ required property QtObject backend
+ readonly property var d: backend.detail
+ readonly property string mono: "JetBrains Mono, Menlo, DejaVu Sans Mono"
+
+ color: theme.bg
+
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 0
+
+ // ---- toolbar -------------------------------------------------------------------------
+ Rectangle {
+ Layout.fillWidth: true
+ implicitHeight: 46
+ color: theme.card
+ Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: theme.border }
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: 12
+ anchors.rightMargin: 12
+ spacing: 10
+ Icon { name: "memory"; size: 18; color: theme.fgMuted }
+ Text { text: "Devices"; color: theme.fg; font.pixelSize: 13; font.weight: Font.Bold }
+ InputField {
+ id: search
+ Layout.preferredWidth: 300
+ placeholderText: "Search devices by name, tag or description"
+ Accessible.name: "Search devices"
+ onTextChanged: debounce.restart()
+ Timer { id: debounce; interval: 120; onTriggered: root.backend.setQuery(search.text) }
+ }
+ Segmented {
+ model: root.backend.kinds
+ current: root.backend.kind
+ onPicked: (key) => root.backend.setKind(key)
+ }
+ Item { Layout.fillWidth: true }
+ Chip {
+ text: "Read-only configuration"
+ iconName: "lock"
+ HoverHandler { id: lockHover }
+ ToolTip.visible: lockHover.hovered
+ ToolTip.text: "Editing the device session is in Config (staff)"
+ }
+ }
+ }
+
+ RowLayout {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ spacing: 0
+
+ // ---- device list -------------------------------------------------------------------
+ Rectangle {
+ Layout.fillWidth: true
+ Layout.preferredWidth: 1
+ Layout.fillHeight: true
+ color: theme.card
+ ColumnLayout {
+ anchors.fill: parent
+ spacing: 0
+ Text {
+ text: root.backend.shownText
+ color: theme.fgMuted
+ font.pixelSize: 12
+ Layout.margins: 10
+ Layout.topMargin: 6
+ Layout.bottomMargin: 6
+ }
+ Rectangle {
+ Layout.fillWidth: true
+ implicitHeight: 26
+ color: theme.card
+ Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: theme.border }
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: 8
+ anchors.rightMargin: 16
+ spacing: 0
+ Text { text: "Name"; color: theme.fgMuted; font.pixelSize: 11; font.weight: Font.Bold; Layout.preferredWidth: 150 }
+ Text { text: "What it is"; color: theme.fgMuted; font.pixelSize: 11; font.weight: Font.Bold; Layout.fillWidth: true }
+ Text { text: "Value"; color: theme.fgMuted; font.pixelSize: 11; font.weight: Font.Bold; Layout.preferredWidth: 140; horizontalAlignment: Text.AlignRight }
+ }
+ }
+ ListView {
+ id: list
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ clip: true
+ model: root.backend.rows
+ boundsBehavior: Flickable.StopAtBounds
+ focus: true
+ keyNavigationEnabled: true
+ ScrollBar.vertical: ScrollBar {}
+ currentIndex: root.backend.selectedIndex
+ onCurrentIndexChanged: if (currentItem) root.backend.select(currentItem.rowName)
+ Connections {
+ target: root.backend
+ function onChanged() { if (root.backend.selectedIndex >= 0) list.positionViewAtIndex(root.backend.selectedIndex, ListView.Contain) }
+ }
+ delegate: Rectangle {
+ id: rowItem
+ required property int index
+ required property string name
+ required property string what
+ required property string valueText
+ required property bool selected
+ readonly property string rowName: name
+ width: ListView.view.width
+ height: 32
+ color: selected ? Qt.rgba(theme.primary.r, theme.primary.g, theme.primary.b, 0.18)
+ : hover.hovered ? theme.hover : "transparent"
+ Rectangle { anchors.bottom: parent.bottom; width: parent.width; height: 1; color: theme.border; opacity: 0.6 }
+ HoverHandler { id: hover }
+ TapHandler { onTapped: { list.currentIndex = rowItem.index; root.backend.select(rowItem.name) } }
+ RowLayout {
+ anchors.fill: parent
+ anchors.leftMargin: 8
+ anchors.rightMargin: 16
+ spacing: 0
+ Text { text: rowItem.name; color: theme.fg; font.family: root.mono; font.pixelSize: 12; font.weight: Font.DemiBold; Layout.preferredWidth: 150; elide: Text.ElideRight }
+ Text { text: rowItem.what; color: theme.fgMuted; font.pixelSize: 12; Layout.fillWidth: true; elide: Text.ElideRight }
+ Text { text: rowItem.valueText; color: theme.fg; font.family: root.mono; font.pixelSize: 12; Layout.preferredWidth: 140; horizontalAlignment: Text.AlignRight }
+ }
+ }
+ }
+ }
+ }
+ Rectangle { Layout.fillHeight: true; implicitWidth: 1; color: theme.border }
+
+ // ---- detail ------------------------------------------------------------------------
+ ScrollView {
+ id: detailScroll
+ Layout.fillWidth: true
+ Layout.preferredWidth: 1
+ Layout.fillHeight: true
+ contentWidth: availableWidth
+ clip: true
+ Item {
+ width: detailScroll.availableWidth
+ implicitHeight: detail.implicitHeight + 24
+ ColumnLayout {
+ id: detail
+ width: Math.min(440, parent.width - 32)
+ anchors.horizontalCenter: parent.horizontalCenter
+ y: 12
+ spacing: 8
+
+ Text {
+ visible: root.backend.detailName === ""
+ text: "Select a device to see it here."
+ color: theme.fgMuted
+ font.pixelSize: 12
+ }
+ ColumnLayout {
+ visible: root.backend.detailName !== ""
+ spacing: 4
+ Layout.fillWidth: true
+ Text { visible: root.d.kind !== "positioner"; text: root.d.name || ""; color: theme.fg; font.family: root.mono; font.pixelSize: 15; font.weight: Font.Bold }
+ Text { text: root.d.description || ""; color: theme.fgMuted; font.pixelSize: 12; wrapMode: Text.WordWrap; Layout.fillWidth: true }
+ Text {
+ visible: root.d.kind === "monitor"
+ textFormat: Text.StyledText
+ text: (root.d.valueText || "") + " " + (root.d.units || "") + ""
+ color: theme.fg
+ font.family: root.mono
+ font.pixelSize: 28
+ font.weight: Font.DemiBold
+ }
+ Canvas {
+ id: spark
+ visible: root.d.kind === "monitor"
+ Layout.fillWidth: true
+ Layout.maximumWidth: 380
+ Layout.preferredHeight: 70
+ property var points: root.backend.spark
+ onPointsChanged: requestPaint()
+ onPaint: {
+ const ctx = getContext("2d")
+ ctx.clearRect(0, 0, width, height)
+ if (!points || points.length < 2) return
+ let lo = Math.min.apply(null, points), hi = Math.max.apply(null, points)
+ const span = (hi - lo) || 1
+ ctx.strokeStyle = theme.primary
+ ctx.lineWidth = 1.6
+ ctx.beginPath()
+ for (let i = 0; i < points.length; i++) {
+ const x = i / 89 * width, y = height - 5 - (points[i] - lo) / span * (height - 10)
+ i ? ctx.lineTo(x, y) : ctx.moveTo(x, y)
+ }
+ ctx.stroke()
+ }
+ }
+ Text { visible: root.d.kind === "monitor"; text: "last ~20 s, live"; color: theme.fgSubtle; font.pixelSize: 11 }
+ }
+ Loader {
+ id: motor
+ active: root.d.kind === "positioner" && root.backend.motor !== null
+ visible: active
+ Layout.fillWidth: true
+ Layout.preferredHeight: item ? item.implicitHeight : 0
+ onActiveChanged: if (active) setSource(root.backend.motorSource, { backend: root.backend.motor })
+ Component.onCompleted: if (active) setSource(root.backend.motorSource, { backend: root.backend.motor })
+ }
+ GridLayout {
+ visible: root.backend.detailName !== ""
+ columns: 2
+ columnSpacing: 12
+ rowSpacing: 3
+ Layout.fillWidth: true
+ Repeater {
+ model: root.d.facts || []
+ delegate: Text {
+ required property var modelData
+ required property int index
+ text: modelData.label
+ color: theme.fgMuted
+ font.pixelSize: 12
+ Layout.row: index
+ Layout.column: 0
+ }
+ }
+ Repeater {
+ model: root.d.facts || []
+ delegate: Text {
+ required property var modelData
+ required property int index
+ text: modelData.value
+ color: theme.fg
+ font.pixelSize: 13
+ Layout.row: index
+ Layout.column: 1
+ Layout.fillWidth: true
+ }
+ }
+ }
+ TextButton {
+ visible: root.backend.detailName !== ""
+ text: root.d.actionText || ""
+ iconName: root.d.kind === "detector" ? "image" : root.d.kind === "monitor" ? "show_chart" : "add"
+ variant: root.d.kind === "detector" ? "primary" : "neutral"
+ onClicked: root.backend.openInWorkspace()
+ }
+ }
+ }
+ }
+ }
+ }
+}
diff --git a/bec_widgets/applications/views/devices_views/qml/GhostButton.qml b/bec_widgets/applications/views/devices_views/qml/GhostButton.qml
new file mode 100644
index 00000000..2d33e7d0
--- /dev/null
+++ b/bec_widgets/applications/views/devices_views/qml/GhostButton.qml
@@ -0,0 +1,45 @@
+import QtQuick
+import QtQuick.Controls.Basic
+import QtQuick.Layouts
+import BecUi
+
+// Toolbar button: icon and optional text, no frame until hovered.
+AbstractButton {
+ id: root
+ property string iconName: ""
+ property string tip: ""
+ property bool showMenuArrow: false
+
+ implicitHeight: 28
+ implicitWidth: row.implicitWidth + 16
+ hoverEnabled: true
+ focusPolicy: Qt.TabFocus
+ opacity: enabled ? 1.0 : 0.45
+ Accessible.name: text !== "" ? text : tip
+
+ background: Rectangle {
+ radius: 6
+ color: root.down ? theme.pressed : root.hovered ? theme.hover : "transparent"
+ border.width: root.visualFocus ? 1 : 0
+ border.color: theme.primary
+ }
+ contentItem: Item {
+ implicitWidth: row.implicitWidth
+ implicitHeight: row.implicitHeight
+ RowLayout {
+ id: row
+ anchors.centerIn: parent
+ spacing: 5
+ Icon { name: root.iconName; size: 16; color: theme.fgMuted; visible: root.iconName !== "" }
+ Text {
+ visible: root.text !== ""
+ text: root.text
+ color: theme.fg
+ font.pixelSize: 12
+ }
+ }
+ }
+ ToolTip.visible: tip !== "" && hovered
+ ToolTip.delay: 500
+ ToolTip.text: tip
+}
diff --git a/bec_widgets/applications/views/devices_views/qml/Segmented.qml b/bec_widgets/applications/views/devices_views/qml/Segmented.qml
new file mode 100644
index 00000000..db3330b8
--- /dev/null
+++ b/bec_widgets/applications/views/devices_views/qml/Segmented.qml
@@ -0,0 +1,49 @@
+import QtQuick
+import QtQuick.Controls.Basic
+
+// Segmented control. model: [{key, label}], current: key.
+Rectangle {
+ id: root
+ property var model: []
+ property string current: ""
+ signal picked(string key)
+
+ implicitHeight: 30
+ implicitWidth: row.implicitWidth + 6
+ radius: 7
+ color: theme.track
+ Row {
+ id: row
+ anchors.centerIn: parent
+ spacing: 2
+ Repeater {
+ model: root.model
+ delegate: AbstractButton {
+ id: seg
+ required property var modelData
+ readonly property bool on: modelData.key === root.current
+ implicitHeight: 24
+ implicitWidth: lbl.implicitWidth + 20
+ hoverEnabled: true
+ focusPolicy: Qt.TabFocus
+ Accessible.name: modelData.label
+ onClicked: root.picked(modelData.key)
+ background: Rectangle {
+ radius: 5
+ color: seg.on ? theme.card : seg.hovered ? theme.hover : "transparent"
+ border.width: seg.visualFocus ? 1 : 0
+ border.color: theme.primary
+ }
+ contentItem: Text {
+ id: lbl
+ text: seg.modelData.label
+ color: seg.on ? theme.fg : theme.fgMuted
+ font.pixelSize: 12
+ font.weight: seg.on ? Font.DemiBold : Font.Normal
+ horizontalAlignment: Text.AlignHCenter
+ verticalAlignment: Text.AlignVCenter
+ }
+ }
+ }
+ }
+}
diff --git a/bec_widgets/applications/views/devices_views/qml/Sheet.qml b/bec_widgets/applications/views/devices_views/qml/Sheet.qml
new file mode 100644
index 00000000..57687bee
--- /dev/null
+++ b/bec_widgets/applications/views/devices_views/qml/Sheet.qml
@@ -0,0 +1,49 @@
+import QtQuick
+import QtQuick.Controls.Basic
+import QtQuick.Layouts
+
+// Window-modal sheet: title, sub line, scrolling body and a footer row.
+Popup {
+ id: root
+ property string title: ""
+ property string sub: ""
+ property bool wide: false
+ default property alias content: body.data
+ property alias footer: foot.data
+
+ modal: true
+ dim: true
+ focus: true
+ anchors.centerIn: Overlay.overlay
+ width: Math.min(parent ? parent.width - 40 : 640, wide ? 680 : 440)
+ height: Math.min(parent ? parent.height - 40 : 600, layout.implicitHeight + 36)
+ padding: 18
+ closePolicy: Popup.CloseOnEscape | Popup.CloseOnPressOutside
+
+ background: Rectangle { color: theme.card; radius: 12; border.color: theme.border }
+ Overlay.modal: Rectangle { color: Qt.rgba(0, 0, 0, 0.45) }
+
+ contentItem: ColumnLayout {
+ id: layout
+ spacing: 12
+ Text { text: root.title; color: theme.fg; font.pixelSize: 16; font.weight: Font.Bold }
+ Text {
+ visible: root.sub !== ""
+ text: root.sub
+ textFormat: Text.StyledText
+ color: theme.fgMuted
+ font.pixelSize: 12
+ wrapMode: Text.WordWrap
+ Layout.fillWidth: true
+ }
+ ScrollView {
+ Layout.fillWidth: true
+ Layout.fillHeight: true
+ Layout.preferredHeight: Math.min(body.implicitHeight, 460)
+ clip: true
+ contentWidth: availableWidth
+ ColumnLayout { id: body; width: parent.width; spacing: 8 }
+ }
+ RowLayout { id: foot; Layout.fillWidth: true; spacing: 8 }
+ }
+}
diff --git a/bec_widgets/applications/views/devices_views/qml/TickBox.qml b/bec_widgets/applications/views/devices_views/qml/TickBox.qml
new file mode 100644
index 00000000..e7c45365
--- /dev/null
+++ b/bec_widgets/applications/views/devices_views/qml/TickBox.qml
@@ -0,0 +1,29 @@
+import QtQuick
+import QtQuick.Controls.Basic
+import BecUi
+
+// Small checkbox matching the field style.
+CheckBox {
+ id: root
+ padding: 0
+ spacing: 6
+ font.pixelSize: 12
+ indicator: Rectangle {
+ implicitWidth: 15
+ implicitHeight: 15
+ x: root.leftPadding
+ y: (root.height - height) / 2
+ radius: 4
+ color: root.checked ? theme.primary : theme.field
+ border.color: root.checked ? theme.primary : root.visualFocus ? theme.primary : theme.border
+ Icon { anchors.centerIn: parent; name: "check"; size: 12; color: theme.onPrimary; visible: root.checked }
+ }
+ contentItem: Text {
+ leftPadding: root.text !== "" ? root.indicator.width + root.spacing : 0
+ text: root.text
+ color: theme.fg
+ font: root.font
+ elide: Text.ElideRight
+ verticalAlignment: Text.AlignVCenter
+ }
+}
diff --git a/bec_widgets/applications/views/devices_views/qml/Tones.js b/bec_widgets/applications/views/devices_views/qml/Tones.js
new file mode 100644
index 00000000..e321364e
--- /dev/null
+++ b/bec_widgets/applications/views/devices_views/qml/Tones.js
@@ -0,0 +1,15 @@
+.pragma library
+// Colour of a state tone; only problems and work in progress are coloured.
+function color(theme, tone) {
+ return tone === "ok" ? theme.success
+ : tone === "busy" ? theme.primary
+ : tone === "warn" ? theme.warning
+ : tone === "err" ? theme.danger
+ : tone === "stale" ? theme.fgSubtle
+ : theme.fgMuted
+}
+function soft(theme, c, amount) {
+ return Qt.rgba(theme.card.r + (c.r - theme.card.r) * amount,
+ theme.card.g + (c.g - theme.card.g) * amount,
+ theme.card.b + (c.b - theme.card.b) * amount, 1)
+}
diff --git a/tests/unit_tests/test_devices_views.py b/tests/unit_tests/test_devices_views.py
new file mode 100644
index 00000000..c6427cfe
--- /dev/null
+++ b/tests/unit_tests/test_devices_views.py
@@ -0,0 +1,381 @@
+"""Tests for the Devices and Config views (device manager split into two views)."""
+
+# pylint: disable=redefined-outer-name, protected-access, missing-function-docstring
+import copy
+from unittest import mock
+
+import pytest
+
+from bec_widgets.applications.views.devices_views import devices_core
+from bec_widgets.applications.views.devices_views.devices_core import (
+ Change,
+ ConfigEditor,
+ DeviceBrowser,
+ configs_by_name,
+ device_kind,
+ diff_configs,
+ format_value,
+ parse_value,
+ plan_requests,
+)
+from bec_widgets.applications.views.devices_views.devices_qml import (
+ DeviceConfigViewQML,
+ DevicesViewQML,
+)
+from bec_widgets.applications.views.devices_views.devices_qwidget import (
+ DeviceConfigViewQWidget,
+ DevicesViewQWidget,
+)
+
+from .conftest import create_widget
+
+SESSION = {
+ "samx": {
+ "deviceClass": "ophyd_devices.SimPositioner",
+ "readoutPriority": "baseline",
+ "deviceConfig": {"limits": [-50, 50], "tolerance": 0.01},
+ "deviceTags": ["user motors"],
+ "needs": ["samy"],
+ "connectionTimeout": 10,
+ },
+ "bpm4i": {
+ "deviceClass": "ophyd_devices.SimMonitor",
+ "readoutPriority": "monitored",
+ "deviceConfig": {},
+ },
+}
+
+
+class _SyncPool:
+ """Runs QRunnables at once on the calling thread."""
+
+ def start(self, worker):
+ worker.run()
+
+
+@pytest.fixture
+def sync_pool():
+ with mock.patch.object(devices_core.QThreadPool, "globalInstance", return_value=_SyncPool()):
+ yield
+
+
+@pytest.fixture
+def editor(mocked_client):
+ with mock.patch.object(
+ mocked_client.device_manager,
+ "_get_redis_device_config",
+ return_value=[{"name": k, **copy.deepcopy(v)} for k, v in SESSION.items()],
+ ):
+ ed = ConfigEditor(mocked_client)
+ ed.load_session()
+ yield ed
+
+
+# ------------------------------------------------------------------------------------ pure logic
+def test_configs_by_name_keeps_unknown_keys_and_fills_defaults():
+ cfgs = configs_by_name(SESSION)
+ assert cfgs["samx"]["needs"] == ["samy"]
+ assert cfgs["samx"]["connectionTimeout"] == 10
+ assert cfgs["bpm4i"]["enabled"] is True
+ assert cfgs["bpm4i"]["deviceTags"] == []
+
+
+def test_diff_is_field_level_and_ordered():
+ session = configs_by_name(SESSION)
+ work = copy.deepcopy(session)
+ work["samx"]["readoutPriority"] = "monitored"
+ work["samx"]["deviceConfig"]["limits"] = [-10, 10]
+ del work["bpm4i"]
+ work["newmot"] = devices_core.normalize_config("newmot", {"deviceClass": "ophyd.EpicsMotor"})
+ changes = diff_configs(session, work)
+ assert [(c.kind, c.name) for c in changes] == [("M", "samx"), ("A", "newmot"), ("D", "bpm4i")]
+ assert changes[0].fields == [
+ ("readoutPriority", "baseline", "monitored"),
+ ("deviceConfig.limits", "[-50, 50]", "[-10, 10]"),
+ ]
+
+
+def test_plan_requests_update_only_changed_updatable_keys():
+ session = configs_by_name(SESSION)
+ work = copy.deepcopy(session)
+ work["samx"]["readoutPriority"] = "monitored"
+ change = diff_configs(session, work)[0]
+ assert plan_requests(change, session, work) == [
+ ("update", {"samx": {"readoutPriority": "monitored"}})
+ ]
+
+
+def test_plan_requests_recreates_device_for_class_or_removed_key():
+ session = configs_by_name(SESSION)
+ work = copy.deepcopy(session)
+ work["samx"]["deviceClass"] = "ophyd.EpicsMotor"
+ change = diff_configs(session, work)[0]
+ steps = plan_requests(change, session, work)
+ assert [s[0] for s in steps] == ["remove", "add"]
+ # the re-added device keeps keys the editor does not show
+ assert steps[1][1]["samx"]["needs"] == ["samy"]
+
+ work = copy.deepcopy(session)
+ del work["samx"]["deviceConfig"]["tolerance"]
+ change = diff_configs(session, work)[0]
+ assert [s[0] for s in plan_requests(change, session, work)] == ["remove", "add"]
+ assert plan_requests(Change("D", "bpm4i"), session, work) == [("remove", {"bpm4i": {}})]
+
+
+def test_parse_and_format_values():
+ assert parse_value("1,5") == 1.5
+ assert parse_value("[1, 2]") == [1, 2]
+ assert parse_value("X05LA:ES") == "X05LA:ES"
+ assert parse_value("") is None
+ assert format_value(-1.23456, 3, "mm") == "−1.235 mm"
+ assert format_value(None, 3) == "—"
+ assert format_value([1, 2, 3], None) == "list [3]"
+
+
+# ------------------------------------------------------------------------------------ Config logic
+def test_editor_tracks_changes_and_undo(editor):
+ assert editor.changes() == []
+ assert editor.bar_state()["changeText"] == "Same as session"
+ editor.set_field("samx", "readoutPriority", "monitored")
+ assert [c.kind for c in editor.changes()] == ["M"]
+ assert editor.status["samx"] == "unchecked"
+ assert editor.detail()["readoutWas"] == "In session: Baseline · once per scan"
+ assert editor.rows()[0]["readoutWas"] == "was baseline"
+ editor.undo()
+ assert editor.changes() == []
+ assert editor.status["samx"] == "session"
+
+
+def test_editor_add_remove_and_name_rules(editor):
+ assert editor.add_device("Bad Name", "ophyd.EpicsMotor") != ""
+ assert "already exists" in editor.add_device("samx", "ophyd.EpicsMotor")
+ assert editor.add_device("pinz2", "ophyd.EpicsMotor", "X05LA:Z2") == ""
+ assert editor.selected == "pinz2"
+ assert editor.work["pinz2"]["deviceConfig"] == {"prefix": "X05LA:Z2"}
+ editor.remove("bpm4i")
+ kinds = {c.name: c.kind for c in editor.changes()}
+ assert kinds == {"pinz2": "A", "bpm4i": "D"}
+ review = editor.review()
+ assert [g["kind"] for g in review["groups"]] == ["A", "D"]
+ assert review["applyText"] == "Apply 2 changes"
+ editor.undo()
+ assert "bpm4i" in editor.work
+
+
+def test_editor_revert_and_config_value(editor):
+ editor.set_config_value("samx", "limits", "[-5, 5]")
+ assert editor.work["samx"]["deviceConfig"]["limits"] == [-5, 5]
+ assert editor.detail()["deviceConfig"][0]["was"] == "In session: [-50, 50]"
+ editor.revert("samx")
+ assert editor.changes() == []
+
+
+def test_editor_filters_and_facets(editor):
+ editor.set_field("samx", "enabled", False)
+ editor.set_query("bpm")
+ assert [r["name"] for r in editor.rows()] == ["bpm4i"]
+ editor.clear_filters()
+ editor.toggle_facet("st", "changed", True)
+ assert [r["name"] for r in editor.rows()] == ["samx"]
+ status = editor.facet_sections()[0]
+ assert status["items"][0] == {
+ "value": "changed",
+ "label": "Changed vs session",
+ "count": 1,
+ "checked": True,
+ }
+ assert editor.filters_active()
+
+
+def test_scan_running_blocks_apply(editor):
+ editor.set_field("samx", "enabled", False)
+ assert editor.can_apply()
+ editor.on_scan_status({"status": "open", "scan_number": 1285}, {})
+ assert not editor.can_apply()
+ assert editor.bar_state()["scanText"] == "Scan 1285 is running"
+ assert not editor.apply()
+ editor.on_scan_status({"status": "closed"}, {})
+ assert editor.can_apply()
+
+
+def test_apply_sends_one_request_per_device(editor, sync_pool):
+ editor.set_field("samx", "readoutPriority", "monitored")
+ editor.remove("bpm4i")
+ helper = mock.MagicMock()
+ editor._helper = helper
+ finished = []
+ editor.apply_finished.connect(lambda ok, failed: finished.append((ok, failed)))
+ assert editor.apply()
+ calls = [c.kwargs for c in helper.send_config_request.call_args_list]
+ assert calls == [
+ {
+ "action": "update",
+ "config": {"samx": {"readoutPriority": "monitored"}},
+ "wait_for_response": True,
+ },
+ {"action": "remove", "config": {"bpm4i": {}}, "wait_for_response": True},
+ ]
+ assert finished == [(2, 0)]
+ assert [r["state"] for r in editor.apply_rows] == ["done", "done"]
+ assert editor.apply_summary == "2 of 2 changes applied"
+
+
+def test_apply_reports_failed_device(editor, sync_pool):
+ editor.set_field("samx", "readoutPriority", "monitored")
+ helper = mock.MagicMock()
+ helper.send_config_request.side_effect = RuntimeError("PV X05LA-ES:Z not found")
+ editor._helper = helper
+ editor.apply()
+ assert editor.apply_rows[0]["state"] == "failed"
+ assert editor.status["samx"] == "invalid"
+ assert "not found" in editor.messages["samx"]
+ assert editor.problem_counts() == (1, 0)
+
+
+def test_clear_session_needs_typed_confirmation(editor, sync_pool):
+ with mock.patch.object(editor.client.config, "reset_config") as reset:
+ assert editor.clear_session("clear") == "Type CLEAR to confirm"
+ reset.assert_not_called()
+ editor.on_scan_status({"status": "open"}, {})
+ assert editor.clear_session("CLEAR") == "Clearing is not possible during a scan"
+ editor.on_scan_status({"status": "closed"}, {})
+ assert editor.clear_session("CLEAR") == ""
+ reset.assert_called_once()
+
+
+# ------------------------------------------------------------------------------------ Devices logic
+def test_device_kind_and_browser_rows(mocked_client):
+ devices = mocked_client.device_manager.devices
+ assert device_kind(devices["samx"]) == "positioner"
+ assert device_kind(devices["eiger"]) == "detector"
+ assert device_kind(devices["bpm4i"]) == "monitor"
+ browser = DeviceBrowser(mocked_client)
+ rows = browser.rows()
+ assert rows[0]["name"] == "samx"
+ assert all(r["kind"] == "positioner" for r in rows)
+ browser.set_kind("detector")
+ assert {r["name"] for r in browser.rows()} == {"eiger", "async_device"}
+ browser.set_kind("any")
+ browser.set_query("bpm4")
+ assert [r["name"] for r in browser.rows()] == ["bpm4i"]
+ browser.cleanup()
+
+
+def test_browser_readback_and_open_request(mocked_client):
+ browser = DeviceBrowser(mocked_client)
+ browser.set_kind("monitor")
+ browser.select("bpm4i")
+ browser.on_readback({"signals": {"bpm4i": {"value": 12.5}}}, {})
+ browser.on_readback({"signals": {"bpm4i": {"value": 13.5}}}, {})
+ assert browser.spark_points() == [12.5, 13.5]
+ assert browser.detail()["valueText"] == "13.5"
+ opened = []
+ browser.open_in_workspace.connect(lambda w, d: opened.append((w, d)))
+ browser.request_open()
+ assert opened == [("Waveform", "bpm4i")]
+ browser.cleanup()
+
+
+# ------------------------------------------------------------------------------------ both views
+@pytest.fixture(params=[DeviceConfigViewQWidget, DeviceConfigViewQML])
+def config_view(request, qtbot, mocked_client):
+ with mock.patch.object(
+ mocked_client.device_manager,
+ "_get_redis_device_config",
+ return_value=[{"name": k, **copy.deepcopy(v)} for k, v in SESSION.items()],
+ ):
+ widget = create_widget(qtbot, request.param, client=mocked_client)
+ yield widget
+
+
+@pytest.fixture(params=[DevicesViewQWidget, DevicesViewQML])
+def devices_view(request, qtbot, mocked_client):
+ yield create_widget(qtbot, request.param, client=mocked_client)
+
+
+def test_config_view_shows_session_and_edits(config_view, qtbot):
+ editor = config_view.editor
+ assert len(editor.work) == 2
+ editor.select("bpm4i")
+ editor.set_field("bpm4i", "enabled", False)
+ qtbot.wait(20)
+ assert editor.bar_state()["reviewText"] == "Review changes (1)"
+ if isinstance(config_view, DeviceConfigViewQML):
+ assert config_view.backend.rows.rowCount() == 2
+ assert config_view.backend.detail["name"] == "bpm4i"
+ assert config_view.backend.selectedIndex == 1
+ else:
+ assert config_view.model.rowCount() == 2
+ assert config_view.i_name.text() == "bpm4i"
+ assert config_view.review_btn.isEnabled()
+
+
+def test_config_view_review_sheet_opens_and_closes(config_view, qtbot):
+ config_view.editor.set_field("samx", "readoutPriority", "monitored")
+ sheet = config_view.open_review()
+ qtbot.wait(20)
+ if isinstance(config_view, DeviceConfigViewQML):
+ root = config_view.view.rootObject()
+ qtbot.waitUntil(lambda: root.property("reviewOpen"), timeout=1000)
+ config_view.close_sheet()
+ qtbot.waitUntil(lambda: not root.property("reviewOpen"), timeout=1000)
+ else:
+ assert sheet.isVisible()
+ sheet.reject()
+
+
+def test_config_view_toggles_enabled_from_table(config_view, qtbot):
+ if isinstance(config_view, DeviceConfigViewQML):
+ config_view.backend.setField("samx", "enabled", False)
+ else:
+ config_view.model.toggled.emit("samx", False)
+ qtbot.wait(20)
+ assert config_view.editor.work["samx"]["enabled"] is False
+
+
+def test_devices_view_lists_motors_and_follows_selection(devices_view, qtbot):
+ browser = devices_view.browser
+ assert browser.selected == "samx"
+ browser.set_kind("monitor")
+ browser.select("bpm4i")
+ qtbot.wait(20)
+ if isinstance(devices_view, DevicesViewQML):
+ assert devices_view.backend.detail["name"] == "bpm4i"
+ assert devices_view.backend.kind == "monitor"
+ else:
+ assert devices_view.d_name.text() == "bpm4i"
+ assert devices_view.model.rows[devices_view.table.currentIndex().row()]["name"] == "bpm4i"
+
+
+def test_devices_view_subscribes_to_visible_readbacks(devices_view):
+ assert "samx" in devices_view.browser._subscribed
+ devices_view.browser.set_kind("detector")
+ devices_view.browser.rows()
+ assert "samx" not in devices_view.browser._subscribed
+ assert "eiger" in devices_view.browser._subscribed
+
+
+def test_main_app_has_no_device_views_by_default(monkeypatch, qtbot, mocked_client):
+ from bec_widgets.applications.main_app import BECMainApp
+
+ monkeypatch.delenv("BEC_DEVICE_VIEWS", raising=False)
+ app = BECMainApp(client=mocked_client, anim_duration=60, show_examples=False)
+ qtbot.addWidget(app)
+ assert "Devices" not in app._view_index
+ assert "Config" not in app._view_index
+ assert "DM" in app._view_index
+
+
+@pytest.mark.parametrize("flavor", ["qml", "qwidget"])
+def test_main_app_adds_device_views_when_opted_in(flavor, monkeypatch, qtbot, mocked_client):
+ from bec_widgets.applications.main_app import BECMainApp
+
+ monkeypatch.setenv("BEC_DEVICE_VIEWS", flavor)
+ app = BECMainApp(client=mocked_client, anim_duration=60, show_examples=False)
+ qtbot.addWidget(app)
+ assert {"Devices", "Config", "DM"} <= set(app._view_index)
+ with mock.patch.object(app.dock_area.dock_area, "new") as new:
+ app.devices_view.browser.open_in_workspace.emit("PositionerBox", "samx")
+ new.assert_called_once_with("PositionerBox")
+ new.return_value.set_positioner.assert_called_once_with("samx")
+ assert app.stack.currentIndex() == app._view_index["Docks"]