mirror of
https://github.com/bec-project/bec_widgets.git
synced 2026-04-09 02:00:56 +02:00
Compare commits
15 Commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
9a2396ee9c | ||
| 2dab16b684 | |||
| e6c8cd0b1a | |||
| 242f8933b2 | |||
|
|
83ac6bcd37 | ||
| 90ecd8ea87 | |||
|
|
6e5f6e7fbb | ||
| 2f75aaea16 | |||
| 677550931b | |||
| 96b5179658 | |||
| e25b6604d1 | |||
|
|
f74c5a4516 | ||
| a2923752c2 | |||
| a486c52058 | |||
| 31389a3dd0 |
57
CHANGELOG.md
57
CHANGELOG.md
@@ -1,6 +1,63 @@
|
||||
# CHANGELOG
|
||||
|
||||
|
||||
## v3.4.2 (2026-04-01)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- Allow admin user to pass deployment group check
|
||||
([`e6c8cd0`](https://github.com/bec-project/bec_widgets/commit/e6c8cd0b1a1162302071c93a2ac51880b3cf1b7d))
|
||||
|
||||
- **bec-atlas-admin-view**: Fix atlas_url to bec-atlas-prod.psi.ch
|
||||
([`242f893`](https://github.com/bec-project/bec_widgets/commit/242f8933b246802f5f3a5b9df7de07901f151c82))
|
||||
|
||||
### Testing
|
||||
|
||||
- Add tests for admin access
|
||||
([`2dab16b`](https://github.com/bec-project/bec_widgets/commit/2dab16b68415806f3f291657f394bb2d8654229d))
|
||||
|
||||
|
||||
## v3.4.1 (2026-04-01)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **hover_widget**: Make it fancy + mouse tracking
|
||||
([`e25b660`](https://github.com/bec-project/bec_widgets/commit/e25b6604d195804bbd6ea6aac395d44dc00d6155))
|
||||
|
||||
- **ring**: Changed inheritance to BECWidget and added cleanup
|
||||
([`2f75aae`](https://github.com/bec-project/bec_widgets/commit/2f75aaea16a178e180e68d702cd1bdf85a768bcf))
|
||||
|
||||
- **ring**: Hook update hover to update method
|
||||
([`90ecd8e`](https://github.com/bec-project/bec_widgets/commit/90ecd8ea87faf06c3f545e3f9241f403b733d7eb))
|
||||
|
||||
- **ring**: Minor general fixes
|
||||
([`6775509`](https://github.com/bec-project/bec_widgets/commit/677550931b28fbf35fd55880bf6e001f7351b99b))
|
||||
|
||||
- **ring_progress_bar**: Added hover mouse effect
|
||||
([`96b5179`](https://github.com/bec-project/bec_widgets/commit/96b5179658c41fb39df7a40f4d96e82092605791))
|
||||
|
||||
### Testing
|
||||
|
||||
- **ring_progress_bar**: Add unit tests for hover behavior
|
||||
([`6e5f6e7`](https://github.com/bec-project/bec_widgets/commit/6e5f6e7fbb6f9680f6d026e105e6840d24c6591c))
|
||||
|
||||
|
||||
## v3.4.0 (2026-03-26)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
- **lmfit_dialog**: Compact layout size policy for better alignment panel UX
|
||||
([`31389a3`](https://github.com/bec-project/bec_widgets/commit/31389a3dd0c7b1c671acdf49ae50b08455f466a7))
|
||||
|
||||
- **waveform**: Alignment panel indicators request autoscale if updated
|
||||
([`a292375`](https://github.com/bec-project/bec_widgets/commit/a2923752c27ad7b9749db3d309fe288747b85acb))
|
||||
|
||||
### Features
|
||||
|
||||
- **waveform**: 1d alignment mode panel
|
||||
([`a486c52`](https://github.com/bec-project/bec_widgets/commit/a486c52058b4edbea00ad7bb018f1fa2822fb9c6))
|
||||
|
||||
|
||||
## v3.3.4 (2026-03-24)
|
||||
|
||||
### Bug Fixes
|
||||
|
||||
@@ -1,27 +1,83 @@
|
||||
import sys
|
||||
|
||||
from qtpy import QtGui, QtWidgets
|
||||
from qtpy.QtCore import QPoint, Qt
|
||||
from qtpy.QtWidgets import QApplication, QHBoxLayout, QLabel, QProgressBar, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import (
|
||||
QApplication,
|
||||
QFrame,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QProgressBar,
|
||||
QVBoxLayout,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
|
||||
class WidgetTooltip(QWidget):
|
||||
"""Frameless, always-on-top window that behaves like a tooltip."""
|
||||
|
||||
def __init__(self, content: QWidget) -> None:
|
||||
super().__init__(None, Qt.ToolTip | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
|
||||
self.setAttribute(Qt.WA_ShowWithoutActivating)
|
||||
super().__init__(
|
||||
None,
|
||||
Qt.WindowType.ToolTip
|
||||
| Qt.WindowType.FramelessWindowHint
|
||||
| Qt.WindowType.WindowStaysOnTopHint,
|
||||
)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_ShowWithoutActivating)
|
||||
self.setAttribute(Qt.WidgetAttribute.WA_TranslucentBackground)
|
||||
self.setMouseTracking(True)
|
||||
self.content = content
|
||||
|
||||
layout = QVBoxLayout(self)
|
||||
layout.setContentsMargins(6, 6, 6, 6)
|
||||
layout.addWidget(self.content)
|
||||
layout.setContentsMargins(14, 14, 14, 14)
|
||||
|
||||
self._card = QFrame(self)
|
||||
self._card.setObjectName("WidgetTooltipCard")
|
||||
card_layout = QVBoxLayout(self._card)
|
||||
card_layout.setContentsMargins(12, 10, 12, 10)
|
||||
card_layout.addWidget(self.content)
|
||||
|
||||
shadow = QtWidgets.QGraphicsDropShadowEffect(self._card)
|
||||
shadow.setBlurRadius(18)
|
||||
shadow.setOffset(0, 2)
|
||||
shadow.setColor(QtGui.QColor(0, 0, 0, 140))
|
||||
self._card.setGraphicsEffect(shadow)
|
||||
|
||||
layout.addWidget(self._card)
|
||||
self.apply_theme()
|
||||
self.adjustSize()
|
||||
|
||||
def leaveEvent(self, _event) -> None:
|
||||
self.hide()
|
||||
|
||||
def apply_theme(self) -> None:
|
||||
palette = QApplication.palette()
|
||||
base = palette.color(QtGui.QPalette.ColorRole.Base)
|
||||
text = palette.color(QtGui.QPalette.ColorRole.Text)
|
||||
border = palette.color(QtGui.QPalette.ColorRole.Mid)
|
||||
background = QtGui.QColor(base)
|
||||
background.setAlpha(242)
|
||||
self._card.setStyleSheet(f"""
|
||||
QFrame#WidgetTooltipCard {{
|
||||
background: {background.name(QtGui.QColor.NameFormat.HexArgb)};
|
||||
border: 1px solid {border.name()};
|
||||
border-radius: 12px;
|
||||
}}
|
||||
QFrame#WidgetTooltipCard QLabel {{
|
||||
color: {text.name()};
|
||||
background: transparent;
|
||||
}}
|
||||
""")
|
||||
|
||||
def show_above(self, global_pos: QPoint, offset: int = 8) -> None:
|
||||
"""
|
||||
Show the tooltip above a global position, adjusting to stay within screen bounds.
|
||||
|
||||
Args:
|
||||
global_pos(QPoint): The global position to show above.
|
||||
offset(int, optional): The vertical offset from the global position. Defaults to 8 pixels.
|
||||
"""
|
||||
self.apply_theme()
|
||||
self.adjustSize()
|
||||
screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen()
|
||||
screen_geo = screen.availableGeometry()
|
||||
@@ -30,11 +86,43 @@ class WidgetTooltip(QWidget):
|
||||
x = global_pos.x() - geom.width() // 2
|
||||
y = global_pos.y() - geom.height() - offset
|
||||
|
||||
self._navigate_screen_coordinates(screen_geo, geom, x, y)
|
||||
|
||||
def show_near(self, global_pos: QPoint, offset: QPoint | None = None) -> None:
|
||||
"""
|
||||
Show the tooltip near a global position, adjusting to stay within screen bounds.
|
||||
By default, it will try to show below and to the right of the position,
|
||||
but if that would cause it to go off-screen, it will flip to the other side.
|
||||
|
||||
Args:
|
||||
global_pos(QPoint): The global position to show near.
|
||||
offset(QPoint, optional): The offset from the global position. Defaults to QPoint(12, 16).
|
||||
"""
|
||||
|
||||
self.apply_theme()
|
||||
self.adjustSize()
|
||||
offset = offset or QPoint(12, 16)
|
||||
screen = QApplication.screenAt(global_pos) or QApplication.primaryScreen()
|
||||
screen_geo = screen.availableGeometry()
|
||||
geom = self.geometry()
|
||||
|
||||
x = global_pos.x() + offset.x()
|
||||
y = global_pos.y() + offset.y()
|
||||
|
||||
if x + geom.width() > screen_geo.right():
|
||||
x = global_pos.x() - geom.width() - abs(offset.x())
|
||||
if y + geom.height() > screen_geo.bottom():
|
||||
y = global_pos.y() - geom.height() - abs(offset.y())
|
||||
|
||||
self._navigate_screen_coordinates(screen_geo, geom, x, y)
|
||||
|
||||
def _navigate_screen_coordinates(self, screen_geo, geom, x, y):
|
||||
x = max(screen_geo.left(), min(x, screen_geo.right() - geom.width()))
|
||||
y = max(screen_geo.top(), min(y, screen_geo.bottom() - geom.height()))
|
||||
|
||||
self.move(x, y)
|
||||
self.show()
|
||||
self.raise_()
|
||||
|
||||
|
||||
class HoverWidget(QWidget):
|
||||
|
||||
@@ -3,7 +3,7 @@ import os
|
||||
import shiboken6
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import Signal
|
||||
from qtpy.QtWidgets import QPushButton, QTreeWidgetItem, QVBoxLayout, QWidget
|
||||
from qtpy.QtWidgets import QPushButton, QSizePolicy, QTreeWidgetItem, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils import UILoader
|
||||
from bec_widgets.utils.bec_widget import BECWidget
|
||||
@@ -69,6 +69,27 @@ class LMFitDialog(BECWidget, QWidget):
|
||||
self._hide_curve_selection = False
|
||||
self._hide_summary = False
|
||||
self._hide_parameters = False
|
||||
self._configure_embedded_size_policy()
|
||||
|
||||
def _configure_embedded_size_policy(self):
|
||||
"""Allow the compact dialog to shrink more gracefully in embedded layouts."""
|
||||
if self._ui_file != "lmfit_dialog_compact.ui":
|
||||
return
|
||||
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
self.ui.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
|
||||
for group in (
|
||||
self.ui.group_curve_selection,
|
||||
self.ui.group_summary,
|
||||
self.ui.group_parameters,
|
||||
):
|
||||
group.setMinimumHeight(0)
|
||||
group.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
|
||||
for view in (self.ui.curve_list, self.ui.summary_tree, self.ui.param_tree):
|
||||
view.setMinimumHeight(0)
|
||||
view.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Ignored)
|
||||
|
||||
@property
|
||||
def enable_actions(self) -> bool:
|
||||
|
||||
345
bec_widgets/widgets/plots/waveform/utils/alignment_controller.py
Normal file
345
bec_widgets/widgets/plots/waveform/utils/alignment_controller.py
Normal file
@@ -0,0 +1,345 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from dataclasses import dataclass
|
||||
|
||||
import pyqtgraph as pg
|
||||
from qtpy.QtCore import QObject, Qt, Signal
|
||||
from qtpy.QtGui import QColor
|
||||
|
||||
from bec_widgets.utils.colors import get_accent_colors, get_theme_name
|
||||
from bec_widgets.utils.error_popups import SafeSlot
|
||||
from bec_widgets.widgets.plots.waveform.utils.alignment_panel import WaveformAlignmentPanel
|
||||
|
||||
|
||||
@dataclass(slots=True)
|
||||
class AlignmentContext:
|
||||
"""Alignment state produced by `Waveform` and consumed by the controller.
|
||||
|
||||
Attributes:
|
||||
visible: Whether alignment mode is currently visible.
|
||||
positioner_name: Name of the resolved x-axis positioner, if available.
|
||||
precision: Decimal precision to use for readback and target labels.
|
||||
limits: Optional positioner limits for the draggable target line.
|
||||
readback: Current cached positioner readback value.
|
||||
has_dap_curves: Whether the waveform currently contains any DAP curves.
|
||||
force_readback: Whether the embedded positioner should refresh its readback immediately.
|
||||
"""
|
||||
|
||||
visible: bool
|
||||
positioner_name: str | None
|
||||
precision: int = 3
|
||||
limits: tuple[float, float] | None = None
|
||||
readback: float | None = None
|
||||
has_dap_curves: bool = False
|
||||
force_readback: bool = False
|
||||
|
||||
|
||||
class WaveformAlignmentController(QObject):
|
||||
"""Own the alignment plot overlays and synchronize them with the alignment panel."""
|
||||
|
||||
move_absolute_requested = Signal(float)
|
||||
autoscale_requested = Signal()
|
||||
|
||||
def __init__(self, plot_item: pg.PlotItem, panel: WaveformAlignmentPanel, parent=None):
|
||||
super().__init__(parent=parent)
|
||||
self._plot_item = plot_item
|
||||
self._panel = panel
|
||||
|
||||
self._visible = False
|
||||
self._positioner_name: str | None = None
|
||||
self._precision = 3
|
||||
self._limits: tuple[float, float] | None = None
|
||||
self._readback: float | None = None
|
||||
self._marker_line: pg.InfiniteLine | None = None
|
||||
self._target_line: pg.InfiniteLine | None = None
|
||||
|
||||
self._panel.position_readback_changed.connect(self.update_position)
|
||||
self._panel.target_toggled.connect(self._on_target_toggled)
|
||||
self._panel.target_move_requested.connect(self._on_target_move_requested)
|
||||
self._panel.fit_selection_changed.connect(self._on_fit_selection_changed)
|
||||
self._panel.fit_center_requested.connect(self._on_fit_center_requested)
|
||||
|
||||
@property
|
||||
def marker_line(self) -> pg.InfiniteLine | None:
|
||||
"""Return the current-position indicator line, if it exists."""
|
||||
return self._marker_line
|
||||
|
||||
@property
|
||||
def target_line(self) -> pg.InfiniteLine | None:
|
||||
"""Return the draggable target indicator line, if it exists."""
|
||||
return self._target_line
|
||||
|
||||
def update_context(self, context: AlignmentContext):
|
||||
"""Apply waveform-owned alignment context to the panel and plot overlays.
|
||||
|
||||
Args:
|
||||
context: Snapshot of the current alignment-relevant waveform/device state.
|
||||
"""
|
||||
previous_name = self._positioner_name
|
||||
self._visible = context.visible
|
||||
self._positioner_name = context.positioner_name
|
||||
self._precision = context.precision
|
||||
self._limits = context.limits
|
||||
self._readback = context.readback
|
||||
|
||||
self._panel.set_positioner_device(context.positioner_name)
|
||||
self._panel.set_positioner_enabled(context.visible and context.positioner_name is not None)
|
||||
self._panel.set_status_message(self._status_message_for_context(context))
|
||||
|
||||
if context.positioner_name is None or not context.visible:
|
||||
self.clear()
|
||||
self._refresh_fit_actions()
|
||||
self._refresh_target_controls()
|
||||
return
|
||||
|
||||
if previous_name != context.positioner_name:
|
||||
self._clear_marker()
|
||||
if self._panel.target_active:
|
||||
self._clear_target_line()
|
||||
|
||||
if context.readback is not None:
|
||||
self.update_position(context.readback)
|
||||
|
||||
if self._panel.target_active:
|
||||
if previous_name != context.positioner_name or self._target_line is None:
|
||||
self._show_target_line()
|
||||
else:
|
||||
self._refresh_target_line_metadata()
|
||||
self._on_target_line_changed()
|
||||
|
||||
if context.force_readback or previous_name != context.positioner_name:
|
||||
self._panel.force_positioner_readback()
|
||||
|
||||
self._refresh_fit_actions()
|
||||
self._refresh_target_controls()
|
||||
|
||||
@SafeSlot(float)
|
||||
def update_position(self, position: float):
|
||||
"""Update the live position marker from a positioner readback value.
|
||||
|
||||
Args:
|
||||
position: Current absolute position of the active alignment positioner.
|
||||
"""
|
||||
self._readback = float(position)
|
||||
if not self._visible or self._positioner_name is None:
|
||||
self._clear_marker()
|
||||
return
|
||||
|
||||
self._ensure_marker()
|
||||
self._marker_line.setValue(self._readback)
|
||||
self._marker_line.label.setText(
|
||||
f"{self._positioner_name}: {self._readback:.{self._precision}f}"
|
||||
)
|
||||
self.autoscale_requested.emit()
|
||||
|
||||
@SafeSlot(dict, dict)
|
||||
def update_dap_summary(self, data: dict, metadata: dict):
|
||||
"""Forward DAP summary updates into the alignment fit panel.
|
||||
|
||||
Args:
|
||||
data: DAP fit summary payload.
|
||||
metadata: Metadata describing the emitting DAP curve.
|
||||
"""
|
||||
self._panel.update_dap_summary(data, metadata)
|
||||
self._refresh_fit_actions()
|
||||
|
||||
@SafeSlot(str)
|
||||
def remove_dap_curve(self, curve_id: str):
|
||||
"""Remove a deleted DAP curve from the alignment fit selection state.
|
||||
|
||||
Args:
|
||||
curve_id: Label of the DAP curve that was removed from the waveform.
|
||||
"""
|
||||
self._panel.remove_dap_curve(curve_id)
|
||||
self._panel.clear_fit_selection_if_missing()
|
||||
self._refresh_fit_actions()
|
||||
|
||||
def clear(self):
|
||||
"""Remove alignment overlay items from the plot and reset target state."""
|
||||
self._clear_marker()
|
||||
self._clear_target_line()
|
||||
|
||||
def cleanup(self):
|
||||
"""Disconnect panel signals and remove all controller-owned overlay items."""
|
||||
self.clear()
|
||||
self._disconnect_panel_signals()
|
||||
|
||||
def refresh_theme_colors(self):
|
||||
"""Reapply theme-aware styling to any existing alignment overlay items."""
|
||||
self._apply_marker_style()
|
||||
self._apply_target_style()
|
||||
|
||||
def _disconnect_panel_signals(self):
|
||||
signal_pairs = [
|
||||
(self._panel.position_readback_changed, self.update_position),
|
||||
(self._panel.target_toggled, self._on_target_toggled),
|
||||
(self._panel.target_move_requested, self._on_target_move_requested),
|
||||
(self._panel.fit_selection_changed, self._on_fit_selection_changed),
|
||||
(self._panel.fit_center_requested, self._on_fit_center_requested),
|
||||
]
|
||||
for signal, slot in signal_pairs:
|
||||
try:
|
||||
signal.disconnect(slot)
|
||||
except (RuntimeError, TypeError):
|
||||
continue
|
||||
|
||||
def _selected_fit_has_center(self) -> bool:
|
||||
data = self._panel.selected_fit_summary()
|
||||
params = data.get("params", []) if isinstance(data, dict) else []
|
||||
return any(param[0] == "center" for param in params if param)
|
||||
|
||||
@staticmethod
|
||||
def _status_message_for_context(context: AlignmentContext) -> str | None:
|
||||
if context.positioner_name is None:
|
||||
return "Alignment mode requires a positioner on the x axis."
|
||||
if not context.has_dap_curves:
|
||||
return "Add a DAP curve in Curve Settings to enable alignment fitting."
|
||||
return None
|
||||
|
||||
def _refresh_fit_actions(self):
|
||||
self._panel.set_fit_actions_enabled(
|
||||
self._visible and self._positioner_name is not None and self._selected_fit_has_center()
|
||||
)
|
||||
|
||||
def _refresh_target_controls(self):
|
||||
has_positioner = self._visible and self._positioner_name is not None
|
||||
self._panel.set_target_enabled(has_positioner)
|
||||
self._panel.set_target_move_enabled(has_positioner and self._target_line is not None)
|
||||
if self._target_line is None:
|
||||
self._panel.set_target_value(None)
|
||||
|
||||
def _ensure_marker(self):
|
||||
if self._marker_line is not None:
|
||||
return
|
||||
|
||||
warning = get_accent_colors().warning
|
||||
|
||||
self._marker_line = pg.InfiniteLine(
|
||||
angle=90,
|
||||
movable=False,
|
||||
pen=pg.mkPen(warning, width=4),
|
||||
label="",
|
||||
labelOpts={"position": 0.95, "color": warning},
|
||||
)
|
||||
self._apply_marker_style()
|
||||
self._plot_item.addItem(self._marker_line)
|
||||
|
||||
def _clear_marker(self):
|
||||
if self._marker_line is None:
|
||||
return
|
||||
self._plot_item.removeItem(self._marker_line)
|
||||
self._marker_line = None
|
||||
|
||||
def _show_target_line(self):
|
||||
if not self._visible or self._positioner_name is None:
|
||||
return
|
||||
|
||||
if self._target_line is None:
|
||||
accent_colors = get_accent_colors()
|
||||
label = f"{self._positioner_name} target={{value:0.{self._precision}f}}"
|
||||
self._target_line = pg.InfiniteLine(
|
||||
movable=True,
|
||||
angle=90,
|
||||
pen=pg.mkPen(accent_colors.default, width=2, style=Qt.PenStyle.DashLine),
|
||||
hoverPen=pg.mkPen(accent_colors.success, width=2),
|
||||
label=label,
|
||||
labelOpts={"movable": True, "color": accent_colors.default},
|
||||
)
|
||||
self._target_line.sigPositionChanged.connect(self._on_target_line_changed)
|
||||
self._apply_target_style()
|
||||
self._plot_item.addItem(self._target_line)
|
||||
self._refresh_target_line_metadata()
|
||||
|
||||
value = 0.0 if self._readback is None else self._readback
|
||||
if self._limits is not None:
|
||||
value = min(max(value, self._limits[0]), self._limits[1])
|
||||
self._target_line.setValue(value)
|
||||
self._on_target_line_changed()
|
||||
self.autoscale_requested.emit()
|
||||
|
||||
def _refresh_target_line_metadata(self):
|
||||
if self._target_line is None or self._positioner_name is None:
|
||||
return
|
||||
self._apply_target_style()
|
||||
self._target_line.label.setFormat(
|
||||
f"{self._positioner_name} target={{value:0.{self._precision}f}}"
|
||||
)
|
||||
if self._limits is not None:
|
||||
self._target_line.setBounds(list(self._limits))
|
||||
else:
|
||||
self._target_line.setBounds((None, None))
|
||||
if self._limits is not None:
|
||||
current_value = float(self._target_line.value())
|
||||
clamped_value = min(max(current_value, self._limits[0]), self._limits[1])
|
||||
if clamped_value != current_value:
|
||||
self._target_line.setValue(clamped_value)
|
||||
|
||||
def _clear_target_line(self):
|
||||
if self._target_line is not None:
|
||||
try:
|
||||
self._target_line.sigPositionChanged.disconnect(self._on_target_line_changed)
|
||||
except (RuntimeError, TypeError):
|
||||
pass
|
||||
self._plot_item.removeItem(self._target_line)
|
||||
self._target_line = None
|
||||
self._panel.set_target_value(None)
|
||||
|
||||
def _apply_marker_style(self):
|
||||
if self._marker_line is None:
|
||||
return
|
||||
|
||||
accent_colors = get_accent_colors()
|
||||
warning = accent_colors.warning
|
||||
|
||||
self._marker_line.setPen(pg.mkPen(warning, width=4))
|
||||
self._marker_line.label.setColor(warning)
|
||||
self._marker_line.label.fill = pg.mkBrush(self._label_fill_color())
|
||||
|
||||
def _apply_target_style(self):
|
||||
if self._target_line is None:
|
||||
return
|
||||
|
||||
accent_colors = get_accent_colors()
|
||||
default = accent_colors.default
|
||||
success = accent_colors.success
|
||||
|
||||
self._target_line.setPen(pg.mkPen(default, width=2, style=Qt.PenStyle.DashLine))
|
||||
self._target_line.setHoverPen(pg.mkPen(success, width=2))
|
||||
self._target_line.label.setColor(default)
|
||||
self._target_line.label.fill = pg.mkBrush(self._label_fill_color())
|
||||
|
||||
@staticmethod
|
||||
def _label_fill_color() -> QColor:
|
||||
if get_theme_name() == "light":
|
||||
return QColor(244, 244, 244, 228)
|
||||
return QColor(48, 48, 48, 210)
|
||||
|
||||
@SafeSlot(bool)
|
||||
def _on_target_toggled(self, checked: bool):
|
||||
if checked:
|
||||
self._show_target_line()
|
||||
else:
|
||||
self._clear_target_line()
|
||||
self._refresh_target_controls()
|
||||
|
||||
@SafeSlot(object)
|
||||
def _on_target_line_changed(self, _line=None):
|
||||
if self._target_line is None:
|
||||
return
|
||||
self._panel.set_target_value(float(self._target_line.value()), precision=self._precision)
|
||||
self._refresh_target_controls()
|
||||
self.autoscale_requested.emit()
|
||||
|
||||
@SafeSlot()
|
||||
def _on_target_move_requested(self):
|
||||
if self._visible and self._positioner_name is not None and self._target_line is not None:
|
||||
self.move_absolute_requested.emit(float(self._target_line.value()))
|
||||
|
||||
@SafeSlot(str)
|
||||
def _on_fit_selection_changed(self, _curve_id: str):
|
||||
self._refresh_fit_actions()
|
||||
|
||||
@SafeSlot(float)
|
||||
def _on_fit_center_requested(self, value: float):
|
||||
if self._visible and self._positioner_name is not None:
|
||||
self.move_absolute_requested.emit(float(value))
|
||||
285
bec_widgets/widgets/plots/waveform/utils/alignment_panel.py
Normal file
285
bec_widgets/widgets/plots/waveform/utils/alignment_panel.py
Normal file
@@ -0,0 +1,285 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from qtpy.QtCore import Qt, Signal
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import (
|
||||
QCheckBox,
|
||||
QGridLayout,
|
||||
QGroupBox,
|
||||
QHBoxLayout,
|
||||
QLabel,
|
||||
QPushButton,
|
||||
QSizePolicy,
|
||||
QWidget,
|
||||
)
|
||||
|
||||
from bec_widgets.utils.colors import get_accent_colors, get_theme_name
|
||||
from bec_widgets.widgets.control.device_control.positioner_box.positioner_control_line.positioner_control_line import (
|
||||
PositionerControlLine,
|
||||
)
|
||||
from bec_widgets.widgets.dap.lmfit_dialog.lmfit_dialog import LMFitDialog
|
||||
|
||||
|
||||
class WaveformAlignmentPanel(QWidget):
|
||||
"""Compact bottom panel used by Waveform alignment mode."""
|
||||
|
||||
position_readback_changed = Signal(float)
|
||||
target_toggled = Signal(bool)
|
||||
target_move_requested = Signal()
|
||||
fit_selection_changed = Signal(str)
|
||||
fit_center_requested = Signal(float)
|
||||
|
||||
def __init__(self, parent=None, client=None, gui_id: str | None = None, **kwargs):
|
||||
super().__init__(parent=parent, **kwargs)
|
||||
self.setProperty("skip_settings", True)
|
||||
|
||||
self.positioner = PositionerControlLine(parent=self, client=client, gui_id=gui_id)
|
||||
self.positioner.hide_device_selection = True
|
||||
|
||||
self.fit_dialog = LMFitDialog(
|
||||
parent=self, client=client, gui_id=gui_id, ui_file="lmfit_dialog_compact.ui"
|
||||
)
|
||||
self.fit_dialog.active_action_list = ["center"]
|
||||
self.fit_dialog.enable_actions = False
|
||||
|
||||
self.target_toggle = QCheckBox("Target: --", parent=self)
|
||||
self.move_to_target_button = QPushButton("Move To Target", parent=self)
|
||||
self.move_to_target_button.setEnabled(False)
|
||||
self.target_group = QGroupBox("Target Position", parent=self)
|
||||
|
||||
self.status_label = QLabel(parent=self)
|
||||
self.status_label.setWordWrap(False)
|
||||
self.status_label.setAlignment(
|
||||
Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter
|
||||
)
|
||||
self.status_label.setSizePolicy(QSizePolicy.Policy.Maximum, QSizePolicy.Policy.Fixed)
|
||||
self.status_label.setMaximumHeight(28)
|
||||
self.status_label.setVisible(False)
|
||||
|
||||
self._init_ui()
|
||||
self.fit_dialog.setMinimumHeight(0)
|
||||
self.target_group.setSizePolicy(QSizePolicy.Policy.Fixed, QSizePolicy.Policy.Fixed)
|
||||
self._sync_target_group_size()
|
||||
self.refresh_theme_colors()
|
||||
self._connect_signals()
|
||||
|
||||
def _connect_signals(self):
|
||||
self.positioner.position_update.connect(self.position_readback_changed)
|
||||
self.target_toggle.toggled.connect(self.target_toggled)
|
||||
self.move_to_target_button.clicked.connect(self.target_move_requested)
|
||||
self.fit_dialog.selected_fit.connect(self.fit_selection_changed)
|
||||
self.fit_dialog.move_action.connect(self._forward_fit_move_action)
|
||||
|
||||
def _init_ui(self):
|
||||
self.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
self.setMinimumHeight(260)
|
||||
|
||||
root = QGridLayout(self)
|
||||
root.setContentsMargins(8, 8, 8, 8)
|
||||
root.setSpacing(8)
|
||||
|
||||
self.fit_dialog.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Preferred)
|
||||
root.addWidget(
|
||||
self.status_label,
|
||||
0,
|
||||
0,
|
||||
1,
|
||||
2,
|
||||
alignment=Qt.AlignmentFlag.AlignHCenter | Qt.AlignmentFlag.AlignVCenter,
|
||||
)
|
||||
root.addWidget(self.fit_dialog, 1, 0, 1, 2)
|
||||
|
||||
target_layout = QHBoxLayout(self.target_group)
|
||||
target_layout.addWidget(self.target_toggle)
|
||||
target_layout.addWidget(self.move_to_target_button)
|
||||
|
||||
root.addWidget(self.positioner, 2, 0, alignment=Qt.AlignmentFlag.AlignTop)
|
||||
root.addWidget(self.target_group, 2, 1, alignment=Qt.AlignmentFlag.AlignTop)
|
||||
root.setColumnStretch(0, 1)
|
||||
root.setColumnStretch(1, 0)
|
||||
root.setRowStretch(1, 1)
|
||||
|
||||
def _sync_target_group_size(self):
|
||||
representative_text = "Target: -99999.999"
|
||||
label_width = max(
|
||||
self.target_toggle.sizeHint().width(),
|
||||
self.target_toggle.fontMetrics().horizontalAdvance(representative_text) + 24,
|
||||
)
|
||||
self.target_toggle.setMinimumWidth(label_width)
|
||||
|
||||
# To make those two box the same height
|
||||
target_height = max(
|
||||
self.positioner.height(),
|
||||
self.positioner.ui.device_box.minimumSizeHint().height(),
|
||||
self.positioner.ui.device_box.sizeHint().height(),
|
||||
)
|
||||
self.target_group.setFixedHeight(target_height)
|
||||
self.target_group.setFixedWidth(self.target_group.sizeHint().width() + 16)
|
||||
|
||||
def showEvent(self, event):
|
||||
super().showEvent(event)
|
||||
self._sync_target_group_size()
|
||||
|
||||
def resizeEvent(self, event):
|
||||
super().resizeEvent(event)
|
||||
self._sync_target_group_size()
|
||||
|
||||
def set_status_message(self, text: str | None):
|
||||
"""Show or hide the alignment status pill.
|
||||
|
||||
Args:
|
||||
text: Message to display. Pass `None` or an empty string to hide the pill.
|
||||
"""
|
||||
|
||||
text = text or ""
|
||||
self.status_label.setText(text)
|
||||
self.status_label.setVisible(bool(text))
|
||||
|
||||
@staticmethod
|
||||
def _qcolor_to_rgba(color: QColor, alpha: int | None = None) -> str:
|
||||
if alpha is not None:
|
||||
color = QColor(color)
|
||||
color.setAlpha(alpha)
|
||||
return f"rgba({color.red()}, {color.green()}, {color.blue()}, {color.alpha()})"
|
||||
|
||||
def refresh_theme_colors(self):
|
||||
"""Apply theme-aware accent styling to the status pill."""
|
||||
warning = get_accent_colors().warning
|
||||
is_light = get_theme_name() == "light"
|
||||
text_color = "#202124" if is_light else warning.name()
|
||||
fill_alpha = 72 if is_light else 48
|
||||
border_alpha = 220 if is_light else 160
|
||||
|
||||
self.status_label.setStyleSheet(f"""
|
||||
QLabel {{
|
||||
background-color: {self._qcolor_to_rgba(warning, fill_alpha)};
|
||||
border: 1px solid {self._qcolor_to_rgba(warning, border_alpha)};
|
||||
border-radius: 12px;
|
||||
padding: 4px 10px;
|
||||
color: {text_color};
|
||||
}}
|
||||
""")
|
||||
|
||||
def set_positioner_device(self, device: str | None):
|
||||
"""Bind the embedded positioner control to a fixed device.
|
||||
|
||||
Args:
|
||||
device: Name of the positioner device to display, or `None` to clear it.
|
||||
"""
|
||||
if device is None:
|
||||
self.positioner.ui.device_box.setTitle("No positioner selected")
|
||||
return
|
||||
if self.positioner.device != device:
|
||||
self.positioner.set_positioner(device)
|
||||
self.positioner.hide_device_selection = True
|
||||
|
||||
def set_positioner_enabled(self, enabled: bool):
|
||||
"""Enable or disable the embedded positioner widget.
|
||||
|
||||
Args:
|
||||
enabled: Whether the positioner widget should accept interaction.
|
||||
"""
|
||||
self.positioner.setEnabled(enabled)
|
||||
|
||||
def force_positioner_readback(self):
|
||||
"""Trigger an immediate readback refresh on the embedded positioner widget."""
|
||||
self.positioner.force_update_readback()
|
||||
|
||||
def set_target_enabled(self, enabled: bool):
|
||||
"""Enable or disable the target-line toggle.
|
||||
|
||||
Args:
|
||||
enabled: Whether the target toggle should accept interaction.
|
||||
"""
|
||||
self.target_toggle.setEnabled(enabled)
|
||||
|
||||
def set_target_move_enabled(self, enabled: bool):
|
||||
"""Enable or disable the move-to-target button.
|
||||
|
||||
Args:
|
||||
enabled: Whether the move button should accept interaction.
|
||||
"""
|
||||
self.move_to_target_button.setEnabled(enabled)
|
||||
|
||||
def set_target_active(self, active: bool):
|
||||
"""Programmatically toggle the draggable target-line state.
|
||||
|
||||
Args:
|
||||
active: Whether the target line should be considered active.
|
||||
"""
|
||||
blocker = self.target_toggle.blockSignals(True)
|
||||
self.target_toggle.setChecked(active)
|
||||
self.target_toggle.blockSignals(blocker)
|
||||
if not active:
|
||||
self.set_target_value(None)
|
||||
|
||||
def set_target_value(self, value: float | None, precision: int = 3) -> None:
|
||||
"""
|
||||
Update the target checkbox label for the draggable target line.
|
||||
|
||||
Args:
|
||||
value(float | None): The target value to display. If None, the label will show "--".
|
||||
precision(int): The number of decimal places to display for the target value.
|
||||
"""
|
||||
if value is None or not self.target_toggle.isChecked():
|
||||
self.target_toggle.setText("Target: --")
|
||||
return
|
||||
self.target_toggle.setText(f"Target: {value:.{precision}f}")
|
||||
|
||||
def set_fit_actions_enabled(self, enabled: bool):
|
||||
"""Enable or disable LMFit action buttons in the embedded fit dialog.
|
||||
|
||||
Args:
|
||||
enabled: Whether fit action buttons should be enabled.
|
||||
"""
|
||||
self.fit_dialog.enable_actions = enabled
|
||||
|
||||
def update_dap_summary(self, data: dict, metadata: dict):
|
||||
"""Forward a DAP summary update into the embedded fit dialog.
|
||||
|
||||
Args:
|
||||
data: DAP fit summary payload.
|
||||
metadata: Metadata describing the emitting DAP curve.
|
||||
"""
|
||||
self.fit_dialog.update_summary_tree(data, metadata)
|
||||
|
||||
def remove_dap_curve(self, curve_id: str):
|
||||
"""Remove DAP summary state for a deleted fit curve.
|
||||
|
||||
Args:
|
||||
curve_id: Label of the DAP curve that should be removed.
|
||||
"""
|
||||
self.fit_dialog.remove_dap_data(curve_id)
|
||||
|
||||
def clear_fit_selection_if_missing(self):
|
||||
"""Select a remaining fit curve if the current selection no longer exists."""
|
||||
fit_curve_id = self.fit_dialog.fit_curve_id
|
||||
if fit_curve_id is not None and fit_curve_id not in self.fit_dialog.summary_data:
|
||||
remaining = list(self.fit_dialog.summary_data)
|
||||
self.fit_dialog.fit_curve_id = remaining[0] if remaining else None
|
||||
|
||||
@property
|
||||
def target_active(self) -> bool:
|
||||
"""Whether the target-line checkbox is currently checked."""
|
||||
return self.target_toggle.isChecked()
|
||||
|
||||
@property
|
||||
def selected_fit_curve_id(self) -> str | None:
|
||||
"""Return the currently selected fit curve label, if any."""
|
||||
return self.fit_dialog.fit_curve_id
|
||||
|
||||
def selected_fit_summary(self) -> dict | None:
|
||||
"""Return the summary payload for the currently selected fit curve.
|
||||
|
||||
Returns:
|
||||
The selected fit summary, or `None` if no fit curve is selected.
|
||||
"""
|
||||
fit_curve_id = self.selected_fit_curve_id
|
||||
if fit_curve_id is None:
|
||||
return None
|
||||
return self.fit_dialog.summary_data.get(fit_curve_id)
|
||||
|
||||
def _forward_fit_move_action(self, action: tuple[str, float]):
|
||||
param_name, param_value = action
|
||||
if param_name == "center":
|
||||
self.fit_center_requested.emit(float(param_value))
|
||||
@@ -6,6 +6,7 @@ from typing import TYPE_CHECKING, Literal
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
from bec_lib import bec_logger, messages
|
||||
from bec_lib.device import Positioner
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from bec_lib.lmfit_serializer import serialize_lmfit_params, serialize_param_object
|
||||
from bec_lib.scan_data_container import ScanDataContainer
|
||||
@@ -30,11 +31,18 @@ from bec_widgets.utils.colors import Colors, apply_theme
|
||||
from bec_widgets.utils.container_utils import WidgetContainerUtils
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.side_panel import SidePanel
|
||||
from bec_widgets.utils.toolbars.bundles import ToolbarBundle
|
||||
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.utils.alignment_controller import (
|
||||
AlignmentContext,
|
||||
WaveformAlignmentController,
|
||||
)
|
||||
from bec_widgets.widgets.plots.waveform.utils.alignment_panel import WaveformAlignmentPanel
|
||||
from bec_widgets.widgets.plots.waveform.utils.roi_manager import WaveformROIManager
|
||||
from bec_widgets.widgets.services.scan_history_browser.scan_history_browser import (
|
||||
ScanHistoryBrowser,
|
||||
@@ -156,6 +164,12 @@ class Waveform(PlotBase):
|
||||
"label_suffix": "",
|
||||
}
|
||||
self._current_x_device: tuple[str, str] | None = None
|
||||
self._alignment_panel_visible = False
|
||||
self._alignment_side_panel: SidePanel | None = None
|
||||
self._alignment_panel_index: int | None = None
|
||||
self._alignment_panel: WaveformAlignmentPanel | None = None
|
||||
self._alignment_controller: WaveformAlignmentController | None = None
|
||||
self._alignment_positioner_name: str | None = None
|
||||
|
||||
# Specific GUI elements
|
||||
self._init_roi_manager()
|
||||
@@ -165,6 +179,7 @@ class Waveform(PlotBase):
|
||||
self._add_waveform_specific_popup()
|
||||
self._enable_roi_toolbar_action(False) # default state where are no dap curves
|
||||
self._init_curve_dialog()
|
||||
self._init_alignment_mode()
|
||||
self.curve_settings_dialog = None
|
||||
|
||||
# Large‑dataset guard
|
||||
@@ -195,7 +210,9 @@ class Waveform(PlotBase):
|
||||
# To fix the ViewAll action with clipToView activated
|
||||
self._connect_viewbox_menu_actions()
|
||||
|
||||
self.toolbar.show_bundles(["plot_export", "mouse_interaction", "roi", "axis_popup"])
|
||||
self.toolbar.show_bundles(
|
||||
["plot_export", "mouse_interaction", "roi", "alignment_mode", "axis_popup"]
|
||||
)
|
||||
|
||||
def _connect_viewbox_menu_actions(self):
|
||||
"""Connect the viewbox menu action ViewAll to the custom reset_view method."""
|
||||
@@ -221,6 +238,12 @@ class Waveform(PlotBase):
|
||||
theme(str, optional): The theme to be applied.
|
||||
"""
|
||||
self._refresh_colors()
|
||||
alignment_panel = getattr(self, "_alignment_panel", None)
|
||||
alignment_controller = getattr(self, "_alignment_controller", None)
|
||||
if alignment_panel is not None:
|
||||
alignment_panel.refresh_theme_colors()
|
||||
if alignment_controller is not None:
|
||||
alignment_controller.refresh_theme_colors()
|
||||
super().apply_theme(theme)
|
||||
|
||||
def add_side_menus(self):
|
||||
@@ -230,6 +253,159 @@ class Waveform(PlotBase):
|
||||
super().add_side_menus()
|
||||
self._add_dap_summary_side_menu()
|
||||
|
||||
def _init_alignment_mode(self):
|
||||
"""
|
||||
Initialize the top alignment panel.
|
||||
"""
|
||||
self.toolbar.components.add_safe(
|
||||
"alignment_mode",
|
||||
MaterialIconAction(
|
||||
icon_name="align_horizontal_center",
|
||||
tooltip="Show Alignment Mode",
|
||||
checkable=True,
|
||||
parent=self,
|
||||
),
|
||||
)
|
||||
bundle = ToolbarBundle("alignment_mode", self.toolbar.components)
|
||||
bundle.add_action("alignment_mode")
|
||||
self.toolbar.add_bundle(bundle)
|
||||
shown_bundles = list(self.toolbar.shown_bundles)
|
||||
if "alignment_mode" not in shown_bundles:
|
||||
shown_bundles.append("alignment_mode")
|
||||
self.toolbar.show_bundles(shown_bundles)
|
||||
|
||||
self._alignment_side_panel = SidePanel(
|
||||
parent=self, orientation="top", panel_max_width=320, show_toolbar=False
|
||||
)
|
||||
self.layout_manager.add_widget_relative(
|
||||
self._alignment_side_panel,
|
||||
self.round_plot_widget,
|
||||
position="top",
|
||||
shift_direction="down",
|
||||
)
|
||||
|
||||
self._alignment_panel = WaveformAlignmentPanel(parent=self, client=self.client)
|
||||
self._alignment_controller = WaveformAlignmentController(
|
||||
self.plot_item, self._alignment_panel, parent=self
|
||||
)
|
||||
self._alignment_panel_index = self._alignment_side_panel.add_menu(
|
||||
widget=self._alignment_panel
|
||||
)
|
||||
self._alignment_controller.move_absolute_requested.connect(self._move_alignment_positioner)
|
||||
self._alignment_controller.autoscale_requested.connect(self._autoscale_alignment_indicators)
|
||||
self.dap_summary_update.connect(self._alignment_controller.update_dap_summary)
|
||||
self.toolbar.components.get_action("alignment_mode").action.toggled.connect(
|
||||
self.toggle_alignment_mode
|
||||
)
|
||||
|
||||
self._refresh_alignment_state()
|
||||
|
||||
@SafeSlot(bool)
|
||||
def toggle_alignment_mode(self, checked: bool):
|
||||
"""
|
||||
Show or hide the alignment panel.
|
||||
|
||||
Args:
|
||||
checked(bool): Whether the panel should be visible.
|
||||
"""
|
||||
if self._alignment_side_panel is None or self._alignment_panel_index is None:
|
||||
return
|
||||
|
||||
self._alignment_panel_visible = checked
|
||||
if checked:
|
||||
self._alignment_side_panel.show_panel(self._alignment_panel_index)
|
||||
self._refresh_alignment_state(force_readback=True)
|
||||
self._refresh_dap_signals()
|
||||
else:
|
||||
self._alignment_side_panel.hide_panel()
|
||||
self._refresh_alignment_state()
|
||||
|
||||
def _refresh_alignment_state(self, force_readback: bool = False):
|
||||
"""
|
||||
Refresh the alignment panel state after waveform changes.
|
||||
|
||||
Args:
|
||||
force_readback(bool): Force a positioner readback refresh.
|
||||
"""
|
||||
if self._alignment_controller is None:
|
||||
return
|
||||
|
||||
context = self._build_alignment_context(force_readback=force_readback)
|
||||
self._alignment_positioner_name = context.positioner_name
|
||||
self._alignment_controller.update_context(context)
|
||||
|
||||
def _resolve_alignment_positioner(self) -> str | None:
|
||||
"""
|
||||
Resolve the active x-axis positioner for alignment mode.
|
||||
"""
|
||||
if self.x_axis_mode["name"] in {"index", "timestamp"}:
|
||||
return None
|
||||
|
||||
if self.x_axis_mode["name"] == "auto":
|
||||
device_name = self._current_x_device[0] if self._current_x_device is not None else None
|
||||
else:
|
||||
device_name = self.x_axis_mode["name"]
|
||||
|
||||
if not device_name or device_name not in self.dev:
|
||||
return None
|
||||
if not isinstance(self.dev[device_name], Positioner):
|
||||
return None
|
||||
return device_name
|
||||
|
||||
def _build_alignment_context(self, force_readback: bool = False) -> AlignmentContext:
|
||||
"""Build controller-facing alignment context from waveform/device state."""
|
||||
positioner_name = self._resolve_alignment_positioner()
|
||||
if positioner_name is None:
|
||||
return AlignmentContext(
|
||||
visible=self._alignment_panel_visible,
|
||||
positioner_name=None,
|
||||
has_dap_curves=bool(self._dap_curves),
|
||||
force_readback=force_readback,
|
||||
)
|
||||
|
||||
precision = getattr(self.dev[positioner_name], "precision", 3)
|
||||
try:
|
||||
precision = int(precision)
|
||||
except (TypeError, ValueError):
|
||||
precision = 3
|
||||
|
||||
limits = getattr(self.dev[positioner_name], "limits", None)
|
||||
parsed_limits: tuple[float, float] | None = None
|
||||
if limits is not None and len(limits) == 2:
|
||||
low, high = float(limits[0]), float(limits[1])
|
||||
if low != 0 or high != 0:
|
||||
if low > high:
|
||||
low, high = high, low
|
||||
parsed_limits = (low, high)
|
||||
|
||||
data = self.dev[positioner_name].read(cached=True)
|
||||
value = data.get(positioner_name, {}).get("value")
|
||||
readback = None if value is None else float(value)
|
||||
|
||||
return AlignmentContext(
|
||||
visible=self._alignment_panel_visible,
|
||||
positioner_name=positioner_name,
|
||||
precision=precision,
|
||||
limits=parsed_limits,
|
||||
readback=readback,
|
||||
has_dap_curves=bool(self._dap_curves),
|
||||
force_readback=force_readback,
|
||||
)
|
||||
|
||||
@SafeSlot(float)
|
||||
def _move_alignment_positioner(self, value: float):
|
||||
"""
|
||||
Move the active alignment positioner to an absolute value requested by the controller.
|
||||
"""
|
||||
if self._alignment_positioner_name is None:
|
||||
return
|
||||
self.dev[self._alignment_positioner_name].move(float(value), relative=False)
|
||||
|
||||
@SafeSlot()
|
||||
def _autoscale_alignment_indicators(self):
|
||||
"""Autoscale the waveform view after alignment indicator updates."""
|
||||
self._reset_view()
|
||||
|
||||
def _add_waveform_specific_popup(self):
|
||||
"""
|
||||
Add popups to the Waveform widget.
|
||||
@@ -266,7 +442,7 @@ class Waveform(PlotBase):
|
||||
Due to setting clipToView to True on the curves, the autoRange() method
|
||||
of the ViewBox does no longer work as expected. This method deactivates the
|
||||
setClipToView for all curves, calls autoRange() to circumvent that issue.
|
||||
Afterwards, it re-enables the setClipToView for all curves again.
|
||||
Afterward, it re-enables the setClipToView for all curves again.
|
||||
|
||||
It is hooked to the ViewAll action in the right-click menu of the pg.PlotItem ViewBox.
|
||||
"""
|
||||
@@ -544,6 +720,7 @@ class Waveform(PlotBase):
|
||||
self.sync_signal_update.emit()
|
||||
self.plot_item.enableAutoRange(x=True)
|
||||
self.round_plot_widget.apply_plot_widget_style() # To keep the correct theme
|
||||
self._refresh_alignment_state(force_readback=True)
|
||||
|
||||
@SafeProperty(str)
|
||||
def signal_x(self) -> str | None:
|
||||
@@ -573,6 +750,7 @@ class Waveform(PlotBase):
|
||||
self.sync_signal_update.emit()
|
||||
self.plot_item.enableAutoRange(x=True)
|
||||
self.round_plot_widget.apply_plot_widget_style()
|
||||
self._refresh_alignment_state(force_readback=True)
|
||||
|
||||
@SafeProperty(str)
|
||||
def color_palette(self) -> str:
|
||||
@@ -627,6 +805,8 @@ class Waveform(PlotBase):
|
||||
continue
|
||||
config = CurveConfig(**cfg_dict)
|
||||
self._add_curve(config=config)
|
||||
self._refresh_alignment_state(force_readback=self._alignment_panel_visible)
|
||||
self._refresh_dap_signals()
|
||||
except json.JSONDecodeError as e:
|
||||
logger.error(f"Failed to decode JSON: {e}")
|
||||
|
||||
@@ -1002,6 +1182,7 @@ class Waveform(PlotBase):
|
||||
QTimer.singleShot(
|
||||
150, self.auto_range
|
||||
) # autorange with a delay to ensure the plot is updated
|
||||
self._refresh_alignment_state()
|
||||
|
||||
return curve
|
||||
|
||||
@@ -1257,6 +1438,7 @@ class Waveform(PlotBase):
|
||||
self.remove_curve(curve.name())
|
||||
if self.crosshair is not None:
|
||||
self.crosshair.clear_markers()
|
||||
self._refresh_alignment_state()
|
||||
|
||||
def get_curve(self, curve: int | str) -> Curve | None:
|
||||
"""
|
||||
@@ -1292,6 +1474,7 @@ class Waveform(PlotBase):
|
||||
|
||||
self._refresh_colors()
|
||||
self._categorise_device_curves()
|
||||
self._refresh_alignment_state()
|
||||
|
||||
def _remove_curve_by_name(self, name: str):
|
||||
"""
|
||||
@@ -1342,6 +1525,8 @@ class Waveform(PlotBase):
|
||||
and self.enable_side_panel is True
|
||||
):
|
||||
self.dap_summary.remove_dap_data(curve.name())
|
||||
if curve.config.source == "dap" and self._alignment_controller is not None:
|
||||
self._alignment_controller.remove_dap_curve(curve.name())
|
||||
|
||||
# find a corresponding dap curve and remove it
|
||||
for c in self.curves:
|
||||
@@ -1983,6 +2168,7 @@ class Waveform(PlotBase):
|
||||
"""
|
||||
x_data = None
|
||||
new_suffix = None
|
||||
previous_x_device = self._current_x_device
|
||||
data, access_key = self._fetch_scan_data_and_access()
|
||||
|
||||
# 1 User wants custom signal
|
||||
@@ -2041,6 +2227,7 @@ class Waveform(PlotBase):
|
||||
if not scan_report_devices:
|
||||
x_data = None
|
||||
new_suffix = " (auto: index)"
|
||||
self._current_x_device = None
|
||||
else:
|
||||
device_x = scan_report_devices[0]
|
||||
signal_x = self.entry_validator.validate_signal(device_x, None)
|
||||
@@ -2050,8 +2237,10 @@ class Waveform(PlotBase):
|
||||
entry_obj = data.get(device_x, {}).get(signal_x)
|
||||
x_data = entry_obj.read()["value"] if entry_obj else None
|
||||
new_suffix = f" (auto: {device_x}-{signal_x})"
|
||||
self._current_x_device = (device_x, signal_x)
|
||||
self._current_x_device = (device_x, signal_x)
|
||||
self._update_x_label_suffix(new_suffix)
|
||||
if previous_x_device != self._current_x_device:
|
||||
self._refresh_alignment_state(force_readback=True)
|
||||
return x_data
|
||||
|
||||
def _update_x_label_suffix(self, new_suffix: str):
|
||||
@@ -2096,7 +2285,7 @@ class Waveform(PlotBase):
|
||||
|
||||
def _categorise_device_curves(self) -> str:
|
||||
"""
|
||||
Categorise the device curves into sync and async based on the readout priority.
|
||||
Categorize the device curves into sync and async based on the readout priority.
|
||||
"""
|
||||
if self.scan_item is None:
|
||||
self.update_with_scan_history(-1)
|
||||
@@ -2453,6 +2642,8 @@ class Waveform(PlotBase):
|
||||
Cleanup the widget by disconnecting signals and closing dialogs.
|
||||
"""
|
||||
self.proxy_dap_request.cleanup()
|
||||
if self._alignment_controller is not None:
|
||||
self._alignment_controller.cleanup()
|
||||
self.clear_all()
|
||||
if self.curve_settings_dialog is not None:
|
||||
self.curve_settings_dialog.reject()
|
||||
|
||||
@@ -9,7 +9,8 @@ from qtpy import QtCore, QtGui
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtWidgets import QWidget
|
||||
|
||||
from bec_widgets.utils.bec_connector import BECConnector, ConnectionConfig
|
||||
from bec_widgets import BECWidget
|
||||
from bec_widgets.utils.bec_connector import ConnectionConfig
|
||||
from bec_widgets.utils.colors import Colors
|
||||
from bec_widgets.utils.error_popups import SafeProperty, SafeSlot
|
||||
|
||||
@@ -40,7 +41,7 @@ class ProgressbarConfig(ConnectionConfig):
|
||||
line_width: int = Field(20, description="Line widths for the progress bars.")
|
||||
start_position: int = Field(
|
||||
90,
|
||||
description="Start position for the progress bars in degrees. Default is 90 degrees - corespons to "
|
||||
description="Start position for the progress bars in degrees. Default is 90 degrees - corresponds to "
|
||||
"the top of the ring.",
|
||||
)
|
||||
min_value: int | float = Field(0, description="Minimum value for the progress bars.")
|
||||
@@ -59,7 +60,7 @@ class ProgressbarConfig(ConnectionConfig):
|
||||
)
|
||||
|
||||
|
||||
class Ring(BECConnector, QWidget):
|
||||
class Ring(BECWidget, QWidget):
|
||||
USER_ACCESS = [
|
||||
"set_value",
|
||||
"set_color",
|
||||
@@ -82,8 +83,26 @@ class Ring(BECConnector, QWidget):
|
||||
self.registered_slot: tuple[Callable, str | EndpointInfo] | None = None
|
||||
self.RID = None
|
||||
self._gap = 5
|
||||
self._hovered = False
|
||||
self._hover_progress = 0.0
|
||||
self._hover_animation = QtCore.QPropertyAnimation(self, b"hover_progress", parent=self)
|
||||
self._hover_animation.setDuration(180)
|
||||
easing_curve = (
|
||||
QtCore.QEasingCurve.Type.OutCubic
|
||||
if hasattr(QtCore.QEasingCurve, "Type")
|
||||
else QtCore.QEasingCurve.Type.OutCubic
|
||||
)
|
||||
self._hover_animation.setEasingCurve(easing_curve)
|
||||
self.set_start_angle(self.config.start_position)
|
||||
|
||||
def _request_update(self, *, refresh_tooltip: bool = True):
|
||||
# NOTE why not just overwrite update() to always refresh the tooltip?
|
||||
# Because in some cases (e.g. hover animation) we want to update the widget without refreshing the tooltip, to avoid performance issues.
|
||||
if refresh_tooltip:
|
||||
if self.progress_container and self.progress_container.is_ring_hovered(self):
|
||||
self.progress_container.refresh_hover_tooltip(self)
|
||||
self.update()
|
||||
|
||||
def set_value(self, value: int | float):
|
||||
"""
|
||||
Set the value for the ring widget
|
||||
@@ -107,7 +126,7 @@ class Ring(BECConnector, QWidget):
|
||||
if self.config.link_colors:
|
||||
self._auto_set_background_color()
|
||||
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
def set_background(self, color: str | tuple | QColor):
|
||||
"""
|
||||
@@ -122,7 +141,7 @@ class Ring(BECConnector, QWidget):
|
||||
|
||||
self._background_color = self.convert_color(color)
|
||||
self.config.background_color = self._background_color.name()
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
def _auto_set_background_color(self):
|
||||
"""
|
||||
@@ -133,7 +152,7 @@ class Ring(BECConnector, QWidget):
|
||||
bg_color = Colors.subtle_background_color(self._color, bg)
|
||||
self.config.background_color = bg_color.name()
|
||||
self._background_color = bg_color
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
def set_colors_linked(self, linked: bool):
|
||||
"""
|
||||
@@ -146,7 +165,7 @@ class Ring(BECConnector, QWidget):
|
||||
self.config.link_colors = linked
|
||||
if linked:
|
||||
self._auto_set_background_color()
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
def set_line_width(self, width: int):
|
||||
"""
|
||||
@@ -156,7 +175,7 @@ class Ring(BECConnector, QWidget):
|
||||
width(int): Line width for the ring widget
|
||||
"""
|
||||
self.config.line_width = width
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
def set_min_max_values(self, min_value: int | float, max_value: int | float):
|
||||
"""
|
||||
@@ -168,7 +187,7 @@ class Ring(BECConnector, QWidget):
|
||||
"""
|
||||
self.config.min_value = min_value
|
||||
self.config.max_value = max_value
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
def set_start_angle(self, start_angle: int):
|
||||
"""
|
||||
@@ -178,7 +197,7 @@ class Ring(BECConnector, QWidget):
|
||||
start_angle(int): Start angle for the ring widget in degrees
|
||||
"""
|
||||
self.config.start_position = start_angle
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
def set_update(
|
||||
self, mode: Literal["manual", "scan", "device"], device: str = "", signal: str = ""
|
||||
@@ -237,7 +256,7 @@ class Ring(BECConnector, QWidget):
|
||||
precision(int): Precision for the ring widget
|
||||
"""
|
||||
self.config.precision = precision
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
def set_direction(self, direction: int):
|
||||
"""
|
||||
@@ -247,7 +266,7 @@ class Ring(BECConnector, QWidget):
|
||||
direction(int): Direction for the ring widget. -1 for clockwise, 1 for counter-clockwise.
|
||||
"""
|
||||
self.config.direction = direction
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
def _get_signals_for_device(self, device: str) -> dict[str, list[str]]:
|
||||
"""
|
||||
@@ -424,8 +443,11 @@ class Ring(BECConnector, QWidget):
|
||||
rect.adjust(max_ring_size, max_ring_size, -max_ring_size, -max_ring_size)
|
||||
|
||||
# Background arc
|
||||
base_line_width = float(self.config.line_width)
|
||||
hover_line_delta = min(3.0, round(base_line_width * 0.6, 1))
|
||||
current_line_width = base_line_width + (hover_line_delta * self._hover_progress)
|
||||
painter.setPen(
|
||||
QtGui.QPen(self._background_color, self.config.line_width, QtCore.Qt.PenStyle.SolidLine)
|
||||
QtGui.QPen(self._background_color, current_line_width, QtCore.Qt.PenStyle.SolidLine)
|
||||
)
|
||||
|
||||
gap: int = self.gap # type: ignore
|
||||
@@ -433,13 +455,25 @@ class Ring(BECConnector, QWidget):
|
||||
# Important: Qt uses a 16th of a degree for angles. start_position is therefore multiplied by 16.
|
||||
start_position: float = self.config.start_position * 16 # type: ignore
|
||||
|
||||
adjusted_rect = QtCore.QRect(
|
||||
adjusted_rect = QtCore.QRectF(
|
||||
rect.left() + gap, rect.top() + gap, rect.width() - 2 * gap, rect.height() - 2 * gap
|
||||
)
|
||||
if self._hover_progress > 0.0:
|
||||
hover_radius_delta = 4.0
|
||||
base_radius = adjusted_rect.width() / 2
|
||||
if base_radius > 0:
|
||||
target_radius = base_radius + (hover_radius_delta * self._hover_progress)
|
||||
scale = target_radius / base_radius
|
||||
center = adjusted_rect.center()
|
||||
new_width = adjusted_rect.width() * scale
|
||||
new_height = adjusted_rect.height() * scale
|
||||
adjusted_rect = QtCore.QRectF(
|
||||
center.x() - new_width / 2, center.y() - new_height / 2, new_width, new_height
|
||||
)
|
||||
painter.drawArc(adjusted_rect, start_position, 360 * 16)
|
||||
|
||||
# Foreground arc
|
||||
pen = QtGui.QPen(self.color, self.config.line_width, QtCore.Qt.PenStyle.SolidLine)
|
||||
pen = QtGui.QPen(self.color, current_line_width, QtCore.Qt.PenStyle.SolidLine)
|
||||
pen.setCapStyle(QtCore.Qt.PenCapStyle.RoundCap)
|
||||
painter.setPen(pen)
|
||||
proportion = (self.config.value - self.config.min_value) / (
|
||||
@@ -449,7 +483,17 @@ class Ring(BECConnector, QWidget):
|
||||
painter.drawArc(adjusted_rect, start_position, angle)
|
||||
painter.end()
|
||||
|
||||
def convert_color(self, color: str | tuple | QColor) -> QColor:
|
||||
def set_hovered(self, hovered: bool):
|
||||
if hovered == self._hovered:
|
||||
return
|
||||
self._hovered = hovered
|
||||
self._hover_animation.stop()
|
||||
self._hover_animation.setStartValue(self._hover_progress)
|
||||
self._hover_animation.setEndValue(1.0 if hovered else 0.0)
|
||||
self._hover_animation.start()
|
||||
|
||||
@staticmethod
|
||||
def convert_color(color: str | tuple | QColor) -> QColor:
|
||||
"""
|
||||
Convert the color to QColor
|
||||
|
||||
@@ -485,7 +529,7 @@ class Ring(BECConnector, QWidget):
|
||||
@gap.setter
|
||||
def gap(self, value: int):
|
||||
self._gap = value
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(bool)
|
||||
def link_colors(self) -> bool:
|
||||
@@ -522,7 +566,7 @@ class Ring(BECConnector, QWidget):
|
||||
float(max(self.config.min_value, min(self.config.max_value, value))),
|
||||
self.config.precision,
|
||||
)
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(float)
|
||||
def min_value(self) -> float:
|
||||
@@ -531,7 +575,7 @@ class Ring(BECConnector, QWidget):
|
||||
@min_value.setter
|
||||
def min_value(self, value: float):
|
||||
self.config.min_value = value
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(float)
|
||||
def max_value(self) -> float:
|
||||
@@ -540,7 +584,7 @@ class Ring(BECConnector, QWidget):
|
||||
@max_value.setter
|
||||
def max_value(self, value: float):
|
||||
self.config.max_value = value
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(str)
|
||||
def mode(self) -> str:
|
||||
@@ -549,6 +593,7 @@ class Ring(BECConnector, QWidget):
|
||||
@mode.setter
|
||||
def mode(self, value: str):
|
||||
self.set_update(value)
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(str)
|
||||
def device(self) -> str:
|
||||
@@ -557,6 +602,7 @@ class Ring(BECConnector, QWidget):
|
||||
@device.setter
|
||||
def device(self, value: str):
|
||||
self.config.device = value
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(str)
|
||||
def signal(self) -> str:
|
||||
@@ -565,6 +611,7 @@ class Ring(BECConnector, QWidget):
|
||||
@signal.setter
|
||||
def signal(self, value: str):
|
||||
self.config.signal = value
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(int)
|
||||
def line_width(self) -> int:
|
||||
@@ -573,7 +620,7 @@ class Ring(BECConnector, QWidget):
|
||||
@line_width.setter
|
||||
def line_width(self, value: int):
|
||||
self.config.line_width = value
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(int)
|
||||
def start_position(self) -> int:
|
||||
@@ -582,7 +629,7 @@ class Ring(BECConnector, QWidget):
|
||||
@start_position.setter
|
||||
def start_position(self, value: int):
|
||||
self.config.start_position = value
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(int)
|
||||
def precision(self) -> int:
|
||||
@@ -591,7 +638,7 @@ class Ring(BECConnector, QWidget):
|
||||
@precision.setter
|
||||
def precision(self, value: int):
|
||||
self.config.precision = value
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(int)
|
||||
def direction(self) -> int:
|
||||
@@ -600,7 +647,27 @@ class Ring(BECConnector, QWidget):
|
||||
@direction.setter
|
||||
def direction(self, value: int):
|
||||
self.config.direction = value
|
||||
self.update()
|
||||
self._request_update()
|
||||
|
||||
@SafeProperty(float)
|
||||
def hover_progress(self) -> float:
|
||||
return self._hover_progress
|
||||
|
||||
@hover_progress.setter
|
||||
def hover_progress(self, value: float):
|
||||
self._hover_progress = value
|
||||
self._request_update(refresh_tooltip=False)
|
||||
|
||||
def cleanup(self):
|
||||
"""
|
||||
Cleanup the ring widget.
|
||||
Disconnect any registered slots.
|
||||
"""
|
||||
if self.registered_slot is not None:
|
||||
self.bec_dispatcher.disconnect_slot(*self.registered_slot)
|
||||
self.registered_slot = None
|
||||
self._hover_animation.stop()
|
||||
super().cleanup()
|
||||
|
||||
|
||||
if __name__ == "__main__": # pragma: no cover
|
||||
|
||||
@@ -3,7 +3,7 @@ from typing import Literal
|
||||
|
||||
import pyqtgraph as pg
|
||||
from bec_lib.logger import bec_logger
|
||||
from qtpy.QtCore import QSize, Qt
|
||||
from qtpy.QtCore import QPointF, QSize, Qt
|
||||
from qtpy.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
|
||||
|
||||
from bec_widgets.utils import Colors
|
||||
@@ -12,6 +12,7 @@ from bec_widgets.utils.error_popups import SafeProperty
|
||||
from bec_widgets.utils.settings_dialog import SettingsDialog
|
||||
from bec_widgets.utils.toolbars.actions import MaterialIconAction
|
||||
from bec_widgets.utils.toolbars.toolbar import ModularToolBar
|
||||
from bec_widgets.widgets.containers.main_window.addons.hover_widget import WidgetTooltip
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring import Ring
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_settings_cards import RingSettings
|
||||
|
||||
@@ -29,7 +30,16 @@ class RingProgressContainerWidget(QWidget):
|
||||
self.rings: list[Ring] = []
|
||||
self.gap = 20 # Gap between rings
|
||||
self.color_map: str = "turbo"
|
||||
self._hovered_ring: Ring | None = None
|
||||
self._last_hover_global_pos = None
|
||||
self._hover_tooltip_label = QLabel()
|
||||
self._hover_tooltip_label.setWordWrap(True)
|
||||
self._hover_tooltip_label.setTextFormat(Qt.TextFormat.PlainText)
|
||||
self._hover_tooltip_label.setMaximumWidth(260)
|
||||
self._hover_tooltip_label.setStyleSheet("font-size: 12px;")
|
||||
self._hover_tooltip = WidgetTooltip(self._hover_tooltip_label)
|
||||
self.setLayout(QHBoxLayout())
|
||||
self.setMouseTracking(True)
|
||||
self.initialize_bars()
|
||||
self.initialize_center_label()
|
||||
|
||||
@@ -59,6 +69,7 @@ class RingProgressContainerWidget(QWidget):
|
||||
"""
|
||||
ring = Ring(parent=self)
|
||||
ring.setGeometry(self.rect())
|
||||
ring.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||
ring.gap = self.gap * len(self.rings)
|
||||
ring.set_value(0)
|
||||
self.rings.append(ring)
|
||||
@@ -88,6 +99,10 @@ class RingProgressContainerWidget(QWidget):
|
||||
index = self.num_bars - 1
|
||||
index = self._validate_index(index)
|
||||
ring = self.rings[index]
|
||||
if ring is self._hovered_ring:
|
||||
self._hovered_ring = None
|
||||
self._last_hover_global_pos = None
|
||||
self._hover_tooltip.hide()
|
||||
ring.cleanup()
|
||||
ring.close()
|
||||
ring.deleteLater()
|
||||
@@ -106,6 +121,7 @@ class RingProgressContainerWidget(QWidget):
|
||||
|
||||
self.center_label = QLabel("", parent=self)
|
||||
self.center_label.setAlignment(Qt.AlignmentFlag.AlignCenter)
|
||||
self.center_label.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents, True)
|
||||
layout.addWidget(self.center_label)
|
||||
|
||||
def _calculate_minimum_size(self):
|
||||
@@ -150,6 +166,130 @@ class RingProgressContainerWidget(QWidget):
|
||||
for ring in self.rings:
|
||||
ring.setGeometry(self.rect())
|
||||
|
||||
def enterEvent(self, event):
|
||||
self.setMouseTracking(True)
|
||||
super().enterEvent(event)
|
||||
|
||||
def mouseMoveEvent(self, event):
|
||||
pos = event.position() if hasattr(event, "position") else QPointF(event.pos())
|
||||
self._last_hover_global_pos = (
|
||||
event.globalPosition().toPoint()
|
||||
if hasattr(event, "globalPosition")
|
||||
else event.globalPos()
|
||||
)
|
||||
ring = self._ring_at_pos(pos)
|
||||
self._set_hovered_ring(ring, event)
|
||||
super().mouseMoveEvent(event)
|
||||
|
||||
def leaveEvent(self, event):
|
||||
self._last_hover_global_pos = None
|
||||
self._set_hovered_ring(None, event)
|
||||
super().leaveEvent(event)
|
||||
|
||||
def _set_hovered_ring(self, ring: Ring | None, event=None):
|
||||
if ring is self._hovered_ring:
|
||||
if ring is not None:
|
||||
self.refresh_hover_tooltip(ring, event)
|
||||
return
|
||||
if self._hovered_ring is not None:
|
||||
self._hovered_ring.set_hovered(False)
|
||||
self._hovered_ring = ring
|
||||
if self._hovered_ring is not None:
|
||||
self._hovered_ring.set_hovered(True)
|
||||
self.refresh_hover_tooltip(self._hovered_ring, event)
|
||||
else:
|
||||
self._hover_tooltip.hide()
|
||||
|
||||
def _ring_at_pos(self, pos: QPointF) -> Ring | None:
|
||||
if not self.rings:
|
||||
return None
|
||||
size = min(self.width(), self.height())
|
||||
if size <= 0:
|
||||
return None
|
||||
x_offset = (self.width() - size) / 2
|
||||
y_offset = (self.height() - size) / 2
|
||||
center_x = x_offset + size / 2
|
||||
center_y = y_offset + size / 2
|
||||
dx = pos.x() - center_x
|
||||
dy = pos.y() - center_y
|
||||
distance = (dx * dx + dy * dy) ** 0.5
|
||||
|
||||
max_ring_size = self.get_max_ring_size()
|
||||
base_radius = (size - 2 * max_ring_size) / 2
|
||||
if base_radius <= 0:
|
||||
return None
|
||||
|
||||
best_ring: Ring | None = None
|
||||
best_delta: float | None = None
|
||||
for ring in self.rings:
|
||||
radius = base_radius - ring.gap
|
||||
if radius <= 0:
|
||||
continue
|
||||
half_width = ring.config.line_width / 2
|
||||
inner = radius - half_width
|
||||
outer = radius + half_width
|
||||
if inner <= distance <= outer:
|
||||
delta = abs(distance - radius)
|
||||
if best_delta is None or delta < best_delta:
|
||||
best_delta = delta
|
||||
best_ring = ring
|
||||
|
||||
return best_ring
|
||||
|
||||
def is_ring_hovered(self, ring: Ring) -> bool:
|
||||
return ring is self._hovered_ring
|
||||
|
||||
def refresh_hover_tooltip(self, ring: Ring, event=None):
|
||||
text = self._build_tooltip_text(ring)
|
||||
if event is not None:
|
||||
self._last_hover_global_pos = (
|
||||
event.globalPosition().toPoint()
|
||||
if hasattr(event, "globalPosition")
|
||||
else event.globalPos()
|
||||
)
|
||||
if self._last_hover_global_pos is None:
|
||||
return
|
||||
self._hover_tooltip_label.setText(text)
|
||||
self._hover_tooltip.apply_theme()
|
||||
self._hover_tooltip.show_near(self._last_hover_global_pos)
|
||||
|
||||
@staticmethod
|
||||
def _build_tooltip_text(ring: Ring) -> str:
|
||||
mode = ring.config.mode
|
||||
mode_label = {"manual": "Manual", "scan": "Scan progress", "device": "Device"}.get(
|
||||
mode, mode
|
||||
)
|
||||
|
||||
precision = int(ring.config.precision)
|
||||
value = ring.config.value
|
||||
min_value = ring.config.min_value
|
||||
max_value = ring.config.max_value
|
||||
range_span = max(max_value - min_value, 1e-9)
|
||||
progress = max(0.0, min(100.0, ((value - min_value) / range_span) * 100))
|
||||
|
||||
lines = [
|
||||
f"Mode: {mode_label}",
|
||||
f"Progress: {value:.{precision}f} / {max_value:.{precision}f} ({progress:.1f}%)",
|
||||
]
|
||||
if min_value != 0:
|
||||
lines.append(f"Range: {min_value:.{precision}f} -> {max_value:.{precision}f}")
|
||||
if mode == "device" and ring.config.device:
|
||||
if ring.config.signal:
|
||||
lines.append(f"Device: {ring.config.device}:{ring.config.signal}")
|
||||
else:
|
||||
lines.append(f"Device: {ring.config.device}")
|
||||
|
||||
return "\n".join(lines)
|
||||
|
||||
def closeEvent(self, event):
|
||||
# Ensure the hover tooltip is properly cleaned up when this widget closes
|
||||
tooltip = getattr(self, "_hover_tooltip", None)
|
||||
if tooltip is not None:
|
||||
tooltip.close()
|
||||
tooltip.deleteLater()
|
||||
self._hover_tooltip = None
|
||||
super().closeEvent(event)
|
||||
|
||||
def set_colors_from_map(self, colormap, color_format: Literal["RGB", "HEX"] = "RGB"):
|
||||
"""
|
||||
Set the colors for the progress bars from a colormap.
|
||||
@@ -230,6 +370,9 @@ class RingProgressContainerWidget(QWidget):
|
||||
"""
|
||||
Clear all rings from the widget.
|
||||
"""
|
||||
self._hovered_ring = None
|
||||
self._last_hover_global_pos = None
|
||||
self._hover_tooltip.hide()
|
||||
for ring in self.rings:
|
||||
ring.close()
|
||||
ring.deleteLater()
|
||||
|
||||
@@ -63,7 +63,8 @@ class RingCardWidget(QFrame):
|
||||
self.mode_combo.setCurrentText(self._get_display_mode_string(self.ring.config.mode))
|
||||
self._set_widget_mode_enabled(self.ring.config.mode)
|
||||
|
||||
def _get_theme_color(self, color_name: str) -> QColor | None:
|
||||
@staticmethod
|
||||
def _get_theme_color(color_name: str) -> QColor | None:
|
||||
app = QApplication.instance()
|
||||
if not app:
|
||||
return
|
||||
@@ -249,12 +250,13 @@ class RingCardWidget(QFrame):
|
||||
def _on_signal_changed(self, signal: str):
|
||||
device = self.ui.device_combo_box.currentText()
|
||||
signal = self.ui.signal_combo_box.get_signal_name()
|
||||
if not device or device not in self.container.bec_dispatcher.client.device_manager.devices:
|
||||
if not device or device not in self.ring.bec_dispatcher.client.device_manager.devices:
|
||||
return
|
||||
self.ring.set_update("device", device=device, signal=signal)
|
||||
self.ring.config.signal = signal
|
||||
|
||||
def _unify_mode_string(self, mode: str) -> str:
|
||||
@staticmethod
|
||||
def _unify_mode_string(mode: str) -> str:
|
||||
"""Convert mode string to a unified format"""
|
||||
mode = mode.lower()
|
||||
if mode == "scan progress":
|
||||
@@ -263,7 +265,8 @@ class RingCardWidget(QFrame):
|
||||
return "device"
|
||||
return mode
|
||||
|
||||
def _get_display_mode_string(self, mode: str) -> str:
|
||||
@staticmethod
|
||||
def _get_display_mode_string(mode: str) -> str:
|
||||
"""Convert mode string to display format"""
|
||||
match mode:
|
||||
case "manual":
|
||||
|
||||
@@ -251,7 +251,7 @@ class BECAtlasAdminView(BECWidget, QWidget):
|
||||
def __init__(
|
||||
self,
|
||||
parent=None,
|
||||
atlas_url: str = "https://bec-atlas-dev.psi.ch/api/v1",
|
||||
atlas_url: str = "https://bec-atlas-prod.psi.ch/api/v1",
|
||||
client=None,
|
||||
**kwargs,
|
||||
):
|
||||
|
||||
@@ -142,6 +142,17 @@ class BECAtlasHTTPService(QWidget):
|
||||
if self._auth_user_info is not None:
|
||||
self._auth_user_info.groups = set(groups)
|
||||
|
||||
def __check_access_to_owner_groups(self, groups: list[str]) -> bool:
|
||||
"""Check if the authenticated user has access to the current deployment based on their groups."""
|
||||
if self._auth_user_info is None or self._current_deployment_info is None:
|
||||
return False
|
||||
# Admin user
|
||||
has_both = {"admin", "atlas_func_account"}.issubset(groups)
|
||||
if has_both:
|
||||
return True
|
||||
# Regular user check with group intersection
|
||||
return not self.auth_user_info.groups.isdisjoint(groups)
|
||||
|
||||
def __clear_login_info(self, skip_logout: bool = False):
|
||||
"""Clear the authenticated user information after logout."""
|
||||
self._auth_user_info = None
|
||||
@@ -231,9 +242,7 @@ class BECAtlasHTTPService(QWidget):
|
||||
)
|
||||
elif AtlasEndpoints.DEPLOYMENT_INFO.value in request_url:
|
||||
owner_groups = data.get("owner_groups", [])
|
||||
if self.auth_user_info is not None and not self.auth_user_info.groups.isdisjoint(
|
||||
owner_groups
|
||||
):
|
||||
if self.__check_access_to_owner_groups(owner_groups):
|
||||
self.authenticated.emit(self.auth_user_info.model_dump())
|
||||
else:
|
||||
if self.auth_user_info is not None:
|
||||
|
||||
@@ -4,7 +4,7 @@ build-backend = "hatchling.build"
|
||||
|
||||
[project]
|
||||
name = "bec_widgets"
|
||||
version = "3.3.4"
|
||||
version = "3.4.2"
|
||||
description = "BEC Widgets"
|
||||
requires-python = ">=3.11"
|
||||
classifiers = [
|
||||
|
||||
156
tests/unit_tests/test_alignment_controller.py
Normal file
156
tests/unit_tests/test_alignment_controller.py
Normal file
@@ -0,0 +1,156 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import numpy as np
|
||||
import pyqtgraph as pg
|
||||
|
||||
from bec_widgets.widgets.plots.waveform.utils.alignment_controller import (
|
||||
AlignmentContext,
|
||||
WaveformAlignmentController,
|
||||
)
|
||||
from bec_widgets.widgets.plots.waveform.utils.alignment_panel import WaveformAlignmentPanel
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
from .conftest import create_widget
|
||||
from .test_waveform import make_alignment_fit_summary
|
||||
|
||||
|
||||
def create_alignment_controller(qtbot, mocked_client):
|
||||
plot_widget = pg.PlotWidget()
|
||||
qtbot.addWidget(plot_widget)
|
||||
panel = create_widget(qtbot, WaveformAlignmentPanel, client=mocked_client)
|
||||
controller = WaveformAlignmentController(plot_widget.plotItem, panel, parent=plot_widget)
|
||||
return plot_widget, panel, controller
|
||||
|
||||
|
||||
def test_alignment_controller_shows_marker_only_when_visible(qtbot, mocked_client):
|
||||
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
|
||||
|
||||
controller.update_context(
|
||||
AlignmentContext(visible=False, positioner_name="samx", precision=3, readback=1.0)
|
||||
)
|
||||
controller.update_position(4.2)
|
||||
assert controller.marker_line is None
|
||||
|
||||
controller.update_context(
|
||||
AlignmentContext(visible=True, positioner_name="samx", precision=3, readback=4.2)
|
||||
)
|
||||
|
||||
assert controller.marker_line is not None
|
||||
assert np.isclose(controller.marker_line.value(), 4.2)
|
||||
assert panel.target_toggle.isEnabled() is True
|
||||
|
||||
controller.update_context(AlignmentContext(visible=False, positioner_name="samx"))
|
||||
assert controller.marker_line is None
|
||||
|
||||
|
||||
def test_alignment_controller_target_line_uses_readback_and_limits(qtbot, mocked_client):
|
||||
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
|
||||
|
||||
controller.update_context(
|
||||
AlignmentContext(
|
||||
visible=True, positioner_name="samx", precision=3, limits=(0.0, 2.0), readback=5.0
|
||||
)
|
||||
)
|
||||
|
||||
panel.target_toggle.setChecked(True)
|
||||
assert controller.target_line is not None
|
||||
assert np.isclose(controller.target_line.value(), 2.0)
|
||||
|
||||
panel.target_toggle.setChecked(False)
|
||||
assert controller.target_line is None
|
||||
|
||||
controller.update_context(
|
||||
AlignmentContext(
|
||||
visible=True, positioner_name="samx", precision=3, limits=None, readback=5.0
|
||||
)
|
||||
)
|
||||
panel.target_toggle.setChecked(True)
|
||||
assert controller.target_line is not None
|
||||
assert np.isclose(controller.target_line.value(), 5.0)
|
||||
|
||||
|
||||
def test_alignment_controller_preserves_dragged_target_on_context_refresh(qtbot, mocked_client):
|
||||
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
|
||||
|
||||
controller.update_context(
|
||||
AlignmentContext(
|
||||
visible=True, positioner_name="samx", precision=3, limits=(0.0, 5.0), readback=1.0
|
||||
)
|
||||
)
|
||||
panel.target_toggle.setChecked(True)
|
||||
controller.target_line.setValue(3.0)
|
||||
|
||||
controller.update_context(
|
||||
AlignmentContext(
|
||||
visible=True, positioner_name="samx", precision=3, limits=(0.0, 5.0), readback=2.0
|
||||
)
|
||||
)
|
||||
|
||||
assert controller.target_line is not None
|
||||
assert np.isclose(controller.target_line.value(), 3.0)
|
||||
|
||||
|
||||
def test_alignment_controller_emits_move_request_for_fit_center(qtbot, mocked_client):
|
||||
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
|
||||
|
||||
move_callback = MagicMock()
|
||||
controller.move_absolute_requested.connect(move_callback)
|
||||
|
||||
controller.update_context(
|
||||
AlignmentContext(visible=True, positioner_name="samx", precision=3, readback=1.0)
|
||||
)
|
||||
controller.update_dap_summary(make_alignment_fit_summary(center=2.5), {"curve_id": "fit"})
|
||||
|
||||
assert panel.fit_dialog.action_buttons["center"].isEnabled() is True
|
||||
panel.fit_dialog.action_buttons["center"].click()
|
||||
|
||||
move_callback.assert_called_once_with(2.5)
|
||||
|
||||
|
||||
def test_alignment_controller_requests_autoscale_for_marker_and_target(qtbot, mocked_client):
|
||||
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
|
||||
|
||||
autoscale_callback = MagicMock()
|
||||
controller.autoscale_requested.connect(autoscale_callback)
|
||||
|
||||
controller.update_context(
|
||||
AlignmentContext(visible=True, positioner_name="samx", precision=3, readback=1.0)
|
||||
)
|
||||
panel.target_toggle.setChecked(True)
|
||||
|
||||
assert autoscale_callback.call_count >= 2
|
||||
|
||||
|
||||
def test_alignment_controller_emits_move_request_for_target(qtbot, mocked_client):
|
||||
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
|
||||
|
||||
move_callback = MagicMock()
|
||||
controller.move_absolute_requested.connect(move_callback)
|
||||
|
||||
controller.update_context(
|
||||
AlignmentContext(
|
||||
visible=True, positioner_name="samx", precision=3, limits=(0.0, 5.0), readback=1.0
|
||||
)
|
||||
)
|
||||
panel.target_toggle.setChecked(True)
|
||||
controller.target_line.setValue(1.25)
|
||||
panel.move_to_target_button.click()
|
||||
|
||||
move_callback.assert_called_once_with(1.25)
|
||||
|
||||
|
||||
def test_alignment_controller_removes_deleted_dap_curve(qtbot, mocked_client):
|
||||
_, panel, controller = create_alignment_controller(qtbot, mocked_client)
|
||||
|
||||
controller.update_context(
|
||||
AlignmentContext(visible=True, positioner_name="samx", precision=3, readback=1.0)
|
||||
)
|
||||
controller.update_dap_summary(make_alignment_fit_summary(center=1.5), {"curve_id": "fit"})
|
||||
|
||||
assert "fit" in panel.fit_dialog.summary_data
|
||||
|
||||
controller.remove_dap_curve("fit")
|
||||
|
||||
assert "fit" not in panel.fit_dialog.summary_data
|
||||
assert panel.fit_dialog.fit_curve_id is None
|
||||
assert panel.fit_dialog.enable_actions is False
|
||||
40
tests/unit_tests/test_alignment_panel.py
Normal file
40
tests/unit_tests/test_alignment_panel.py
Normal file
@@ -0,0 +1,40 @@
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
from bec_widgets.widgets.plots.waveform.utils.alignment_panel import WaveformAlignmentPanel
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
from .conftest import create_widget
|
||||
|
||||
|
||||
def test_alignment_panel_forwards_position_and_target_signals(qtbot, mocked_client):
|
||||
panel = create_widget(qtbot, WaveformAlignmentPanel, client=mocked_client)
|
||||
|
||||
position_callback = MagicMock()
|
||||
target_toggle_callback = MagicMock()
|
||||
target_move_callback = MagicMock()
|
||||
|
||||
panel.position_readback_changed.connect(position_callback)
|
||||
panel.target_toggled.connect(target_toggle_callback)
|
||||
panel.target_move_requested.connect(target_move_callback)
|
||||
|
||||
panel.positioner.position_update.emit(1.25)
|
||||
panel.target_toggle.setChecked(True)
|
||||
panel.move_to_target_button.setEnabled(True)
|
||||
panel.move_to_target_button.click()
|
||||
|
||||
position_callback.assert_called_once_with(1.25)
|
||||
target_toggle_callback.assert_called_once_with(True)
|
||||
target_move_callback.assert_called_once_with()
|
||||
|
||||
|
||||
def test_alignment_panel_emits_only_center_fit_actions(qtbot, mocked_client):
|
||||
panel = create_widget(qtbot, WaveformAlignmentPanel, client=mocked_client)
|
||||
|
||||
fit_center_callback = MagicMock()
|
||||
panel.fit_center_requested.connect(fit_center_callback)
|
||||
|
||||
panel.fit_dialog.move_action.emit(("sigma", 0.5))
|
||||
fit_center_callback.assert_not_called()
|
||||
|
||||
panel.fit_dialog.move_action.emit(("center", 2.5))
|
||||
fit_center_callback.assert_called_once_with(2.5)
|
||||
@@ -81,12 +81,95 @@ class _FakeReply:
|
||||
self.deleted = True
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def experiment_info_message() -> ExperimentInfoMessage:
|
||||
data = {
|
||||
"_id": "p22622",
|
||||
"owner_groups": ["admin"],
|
||||
"access_groups": ["unx-sls_xda_bs", "p22622"],
|
||||
"realm_id": "TestBeamline",
|
||||
"proposal": "12345967",
|
||||
"title": "Test Experiment for Mat Card Widget",
|
||||
"firstname": "John",
|
||||
"lastname": "Doe",
|
||||
"email": "john.doe@psi.ch",
|
||||
"account": "doe_j",
|
||||
"pi_firstname": "Jane",
|
||||
"pi_lastname": "Smith",
|
||||
"pi_email": "jane.smith@psi.ch",
|
||||
"pi_account": "smith_j",
|
||||
"eaccount": "e22622",
|
||||
"pgroup": "p22622",
|
||||
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
|
||||
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
|
||||
"proposal_submitted": "15/12/2024",
|
||||
"proposal_expire": "31/12/2025",
|
||||
"proposal_status": "Scheduled",
|
||||
"delta_last_schedule": 30,
|
||||
"mainproposal": "",
|
||||
}
|
||||
return ExperimentInfoMessage.model_validate(data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def experiment_info_list(experiment_info_message: ExperimentInfoMessage) -> list[dict]:
|
||||
"""Fixture to provide a list of experiment info dictionaries."""
|
||||
another_experiment_info = {
|
||||
"_id": "p22623",
|
||||
"owner_groups": ["admin"],
|
||||
"access_groups": ["unx-sls_xda_bs", "p22623"],
|
||||
"realm_id": "TestBeamline",
|
||||
"proposal": "",
|
||||
"title": "Experiment without Proposal",
|
||||
"firstname": "Alice",
|
||||
"lastname": "Johnson",
|
||||
"email": "alice.johnson@psi.ch",
|
||||
"account": "johnson_a",
|
||||
"pi_firstname": "Bob",
|
||||
"pi_lastname": "Brown",
|
||||
"pi_email": "bob.brown@psi.ch",
|
||||
"pi_account": "brown_b",
|
||||
"eaccount": "e22623",
|
||||
"pgroup": "p22623",
|
||||
"abstract": "",
|
||||
"schedule": [],
|
||||
"proposal_submitted": "",
|
||||
"proposal_expire": "",
|
||||
"proposal_status": "",
|
||||
"delta_last_schedule": None,
|
||||
"mainproposal": "",
|
||||
}
|
||||
return [
|
||||
experiment_info_message.model_dump(),
|
||||
ExperimentInfoMessage.model_validate(another_experiment_info).model_dump(),
|
||||
]
|
||||
|
||||
|
||||
class TestBECAtlasHTTPService:
|
||||
|
||||
@pytest.fixture
|
||||
def http_service(self, qtbot):
|
||||
def deployment_info(
|
||||
self, experiment_info_message: ExperimentInfoMessage
|
||||
) -> DeploymentInfoMessage:
|
||||
"""Fixture to provide a DeploymentInfoMessage instance."""
|
||||
return DeploymentInfoMessage(
|
||||
deployment_id="dep-1",
|
||||
name="Test Deployment",
|
||||
messaging_config=MessagingConfig(
|
||||
signal=MessagingServiceScopeConfig(enabled=False),
|
||||
teams=MessagingServiceScopeConfig(enabled=False),
|
||||
scilog=MessagingServiceScopeConfig(enabled=False),
|
||||
),
|
||||
active_session=SessionInfoMessage(
|
||||
experiment=experiment_info_message, name="Test Session"
|
||||
),
|
||||
)
|
||||
|
||||
@pytest.fixture
|
||||
def http_service(self, deployment_info: DeploymentInfoMessage, qtbot):
|
||||
"""Fixture to create a BECAtlasHTTPService instance."""
|
||||
service = BECAtlasHTTPService(base_url="http://localhost:8000")
|
||||
service._set_current_deployment_info(deployment_info)
|
||||
qtbot.addWidget(service)
|
||||
qtbot.waitExposed(service)
|
||||
return service
|
||||
@@ -224,7 +307,7 @@ class TestBECAtlasHTTPService:
|
||||
assert http_service.auth_user_info.groups == {"operators", "staff"}
|
||||
mock_get_deployment_info.assert_called_once_with(deployment_id="dep-1")
|
||||
|
||||
def test_handle_response_deployment_info(self, http_service, qtbot):
|
||||
def test_handle_response_deployment_info(self, http_service: BECAtlasHTTPService, qtbot):
|
||||
"""Test handling deployment info response"""
|
||||
|
||||
# Groups match: should emit authenticated signal with user info
|
||||
@@ -268,6 +351,25 @@ class TestBECAtlasHTTPService:
|
||||
mock_show_warning.assert_called_once()
|
||||
mock_logout.assert_called_once()
|
||||
|
||||
def test_handle_response_deployment_info_admin_access(self, http_service, qtbot):
|
||||
http_service._auth_user_info = AuthenticatedUserInfo(
|
||||
email="alice@example.org",
|
||||
exp=time.time() + 60,
|
||||
groups={"operators"},
|
||||
deployment_id="dep-1",
|
||||
)
|
||||
# Admin user should authenticate regardless of group membership
|
||||
reply = _FakeReply(
|
||||
request_url="http://localhost:8000/deployments/id?deployment_id=dep-1",
|
||||
status=200,
|
||||
payload=b'{"owner_groups": ["admin", "atlas_func_account"], "name": "Beamline Deployment"}',
|
||||
)
|
||||
|
||||
with qtbot.waitSignal(http_service.authenticated, timeout=1000) as blocker:
|
||||
http_service._handle_response(reply)
|
||||
|
||||
assert blocker.args[0]["email"] == "alice@example.org"
|
||||
|
||||
def test_handle_response_emits_http_response(self, http_service, qtbot):
|
||||
"""Test that _handle_response emits the http_response signal with correct parameters for a generic response."""
|
||||
reply = _FakeReply(
|
||||
@@ -297,70 +399,6 @@ class TestBECAtlasHTTPService:
|
||||
http_service._handle_response(reply, _override_slot_params={"raise_error": True})
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def experiment_info_message() -> ExperimentInfoMessage:
|
||||
data = {
|
||||
"_id": "p22622",
|
||||
"owner_groups": ["admin"],
|
||||
"access_groups": ["unx-sls_xda_bs", "p22622"],
|
||||
"realm_id": "TestBeamline",
|
||||
"proposal": "12345967",
|
||||
"title": "Test Experiment for Mat Card Widget",
|
||||
"firstname": "John",
|
||||
"lastname": "Doe",
|
||||
"email": "john.doe@psi.ch",
|
||||
"account": "doe_j",
|
||||
"pi_firstname": "Jane",
|
||||
"pi_lastname": "Smith",
|
||||
"pi_email": "jane.smith@psi.ch",
|
||||
"pi_account": "smith_j",
|
||||
"eaccount": "e22622",
|
||||
"pgroup": "p22622",
|
||||
"abstract": "This is a test abstract for the experiment mat card widget. It should be long enough to test text wrapping and display in the card. The abstract provides a brief overview of the experiment, its goals, and its significance. This text is meant to simulate a real abstract that might be associated with an experiment in the BEC Atlas system. The card should be able to handle abstracts of varying lengths without any issues, ensuring that the user can read the full abstract even if it is quite long.",
|
||||
"schedule": [{"start": "01/01/2025 08:00:00", "end": "03/01/2025 18:00:00"}],
|
||||
"proposal_submitted": "15/12/2024",
|
||||
"proposal_expire": "31/12/2025",
|
||||
"proposal_status": "Scheduled",
|
||||
"delta_last_schedule": 30,
|
||||
"mainproposal": "",
|
||||
}
|
||||
return ExperimentInfoMessage.model_validate(data)
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def experiment_info_list(experiment_info_message: ExperimentInfoMessage) -> list[dict]:
|
||||
"""Fixture to provide a list of experiment info dictionaries."""
|
||||
another_experiment_info = {
|
||||
"_id": "p22623",
|
||||
"owner_groups": ["admin"],
|
||||
"access_groups": ["unx-sls_xda_bs", "p22623"],
|
||||
"realm_id": "TestBeamline",
|
||||
"proposal": "",
|
||||
"title": "Experiment without Proposal",
|
||||
"firstname": "Alice",
|
||||
"lastname": "Johnson",
|
||||
"email": "alice.johnson@psi.ch",
|
||||
"account": "johnson_a",
|
||||
"pi_firstname": "Bob",
|
||||
"pi_lastname": "Brown",
|
||||
"pi_email": "bob.brown@psi.ch",
|
||||
"pi_account": "brown_b",
|
||||
"eaccount": "e22623",
|
||||
"pgroup": "p22623",
|
||||
"abstract": "",
|
||||
"schedule": [],
|
||||
"proposal_submitted": "",
|
||||
"proposal_expire": "",
|
||||
"proposal_status": "",
|
||||
"delta_last_schedule": None,
|
||||
"mainproposal": "",
|
||||
}
|
||||
return [
|
||||
experiment_info_message.model_dump(),
|
||||
ExperimentInfoMessage.model_validate(another_experiment_info).model_dump(),
|
||||
]
|
||||
|
||||
|
||||
class TestBECAtlasExperimentSelection:
|
||||
|
||||
def test_format_name(self, experiment_info_message: ExperimentInfoMessage):
|
||||
@@ -546,7 +584,7 @@ class TestBECAtlasAdminView:
|
||||
def test_init_and_login(self, admin_view: BECAtlasAdminView, qtbot):
|
||||
"""Test that the BECAtlasAdminView initializes correctly."""
|
||||
# Check that the atlas URL is set correctly
|
||||
assert admin_view._atlas_url == "https://bec-atlas-dev.psi.ch/api/v1"
|
||||
assert admin_view._atlas_url == "https://bec-atlas-prod.psi.ch/api/v1"
|
||||
|
||||
# Test that clicking the login button emits the credentials_entered signal with the correct username and password
|
||||
with mock.patch.object(admin_view.atlas_http_service, "login") as mock_login:
|
||||
|
||||
@@ -1,14 +1,18 @@
|
||||
# pylint: disable=missing-function-docstring, missing-module-docstring, unused-import
|
||||
|
||||
import json
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
import pytest
|
||||
from bec_lib.endpoints import MessageEndpoints
|
||||
from pydantic import ValidationError
|
||||
from qtpy.QtGui import QColor
|
||||
from qtpy.QtCore import QEvent, QPoint, QPointF, Qt
|
||||
from qtpy.QtGui import QColor, QMouseEvent
|
||||
from qtpy.QtWidgets import QApplication
|
||||
|
||||
from bec_widgets.utils import Colors
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import RingProgressBar
|
||||
from bec_widgets.widgets.progress.ring_progress_bar.ring_progress_bar import (
|
||||
RingProgressBar,
|
||||
RingProgressContainerWidget,
|
||||
)
|
||||
|
||||
from .client_mocks import mocked_client
|
||||
|
||||
@@ -432,8 +436,6 @@ def test_gap_affects_ring_positioning(ring_progress_bar):
|
||||
for _ in range(3):
|
||||
ring_progress_bar.add_ring()
|
||||
|
||||
initial_gap = ring_progress_bar.gap
|
||||
|
||||
# Change gap
|
||||
new_gap = 30
|
||||
ring_progress_bar.set_gap(new_gap)
|
||||
@@ -467,3 +469,275 @@ def test_rings_property_returns_correct_list(ring_progress_bar):
|
||||
# Should return the same list
|
||||
assert rings_via_property is rings_direct
|
||||
assert len(rings_via_property) == 3
|
||||
|
||||
|
||||
###################################
|
||||
# Hover behavior tests
|
||||
###################################
|
||||
|
||||
|
||||
@pytest.fixture
|
||||
def container(qtbot):
|
||||
widget = RingProgressContainerWidget()
|
||||
qtbot.addWidget(widget)
|
||||
widget.resize(200, 200)
|
||||
yield widget
|
||||
|
||||
|
||||
def _ring_center_pos(container):
|
||||
"""Return (center_x, center_y, base_radius) for a square container."""
|
||||
size = min(container.width(), container.height())
|
||||
center_x = container.width() / 2
|
||||
center_y = container.height() / 2
|
||||
max_ring_size = container.get_max_ring_size()
|
||||
base_radius = (size - 2 * max_ring_size) / 2
|
||||
return center_x, center_y, base_radius
|
||||
|
||||
|
||||
def _send_mouse_move(widget, pos: QPoint):
|
||||
global_pos = widget.mapToGlobal(pos)
|
||||
event = QMouseEvent(
|
||||
QEvent.Type.MouseMove,
|
||||
QPointF(pos),
|
||||
QPointF(global_pos),
|
||||
Qt.MouseButton.NoButton,
|
||||
Qt.MouseButton.NoButton,
|
||||
Qt.KeyboardModifier.NoModifier,
|
||||
)
|
||||
QApplication.sendEvent(widget, event)
|
||||
|
||||
|
||||
def test_ring_at_pos_no_rings(container):
|
||||
assert container._ring_at_pos(QPointF(100, 100)) is None
|
||||
|
||||
|
||||
def test_ring_at_pos_center_is_inside_rings(container):
|
||||
"""The center of the widget is inside all rings; _ring_at_pos should return None."""
|
||||
container.add_ring()
|
||||
assert container._ring_at_pos(QPointF(100, 100)) is None
|
||||
|
||||
|
||||
def test_ring_at_pos_on_single_ring(container):
|
||||
"""A point on the ring arc should resolve to that ring."""
|
||||
ring = container.add_ring()
|
||||
cx, cy, base_radius = _ring_center_pos(container)
|
||||
# Point exactly on the ring centerline
|
||||
pos = QPointF(cx + base_radius, cy)
|
||||
assert container._ring_at_pos(pos) is ring
|
||||
|
||||
|
||||
def test_ring_at_pos_outside_all_rings(container):
|
||||
"""A point well outside the outermost ring returns None."""
|
||||
container.add_ring()
|
||||
cx, cy, base_radius = _ring_center_pos(container)
|
||||
line_width = container.rings[0].config.line_width
|
||||
# Place point clearly beyond the outer edge
|
||||
pos = QPointF(cx + base_radius + line_width + 5, cy)
|
||||
assert container._ring_at_pos(pos) is None
|
||||
|
||||
|
||||
def test_ring_at_pos_selects_correct_ring_from_multiple(qtbot):
|
||||
"""With multiple rings, each position resolves to the right ring."""
|
||||
container = RingProgressContainerWidget()
|
||||
qtbot.addWidget(container)
|
||||
container.resize(300, 300)
|
||||
|
||||
ring0 = container.add_ring()
|
||||
ring1 = container.add_ring()
|
||||
|
||||
size = min(container.width(), container.height())
|
||||
cx = container.width() / 2
|
||||
cy = container.height() / 2
|
||||
max_ring_size = container.get_max_ring_size()
|
||||
base_radius = (size - 2 * max_ring_size) / 2
|
||||
|
||||
radius0 = base_radius - ring0.gap
|
||||
radius1 = base_radius - ring1.gap
|
||||
|
||||
assert container._ring_at_pos(QPointF(cx + radius0, cy)) is ring0
|
||||
assert container._ring_at_pos(QPointF(cx + radius1, cy)) is ring1
|
||||
|
||||
|
||||
def test_set_hovered_ring_sets_flag(container):
|
||||
"""_set_hovered_ring marks the ring as hovered and updates _hovered_ring."""
|
||||
ring = container.add_ring()
|
||||
assert container._hovered_ring is None
|
||||
container._set_hovered_ring(ring)
|
||||
assert container._hovered_ring is ring
|
||||
assert ring._hovered is True
|
||||
|
||||
|
||||
def test_set_hovered_ring_to_none_clears_flag(container):
|
||||
"""Calling _set_hovered_ring(None) un-hovers the current ring."""
|
||||
ring = container.add_ring()
|
||||
container._set_hovered_ring(ring)
|
||||
container._set_hovered_ring(None)
|
||||
assert container._hovered_ring is None
|
||||
assert ring._hovered is False
|
||||
|
||||
|
||||
def test_set_hovered_ring_switches_between_rings(qtbot):
|
||||
"""Switching hover from one ring to another correctly updates both flags."""
|
||||
container = RingProgressContainerWidget()
|
||||
qtbot.addWidget(container)
|
||||
ring0 = container.add_ring()
|
||||
ring1 = container.add_ring()
|
||||
|
||||
container._set_hovered_ring(ring0)
|
||||
assert ring0._hovered is True
|
||||
assert ring1._hovered is False
|
||||
|
||||
container._set_hovered_ring(ring1)
|
||||
assert ring0._hovered is False
|
||||
assert ring1._hovered is True
|
||||
assert container._hovered_ring is ring1
|
||||
|
||||
|
||||
def test_build_tooltip_text_manual_mode(container):
|
||||
"""Manual mode tooltip contains mode label, value, max and percentage."""
|
||||
ring = container.add_ring()
|
||||
ring.set_value(50)
|
||||
ring.set_min_max_values(0, 100)
|
||||
|
||||
text = RingProgressContainerWidget._build_tooltip_text(ring)
|
||||
assert "Manual" in text
|
||||
assert "50.0%" in text
|
||||
assert "100" in text
|
||||
|
||||
|
||||
def test_build_tooltip_text_scan_mode(container):
|
||||
"""Scan mode tooltip labels the mode as 'Scan progress'."""
|
||||
ring = container.add_ring()
|
||||
ring.config.mode = "scan"
|
||||
ring.set_value(25)
|
||||
|
||||
text = RingProgressContainerWidget._build_tooltip_text(ring)
|
||||
assert "Scan progress" in text
|
||||
|
||||
|
||||
def test_build_tooltip_text_device_mode_with_signal(container):
|
||||
"""Device mode tooltip shows device:signal when both are set."""
|
||||
ring = container.add_ring()
|
||||
ring.config.mode = "device"
|
||||
ring.config.device = "samx"
|
||||
ring.config.signal = "readback"
|
||||
ring.set_value(10)
|
||||
|
||||
text = RingProgressContainerWidget._build_tooltip_text(ring)
|
||||
assert "Device" in text
|
||||
assert "samx:readback" in text
|
||||
|
||||
|
||||
def test_build_tooltip_text_device_mode_without_signal(container):
|
||||
"""Device mode tooltip shows only device name when signal is absent."""
|
||||
ring = container.add_ring()
|
||||
ring.config.mode = "device"
|
||||
ring.config.device = "samy"
|
||||
ring.config.signal = None
|
||||
ring.set_value(10)
|
||||
|
||||
text = RingProgressContainerWidget._build_tooltip_text(ring)
|
||||
assert "samy" in text
|
||||
assert ":" not in text.split("Device:")[-1].split("\n")[0]
|
||||
|
||||
|
||||
def test_build_tooltip_text_nonzero_min_shows_range(container):
|
||||
"""Tooltip includes Range line when min_value is not 0."""
|
||||
ring = container.add_ring()
|
||||
ring.set_min_max_values(10, 90)
|
||||
ring.set_value(50)
|
||||
|
||||
text = RingProgressContainerWidget._build_tooltip_text(ring)
|
||||
assert "Range" in text
|
||||
|
||||
|
||||
def test_build_tooltip_text_zero_min_no_range(container):
|
||||
"""Tooltip omits Range line when min_value is 0."""
|
||||
ring = container.add_ring()
|
||||
ring.set_min_max_values(0, 100)
|
||||
ring.set_value(50)
|
||||
|
||||
text = RingProgressContainerWidget._build_tooltip_text(ring)
|
||||
assert "Range" not in text
|
||||
|
||||
|
||||
def test_refresh_hover_tooltip_updates_label_on_value_change(container):
|
||||
"""refresh_hover_tooltip updates the label text after the ring value changes."""
|
||||
ring = container.add_ring()
|
||||
ring.set_value(30)
|
||||
container._hovered_ring = ring
|
||||
container._last_hover_global_pos = QPoint(100, 100)
|
||||
|
||||
container.refresh_hover_tooltip(ring)
|
||||
text_before = container._hover_tooltip_label.text()
|
||||
|
||||
ring.set_value(70)
|
||||
container.refresh_hover_tooltip(ring)
|
||||
text_after = container._hover_tooltip_label.text()
|
||||
|
||||
assert text_before != text_after
|
||||
assert "70" in text_after
|
||||
|
||||
|
||||
def test_refresh_hover_tooltip_no_pos_does_not_crash(container):
|
||||
"""refresh_hover_tooltip with no stored position should return without raising."""
|
||||
ring = container.add_ring()
|
||||
container._last_hover_global_pos = None
|
||||
# Should not raise
|
||||
container.refresh_hover_tooltip(ring)
|
||||
|
||||
|
||||
def test_mouse_move_sets_hovered_ring_and_updates_tooltip(qtbot, container):
|
||||
ring = container.add_ring()
|
||||
container._hover_tooltip.show_near = MagicMock()
|
||||
container.show()
|
||||
qtbot.waitExposed(container)
|
||||
|
||||
cx, cy, base_radius = _ring_center_pos(container)
|
||||
_send_mouse_move(container, QPoint(int(cx + base_radius), int(cy)))
|
||||
|
||||
assert container._hovered_ring is ring
|
||||
assert ring._hovered is True
|
||||
assert "Mode: Manual" in container._hover_tooltip_label.text()
|
||||
container._hover_tooltip.show_near.assert_called_once()
|
||||
|
||||
|
||||
def test_mouse_move_switches_hover_between_rings(qtbot):
|
||||
container = RingProgressContainerWidget()
|
||||
qtbot.addWidget(container)
|
||||
container.resize(300, 300)
|
||||
ring0 = container.add_ring()
|
||||
ring1 = container.add_ring()
|
||||
container._hover_tooltip.show_near = MagicMock()
|
||||
container.show()
|
||||
qtbot.waitExposed(container)
|
||||
|
||||
cx, cy, base_radius = _ring_center_pos(container)
|
||||
radius0 = base_radius - ring0.gap
|
||||
radius1 = base_radius - ring1.gap
|
||||
_send_mouse_move(container, QPoint(int(cx + radius0), int(cy)))
|
||||
|
||||
assert container._hovered_ring is ring0
|
||||
|
||||
_send_mouse_move(container, QPoint(int(cx + radius1), int(cy)))
|
||||
|
||||
assert container._hovered_ring is ring1
|
||||
assert ring0._hovered is False
|
||||
assert ring1._hovered is True
|
||||
|
||||
|
||||
def test_leave_event_clears_hover_and_hides_tooltip(qtbot, container):
|
||||
ring = container.add_ring()
|
||||
container._hover_tooltip.hide = MagicMock()
|
||||
container.show()
|
||||
qtbot.waitExposed(container)
|
||||
|
||||
cx, cy, base_radius = _ring_center_pos(container)
|
||||
_send_mouse_move(container, QPoint(int(cx + base_radius), int(cy)))
|
||||
|
||||
QApplication.sendEvent(container, QEvent(QEvent.Type.Leave))
|
||||
|
||||
assert container._hovered_ring is None
|
||||
assert ring._hovered is False
|
||||
assert container._last_hover_global_pos is None
|
||||
container._hover_tooltip.hide.assert_called()
|
||||
|
||||
@@ -240,6 +240,36 @@ def test_set_start_angle(ring_widget):
|
||||
assert ring_widget.config.start_position == 180
|
||||
|
||||
|
||||
def test_set_hovered_updates_animation_target(ring_widget):
|
||||
ring_widget.set_hovered(True)
|
||||
|
||||
assert ring_widget._hovered is True
|
||||
assert ring_widget._hover_animation.endValue() == 1.0
|
||||
|
||||
ring_widget.set_hovered(False)
|
||||
|
||||
assert ring_widget._hovered is False
|
||||
assert ring_widget._hover_animation.endValue() == 0.0
|
||||
|
||||
|
||||
def test_refresh_hover_tooltip_delegates_to_container(ring_widget):
|
||||
ring_widget.progress_container = MagicMock()
|
||||
ring_widget.progress_container.is_ring_hovered.return_value = True
|
||||
|
||||
ring_widget._request_update()
|
||||
|
||||
ring_widget.progress_container.refresh_hover_tooltip.assert_called_once_with(ring_widget)
|
||||
|
||||
|
||||
def test_refresh_hover_tooltip_skips_when_ring_is_not_hovered(ring_widget):
|
||||
ring_widget.progress_container = MagicMock()
|
||||
ring_widget.progress_container.is_ring_hovered.return_value = False
|
||||
|
||||
ring_widget._request_update()
|
||||
|
||||
ring_widget.progress_container.refresh_hover_tooltip.assert_not_called()
|
||||
|
||||
|
||||
###################################
|
||||
# Color management tests
|
||||
###################################
|
||||
|
||||
@@ -36,6 +36,22 @@ from .conftest import create_widget
|
||||
##################################################
|
||||
|
||||
|
||||
def make_alignment_fit_summary(center: float | None = None) -> dict:
|
||||
params = []
|
||||
if center is not None:
|
||||
params.append(["center", center, True, None, -np.inf, np.inf, None, 0.1, {}, 0.0, None])
|
||||
params.append(["sigma", 0.5, True, None, 0.0, np.inf, None, 0.1, {}, 1.0, None])
|
||||
return {
|
||||
"model": "Model(test)",
|
||||
"method": "leastsq",
|
||||
"chisqr": 1.0,
|
||||
"redchi": 1.0,
|
||||
"rsquared": 0.99,
|
||||
"message": "Fit succeeded.",
|
||||
"params": params,
|
||||
}
|
||||
|
||||
|
||||
def test_waveform_initialization(qtbot, mocked_client):
|
||||
"""
|
||||
Test that a new Waveform widget initializes with the correct defaults.
|
||||
@@ -496,6 +512,218 @@ def test_add_dap_curve_custom_source(qtbot, mocked_client_with_dap):
|
||||
assert dap_curve.config.signal.dap == "GaussianModel"
|
||||
|
||||
|
||||
def test_alignment_mode_toggle_shows_bottom_panel(qtbot, mocked_client):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
action = wf.toolbar.components.get_action("alignment_mode").action
|
||||
action.trigger()
|
||||
|
||||
assert wf._alignment_panel_visible is True
|
||||
assert wf._alignment_side_panel.panel_visible is True
|
||||
assert action.isChecked() is True
|
||||
|
||||
action.trigger()
|
||||
|
||||
assert wf._alignment_panel_visible is False
|
||||
assert wf._alignment_side_panel.panel_visible is False
|
||||
assert action.isChecked() is False
|
||||
|
||||
|
||||
def test_resolve_alignment_positioner(qtbot, mocked_client):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
|
||||
wf.x_mode = "samx"
|
||||
assert wf._resolve_alignment_positioner() == "samx"
|
||||
|
||||
wf.x_mode = "auto"
|
||||
wf._current_x_device = ("samx", "samx")
|
||||
assert wf._resolve_alignment_positioner() == "samx"
|
||||
|
||||
wf._current_x_device = ("bpm4i", "bpm4i")
|
||||
assert wf._resolve_alignment_positioner() is None
|
||||
|
||||
wf.x_mode = "index"
|
||||
assert wf._resolve_alignment_positioner() is None
|
||||
|
||||
wf.x_mode = "timestamp"
|
||||
assert wf._resolve_alignment_positioner() is None
|
||||
|
||||
|
||||
def test_alignment_panel_updates_when_auto_x_motor_changes(
|
||||
qtbot, mocked_client_with_dap, monkeypatch
|
||||
):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
wf.plot(arg1="bpm4i", dap="GaussianModel")
|
||||
wf.x_mode = "auto"
|
||||
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
||||
|
||||
wf._current_x_device = ("samx", "samx")
|
||||
wf._alignment_panel.set_positioner_device("samx")
|
||||
wf.scan_item = create_dummy_scan_item()
|
||||
wf.scan_item.metadata["bec"]["scan_report_devices"] = ["samy"]
|
||||
|
||||
data = {
|
||||
"samy": {"samy": {"val": np.array([1.0, 2.0, 3.0])}},
|
||||
"bpm4i": {"bpm4i": {"val": np.array([10.0, 20.0, 30.0])}},
|
||||
}
|
||||
monkeypatch.setattr(wf, "_fetch_scan_data_and_access", lambda: (data, "val"))
|
||||
|
||||
wf._get_x_data("bpm4i", "bpm4i")
|
||||
|
||||
assert wf._current_x_device == ("samy", "samy")
|
||||
assert wf._alignment_positioner_name == "samy"
|
||||
assert wf._alignment_panel.positioner.device == "samy"
|
||||
|
||||
|
||||
def test_alignment_panel_disables_without_positioner(qtbot, mocked_client_with_dap):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
wf.plot(arg1="bpm4i")
|
||||
wf.x_mode = "index"
|
||||
|
||||
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
||||
|
||||
assert wf._alignment_panel.positioner.isEnabled() is False
|
||||
assert "positioner on the x axis" in wf._alignment_panel.status_label.text()
|
||||
|
||||
|
||||
def test_alignment_marker_updates_from_positioner_readback(qtbot, mocked_client_with_dap):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
wf.plot(arg1="bpm4i", dap="GaussianModel")
|
||||
wf.x_mode = "samx"
|
||||
|
||||
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
||||
wf.dev["samx"].signals["samx"]["value"] = 4.2
|
||||
wf._alignment_panel.positioner.force_update_readback()
|
||||
|
||||
assert wf._alignment_controller is not None
|
||||
assert wf._alignment_controller.marker_line is not None
|
||||
assert np.isclose(wf._alignment_controller.marker_line.value(), 4.2)
|
||||
assert "samx" in wf._alignment_controller.marker_line.label.toPlainText()
|
||||
assert "4.200" in wf._alignment_controller.marker_line.label.toPlainText()
|
||||
|
||||
|
||||
def test_alignment_panel_uses_existing_dap_curves_and_moves_positioner(
|
||||
qtbot, mocked_client_with_dap
|
||||
):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
source_curve = wf.plot(arg1="bpm4i")
|
||||
dap_curve = wf.add_dap_curve(device_label=source_curve.name(), dap_name="GaussianModel")
|
||||
wf.x_mode = "samx"
|
||||
|
||||
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
||||
fit_summary = make_alignment_fit_summary(center=2.5)
|
||||
wf.dap_summary_update.emit(fit_summary, {"curve_id": dap_curve.name()})
|
||||
wf._alignment_panel.fit_dialog.select_curve(dap_curve.name())
|
||||
|
||||
move_spy = MagicMock()
|
||||
wf.dev["samx"].move = move_spy
|
||||
|
||||
assert wf._alignment_panel.fit_dialog.fit_curve_id == dap_curve.name()
|
||||
assert wf._alignment_panel.fit_dialog.action_buttons["center"].isEnabled() is True
|
||||
|
||||
wf._alignment_panel.fit_dialog.action_buttons["center"].click()
|
||||
|
||||
move_spy.assert_called_once_with(2.5, relative=False)
|
||||
|
||||
|
||||
def test_alignment_target_line_toggle_updates_target_value_label(qtbot, mocked_client_with_dap):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
wf.plot(arg1="bpm4i", dap="GaussianModel")
|
||||
wf.x_mode = "samx"
|
||||
|
||||
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
||||
wf._alignment_panel.target_toggle.setChecked(True)
|
||||
|
||||
assert wf._alignment_controller is not None
|
||||
assert wf._alignment_controller.target_line is not None
|
||||
assert wf._alignment_panel.move_to_target_button.isEnabled() is True
|
||||
|
||||
wf._alignment_controller.target_line.setValue(1.5)
|
||||
|
||||
assert "1.500" in wf._alignment_panel.target_toggle.text()
|
||||
|
||||
|
||||
def test_alignment_move_to_target_uses_draggable_line_value(qtbot, mocked_client_with_dap):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
wf.plot(arg1="bpm4i", dap="GaussianModel")
|
||||
wf.x_mode = "samx"
|
||||
|
||||
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
||||
wf._alignment_panel.target_toggle.setChecked(True)
|
||||
wf._alignment_controller.target_line.setValue(1.25)
|
||||
|
||||
move_spy = MagicMock()
|
||||
wf.dev["samx"].move = move_spy
|
||||
|
||||
wf._alignment_panel.move_to_target_button.click()
|
||||
|
||||
move_spy.assert_called_once_with(1.25, relative=False)
|
||||
|
||||
|
||||
def test_alignment_mode_toggle_off_keeps_user_dap_curve(qtbot, mocked_client_with_dap):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
source_curve = wf.plot(arg1="bpm4i")
|
||||
dap_curve = wf.add_dap_curve(device_label=source_curve.name(), dap_name="GaussianModel")
|
||||
wf.x_mode = "samx"
|
||||
|
||||
action = wf.toolbar.components.get_action("alignment_mode").action
|
||||
action.trigger()
|
||||
action.trigger()
|
||||
|
||||
assert wf.get_curve(dap_curve.name()) is not None
|
||||
|
||||
|
||||
def test_alignment_mode_toggle_off_clears_controller_overlays(qtbot, mocked_client_with_dap):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
wf.plot(arg1="bpm4i", dap="GaussianModel")
|
||||
wf.x_mode = "samx"
|
||||
|
||||
action = wf.toolbar.components.get_action("alignment_mode").action
|
||||
action.trigger()
|
||||
wf._alignment_panel.target_toggle.setChecked(True)
|
||||
wf.dev["samx"].signals["samx"]["value"] = 2.0
|
||||
wf._alignment_panel.positioner.force_update_readback()
|
||||
|
||||
assert wf._alignment_controller.marker_line is not None
|
||||
assert wf._alignment_controller.target_line is not None
|
||||
|
||||
action.trigger()
|
||||
|
||||
assert wf._alignment_controller.marker_line is None
|
||||
assert wf._alignment_controller.target_line is None
|
||||
|
||||
|
||||
def test_alignment_panel_removes_deleted_dap_curve_from_fit_list(qtbot, mocked_client_with_dap):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
source_curve = wf.plot(arg1="bpm4i")
|
||||
dap_curve = wf.add_dap_curve(device_label=source_curve.name(), dap_name="GaussianModel")
|
||||
|
||||
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
||||
wf.dap_summary_update.emit(
|
||||
make_alignment_fit_summary(center=1.5), {"curve_id": dap_curve.name()}
|
||||
)
|
||||
|
||||
assert dap_curve.name() in wf._alignment_panel.fit_dialog.summary_data
|
||||
|
||||
wf.remove_curve(dap_curve.name())
|
||||
|
||||
assert dap_curve.name() not in wf._alignment_panel.fit_dialog.summary_data
|
||||
|
||||
|
||||
def test_alignment_controller_move_request_moves_positioner(qtbot, mocked_client_with_dap):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client_with_dap)
|
||||
wf.plot(arg1="bpm4i", dap="GaussianModel")
|
||||
wf.x_mode = "samx"
|
||||
|
||||
move_spy = MagicMock()
|
||||
wf.dev["samx"].move = move_spy
|
||||
|
||||
wf.toolbar.components.get_action("alignment_mode").action.trigger()
|
||||
wf._alignment_controller.move_absolute_requested.emit(3.5)
|
||||
|
||||
move_spy.assert_called_once_with(3.5, relative=False)
|
||||
|
||||
|
||||
def test_curve_set_data_emits_dap_update(qtbot, mocked_client):
|
||||
wf = create_widget(qtbot, Waveform, client=mocked_client)
|
||||
c = wf.plot(x=[1, 2, 3], y=[4, 5, 6], label="test_curve")
|
||||
|
||||
Reference in New Issue
Block a user