diff --git a/bec_widgets/utils/widget_highlighter.py b/bec_widgets/utils/widget_highlighter.py new file mode 100644 index 00000000..2c45544f --- /dev/null +++ b/bec_widgets/utils/widget_highlighter.py @@ -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 diff --git a/bec_widgets/widgets/utility/widget_finder/widget_finder.py b/bec_widgets/widgets/utility/widget_finder/widget_finder.py index b13a5edb..bee2b1f0 100644 --- a/bec_widgets/widgets/utility/widget_finder/widget_finder.py +++ b/bec_widgets/widgets/utility/widget_finder/widget_finder.py @@ -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): """ diff --git a/tests/unit_tests/test_widget_finder.py b/tests/unit_tests/test_widget_finder.py index 1dc6a1ba..c862011f 100644 --- a/tests/unit_tests/test_widget_finder.py +++ b/tests/unit_tests/test_widget_finder.py @@ -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