1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-03-04 16:02:51 +01:00

feat(widget_highlighter): reusable separate widget highlighter

This commit is contained in:
2026-01-06 16:56:30 +01:00
committed by Jan Wyzula
parent d9cd041880
commit 38d593941b
3 changed files with 113 additions and 53 deletions

View File

@@ -0,0 +1,95 @@
from __future__ import annotations
import shiboken6
from qtpy.QtCore import QPropertyAnimation, QRect, QSequentialAnimationGroup, Qt
from qtpy.QtWidgets import QFrame, QWidget
class WidgetHighlighter:
"""
Utility that highlights widgets by drawing a temporary frame around them.
"""
def __init__(
self,
*,
frame_parent: QWidget | None = None,
window_flags: Qt.WindowType | Qt.WindowFlags = Qt.WindowType.Tool
| Qt.WindowType.FramelessWindowHint
| Qt.WindowType.WindowStaysOnTopHint,
style_sheet: str = "border: 2px solid #FF00FF; border-radius: 6px; background: transparent;",
) -> None:
self._frame_parent = frame_parent
self._window_flags = window_flags
self._style_sheet = style_sheet
self._frame: QFrame | None = None
self._animation_group: QSequentialAnimationGroup | None = None
def highlight(self, widget: QWidget | None) -> None:
"""
Highlight the given widget with a pulsing frame.
"""
if widget is None or not shiboken6.isValid(widget):
return
frame = self._ensure_frame()
frame.hide()
geom = widget.frameGeometry()
top_left = widget.mapToGlobal(widget.rect().topLeft())
frame.setGeometry(top_left.x(), top_left.y(), geom.width(), geom.height())
frame.setWindowOpacity(1.0)
frame.show()
start_rect = QRect(
top_left.x() - 5, top_left.y() - 5, geom.width() + 10, geom.height() + 10
)
pulse = QPropertyAnimation(frame, b"geometry", frame)
pulse.setDuration(300)
pulse.setStartValue(start_rect)
pulse.setEndValue(QRect(top_left.x(), top_left.y(), geom.width(), geom.height()))
fade = QPropertyAnimation(frame, b"windowOpacity", frame)
fade.setDuration(2000)
fade.setStartValue(1.0)
fade.setEndValue(0.0)
fade.finished.connect(frame.hide)
if self._animation_group is not None:
old_group = self._animation_group
self._animation_group = None
old_group.stop()
old_group.deleteLater()
animation = QSequentialAnimationGroup(frame)
animation.addAnimation(pulse)
animation.addAnimation(fade)
animation.start()
self._animation_group = animation
def cleanup(self) -> None:
"""
Delete the highlight frame and cancel pending animations.
"""
if self._animation_group is not None:
self._animation_group.stop()
self._animation_group.deleteLater()
self._animation_group = None
if self._frame is not None:
self._frame.hide()
self._frame.deleteLater()
self._frame = None
@property
def frame(self) -> QFrame | None:
"""Return the currently allocated highlight frame (if any)."""
return self._frame
def _ensure_frame(self) -> QFrame:
if self._frame is None:
self._frame = QFrame(self._frame_parent, self._window_flags)
self._frame.setAttribute(Qt.WidgetAttribute.WA_TransparentForMouseEvents)
self._frame.setStyleSheet(self._style_sheet)
return self._frame

View File

