From 177b2a424f19af3855bce5656a3e2730b485066c Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 28 Jan 2026 10:00:12 +0100 Subject: [PATCH 1/4] fix(ring_progress_bar): added hover mouse effect --- .../progress/ring_progress_bar/ring.py | 68 ++++++++- .../ring_progress_bar/ring_progress_bar.py | 140 +++++++++++++++++- 2 files changed, 204 insertions(+), 4 deletions(-) diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring.py b/bec_widgets/widgets/progress/ring_progress_bar/ring.py index a29641544..57a35a471 100644 --- a/bec_widgets/widgets/progress/ring_progress_bar/ring.py +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring.py @@ -82,8 +82,22 @@ def __init__(self, parent: RingProgressContainerWidget | None = None, client=Non 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") + self._hover_animation.setDuration(180) + easing_curve = ( + QtCore.QEasingCurve.Type.OutCubic + if hasattr(QtCore.QEasingCurve, "Type") + else QtCore.QEasingCurve.OutCubic + ) + self._hover_animation.setEasingCurve(easing_curve) self.set_start_angle(self.config.start_position) + def _refresh_hover_tooltip(self): + if self.progress_container and self.progress_container.is_ring_hovered(self): + self.progress_container.refresh_hover_tooltip(self) + def set_value(self, value: int | float): """ Set the value for the ring widget @@ -156,6 +170,7 @@ def set_line_width(self, width: int): width(int): Line width for the ring widget """ self.config.line_width = width + self._refresh_hover_tooltip() self.update() def set_min_max_values(self, min_value: int | float, max_value: int | float): @@ -168,6 +183,7 @@ def set_min_max_values(self, min_value: int | float, max_value: int | float): """ self.config.min_value = min_value self.config.max_value = max_value + self._refresh_hover_tooltip() self.update() def set_start_angle(self, start_angle: int): @@ -204,6 +220,7 @@ def set_update( self.bec_dispatcher.disconnect_slot(*self.registered_slot) self.config.mode = "manual" self.registered_slot = None + self._refresh_hover_tooltip() case "scan": if self.config.mode == "scan": return @@ -214,17 +231,20 @@ def set_update( self.on_scan_progress, MessageEndpoints.scan_progress() ) self.registered_slot = (self.on_scan_progress, MessageEndpoints.scan_progress()) + self._refresh_hover_tooltip() case "device": if self.registered_slot is not None: self.bec_dispatcher.disconnect_slot(*self.registered_slot) self.config.mode = "device" if device == "": self.registered_slot = None + self._refresh_hover_tooltip() return self.config.device = device # self.config.signal = self._get_signal_from_device(device, signal) signal = self._update_device_connection(device, signal) self.config.signal = signal + self._refresh_hover_tooltip() case _: raise ValueError(f"Unsupported mode: {mode}") @@ -237,6 +257,7 @@ def set_precision(self, precision: int): precision(int): Precision for the ring widget """ self.config.precision = precision + self._refresh_hover_tooltip() self.update() def set_direction(self, direction: int): @@ -247,6 +268,7 @@ def set_direction(self, direction: int): direction(int): Direction for the ring widget. -1 for clockwise, 1 for counter-clockwise. """ self.config.direction = direction + self._refresh_hover_tooltip() self.update() def _get_signals_for_device(self, device: str) -> dict[str, list[str]]: @@ -424,8 +446,11 @@ def paintEvent(self, event): 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 +458,25 @@ def paintEvent(self, event): # 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,6 +486,15 @@ def paintEvent(self, event): painter.drawArc(adjusted_rect, start_position, angle) painter.end() + 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() + def convert_color(self, color: str | tuple | QColor) -> QColor: """ Convert the color to QColor @@ -522,6 +568,7 @@ def value(self, value: float): float(max(self.config.min_value, min(self.config.max_value, value))), self.config.precision, ) + self._refresh_hover_tooltip() self.update() @SafeProperty(float) @@ -531,6 +578,7 @@ def min_value(self) -> float: @min_value.setter def min_value(self, value: float): self.config.min_value = value + self._refresh_hover_tooltip() self.update() @SafeProperty(float) @@ -540,6 +588,7 @@ def max_value(self) -> float: @max_value.setter def max_value(self, value: float): self.config.max_value = value + self._refresh_hover_tooltip() self.update() @SafeProperty(str) @@ -557,6 +606,7 @@ def device(self) -> str: @device.setter def device(self, value: str): self.config.device = value + self._refresh_hover_tooltip() @SafeProperty(str) def signal(self) -> str: @@ -565,6 +615,7 @@ def signal(self) -> str: @signal.setter def signal(self, value: str): self.config.signal = value + self._refresh_hover_tooltip() @SafeProperty(int) def line_width(self) -> int: @@ -573,6 +624,7 @@ def line_width(self) -> int: @line_width.setter def line_width(self, value: int): self.config.line_width = value + self._refresh_hover_tooltip() self.update() @SafeProperty(int) @@ -591,6 +643,7 @@ def precision(self) -> int: @precision.setter def precision(self, value: int): self.config.precision = value + self._refresh_hover_tooltip() self.update() @SafeProperty(int) @@ -602,6 +655,15 @@ def direction(self, value: int): self.config.direction = value self.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.update() + if __name__ == "__main__": # pragma: no cover import sys diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py index 0a6c8dd8b..577858bb8 100644 --- a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py @@ -3,7 +3,7 @@ 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.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 @@ def __init__(self, parent: QWidget | None = None, **kwargs): 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 @@ def add_ring(self, config: dict | None = None) -> Ring: """ 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 @@ def remove_ring(self, index: int | None = None): 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 @@ def initialize_center_label(self): 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,125 @@ def resizeEvent(self, event): 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): + self._hover_tooltip.hide() + 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 +365,9 @@ def clear_all(self): """ 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() From 2c140383e2034e806d20b7be5b3087b86c897a91 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Wed, 28 Jan 2026 10:00:12 +0100 Subject: [PATCH 2/4] fix(ring): minor general fixes --- .../widgets/progress/ring_progress_bar/ring.py | 7 ++++--- .../ring_progress_bar/ring_progress_settings_cards.py | 11 +++++++---- 2 files changed, 11 insertions(+), 7 deletions(-) diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring.py b/bec_widgets/widgets/progress/ring_progress_bar/ring.py index 57a35a471..e0417b7b0 100644 --- a/bec_widgets/widgets/progress/ring_progress_bar/ring.py +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring.py @@ -40,7 +40,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.") @@ -298,7 +298,7 @@ def _get_signals_for_device(self, device: str) -> dict[str, list[str]]: for obj in dev_obj._info["signals"].values() if obj["kind_str"] == "hinted" and obj["signal_class"] - not in ["ProgressSignal", "AyncSignal", "AsyncMultiSignal", "DynamicSignal"] + not in ["ProgressSignal", "AsyncSignal", "AsyncMultiSignal", "DynamicSignal"] ] normal_signals = [ @@ -495,7 +495,8 @@ def set_hovered(self, hovered: bool): self._hover_animation.setEndValue(1.0 if hovered else 0.0) self._hover_animation.start() - def convert_color(self, color: str | tuple | QColor) -> QColor: + @staticmethod + def convert_color(color: str | tuple | QColor) -> QColor: """ Convert the color to QColor diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_settings_cards.py b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_settings_cards.py index e3c0a9991..20d901644 100644 --- a/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_settings_cards.py +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring_progress_settings_cards.py @@ -63,7 +63,8 @@ def __init__(self, ring: Ring, container: RingProgressContainerWidget, parent=No 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 @@ def _on_device_changed(self, device: str): 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 @@ def _unify_mode_string(self, mode: str) -> str: 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": From ba1eece9130fa4e32b7bfea858a61ff5a2afc4dd Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 12:59:45 +0000 Subject: [PATCH 3/4] Initial plan From 7f9c8ad3a40b832560828423eb1887eb945f426e Mon Sep 17 00:00:00 2001 From: "copilot-swe-agent[bot]" <198982749+Copilot@users.noreply.github.com> Date: Tue, 17 Mar 2026 13:16:36 +0000 Subject: [PATCH 4/4] fix(ring): verify hover_progress Qt property is registered before animation use Co-authored-by: wyzula-jan <133381102+wyzula-jan@users.noreply.github.com> --- .../widgets/progress/ring_progress_bar/ring.py | 11 ++++++++++- 1 file changed, 10 insertions(+), 1 deletion(-) diff --git a/bec_widgets/widgets/progress/ring_progress_bar/ring.py b/bec_widgets/widgets/progress/ring_progress_bar/ring.py index 4b8576af3..5a0ab3e57 100644 --- a/bec_widgets/widgets/progress/ring_progress_bar/ring.py +++ b/bec_widgets/widgets/progress/ring_progress_bar/ring.py @@ -85,12 +85,21 @@ def __init__(self, parent: RingProgressContainerWidget | None = None, client=Non self._gap = 5 self._hovered = False self._hover_progress = 0.0 + # NOTE: b"hover_progress" must match the @SafeProperty(float) named 'hover_progress' + # defined on this class. QPropertyAnimation silently fails if the target property is + # absent or misnamed, so we verify registration immediately after construction. self._hover_animation = QtCore.QPropertyAnimation(self, b"hover_progress", parent=self) + if self.metaObject().indexOfProperty("hover_progress") == -1: # pragma: no cover + logger.warning( + "Ring: 'hover_progress' is not registered as a Qt property; " + "the hover animation will not work. Ensure the 'hover_progress' " + "SafeProperty is defined on this class." + ) self._hover_animation.setDuration(180) easing_curve = ( QtCore.QEasingCurve.Type.OutCubic if hasattr(QtCore.QEasingCurve, "Type") - else QtCore.QEasingCurve.Type.OutCubic + else QtCore.QEasingCurve.OutCubic ) self._hover_animation.setEasingCurve(easing_curve) self.set_start_angle(self.config.start_position)