mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-10-28 08:01:29 +01:00
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:
16 files changed
+5584
No files matched your search
@@ -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)
|
||||
}
|
||||
@@ -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"]
|
||||
Reference in new issue
Block a user