0
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2025-07-13 03:01:50 +02:00

feat(widget_finder): widget to fetch any other widget by class from currently running app

This commit is contained in:
2025-07-08 17:57:13 +02:00
committed by Jan Wyzula
parent 783d042e8c
commit a9fce175b7
3 changed files with 361 additions and 0 deletions

View File

@ -0,0 +1,244 @@
from __future__ import annotations
from bec_qthemes import material_icon
from qtpy.QtCore import QPropertyAnimation, QRect, QSequentialAnimationGroup, Qt, QTimer
from qtpy.QtWidgets import (
QApplication,
QComboBox,
QFrame,
QGridLayout,
QGroupBox,
QPushButton,
QSizePolicy,
QToolButton,
QVBoxLayout,
QWidget,
)
from bec_widgets import SafeProperty
from bec_widgets.utils.widget_io import WidgetIO
from bec_widgets.widgets.containers.main_window.main_window import BECMainWindow
from bec_widgets.widgets.plots.image.image import Image
from bec_widgets.widgets.plots.waveform.waveform import Waveform
class WidgetFinderComboBox(QComboBox):
def __init__(self, parent=None, widget_class: type[QWidget] | str | None = None):
super().__init__(parent)
self.widget_class = widget_class
self.setSizePolicy(QSizePolicy.Minimum, QSizePolicy.Fixed)
self.setMinimumWidth(200)
# find button inside combobox
self.find_button = QToolButton(self)
self.find_button.setIcon(material_icon("frame_inspect"))
self.find_button.setCursor(Qt.PointingHandCursor)
self.find_button.setFocusPolicy(Qt.NoFocus)
self.find_button.setToolTip("Highlight selected widget")
self.find_button.setStyleSheet("QToolButton { border: none; padding: 0px; }")
self.find_button.clicked.connect(self.inspect_widget)
# refresh button inside combobox
self.refresh_button = QToolButton(self)
self.refresh_button.setIcon(material_icon("refresh"))
self.refresh_button.setCursor(Qt.PointingHandCursor)
self.refresh_button.setFocusPolicy(Qt.NoFocus)
self.refresh_button.setToolTip("Refresh widget list")
self.refresh_button.setStyleSheet("QToolButton { border: none; padding: 0px; }")
self.refresh_button.clicked.connect(self.refresh_list)
# Purple Highlighter
self.highlighter = None
# 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
arrow_width = 24
x = self.width() - arrow_width - btn_size - 2
y = (self.height() - btn_size) // 2 - 2
# position find_button first
self.find_button.setFixedSize(btn_size, btn_size)
self.find_button.move(x, y)
# position refresh_button to the left of find_button
refresh_x = x - btn_size - 2
self.refresh_button.setFixedSize(btn_size, btn_size)
self.refresh_button.move(refresh_x, y)
def refresh_list(self):
"""
Refresh the list of widgets in the combobox based on the specified widget class.
"""
self.clear()
if self.widget_class is None:
return
widgets = WidgetIO.find_widgets(self.widget_class, recursive=True)
# Build display names with counts for duplicates
name_counts: dict[str, int] = {}
for w in widgets:
base_name = w.objectName() or w.__class__.__name__
count = name_counts.get(base_name, 0) + 1
name_counts[base_name] = count
display_name = base_name if count == 1 else f"{base_name} ({count})"
self.addItem(display_name, w)
def showPopup(self):
"""
Refresh list each time the popup opens to reflect dynamic widget changes.
"""
self.refresh_list()
super().showPopup()
def inspect_widget(self):
"""
Inspect the currently selected widget in the combobox.
"""
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()
@SafeProperty(str)
def widget_class_name(self) -> str:
"""
Get or set the target widget class by name.
"""
return (
self.widget_class if isinstance(self.widget_class, str) else self.widget_class.__name__
)
@widget_class_name.setter
def widget_class_name(self, name: str):
self.widget_class = name
self.refresh_list()
@property
def selected_widget(self):
"""
The currently selected QWidget instance (or None if not found).
"""
try:
return self.currentData()
except Exception:
return None
def cleanup(self):
"""
Clean up the highlighter frame when the combobox is deleted.
"""
if self.highlighter:
self.highlighter.close()
self.highlighter.deleteLater()
self.highlighter = None
def closeEvent(self, event):
"""
Override closeEvent to clean up the highlighter frame.
"""
self.cleanup()
event.accept()
class InspectorMainWindow(BECMainWindow): # pragma: no cover
"""
A main window that includes a widget finder combobox to inspect widgets.
"""
def __init__(self, parent=None):
super().__init__(parent)
self.setWindowTitle("Widget Inspector")
self.setMinimumSize(800, 600)
self.central_widget = QWidget(self)
self.setCentralWidget(self.central_widget)
self.central_widget.layout = QGridLayout(self.central_widget)
# Inspector box
self.group_box_inspector = QGroupBox(self.central_widget)
self.group_box_inspector.setTitle("Inspector")
self.group_box_inspector.layout = QVBoxLayout(self.group_box_inspector)
self.inspector_combobox = WidgetFinderComboBox(self.group_box_inspector, Waveform)
self.switch_combobox = QComboBox(self.group_box_inspector)
self.switch_combobox.addItems(["Waveform", "Image", "QPushButton"])
self.switch_combobox.setToolTip("Switch the widget class to inspect")
self.switch_combobox.currentTextChanged.connect(
lambda text: setattr(self.inspector_combobox, "widget_class_name", text)
)
self.group_box_inspector.layout.addWidget(self.inspector_combobox)
self.group_box_inspector.layout.addWidget(self.switch_combobox)
# Some bec widgets to inspect
self.wf1 = Waveform(self.central_widget)
self.wf2 = Waveform(self.central_widget)
self.im1 = Image(self.central_widget)
self.im2 = Image(self.central_widget)
# Some normal widgets to inspect
self.group_box_widgets = QGroupBox(self.central_widget)
self.group_box_widgets.setTitle("Widgets ")
self.group_box_widgets.layout = QVBoxLayout(self.group_box_widgets)
self.btn1 = QPushButton("Button 1", self.group_box_widgets)
self.btn1.setObjectName("btn1")
self.btn2 = QPushButton("Button 2", self.group_box_widgets)
self.btn2.setObjectName("btn1") # Same object name to test duplicate handling
self.btn3 = QPushButton("Button 3", self.group_box_widgets)
self.btn3.setObjectName("btn3")
self.group_box_widgets.layout.addWidget(self.btn1)
self.group_box_widgets.layout.addWidget(self.btn2)
self.group_box_widgets.layout.addWidget(self.btn3)
self.central_widget.layout.addWidget(self.group_box_inspector, 0, 0)
self.central_widget.layout.addWidget(self.group_box_widgets, 1, 0)
self.central_widget.layout.addWidget(self.wf1, 0, 1)
self.central_widget.layout.addWidget(self.wf2, 1, 1)
self.central_widget.layout.addWidget(self.im1, 0, 2)
self.central_widget.layout.addWidget(self.im2, 1, 2)
if __name__ == "__main__": # pragma: no cover
import sys
app = QApplication(sys.argv)
main_window = InspectorMainWindow()
main_window.show()
sys.exit(app.exec())

