Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
51 changes: 48 additions & 3 deletions bec_widgets/widgets/progress/ring_progress_bar/ring.py
Original file line number Diff line number Diff line change
Expand Up @@ -82,6 +82,16 @@ 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)
Comment on lines +87 to +94
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The easing curve fallback logic handles both old and new versions of Qt/qtpy. However, the property animation is targeting b"hover_progress" which is defined later as a QProperty. If this property definition is missing or incorrect, the animation will silently fail. Consider adding a check or assertion to verify that the property animation target is valid, or document this dependency clearly.

Copilot uses AI. Check for mistakes.
self.set_start_angle(self.config.start_position)

Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The animation object should be stopped and cleaned up when the Ring is destroyed to prevent potential memory leaks or animation issues. Consider adding self._hover_animation.stop() in the cleanup method.

Suggested change
def cleanup(self) -> None:
"""
Clean up resources associated with this progress bar.
Stops the hover animation and delegates to any base-class cleanup if available.
"""
parent_cleanup = getattr(super(), "cleanup", None)
if callable(parent_cleanup):
parent_cleanup()
# Ensure the hover animation is stopped to prevent it from running on a destroyed object.
self._hover_animation.stop()

Copilot uses AI. Check for mistakes.
def set_value(self, value: int | float):
Expand Down Expand Up @@ -424,22 +434,37 @@ 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

# 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
)
Comment on lines +452 to +463
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The hover effect increases both the line width and the radius of the ring. The radius expansion (hover_radius_delta = 4.0) could cause the hovered ring to overlap with adjacent rings, especially when rings are closely spaced. This might create visual artifacts or confusion about which ring is actually hovered. Consider either:

  1. Reducing the radius expansion to prevent overlap
  2. Accounting for the gap between rings when calculating the expansion
  3. Only expanding the line width without changing the radius
Suggested change
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
)
# Keep hover feedback limited to line width changes to avoid overlapping adjacent rings.
# The adjusted_rect (and thus radius) is not scaled based on hover state.

Copilot uses AI. Check for mistakes.
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) / (
Expand All @@ -449,6 +474,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
Expand Down Expand Up @@ -522,6 +556,8 @@ def value(self, value: float):
float(max(self.config.min_value, min(self.config.max_value, value))),
self.config.precision,
)
if self.progress_container and self.progress_container.is_ring_hovered(self):
self.progress_container._update_hover_tooltip(self)
Comment on lines +559 to +560
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

When the value changes, the tooltip is updated only if this ring is currently hovered. However, there's a potential race condition: if the hover state changes between checking is_ring_hovered and calling _update_hover_tooltip, the tooltip might not be updated correctly. Additionally, calling _update_hover_tooltip without the event parameter means it falls back to setToolTip, which may not display correctly when the mouse is already over the ring. Consider passing the last known mouse position or handling this case differently.

Copilot uses AI. Check for mistakes.
self.update()

@SafeProperty(float)
Expand Down Expand Up @@ -602,6 +638,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
Expand Down
102 changes: 100 additions & 2 deletions bec_widgets/widgets/progress/ring_progress_bar/ring_progress_bar.py
Original file line number Diff line number Diff line change
Expand Up @@ -3,8 +3,8 @@

import pyqtgraph as pg
from bec_lib.logger import bec_logger
from qtpy.QtCore import QSize, Qt
from qtpy.QtWidgets import QHBoxLayout, QLabel, QVBoxLayout, QWidget
from qtpy.QtCore import QPointF, QSize, Qt
from qtpy.QtWidgets import QHBoxLayout, QLabel, QToolTip, QVBoxLayout, QWidget

Comment on lines +7 to 8
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The imports from qtpy.QtWidgets are split across two lines (line 7 and line 8). While this works correctly, it would be more maintainable to combine these into a single import statement for consistency and readability, e.g., from qtpy.QtWidgets import QHBoxLayout, QLabel, QToolTip, QVBoxLayout, QWidget.

Copilot uses AI. Check for mistakes.
from bec_widgets.utils import Colors
from bec_widgets.utils.bec_widget import BECWidget
Expand All @@ -29,7 +29,9 @@ 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.setLayout(QHBoxLayout())
self.setMouseTracking(True)
self.initialize_bars()
self.initialize_center_label()

Expand Down Expand Up @@ -59,6 +61,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)
Expand Down Expand Up @@ -106,6 +109,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):
Expand Down Expand Up @@ -150,6 +154,100 @@ def resizeEvent(self, event):
for ring in self.rings:
ring.setGeometry(self.rect())

def mouseMoveEvent(self, event):
pos = event.position() if hasattr(event, "position") else QPointF(event.pos())
ring = self._ring_at_pos(pos)
Comment on lines 154 to +159
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The mouseMoveEvent is called frequently as the mouse moves, and each call triggers _ring_at_pos which iterates through all rings and performs floating-point calculations. For widgets with many rings, this could impact performance. Consider optimizing by:

  1. Caching ring geometries and only recalculating on resize
  2. Early exit when a ring is found (already implemented)
  3. Adding a distance threshold to avoid unnecessary calculations when the mouse is far from any ring
