diff --git a/bec_widgets/widgets/plots/waveform/settings/curve_dialog/__init__.py b/bec_widgets/widgets/plots/waveform/settings/curve_dialog/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/plots/waveform/settings/curve_dialog/curve_dialog_common.py b/bec_widgets/widgets/plots/waveform/settings/curve_dialog/curve_dialog_common.py new file mode 100644 index 00000000..fbf446f5 --- /dev/null +++ b/bec_widgets/widgets/plots/waveform/settings/curve_dialog/curve_dialog_common.py @@ -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) diff --git a/bec_widgets/widgets/plots/waveform/settings/curve_dialog/curve_dialog_model.py b/bec_widgets/widgets/plots/waveform/settings/curve_dialog/curve_dialog_model.py new file mode 100644 index 00000000..a269c552 --- /dev/null +++ b/bec_widgets/widgets/plots/waveform/settings/curve_dialog/curve_dialog_model.py @@ -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() diff --git a/bec_widgets/widgets/plots/waveform/settings/curve_dialog/curve_dialog_qml.py b/bec_widgets/widgets/plots/waveform/settings/curve_dialog/curve_dialog_qml.py new file mode 100644 index 00000000..7b2c83cb --- /dev/null +++ b/bec_widgets/widgets/plots/waveform/settings/curve_dialog/curve_dialog_qml.py @@ -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() diff --git a/bec_widgets/widgets/plots/waveform/settings/curve_dialog/curve_dialog_qwidget.py b/bec_widgets/widgets/plots/waveform/settings/curve_dialog/curve_dialog_qwidget.py new file mode 100644 index 00000000..a26ba6f1 --- /dev/null +++ b/bec_widgets/widgets/plots/waveform/settings/curve_dialog/curve_dialog_qwidget.py @@ -0,0 +1,1154 @@ +"""QWidget version of the redesigned curve settings dialog. + +Layout: an X axis strip on top, the list of curves (with their fits nested below them) on the +left and an inspector for the selected curve on the right. Adding a curve is one step: "Add +curve" opens the device picker and the picked device is plotted with its first hinted signal. +All state lives in :class:`CurveDialogModel`; the QML version shows the same model. +""" + +from __future__ import annotations + +from typing import TYPE_CHECKING, Callable + +from bec_qthemes import material_icon +from qtpy.QtCore import QPointF, QRectF, QSize, Qt, Signal +from qtpy.QtGui import QColor, QFont, QIcon, QKeySequence, QLinearGradient, QPainter, QPen, QPixmap +from qtpy.QtWidgets import ( + QAbstractButton, + QButtonGroup, + QColorDialog, + QComboBox, + QFrame, + QGridLayout, + QHBoxLayout, + QLabel, + QPushButton, + QScrollArea, + QShortcut, + QSizePolicy, + QSlider, + QStackedWidget, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils.colors import Colors +from bec_widgets.utils.error_popups import SafeSlot +from bec_widgets.utils.quick.tokens import ThemeTokens +from bec_widgets.utils.settings_dialog import SettingWidget +from bec_widgets.utils.ux_kit import ( + Badge, + Banner, + Card, + EmptyState, + FormField, + IconButton, + SegmentedControl, + TextButton, + _ThemeFollower, + field_qss, + rgba, +) +from bec_widgets.widgets.control.device_input.picker.picker_qwidget import ( + DevicePicker, + SignalPicker, +) +from bec_widgets.widgets.plots.waveform.settings.curve_dialog.curve_dialog_common import ( + open_picker_at, + paint_curve_sample, + paint_symbol, + 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, + SWATCH_COUNT, + SYMBOL_LABELS, + SYMBOLS, + X_MODE_HELP, + X_MODE_LABELS, + X_MODES, + CurveDialogModel, + color_hex, +) + +if TYPE_CHECKING: # pragma: no cover + from bec_widgets.widgets.plots.waveform.waveform import Waveform + + +def _font(widget: QWidget, px: int, weight: QFont.Weight | None = None) -> QFont: + font = QFont(widget.font()) + font.setPixelSize(px) + if weight is not None: + font.setWeight(weight) + return font + + +def _short_model(model: str) -> str: + return model[:-5] if model.endswith("Model") and len(model) > 5 else model + + +def palette_icon(name: str, width: int = 64, height: int = 14) -> QIcon: + """Small gradient preview of a colormap.""" + pixmap = QPixmap(width, height) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + gradient = QLinearGradient(0, 0, width, 0) + colors = Colors.evenly_spaced_colors(colormap=name, num=8, format="HEX") + for index, color in enumerate(colors): + gradient.setColorAt(index / (len(colors) - 1), QColor(color)) + painter.setBrush(gradient) + painter.setPen(Qt.PenStyle.NoPen) + painter.drawRoundedRect(QRectF(0, 0, width, height), 3, 3) + painter.end() + return QIcon(pixmap) + + +class CurveRow(_ThemeFollower, QWidget): + """One curve in the list: sample, name, source and status.""" + + clicked = Signal(int) + + def __init__(self, row: dict, parent: QWidget | None = None): + super().__init__(parent) + self.row = row + self._hover = False + self.setAttribute(Qt.WidgetAttribute.WA_Hover, True) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setFixedHeight(48 if row["depth"] else 52) + self.setAccessibleName(f"{row['title']}, {row['subtitle']}") + self.setToolTip(row["error"] or "") + self._follow_theme() + + def set_row(self, row: dict) -> None: + """Show new display data.""" + self.row = row + self.setToolTip(row["error"] or "") + self.setAccessibleName(f"{row['title']}, {row['subtitle']}") + self.update() + + def refresh_theme(self, _tokens: ThemeTokens) -> None: + """Repaint with the new theme.""" + self.update() + + def enterEvent(self, event): # pylint: disable=invalid-name + self._hover = True + self.update() + super().enterEvent(event) + + def leaveEvent(self, event): # pylint: disable=invalid-name + self._hover = False + self.update() + super().leaveEvent(event) + + def mousePressEvent(self, event): # pylint: disable=invalid-name + if event.button() == Qt.MouseButton.LeftButton: + self.clicked.emit(self.row["uid"]) + super().mousePressEvent(event) + + def paintEvent(self, _event): # pylint: disable=invalid-name + """Paint the control.""" + tokens = ThemeTokens.current() + row = self.row + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = QRectF(self.rect()).adjusted(4, 2, -4, -2) + if row["selected"]: + painter.setPen(QPen(tokens.primary, 1.5)) + painter.setBrush(tokens.soft(tokens.primary, 0.14)) + painter.drawRoundedRect(rect, 8, 8) + elif self._hover: + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(tokens.hover) + painter.drawRoundedRect(rect, 8, 8) + x = rect.left() + 10 + if row["depth"]: + painter.setPen(QPen(tokens.separator, 1.5)) + mid = rect.center().y() + painter.drawLine(QPointF(x + 6, rect.top() - 4), QPointF(x + 6, mid)) + painter.drawLine(QPointF(x + 6, mid), QPointF(x + 16, mid)) + x += 22 + sample = QRectF(x, rect.center().y() - 10, 40, 20) + paint_curve_sample( + painter, + sample, + row["color"], + row["penStyle"], + row["penWidth"], + row["symbol"], + row["symbolSize"], + max_width=3, + max_symbol=7, + peak=row["kind"] != "custom", + ) + x = sample.right() + 12 + right = rect.right() - 10 + # trailing status: error icon or tag + if row["error"]: + icon = material_icon( + "error", + size=(32, 32), + color=tokens.danger_text, + filled=True, + convert_to_pixmap=True, + ) + painter.drawPixmap( + QRectF(right - 18, rect.center().y() - 9, 18, 18), icon, QRectF(icon.rect()) + ) + right -= 26 + tag = row["tag"] + if tag == "history": + tag = row["subtitle"].split("· ")[-1] + if tag and tag != "fit": + painter.setFont(_font(self, tokens.metrics["fontCaption"], QFont.Weight.DemiBold)) + width = painter.fontMetrics().horizontalAdvance(tag) + 14 + pill = QRectF(right - width, rect.center().y() - 10, width, 20) + tone = "highlight" if row["tag"] == "custom" else "info" + painter.setPen(Qt.PenStyle.NoPen) + painter.setBrush(tokens.tone_tint(tone)) + painter.drawRoundedRect(pill, 10, 10) + painter.setPen(tokens.tone_text(tone)) + painter.drawText(pill, Qt.AlignmentFlag.AlignCenter, tag) + right = pill.left() - 8 + title_font = _font(self, tokens.metrics["fontBody"], QFont.Weight.DemiBold) + painter.setFont(title_font) + painter.setPen(tokens.fg) + width = max(10, right - x) + title = painter.fontMetrics().elidedText( + row["title"], Qt.TextElideMode.ElideRight, int(width) + ) + painter.drawText(QRectF(x, rect.top() + 6, width, 20), Qt.AlignmentFlag.AlignVCenter, title) + painter.setFont(_font(self, tokens.metrics["fontSmall"])) + painter.setPen(tokens.danger_text if row["error"] else tokens.fg_muted) + subtitle = row["error"] or row["subtitle"] + subtitle = painter.fontMetrics().elidedText( + subtitle, Qt.TextElideMode.ElideRight, int(width) + ) + painter.drawText( + QRectF(x, rect.bottom() - 24, width, 18), Qt.AlignmentFlag.AlignVCenter, subtitle + ) + painter.end() + + +class CurveList(QScrollArea): + """Scrollable list of :class:`CurveRow` with keyboard navigation.""" + + selected = Signal(int) + delete_requested = Signal(int) + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self.setWidgetResizable(True) + self.setFrameShape(QFrame.Shape.NoFrame) + self.setFocusPolicy(Qt.FocusPolicy.StrongFocus) + self.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.container = QWidget() + self.container.setObjectName("curveListContainer") + self._layout = QVBoxLayout(self.container) + self._layout.setContentsMargins(0, 2, 0, 2) + self._layout.setSpacing(0) + self._layout.addStretch(1) + self.setWidget(self.container) + self._rows: list[CurveRow] = [] + + def set_rows(self, rows: list[dict]) -> None: + """Show ``rows``; existing row widgets are reused.""" + while len(self._rows) > len(rows): + widget = self._rows.pop() + widget.setParent(None) + widget.deleteLater() + for index, row in enumerate(rows): + if index < len(self._rows): + self._rows[index].set_row(row) + self._rows[index].setFixedHeight(48 if row["depth"] else 52) + else: + widget = CurveRow(row, self.container) + widget.clicked.connect(self._on_clicked) + self._layout.insertWidget(self._layout.count() - 1, widget) + self._rows.append(widget) + for widget in self._rows: + if widget.row["selected"]: + self.ensureWidgetVisible(widget, 0, 4) + + @property + def rows(self) -> list[CurveRow]: + """Row widgets in order.""" + return list(self._rows) + + def _on_clicked(self, uid: int) -> None: + self.setFocus() + self.selected.emit(uid) + + def _current_index(self) -> int: + return next((i for i, w in enumerate(self._rows) if w.row["selected"]), -1) + + def keyPressEvent(self, event): # pylint: disable=invalid-name + index = self._current_index() + if event.key() in (Qt.Key.Key_Down, Qt.Key.Key_Up) and self._rows: + step = 1 if event.key() == Qt.Key.Key_Down else -1 + target = max(0, min(len(self._rows) - 1, index + step)) + self.selected.emit(self._rows[target].row["uid"]) + return + if event.key() in (Qt.Key.Key_Delete, Qt.Key.Key_Backspace) and index >= 0: + self.delete_requested.emit(self._rows[index].row["uid"]) + return + super().keyPressEvent(event) + + +class SampleButton(_ThemeFollower, QAbstractButton): + """Checkable tile showing a line style or a symbol.""" + + def __init__( + self, + tip: str, + pen_style: str = "solid", + symbol: str | None = None, + parent: QWidget | None = None, + ): + super().__init__(parent) + self._pen_style = pen_style + self._symbol = symbol + self._color = QColor("#888888") + self.setCheckable(True) + self.setToolTip(tip) + self.setAccessibleName(tip) + self.setCursor(Qt.CursorShape.PointingHandCursor) + self.setFixedSize(46 if symbol is None else 34, 30) + self._follow_theme() + + def set_color(self, color: str) -> None: + """Draw the sample in the curve colour.""" + self._color = QColor(color) + self.update() + + def refresh_theme(self, _tokens: ThemeTokens) -> None: + """Repaint with the new theme.""" + self.update() + + def paintEvent(self, _event): # pylint: disable=invalid-name + """Paint the control.""" + tokens = ThemeTokens.current() + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = QRectF(self.rect()).adjusted(1, 1, -1, -1) + border = tokens.primary if self.isChecked() else tokens.border + if self.hasFocus(): + border = tokens.primary + painter.setPen(QPen(border, 2 if self.isChecked() or self.hasFocus() else 1)) + painter.setBrush(tokens.soft(tokens.primary, 0.12) if self.isChecked() else tokens.field) + painter.drawRoundedRect(rect, 6, 6) + if self._symbol is None: + pen = QPen(tokens.fg if not self.isChecked() else self._color, 2.2) + pen.setStyle( + { + "solid": Qt.PenStyle.SolidLine, + "dash": Qt.PenStyle.DashLine, + "dot": Qt.PenStyle.DotLine, + "dashdot": Qt.PenStyle.DashDotLine, + }[self._pen_style] + ) + painter.setPen(pen) + painter.drawLine( + QPointF(rect.left() + 8, rect.center().y()), + QPointF(rect.right() - 8, rect.center().y()), + ) + elif self._symbol == "": + pen = QPen(tokens.fg_muted, 1.5) + painter.setPen(pen) + c = rect.center() + painter.drawLine(QPointF(c.x() - 6, c.y() + 6), QPointF(c.x() + 6, c.y() - 6)) + else: + color = self._color if self.isChecked() else tokens.fg + painter.setPen(QPen(color, 1.2)) + painter.setBrush(color) + paint_symbol(painter, rect.center(), self._symbol, 11) + painter.end() + + +class SwatchButton(_ThemeFollower, QAbstractButton): + """Round colour swatch; checked when it is the curve colour.""" + + def __init__(self, color: str, parent: QWidget | None = None): + super().__init__(parent) + self.color = color + self.setCheckable(True) + self.setFixedSize(26, 26) + self.setToolTip(color) + self.setAccessibleName(f"Colour {color}") + self.setCursor(Qt.CursorShape.PointingHandCursor) + self._follow_theme() + + def refresh_theme(self, _tokens: ThemeTokens) -> None: + """Repaint with the new theme.""" + self.update() + + def paintEvent(self, _event): # pylint: disable=invalid-name + """Paint the control.""" + tokens = ThemeTokens.current() + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = QRectF(self.rect()) + if self.isChecked() or self.hasFocus(): + painter.setPen(QPen(tokens.fg if self.isChecked() else tokens.primary, 2)) + painter.setBrush(Qt.BrushStyle.NoBrush) + painter.drawEllipse(rect.adjusted(1, 1, -1, -1)) + painter.setPen(QPen(QColor(0, 0, 0, 40), 1)) + painter.setBrush(QColor(self.color)) + painter.drawEllipse(rect.adjusted(5, 5, -5, -5)) + painter.end() + + +class SamplePreview(QWidget): + """Large sample of the selected curve, shown in the inspector header.""" + + def __init__(self, parent: QWidget | None = None): + super().__init__(parent) + self.row: dict | None = None + self.setFixedSize(84, 44) + + def set_row(self, row: dict) -> None: + """Show ``row``'s colour and style.""" + self.row = row + self.update() + + def paintEvent(self, _event): # pylint: disable=invalid-name + """Paint the control.""" + if self.row is None: + return + tokens = ThemeTokens.current() + painter = QPainter(self) + painter.setRenderHint(QPainter.RenderHint.Antialiasing) + rect = QRectF(self.rect()).adjusted(0.5, 0.5, -0.5, -0.5) + painter.setPen(QPen(tokens.border, 1)) + painter.setBrush(tokens.field) + painter.drawRoundedRect(rect, 8, 8) + row = self.row + paint_curve_sample( + painter, + rect.adjusted(8, 7, -8, -7), + row["color"], + row["penStyle"], + row["penWidth"], + row["symbol"], + row["symbolSize"], + max_width=6, + max_symbol=12, + peak=row["kind"] != "custom", + ) + painter.end() + + +class ValueSlider(QWidget): + """Slider with its value shown next to it.""" + + value_changed = Signal(int) + + def __init__(self, minimum: int, maximum: int, value: int, unit: str, tip: str, parent=None): + super().__init__(parent) + layout = QHBoxLayout(self) + layout.setContentsMargins(0, 0, 0, 0) + layout.setSpacing(8) + self.slider = QSlider(Qt.Orientation.Horizontal, self) + self.slider.setRange(minimum, maximum) + self.slider.setValue(value) + self.slider.setAccessibleName(tip) + self.slider.setToolTip(tip) + self.label = QLabel(f"{value} {unit}", self) + self.label.setObjectName("sliderValue") + self.label.setMinimumWidth(38) + self._unit = unit + self.slider.valueChanged.connect(self._on_value) + layout.addWidget(self.slider, 1) + layout.addWidget(self.label) + + def _on_value(self, value: int) -> None: + self.label.setText(f"{value} {self._unit}") + self.value_changed.emit(value) + + +class CurveSettingsQWidget(_ThemeFollower, SettingWidget): + """Curve settings of a :class:`Waveform`: X axis, curves, fits and their look. + + 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.setObjectName("curveSettings") + self.target_widget = target_widget + self.model = CurveDialogModel(target_widget, parent=self) + self._inspector_refreshers: list[Callable[[], None]] = [] + self._inspector_uid: int | None = -1 + self._cleaned = False + self.scan_combo: QComboBox | None = None + + # pickers of the curve inspector and of "Add curve" (whose combobox stays hidden) + self.device_picker = DevicePicker(parent=self, client=self.model.client) + self.device_picker.setEditable(True) + self.device_picker.picker_controller.picked.connect(self._on_device_picked) + self.signal_picker = SignalPicker(parent=self, client=self.model.client) + self.signal_picker.include_config_signals = False + self.signal_picker.setEditable(True) + self.signal_picker.picker_controller.picked.connect(self._on_signal_picked) + self.add_picker = DevicePicker(parent=self, client=self.model.client) + self.add_picker.setEditable(True) + self.add_picker.hide() + self.add_picker.picker_controller.picked.connect(self._on_add_picked) + + root = QVBoxLayout(self) + root.setContentsMargins(4, 4, 4, 4) + root.setSpacing(10) + root.addWidget(self._build_x_axis()) + body = QHBoxLayout() + body.setSpacing(10) + body.addWidget(self._build_list(), 0) + body.addWidget(self._build_inspector(), 1) + root.addLayout(body, 1) + + self.model.rows_changed.connect(self._refresh_list) + self.model.selection_changed.connect(self._rebuild_inspector) + self.model.draft_changed.connect(self._on_draft_changed) + self.model.x_axis_changed.connect(self._refresh_x_axis) + self.model.palette_changed.connect(self._refresh_palette) + + QShortcut(QKeySequence.StandardKey.New, self, activated=self.open_add_picker) + + self.refresh_theme(ThemeTokens.current()) + self._refresh_x_axis() + self._refresh_palette() + self._refresh_list() + self._rebuild_inspector() + self._follow_theme() + + def sizeHint(self) -> QSize: # pylint: disable=invalid-name + """Default size of the dialog content.""" + return QSize(940, 600) + + # ---- X axis ------------------------------------------------------------------------------ + + def _build_x_axis(self) -> QWidget: + self.x_card = Card("X axis", title_style="caption", padding=12) + row = QHBoxLayout() + row.setSpacing(10) + self.x_mode_control = SegmentedControl([X_MODE_LABELS[m] for m in X_MODES]) + self.x_mode_control.setAccessibleName("X axis mode") + self.x_mode_control.activated.connect(lambda i: self.model.set_x_mode(X_MODES[i])) + row.addWidget(self.x_mode_control) + self.x_device_picker = DevicePicker(parent=self.x_card, client=self.model.client) + self.x_device_picker.setEditable(True) + self.x_device_picker.setMinimumWidth(170) + self.x_device_picker.picker_controller.picked.connect(self.model.set_x_device) + self.x_signal_picker = SignalPicker(parent=self.x_card, client=self.model.client) + self.x_signal_picker.include_config_signals = False + self.x_signal_picker.setEditable(True) + self.x_signal_picker.setMinimumWidth(170) + self.x_signal_picker.picker_controller.picked.connect( + lambda text: self.model.set_x_signal(signal_obj_name(self.x_signal_picker, text)) + ) + row.addWidget(self.x_device_picker, 1) + row.addWidget(self.x_signal_picker, 1) + self.x_help = QLabel(self.x_card) + self.x_help.setObjectName("helper") + self.x_help.setWordWrap(True) + row.addWidget(self.x_help, 2) + self.x_card.body.addLayout(row) + return self.x_card + + def _refresh_x_axis(self) -> None: + mode = self.model.x_mode + self.x_mode_control.set_current_index(X_MODES.index(mode)) + device_mode = mode == "device" + self.x_device_picker.setVisible(device_mode) + self.x_signal_picker.setVisible(device_mode) + error = self.model.x_axis_error() + self.x_help.setText(error or X_MODE_HELP[mode]) + self.x_help.setProperty("error", bool(error)) + self.x_help.style().unpolish(self.x_help) + self.x_help.style().polish(self.x_help) + if device_mode: + select_device(self.x_device_picker, self.model.x_device) + select_signal(self.x_signal_picker, self.model.x_device, self.model.x_signal) + self._refresh_problems() + + # ---- curve list -------------------------------------------------------------------------- + + def _build_list(self) -> QWidget: + panel = QFrame() + panel.setObjectName("panel") + panel.setFixedWidth(330) + layout = QVBoxLayout(panel) + layout.setContentsMargins(10, 10, 10, 10) + layout.setSpacing(8) + header = QHBoxLayout() + header.setSpacing(6) + title = QLabel("Curves", panel) + title.setObjectName("panelTitle") + self.count_badge = Badge(count=0, tone="neutral", parent=panel) + header.addWidget(title) + header.addWidget(self.count_badge) + header.addStretch(1) + self.palette_combo = QComboBox(panel) + self.palette_combo.setToolTip( + "Palette for curve colours. Choosing one recolours every curve." + ) + self.palette_combo.setAccessibleName("Colour palette") + self.palette_combo.setIconSize(QSize(44, 12)) + self.palette_combo.activated.connect( + lambda i: self.model.set_palette(self.palette_combo.itemText(i)) + ) + header.addWidget(self.palette_combo) + self.recolor_button = IconButton( + "format_color_fill", "Recolour all curves from the palette", panel, compact=True + ) + # clicked passes `checked`, which recolor_all would take as `emit` + self.recolor_button.clicked.connect( + lambda: self.model.recolor_all() + ) # pylint: disable=unnecessary-lambda + header.addWidget(self.recolor_button) + layout.addLayout(header) + self.add_button = TextButton("Add curve", "primary", "add", panel) + self.add_button.setToolTip("Pick a device to plot (Ctrl+N)") + self.add_button.clicked.connect(self.open_add_picker) + layout.addWidget(self.add_button) + self.curve_list = CurveList(panel) + self.curve_list.selected.connect(self.model.select) + self.curve_list.delete_requested.connect(self.model.remove) + layout.addWidget(self.curve_list, 1) + self.problem_banner = Banner("warning", "", "", panel) + self.problem_banner.hide() + layout.addWidget(self.problem_banner) + return panel + + def _refresh_palette(self) -> None: + self.palette_combo.blockSignals(True) + self.palette_combo.clear() + for name in self.model.palettes(): + self.palette_combo.addItem(palette_icon(name), name) + self.palette_combo.setCurrentText(self.model.palette) + self.palette_combo.blockSignals(False) + + def _refresh_list(self) -> None: + rows = self.model.rows() + self.curve_list.set_rows(rows) + self.count_badge.set_count(sum(1 for r in rows if r["depth"] == 0)) + self._refresh_problems() + + def _refresh_problems(self) -> None: + problems = self.model.problems() + if not problems: + self.problem_banner.hide() + return + title = ( + "1 problem to fix before applying" + if len(problems) == 1 + else f"{len(problems)} problems to fix before applying" + ) + self.problem_banner.set_message(title, "\n".join(problems[:3])) + self.problem_banner.show() + + @SafeSlot() + def open_add_picker(self) -> None: + """Open the device picker under "Add curve"; picking a device adds its curve.""" + open_picker_at(self.add_picker, self.add_button) + + @SafeSlot(str) + def _on_add_picked(self, device: str) -> None: + self.model.add_curve(device) + + # ---- inspector --------------------------------------------------------------------------- + # One page per kind of curve is built once; selecting a curve only re-binds the values. + # Building widgets under the dialog's style sheet costs ~25 ms per page, re-binding ~2 ms. + + def _build_inspector(self) -> QWidget: + self.inspector_scroll = QScrollArea() + self.inspector_scroll.setObjectName("inspector") + self.inspector_scroll.setWidgetResizable(True) + self.inspector_scroll.setFrameShape(QFrame.Shape.NoFrame) + self.inspector_scroll.setHorizontalScrollBarPolicy(Qt.ScrollBarPolicy.ScrollBarAlwaysOff) + self.inspector_stack = QStackedWidget() + self.inspector_stack.setObjectName("inspectorPage") + self.inspector_scroll.setWidget(self.inspector_stack) + self._pages: dict[str, tuple[QWidget, list[Callable[[], None]]]] = {} + return self.inspector_scroll + + def _page(self, kind: str) -> tuple[QWidget, list[Callable[[], None]]]: + if kind in self._pages: + return self._pages[kind] + page = QWidget() + page.setObjectName("inspectorPage") + layout = QVBoxLayout(page) + layout.setContentsMargins(0, 0, 6, 0) + layout.setSpacing(10) + refreshers: list[Callable[[], None]] = [] + if kind == "empty": + empty = EmptyState( + "show_chart", + "No curves yet", + "Add a device to plot it against the X axis. Fits and colours are set per curve.", + page, + action_text="Add curve", + action_icon="add", + ) + empty.action_triggered.connect(self.open_add_picker) + layout.addStretch(1) + layout.addWidget(empty, 0, Qt.AlignmentFlag.AlignCenter) + layout.addStretch(2) + else: + layout.addWidget(self._header(kind, refreshers)) + if kind == "device": + layout.addWidget(self._data_card(refreshers)) + layout.addWidget(self._fits_card(refreshers)) + elif kind == "dap": + layout.addWidget(self._fit_card(refreshers)) + else: + layout.addWidget( + Banner( + "info", + "Curve sent from the command line", + "Its data came from plot(x=..., y=...), so only its look can be changed " + "here. Remove it from the command line.", + page, + ) + ) + layout.addWidget(self._fits_card(refreshers)) + layout.addWidget(self._style_card(refreshers)) + layout.addStretch(1) + self.inspector_stack.addWidget(page) + self._pages[kind] = (page, refreshers) + return self._pages[kind] + + def _rebuild_inspector(self) -> None: + draft = self.model.selected + self._inspector_uid = draft.uid if draft else None + page, refreshers = self._page(draft.kind if draft else "empty") + self._inspector_refreshers = refreshers + for refresh in refreshers: + refresh() + self.inspector_stack.setCurrentWidget(page) + self.inspector_scroll.verticalScrollBar().setValue(0) + + def _on_draft_changed(self, uid: int) -> None: + if uid == self._inspector_uid: + for refresh in self._inspector_refreshers: + refresh() + + def _uid(self) -> int: + return self.model.selected_uid if self.model.selected_uid is not None else -1 + + def _caption(self, text: str, parent: QWidget) -> QLabel: + label = QLabel(text, parent) + label.setObjectName("rowLabel") + return label + + def _header(self, kind: str, refreshers: list) -> QWidget: + box = QWidget() + layout = QHBoxLayout(box) + layout.setContentsMargins(2, 0, 2, 0) + layout.setSpacing(12) + preview = SamplePreview(box) + layout.addWidget(preview) + texts = QVBoxLayout() + texts.setSpacing(2) + title = QLabel(box) + title.setObjectName("inspectorTitle") + subtitle = QLabel(box) + subtitle.setObjectName("helper") + texts.addWidget(title) + texts.addWidget(subtitle) + layout.addLayout(texts, 1) + if kind != "dap": + add_fit = TextButton("Add fit", "neutral", "add", box, compact=True) + add_fit.setToolTip("Fit a model to this curve") + add_fit.clicked.connect(lambda: self.model.add_fit(self._uid())) + layout.addWidget(add_fit) + if kind != "custom": + remove = IconButton( + "delete", + "Remove fit" if kind == "dap" else "Remove curve and its fits", + box, + danger=True, + ) + remove.clicked.connect(lambda: self.model.remove(self._uid())) + layout.addWidget(remove) + + def refresh(): + row = self.model.row(self.model.selected) + preview.set_row(row) + title.setText(row["title"]) + subtitle.setText(row["subtitle"]) + + refreshers.append(refresh) + return box + + def _data_card(self, refreshers: list) -> QWidget: + card = Card("Data", title_style="caption", padding=12) + grid = QGridLayout() + grid.setHorizontalSpacing(12) + grid.setVerticalSpacing(10) + self.device_picker.setParent(card) + self.signal_picker.setParent(card) + device_field = FormField("Device", self.device_picker, card, required=True) + signal_field = FormField("Signal", self.signal_picker, card, required=True) + self.device_picker.show() + self.signal_picker.show() + scan_combo = QComboBox(card) + scan_combo.setAccessibleName("Data from") + scan_combo.activated.connect( + lambda i: self.model.set_scan(self._uid(), scan_combo.itemData(i)) + ) + scan_field = FormField( + "Data from", + scan_combo, + card, + helper="Live follows the running scan; an earlier scan shows its stored data.", + ) + grid.addWidget(device_field, 0, 0, Qt.AlignmentFlag.AlignTop) + grid.addWidget(signal_field, 0, 1, Qt.AlignmentFlag.AlignTop) + grid.addWidget(scan_field, 1, 0, 1, 2) + grid.setColumnStretch(0, 1) + grid.setColumnStretch(1, 1) + card.body.addLayout(grid) + self.scan_combo = scan_combo + + def refresh(): + draft = self.model.selected + select_device(self.device_picker, draft.device) + select_signal(self.signal_picker, draft.device, draft.signal) + scan_combo.blockSignals(True) + scan_combo.clear() + for text, scan_id in self.model.scans(): + scan_combo.addItem(text, scan_id) + index = scan_combo.findData(draft.config.scan_id) if draft.config.scan_id else 0 + if index < 0: + scan_combo.addItem( + f"Scan #{draft.config.scan_number} (missing)", draft.config.scan_id + ) + index = scan_combo.count() - 1 + scan_combo.setCurrentIndex(index) + scan_combo.blockSignals(False) + error = self.model.validate().get(draft.uid, "") + device_field.set_error(error if "evice" in error else "") + signal_field.set_error(error if "ignal" in error else "") + scan_field.set_error(error if "scan" in error else "") + + refreshers.append(refresh) + return card + + def _fits_card(self, refreshers: list) -> QWidget: + card = Card("Fits", title_style="caption", padding=12) + none_label = QLabel( + "No fits. Add one to fit a model such as a Gaussian to this curve.", card + ) + none_label.setObjectName("helper") + none_label.setWordWrap(True) + card.body.addWidget(none_label) + holder = QWidget(card) + rows = QVBoxLayout(holder) + rows.setContentsMargins(0, 0, 0, 0) + rows.setSpacing(2) + card.body.addWidget(holder) + buttons: list[TextButton] = [] + + def refresh(): + fits = self.model.children(self._uid()) + none_label.setVisible(not fits) + while len(buttons) < len(fits): + button = TextButton("", "ghost", "", holder, compact=True) + button.setIconSize(QSize(32, 14)) + button.setToolTip("Edit this fit") + button.clicked.connect( + lambda _=False, b=button: self.model.select(b.property("uid")) + ) + rows.addWidget(button, 0, Qt.AlignmentFlag.AlignLeft) + buttons.append(button) + for index, button in enumerate(buttons): + button.setVisible(index < len(fits)) + if index < len(fits): + row = self.model.row(fits[index]) + button.setText(row["title"]) + button.setIcon(self._sample_icon(row)) + button.setProperty("uid", fits[index].uid) + + refreshers.append(refresh) + return card + + def _sample_icon(self, row: dict) -> QIcon: + pixmap = QPixmap(64, 28) + pixmap.fill(Qt.GlobalColor.transparent) + painter = QPainter(pixmap) + paint_curve_sample(painter, QRectF(0, 0, 64, 28), row["color"], row["penStyle"], 4, "", 1) + painter.end() + return QIcon(pixmap) + + def _fit_card(self, refreshers: list) -> QWidget: + card = Card("Fit", title_style="caption", padding=12) + model_combo = QComboBox(card) + model_combo.setAccessibleName("Fit model") + model_combo.textActivated.connect( + lambda text: self.model.set_primary_model(self._uid(), text) + ) + model_field = FormField("Model", model_combo, card, required=True) + card.body.addWidget(model_field) + card.body.addWidget(self._caption("Combine with", card)) + chips = QWidget(card) + flow = QGridLayout(chips) + flow.setContentsMargins(0, 0, 0, 0) + flow.setHorizontalSpacing(6) + flow.setVerticalSpacing(6) + flow.setColumnStretch(4, 1) + card.body.addWidget(chips) + chip_buttons: dict[str, QPushButton] = {} + helper = QLabel(card) + helper.setObjectName("helper") + helper.setWordWrap(True) + card.body.addWidget(helper) + go = TextButton("", "ghost", "arrow_back", card, compact=True) + go.setToolTip("Select the fitted curve") + go.clicked.connect(lambda: self.model.select(self.model.selected.parent_uid)) + line = QHBoxLayout() + line.addWidget(go) + line.addStretch(1) + card.body.addLayout(line) + + def refresh(): + draft = self.model.selected + available = self.model.available_models() or list(draft.models) + if draft.models[0] not in available: + available = [draft.models[0]] + available + model_combo.blockSignals(True) + if [model_combo.itemText(i) for i in range(model_combo.count())] != available: + model_combo.clear() + model_combo.addItems(available) + model_combo.setCurrentText(draft.models[0]) + model_combo.blockSignals(False) + for model in available: + if model in chip_buttons: + continue + chip = QPushButton(_short_model(model), chips) + chip.setObjectName("chip") + chip.setCheckable(True) + chip.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed) + chip.setCursor(Qt.CursorShape.PointingHandCursor) + chip.setToolTip(f"Add {model} to a composite fit") + chip.setAccessibleName(f"Add {model} to a composite fit") + chip.clicked.connect( + lambda _=False, m=model: self.model.toggle_extra_model(self._uid(), m) + ) + chip_buttons[model] = chip + others = [m for m in available if m != draft.models[0]] + for model, chip in chip_buttons.items(): + flow.removeWidget(chip) + chip.setVisible(model in others) + chip.setChecked(model in draft.models[1:]) + for index, model in enumerate(others): + flow.addWidget(chip_buttons[model], index // 4, index % 4) + if len(draft.models) > 1: + helper.setText(f"Composite fit: the sum of {' + '.join(draft.models)}.") + else: + helper.setText("Select more models to fit their sum (a composite fit).") + model_field.set_error(self.model.validate().get(draft.uid, "")) + parent = self.model.draft(draft.parent_uid) + go.setVisible(parent is not None) + if parent is not None: + go.setText(f"Fits {self.model.row(parent)['title']}") + + refreshers.append(refresh) + return card + + def _style_card(self, refreshers: list) -> QWidget: + card = Card("Appearance", title_style="caption", padding=12) + grid = QGridLayout() + grid.setHorizontalSpacing(14) + grid.setVerticalSpacing(12) + + # colour + swatch_row = QHBoxLayout() + swatch_row.setSpacing(2) + swatches: list[SwatchButton] = [] + swatch_group = QButtonGroup(card) + for _ in range(SWATCH_COUNT): + swatch = SwatchButton("#888888", card) + swatch.clicked.connect( + lambda _=False, s=swatch: self.model.set_style(self._uid(), "color", s.color) + ) + swatch_row.addWidget(swatch) + swatch_group.addButton(swatch) + swatches.append(swatch) + custom = TextButton("Custom…", "ghost", "colorize", card, compact=True) + custom.setToolTip("Pick any colour") + custom.clicked.connect(lambda: self._pick_custom_color(self._uid())) + swatch_row.addSpacing(6) + swatch_row.addWidget(custom) + swatch_row.addStretch(1) + grid.addWidget(self._caption("Colour", card), 0, 0) + grid.addLayout(swatch_row, 0, 1) + + # line style and width + line_row = QHBoxLayout() + line_row.setSpacing(4) + line_buttons = {} + line_group = QButtonGroup(card) + for style in PEN_STYLES: + button = SampleButton(f"{PEN_STYLE_LABELS[style]} line", pen_style=style, parent=card) + button.clicked.connect( + lambda _=False, s=style: self.model.set_style(self._uid(), "pen_style", s) + ) + line_row.addWidget(button) + line_group.addButton(button) + line_buttons[style] = button + line_row.addSpacing(14) + width = ValueSlider(1, 10, 1, "px", "Line width", card) + width.value_changed.connect(lambda v: self.model.set_style(self._uid(), "pen_width", v)) + line_row.addWidget(width, 1) + grid.addWidget(self._caption("Line", card), 1, 0) + grid.addLayout(line_row, 1, 1) + + # symbol and size + symbol_row = QHBoxLayout() + symbol_row.setSpacing(4) + symbol_buttons = {} + symbol_group = QButtonGroup(card) + for symbol in SYMBOLS: + button = SampleButton(SYMBOL_LABELS[symbol], symbol=symbol, parent=card) + button.clicked.connect( + lambda _=False, s=symbol: self.model.set_style(self._uid(), "symbol", s) + ) + symbol_row.addWidget(button) + symbol_group.addButton(button) + symbol_buttons[symbol] = button + symbol_row.addSpacing(14) + size = ValueSlider(1, 20, 1, "px", "Symbol size", card) + size.value_changed.connect(lambda v: self.model.set_style(self._uid(), "symbol_size", v)) + symbol_row.addWidget(size, 1) + grid.addWidget(self._caption("Symbol", card), 2, 0) + grid.addLayout(symbol_row, 2, 1) + grid.setColumnStretch(1, 1) + card.body.addLayout(grid) + + def refresh(): + config = self.model.selected.config + color = color_hex(config.color) + swatch_group.setExclusive(False) + for swatch, swatch_color in zip(swatches, self.model.swatches(color)): + swatch.color = swatch_color + swatch.setToolTip(swatch_color) + swatch.setAccessibleName(f"Colour {swatch_color}") + swatch.setChecked(swatch_color.lower() == color.lower()) + swatch.update() + swatch_group.setExclusive(True) + for style, button in line_buttons.items(): + button.set_color(color) + button.setChecked(style == (config.pen_style or "solid")) + for symbol, button in symbol_buttons.items(): + button.set_color(color) + button.setChecked(symbol == (config.symbol or "")) + for slider, value, top in ( + (width, int(config.pen_width or 1), 10), + (size, int(config.symbol_size or 1), 20), + ): + slider.slider.blockSignals(True) + slider.slider.setMaximum(max(top, value)) + slider.slider.setValue(value) + slider.slider.blockSignals(False) + slider.label.setText(f"{value} px") + size.setEnabled(bool(config.symbol)) + + refreshers.append(refresh) + return card + + @SafeSlot(int) + def _pick_custom_color(self, uid: int) -> None: + draft = self.model.draft(uid) + if draft is None: + return + color = QColorDialog.getColor(QColor(color_hex(draft.config.color)), self, "Curve colour") + if color.isValid(): + self.model.set_style(uid, "color", color.name()) + + # ---- picker callbacks -------------------------------------------------------------------- + + @SafeSlot(str) + def _on_device_picked(self, device: str) -> None: + uid = self._inspector_uid + if uid is None: + return + self.model.set_device(uid, device) + draft = self.model.draft(uid) + if draft is not None: + select_signal(self.signal_picker, draft.device, draft.signal) + + @SafeSlot(str) + def _on_signal_picked(self, text: str) -> None: + uid = self._inspector_uid + if uid is None: + return + self.model.set_signal(uid, signal_obj_name(self.signal_picker, text)) + + # ---- theme ------------------------------------------------------------------------------- + + def refresh_theme(self, tokens: ThemeTokens) -> None: + """Apply the theme tokens to the parts that are not kit controls.""" + small = tokens.metrics["fontSmall"] + self.setStyleSheet(field_qss(tokens) + f""" + QWidget#curveSettings {{ background: {tokens.bg.name()}; }} + QFrame#panel {{ background: {tokens.card.name()}; border: 1px solid {tokens.border.name()}; + border-radius: {tokens.metrics["radiusLarge"]}px; }} + QWidget#curveListContainer, QWidget#inspectorPage {{ background: transparent; }} + QScrollArea {{ background: transparent; border: none; }} + QScrollArea > QWidget > QWidget {{ background: transparent; }} + QLabel {{ background: transparent; color: {tokens.fg.name()}; }} + QLabel#panelTitle {{ font-size: {tokens.metrics["fontTitle"]}px; font-weight: 600; }} + QLabel#inspectorTitle {{ font-size: {tokens.metrics["fontTitle"]}px; font-weight: 600; }} + QLabel#helper {{ color: {tokens.fg_muted.name()}; font-size: {small}px; }} + QLabel#helper[error="true"] {{ color: {tokens.danger_text.name()}; }} + QLabel#rowLabel {{ color: {tokens.fg_muted.name()}; font-size: {small}px; font-weight: 500; }} + QLabel#sliderValue {{ color: {tokens.fg_muted.name()}; font-size: {small}px; }} + QPushButton#chip {{ background: {tokens.field.name()}; color: {tokens.fg.name()}; + border: 1px solid {tokens.border.name()}; border-radius: 13px; padding: 3px 12px; + font-size: {small}px; min-height: 18px; }} + QPushButton#chip:hover {{ border-color: {tokens.fg_subtle.name()}; }} + QPushButton#chip:checked {{ background: {rgba(tokens.primary, 0.16)}; + border-color: {tokens.primary.name()}; color: {tokens.fg.name()}; font-weight: 600; }} + QPushButton#chip:disabled {{ color: {tokens.fg_subtle.name()}; border-style: dashed; }} + QSlider::groove:horizontal {{ height: 4px; background: {tokens.track.name()}; border-radius: 2px; }} + QSlider::sub-page:horizontal {{ background: {tokens.primary.name()}; border-radius: 2px; }} + QSlider::handle:horizontal {{ background: {tokens.card.name()}; border: 2px solid {tokens.primary.name()}; + width: 12px; height: 12px; margin: -6px 0; border-radius: 8px; }} + QSlider::handle:horizontal:disabled {{ border-color: {tokens.fg_subtle.name()}; }} + QSlider::sub-page:horizontal:disabled {{ background: {tokens.fg_subtle.name()}; }} + """) + if hasattr(self, "curve_list"): + self.curve_list.viewport().update() + + # ---- SettingWidget API ------------------------------------------------------------------- + + @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): + """Close the pickers and their popups.""" + if self._cleaned: + return + self._cleaned = True + for picker in ( + self.device_picker, + self.signal_picker, + self.add_picker, + self.x_device_picker, + self.x_signal_picker, + ): + picker.close() + picker.deleteLater() diff --git a/bec_widgets/widgets/plots/waveform/settings/curve_dialog/qml/CurveDialog.qml b/bec_widgets/widgets/plots/waveform/settings/curve_dialog/qml/CurveDialog.qml new file mode 100644 index 00000000..1392b5e6 --- /dev/null +++ b/bec_widgets/widgets/plots/waveform/settings/curve_dialog/qml/CurveDialog.qml @@ -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 } + } + } + } + } + } +} diff --git a/bec_widgets/widgets/plots/waveform/settings/curve_dialog/qml/CurveSample.qml b/bec_widgets/widgets/plots/waveform/settings/curve_dialog/qml/CurveSample.qml new file mode 100644 index 00000000..d2ae56a5 --- /dev/null +++ b/bec_widgets/widgets/plots/waveform/settings/curve_dialog/qml/CurveSample.qml @@ -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) + } + } +} diff --git a/bec_widgets/widgets/plots/waveform/settings/curve_dialog/qml/PickerField.qml b/bec_widgets/widgets/plots/waveform/settings/curve_dialog/qml/PickerField.qml new file mode 100644 index 00000000..29a667db --- /dev/null +++ b/bec_widgets/widgets/plots/waveform/settings/curve_dialog/qml/PickerField.qml @@ -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() } + } +} diff --git a/bec_widgets/widgets/plots/waveform/waveform.py b/bec_widgets/widgets/plots/waveform/waveform.py index b8b689f2..384268fe 100644 --- a/bec_widgets/widgets/plots/waveform/waveform.py +++ b/bec_widgets/widgets/plots/waveform/waveform.py @@ -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 ) diff --git a/tests/unit_tests/test_curve_dialog_redesign.py b/tests/unit_tests/test_curve_dialog_redesign.py new file mode 100644 index 00000000..406ee2d4 --- /dev/null +++ b/tests/unit_tests/test_curve_dialog_redesign.py @@ -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"