View File

@ -0,0 +1,117 @@
import pytest
from qtpy.QtCore import QPoint, QSize, 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
def finder_fixture(qtbot):
central_widget = QWidget()
central_widget.layout = QVBoxLayout(central_widget)
# Create some buttons and a label under parent
btn1 = QPushButton("Button1", central_widget)
btn1.setObjectName("btn1")
btn2 = QPushButton("Button2", central_widget)
btn2.setObjectName("btn2")
lbl1 = QLabel("Label1", central_widget)
lbl1.setObjectName("lbl1")
# Instantiate finder to look for QPushButton
finder = WidgetFinderComboBox(central_widget, QPushButton)
# Add buttons and label to the layout
central_widget.layout.addWidget(btn1)
central_widget.layout.addWidget(btn2)
central_widget.layout.addWidget(lbl1)
central_widget.layout.addWidget(finder)
qtbot.addWidget(central_widget)
qtbot.waitExposed(central_widget)
return finder, central_widget, btn1, btn2, lbl1
def test_initial_list_contains_buttons_only(qtbot, finder_fixture):
finder, parent, btn1, btn2, lbl1 = finder_fixture
items = [finder.itemText(i) for i in range(finder.count())]
assert "btn1" in items
assert "btn2" in items
assert "lbl1" not in items
def test_refresh_and_show_popup_update_list(finder_fixture, qtbot):
finder, parent, btn1, btn2, lbl1 = finder_fixture
# Dynamically add a third button
btn3 = QPushButton("Button3", parent)
btn3.setObjectName("btn3")
# Manual refresh
qtbot.mouseClick(finder.refresh_button, Qt.LeftButton)
items = [finder.itemText(i) for i in range(finder.count())]
assert "btn3" in items
# And via showPopup
btn4 = QPushButton("Button4", parent)
btn4.setObjectName("btn4")
finder.showPopup()
items = [finder.itemText(i) for i in range(finder.count())]
assert "btn4" in items
def test_selected_widget_and_widget_class_name_setter(finder_fixture, qtbot):
finder, parent, btn1, btn2, lbl1 = finder_fixture
# Select btn2
idx = finder.findText("btn2")
finder.setCurrentIndex(idx)
qtbot.wait(200) # allow refresh_list to run
selected_widget = finder.selected_widget
assert selected_widget == btn2
# Now switch to QLabel via the property setter
finder.widget_class_name = "QLabel"
qtbot.wait(200) # allow refresh_list to run
items = [finder.itemText(i) for i in range(finder.count())]
assert "lbl1" in items
assert "btn1" not in items
def test_inspect_widget_highlights_button(qtbot, finder_fixture):
finder, parent, btn1, btn2, lbl1 = finder_fixture
# Select btn1 and inspect
idx = finder.findText("btn1")
finder.setCurrentIndex(idx)
finder.inspect_widget()
qtbot.wait(100) # allow highlighter to show
highlighter = finder.highlighter
assert highlighter.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
def test_inspect_widget_highlights_label(qtbot, finder_fixture):
finder, parent, btn1, btn2, lbl1 = finder_fixture
# Switch to QLabel and inspect lbl1
finder.widget_class_name = "QLabel"
qtbot.wait(50) # allow refresh
idx = finder.findText("lbl1")
finder.setCurrentIndex(idx)
finder.inspect_widget()
qtbot.wait(100) # allow highlighter to show
highlighter = finder.highlighter
assert highlighter.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