diff --git a/bec_widgets/widgets/utility/widget_finder/__init__.py b/bec_widgets/widgets/utility/widget_finder/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/utility/widget_finder/widget_finder.py b/bec_widgets/widgets/utility/widget_finder/widget_finder.py new file mode 100644 index 00000000..20e2839f --- /dev/null +++ b/bec_widgets/widgets/utility/widget_finder/widget_finder.py @@ -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()) diff --git a/tests/unit_tests/test_widget_finder.py b/tests/unit_tests/test_widget_finder.py new file mode 100644 index 00000000..1dc6a1ba --- /dev/null +++ b/tests/unit_tests/test_widget_finder.py @@ -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