diff --git a/bec_widgets/cli/client.py b/bec_widgets/cli/client.py index 1f84fa5a1..01d9d8791 100644 --- a/bec_widgets/cli/client.py +++ b/bec_widgets/cli/client.py @@ -427,7 +427,7 @@ def detach(self): class BECProgressBar(RPCBase): - """A custom progress bar with smooth transitions. The displayed text can be customized using a template.""" + """A BEC progress bar backed by Qt's native QProgressBar.""" _IMPORT_MODULE = "bec_widgets.widgets.progress.bec_progressbar.bec_progressbar" diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index 84b92de74..8c4f7d574 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -46,8 +46,8 @@ class BECMainWindow(BECWidget, QMainWindow): RPC = True PLUGIN = True - SCAN_PROGRESS_WIDTH = 100 # px - SCAN_PROGRESS_HEIGHT = 12 # px + SCAN_PROGRESS_WIDTH = 120 # px + SCAN_PROGRESS_HEIGHT = 20 # px def __init__(self, parent=None, window_title: str = "BEC", **kwargs): super().__init__(parent=parent, **kwargs) @@ -197,7 +197,11 @@ def _add_scan_progress_bar(self): # Setting HoverWidget for the scan progress bar - minimal and full version self._scan_progress_bar_simple = ScanProgressBar( - self, one_line_design=True, rpc_exposed=False, rpc_passthrough_children=False + self, + one_line_design=True, + rpc_exposed=False, + rpc_passthrough_children=False, + enable_dynamic_stylesheet=True, ) self._scan_progress_bar_simple.show_elapsed_time = False self._scan_progress_bar_simple.show_remaining_time = False @@ -206,7 +210,7 @@ def _add_scan_progress_bar(self): self._scan_progress_bar_simple.progressbar.setFixedHeight(self.SCAN_PROGRESS_HEIGHT) self._scan_progress_bar_simple.progressbar.setFixedWidth(self.SCAN_PROGRESS_WIDTH) self._scan_progress_bar_full = ScanProgressBar( - self, rpc_exposed=False, rpc_passthrough_children=False + self, rpc_exposed=False, rpc_passthrough_children=False, enable_dynamic_stylesheet=False ) self._scan_progress_hover = HoverWidget( self, simple=self._scan_progress_bar_simple, full=self._scan_progress_bar_full diff --git a/bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py b/bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py index 2e758e226..03076173b 100644 --- a/bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py +++ b/bec_widgets/widgets/progress/bec_progressbar/bec_progressbar.py @@ -2,8 +2,13 @@ from enum import Enum from string import Template -from qtpy.QtCore import QEasingCurve, QPropertyAnimation, QRectF, Qt, QTimer -from qtpy.QtGui import QColor, QPainter, QPainterPath +from qtpy.QtCore import QTimer +from qtpy.QtGui import QPalette +from qtpy.QtWidgets import QApplication, QProgressBar, QSizePolicy, QVBoxLayout, QWidget + +from bec_widgets.utils.bec_widget import BECWidget +from bec_widgets.utils.colors import get_accent_colors +from bec_widgets.utils.error_popups import SafeProperty, SafeSlot class ProgressState(Enum): @@ -15,7 +20,7 @@ class ProgressState(Enum): @classmethod def from_bec_status(cls, status: str) -> "ProgressState": """ - Map a BEC status string (open, paused, aborted, halted, closed) + Map a BEC status string (open, paused, aborted, halt/halted, closed, user_completed) to the corresponding ProgressState. Any unknown status falls back to NORMAL. """ @@ -23,29 +28,20 @@ def from_bec_status(cls, status: str) -> "ProgressState": "open": cls.NORMAL, "paused": cls.PAUSED, "aborted": cls.INTERRUPTED, + "halt": cls.PAUSED, "halted": cls.PAUSED, "closed": cls.COMPLETED, + "user_completed": cls.PAUSED, } return mapping.get(status.lower(), cls.NORMAL) -PROGRESS_STATE_COLORS = { - ProgressState.NORMAL: QColor("#2979ff"), # blue – normal progress - ProgressState.PAUSED: QColor("#ffca28"), # orange/amber – paused - ProgressState.INTERRUPTED: QColor("#ff5252"), # red – interrupted - ProgressState.COMPLETED: QColor("#00e676"), # green – finished -} - -from qtpy.QtWidgets import QApplication, QLabel, QVBoxLayout, QWidget - -from bec_widgets.utils.bec_widget import BECWidget -from bec_widgets.utils.colors import get_accent_colors -from bec_widgets.utils.error_popups import SafeProperty, SafeSlot - - class BECProgressBar(BECWidget, QWidget): """ - A custom progress bar with smooth transitions. The displayed text can be customized using a template. + A BEC progress bar backed by Qt's native QProgressBar. + + The displayed text can be customized using a template with $value, $maximum, + and $percentage placeholders. """ PLUGIN = True @@ -61,7 +57,15 @@ class BECProgressBar(BECWidget, QWidget): ] ICON_NAME = "page_control" - def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs): + def __init__( + self, + parent=None, + client=None, + config=None, + gui_id=None, + enable_dynamic_stylesheet: bool = True, + **kwargs, + ): super().__init__( parent=parent, client=client, gui_id=gui_id, config=config, theme_update=True, **kwargs ) @@ -71,7 +75,6 @@ def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs) # internal values self._oversampling_factor = 50 self._value = 0 - self._target_value = 0 self._maximum = 100 * self._oversampling_factor # User values @@ -80,14 +83,7 @@ def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs) self._user_maximum = 100 self._label_template = "$value / $maximum - $percentage %" - # Color settings - self._background_color = QColor(30, 30, 30) - self._progress_color = accent_colors.highlight - - self._completed_color = accent_colors.success - self._border_color = QColor(50, 50, 50) - # Corner‑rounding: base radius in pixels (auto‑reduced if bar is small) - self._corner_radius = 10 + self._corner_radius = 8 # Progress‑bar state handling self._state = ProgressState.NORMAL @@ -101,25 +97,25 @@ def __init__(self, parent=None, client=None, config=None, gui_id=None, **kwargs) # layout settings self._padding_left_right = 10 - self._value_animation = QPropertyAnimation(self, b"_progressbar_value") - self._value_animation.setDuration(200) - self._value_animation.setEasingCurve(QEasingCurve.Type.OutCubic) - - # label on top of the progress bar - self.center_label = QLabel(self) - self.center_label.setAlignment(Qt.AlignHCenter) - self.center_label.setMinimumSize(0, 0) - self.center_label.setStyleSheet("background: transparent; color: white;") - - layout = QVBoxLayout(self) - layout.setContentsMargins(10, 0, 10, 0) - layout.setSpacing(0) - layout.addWidget(self.center_label) - layout.setAlignment(self.center_label, Qt.AlignCenter) - self.setLayout(layout) - - self.update() - self._adjust_label_width() + self._chunk_radius = None + self._enable_dynamic_stylesheet = enable_dynamic_stylesheet + + self.progressbar = QProgressBar(self) + self.progressbar.setTextVisible(True) + self.progressbar.setRange(0, self._maximum) + self.progressbar.setMinimumHeight(0) + self.progressbar.setSizePolicy(QSizePolicy.Policy.Expanding, QSizePolicy.Policy.Ignored) + + self._layout = QVBoxLayout(self) + self._layout.setContentsMargins(self._padding_left_right, 0, self._padding_left_right, 0) + self._layout.setSpacing(0) + self._layout.addWidget(self.progressbar) + self.setLayout(self._layout) + + self._setup_style_sheet(chunk_radius=self._initial_chunk_radius()) + self._sync_progressbar() + self._apply_state_style() + self._update_chunk_radius(force=True) @SafeProperty( str, doc="The template for the center label. Use $value, $maximum, and $percentage." @@ -144,13 +140,12 @@ def apply_theme(self, theme=None): ProgressState.INTERRUPTED: accent_colors.emergency, ProgressState.COMPLETED: accent_colors.success, } + self._apply_state_style() @label_template.setter def label_template(self, template): self._label_template = template - self._adjust_label_width() - self.set_value(self._user_value) - self.update() + self._sync_progressbar() @SafeProperty(float, designable=False) def _progressbar_value(self): @@ -162,28 +157,16 @@ def _progressbar_value(self): @_progressbar_value.setter def _progressbar_value(self, val): self._value = val - self.update() + self.progressbar.setValue(int(round(val))) def _update_template(self): template = Template(self._label_template) return template.safe_substitute( value=self._user_value, maximum=self._user_maximum, - percentage=int((self.map_value(self._user_value) / self._maximum) * 100), + percentage=int(self._percentage(self._user_value)), ) - def _adjust_label_width(self): - """ - Reserve enough horizontal space for the center label so the widget - doesn't resize as the text grows during progress. - """ - template = Template(self._label_template) - sample_text = template.safe_substitute( - value=self._user_maximum, maximum=self._user_maximum, percentage=100 - ) - width = self.center_label.fontMetrics().horizontalAdvance(sample_text) - self.center_label.setFixedWidth(width) - @SafeSlot(float) @SafeSlot(int) def set_value(self, value): @@ -193,13 +176,12 @@ def set_value(self, value): Args: value (float): The value to set. """ - if value > self._user_maximum: - value = self._user_maximum - elif value < self._user_minimum: - value = self._user_minimum - self._target_value = self.map_value(value) - self._user_value = value - self.center_label.setText(self._update_template()) + previous_visual_state = self._current_visual_state() + previous_value = self._value + self._user_value = self._clamp_value(value) + self._value = self.map_value(self._user_value) + if self._value < previous_value: + self._chunk_radius = None # Update state automatically unless paused or interrupted if self._state not in (ProgressState.PAUSED, ProgressState.INTERRUPTED): self._state = ( @@ -207,7 +189,12 @@ def set_value(self, value): if self._user_value >= self._user_maximum else ProgressState.NORMAL ) - self.animate_progress() + self._sync_progressbar() + target_radius = self._target_chunk_radius() + if self._enable_dynamic_stylesheet and self._chunk_radius != target_radius: + self._update_chunk_radius() + if self._current_visual_state() is not previous_visual_state: + self._apply_state_style() @SafeProperty(object, doc="Current visual state of the progress bar.") def state(self): @@ -226,7 +213,7 @@ def state(self, state): if not isinstance(state, ProgressState): raise ValueError("state must be a ProgressState or its value") self._state = state - self.update() + self._apply_state_style() @SafeProperty(float, doc="Base corner radius in pixels (auto‑scaled down on small bars).") def corner_radius(self) -> float: @@ -235,7 +222,18 @@ def corner_radius(self) -> float: @corner_radius.setter def corner_radius(self, radius: float): self._corner_radius = max(0.0, radius) - self.update() + self._chunk_radius = None + self._update_chunk_radius(force=True) + + @SafeProperty(bool) + def enable_dynamic_stylesheet(self) -> bool: + return self._enable_dynamic_stylesheet + + @enable_dynamic_stylesheet.setter + def enable_dynamic_stylesheet(self, enabled: bool): + self._enable_dynamic_stylesheet = bool(enabled) + self._chunk_radius = None + self._update_chunk_radius(force=True) @SafeProperty(float) def padding_left_right(self) -> float: @@ -244,60 +242,12 @@ def padding_left_right(self) -> float: @padding_left_right.setter def padding_left_right(self, padding: float): self._padding_left_right = padding - self.update() - - def paintEvent(self, event): - painter = QPainter(self) - painter.setRenderHint(QPainter.Antialiasing) - rect = self.rect().adjusted(self._padding_left_right, 0, -self._padding_left_right, -1) - - # Corner radius adapts to widget height so it never exceeds half the bar’s thickness - radius = min(self._corner_radius, rect.height() / 2) - - # Draw background - painter.setBrush(self._background_color) - painter.setPen(Qt.NoPen) - painter.drawRoundedRect(rect, radius, radius) # Rounded corners - - # Draw border - painter.setBrush(Qt.NoBrush) - painter.setPen(self._border_color) - painter.drawRoundedRect(rect, radius, radius) - - # Determine progress colour based on current state - if self._state == ProgressState.PAUSED: - current_color = self._state_colors[ProgressState.PAUSED] - elif self._state == ProgressState.INTERRUPTED: - current_color = self._state_colors[ProgressState.INTERRUPTED] - elif self._state == ProgressState.COMPLETED or self._value >= self._maximum: - current_color = self._state_colors[ProgressState.COMPLETED] - else: - current_color = self._state_colors[ProgressState.NORMAL] - - # Set clipping region to preserve the background's rounded corners - progress_rect = rect.adjusted( - 0, 0, int(-rect.width() + (self._value / self._maximum) * rect.width()), 0 - ) - clip_path = QPainterPath() - clip_path.addRoundedRect( - QRectF(rect), radius, radius - ) # Clip to the background's rounded corners - painter.setClipPath(clip_path) + self._layout.setContentsMargins(int(round(padding)), 0, int(round(padding)), 0) - # Draw progress bar - painter.setBrush(current_color) - painter.drawRect(progress_rect) # Less rounded, no additional rounding - - painter.end() - - def animate_progress(self): - """ - Animate the progress bar from the current value to the target value. - """ - self._value_animation.stop() - self._value_animation.setStartValue(self._value) - self._value_animation.setEndValue(self._target_value) - self._value_animation.start() + def resizeEvent(self, event): + super().resizeEvent(event) + self._chunk_radius = None + self._update_chunk_radius(force=True) @SafeProperty(float) def maximum(self): @@ -343,10 +293,11 @@ def set_maximum(self, maximum: float): Args: maximum (float): The maximum value. """ + previous_maximum = self._user_maximum self._user_maximum = maximum - self._adjust_label_width() + if maximum != previous_maximum: + self._chunk_radius = None self.set_value(self._user_value) # Update the value to fit the new range - self.update() @SafeSlot(float) def set_minimum(self, minimum: float): @@ -356,40 +307,132 @@ def set_minimum(self, minimum: float): Args: minimum (float): The minimum value. """ + previous_minimum = self._user_minimum self._user_minimum = minimum + if minimum != previous_minimum: + self._chunk_radius = None self.set_value(self._user_value) # Update the value to fit the new range - self.update() def map_value(self, value: float): """ Map the user value to the range [0, 100*self._oversampling_factor] for the progress """ - return ( - (value - self._user_minimum) / (self._user_maximum - self._user_minimum) * self._maximum - ) + span = self._user_maximum - self._user_minimum + if span <= 0: + return float(self._maximum if value >= self._user_maximum else 0) + mapped_value = (value - self._user_minimum) / span * self._maximum + return min(float(self._maximum), max(0.0, mapped_value)) + + def _percentage(self, value: float) -> float: + return (self.map_value(value) / self._maximum) * 100 if self._maximum else 0.0 + + def _clamp_value(self, value: float) -> float: + if self._user_maximum <= self._user_minimum: + return self._user_maximum + return min(self._user_maximum, max(self._user_minimum, value)) + + def _sync_progressbar(self) -> None: + self.progressbar.setRange(0, int(self._maximum)) + self.progressbar.setValue(int(round(self._value))) + self.progressbar.setFormat(self._update_template()) + + def _setup_style_sheet(self, *, chunk_radius: int) -> None: + radius = int(round(self._corner_radius)) + chunk_color = self._state_colors[self._current_visual_state()].name() + self.progressbar.setStyleSheet(f""" + QProgressBar {{ + background-color: palette(mid); + border: none; + border-radius: {radius}px; + color: palette(text); + text-align: center; + }} + QProgressBar::chunk {{ + background-color: {chunk_color}; + border-radius: {chunk_radius}px; + }} + """) + + def _update_chunk_radius(self, *, force: bool = False) -> None: + target_radius = self._target_chunk_radius() + if not self._enable_dynamic_stylesheet: + if not force and self._chunk_radius == target_radius: + return + self._chunk_radius = target_radius + self._setup_style_sheet(chunk_radius=target_radius) + return + if not force and self._chunk_radius == target_radius: + return + chunk_radius = self._calculate_chunk_radius(target_radius) + if not force and chunk_radius == self._chunk_radius: + return + self._chunk_radius = chunk_radius + self._setup_style_sheet(chunk_radius=chunk_radius) + + def _target_chunk_radius(self) -> int: + radius = int(round(self._corner_radius)) + return max(0, radius - 1) + + def _initial_chunk_radius(self) -> int: + return 0 if self._enable_dynamic_stylesheet else self._target_chunk_radius() + + def _calculate_chunk_radius(self, target_radius: int) -> int: + if target_radius <= 0 or self._maximum <= 0: + return 0 + fill_width = self.progressbar.width() * min(1.0, max(0.0, self._value / self._maximum)) + if fill_width <= 0: + return 0 + return min(target_radius, max(1, int(fill_width / 2))) + + def _apply_state_style(self) -> None: + chunk_radius = self._chunk_radius + if chunk_radius is None: + target_radius = self._target_chunk_radius() + chunk_radius = ( + self._calculate_chunk_radius(target_radius) + if self._enable_dynamic_stylesheet + else target_radius + ) + self._chunk_radius = chunk_radius + self._setup_style_sheet(chunk_radius=chunk_radius) + color = self._state_colors[self._current_visual_state()] + palette = self.progressbar.palette() + palette.setColor(QPalette.ColorRole.Highlight, color) + palette.setColor(QPalette.ColorRole.HighlightedText, palette.color(QPalette.ColorRole.Text)) + self.progressbar.setPalette(palette) + + def _current_visual_state(self) -> ProgressState: + if self._state in (ProgressState.PAUSED, ProgressState.INTERRUPTED): + return self._state + if self._state == ProgressState.COMPLETED or self._value >= self._maximum: + return ProgressState.COMPLETED + return ProgressState.NORMAL def _get_label(self) -> str: """Return the label text. mostly used for testing rpc.""" - return self.center_label.text() + return self.progressbar.text() if __name__ == "__main__": # pragma: no cover app = QApplication(sys.argv) - progressBar = BECProgressBar() - progressBar.show() - progressBar.set_minimum(-100) - progressBar.set_maximum(0) + progress_bar = BECProgressBar() + progress_bar.setWindowTitle("BEC Progress Bar") + progress_bar.resize(360, 48) + progress_bar.set_minimum(-100) + progress_bar.set_maximum(0) + progress_bar.set_value(-100) + progress_bar.show() # Example of setting values def update_progress(): - value = progressBar._user_value + 2.5 - if value > progressBar._user_maximum: - value = -100 # progressBar._maximum / progressBar._upsampling_factor - progressBar.set_value(value) + value = progress_bar._user_value + 2.5 + if value > progress_bar._user_maximum: + value = progress_bar._user_minimum + progress_bar.set_value(value) - timer = QTimer() + timer = QTimer(progress_bar) timer.timeout.connect(update_progress) - timer.start(200) # Update every half second + timer.start(200) sys.exit(app.exec()) diff --git a/bec_widgets/widgets/progress/progress_backend.py b/bec_widgets/widgets/progress/progress_backend.py new file mode 100644 index 000000000..c4f842728 --- /dev/null +++ b/bec_widgets/widgets/progress/progress_backend.py @@ -0,0 +1,315 @@ +from __future__ import annotations + +import enum +import time +from dataclasses import dataclass +from typing import Literal + +import numpy as np +from bec_lib.endpoints import MessageEndpoints +from qtpy.QtCore import QObject, QTimer, Signal + + +class ProgressSource(enum.Enum): + """ + Enum to define the source of the progress. + """ + + SCAN_PROGRESS = "scan_progress" + DEVICE_PROGRESS = "device_progress" + + +@dataclass(frozen=True) +class ProgressSnapshot: + source: ProgressSource + value: float + max_value: float + done: bool + status: Literal["open", "paused", "aborted", "halt", "halted", "closed", "user_completed"] + device: str | None = None + scan_id: str | None = None + scan_number: int | None = None + rid: str | None = None + is_new_scan: bool = False + + +class ProgressTask(QObject): + """ + Class to store progress information. + Inspired by https://github.com/Textualize/rich/blob/master/rich/progress.py + """ + + def __init__( + self, parent: QObject | None, value: float = 0, max_value: float = 0, done: bool = False + ): + super().__init__(parent=parent) + self.start_time = time.monotonic() + self.done = done + self.value = value + self.max_value = max_value + self._elapsed_time = 0 + + self.timer = QTimer(self) + self.timer.timeout.connect(self.update_elapsed_time) + self.timer.start(1000) + + def update(self, value: float, max_value: float, done: bool = False): + """ + Update the progress. + """ + self.max_value = max_value + self.done = done + self.value = value + if done: + self.timer.stop() + + def update_elapsed_time(self): + """ + Update the time estimates. This is called every second by a QTimer. + """ + self._elapsed_time = max(0.0, time.monotonic() - self.start_time) + + @property + def percentage(self) -> float: + """float: Get progress of task as a percentage. If a None total was set, returns 0""" + if not self.max_value: + return 0.0 + completed = (self.value / self.max_value) * 100.0 + completed = min(100.0, max(0.0, completed)) + return completed + + @property + def speed(self) -> float: + """Get the estimated speed in steps per second.""" + if self._elapsed_time == 0: + return 0.0 + + return self.value / self._elapsed_time + + @property + def frequency(self) -> float: + """Get the estimated frequency in steps per second.""" + if self.speed == 0: + return 0.0 + return 1 / self.speed + + @property + def time_elapsed(self) -> str: + return self._format_time(int(self._elapsed_time)) + + @property + def remaining(self) -> float: + """Get the estimated remaining steps.""" + if self.done: + return 0.0 + remaining = self.max_value - self.value + return remaining + + @property + def time_remaining(self) -> str: + """ + Get the estimated remaining time in the format HH:MM:SS. + """ + if self.done or not self.speed or not self.remaining: + return self._format_time(0) + estimate = int(np.round(self.remaining / self.speed)) + + return self._format_time(estimate) + + def _format_time(self, seconds: float) -> str: + """ + Format the time in seconds to a string in the format HH:MM:SS. + """ + return f"{seconds // 3600:02}:{(seconds // 60) % 60:02}:{seconds % 60:02}" + + +class BECProgressTracker(QObject): + """ + Shared backend for BEC scan and device progress messages. + """ + + progress_started = Signal(object) + progress_updated = Signal(object) + progress_finished = Signal(object) + progress_cleared = Signal() + source_changed = Signal(object) + + def __init__(self, bec_dispatcher, parent: QObject | None = None): + super().__init__(parent=parent) + self.bec_dispatcher = bec_dispatcher + self._progress_source: ProgressSource | None = None + self._progress_device: str | None = None + self.task: ProgressTask | None = None + self.scan_number: int | None = None + self._active_scan_id: str | None = None + self._active_rid: str | None = None + + @property + def progress_source(self) -> ProgressSource | None: + return self._progress_source + + @property + def progress_device(self) -> str | None: + return self._progress_device + + @property + def active_scan_id(self) -> str | None: + return self._active_scan_id + + @property + def active_rid(self) -> str | None: + return self._active_rid + + def start( + self, + *, + source: ProgressSource | None = ProgressSource.SCAN_PROGRESS, + device: str | None = None, + ) -> None: + if source is not None: + self.set_progress_source(source, device=device) + + def set_progress_source(self, source: ProgressSource, device: str | None = None) -> None: + if source == ProgressSource.DEVICE_PROGRESS and not device: + return + if self._progress_source == source and self._progress_device == device: + self.source_changed.emit(self.current_snapshot(value=0, max_value=100, done=False)) + return + + self._disconnect_progress_source() + self._progress_source = source + self._progress_device = None if source == ProgressSource.SCAN_PROGRESS else device + self.bec_dispatcher.connect_slot(self.on_progress_update, self._progress_endpoint()) + self.source_changed.emit(self.current_snapshot(value=0, max_value=100, done=False)) + + def _disconnect_progress_source(self) -> None: + if self._progress_source is None: + return + self.bec_dispatcher.disconnect_slot(self.on_progress_update, self._progress_endpoint()) + self._progress_source = None + self._progress_device = None + + def _progress_endpoint(self): + if self._progress_source == ProgressSource.SCAN_PROGRESS: + return MessageEndpoints.scan_progress() + return MessageEndpoints.device_progress(device=self._progress_device) + + def current_snapshot( + self, + *, + value: float, + max_value: float, + done: bool, + status: Literal[ + "open", "paused", "aborted", "halt", "halted", "closed", "user_completed" + ] = "open", + is_new_scan: bool = False, + ) -> ProgressSnapshot: + source = self._progress_source or ProgressSource.SCAN_PROGRESS + return ProgressSnapshot( + source=source, + value=value, + max_value=max_value, + done=done, + status=status, + device=self._progress_device, + scan_id=self._active_scan_id, + scan_number=self.scan_number, + rid=self._active_rid, + is_new_scan=is_new_scan, + ) + + def _start_task(self, scan_id: str | None, rid: str | None = None) -> None: + if self.task is not None: + self.task.timer.stop() + self.task.deleteLater() + self.task = ProgressTask(parent=self) + self._active_scan_id = scan_id + self._active_rid = rid + self.progress_started.emit(self.current_snapshot(value=0, max_value=100, done=False)) + + def clear_task(self, *, emit_finished: bool = True) -> None: + if self.task is None: + self._active_scan_id = None + self._active_rid = None + self.progress_cleared.emit() + return + self.task.timer.stop() + self.task.deleteLater() + self.task = None + self._active_scan_id = None + self._active_rid = None + self.progress_cleared.emit() + if emit_finished: + self.progress_finished.emit(self.current_snapshot(value=0, max_value=100, done=True)) + + def on_progress_update(self, msg_content: dict, metadata: dict): + if self._progress_source is None: + return + self.process_progress_message(self._progress_source, msg_content, metadata) + + def process_progress_message( + self, + source: ProgressSource, + msg_content: dict, + metadata: dict, + *, + device: str | None = None, + ) -> ProgressSnapshot | None: + done = msg_content.get("done", False) + value = msg_content.get("value", 0) + max_value = msg_content.get("max_value", 100) + status: Literal[ + "open", "paused", "aborted", "halt", "halted", "closed", "user_completed" + ] = metadata.get("status", "open") + if done and source == ProgressSource.DEVICE_PROGRESS: + value = max_value + scan_id = metadata.get("scan_id") or metadata.get("RID") + rid = metadata.get("RID") + scan_number = metadata.get("scan_number") + if scan_number is not None: + self.scan_number = scan_number + is_new_scan = False + previous_scan_id = self._active_scan_id + previous_rid = self._active_rid + identity_changed = ( + (scan_id is not None and scan_id != previous_scan_id) + or (rid is not None and rid != previous_rid) + or (previous_scan_id is None and previous_rid is None) + ) + + if self.task is None: + self._start_task(scan_id, rid=rid) + is_new_scan = identity_changed + elif scan_id is not None and scan_id != self._active_scan_id: + self._start_task(scan_id, rid=rid) + is_new_scan = True + elif rid is not None and rid != self._active_rid: + self._start_task(scan_id or self._active_scan_id, rid=rid) + is_new_scan = True + + if self.task is None: + return None + + self.task.update(value, max_value, done) + progress_device = device or self._progress_device + snapshot = ProgressSnapshot( + source=source, + value=value, + max_value=max_value, + done=done, + status=status, + device=progress_device if source == ProgressSource.DEVICE_PROGRESS else None, + scan_id=self._active_scan_id, + scan_number=self.scan_number, + rid=self._active_rid, + is_new_scan=is_new_scan, + ) + self.progress_updated.emit(snapshot) + if done: + self.clear_task() + return snapshot + + def cleanup(self) -> None: + self.clear_task(emit_finished=False) + self._disconnect_progress_source() diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring.py b/bec_widgets/widgets/progress/ring_progress_bar/ring.py index c998c953e..48cee10c3 100644 --- a/bec_widgets/widgets/progress/ring_progress_bar/ring.py +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring.py @@ -13,6 +13,11 @@ from bec_widgets.utils.bec_connector import ConnectionConfig from bec_widgets.utils.colors import Colors from bec_widgets.utils.error_popups import SafeProperty, SafeSlot +from bec_widgets.widgets.progress.progress_backend import ( + BECProgressTracker, + ProgressSnapshot, + ProgressSource, +) logger = bec_logger.logger if TYPE_CHECKING: @@ -81,6 +86,8 @@ def __init__(self, parent: RingProgressContainerWidget | None = None, client=Non self._color: QColor = self.convert_color(self.config.color) self._background_color: QColor = self.convert_color(self.config.background_color) self.registered_slot: tuple[Callable, str | EndpointInfo] | None = None + self.progress_tracker = BECProgressTracker(self.bec_dispatcher, parent=self) + self.progress_tracker.progress_updated.connect(self._on_progress_snapshot) self.RID = None self._gap = 5 self._hovered = False @@ -219,26 +226,18 @@ def set_update( case "manual": if self.config.mode == "manual": return - if self.registered_slot is not None: - self.bec_dispatcher.disconnect_slot(*self.registered_slot) + self._disconnect_registered_update() self.config.mode = "manual" - self.registered_slot = None case "scan": if self.config.mode == "scan": return - if self.registered_slot is not None: - self.bec_dispatcher.disconnect_slot(*self.registered_slot) + self._disconnect_registered_update() self.config.mode = "scan" - self.bec_dispatcher.connect_slot( - self.on_scan_progress, MessageEndpoints.scan_progress() - ) - self.registered_slot = (self.on_scan_progress, MessageEndpoints.scan_progress()) + self.progress_tracker.start(source=ProgressSource.SCAN_PROGRESS) case "device": - if self.registered_slot is not None: - self.bec_dispatcher.disconnect_slot(*self.registered_slot) + self._disconnect_registered_update() self.config.mode = "device" if device == "": - self.registered_slot = None return self.config.device = device # self.config.signal = self._get_signal_from_device(device, signal) @@ -248,6 +247,12 @@ def set_update( case _: raise ValueError(f"Unsupported mode: {mode}") + def _disconnect_registered_update(self): + if self.registered_slot is not None: + self.bec_dispatcher.disconnect_slot(*self.registered_slot) + self.registered_slot = None + self.progress_tracker.cleanup() + def set_precision(self, precision: int): """ Set the precision for the ring widget. @@ -362,15 +367,14 @@ def _update_device_connection(self, device: str, signal: str | None) -> str: return "" if signal in progress_signals: - endpoint = MessageEndpoints.device_progress(device) - self.bec_dispatcher.connect_slot(self.on_device_progress, endpoint) - self.registered_slot = (self.on_device_progress, endpoint) + self.progress_tracker.start(source=ProgressSource.DEVICE_PROGRESS, device=device) return signal if signal in hinted_signals or signal in normal_signals: endpoint = MessageEndpoints.device_readback(device) self.bec_dispatcher.connect_slot(self.on_device_readback, endpoint) self.registered_slot = (self.on_device_readback, endpoint) return signal + return "" @SafeSlot(dict, dict) def on_scan_progress(self, msg, meta): @@ -381,11 +385,10 @@ def on_scan_progress(self, msg, meta): msg(dict): Message with the scan progress meta(dict): Metadata for the message """ - current_RID = meta.get("RID", None) - if current_RID != self.RID: - self.set_min_max_values(0, msg.get("max_value", 100)) - self.set_value(msg.get("value", 0)) - self.update() + if self.progress_tracker.active_rid is None and self.RID is not None: + self.progress_tracker._active_rid = self.RID + self.progress_tracker._active_scan_id = self.RID + self.progress_tracker.process_progress_message(ProgressSource.SCAN_PROGRESS, msg, meta) @SafeSlot(dict, dict) def on_device_readback(self, msg, meta): @@ -418,12 +421,20 @@ def on_device_progress(self, msg, meta): device = self.config.device if device is None: return - max_val = msg.get("max_value", 100) - self.set_min_max_values(0, max_val) - value = msg.get("value", 0) - if msg.get("done"): - value = max_val - self.set_value(value) + self.progress_tracker.process_progress_message( + ProgressSource.DEVICE_PROGRESS, msg, meta, device=device + ) + + def _on_progress_snapshot(self, snapshot: ProgressSnapshot): + if snapshot.source == ProgressSource.SCAN_PROGRESS: + if snapshot.is_new_scan: + self.set_min_max_values(0, snapshot.max_value) + self.RID = snapshot.rid + else: + if self.config.device is None: + return + self.set_min_max_values(0, snapshot.max_value) + self.set_value(snapshot.value) self.update() def paintEvent(self, event): @@ -509,15 +520,6 @@ def convert_color(color: str | tuple | QColor) -> QColor: return QtGui.QColor(*color) raise ValueError(f"Unsupported color format: {color}") - 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 - ############################################### ####### QProperties ########################### ############################################### @@ -666,6 +668,7 @@ def cleanup(self): if self.registered_slot is not None: self.bec_dispatcher.disconnect_slot(*self.registered_slot) self.registered_slot = None + self.progress_tracker.cleanup() self._hover_animation.stop() super().cleanup() diff --git a/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar.py b/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar.py index c10f7b3b1..f4b2603cf 100644 --- a/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar.py +++ b/bec_widgets/widgets/progress/scan_progressbar/scan_progressbar.py @@ -1,123 +1,25 @@ from __future__ import annotations -import enum import os -import time -from typing import Literal -import numpy as np -from bec_lib import messages -from bec_lib.endpoints import MessageEndpoints from bec_lib.logger import bec_logger -from qtpy.QtCore import QObject, QTimer, Signal +from qtpy.QtCore import Signal from qtpy.QtWidgets import QVBoxLayout, QWidget from bec_widgets.utils.bec_widget import BECWidget from bec_widgets.utils.error_popups import SafeProperty, SafeSlot from bec_widgets.utils.ui_loader import UILoader from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import ProgressState +from bec_widgets.widgets.progress.progress_backend import ( + BECProgressTracker, + ProgressSnapshot, + ProgressSource, + ProgressTask, +) logger = bec_logger.logger -class ProgressSource(enum.Enum): - """ - Enum to define the source of the progress. - """ - - SCAN_PROGRESS = "scan_progress" - DEVICE_PROGRESS = "device_progress" - - -class ProgressTask(QObject): - """ - Class to store progress information. - Inspired by https://github.com/Textualize/rich/blob/master/rich/progress.py - """ - - def __init__(self, parent: QWidget, value: float = 0, max_value: float = 0, done: bool = False): - super().__init__(parent=parent) - self.start_time = time.time() - self.done = done - self.value = value - self.max_value = max_value - self._elapsed_time = 0 - - self.timer = QTimer(self) - self.timer.timeout.connect(self.update_elapsed_time) - self.timer.start(100) # update the elapsed time every 100 ms - - def update(self, value: float, max_value: float, done: bool = False): - """ - Update the progress. - """ - self.max_value = max_value - self.done = done - self.value = value - if done: - self.timer.stop() - - def update_elapsed_time(self): - """ - Update the time estimates. This is called every 100 ms by a QTimer. - """ - self._elapsed_time += 0.1 - - @property - def percentage(self) -> float: - """float: Get progress of task as a percentage. If a None total was set, returns 0""" - if not self.max_value: - return 0.0 - completed = (self.value / self.max_value) * 100.0 - completed = min(100.0, max(0.0, completed)) - return completed - - @property - def speed(self) -> float: - """Get the estimated speed in steps per second.""" - if self._elapsed_time == 0: - return 0.0 - - return self.value / self._elapsed_time - - @property - def frequency(self) -> float: - """Get the estimated frequency in steps per second.""" - if self.speed == 0: - return 0.0 - return 1 / self.speed - - @property - def time_elapsed(self) -> str: - # format the elapsed time to a string in the format HH:MM:SS - return self._format_time(int(self._elapsed_time)) - - @property - def remaining(self) -> float: - """Get the estimated remaining steps.""" - if self.done: - return 0.0 - remaining = self.max_value - self.value - return remaining - - @property - def time_remaining(self) -> str: - """ - Get the estimated remaining time in the format HH:MM:SS. - """ - if self.done or not self.speed or not self.remaining: - return self._format_time(0) - estimate = int(np.round(self.remaining / self.speed)) - - return self._format_time(estimate) - - def _format_time(self, seconds: float) -> str: - """ - Format the time in seconds to a string in the format HH:MM:SS. - """ - return f"{seconds // 3600:02}:{(seconds // 60) % 60:02}:{seconds % 60:02}" - - class ScanProgressBar(BECWidget, QWidget): """ Widget to display a progress bar that is hooked up to the scan progress of a scan. @@ -130,7 +32,14 @@ class ScanProgressBar(BECWidget, QWidget): progress_finished = Signal() def __init__( - self, parent=None, client=None, config=None, gui_id=None, one_line_design=False, **kwargs + self, + parent=None, + client=None, + config=None, + gui_id=None, + one_line_design=False, + enable_dynamic_stylesheet: bool = True, + **kwargs, ): super().__init__(parent=parent, client=client, config=config, gui_id=gui_id, **kwargs) @@ -146,54 +55,75 @@ def __init__( self.layout.addWidget(self.ui) self.setLayout(self.layout) self.progressbar = self.ui.progressbar + self.progressbar.enable_dynamic_stylesheet = enable_dynamic_stylesheet self._show_elapsed_time = self.ui.elapsed_time_label.isVisible() self._show_remaining_time = self.ui.remaining_time_label.isVisible() self._show_source_label = self.ui.source_label.isVisible() - self.connect_to_queue() - self._progress_source = None - self._progress_device = None - self.task = None - self.scan_number = None + self.progress_tracker = BECProgressTracker(self.bec_dispatcher, parent=self) + self.progress_tracker.progress_started.connect(self._on_progress_started) + self.progress_tracker.progress_updated.connect(self._on_progress_snapshot) + self.progress_tracker.progress_finished.connect(self._on_progress_finished) + self.progress_tracker.source_changed.connect(self._on_progress_source_changed) + self.progress_tracker.start(source=ProgressSource.SCAN_PROGRESS) - def connect_to_queue(self): - """ - Connect to the queue status signal. - """ - self.bec_dispatcher.connect_slot(self.on_queue_update, MessageEndpoints.scan_queue_status()) + @property + def task(self): + return self.progress_tracker.task + + @task.setter + def task(self, value): + self.progress_tracker.task = value + + @property + def scan_number(self): + return self.progress_tracker.scan_number + + @scan_number.setter + def scan_number(self, value): + self.progress_tracker.scan_number = value + + @property + def _active_scan_id(self): + return self.progress_tracker.active_scan_id + + @_active_scan_id.setter + def _active_scan_id(self, value): + self.progress_tracker._active_scan_id = value + + @property + def _progress_source(self): + return self.progress_tracker.progress_source + + @_progress_source.setter + def _progress_source(self, value): + self.progress_tracker._progress_source = value + + @property + def _progress_device(self): + return self.progress_tracker.progress_device + + @_progress_device.setter + def _progress_device(self, value): + self.progress_tracker._progress_device = value def set_progress_source(self, source: ProgressSource, device=None): """ Set the source of the progress. """ - if self._progress_source == source and self._progress_device == device: - self.update_source_label(source, device=device) - return - if self._progress_source is not None: - self.bec_dispatcher.disconnect_slot( - self.on_progress_update, - ( - MessageEndpoints.scan_progress() - if self._progress_source == ProgressSource.SCAN_PROGRESS - else MessageEndpoints.device_progress(device=self._progress_device) - ), - ) - self._progress_source = source - self._progress_device = None if source == ProgressSource.SCAN_PROGRESS else device - self.bec_dispatcher.connect_slot( - self.on_progress_update, - ( - MessageEndpoints.scan_progress() - if source == ProgressSource.SCAN_PROGRESS - else MessageEndpoints.device_progress(device=device) - ), - ) - self.update_source_label(source, device=device) - # self.progress_started.emit() + self.progress_tracker.set_progress_source(source, device=device) + + def _start_task(self, scan_id: str | None) -> None: + self.progress_tracker._start_task(scan_id) + + def _clear_task(self, *, emit_finished: bool = True) -> None: + self.progress_tracker.clear_task(emit_finished=emit_finished) def update_source_label(self, source: ProgressSource, device=None): scan_text = f"Scan {self.scan_number}" if self.scan_number is not None else "Scan" text = scan_text if source == ProgressSource.SCAN_PROGRESS else f"Device {device}" + if self.ui.source_label.text() == text: + return logger.info(f"Set progress source to {text}") self.ui.source_label.setText(text) @@ -202,27 +132,26 @@ def on_progress_update(self, msg_content: dict, metadata: dict): """ Update the progress bar based on the progress message. """ - value = msg_content["value"] - max_value = msg_content.get("max_value", 100) - done = msg_content.get("done", False) - status: Literal["open", "paused", "aborted", "halted", "closed"] = metadata.get( - "status", "open" - ) + self.progress_tracker.on_progress_update(msg_content, metadata) - if self.task is None: - return - self.task.update(value, max_value, done) + def _on_progress_started(self, _snapshot: ProgressSnapshot): + if self.task is not None: + self.task.timer.timeout.connect(self.update_labels) + self.progress_started.emit() - self.update_labels() + def _on_progress_finished(self, _snapshot: ProgressSnapshot): + self.progress_finished.emit() - self.progressbar.set_maximum(self.task.max_value) - self.progressbar.state = ProgressState.from_bec_status(status) - self.progressbar.set_value(self.task.value) + def _on_progress_source_changed(self, snapshot: ProgressSnapshot): + self.update_source_label(snapshot.source, device=snapshot.device) - if done: - self.task = None - self.progress_finished.emit() - return + def _on_progress_snapshot(self, snapshot: ProgressSnapshot): + self.update_labels() + self.update_source_label(snapshot.source, device=snapshot.device) + self.progressbar.set_maximum(snapshot.max_value) + state = ProgressState.from_bec_status(snapshot.status) + self.progressbar.state = state + self.progressbar.set_value(snapshot.value) @SafeProperty(bool) def show_elapsed_time(self): @@ -265,68 +194,10 @@ def update_labels(self): self.ui.elapsed_time_label.setText(self.task.time_elapsed) self.ui.remaining_time_label.setText(self.task.time_remaining) - @SafeSlot(dict, dict, verify_sender=True) - def on_queue_update(self, msg_content, metadata): - """ - Update the progress bar based on the queue status. - """ - if not "queue" in msg_content: - return - if "primary" not in msg_content["queue"]: - return - if (primary_queue := msg_content.get("queue").get("primary")) is None: - return - if not isinstance(primary_queue, messages.ScanQueueStatus): - return - primary_queue_info = primary_queue.info - if len(primary_queue_info) == 0: - return - scan_info = primary_queue_info[0] - if scan_info is None: - return - if scan_info.status.lower() == "running" and self.task is None: - self.task = ProgressTask(parent=self) - self.progress_started.emit() - - active_request_block = scan_info.active_request_block - if active_request_block is None: - return - - self.scan_number = active_request_block.scan_number - report_instructions = active_request_block.report_instructions - if not report_instructions: - return - - # for now, let's just use the first instruction - instruction = report_instructions[0] - - if "scan_progress" in instruction: - self.set_progress_source(ProgressSource.SCAN_PROGRESS) - elif "device_progress" in instruction: - device = instruction["device_progress"][0] - self.set_progress_source(ProgressSource.DEVICE_PROGRESS, device=device) - def cleanup(self): - if self.task is not None: - self.task.timer.stop() - self.close() - self.deleteLater() - if self._progress_source is not None: - self.bec_dispatcher.disconnect_slot( - self.on_progress_update, - ( - MessageEndpoints.scan_progress() - if self._progress_source == ProgressSource.SCAN_PROGRESS - else MessageEndpoints.device_progress(device=self._progress_device) - ), - ) - self._progress_source = None - self._progress_device = None + self.progress_tracker.cleanup() self.progressbar.close() self.progressbar.deleteLater() - self.bec_dispatcher.disconnect_slot( - self.on_queue_update, MessageEndpoints.scan_queue_status() - ) super().cleanup() diff --git a/tests/unit_tests/test_bec_progressbar.py b/tests/unit_tests/test_bec_progressbar.py index 78bfb384e..9269137e7 100644 --- a/tests/unit_tests/test_bec_progressbar.py +++ b/tests/unit_tests/test_bec_progressbar.py @@ -1,5 +1,8 @@ -import numpy as np +from unittest import mock + import pytest +from qtpy.QtGui import QPalette +from qtpy.QtWidgets import QProgressBar from bec_widgets.widgets.progress.bec_progressbar.bec_progressbar import ( BECProgressBar, @@ -15,6 +18,14 @@ def progressbar(qtbot): yield widget +@pytest.fixture +def static_progressbar(qtbot): + widget = BECProgressBar(enable_dynamic_stylesheet=False) + qtbot.addWidget(widget) + qtbot.waitExposed(widget) + yield widget + + def test_progressbar(progressbar): progressbar.update() @@ -23,19 +34,138 @@ def test_progressbar_set_value(qtbot, progressbar): progressbar.set_minimum(0) progressbar.set_maximum(100) progressbar.set_value(50) - progressbar.paintEvent(None) - qtbot.waitUntil( - lambda: np.isclose( - progressbar._value, progressbar._user_value * progressbar._oversampling_factor - ) - ) + assert isinstance(progressbar.progressbar, QProgressBar) + assert progressbar._value == progressbar._user_value * progressbar._oversampling_factor + assert progressbar.progressbar.value() == 50 * progressbar._oversampling_factor def test_progressbar_label(progressbar): progressbar.label_template = "Test: $value" progressbar.set_value(50) - assert progressbar.center_label.text() == "Test: 50" + assert progressbar._get_label() == "Test: 50" + assert progressbar.progressbar.text() == "Test: 50" + + +def test_progressbar_equal_minimum_and_maximum_does_not_raise(progressbar): + progressbar.set_minimum(0) + progressbar.set_maximum(0) + progressbar.set_value(0) + + assert progressbar._get_label() == "0 / 0 - 100 %" + assert progressbar.progressbar.value() == progressbar.progressbar.maximum() + + +def test_progressbar_uses_static_stylesheet_with_palette_state_color(progressbar): + progressbar.progressbar.resize(100, 20) + progressbar.set_value(50) + progressbar.state = ProgressState.PAUSED + + style_sheet = progressbar.progressbar.styleSheet() + assert "QProgressBar::chunk" in style_sheet + assert ( + f"background-color: {progressbar._state_colors[ProgressState.PAUSED].name()};" + in style_sheet + ) + assert "background-color: palette(mid);" in style_sheet + assert "border-radius: 7px;" in style_sheet + assert ( + progressbar.progressbar.palette().color(QPalette.ColorRole.Highlight) + == progressbar._state_colors[ProgressState.PAUSED] + ) + + +def test_progressbar_value_updates_do_not_rebuild_stylesheet_within_same_chunk_mode(progressbar): + progressbar.progressbar.resize(100, 20) + progressbar.set_value(30) + + with mock.patch.object( + progressbar, "_setup_style_sheet", wraps=progressbar._setup_style_sheet + ) as setup_style_sheet: + progressbar.set_value(35) + progressbar.set_value(42) + progressbar.set_value(50) + + setup_style_sheet.assert_not_called() + + +def test_progressbar_value_updates_skip_chunk_radius_after_target_reached(progressbar): + progressbar.progressbar.resize(100, 20) + progressbar.set_value(30) + assert progressbar._chunk_radius == progressbar._target_chunk_radius() + + with mock.patch.object( + progressbar, "_update_chunk_radius", wraps=progressbar._update_chunk_radius + ) as update_chunk_radius: + progressbar.set_value(35) + progressbar.set_value(42) + progressbar.set_value(50) + + update_chunk_radius.assert_not_called() + + +def test_progressbar_repeated_same_maximum_does_not_reset_chunk_radius(progressbar): + progressbar.progressbar.resize(100, 20) + progressbar.set_maximum(100) + progressbar.set_value(30) + assert progressbar._chunk_radius == progressbar._target_chunk_radius() + + with mock.patch.object( + progressbar, "_update_chunk_radius", wraps=progressbar._update_chunk_radius + ) as update_chunk_radius: + progressbar.set_maximum(100) + progressbar.set_value(40) + + update_chunk_radius.assert_not_called() + + +def test_progressbar_can_disable_dynamic_stylesheet(static_progressbar): + static_progressbar.progressbar.resize(100, 20) + assert static_progressbar.enable_dynamic_stylesheet is False + assert static_progressbar._chunk_radius == static_progressbar._target_chunk_radius() + + with mock.patch.object( + static_progressbar, "_setup_style_sheet", wraps=static_progressbar._setup_style_sheet + ) as setup_style_sheet: + static_progressbar.set_value(1) + static_progressbar.set_value(2) + static_progressbar.set_value(3) + + setup_style_sheet.assert_not_called() + assert "border-radius: 7px;" in static_progressbar.progressbar.styleSheet() + + +def test_progressbar_dynamic_stylesheet_can_be_toggled(progressbar): + progressbar.enable_dynamic_stylesheet = False + + assert progressbar.enable_dynamic_stylesheet is False + assert progressbar._chunk_radius == progressbar._target_chunk_radius() + assert "border-radius: 7px;" in progressbar.progressbar.styleSheet() + + +def test_progressbar_rebuilds_stylesheet_until_chunk_radius_reaches_target(progressbar): + progressbar.progressbar.resize(100, 20) + progressbar.set_value(9) + + with mock.patch.object( + progressbar, "_setup_style_sheet", wraps=progressbar._setup_style_sheet + ) as setup_style_sheet: + progressbar.set_value(12) + progressbar.set_value(25) + progressbar.set_value(30) + + assert setup_style_sheet.call_count == 2 + assert "border-radius: 7px;" in progressbar.progressbar.styleSheet() + + +def test_progressbar_resets_chunk_radius_when_value_goes_backwards(progressbar): + progressbar.progressbar.resize(100, 20) + progressbar.set_value(30) + assert "border-radius: 7px;" in progressbar.progressbar.styleSheet() + + progressbar.set_value(4) + + assert "border-radius: 2px;" in progressbar.progressbar.styleSheet() def test_progress_state_from_bec_status(): @@ -44,8 +174,10 @@ def test_progress_state_from_bec_status(): "open": ProgressState.NORMAL, "paused": ProgressState.PAUSED, "aborted": ProgressState.INTERRUPTED, + "halt": ProgressState.PAUSED, "halted": ProgressState.PAUSED, "closed": ProgressState.COMPLETED, + "user_completed": ProgressState.PAUSED, "UNKNOWN": ProgressState.NORMAL, # fallback } for text, expected in mapping.items(): diff --git a/tests/unit_tests/test_device_initialization_progress_bar.py b/tests/unit_tests/test_device_initialization_progress_bar.py index 530b53c1b..c08a82b97 100644 --- a/tests/unit_tests/test_device_initialization_progress_bar.py +++ b/tests/unit_tests/test_device_initialization_progress_bar.py @@ -40,7 +40,7 @@ def test_update_device_initialization_progress(progress_bar, qtbot): assert progress_bar.progress_bar._user_value == 1 assert progress_bar.progress_bar._user_maximum == 3 assert progress_bar.progress_label.text() == f"{msg.device} initialization in progress..." - assert "1 / 3 - 33 %" == progress_bar.progress_bar.center_label.text() + assert "1 / 3 - 33 %" == progress_bar.progress_bar.progressbar.text() # II. Update with message of finished DeviceInitializationProgressMessage, finished=True, success=True msg.finished = True @@ -49,7 +49,7 @@ def test_update_device_initialization_progress(progress_bar, qtbot): assert progress_bar.progress_bar._user_value == 1 assert progress_bar.progress_bar._user_maximum == 3 assert progress_bar.progress_label.text() == f"{msg.device} initialization succeeded!" - assert "1 / 3 - 33 %" == progress_bar.progress_bar.center_label.text() + assert "1 / 3 - 33 %" == progress_bar.progress_bar.progressbar.text() # III. Update with message of finished DeviceInitializationProgressMessage, finished=True, success=False msg.finished = True @@ -59,7 +59,7 @@ def test_update_device_initialization_progress(progress_bar, qtbot): with qtbot.waitSignal(progress_bar.failed_devices_changed) as signal_blocker: progress_bar._update_device_initialization_progress(msg.model_dump(), {}) assert progress_bar.progress_label.text() == f"{msg.device} initialization failed!" - assert "2 / 3 - 66 %" == progress_bar.progress_bar.center_label.text() + assert "2 / 3 - 66 %" == progress_bar.progress_bar.progressbar.text() assert progress_bar.progress_bar._user_value == 2 assert progress_bar.progress_bar._user_maximum == 3 diff --git a/tests/unit_tests/test_main_widnow.py b/tests/unit_tests/test_main_widnow.py index 2b0fd40e5..641973cc8 100644 --- a/tests/unit_tests/test_main_widnow.py +++ b/tests/unit_tests/test_main_widnow.py @@ -117,6 +117,13 @@ def test_hidden_scan_progress_parent_blocks_children_namespace(bec_main_window): assert nested_progress.parent_id == hidden_progress.gui_id +def test_compact_scan_progress_bar_uses_status_bar_sizing(bec_main_window): + progressbar = bec_main_window._scan_progress_bar_simple.progressbar + + assert progressbar.height() == bec_main_window.SCAN_PROGRESS_HEIGHT + assert progressbar.progressbar.minimumHeight() == 0 + + ################################################################# # Tests for BECMainWindow Addons ################################################################# diff --git a/tests/unit_tests/test_ring_progress_bar_ring.py b/tests/unit_tests/test_ring_progress_bar_ring.py index 2700eb665..a4b10849d 100644 --- a/tests/unit_tests/test_ring_progress_bar_ring.py +++ b/tests/unit_tests/test_ring_progress_bar_ring.py @@ -79,7 +79,7 @@ def test_set_update_to_scan(ring_widget): # Verify that connect_slot was called ring_widget.bec_dispatcher.connect_slot.assert_called_once() call_args = ring_widget.bec_dispatcher.connect_slot.call_args - assert call_args[0][0] == ring_widget.on_scan_progress + assert call_args[0][0] == ring_widget.progress_tracker.on_progress_update assert "scan_progress" in str(call_args[0][1]) @@ -437,7 +437,7 @@ def test_update_device_connection_with_progress_signal(ring_widget_with_device): # Should connect to device_progress endpoint ring_widget.bec_dispatcher.connect_slot.assert_called_once() call_args = ring_widget.bec_dispatcher.connect_slot.call_args - assert call_args[0][0] == ring_widget.on_device_progress + assert call_args[0][0] == ring_widget.progress_tracker.on_progress_update def test_update_device_connection_with_hinted_signal(ring_widget): @@ -630,3 +630,13 @@ def test_on_device_progress_default_values(ring_widget): # Should use defaults: value=0, max_value=100 assert ring_widget.config.value == 0 assert ring_widget.config.max_value == 100 + + +def test_cleanup_disconnects_progress_tracker(ring_widget): + ring_widget.set_update("scan") + ring_widget.bec_dispatcher.disconnect_slot = MagicMock() + + ring_widget.cleanup() + + ring_widget.bec_dispatcher.disconnect_slot.assert_called_once() + assert ring_widget.progress_tracker.progress_source is None diff --git a/tests/unit_tests/test_scan_progress_bar.py b/tests/unit_tests/test_scan_progress_bar.py index b450f97b4..1351c94f9 100644 --- a/tests/unit_tests/test_scan_progress_bar.py +++ b/tests/unit_tests/test_scan_progress_bar.py @@ -2,7 +2,6 @@ import numpy as np import pytest -from bec_lib import messages from bec_lib.endpoints import MessageEndpoints from bec_widgets.utils.bec_widget import BECWidget @@ -27,30 +26,6 @@ def scan_progressbar(qtbot, mocked_client): yield widget -@pytest.fixture -def scan_message(): - return messages.ScanQueueMessage( - metadata={ - "file_suffix": None, - "file_directory": None, - "user_metadata": {"sample_name": ""}, - "RID": "94949c6e-d5f2-4f01-837e-a5d36257dd5d", - }, - scan_type="line_scan", - parameter={ - "args": {"samx": [-10.0, 10.0]}, - "kwargs": { - "steps": 20, - "relative": False, - "exp_time": 0.1, - "burst_at_each_point": 1, - "system_config": {"file_suffix": None, "file_directory": None}, - }, - }, - queue="primary", - ) - - def test_progress_task_basic(): """percentage, remaining, and formatted time helpers behave as expected.""" task = ProgressTask(parent=None, value=50, max_value=100, done=False) @@ -71,11 +46,45 @@ def test_progress_task_basic(): assert task.time_elapsed == "00:00:10" +def test_progress_task_elapsed_time_uses_monotonic_clock(monkeypatch): + times = iter([100.0, 102.5]) + monkeypatch.setattr( + "bec_widgets.widgets.progress.progress_backend.time.monotonic", lambda: next(times) + ) + task = ProgressTask(parent=None) + task.timer.stop() + + task.update_elapsed_time() + + assert task._elapsed_time == 2.5 + assert task.time_elapsed == "00:00:02" + + def test_scan_progressbar_initialization(scan_progressbar): assert isinstance(scan_progressbar, ScanProgressBar) assert isinstance(scan_progressbar.progressbar, BECProgressBar) +def test_scan_progressbar_passes_dynamic_stylesheet_setting(qtbot, mocked_client): + widget = ScanProgressBar(client=mocked_client, enable_dynamic_stylesheet=False) + qtbot.addWidget(widget) + + assert widget.progressbar.enable_dynamic_stylesheet is False + + +def test_scan_progressbar_starts_from_scan_progress_before_queue_update(scan_progressbar): + scan_progressbar._clear_task(emit_finished=False) + + scan_progressbar.on_progress_update( + {"value": 3, "max_value": 10, "done": False}, metadata={"RID": "live-rid"} + ) + + assert scan_progressbar.task is not None + assert scan_progressbar._active_scan_id == "live-rid" + assert scan_progressbar.progressbar._user_value == 3 + assert scan_progressbar.progressbar._user_maximum == 10 + + def test_update_labels_content(scan_progressbar): """update_labels() reflects ProgressTask time strings on the UI.""" # fabricate a task with known timings @@ -116,8 +125,10 @@ def test_on_progress_update(qtbot, scan_progressbar): ("open", 10, 100, ProgressState.NORMAL), ("paused", 25, 100, ProgressState.PAUSED), ("aborted", 30, 100, ProgressState.INTERRUPTED), + ("halt", 40, 100, ProgressState.PAUSED), ("halted", 40, 100, ProgressState.PAUSED), ("closed", 100, 100, ProgressState.COMPLETED), + ("user_completed", 40, 100, ProgressState.PAUSED), ], ) def test_state_mapping_during_updates( @@ -136,6 +147,18 @@ def test_state_mapping_during_updates( assert scan_progressbar.progressbar.state is expected_state +def test_aborted_done_scan_keeps_partial_progress(scan_progressbar): + scan_progressbar.on_progress_update( + {"value": 4, "max_value": 10, "done": True}, + metadata={"scan_id": "scan-1", "RID": "rid-1", "status": "aborted"}, + ) + + assert scan_progressbar.progressbar._user_value == 4 + assert scan_progressbar.progressbar._user_maximum == 10 + assert scan_progressbar.progressbar.state is ProgressState.INTERRUPTED + assert scan_progressbar.task is None + + def test_source_label_updates(scan_progressbar): """update_source_label() renders correct text for both progress sources.""" # device progress @@ -148,6 +171,19 @@ def test_source_label_updates(scan_progressbar): assert scan_progressbar.ui.source_label.text() == "Scan 5" +def test_source_label_update_logs_only_on_text_change(scan_progressbar): + scan_progressbar.scan_number = 5 + + with mock.patch( + "bec_widgets.widgets.progress.scan_progressbar.scan_progressbar.logger.info" + ) as mock_info: + scan_progressbar.set_progress_source(ProgressSource.SCAN_PROGRESS) + scan_progressbar.set_progress_source(ProgressSource.SCAN_PROGRESS) + scan_progressbar.set_progress_source(ProgressSource.SCAN_PROGRESS) + + mock_info.assert_called_once_with("Set progress source to Scan 5") + + def test_set_progress_source_connections(scan_progressbar, monkeypatch): """ """ @@ -170,7 +206,7 @@ def fake_disconnect(slot, endpoint): assert scan_progressbar._progress_source == ProgressSource.SCAN_PROGRESS assert scan_progressbar.ui.source_label.text() == "Scan 7" - assert connect_calls[-1] == MessageEndpoints.scan_progress() + assert connect_calls == [] assert disconnect_calls == [] # switch to DEVICE_PROGRESS @@ -204,7 +240,10 @@ def test_set_progress_source_disconnects_previous_device_subscription( scan_progressbar.set_progress_source(ProgressSource.DEVICE_PROGRESS, device="motor1") scan_progressbar.set_progress_source(ProgressSource.DEVICE_PROGRESS, device="motor2") - assert disconnect_calls == [MessageEndpoints.device_progress(device="motor1")] + assert disconnect_calls == [ + MessageEndpoints.scan_progress(), + MessageEndpoints.device_progress(device="motor1"), + ] def test_set_progress_source_disconnects_device_when_switching_to_scan( @@ -223,7 +262,10 @@ def test_set_progress_source_disconnects_device_when_switching_to_scan( scan_progressbar.set_progress_source(ProgressSource.DEVICE_PROGRESS, device="motor1") scan_progressbar.set_progress_source(ProgressSource.SCAN_PROGRESS) - assert disconnect_calls == [MessageEndpoints.device_progress(device="motor1")] + assert disconnect_calls == [ + MessageEndpoints.scan_progress(), + MessageEndpoints.device_progress(device="motor1"), + ] def test_cleanup_disconnects_active_device_subscription(scan_progressbar, monkeypatch): @@ -241,147 +283,32 @@ def test_cleanup_disconnects_active_device_subscription(scan_progressbar, monkey monkeypatch.setattr(BECWidget, "cleanup", lambda self: None) scan_progressbar.set_progress_source(ProgressSource.DEVICE_PROGRESS, device="motor1") - ScanProgressBar.cleanup(scan_progressbar) + with ( + mock.patch.object(scan_progressbar, "close", wraps=scan_progressbar.close) as close_mock, + mock.patch.object( + scan_progressbar, "deleteLater", wraps=scan_progressbar.deleteLater + ) as delete_later_mock, + ): + ScanProgressBar.cleanup(scan_progressbar) assert disconnect_calls == [ + MessageEndpoints.scan_progress(), MessageEndpoints.device_progress(device="motor1"), - MessageEndpoints.scan_queue_status(), ] assert scan_progressbar._progress_source is None assert scan_progressbar._progress_device is None + close_mock.assert_not_called() + delete_later_mock.assert_not_called() -def test_progressbar_queue_update(scan_progressbar): - """ - Test that an empty queue update does not change the progress source. - """ - msg = messages.ScanQueueStatusMessage( - queue={"primary": messages.ScanQueueStatus(info=[], status="RUNNING")} - ) - with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source: - scan_progressbar.on_queue_update( - msg.content, msg.metadata, _override_slot_params={"verify_sender": False} - ) - mock_set_source.assert_not_called() - - -def test_progressbar_queue_update_with_scan(scan_progressbar, scan_message): - """ - Test that a queue update with a scan changes the progress source to SCAN_PROGRESS. - """ - request_block = messages.RequestBlock( - msg=scan_message, - RID="some-rid", - scan_motors=["samx"], - readout_priority={"monitored": ["samx"]}, - is_scan=True, - scan_number=1, - scan_id="e3f50794-852c-4bb1-965e-41c585ab0aa9", - report_instructions=[{"scan_progress": 20}], - ) - msg = messages.ScanQueueStatusMessage( - metadata={}, - queue={ - "primary": messages.ScanQueueStatus( - info=[ - messages.QueueInfoEntry( - queue_id="40831e2c-fbd1-4432-8072-ad168a7ad964", - scan_id=["e3f50794-852c-4bb1-965e-41c585ab0aa9"], - status="RUNNING", - active_request_block=request_block, - is_scan=[True], - request_blocks=[request_block], - scan_number=[1], - ) - ], - status="RUNNING", - ) - }, - ) - - with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source: - scan_progressbar.on_queue_update( - msg.content, msg.metadata, _override_slot_params={"verify_sender": False} - ) - mock_set_source.assert_called_once_with(ProgressSource.SCAN_PROGRESS) - - -def test_progressbar_queue_update_with_device(scan_progressbar, scan_message): - """ - Test that a queue update with a device changes the progress source to DEVICE_PROGRESS. - """ - request_block = messages.RequestBlock( - msg=scan_message, - RID="some-rid", - scan_motors=["samx"], - readout_priority={"monitored": ["samx"]}, - is_scan=True, - scan_number=1, - scan_id="e3f50794-852c-4bb1-965e-41c585ab0aa9", - report_instructions=[{"device_progress": ["samx"]}], - ) - msg = messages.ScanQueueStatusMessage( - metadata={}, - queue={ - "primary": messages.ScanQueueStatus( - info=[ - messages.QueueInfoEntry( - queue_id="40831e2c-fbd1-4432-8072-ad168a7ad964", - scan_id=["e3f50794-852c-4bb1-965e-41c585ab0aa9"], - status="RUNNING", - active_request_block=request_block, - is_scan=[True], - request_blocks=[request_block], - scan_number=[1], - ) - ], - status="RUNNING", - ) - }, - ) - - with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source: - scan_progressbar.on_queue_update( - msg.content, msg.metadata, _override_slot_params={"verify_sender": False} - ) - mock_set_source.assert_called_once_with(ProgressSource.DEVICE_PROGRESS, device="samx") - +def test_cleanup_stops_active_task(scan_progressbar, monkeypatch): + monkeypatch.setattr(BECWidget, "cleanup", lambda self: None) + scan_progressbar.task = ProgressTask(parent=scan_progressbar) + scan_progressbar._active_scan_id = "scan-1" + timer = scan_progressbar.task.timer -def test_progressbar_queue_update_with_no_scan_or_device(scan_progressbar, scan_message): - """ - Test that a queue update with neither scan nor device does not change the progress source. - """ - request_block = messages.RequestBlock( - msg=scan_message, - RID="some-rid", - scan_motors=["samx"], - readout_priority={"monitored": ["samx"]}, - is_scan=True, - scan_number=1, - scan_id="e3f50794-852c-4bb1-965e-41c585ab0aa9", - ) - msg = messages.ScanQueueStatusMessage( - metadata={}, - queue={ - "primary": messages.ScanQueueStatus( - info=[ - messages.QueueInfoEntry( - queue_id="40831e2c-fbd1-4432-8072-ad168a7ad964", - scan_id=["e3f50794-852c-4bb1-965e-41c585ab0aa9"], - status="RUNNING", - active_request_block=request_block, - is_scan=[True], - request_blocks=[request_block], - scan_number=[1], - ) - ], - status="RUNNING", - ) - }, - ) + ScanProgressBar.cleanup(scan_progressbar) - with mock.patch.object(scan_progressbar, "set_progress_source") as mock_set_source: - scan_progressbar.on_queue_update( - msg.content, msg.metadata, _override_slot_params={"verify_sender": False} - ) - mock_set_source.assert_not_called() + assert not timer.isActive() + assert scan_progressbar.task is None + assert scan_progressbar._active_scan_id is None