@@ -1,11 +1,10 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import QPropertyAnimation, QRect, QSequentialAnimationGroup, Qt, QTimer
from qtpy.QtCore import Qt, QTimer
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QFrame,
QGridLayout,
QGroupBox,
QPushButton,
@@ -16,6 +15,7 @@ from qtpy.QtWidgets import (
)
from bec_widgets import SafeProperty
from bec_widgets.utils.widget_highlighter import WidgetHighlighter
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindowNoRPC
from bec_widgets.widgets.plots.image.image import Image
@@ -49,22 +49,11 @@ class WidgetFinderComboBox(QComboBox):
self.refresh_button.setStyleSheet("QToolButton { border: none; padding: 0px; }")
self.refresh_button.clicked.connect(self.refresh_list)
# Purple Highlighter
self.highlighter = None
self.highlighter = WidgetHighlighter()
# refresh items - delay to fetch widgets after UI is ready in next event loop
QTimer.singleShot(0, self.refresh_list)
def _init_highlighter(self):
"""
Initialize the highlighter frame that will be used to highlight the inspected widget.
"""
self.highlighter = QFrame(self, Qt.Tool | Qt.FramelessWindowHint | Qt.WindowStaysOnTopHint)
self.highlighter.setAttribute(Qt.WA_TransparentForMouseEvents)
self.highlighter.setStyleSheet(
"border: 2px solid #FF00FF; border-radius: 6px; background: transparent;"
)
def resizeEvent(self, event):
super().resizeEvent(event)
btn_size = 16
@@ -110,33 +99,7 @@ class WidgetFinderComboBox(QComboBox):
target = self.currentData()
if not target:
return
# ensure highlighter exists, avoid calling methods on deleted C++ object
if not getattr(self, "highlighter", None):
self._init_highlighter()
else:
self.highlighter.hide()
# draw new
geom = target.frameGeometry()
pos = target.mapToGlobal(target.rect().topLeft())
self.highlighter.setGeometry(pos.x(), pos.y(), geom.width(), geom.height())
self.highlighter.show()
# Pulse and fade animation to draw attention
start_rect = QRect(pos.x() - 5, pos.y() - 5, geom.width() + 10, geom.height() + 10)
pulse = QPropertyAnimation(self.highlighter, b"geometry")
pulse.setDuration(300)
pulse.setStartValue(start_rect)
pulse.setEndValue(QRect(pos.x(), pos.y(), geom.width(), geom.height()))
fade = QPropertyAnimation(self.highlighter, b"windowOpacity")
fade.setDuration(2000)
fade.setStartValue(1.0)
fade.setEndValue(0.0)
fade.finished.connect(self.highlighter.hide)
group = QSequentialAnimationGroup(self)
group.addAnimation(pulse)
group.addAnimation(fade)
group.start()
self.highlighter.highlight(target)
@SafeProperty(str)
def widget_class_name(self) -> str:
@@ -167,9 +130,7 @@ class WidgetFinderComboBox(QComboBox):
Clean up the highlighter frame when the combobox is deleted.
"""
if self.highlighter:
self.highlighter.close()
self.highlighter.deleteLater()
self.highlighter = None
self.highlighter.cleanup()
def closeEvent(self, event):
"""

View File

@@ -1,9 +1,8 @@
import pytest
from qtpy.QtCore import QPoint, QSize, Qt
from qtpy.QtCore import Qt
from qtpy.QtWidgets import QLabel, QPushButton, QVBoxLayout, QWidget
from bec_widgets.widgets.utility.widget_finder.widget_finder import WidgetFinderComboBox
from tests.unit_tests.conftest import create_widget
@pytest.fixture
@@ -31,7 +30,10 @@ def finder_fixture(qtbot):
qtbot.addWidget(central_widget)
qtbot.waitExposed(central_widget)
return finder, central_widget, btn1, btn2, lbl1
yield finder, central_widget, btn1, btn2, lbl1
finder.cleanup()
central_widget.close()
def test_initial_list_contains_buttons_only(qtbot, finder_fixture):
@@ -89,12 +91,13 @@ def test_inspect_widget_highlights_button(qtbot, finder_fixture):
finder.inspect_widget()
qtbot.wait(100) # allow highlighter to show
highlighter = finder.highlighter
assert highlighter.isVisible()
frame = finder.highlighter.frame
assert frame is not None
assert frame.isVisible()
qtbot.wait(500) # wait ≥ pulse duration
# Highlighter should match the target widget size
expected_size = btn1.frameGeometry().size()
assert highlighter.geometry().size() == expected_size
assert frame.geometry().size() == expected_size
def test_inspect_widget_highlights_label(qtbot, finder_fixture):
@@ -108,10 +111,11 @@ def test_inspect_widget_highlights_label(qtbot, finder_fixture):
finder.inspect_widget()
qtbot.wait(100) # allow highlighter to show
highlighter = finder.highlighter
assert highlighter.isVisible()
frame = finder.highlighter.frame
assert frame is not None
assert frame.isVisible()
qtbot.wait(500) # wait ≥ pulse duration
# Highlighter should match the target widget size
expected_size = lbl1.frameGeometry().size()
assert highlighter.geometry().size() == expected_size
assert frame.geometry().size() == expected_size