fix(ring_progress_bar): added hover mouse effect

This commit is contained in:
2026-01-28 10:00:12 +01:00
committed by Jan Wyzula
parent e25b6604d1
commit 96b5179658
2 changed files with 209 additions and 4 deletions
@@ -82,8 +82,22 @@ 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")
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 @@ class Ring(BECConnector, QWidget):
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 @@ class Ring(BECConnector, QWidget):
"""
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 @@ class Ring(BECConnector, QWidget):
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 @@ class Ring(BECConnector, QWidget):
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 @@ class Ring(BECConnector, QWidget):
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 @@ class Ring(BECConnector, QWidget):
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 @@ 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 +458,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,6 +486,15 @@ class Ring(BECConnector, QWidget):
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 @@ class Ring(BECConnector, QWidget):
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 @@ class Ring(BECConnector, QWidget):
@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 @@ class Ring(BECConnector, QWidget):
@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 @@ class Ring(BECConnector, QWidget):
@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 @@ class Ring(BECConnector, QWidget):
@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 @@ class Ring(BECConnector, QWidget):
@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 @@ class Ring(BECConnector, QWidget):
@precision.setter
def precision(self, value: int):
self.config.precision = value
self._refresh_hover_tooltip()
self.update()
@SafeProperty(int)
@@ -602,6 +655,15 @@ class Ring(BECConnector, QWidget):
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
@@ -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()