feat(waveform): redesigned curve settings dialog with list and inspector, QWidget and QML versions, opt-in via BEC_CURVE_DIALOG

This commit is contained in:
Claude committed 2026-10-09 22:45:34 +00:00
1 parent 5f129f9ac1
commit 5a2f29cf72
10 files changed
+3792 -2

No files matched your search

Whitespace-only changes.
@@ -0,0 +1,195 @@
"""Helpers shared by the QWidget and QML curve dialogs: picker anchoring, curve samples and the
choice between the dialog versions."""
from __future__ import annotations
import math
import os
from typing import TYPE_CHECKING
from qtpy.QtCore import QPointF, QRectF, Qt
from qtpy.QtGui import QColor, QPainter, QPainterPath, QPen, QPolygonF
from qtpy.QtWidgets import QWidget
from bec_widgets.widgets.control.device_input.picker.picker_model import recent_picks
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.control.device_input.picker.picker_common import PickerPopupMixin
from bec_widgets.widgets.plots.waveform.waveform import Waveform
ENV_VAR = "BEC_CURVE_DIALOG"
PEN_STYLE_MAP = {
"solid": Qt.PenStyle.SolidLine,
"dash": Qt.PenStyle.DashLine,
"dot": Qt.PenStyle.DotLine,
"dashdot": Qt.PenStyle.DashDotLine,
}
def open_picker_at(picker: PickerPopupMixin, anchor: QWidget, query: str = "") -> None:
"""Open the searchable popup of ``picker`` under ``anchor`` instead of under the picker.
Lets a button (or a field drawn by QML) open a device or signal picker whose combobox is
hidden; the pick arrives through ``picker.picker_controller.picked``.
"""
# pylint: disable=protected-access
picker._picker_text_before = picker.currentText()
picker.picker_controller.open(
picker._picker_entries(), picker.currentText(), query, recent_picks(picker.RECENT_KIND)
)
picker.picker_popup.show_below(anchor)
def signal_obj_name(picker, text: str) -> str:
"""Object name of the signal shown as ``text`` in a signal picker."""
index = picker.findText(text)
if index >= 0:
data = picker.itemData(index)
if isinstance(data, dict) and data.get("obj_name"):
return data["obj_name"]
return text
def select_signal(picker, device: str, signal: str) -> None:
"""Show ``signal`` (object name) of ``device`` in a signal picker without emitting."""
picker.blockSignals(True)
try:
picker.set_device(device or None)
if not signal or not picker.set_to_obj_name(signal):
picker.setCurrentText(signal or "")
finally:
picker.blockSignals(False)
def select_device(picker, device: str) -> None:
"""Show ``device`` in a device picker without emitting."""
picker.blockSignals(True)
try:
index = picker.findText(device or "")
if index >= 0:
picker.setCurrentIndex(index)
else:
picker.setCurrentText(device or "")
finally:
picker.blockSignals(False)
def paint_symbol(painter: QPainter, center: QPointF, symbol: str, size: float) -> None:
"""Draw a pyqtgraph-style symbol with the painter's current pen and brush."""
half = size / 2
x, y = center.x(), center.y()
if symbol == "o":
painter.drawEllipse(center, half, half)
elif symbol == "s":
painter.drawRect(QRectF(x - half, y - half, size, size))
elif symbol == "t":
painter.drawPolygon(
QPolygonF(
[QPointF(x - half, y - half), QPointF(x + half, y - half), QPointF(x, y + half)]
)
)
elif symbol == "d":
painter.drawPolygon(
QPolygonF(
[
QPointF(x, y - half),
QPointF(x + half, y),
QPointF(x, y + half),
QPointF(x - half, y),
]
)
)
elif symbol == "star":
points = []
for i in range(10):
radius = half if i % 2 == 0 else half * 0.45
angle = -math.pi / 2 + i * math.pi / 5
points.append(QPointF(x + radius * math.cos(angle), y + radius * math.sin(angle)))
painter.drawPolygon(QPolygonF(points))
elif symbol in ("+", "x"):
pen = QPen(painter.pen())
pen.setColor(painter.brush().color())
pen.setWidthF(max(1.5, size / 4))
painter.save()
painter.setPen(pen)
if symbol == "+":
painter.drawLine(QPointF(x - half, y), QPointF(x + half, y))
painter.drawLine(QPointF(x, y - half), QPointF(x, y + half))
else:
painter.drawLine(QPointF(x - half, y - half), QPointF(x + half, y + half))
painter.drawLine(QPointF(x - half, y + half), QPointF(x + half, y - half))
painter.restore()
def paint_curve_sample(
painter: QPainter,
rect: QRectF,
color: str | QColor,
pen_style: str = "solid",
pen_width: float = 2,
symbol: str = "",
symbol_size: float = 6,
max_width: float = 4,
max_symbol: float = 9,
peak: bool = True,
) -> None:
"""Draw a small sample of a curve: a peak (or a flat line) with symbols on three points."""
painter.save()
painter.setRenderHint(QPainter.RenderHint.Antialiasing)
color = QColor(color)
pen = QPen(color, min(max(1.0, float(pen_width)), max_width))
pen.setStyle(PEN_STYLE_MAP.get(pen_style, Qt.PenStyle.SolidLine))
pen.setCapStyle(Qt.PenCapStyle.RoundCap)
painter.setPen(pen)
painter.setBrush(Qt.BrushStyle.NoBrush)
margin = min(max_symbol, symbol_size) / 2 + 1 if symbol else 2
left, right = rect.left() + margin, rect.right() - margin
top, bottom = rect.top() + margin, rect.bottom() - margin
path = QPainterPath()
steps = 40
points = []
for i in range(steps + 1):
t = i / steps
x = left + t * (right - left)
h = math.exp(-((t - 0.5) ** 2) / 0.03) if peak else 0.5
y = bottom - h * (bottom - top)
points.append(QPointF(x, y))
path.moveTo(points[0])
for point in points[1:]:
path.lineTo(point)
painter.drawPath(path)
if symbol:
painter.setPen(QPen(color.darker(130), 1))
painter.setBrush(color)
size = min(max(3.0, float(symbol_size)), max_symbol)
for index in (steps // 5, steps // 2, steps - steps // 5):
paint_symbol(painter, points[index], symbol, size)
painter.restore()
def curve_dialog_variant() -> str:
"""Which curve dialog the waveform opens: ``legacy`` (default), ``qwidget`` or ``qml``."""
value = os.environ.get(ENV_VAR, "").strip().lower()
return value if value in ("qwidget", "qml") else "legacy"
def create_curve_setting(waveform: Waveform, variant: str | None = None):
"""Create the curve settings widget chosen by ``BEC_CURVE_DIALOG`` (or ``variant``)."""
variant = variant or curve_dialog_variant()
if variant == "qwidget":
from bec_widgets.widgets.plots.waveform.settings.curve_dialog.curve_dialog_qwidget import (
CurveSettingsQWidget,
)
return CurveSettingsQWidget(parent=waveform, target_widget=waveform)
if variant == "qml":
from bec_widgets.widgets.plots.waveform.settings.curve_dialog.curve_dialog_qml import (
CurveSettingsQml,
)
return CurveSettingsQml(parent=waveform, target_widget=waveform)
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_setting import (
CurveSetting,
)
return CurveSetting(parent=waveform, target_widget=waveform)
@@ -0,0 +1,762 @@
"""Toolkit-independent state of the redesigned curve settings dialog.
The dialog edits a staged copy of the waveform's curves ("drafts") and only writes them back
on :meth:`CurveDialogModel.apply`. The QWidget and the QML views are thin layers over this
model, so both behave identically and the JSON they produce matches the legacy curve tree.
"""
from __future__ import annotations
import json
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Any
from bec_lib.logger import bec_logger
from qtpy.QtCore import QObject, Signal
from qtpy.QtGui import QColor
from bec_widgets.utils.colors import Colors
from bec_widgets.widgets.plots.waveform.curve import CurveConfig, DeviceSignal
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.plots.waveform.waveform import Waveform
logger = bec_logger.logger
X_MODES = ("auto", "index", "timestamp", "device")
X_MODE_LABELS = {"auto": "Auto", "index": "Index", "timestamp": "Time", "device": "Device"}
X_MODE_HELP = {
"auto": "Uses the scan motor, or the point index when the scan has no motor.",
"index": "Plots every curve against its point number.",
"timestamp": "Plots every curve against the time each point was read.",
"device": "Plots every curve against a signal you choose.",
}
PEN_STYLES = ("solid", "dash", "dot", "dashdot")
PEN_STYLE_LABELS = {"solid": "Solid", "dash": "Dash", "dot": "Dot", "dashdot": "Dash-dot"}
# pyqtgraph symbol codes; "" means no symbol
SYMBOLS = ("", "o", "s", "t", "d", "star", "+", "x")
SYMBOL_LABELS = {
"": "None",
"o": "Circle",
"s": "Square",
"t": "Triangle",
"d": "Diamond",
"star": "Star",
"+": "Plus",
"x": "Cross",
}
PALETTES = ("plasma", "viridis", "magma", "inferno", "cividis", "turbo")
SWATCH_COUNT = 10
def _pretty_model(model: str) -> str:
"""``GaussianModel`` -> ``Gaussian``."""
return model[:-5] if model.endswith("Model") and len(model) > 5 else model
def color_hex(color: str | tuple | None) -> str:
"""Return a curve colour as ``#rrggbb`` (grey if unset)."""
if color is None:
return "#888888"
if isinstance(color, str):
return QColor(color).name()
return QColor(*[int(c) for c in color[:4]]).name()
def normalize_dap(dap_value: str | list[str] | tuple[str, ...] | None) -> list[str]:
"""Return the fit models of a serialized DAP value as a list, primary model first."""
if isinstance(dap_value, (list, tuple)):
models: list[str] = []
for model in dap_value:
if model and model not in models:
models.append(model)
return models or ["GaussianModel"]
if isinstance(dap_value, str) and dap_value:
return [dap_value]
return ["GaussianModel"]
@dataclass
class CurveDraft:
"""One curve as edited in the dialog.
Attributes:
uid(int): Stable id within the dialog session.
kind(str): ``"device"`` (live or history data), ``"dap"`` (a fit) or ``"custom"``.
config(CurveConfig): Working copy of the curve config.
parent_uid(int | None): The curve a fit belongs to.
original_label(str | None): Label of the waveform curve this draft was loaded from.
original_dap(Any): DAP value the draft was loaded with; fit parameters only carry over
while it is unchanged.
"""
uid: int
kind: str
config: CurveConfig
parent_uid: int | None = None
original_label: str | None = None
original_dap: Any = None
models: list[str] = field(default_factory=list)
@property
def device(self) -> str:
"""Device of the curve ('' if none)."""
return self.config.signal.device if self.config.signal else ""
@property
def signal(self) -> str:
"""Signal object name of the curve ('' if none)."""
return self.config.signal.signal if self.config.signal else ""
class CurveDialogModel(QObject):
"""Staged curves, X axis and palette of one waveform.
Signals:
rows_changed: The list of curves or anything shown in it changed.
selection_changed: Another curve was selected (views rebuild their inspector).
draft_changed(int): A field of the given curve changed through the model.
x_axis_changed: Mode, device or signal of the X axis changed.
palette_changed: The palette changed.
"""
rows_changed = Signal()
selection_changed = Signal()
draft_changed = Signal(int)
x_axis_changed = Signal()
palette_changed = Signal()
def __init__(
self, waveform: Waveform | None = None, client=None, parent: QObject | None = None
):
super().__init__(parent)
self.waveform = waveform
self.client = client if client is not None else getattr(waveform, "client", None)
self._drafts: list[CurveDraft] = []
self._next_uid = 1
self._selected: int | None = None
self.x_mode = "auto"
self.x_device = ""
self.x_signal = ""
self.palette = "plasma"
self.load_from_waveform()
# ---- loading -----------------------------------------------------------------------------
def load_from_waveform(self) -> None:
"""(Re)load curves, X axis and palette from the waveform, discarding staged edits."""
self._drafts = []
self._next_uid = 1
selected_label = None
if self._selected is not None and self.draft(self._selected) is not None:
selected_label = self.draft(self._selected).original_label
self._selected = None
waveform = self.waveform
if waveform is None:
self.rows_changed.emit()
self.selection_changed.emit()
return
self.palette = getattr(waveform, "color_palette", None) or "plasma"
mode = waveform.x_axis_mode.get("name", "auto")
if mode in ("auto", "index", "timestamp"):
self.x_mode, self.x_device, self.x_signal = mode, "", ""
else:
self.x_mode = "device"
self.x_device = mode or ""
self.x_signal = waveform.x_axis_mode.get("entry") or ""
curves = list(waveform.curves)
tops = [c for c in curves if c.config.source in ("device", "history", "custom")]
fits = [c for c in curves if c.config.source == "dap"]
for curve in tops:
kind = "custom" if curve.config.source == "custom" else "device"
top = self._new_draft(kind, curve.config.model_copy(deep=True), curve.name())
for fit in fits:
if fit.config.parent_label == curve.config.label:
config = fit.config.model_copy(deep=True)
draft = self._new_draft("dap", config, fit.name(), parent_uid=top.uid)
draft.original_dap = config.signal.dap if config.signal else None
draft.models = normalize_dap(draft.original_dap)
for draft in self._drafts:
if draft.original_label == selected_label:
self._selected = draft.uid
if self._selected is None and self._drafts:
self._selected = self._drafts[0].uid
self.x_axis_changed.emit()
self.palette_changed.emit()
self.rows_changed.emit()
self.selection_changed.emit()
def _new_draft(
self,
kind: str,
config: CurveConfig,
original_label: str | None = None,
parent_uid: int | None = None,
) -> CurveDraft:
draft = CurveDraft(
uid=self._next_uid,
kind=kind,
config=config,
parent_uid=parent_uid,
original_label=original_label,
)
self._next_uid += 1
if parent_uid is None:
self._drafts.append(draft)
else:
# keep fits right after their parent and its other fits
index = max(
i for i, d in enumerate(self._drafts) if parent_uid in (d.uid, d.parent_uid)
)
self._drafts.insert(index + 1, draft)
return draft
# ---- queries -----------------------------------------------------------------------------
@property
def drafts(self) -> list[CurveDraft]:
"""All drafts in display order (each curve followed by its fits)."""
return list(self._drafts)
def draft(self, uid: int | None) -> CurveDraft | None:
"""The draft with ``uid``, or None."""
return next((d for d in self._drafts if d.uid == uid), None)
def children(self, uid: int) -> list[CurveDraft]:
"""Fits of the curve ``uid``."""
return [d for d in self._drafts if d.parent_uid == uid]
@property
def selected_uid(self) -> int | None:
"""Uid of the selected curve."""
return self._selected
@property
def selected(self) -> CurveDraft | None:
"""The selected draft."""
return self.draft(self._selected)
def device_names(self) -> list[str]:
"""Names of the devices known to the device manager."""
dm = getattr(self.client, "device_manager", None)
devices = getattr(dm, "devices", None) or {}
return list(devices.keys())
def _device(self, name: str):
dm = getattr(self.client, "device_manager", None)
devices = getattr(dm, "devices", None) or {}
return devices.get(name) if name else None
def device_signals(self, device: str) -> list[str]:
"""Object names of the plottable (hinted and normal) signals of ``device``."""
dev = self._device(device)
if dev is None:
return []
info = (getattr(dev, "_info", None) or {}).get("signals", {}) or {}
names = []
for name, signal in info.items():
kind = str(signal.get("kind_str", ""))
if kind in ("hinted", "normal") or "hinted" in kind or "normal" in kind:
names.append(signal.get("obj_name") or name)
return names or [device]
def default_signal(self, device: str) -> str:
"""The signal a new curve of ``device`` starts with: its first hint, else the device."""
dev = self._device(device)
if dev is None:
return ""
hints = []
try:
hints = list(getattr(dev, "_hints", None) or [])
except Exception: # pylint: disable=broad-except
hints = []
if hints:
return hints[0]
signals = self.device_signals(device)
return signals[0] if signals else device
def available_models(self) -> list[str]:
"""Fit models offered by the DAP service."""
# pylint: disable=protected-access
plugins = getattr(getattr(self.client, "dap", None), "_available_dap_plugins", {}) or {}
return list(plugins.keys())
def scans(self) -> list[tuple[str, str | None]]:
"""Selectable data sources: ``("Live scan", None)`` then ``("Scan #N", scan_id)``."""
out: list[tuple[str, str | None]] = [("Live scan", None)]
history = getattr(self.client, "history", None)
if history is None:
return out
# pylint: disable=protected-access
numbers = list(getattr(history, "_scan_numbers", []) or [])
ids = list(getattr(history, "_scan_ids", []) or [])
for number, scan_id in sorted(zip(numbers, ids), key=lambda pair: pair[0], reverse=True):
out.append((f"Scan #{number}", scan_id))
return out
def swatches(self, current: str | None = None) -> list[str]:
"""Suggested colours: evenly spaced over the palette, led by ``current`` if it is not
one of them."""
colors = [
QColor(c).name()
for c in Colors.evenly_spaced_colors(
colormap=self.palette, num=SWATCH_COUNT, format="HEX"
)
]
if current and current.lower() not in colors:
colors = [current.lower()] + colors[:-1]
return colors
def palettes(self) -> list[str]:
"""Palettes offered in the palette selector."""
names = list(PALETTES)
if self.palette not in names:
names.insert(0, self.palette)
return names
# ---- rows shown in the curve list --------------------------------------------------------
def label_of(self, draft: CurveDraft) -> str:
"""The label the curve will get on apply (the legacy naming scheme)."""
if draft.kind == "custom":
return draft.original_label or draft.config.label or ""
if draft.kind == "device":
label = f"{draft.device}-{draft.signal}"
if draft.config.scan_id:
label += f"-scan-{draft.config.scan_number}"
return label
parent = self.draft(draft.parent_uid)
parent_label = self.label_of(parent) if parent else draft.config.parent_label or ""
return f"{parent_label}-{'+'.join(draft.models)}"
def display_name(self, draft: CurveDraft) -> str:
"""Short name of a curve for texts: ``device``, or ``device · signal`` when they differ."""
if draft.kind == "custom":
return draft.original_label or draft.config.label or "custom"
if draft.kind == "dap":
return " + ".join(_pretty_model(m) for m in draft.models) + " fit"
name = draft.device or "new curve"
if draft.signal and draft.signal != draft.device:
name += f" · {draft.signal}"
if draft.config.scan_id:
name += f" (scan #{draft.config.scan_number})"
return name
def row(self, draft: CurveDraft, errors: dict[int, str] | None = None) -> dict:
"""Display data of one draft for the curve list."""
errors = self.validate() if errors is None else errors
config = draft.config
if draft.kind == "device":
title = draft.device or "New curve"
source = f"scan #{config.scan_number}" if config.scan_id else "live"
subtitle = f"{draft.signal or 'no signal'} · {source}"
tag = "history" if config.scan_id else ""
elif draft.kind == "dap":
title = " + ".join(_pretty_model(m) for m in draft.models) + " fit"
parent = self.draft(draft.parent_uid)
subtitle = f"fit of {self.display_name(parent)}" if parent else ""
tag = "fit"
else:
title = draft.original_label or config.label or "custom"
subtitle = "data sent from the command line"
tag = "custom"
color = color_hex(config.color)
return {
"uid": draft.uid,
"kind": draft.kind,
"depth": 1 if draft.parent_uid is not None else 0,
"title": title,
"subtitle": subtitle,
"tag": tag,
"color": color,
"penStyle": config.pen_style or "solid",
"penWidth": int(config.pen_width or 1),
"symbol": config.symbol or "",
"symbolSize": int(config.symbol_size or 1),
"error": errors.get(draft.uid, ""),
"selected": draft.uid == self._selected,
}
def rows(self) -> list[dict]:
"""Display data of all drafts in order."""
errors = self.validate()
return [self.row(d, errors) for d in self._drafts]
# ---- validation --------------------------------------------------------------------------
def validate(self) -> dict[int, str]:
"""Problems that block applying, keyed by draft uid."""
errors: dict[int, str] = {}
devices = set(self.device_names())
models = set(self.available_models())
scan_ids = {scan_id for _, scan_id in self.scans() if scan_id}
labels: dict[str, int] = {}
for draft in self._drafts:
if draft.kind == "device":
if not draft.device:
errors[draft.uid] = "Choose a device."
elif devices and draft.device not in devices:
errors[draft.uid] = f"Device '{draft.device}' is not loaded."
elif not draft.signal:
errors[draft.uid] = "Choose a signal."
elif draft.config.scan_id and draft.config.scan_id not in scan_ids:
errors[draft.uid] = "This scan is no longer in the history."
elif draft.kind == "dap":
missing = [m for m in draft.models if models and m not in models]
if missing:
errors[draft.uid] = f"Fit model {missing[0]} is not available."
label = self.label_of(draft)
if draft.uid not in errors and label in labels:
errors[draft.uid] = "The same curve is already listed."
labels.setdefault(label, draft.uid)
return errors
def x_axis_error(self) -> str:
"""Problem with the X axis selection, '' if none."""
if self.x_mode != "device":
return ""
if not self.x_device:
return "Choose the device for the X axis."
if self.device_names() and self.x_device not in self.device_names():
return f"Device '{self.x_device}' is not loaded."
return ""
def problems(self) -> list[str]:
"""Human-readable problems that block applying."""
out = []
x_error = self.x_axis_error()
if x_error:
out.append(f"X axis: {x_error}")
errors = self.validate()
for draft in self._drafts:
if draft.uid in errors:
out.append(f"{self.row(draft, errors)['title']}: {errors[draft.uid]}")
return out
# ---- editing -----------------------------------------------------------------------------
def select(self, uid: int | None) -> None:
"""Select the curve ``uid`` (None clears the selection)."""
if uid == self._selected or (uid is not None and self.draft(uid) is None):
return
self._selected = uid
self.rows_changed.emit()
self.selection_changed.emit()
def _next_color(self) -> str:
used = {str(d.config.color).lower() for d in self._drafts}
buffer = Colors.golden_angle_color(
colormap=self.palette, num=max(10, len(self._drafts) + 1), format="HEX"
)
for color in buffer:
if color.lower() not in used:
return color
return buffer[len(self._drafts) % len(buffer)]
def add_curve(self, device: str = "", signal: str = "") -> int:
"""Add a live curve of ``device`` and select it.
Args:
device(str): Device name; may be empty and chosen later.
signal(str): Signal object name; defaults to the device's first hint.
Returns:
int: Uid of the new curve.
"""
if device and not signal:
signal = self.default_signal(device)
existing = next(
(
d
for d in self._drafts
if d.kind == "device"
and d.device == device
and d.signal == signal
and not d.config.scan_id
and device
),
None,
)
if existing is not None:
# the curve is already plotted: show it instead of adding a duplicate
self.select(existing.uid)
return existing.uid
color = self._next_color()
config = CurveConfig(
widget_class="Curve",
parent_id=getattr(self.waveform, "gui_id", None),
source="device",
signal=DeviceSignal(device=device, signal=signal),
color=color,
symbol_color=color,
)
draft = self._new_draft("device", config)
self._selected = draft.uid
self.rows_changed.emit()
self.selection_changed.emit()
return draft.uid
def add_fit(self, parent_uid: int, model: str | None = None) -> int | None:
"""Add a fit to the curve ``parent_uid`` and select it.
Returns:
int | None: Uid of the new fit, None if the parent cannot have fits.
"""
parent = self.draft(parent_uid)
if parent is None or parent.kind == "dap":
return None
models = self.available_models()
model = model or ("GaussianModel" if not models or "GaussianModel" in models else models[0])
color = self._next_color()
config = CurveConfig(
widget_class="Curve",
parent_id=getattr(self.waveform, "gui_id", None),
source="dap",
parent_label=self.label_of(parent),
# fits of command-line curves address their parent by label (as Waveform does)
signal=(
DeviceSignal(device=self.label_of(parent), signal="custom", dap=model)
if parent.kind == "custom"
else DeviceSignal(device=parent.device, signal=parent.signal, dap=model)
),
color=color,
symbol_color=color,
)
draft = self._new_draft("dap", config, parent_uid=parent.uid)
draft.models = [model]
self._selected = draft.uid
self.rows_changed.emit()
self.selection_changed.emit()
return draft.uid
def remove(self, uid: int) -> None:
"""Remove a device curve with its fits, or a single fit. Custom curves stay."""
draft = self.draft(uid)
if draft is None or draft.kind == "custom":
return
removed = {uid} | {d.uid for d in self.children(uid)}
index = self._drafts.index(draft)
self._drafts = [d for d in self._drafts if d.uid not in removed]
if self._selected in removed:
if draft.parent_uid is not None:
self._selected = draft.parent_uid
elif self._drafts:
self._selected = self._drafts[min(index, len(self._drafts) - 1)].uid
if self.draft(self._selected).parent_uid is not None:
self._selected = self.draft(self._selected).parent_uid
else:
self._selected = None
self.selection_changed.emit()
self.rows_changed.emit()
def _changed(self, uid: int) -> None:
self.draft_changed.emit(uid)
self.rows_changed.emit()
def set_device(self, uid: int, device: str) -> None:
"""Change the device of a curve; the signal resets to the device's first hint."""
draft = self.draft(uid)
if draft is None or draft.kind != "device" or device == draft.device:
return
draft.config.signal = DeviceSignal(device=device, signal=self.default_signal(device))
self._changed(uid)
def set_signal(self, uid: int, signal: str) -> None:
"""Change the signal (object name) of a curve."""
draft = self.draft(uid)
if draft is None or draft.kind != "device" or signal == draft.signal:
return
draft.config.signal = DeviceSignal(device=draft.device, signal=signal)
self._changed(uid)
def set_scan(self, uid: int, scan_id: str | None) -> None:
"""Plot the curve from the live scan (None) or from a scan of the history."""
draft = self.draft(uid)
if draft is None or draft.kind != "device":
return
number = None
if scan_id:
history = getattr(self.client, "history", None)
# pylint: disable=protected-access
pairs = dict(
zip(getattr(history, "_scan_ids", []), getattr(history, "_scan_numbers", []))
)
number = pairs.get(scan_id)
draft.config.scan_id = scan_id or None
draft.config.scan_number = number
self._changed(uid)
def set_models(self, uid: int, models: list[str]) -> None:
"""Set the fit models of a fit, primary first; more than one makes a composite fit."""
draft = self.draft(uid)
if draft is None or draft.kind != "dap":
return
models = normalize_dap(models)
if models == draft.models:
return
draft.models = models
self._changed(uid)
def set_primary_model(self, uid: int, model: str) -> None:
"""Replace the primary fit model, keeping the other models of a composite fit."""
draft = self.draft(uid)
if draft is None or draft.kind != "dap":
return
self.set_models(uid, [model] + [m for m in draft.models[1:] if m != model])
def toggle_extra_model(self, uid: int, model: str) -> None:
"""Add ``model`` to a composite fit, or remove it."""
draft = self.draft(uid)
if draft is None or draft.kind != "dap" or model == draft.models[0]:
return
extras = list(draft.models[1:])
if model in extras:
extras.remove(model)
else:
extras.append(model)
self.set_models(uid, [draft.models[0]] + extras)
def set_style(self, uid: int, key: str, value: Any) -> None:
"""Change a style field of a curve.
Args:
uid(int): Curve.
key(str): ``color``, ``pen_style``, ``pen_width``, ``symbol`` or ``symbol_size``.
value: New value.
"""
draft = self.draft(uid)
if draft is None or key not in ("color", "pen_style", "pen_width", "symbol", "symbol_size"):
return
if key == "symbol":
value = value or None
if key in ("pen_width", "symbol_size"):
value = max(1, min(20, int(value)))
if getattr(draft.config, key) == value:
return
setattr(draft.config, key, value)
if key == "color":
draft.config.symbol_color = value
self._changed(uid)
def set_palette(self, palette: str, recolor: bool = True) -> None:
"""Choose the palette new curves take their colours from; recolours all curves."""
if palette == self.palette and not recolor:
return
self.palette = palette
if recolor:
self.recolor_all(emit=False)
self.palette_changed.emit()
self.rows_changed.emit()
if self._selected is not None:
self.draft_changed.emit(self._selected)
def recolor_all(self, emit: bool = True) -> None:
"""Give every curve the next colour of the palette, in list order."""
colors = Colors.golden_angle_color(
colormap=self.palette, num=max(10, len(self._drafts) + 1), format="HEX"
)
for index, draft in enumerate(self._drafts):
draft.config.color = colors[index]
draft.config.symbol_color = colors[index]
if emit:
self.rows_changed.emit()
if self._selected is not None:
self.draft_changed.emit(self._selected)
def set_x_mode(self, mode: str) -> None:
"""Choose how the X axis is built (see :data:`X_MODES`)."""
if mode not in X_MODES or mode == self.x_mode:
return
self.x_mode = mode
self.x_axis_changed.emit()
def set_x_device(self, device: str) -> None:
"""Choose the X axis device; the signal resets to its first hint."""
if device == self.x_device:
return
self.x_device = device
self.x_signal = self.default_signal(device) if device else ""
self.x_axis_changed.emit()
def set_x_signal(self, signal: str) -> None:
"""Choose the X axis signal (object name)."""
if signal == self.x_signal:
return
self.x_signal = signal
self.x_axis_changed.emit()
# ---- export and apply --------------------------------------------------------------------
def _export_draft(self, draft: CurveDraft) -> dict:
config = draft.config.model_copy(deep=True)
if draft.kind == "device":
config.source = "history" if config.scan_id else "device"
if not config.scan_id:
config.scan_number = None
config.label = self.label_of(draft)
elif draft.kind == "dap":
parent = self.draft(draft.parent_uid)
device, signal = (parent.device, parent.signal) if parent else ("", "")
if parent is not None and parent.kind == "custom":
device, signal = draft.device, draft.signal
dap: str | list[str] = draft.models if len(draft.models) > 1 else draft.models[0]
old = draft.config.signal
config.signal = DeviceSignal(
device=device,
signal=signal,
dap=dap,
dap_oversample=old.dap_oversample if old is not None else 1,
dap_parameters=(
old.dap_parameters
if old is not None
and draft.original_dap is not None
and draft.original_dap == dap
else None
),
)
config.source = "dap"
config.parent_label = self.label_of(parent) if parent else config.parent_label
config.label = self.label_of(draft)
return config.model_dump()
def export_curves(self) -> list[dict]:
"""Configs of all non-custom curves, in the format of ``Waveform.curve_json``."""
return [self._export_draft(d) for d in self._drafts if d.kind != "custom"]
def apply(self) -> None:
"""Write palette, X axis and curves to the waveform.
Raises:
ValueError: If a curve or the X axis is incomplete.
"""
problems = self.problems()
if problems:
raise ValueError("Cannot apply the curve settings:\n" + "\n".join(problems))
waveform = self.waveform
if waveform is None:
return
if waveform.color_palette != self.palette:
waveform.color_palette = self.palette
if self.x_mode == "device":
waveform.x_mode = self.x_device
if self.x_signal:
waveform.signal_x = self.x_signal
else:
waveform.x_mode = self.x_mode
waveform.curve_json = json.dumps(self.export_curves(), indent=2)
self._apply_custom_styles()
# reload so that labels and fit parameters match what the waveform now holds
self.load_from_waveform()
def _apply_custom_styles(self) -> None:
by_name = {curve.name(): curve for curve in self.waveform.curves}
for draft in self._drafts:
if draft.kind != "custom":
continue
curve = by_name.get(draft.original_label)
if curve is None:
continue
for key in ("color", "symbol_color", "pen_style", "pen_width", "symbol", "symbol_size"):
setattr(curve.config, key, getattr(draft.config, key))
curve.apply_config()
@@ -0,0 +1,405 @@
"""QML version of the redesigned curve settings dialog.
Shows :class:`CurveDialogModel` through ``qml/CurveDialog.qml``. Device and signal fields are
drawn in QML; clicking one opens the QML popup of a hidden :class:`DevicePickerQML` or
:class:`SignalPickerQML`, anchored to the field, so search, grouping and live values are the same
as everywhere else the pickers are used.
"""
from __future__ import annotations
from pathlib import Path
from typing import TYPE_CHECKING
from qtpy.QtCore import Property, QObject, QSize, Signal, Slot
from qtpy.QtGui import QColor
from qtpy.QtWidgets import QColorDialog, QVBoxLayout, QWidget
from bec_widgets.utils.colors import Colors
from bec_widgets.utils.error_popups import SafeSlot
from bec_widgets.utils.quick import create_quick_widget, release_quick_widget
from bec_widgets.utils.settings_dialog import SettingWidget
from bec_widgets.widgets.control.device_input.picker.picker_qml import (
DevicePickerQML,
SignalPickerQML,
)
from bec_widgets.widgets.plots.waveform.settings.curve_dialog.curve_dialog_common import (
open_picker_at,
select_device,
select_signal,
signal_obj_name,
)
from bec_widgets.widgets.plots.waveform.settings.curve_dialog.curve_dialog_model import (
PEN_STYLE_LABELS,
PEN_STYLES,
SYMBOL_LABELS,
SYMBOLS,
X_MODE_HELP,
X_MODE_LABELS,
X_MODES,
CurveDialogModel,
_pretty_model,
color_hex,
)
if TYPE_CHECKING: # pragma: no cover
from bec_widgets.widgets.plots.waveform.waveform import Waveform
QML_FILE = Path(__file__).parent / "qml" / "CurveDialog.qml"
class CurveDialogBackend(QObject):
"""Exposes :class:`CurveDialogModel` to QML as plain lists and maps."""
rowsChanged = Signal()
selectedChanged = Signal()
xAxisChanged = Signal()
paletteChanged = Signal()
pickerRequested = Signal(str, float, float, float, float)
def __init__(self, model: CurveDialogModel, parent: QObject | None = None):
super().__init__(parent)
self.model = model
self._rows: list = []
self._selected: dict = {}
model.rows_changed.connect(self._on_rows)
model.selection_changed.connect(self._on_selected)
model.draft_changed.connect(self._on_draft)
model.x_axis_changed.connect(self.xAxisChanged)
model.x_axis_changed.connect(self._on_rows)
model.palette_changed.connect(self.paletteChanged)
self._on_rows()
self._on_selected()
# ---- change tracking ---------------------------------------------------------------------
def _on_rows(self) -> None:
self._rows = self.model.rows()
self.rowsChanged.emit()
# errors and titles shown in the inspector depend on the rows too
self._on_selected()
def _on_draft(self, uid: int) -> None:
if uid == self.model.selected_uid:
self._on_selected()
def _on_selected(self) -> None:
self._selected = self._inspector_data()
self.selectedChanged.emit()
def _inspector_data(self) -> dict:
model = self.model
draft = model.selected
if draft is None:
return {"uid": -1}
errors = model.validate()
row = model.row(draft, errors)
error = errors.get(draft.uid, "")
data = dict(row)
data.update(
{
"device": draft.device,
"signal": draft.signal,
"swatches": model.swatches(row["color"]),
"deviceError": error if "evice" in error else "",
"signalError": error if "ignal" in error else "",
"scanError": error if "scan" in error else "",
"modelError": error if draft.kind == "dap" else "",
"fits": [model.row(fit, errors) for fit in model.children(draft.uid)],
}
)
scans = model.scans()
scan_ids = [scan_id for _, scan_id in scans]
scan_labels = [label for label, _ in scans]
if draft.config.scan_id and draft.config.scan_id not in scan_ids:
scan_ids.append(draft.config.scan_id)
scan_labels.append(f"Scan #{draft.config.scan_number} (missing)")
data["scanLabels"] = scan_labels
data["scanIndex"] = scan_ids.index(draft.config.scan_id or None)
self._scan_ids = scan_ids
if draft.kind == "dap":
available = model.available_models() or list(draft.models)
if draft.models[0] not in available:
available = [draft.models[0]] + available
data["models"] = available
data["modelIndex"] = available.index(draft.models[0])
data["extras"] = [
{"name": m, "label": _pretty_model(m), "checked": m in draft.models[1:]}
for m in available
if m != draft.models[0]
]
data["composite"] = (
f"Composite fit: the sum of {' + '.join(draft.models)}."
if len(draft.models) > 1
else "Select more models to fit their sum (a composite fit)."
)
parent = model.draft(draft.parent_uid)
data["parentUid"] = parent.uid if parent else -1
data["parentTitle"] = model.row(parent, errors)["title"] if parent else ""
return data
# ---- properties --------------------------------------------------------------------------
@Property("QVariantList", notify=rowsChanged)
def rows(self) -> list:
"""Rows of the curve list."""
return self._rows
@Property(int, notify=rowsChanged)
def curveCount(self) -> int: # pylint: disable=invalid-name
"""Number of curves without their fits."""
return sum(1 for r in self._rows if r["depth"] == 0)
@Property("QVariantList", notify=rowsChanged)
def problems(self) -> list:
"""Problems that block applying."""
return self.model.problems()
@Property("QVariantMap", notify=selectedChanged)
def selected(self) -> dict:
"""Everything the inspector shows for the selected curve (``uid`` -1 if none)."""
return self._selected
@Property("QVariantList", constant=True)
def xModes(self) -> list: # pylint: disable=invalid-name
"""Labels of the X axis modes."""
return [X_MODE_LABELS[m] for m in X_MODES]
@Property(int, notify=xAxisChanged)
def xModeIndex(self) -> int: # pylint: disable=invalid-name
"""Selected X axis mode."""
return X_MODES.index(self.model.x_mode)
@Property(str, notify=xAxisChanged)
def xHelp(self) -> str: # pylint: disable=invalid-name
"""Explanation of the X axis mode, or the X axis problem."""
return self.model.x_axis_error() or X_MODE_HELP[self.model.x_mode]
@Property(bool, notify=xAxisChanged)
def xInvalid(self) -> bool: # pylint: disable=invalid-name
"""Whether the X axis selection is incomplete."""
return bool(self.model.x_axis_error())
@Property(str, notify=xAxisChanged)
def xDevice(self) -> str: # pylint: disable=invalid-name
"""X axis device."""
return self.model.x_device
@Property(str, notify=xAxisChanged)
def xSignal(self) -> str: # pylint: disable=invalid-name
"""X axis signal."""
return self.model.x_signal
@Property(str, notify=paletteChanged)
def palette(self) -> str:
"""Current palette."""
return self.model.palette
@Property("QVariantList", notify=paletteChanged)
def palettes(self) -> list:
"""Palettes with gradient stops for the selector."""
return [
{
"name": name,
"stops": [
QColor(c).name()
for c in Colors.evenly_spaced_colors(colormap=name, num=6, format="HEX")
],
}
for name in self.model.palettes()
]
@Property("QVariantList", constant=True)
def penStyles(self) -> list: # pylint: disable=invalid-name
"""Line styles with labels."""
return [{"value": s, "label": PEN_STYLE_LABELS[s]} for s in PEN_STYLES]
@Property("QVariantList", constant=True)
def symbols(self) -> list:
"""Symbols with labels."""
return [{"value": s, "label": SYMBOL_LABELS[s]} for s in SYMBOLS]
# ---- slots -------------------------------------------------------------------------------
@Slot(int)
def select(self, uid: int) -> None:
"""Select a curve."""
self.model.select(uid)
@Slot(int)
def move(self, step: int) -> None:
"""Select the next (+1) or previous (-1) row."""
uids = [r["uid"] for r in self._rows]
if not uids:
return
current = uids.index(self.model.selected_uid) if self.model.selected_uid in uids else 0
self.model.select(uids[max(0, min(len(uids) - 1, current + step))])
@Slot(int)
def remove(self, uid: int) -> None:
"""Remove a curve with its fits, or a fit."""
self.model.remove(uid)
@Slot(int)
def addFit(self, uid: int) -> None: # pylint: disable=invalid-name
"""Add a fit to a curve."""
self.model.add_fit(uid)
@Slot(int, str, "QVariant")
def setStyle(self, uid: int, key: str, value) -> None: # pylint: disable=invalid-name
"""Change a style field (``color``, ``pen_style``, ``pen_width``, ``symbol``,
``symbol_size``)."""
self.model.set_style(uid, key, value)
@Slot(int, int)
def setScan(self, uid: int, index: int) -> None: # pylint: disable=invalid-name
"""Choose the data source by index into ``selected.scanLabels``."""
ids = getattr(self, "_scan_ids", [None])
if 0 <= index < len(ids):
self.model.set_scan(uid, ids[index])
@Slot(int, str)
def setPrimaryModel(self, uid: int, model: str) -> None: # pylint: disable=invalid-name
"""Change the primary fit model."""
self.model.set_primary_model(uid, model)
@Slot(int, str)
def toggleExtraModel(self, uid: int, model: str) -> None: # pylint: disable=invalid-name
"""Add or remove a model of a composite fit."""
self.model.toggle_extra_model(uid, model)
@Slot(int)
def setXMode(self, index: int) -> None: # pylint: disable=invalid-name
"""Choose the X axis mode by index."""
self.model.set_x_mode(X_MODES[index])
@Slot(str)
def setPalette(self, name: str) -> None: # pylint: disable=invalid-name
"""Choose the palette (recolours all curves)."""
self.model.set_palette(name)
@Slot()
def recolorAll(self) -> None: # pylint: disable=invalid-name
"""Recolour all curves from the palette."""
self.model.recolor_all()
@Slot(int)
def pickCustomColor(self, uid: int) -> None: # pylint: disable=invalid-name
"""Open the colour dialog for a curve."""
draft = self.model.draft(uid)
if draft is None:
return
color = QColorDialog.getColor(QColor(color_hex(draft.config.color)), None, "Curve colour")
if color.isValid():
self.model.set_style(uid, "color", color.name())
@Slot(str, float, float, float, float)
def openPicker(
self, purpose: str, x: float, y: float, w: float, h: float
) -> None: # pylint: disable=invalid-name
"""Open a device or signal picker under the given rectangle of the QML scene.
Args:
purpose(str): ``add``, ``device``, ``signal``, ``xdevice`` or ``xsignal``.
"""
self.pickerRequested.emit(purpose, x, y, w, h)
class CurveSettingsQml(SettingWidget):
"""Curve settings of a :class:`Waveform`, drawn in QML.
Args:
parent(QWidget | None): Parent widget.
target_widget(Waveform): The waveform to edit.
"""
def __init__(self, parent=None, target_widget: Waveform | None = None, **kwargs):
super().__init__(parent=parent, **kwargs)
self.setProperty("skip_settings", True)
self.target_widget = target_widget
self.model = CurveDialogModel(target_widget, parent=self)
self.backend = CurveDialogBackend(self.model, self)
layout = QVBoxLayout(self)
layout.setContentsMargins(0, 0, 0, 0)
self.view = create_quick_widget(
self, QML_FILE, properties={"backend": self.backend}, raise_on_error=True
)
layout.addWidget(self.view)
# an invisible widget moved over the QML field that opened a picker; the popup is
# positioned under it
self._anchor = QWidget(self.view)
self._anchor.hide()
self._purpose = ""
self.device_picker = DevicePickerQML(parent=self, client=self.model.client)
self.device_picker.setEditable(True)
self.device_picker.hide()
self.device_picker.picker_controller.picked.connect(self._on_device_picked)
self.signal_picker = SignalPickerQML(parent=self, client=self.model.client)
self.signal_picker.include_config_signals = False
self.signal_picker.setEditable(True)
self.signal_picker.hide()
self.signal_picker.picker_controller.picked.connect(self._on_signal_picked)
self.backend.pickerRequested.connect(self._open_picker)
self._cleaned = False
def sizeHint(self) -> QSize: # pylint: disable=invalid-name
"""Default size of the dialog content."""
return QSize(940, 600)
@SafeSlot(str, float, float, float, float)
def _open_picker(self, purpose: str, x: float, y: float, w: float, h: float) -> None:
self._purpose = purpose
self._anchor.setGeometry(int(x), int(y), max(1, int(w)), max(1, int(h)))
model = self.model
draft = model.selected
if purpose in ("add", "device", "xdevice"):
current = ""
if purpose == "device" and draft is not None:
current = draft.device
elif purpose == "xdevice":
current = model.x_device
select_device(self.device_picker, current)
open_picker_at(self.device_picker, self._anchor)
else:
if purpose == "signal" and draft is not None:
select_signal(self.signal_picker, draft.device, draft.signal)
else:
select_signal(self.signal_picker, model.x_device, model.x_signal)
open_picker_at(self.signal_picker, self._anchor)
@SafeSlot(str)
def _on_device_picked(self, device: str) -> None:
if self._purpose == "add":
self.model.add_curve(device)
elif self._purpose == "device" and self.model.selected is not None:
self.model.set_device(self.model.selected_uid, device)
elif self._purpose == "xdevice":
self.model.set_x_device(device)
@SafeSlot(str)
def _on_signal_picked(self, text: str) -> None:
signal = signal_obj_name(self.signal_picker, text)
if self._purpose == "signal" and self.model.selected is not None:
self.model.set_signal(self.model.selected_uid, signal)
elif self._purpose == "xsignal":
self.model.set_x_signal(signal)
@SafeSlot(popup_error=True)
def accept_changes(self):
"""Apply the staged curves, X axis and palette to the waveform."""
self.model.apply()
@SafeSlot()
def refresh(self):
"""Reload from the waveform, e.g. after it was changed over RPC."""
self.model.load_from_waveform()
def cleanup(self):
"""Unload the QML scene and close the pickers."""
if self._cleaned:
return
self._cleaned = True
release_quick_widget(self.view)
for picker in (self.device_picker, self.signal_picker):
picker.close()
picker.deleteLater()
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,821 @@
import QtQuick
import QtQuick.Controls.Basic
import QtQuick.Layouts
import BecUi
// Curve settings of a waveform: X axis strip, curve list with nested fits, and an inspector
// for the selected curve. All state lives in the Python CurveDialogModel (via `backend`).
Rectangle {
id: root
required property var backend
readonly property var sel: backend.selected
readonly property bool hasSel: sel.uid !== undefined && sel.uid >= 0
color: theme.bg
function openPicker(purpose, item) {
const p = item.mapToItem(null, 0, 0)
root.backend.openPicker(purpose, p.x, p.y, item.width, item.height)
}
Shortcut { sequences: [StandardKey.New]; onActivated: root.openPicker("add", addButton) }
component Caption: Text {
color: theme.fgMuted
font.pixelSize: theme.fontSmall
font.weight: Font.Medium
}
// checkable tile showing a line style or a symbol
component SampleTile: AbstractButton {
id: tile
property string penStyle: "solid"
property string symbol: "none"
property color sampleColor: theme.fg
property bool selected: false
property string tip: ""
implicitWidth: symbol === "none" ? 46 : 34
implicitHeight: 30
hoverEnabled: true
focusPolicy: Qt.TabFocus
Accessible.name: tip
Accessible.role: Accessible.RadioButton
ToolTip.visible: hovered && tip !== ""
ToolTip.text: tip
ToolTip.delay: 500
background: Rectangle {
radius: theme.radiusSmall
color: tile.selected ? theme.toneTint("primary") : theme.field
border.width: tile.selected || tile.visualFocus ? 2 : 1
border.color: tile.selected || tile.visualFocus ? theme.primary : tile.hovered ? theme.fgSubtle : theme.border
}
contentItem: Item {
CurveSample {
anchors.centerIn: parent
visible: tile.symbol === "none"
width: parent.width - 16
height: 10
peak: false
color: tile.selected ? tile.sampleColor : theme.fg
penStyle: tile.penStyle
penWidth: 2.2
}
CurveSample {
anchors.centerIn: parent
visible: tile.symbol !== "none" && tile.symbol !== ""
width: 14
height: 14
peak: false
penWidth: 0.01
color: tile.selected ? tile.sampleColor : theme.fg
symbol: tile.symbol === "none" ? "" : tile.symbol
symbolSize: 11
maxSymbol: 11
}
Rectangle {
anchors.centerIn: parent
visible: tile.symbol === ""
width: 16; height: 1.5; rotation: -45
color: theme.fgMuted
}
}
}
component ValueSlider: RowLayout {
id: vs
property int from: 1
property int to: 20
property int value: 1
property string tip: ""
signal moved(int value)
spacing: 8
Slider {
id: slider
Layout.fillWidth: true
from: vs.from
to: Math.max(vs.to, vs.value)
stepSize: 1
snapMode: Slider.SnapAlways
value: vs.value
Accessible.name: vs.tip
onMoved: vs.moved(Math.round(value))
background: Rectangle {
x: slider.leftPadding
y: slider.topPadding + slider.availableHeight / 2 - height / 2
width: slider.availableWidth
height: 4
radius: 2
color: theme.track
Rectangle {
width: slider.visualPosition * parent.width
height: parent.height
radius: 2
color: slider.enabled ? theme.primary : theme.fgSubtle
}
}
handle: Rectangle {
x: slider.leftPadding + slider.visualPosition * (slider.availableWidth - width)
y: slider.topPadding + slider.availableHeight / 2 - height / 2
width: 16; height: 16; radius: 8
color: theme.card
border.width: slider.visualFocus ? 3 : 2
border.color: slider.enabled ? theme.primary : theme.fgSubtle
}
}
Text {
Layout.preferredWidth: 38
text: Math.round(slider.value) + " px"
color: theme.fgMuted
font.pixelSize: theme.fontSmall
horizontalAlignment: Text.AlignRight
}
}
ColumnLayout {
anchors.fill: parent
anchors.margins: 4
spacing: 10
// ---- X axis ---------------------------------------------------------------------
Card {
Layout.fillWidth: true
title: "X axis"
titleStyle: "caption"
RowLayout {
Layout.fillWidth: true
spacing: 10
SegmentedControl {
model: root.backend.xModes
currentIndex: root.backend.xModeIndex
Accessible.name: "X axis mode"
onActivated: (index) => root.backend.setXMode(index)
}
PickerField {
id: xDevice
visible: root.backend.xModeIndex === 3
Layout.fillWidth: true
Layout.minimumWidth: 160
text: root.backend.xDevice
placeholder: "Select device…"
invalid: root.backend.xInvalid
onRequested: root.openPicker("xdevice", xDevice)
}
PickerField {
id: xSignal
visible: root.backend.xModeIndex === 3
Layout.fillWidth: true
Layout.minimumWidth: 160
iconName: "sensors"
text: root.backend.xSignal
placeholder: "Select signal…"
onRequested: root.openPicker("xsignal", xSignal)
}
Text {
Layout.fillWidth: true
Layout.horizontalStretchFactor: 2
text: root.backend.xHelp
color: root.backend.xInvalid ? theme.dangerText : theme.fgMuted
font.pixelSize: theme.fontSmall
wrapMode: Text.WordWrap
}
}
}
RowLayout {
Layout.fillWidth: true
Layout.fillHeight: true
spacing: 10
// ---- curve list -------------------------------------------------------------
Rectangle {
Layout.preferredWidth: 330
Layout.fillHeight: true
color: theme.card
radius: theme.radiusLarge
border.color: theme.border
ColumnLayout {
anchors.fill: parent
anchors.margins: 10
spacing: 8
RowLayout {
Layout.fillWidth: true
spacing: 6
Text {
text: "Curves"
color: theme.fg
font.pixelSize: theme.fontTitle
font.weight: Font.DemiBold
}
Badge { count: root.backend.curveCount; tone: "neutral"; showZero: false }
Item { Layout.fillWidth: true }
ComboBox {
id: paletteBox
implicitWidth: 138
implicitHeight: theme.controlHeight
model: root.backend.palettes
textRole: "name"
currentIndex: {
const list = root.backend.palettes
for (let i = 0; i < list.length; ++i)
if (list[i].name === root.backend.palette) return i
return 0
}
hoverEnabled: true
ToolTip.visible: hovered && !popup.visible
ToolTip.text: "Palette for curve colours. Choosing one recolours every curve."
ToolTip.delay: 500
Accessible.name: "Colour palette"
onActivated: (index) => root.backend.setPalette(root.backend.palettes[index].name)
background: FieldFrame {
focused: paletteBox.activeFocus || paletteBox.popup.visible
hovered: paletteBox.hovered
}
contentItem: RowLayout {
spacing: 6
Item { implicitWidth: 3 }
Row {
Repeater {
model: root.backend.palettes.length > paletteBox.currentIndex ? root.backend.palettes[paletteBox.currentIndex].stops : []
Rectangle { width: 8; height: 12; color: modelData }
}
}
Text {
Layout.fillWidth: true
text: paletteBox.currentText
color: theme.fg
font.pixelSize: theme.fontBody
elide: Text.ElideRight
}
}
indicator: Icon {
x: paletteBox.width - width - 6
y: (paletteBox.height - height) / 2
name: "expand_more"; size: 18; color: theme.fgMuted
}
delegate: ItemDelegate {
id: palOption
required property int index
required property var modelData
width: paletteBox.width
height: 30
highlighted: paletteBox.highlightedIndex === index
contentItem: RowLayout {
spacing: 8
Row {
Repeater {
model: palOption.modelData.stops
Rectangle { width: 8; height: 12; color: modelData }
}
}
Text {
Layout.fillWidth: true
text: palOption.modelData.name
color: theme.fg
font.pixelSize: theme.fontBody
}
}
background: Rectangle { color: palOption.highlighted ? theme.hover : "transparent" }
}
popup: Popup {
y: paletteBox.height + 2
width: paletteBox.width
implicitHeight: contentItem.implicitHeight + 8
padding: 4
contentItem: ListView {
clip: true
implicitHeight: contentHeight
model: paletteBox.popup.visible ? paletteBox.delegateModel : null
}
background: Rectangle { color: theme.card; border.color: theme.border; radius: theme.radiusSmall + 2 }
}
}
IconButton {
iconName: "format_color_fill"
tip: "Recolour all curves from the palette"
compact: true
onClicked: root.backend.recolorAll()
}
}
TextButton {
id: addButton
objectName: "addButton"
Layout.fillWidth: true
text: "Add curve"
iconName: "add"
variant: "primary"
tip: "Pick a device to plot (Ctrl+N)"
onClicked: root.openPicker("add", addButton)
}
ListView {
id: curveList
Layout.fillWidth: true
Layout.fillHeight: true
clip: true
model: root.backend.rows
boundsBehavior: Flickable.StopAtBounds
focus: true
activeFocusOnTab: true
ScrollBar.vertical: ScrollBar { policy: ScrollBar.AsNeeded; width: 8 }
Keys.onPressed: (event) => {
if (event.key === Qt.Key_Down) { root.backend.move(1); event.accepted = true }
else if (event.key === Qt.Key_Up) { root.backend.move(-1); event.accepted = true }
else if ((event.key === Qt.Key_Delete || event.key === Qt.Key_Backspace) && root.hasSel) {
root.backend.remove(root.sel.uid); event.accepted = true
}
}
delegate: Item {
id: rowItem
required property var modelData
required property int index
readonly property var r: modelData
width: ListView.view.width
height: r.depth ? 48 : 52
Accessible.role: Accessible.ListItem
Accessible.name: r.title + ", " + r.subtitle
Rectangle {
anchors.fill: parent
anchors.margins: 2
anchors.leftMargin: 4
anchors.rightMargin: 4
radius: 8
color: rowItem.r.selected ? theme.toneTint("primary") : rowMouse.containsMouse ? theme.hover : "transparent"
border.width: rowItem.r.selected ? 1.5 : 0
border.color: theme.primary
}
// tree connector for fits
Rectangle {
visible: rowItem.r.depth > 0
x: 20; y: -4; width: 1.5; height: parent.height / 2 + 4
color: theme.separator
}
Rectangle {
visible: rowItem.r.depth > 0
x: 20; y: parent.height / 2; width: 10; height: 1.5
color: theme.separator
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 14 + (rowItem.r.depth ? 22 : 0)
anchors.rightMargin: 14
spacing: 12
CurveSample {
implicitWidth: 40; implicitHeight: 20
color: rowItem.r.color
penStyle: rowItem.r.penStyle
penWidth: rowItem.r.penWidth
symbol: rowItem.r.symbol
symbolSize: rowItem.r.symbolSize
maxWidth: 3; maxSymbol: 7
peak: rowItem.r.kind !== "custom"
}
ColumnLayout {
Layout.fillWidth: true
spacing: 1
Text {
Layout.fillWidth: true
text: rowItem.r.title
color: theme.fg
font.pixelSize: theme.fontBody
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: rowItem.r.error !== "" ? rowItem.r.error : rowItem.r.subtitle
color: rowItem.r.error !== "" ? theme.dangerText : theme.fgMuted
font.pixelSize: theme.fontSmall
elide: Text.ElideRight
}
}
Rectangle {
readonly property string label: rowItem.r.tag === "history" ? rowItem.r.subtitle.split("· ").pop() : rowItem.r.tag
visible: label !== "" && label !== "fit"
implicitWidth: tagText.implicitWidth + 14
implicitHeight: 20
radius: 10
color: (theme.name, theme.toneTint(rowItem.r.tag === "custom" ? "highlight" : "info"))
Text {
id: tagText
anchors.centerIn: parent
text: parent.label
color: (theme.name, theme.toneText(rowItem.r.tag === "custom" ? "highlight" : "info"))
font.pixelSize: theme.fontCaption
font.weight: Font.DemiBold
}
}
Icon {
visible: rowItem.r.error !== ""
name: "error"; filled: true; size: 18
color: theme.dangerText
}
}
MouseArea {
id: rowMouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: { curveList.forceActiveFocus(); root.backend.select(rowItem.r.uid) }
ToolTip.visible: containsMouse && rowItem.r.error !== ""
ToolTip.text: rowItem.r.error
ToolTip.delay: 400
}
}
}
Banner {
Layout.fillWidth: true
visible: root.backend.problems.length > 0
tone: "warning"
title: root.backend.problems.length === 1 ? "1 problem to fix before applying"
: root.backend.problems.length + " problems to fix before applying"
text: root.backend.problems.slice(0, 3).join("\n")
}
}
}
// ---- inspector --------------------------------------------------------------
Item {
Layout.fillWidth: true
Layout.fillHeight: true
EmptyState {
anchors.centerIn: parent
width: Math.min(parent.width - 40, 380)
visible: !root.hasSel
iconName: "show_chart"
title: "No curves yet"
text: "Add a device to plot it against the X axis. Fits and colours are set per curve."
actionText: "Add curve"
actionIcon: "add"
onActionTriggered: root.openPicker("add", addButton)
}
ScrollView {
id: inspector
anchors.fill: parent
visible: root.hasSel
clip: true
contentWidth: availableWidth
ScrollBar.horizontal.policy: ScrollBar.AlwaysOff
ColumnLayout {
width: inspector.availableWidth - 6
spacing: 10
// header
RowLayout {
Layout.fillWidth: true
Layout.leftMargin: 2
Layout.rightMargin: 2
spacing: 12
Rectangle {
implicitWidth: 84; implicitHeight: 44
radius: 8
color: theme.field
border.color: theme.border
CurveSample {
anchors.fill: parent
anchors.margins: 7
anchors.leftMargin: 8; anchors.rightMargin: 8
color: root.sel.color || "#888888"
penStyle: root.sel.penStyle || "solid"
penWidth: root.sel.penWidth || 1
symbol: root.sel.symbol || ""
symbolSize: root.sel.symbolSize || 1
maxWidth: 6; maxSymbol: 12
peak: root.sel.kind !== "custom"
}
}
ColumnLayout {
Layout.fillWidth: true
spacing: 2
Text {
Layout.fillWidth: true
text: root.sel.title || ""
color: theme.fg
font.pixelSize: theme.fontTitle
font.weight: Font.DemiBold
elide: Text.ElideRight
}
Text {
Layout.fillWidth: true
text: root.sel.subtitle || ""
color: theme.fgMuted
font.pixelSize: theme.fontSmall
elide: Text.ElideRight
}
}
TextButton {
visible: root.sel.kind !== "dap"
text: "Add fit"
iconName: "add"
compact: true
tip: "Fit a model to this curve"
onClicked: root.backend.addFit(root.sel.uid)
}
IconButton {
visible: root.sel.kind !== "custom"
iconName: "delete"
danger: true
tip: root.sel.kind === "dap" ? "Remove fit" : "Remove curve and its fits"
onClicked: root.backend.remove(root.sel.uid)
}
}
// data source of a device curve
Card {
Layout.fillWidth: true
visible: root.sel.kind === "device"
title: "Data"
titleStyle: "caption"
GridLayout {
Layout.fillWidth: true
columns: 2
columnSpacing: 12
rowSpacing: 10
FormField {
Layout.fillWidth: true
Layout.preferredWidth: 1
Layout.alignment: Qt.AlignTop
label: "Device"
required: true
error: root.sel.deviceError || ""
PickerField {
id: deviceField
text: root.sel.device || ""
placeholder: "Select device…"
invalid: (root.sel.deviceError || "") !== ""
onRequested: root.openPicker("device", deviceField)
}
}
FormField {
Layout.fillWidth: true
Layout.preferredWidth: 1
Layout.alignment: Qt.AlignTop
label: "Signal"
required: true
error: root.sel.signalError || ""
PickerField {
id: signalField
iconName: "sensors"
text: root.sel.signal || ""
placeholder: "Select signal…"
invalid: (root.sel.signalError || "") !== ""
onRequested: root.openPicker("signal", signalField)
}
}
FormField {
Layout.columnSpan: 2
Layout.fillWidth: true
label: "Data from"
helper: "Live follows the running scan; an earlier scan shows its stored data."
error: root.sel.scanError || ""
SelectField {
model: root.sel.scanLabels || []
currentIndex: root.sel.scanIndex || 0
invalid: (root.sel.scanError || "") !== ""
Accessible.name: "Data from"
onActivated: (index) => root.backend.setScan(root.sel.uid, index)
}
}
}
}
// command-line curve
Banner {
Layout.fillWidth: true
visible: root.sel.kind === "custom"
tone: "info"
title: "Curve sent from the command line"
text: "Its data came from plot(x=..., y=...), so only its look can be changed here. Remove it from the command line."
}
// fits of a curve
Card {
Layout.fillWidth: true
visible: root.sel.kind === "device" || root.sel.kind === "custom"
title: "Fits"
titleStyle: "caption"
Text {
Layout.fillWidth: true
visible: (root.sel.fits || []).length === 0
text: "No fits. Add one to fit a model such as a Gaussian to this curve."
color: theme.fgMuted
font.pixelSize: theme.fontSmall
wrapMode: Text.WordWrap
}
Repeater {
model: root.sel.fits || []
delegate: AbstractButton {
id: fitLink
required property var modelData
implicitHeight: theme.controlHeightCompact
implicitWidth: fitRow.implicitWidth + 18
hoverEnabled: true
Accessible.name: "Edit " + modelData.title
onClicked: root.backend.select(modelData.uid)
background: Rectangle { radius: theme.radiusSmall; color: fitLink.hovered ? theme.hover : "transparent" }
contentItem: RowLayout {
id: fitRow
spacing: 8
CurveSample {
implicitWidth: 32; implicitHeight: 14
color: fitLink.modelData.color
penStyle: fitLink.modelData.penStyle
penWidth: 2
}
Text {
text: fitLink.modelData.title
color: theme.fg
font.pixelSize: theme.fontSmall
font.weight: Font.DemiBold
}
}
}
}
}
// fit model
Card {
Layout.fillWidth: true
visible: root.sel.kind === "dap"
title: "Fit"
titleStyle: "caption"
FormField {
Layout.fillWidth: true
label: "Model"
required: true
error: root.sel.modelError || ""
SelectField {
model: root.sel.models || []
currentIndex: root.sel.modelIndex || 0
Accessible.name: "Fit model"
onActivated: (index) => root.backend.setPrimaryModel(root.sel.uid, root.sel.models[index])
}
}
Caption { text: "Combine with" }
Flow {
Layout.fillWidth: true
spacing: 6
Repeater {
model: root.sel.extras || []
delegate: AbstractButton {
id: chip
required property var modelData
implicitHeight: 26
implicitWidth: chipText.implicitWidth + 26
hoverEnabled: true
focusPolicy: Qt.TabFocus
Accessible.role: Accessible.CheckBox
Accessible.name: "Add " + modelData.name + " to a composite fit"
onClicked: root.backend.toggleExtraModel(root.sel.uid, modelData.name)
background: Rectangle {
radius: 13
color: chip.modelData.checked ? theme.toneTint("primary") : theme.field
border.width: chip.visualFocus ? 2 : 1
border.color: chip.modelData.checked || chip.visualFocus ? theme.primary : chip.hovered ? theme.fgSubtle : theme.border
}
contentItem: Text {
id: chipText
text: chip.modelData.label
color: theme.fg
font.pixelSize: theme.fontSmall
font.weight: chip.modelData.checked ? Font.DemiBold : Font.Normal
horizontalAlignment: Text.AlignHCenter
verticalAlignment: Text.AlignVCenter
}
}
}
}
Text {
Layout.fillWidth: true
text: root.sel.composite || ""
color: theme.fgMuted
font.pixelSize: theme.fontSmall
wrapMode: Text.WordWrap
}
TextButton {
visible: (root.sel.parentUid || -1) >= 0
text: "Fits " + (root.sel.parentTitle || "")
iconName: "arrow_back"
variant: "ghost"
compact: true
tip: "Select the fitted curve"
onClicked: root.backend.select(root.sel.parentUid)
}
}
// appearance
Card {
Layout.fillWidth: true
title: "Appearance"
titleStyle: "caption"
GridLayout {
Layout.fillWidth: true
columns: 2
columnSpacing: 14
rowSpacing: 12
Caption { text: "Colour" }
RowLayout {
Layout.fillWidth: true
spacing: 2
Repeater {
model: root.sel.swatches || []
delegate: AbstractButton {
id: swatch
required property var modelData
readonly property bool current: (root.sel.color || "").toLowerCase() === modelData.toLowerCase()
implicitWidth: 26; implicitHeight: 26
focusPolicy: Qt.TabFocus
hoverEnabled: true
Accessible.name: "Colour " + modelData
ToolTip.visible: hovered
ToolTip.text: modelData
ToolTip.delay: 500
onClicked: root.backend.setStyle(root.sel.uid, "color", modelData)
background: Rectangle {
radius: 13
color: "transparent"
border.width: swatch.current || swatch.visualFocus ? 2 : 0
border.color: swatch.current ? theme.fg : theme.primary
Rectangle {
anchors.centerIn: parent
width: 16; height: 16; radius: 8
color: swatch.modelData
border.color: Qt.rgba(0, 0, 0, 0.16)
}
}
}
}
Item { implicitWidth: 6 }
TextButton {
text: "Custom…"
iconName: "colorize"
variant: "ghost"
compact: true
tip: "Pick any colour"
onClicked: root.backend.pickCustomColor(root.sel.uid)
}
Item { Layout.fillWidth: true }
}
Caption { text: "Line" }
RowLayout {
Layout.fillWidth: true
spacing: 4
Repeater {
model: root.backend.penStyles
delegate: SampleTile {
required property var modelData
penStyle: modelData.value
tip: modelData.label + " line"
sampleColor: root.sel.color || theme.fg
selected: root.sel.penStyle === modelData.value
onClicked: root.backend.setStyle(root.sel.uid, "pen_style", modelData.value)
}
}
Item { implicitWidth: 10 }
ValueSlider {
Layout.fillWidth: true
from: 1; to: 10
value: root.sel.penWidth || 1
tip: "Line width"
onMoved: (v) => root.backend.setStyle(root.sel.uid, "pen_width", v)
}
}
Caption { text: "Symbol" }
RowLayout {
Layout.fillWidth: true
spacing: 4
Repeater {
model: root.backend.symbols
delegate: SampleTile {
required property var modelData
symbol: modelData.value
tip: modelData.label
sampleColor: root.sel.color || theme.fg
selected: (root.sel.symbol || "") === modelData.value
onClicked: root.backend.setStyle(root.sel.uid, "symbol", modelData.value)
}
}
Item { implicitWidth: 10 }
ValueSlider {
Layout.fillWidth: true
enabled: (root.sel.symbol || "") !== ""
from: 1; to: 20
value: root.sel.symbolSize || 1
tip: "Symbol size"
onMoved: (v) => root.backend.setStyle(root.sel.uid, "symbol_size", v)
}
}
}
}
Item { Layout.fillHeight: true }
}
}
}
}
}
}
@@ -0,0 +1,100 @@
import QtQuick
// Small drawing of a curve in its colour, line style, width and symbol: a peak (or a flat
// line for custom data) with the symbol on three points. Mirrors paint_curve_sample().
Canvas {
id: root
property color color: "#888888"
property string penStyle: "solid"
property real penWidth: 2
property string symbol: ""
property real symbolSize: 6
property real maxWidth: 4
property real maxSymbol: 9
property bool peak: true
implicitWidth: 40
implicitHeight: 20
antialiasing: true
onColorChanged: requestPaint()
onPenStyleChanged: requestPaint()
onPenWidthChanged: requestPaint()
onSymbolChanged: requestPaint()
onSymbolSizeChanged: requestPaint()
onPeakChanged: requestPaint()
onWidthChanged: requestPaint()
onHeightChanged: requestPaint()
function dash(style, w) {
switch (style) {
case "dash": return [4, 2]
case "dot": return [1, 2]
case "dashdot": return [4, 2, 1, 2]
default: return []
}
}
function drawSymbol(ctx, x, y, s, size) {
const h = size / 2
ctx.beginPath()
switch (s) {
case "o": ctx.arc(x, y, h, 0, 2 * Math.PI); break
case "s": ctx.rect(x - h, y - h, size, size); break
case "t": ctx.moveTo(x - h, y - h); ctx.lineTo(x + h, y - h); ctx.lineTo(x, y + h); ctx.closePath(); break
case "d": ctx.moveTo(x, y - h); ctx.lineTo(x + h, y); ctx.lineTo(x, y + h); ctx.lineTo(x - h, y); ctx.closePath(); break
case "star":
for (let i = 0; i < 10; ++i) {
const r = i % 2 === 0 ? h : h * 0.45
const a = -Math.PI / 2 + i * Math.PI / 5
if (i === 0) ctx.moveTo(x + r * Math.cos(a), y + r * Math.sin(a))
else ctx.lineTo(x + r * Math.cos(a), y + r * Math.sin(a))
}
ctx.closePath()
break
case "+": ctx.moveTo(x - h, y); ctx.lineTo(x + h, y); ctx.moveTo(x, y - h); ctx.lineTo(x, y + h); break
case "x": ctx.moveTo(x - h, y - h); ctx.lineTo(x + h, y + h); ctx.moveTo(x - h, y + h); ctx.lineTo(x + h, y - h); break
}
if (s === "+" || s === "x") {
ctx.setLineDash([])
ctx.lineWidth = Math.max(1.5, size / 4)
ctx.strokeStyle = root.color
ctx.stroke()
} else {
ctx.fillStyle = root.color
ctx.fill()
ctx.setLineDash([])
ctx.lineWidth = 1
ctx.strokeStyle = Qt.darker(root.color, 1.3)
ctx.stroke()
}
}
onPaint: {
const ctx = getContext("2d")
ctx.reset()
const size = Math.max(3, Math.min(root.symbolSize, root.maxSymbol))
const margin = root.symbol !== "" ? size / 2 + 1 : 2
const left = margin, right = width - margin, top = margin, bottom = height - margin
const steps = 40
const pts = []
for (let i = 0; i <= steps; ++i) {
const t = i / steps
const h = root.peak ? Math.exp(-((t - 0.5) * (t - 0.5)) / 0.03) : 0.5
pts.push([left + t * (right - left), bottom - h * (bottom - top)])
}
const lw = Math.min(Math.max(1, root.penWidth), root.maxWidth)
ctx.lineWidth = lw
ctx.lineCap = "round"
ctx.lineJoin = "round"
ctx.strokeStyle = root.color
ctx.setLineDash(dash(root.penStyle, lw))
ctx.beginPath()
ctx.moveTo(pts[0][0], pts[0][1])
for (let j = 1; j < pts.length; ++j) ctx.lineTo(pts[j][0], pts[j][1])
ctx.stroke()
if (root.symbol !== "") {
for (const k of [Math.floor(steps / 5), Math.floor(steps / 2), steps - Math.floor(steps / 5)])
drawSymbol(ctx, pts[k][0], pts[k][1], root.symbol, size)
}
}
}
@@ -0,0 +1,55 @@
import QtQuick
import QtQuick.Layouts
import BecUi
// A field that shows a device or signal and opens the searchable picker popup when clicked
// (or on Enter / Space). The popup itself is the shared QML picker, opened by Python.
FocusScope {
id: root
property string text: ""
property string placeholder: ""
property string iconName: "memory"
property bool invalid: false
signal requested()
implicitHeight: theme.controlHeight
implicitWidth: 160
activeFocusOnTab: true
Accessible.role: Accessible.ComboBox
Accessible.name: root.text !== "" ? root.text : root.placeholder
Keys.onPressed: (event) => {
if (event.key === Qt.Key_Return || event.key === Qt.Key_Enter || event.key === Qt.Key_Space || event.key === Qt.Key_Down) {
root.requested()
event.accepted = true
}
}
FieldFrame {
anchors.fill: parent
focused: root.activeFocus
invalid: root.invalid
hovered: mouse.containsMouse
}
RowLayout {
anchors.fill: parent
anchors.leftMargin: 9
anchors.rightMargin: 6
spacing: 6
Icon { name: root.iconName; size: 15; color: theme.fgMuted }
Text {
Layout.fillWidth: true
text: root.text !== "" ? root.text : root.placeholder
color: root.text !== "" ? theme.fg : theme.fgSubtle
font.pixelSize: theme.fontBody
elide: Text.ElideRight
}
Icon { name: "expand_more"; size: 18; color: theme.fgMuted }
}
MouseArea {
id: mouse
anchors.fill: parent
hoverEnabled: true
cursorShape: Qt.PointingHandCursor
onClicked: { root.forceActiveFocus(); root.requested() }
}
}
@@ -43,7 +43,9 @@ from bec_widgets.utils.toolbars.toolbar import MaterialIconAction
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
from bec_widgets.widgets.plots.plot_base import PlotBase
from bec_widgets.widgets.plots.waveform.curve import Curve, CurveConfig, DeviceSignal
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_setting import CurveSetting
from bec_widgets.widgets.plots.waveform.settings.curve_dialog.curve_dialog_common import (
create_curve_setting,
)
from bec_widgets.widgets.plots.waveform.utils.alignment_controller import (
AlignmentContext,
WaveformAlignmentController,
@@ -509,7 +511,8 @@ class Waveform(PlotBase):
curve_action = self.toolbar.components.get_action("curve").action
if self.curve_settings_dialog is None or not self.curve_settings_dialog.isVisible():
curve_setting = CurveSetting(parent=self, target_widget=self)
# BEC_CURVE_DIALOG=qwidget|qml opens the redesigned dialog; the default is unchanged
curve_setting = create_curve_setting(self)
self.curve_settings_dialog = SettingsDialog(
self, settings_widget=curve_setting, window_title="Curve Settings", modal=False
)
@@ -0,0 +1,295 @@
"""Tests for the redesigned curve settings dialog (shared model, QWidget and QML views)."""
# pylint: disable=redefined-outer-name,protected-access
import json
import numpy as np
import pytest
from bec_widgets.tests.client_mocks import dap_plugin_message, mocked_client, mocked_client_with_dap
from bec_widgets.utils.settings_dialog import SettingsDialog
from bec_widgets.widgets.plots.waveform.settings.curve_dialog.curve_dialog_common import (
create_curve_setting,
curve_dialog_variant,
)
from bec_widgets.widgets.plots.waveform.settings.curve_dialog.curve_dialog_model import (
CurveDialogModel,
normalize_dap,
)
from bec_widgets.widgets.plots.waveform.settings.curve_dialog.curve_dialog_qml import (
CurveSettingsQml,
)
from bec_widgets.widgets.plots.waveform.settings.curve_dialog.curve_dialog_qwidget import (
CurveSettingsQWidget,
)
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_setting import CurveSetting
from bec_widgets.widgets.plots.waveform.settings.curve_settings.curve_tree import CurveTree
from bec_widgets.widgets.plots.waveform.waveform import Waveform
from tests.unit_tests.conftest import create_widget
@pytest.fixture
def waveform(qtbot, mocked_client_with_dap):
"""Waveform with two device curves, a fit and a custom curve."""
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
wf.plot(arg1="bpm4i", dap="GaussianModel")
wf.plot(arg1="bpm3a")
wf.plot(x=np.arange(5), y=np.arange(5), label="reference")
return wf
def _labels(wf):
return sorted(c.name() for c in wf.curves)
def test_normalize_dap():
assert normalize_dap(None) == ["GaussianModel"]
assert normalize_dap("LorentzModel") == ["LorentzModel"]
assert normalize_dap(["A", "B", "A", ""]) == ["A", "B"]
def test_model_loads_curves_fits_and_custom(waveform):
model = CurveDialogModel(waveform)
kinds = [(d.kind, d.parent_uid is not None) for d in model.drafts]
assert kinds == [("device", False), ("dap", True), ("device", False), ("custom", False)]
assert model.selected_uid == model.drafts[0].uid
rows = model.rows()
assert rows[1]["subtitle"] == "fit of bpm4i"
assert rows[3]["tag"] == "custom"
assert model.validate() == {}
def test_model_export_matches_legacy_tree(qtbot, waveform):
"""Without edits the new model exports the same curves as the legacy tree."""
model = CurveDialogModel(waveform)
tree = create_widget(qtbot, CurveTree, waveform=waveform)
legacy = {c["label"]: c for c in tree.export_all_curves()}
new = {c["label"]: c for c in model.export_curves()}
assert set(new) == set(legacy)
for label, config in new.items():
for key in ("source", "color", "pen_style", "pen_width", "symbol_size", "parent_label"):
assert config[key] == legacy[label][key], (label, key)
assert config["signal"]["device"] == legacy[label]["signal"]["device"]
assert config["signal"]["dap"] == legacy[label]["signal"]["dap"]
def test_add_curve_picks_first_hint_and_free_color(waveform):
model = CurveDialogModel(waveform)
used = {d.config.color for d in model.drafts}
uid = model.add_curve("samx")
draft = model.draft(uid)
assert model.selected_uid == uid
assert draft.signal == "samx"
assert draft.config.color not in used
model.apply()
assert "samx-samx" in _labels(waveform)
assert "reference" in _labels(waveform)
def test_add_fit_and_composite(waveform):
model = CurveDialogModel(waveform)
parent = next(d for d in model.drafts if d.device == "bpm3a")
fit = model.add_fit(parent.uid)
model.toggle_extra_model(fit, "LorentzModel")
assert model.draft(fit).models == ["GaussianModel", "LorentzModel"]
model.set_primary_model(fit, "LorentzModel")
assert model.draft(fit).models == ["LorentzModel"]
model.toggle_extra_model(fit, "SineModel")
model.apply()
assert "bpm3a-bpm3a-LorentzModel+SineModel" in _labels(waveform)
def test_fit_parameters_kept_only_while_model_unchanged(waveform):
curve = next(c for c in waveform.curves if c.config.source == "dap")
curve.config.signal.dap_parameters = {"amplitude": {"value": 2.0}}
model = CurveDialogModel(waveform)
fit = next(d for d in model.drafts if d.kind == "dap")
assert model.export_curves()[1]["signal"]["dap_parameters"] == {"amplitude": {"value": 2.0}}
model.set_primary_model(fit.uid, "LorentzModel")
exported = next(c for c in model.export_curves() if c["source"] == "dap")
assert exported["signal"]["dap_parameters"] is None
def test_remove_curve_removes_its_fits_but_never_custom(waveform):
model = CurveDialogModel(waveform)
top = model.drafts[0]
model.remove(top.uid)
assert all(d.parent_uid != top.uid for d in model.drafts)
custom = next(d for d in model.drafts if d.kind == "custom")
model.remove(custom.uid)
assert model.draft(custom.uid) is not None
model.apply()
assert _labels(waveform) == ["bpm3a-bpm3a", "reference"]
def test_validation_blocks_apply(waveform):
model = CurveDialogModel(waveform)
uid = model.add_curve("")
assert model.validate()[uid] == "Choose a device."
with pytest.raises(ValueError):
model.apply()
model.set_device(uid, "bpm4i")
assert model.validate()[uid] == "The same curve is already listed."
model.set_device(uid, "samy")
assert uid not in model.validate()
model.set_x_mode("device")
assert model.x_axis_error()
model.set_x_device("samx")
assert model.x_signal == "samx"
model.apply()
assert waveform.x_axis_mode["name"] == "samx"
def test_style_edits_reach_device_and_custom_curves(waveform):
model = CurveDialogModel(waveform)
top = model.drafts[0]
custom = next(d for d in model.drafts if d.kind == "custom")
model.set_style(top.uid, "pen_style", "dash")
model.set_style(top.uid, "pen_width", 50)
model.set_style(top.uid, "symbol", "")
model.set_style(custom.uid, "color", "#123456")
model.apply()
curves = {c.name(): c for c in waveform.curves}
assert curves["bpm4i-bpm4i"].config.pen_style == "dash"
assert curves["bpm4i-bpm4i"].config.pen_width == 20
assert curves["bpm4i-bpm4i"].config.symbol is None
assert curves["reference"].config.color == "#123456"
def test_palette_recolors_all(waveform):
model = CurveDialogModel(waveform)
model.set_palette("viridis")
colors = [d.config.color for d in model.drafts]
assert len(set(colors)) == len(colors)
model.apply()
assert waveform.color_palette == "viridis"
def test_history_scan_selection(waveform, scan_history_factory):
from bec_widgets.tests.client_mocks import inject_scan_history
inject_scan_history(waveform, scan_history_factory, ("scan-a", 7))
# inject_scan_history fills the scan ids only
waveform.client.history._scan_numbers.append(7)
model = CurveDialogModel(waveform)
assert model.scans()[1] == ("Scan #7", "scan-a")
top = model.drafts[0]
model.set_scan(top.uid, "scan-a")
assert model.row(top)["subtitle"] == "bpm4i · scan #7"
exported = model.export_curves()[0]
assert exported["source"] == "history"
assert exported["label"] == "bpm4i-bpm4i-scan-7"
# the fit follows the renamed parent
assert model.export_curves()[1]["parent_label"] == "bpm4i-bpm4i-scan-7"
def test_variant_from_environment(monkeypatch, waveform, qtbot):
monkeypatch.delenv("BEC_CURVE_DIALOG", raising=False)
assert curve_dialog_variant() == "legacy"
monkeypatch.setenv("BEC_CURVE_DIALOG", "QML")
assert curve_dialog_variant() == "qml"
monkeypatch.setenv("BEC_CURVE_DIALOG", "nonsense")
assert curve_dialog_variant() == "legacy"
widget = create_curve_setting(waveform, "legacy")
qtbot.addWidget(widget)
assert isinstance(widget, CurveSetting)
@pytest.mark.parametrize("cls", [CurveSettingsQWidget, CurveSettingsQml])
def test_views_apply_through_settings_dialog(qtbot, waveform, cls):
widget = cls(parent=waveform, target_widget=waveform)
dialog = SettingsDialog(waveform, settings_widget=widget, window_title="Curves")
qtbot.addWidget(dialog)
dialog.show()
qtbot.waitExposed(dialog)
widget.model.add_curve("samx")
qtbot.wait(20)
dialog.apply_changes()
assert "samx-samx" in _labels(waveform)
dialog.reject()
def test_qwidget_view_follows_model(qtbot, waveform):
widget = CurveSettingsQWidget(parent=None, target_widget=waveform)
qtbot.addWidget(widget)
widget.show()
model = widget.model
assert len(widget.curve_list.rows) == 4
assert widget.count_badge._count == 3
fit = model.drafts[1]
model.select(fit.uid)
assert widget._inspector_uid == fit.uid
assert not widget.device_picker.isVisible()
model.select(model.drafts[0].uid)
assert widget.device_picker.currentText() == "bpm4i"
# picking a device in the inspector changes the curve and resets its signal
widget.device_picker.picker_controller.picked.emit("samy")
assert model.drafts[0].device == "samy"
assert model.drafts[0].signal == "samy"
# the add picker adds a curve
widget.add_picker.picker_controller.picked.emit("samz")
assert model.selected.device == "samz"
assert len(widget.curve_list.rows) == 5
# an incomplete curve shows the problem banner
model.add_curve("")
assert not widget.problem_banner.isHidden()
widget.curve_list.delete_requested.emit(model.selected_uid)
assert widget.problem_banner.isHidden()
widget.cleanup()
def test_qml_view_backend(qtbot, waveform):
widget = CurveSettingsQml(parent=None, target_widget=waveform)
qtbot.addWidget(widget)
widget.show()
backend = widget.backend
assert backend.curveCount == 3
assert len(backend.rows) == 4
assert backend.selected["device"] == "bpm4i"
backend.move(1)
assert backend.selected["kind"] == "dap"
assert [e["name"] for e in backend.selected["extras"]] == ["LorentzModel", "SineModel"]
backend.toggleExtraModel(backend.selected["uid"], "SineModel")
assert backend.selected["extras"][1]["checked"]
backend.setStyle(backend.selected["uid"], "pen_style", "dot")
assert backend.selected["penStyle"] == "dot"
# a picker request anchors the QML popup and the pick adds a curve
backend.openPicker("add", 10, 10, 200, 30)
qtbot.wait(50)
assert widget.device_picker.is_popup_open()
widget.device_picker.picker_controller.picked.emit("samx")
assert backend.selected["device"] == "samx"
backend.setXMode(3)
assert backend.xInvalid
backend.openPicker("xdevice", 10, 10, 200, 30)
widget.device_picker.picker_controller.picked.emit("samy")
assert backend.xDevice == "samy" and not backend.xInvalid
widget.cleanup()
def test_waveform_opens_chosen_variant(qtbot, monkeypatch, waveform):
monkeypatch.setenv("BEC_CURVE_DIALOG", "qwidget")
waveform.show_curve_settings_popup()
assert isinstance(waveform.curve_settings_dialog.widget, CurveSettingsQWidget)
waveform.curve_settings_dialog.reject()
def test_adding_a_plotted_device_selects_its_curve(waveform):
model = CurveDialogModel(waveform)
count = len(model.drafts)
model.select(model.drafts[-1].uid)
uid = model.add_curve("bpm4i")
assert len(model.drafts) == count
assert uid == model.drafts[0].uid == model.selected_uid
def test_fit_of_custom_curve_addresses_parent_by_label(waveform):
model = CurveDialogModel(waveform)
custom = next(d for d in model.drafts if d.kind == "custom")
model.add_fit(custom.uid, "LorentzModel")
model.apply()
fit = next(c for c in waveform.curves if c.name() == "reference-LorentzModel")
assert fit.config.signal.device == "reference"
assert fit.config.signal.signal == "custom"
assert fit.config.parent_label == "reference"