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:
95
bec_widgets/utils/widget_highlighter.py
Normal file
95
bec_widgets/utils/widget_highlighter.py
Normal 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
|
||||
@@ -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):
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user