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