From b10639e3452bcb9da025333396f01bdb911676b7 Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 9 Oct 2026 21:46:18 +0000 Subject: [PATCH] feat(devices_views): split device manager into Devices and Config views, QML and QWidget Co-Authored-By: Claude Opus 5.5 (1M context) Claude-Session: https://claude.ai/code/session_016TCZEjCSXwMdY9sTEqPQWx --- bec_widgets/applications/main_app.py | 44 + .../views/devices_views/__init__.py | 9 + .../devices_views/compare_devices_views.py | 286 +++ .../views/devices_views/devices_core.py | 1237 ++++++++++++ .../views/devices_views/devices_qml.py | 427 ++++ .../views/devices_views/devices_qwidget.py | 1795 +++++++++++++++++ .../views/devices_views/qml/Banner.qml | 41 + .../views/devices_views/qml/Chip.qml | 38 + .../devices_views/qml/DeviceConfigView.qml | 881 ++++++++ .../views/devices_views/qml/DevicesView.qml | 258 +++ .../views/devices_views/qml/GhostButton.qml | 45 + .../views/devices_views/qml/Segmented.qml | 49 + .../views/devices_views/qml/Sheet.qml | 49 + .../views/devices_views/qml/TickBox.qml | 29 + .../views/devices_views/qml/Tones.js | 15 + tests/unit_tests/test_devices_views.py | 381 ++++ 16 files changed, 5584 insertions(+) create mode 100644 bec_widgets/applications/views/devices_views/__init__.py create mode 100644 bec_widgets/applications/views/devices_views/compare_devices_views.py create mode 100644 bec_widgets/applications/views/devices_views/devices_core.py create mode 100644 bec_widgets/applications/views/devices_views/devices_qml.py create mode 100644 bec_widgets/applications/views/devices_views/devices_qwidget.py create mode 100644 bec_widgets/applications/views/devices_views/qml/Banner.qml create mode 100644 bec_widgets/applications/views/devices_views/qml/Chip.qml create mode 100644 bec_widgets/applications/views/devices_views/qml/DeviceConfigView.qml create mode 100644 bec_widgets/applications/views/devices_views/qml/DevicesView.qml create mode 100644 bec_widgets/applications/views/devices_views/qml/GhostButton.qml create mode 100644 bec_widgets/applications/views/devices_views/qml/Segmented.qml create mode 100644 bec_widgets/applications/views/devices_views/qml/Sheet.qml create mode 100644 bec_widgets/applications/views/devices_views/qml/TickBox.qml create mode 100644 bec_widgets/applications/views/devices_views/qml/Tones.js create mode 100644 tests/unit_tests/test_devices_views.py 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"]