Suggested change
for ring in self.rings:
ring.setGeometry(self.rect())
def mouseMoveEvent(self, event):
pos = event.position() if hasattr(event, "position") else QPointF(event.pos())
ring = self._ring_at_pos(pos)
rect = self.rect()
for ring in self.rings:
ring.setGeometry(rect)
# Cache approximate ring center and maximum radius for fast hover checks
center = rect.center()
self._ring_center = QPointF(center)
# Use half of the smaller widget dimension as an outer radius approximation
self._max_ring_radius = min(rect.width(), rect.height()) / 2.0
def mouseMoveEvent(self, event):
pos = event.position() if hasattr(event, "position") else QPointF(event.pos())
ring = None
center = getattr(self, "_ring_center", None)
max_radius = getattr(self, "_max_ring_radius", None)
if center is not None and max_radius is not None:
dx = pos.x() - center.x()
dy = pos.y() - center.y()
# Add a small margin to the radius to avoid missing near-edge hovers
outer_radius = max_radius + 5.0
if (dx * dx + dy * dy) <= (outer_radius * outer_radius):
ring = self._ring_at_pos(pos)
else:
ring = self._ring_at_pos(pos)

Copilot uses AI. Check for mistakes.
self._set_hovered_ring(ring, event)
super().mouseMoveEvent(event)

Comment on lines +158 to +162
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The new hover functionality (mouse tracking, hover state management, and tooltip display) lacks test coverage. Given that the repository has comprehensive test coverage for ring_progress_bar functionality (as seen in tests/unit_tests/test_ring_progress_bar.py) and includes hover tests for other widgets (e.g., tests/unit_tests/test_macro_tree_widget.py::test_hover_events), consider adding tests that verify:

  1. Hover state transitions when mouse enters/leaves rings
  2. Tooltip content and display for different ring configurations
  3. Ring detection at various positions (_ring_at_pos logic)
  4. Animation behavior during hover state changes
  5. Value updates correctly updating tooltips while hovered

Copilot uses AI. Check for mistakes.
def leaveEvent(self, event):
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._update_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._update_hover_tooltip(self._hovered_ring, event)
else:
QToolTip.hideText()

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

Comment on lines +207 to +214
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The ring detection logic uses ring.config.line_width to calculate the inner and outer boundaries. However, when hover is active, the actual line width is current_line_width = base_line_width + (hover_line_delta * self._hover_progress) (see ring.py line 439). This discrepancy means that when a ring is hovered and grows, the detection area doesn't grow with it, potentially causing the hover state to flicker or become unstable when the mouse is at the edge of the ring. Consider using the current actual line width in the detection calculation or accounting for the maximum possible hover expansion.

Copilot uses AI. Check for mistakes.
return best_ring

def is_ring_hovered(self, ring: Ring) -> bool:
return ring is self._hovered_ring

def _update_hover_tooltip(self, ring: Ring, event=None):
text = self._build_tooltip_text(ring)
if event is not None:
global_pos = (
event.globalPosition().toPoint()
if hasattr(event, "globalPosition")
else event.globalPos()
)
QToolTip.showText(global_pos, text, self)
else:
self.setToolTip(text)

def _build_tooltip_text(self, ring: Ring) -> str:
mode = ring.config.mode
mode_label = {"manual": "Manual", "scan": "Scan progress", "device": "Device"}.get(
mode, mode
)

precision = int(ring.config.precision)
Copy link

Copilot AI Jan 28, 2026

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

The tooltip formatting uses f"{ring.config.value:.{precision}f}" where precision comes from ring.config.precision. If precision is negative or excessively large, this could cause a ValueError or produce unexpected output. While this is likely validated elsewhere in the config, consider adding defensive bounds checking (e.g., precision = max(0, min(10, int(ring.config.precision)))) to ensure robust tooltip generation.

Suggested change
precision = int(ring.config.precision)
# Ensure precision is a safe, bounded integer to avoid formatting errors
raw_precision = getattr(ring.config, "precision", 0)
try:
precision_int = int(raw_precision)
except (TypeError, ValueError):
precision_int = 0
precision = max(0, min(10, precision_int))

Copilot uses AI. Check for mistakes.
value = f"{ring.config.value:.{precision}f}"
max_value = f"{ring.config.max_value:.{precision}f}"

lines = [f"Mode: {mode_label}", f"Value: {value} / {max_value}"]
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 set_colors_from_map(self, colormap, color_format: Literal["RGB", "HEX"] = "RGB"):
"""
Set the colors for the progress bars from a colormap.
Expand Down
Loading