feat(devices_views): split device manager into Devices and Config views, QML and QWidget

Co-Authored-By: Claude Opus 5.5 (1M context) <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_016TCZEjCSXwMdY9sTEqPQWx
This commit is contained in:
Claude committed 2026-10-09 21:46:18 +00:00
1 parent 78e20e699e
commit b10639e345
16 files changed
+5584

No files matched your search

+44
View File
@@ -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,
*,
@@ -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.
"""
@@ -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()
File diff suppressed because it is too large. Load diff
@@ -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()
File diff suppressed because it is too large. Load diff
@@ -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
}
}
}
}
@@ -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
}
}
@@ -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 !== "" ? " <font color='" + theme.fgSubtle + "' size='2'>" + row.readoutWas + "</font>" : "")
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: "<font color='" + theme.fgMuted + "'>" + modelData.field + ": </font><font color='" + theme.danger + "'><s>" + modelData.old + "</s></font> → <font color='" + theme.success + "'>" + modelData.new + "</font>"
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 <b>all " + root.backend.sessionCount + " devices</b> 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()
}
]
}
}
@@ -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 || "") + "<span style='font-size:14px'> " + (root.d.units || "") + "</span>"
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()
}
}
}
}
}
}
}
@@ -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
}
@@ -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
}
}
}
}
}
@@ -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 }
}
}
@@ -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
}
}
@@ -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)
}
+381
View File
@@ -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"]