From d42c9fccae24da6ced9ae935c25d3371749824c3 Mon Sep 17 00:00:00 2001 From: wyzula-jan Date: Mon, 5 Jan 2026 15:13:17 +0100 Subject: [PATCH] feat(widget_hierarchy_tree): widget displaying parent child hierarchy from the app widgets --- .../containers/main_window/main_window.py | 26 +- .../utility/widget_hierarchy_tree/__init__.py | 0 .../widget_hierarchy_tree.py | 242 ++++++++++++++++++ .../unit_tests/test_widget_hierarchy_tree.py | 150 +++++++++++ 4 files changed, 417 insertions(+), 1 deletion(-) create mode 100644 bec_widgets/widgets/utility/widget_hierarchy_tree/__init__.py create mode 100644 bec_widgets/widgets/utility/widget_hierarchy_tree/widget_hierarchy_tree.py create mode 100644 tests/unit_tests/test_widget_hierarchy_tree.py diff --git a/bec_widgets/widgets/containers/main_window/main_window.py b/bec_widgets/widgets/containers/main_window/main_window.py index e33bb8a0..d82c136e 100644 --- a/bec_widgets/widgets/containers/main_window/main_window.py +++ b/bec_widgets/widgets/containers/main_window/main_window.py @@ -1,7 +1,6 @@ from __future__ import annotations import os -from typing import TYPE_CHECKING from bec_lib.endpoints import MessageEndpoints from qtpy.QtCore import QEvent, QSize, Qt, QTimer @@ -31,6 +30,9 @@ from bec_widgets.widgets.containers.main_window.addons.notification_center.notif from bec_widgets.widgets.containers.main_window.addons.scroll_label import ScrollLabel from bec_widgets.widgets.containers.main_window.addons.web_links import BECWebLinksMixin from bec_widgets.widgets.progress.scan_progressbar.scan_progressbar import ScanProgressBar +from bec_widgets.widgets.utility.widget_hierarchy_tree.widget_hierarchy_tree import ( + WidgetHierarchyDialog, +) MODULE_PATH = os.path.dirname(bec_widgets.__file__) @@ -57,6 +59,7 @@ class BECMainWindow(BECWidget, QMainWindow): self.notification_broker = BECNotificationBroker(parent=self) self._nc_margin = 16 self._position_notification_centre() + self._widget_hierarchy_dialog: WidgetHierarchyDialog | None = None # Init ui self._init_ui() @@ -312,6 +315,11 @@ class BECMainWindow(BECWidget, QMainWindow): light_theme_action.triggered.connect(lambda: self.change_theme("light")) dark_theme_action.triggered.connect(lambda: self.change_theme("dark")) + theme_menu.addSeparator() + widget_tree_action = QAction("Show Widget Hierarchy", self) + widget_tree_action.triggered.connect(self._show_widget_hierarchy_dialog) + theme_menu.addAction(widget_tree_action) + # Set the default theme if hasattr(self.app, "theme") and self.app.theme: theme_name = self.app.theme.theme.lower() @@ -395,7 +403,23 @@ class BECMainWindow(BECWidget, QMainWindow): return True return super().event(event) + def _show_widget_hierarchy_dialog(self): + if self._widget_hierarchy_dialog is None: + dialog = WidgetHierarchyDialog(root_widget=None, parent=self) + dialog.setAttribute(Qt.WidgetAttribute.WA_DeleteOnClose, True) + dialog.destroyed.connect(lambda: setattr(self, "_widget_hierarchy_dialog", None)) + self._widget_hierarchy_dialog = dialog + self._widget_hierarchy_dialog.refresh() + self._widget_hierarchy_dialog.show() + self._widget_hierarchy_dialog.raise_() + self._widget_hierarchy_dialog.activateWindow() + def cleanup(self): + # Widget hierarchy dialog cleanup + if self._widget_hierarchy_dialog is not None: + self._widget_hierarchy_dialog.close() + self._widget_hierarchy_dialog = None + # Timer cleanup if hasattr(self, "_client_info_expire_timer") and self._client_info_expire_timer.isActive(): self._client_info_expire_timer.stop() diff --git a/bec_widgets/widgets/utility/widget_hierarchy_tree/__init__.py b/bec_widgets/widgets/utility/widget_hierarchy_tree/__init__.py new file mode 100644 index 00000000..e69de29b diff --git a/bec_widgets/widgets/utility/widget_hierarchy_tree/widget_hierarchy_tree.py b/bec_widgets/widgets/utility/widget_hierarchy_tree/widget_hierarchy_tree.py new file mode 100644 index 00000000..c3ec0266 --- /dev/null +++ b/bec_widgets/widgets/utility/widget_hierarchy_tree/widget_hierarchy_tree.py @@ -0,0 +1,242 @@ +from __future__ import annotations + +import shiboken6 +from bec_lib import bec_logger +from bec_qthemes import material_icon +from qtpy.QtCore import Qt +from qtpy.QtWidgets import ( + QApplication, + QCheckBox, + QComboBox, + QDialog, + QDialogButtonBox, + QHBoxLayout, + QHeaderView, + QToolButton, + QTreeWidget, + QTreeWidgetItem, + QVBoxLayout, + QWidget, +) + +from bec_widgets.utils import BECConnector +from bec_widgets.utils.widget_highlighter import WidgetHighlighter +from bec_widgets.utils.widget_io import WidgetHierarchy + +logger = bec_logger.logger + + +class WidgetHierarchyDialog(QDialog): + """Popup dialog listing all widgets currently alive in the QApplication.""" + + def __init__(self, root_widget: QWidget | None = None, parent: QWidget | None = None): + super().__init__(parent) + self.root_widget = root_widget + self.setWindowTitle("Widget Hierarchy") + self.resize(520, 640) + + layout = QVBoxLayout(self) + controls = QHBoxLayout() + self._only_bec_checkbox = QCheckBox("Show only BECConnector widgets", self) + controls.addWidget(self._only_bec_checkbox) + self._visibility_filter = QComboBox(self) + self._visibility_filter.addItem("All widgets", "all") + self._visibility_filter.addItem("Visible only", "visible") + self._visibility_filter.addItem("Hidden only", "hidden") + controls.addWidget(self._visibility_filter) + self._refresh_button = QToolButton(self) + self._refresh_button.setText("Refresh") + self._refresh_button.setCursor(Qt.CursorShape.PointingHandCursor) + self._refresh_button.setAutoRaise(True) + self._refresh_button.setToolTip("Reload widget tree") + self._refresh_button.clicked.connect(self._refresh_tree) + controls.addWidget(self._refresh_button) + controls.addStretch() + layout.addLayout(controls) + + self._tree = QTreeWidget(self) + self._tree.setAlternatingRowColors(True) + self._tree.setColumnCount(4) + self._tree.setHeaderLabels(["Widget", "GUI ID", "Visible", "Find"]) + header = self._tree.header() + header.setStretchLastSection(False) + header.setSectionResizeMode(0, QHeaderView.ResizeMode.Interactive) + self._tree.setColumnWidth(0, 260) + header.setSectionResizeMode(1, QHeaderView.ResizeMode.Stretch) + self._tree.setColumnWidth(1, 160) + header.setSectionResizeMode(2, QHeaderView.ResizeMode.Fixed) + self._tree.setColumnWidth(2, 80) + header.setSectionResizeMode(3, QHeaderView.ResizeMode.Fixed) + self._tree.setColumnWidth(3, 40) + header.setSectionsMovable(True) + layout.addWidget(self._tree) + + button_box = QDialogButtonBox(QDialogButtonBox.StandardButton.Close, parent=self) + button_box.rejected.connect(self.reject) + layout.addWidget(button_box) + + self._only_bec_checkbox.toggled.connect(self._refresh_tree) + self._visibility_filter.currentIndexChanged.connect(self._refresh_tree) + self._highlighter = WidgetHighlighter() + self._refresh_tree() + + def refresh(self) -> None: + self._refresh_tree() + + def closeEvent(self, event): + if self._highlighter is not None: + self._highlighter.cleanup() + super().closeEvent(event) + + def _refresh_tree(self) -> None: + self._tree.clear() + only_bec = self._only_bec_checkbox.isChecked() + roots = self._collect_root_widgets() + widget_items: dict[QWidget, QTreeWidgetItem] = {} + seen: set[int] = set() + for root in roots: + for node in WidgetHierarchy.iter_widget_tree(root): + widget = node.widget + widget_id = id(widget) + if widget_id in seen: + continue + seen.add(widget_id) + + if self._is_dialog_ancestor(widget): + continue + + if only_bec and not isinstance(widget, BECConnector): + continue + + parent_widget = ( + WidgetHierarchy.get_becwidget_ancestor(widget) if only_bec else node.parent + ) + parent_item = widget_items.get(parent_widget) + item = self._create_tree_item(widget) + if parent_item is None: + self._tree.addTopLevelItem(item) + else: + parent_item.addChild(item) + self._add_highlight_button(item, widget) + widget_items[widget] = item + self._tree.expandAll() + self._tree.resizeColumnToContents(0) + self._tree.resizeColumnToContents(1) + self._filter_tree_by_visibility() + + def _collect_root_widgets(self) -> list[QWidget]: + if self.root_widget and shiboken6.isValid(self.root_widget): + return [self.root_widget] + app = QApplication.instance() + if app is None: + return [] + roots: list[QWidget] = [] + seen: set[int] = set() + for widget in app.allWidgets(): + if not shiboken6.isValid(widget): + continue + parent = widget.parent() + if parent is not None and shiboken6.isValid(parent): + continue + key = id(widget) + if key in seen: + continue + seen.add(key) + roots.append(widget) + return roots + + def _create_tree_item(self, widget: QWidget) -> QTreeWidgetItem: + labels = [ + self._format_widget_label(widget), + self._get_gui_id(widget), + self._visible_label(widget), + "", + ] + item = QTreeWidgetItem(labels) + item.setData(0, Qt.ItemDataRole.UserRole, widget) + item.setTextAlignment(2, Qt.AlignmentFlag.AlignCenter) + return item + + @staticmethod + def _format_widget_label(widget: QWidget) -> str: + object_name = widget.objectName() or "" + return f"{widget.__class__.__name__} ({object_name})" + + @staticmethod + def _get_gui_id(widget: QWidget) -> str: + gui_id = getattr(widget, "gui_id", None) + return str(gui_id) if gui_id else "" + + @staticmethod + def _visible_label(widget: QWidget) -> str: + try: + return "Yes" if widget.isVisible() else "No" + except Exception as e: + logger.error(f"Error checking visibility for widget {widget}: {e}") + return "Unknown" + + def _add_highlight_button(self, item: QTreeWidgetItem, widget: QWidget) -> None: + button = QToolButton(self._tree) + icon = material_icon("filter_center_focus", convert_to_pixmap=False) + button.setIcon(icon) + button.setEnabled(self._can_highlight(widget)) + button.clicked.connect(lambda _, w=widget: self._highlight_widget(w)) + self._tree.setItemWidget(item, 3, button) + + def _highlight_widget(self, widget: QWidget | None) -> None: + if not self._can_highlight(widget): + return + self._highlighter.highlight(widget) + + @staticmethod + def _can_highlight(widget: QWidget | None) -> bool: + if widget is None or not shiboken6.isValid(widget): + return False + try: + return widget.isVisible() + except Exception: + return False + + def _filter_tree_by_visibility(self) -> None: + mode = self._visibility_filter.currentData() + if mode in (None, "all"): + return + for index in reversed(range(self._tree.topLevelItemCount())): + item = self._tree.topLevelItem(index) + if not self._filter_item_by_visibility(item, mode): + self._tree.takeTopLevelItem(index) + + def _filter_item_by_visibility(self, item: QTreeWidgetItem, mode: str) -> bool: + has_match = self._matches_visibility_filter(item, mode) + for idx in reversed(range(item.childCount())): + child_item = item.child(idx) + if not self._filter_item_by_visibility(child_item, mode): + item.removeChild(child_item) + else: + has_match = True + return has_match + + @staticmethod + def _matches_visibility_filter(item: QTreeWidgetItem, mode: str) -> bool: + if mode == "all": + return True + widget = item.data(0, Qt.ItemDataRole.UserRole) + if widget is None or not shiboken6.isValid(widget): + return False + try: + visible = widget.isVisible() + except Exception: + return False + if mode == "visible": + return visible + if mode == "hidden": + return not visible + return True + + def _is_dialog_ancestor(self, widget: QWidget | None) -> bool: + current = widget + while current is not None and shiboken6.isValid(current): + if current is self: + return True + current = current.parentWidget() + return False diff --git a/tests/unit_tests/test_widget_hierarchy_tree.py b/tests/unit_tests/test_widget_hierarchy_tree.py new file mode 100644 index 00000000..025cc8c4 --- /dev/null +++ b/tests/unit_tests/test_widget_hierarchy_tree.py @@ -0,0 +1,150 @@ +from unittest import mock + +import pytest +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QLabel, QPushButton, QToolButton, QVBoxLayout, QWidget + +from bec_widgets.widgets.utility.widget_hierarchy_tree.widget_hierarchy_tree import ( + WidgetHierarchyDialog, +) + + +def _iter_tree_items(tree_widget): + for i in range(tree_widget.topLevelItemCount()): + item = tree_widget.topLevelItem(i) + yield from _walk_item(item) + + +def _walk_item(item): + yield item + for i in range(item.childCount()): + yield from _walk_item(item.child(i)) + + +def _tree_widgets(dialog: WidgetHierarchyDialog) -> list[QWidget]: + return [ + item.data(0, Qt.ItemDataRole.UserRole) + for item in _iter_tree_items(dialog._tree) + if item.data(0, Qt.ItemDataRole.UserRole) is not None + ] + + +def _find_item_by_widget(dialog: WidgetHierarchyDialog, widget: QWidget): + for item in _iter_tree_items(dialog._tree): + if item.data(0, Qt.ItemDataRole.UserRole) is widget: + return item + return None + + +@pytest.fixture +def hierarchy_fixture(qtbot): + root = QWidget() + root.setObjectName("root_widget") + root_layout = QVBoxLayout(root) + + visible_btn = QPushButton("Visible", root) + visible_btn.setObjectName("visible_btn") + root_layout.addWidget(visible_btn) + + hidden_label = QLabel("Hidden", root) + hidden_label.setObjectName("hidden_lbl") + hidden_label.hide() + root_layout.addWidget(hidden_label) + + container = QWidget(root) + container.setObjectName("container") + container_layout = QVBoxLayout(container) + nested_btn = QPushButton("Nested", container) + nested_btn.setObjectName("nested_btn") + container_layout.addWidget(nested_btn) + root_layout.addWidget(container) + + qtbot.addWidget(root) + root.show() + qtbot.waitExposed(root) + + dialog = WidgetHierarchyDialog(root_widget=root) + qtbot.addWidget(dialog) + dialog.show() + qtbot.waitExposed(dialog) + + yield dialog, root, visible_btn, hidden_label, nested_btn, root_layout + + dialog.close() + root.close() + + +def test_tree_populates_widgets_for_explicit_root(hierarchy_fixture): + dialog, root, visible_btn, hidden_label, nested_btn, _ = hierarchy_fixture + widgets = _tree_widgets(dialog) + + assert dialog._tree.topLevelItemCount() == 1 + assert root in widgets + assert visible_btn in widgets + assert hidden_label in widgets + assert nested_btn in widgets + assert dialog not in widgets + + +def test_visibility_filter_visible_only(hierarchy_fixture, qtbot): + dialog, root, visible_btn, hidden_label, nested_btn, _ = hierarchy_fixture + + dialog._visibility_filter.setCurrentText("Visible only") + qtbot.wait(50) + widgets = _tree_widgets(dialog) + + assert root in widgets + assert visible_btn in widgets + assert nested_btn in widgets + assert hidden_label not in widgets + + +def test_visibility_filter_hidden_only(hierarchy_fixture, qtbot): + dialog, root, visible_btn, hidden_label, nested_btn, _ = hierarchy_fixture + + dialog._visibility_filter.setCurrentText("Hidden only") + qtbot.wait(50) + widgets = _tree_widgets(dialog) + + assert hidden_label in widgets + assert root in widgets + assert visible_btn not in widgets + assert nested_btn not in widgets + + +def test_refresh_button_updates_tree_for_new_widget(hierarchy_fixture, qtbot): + dialog, _, _, _, _, root_layout = hierarchy_fixture + + late_btn = QPushButton("Late") + late_btn.setObjectName("late_btn") + root_layout.addWidget(late_btn) + + qtbot.mouseClick(dialog._refresh_button, Qt.MouseButton.LeftButton) + qtbot.wait(50) + + widgets = _tree_widgets(dialog) + assert late_btn in widgets + + +def test_find_button_triggers_highlighter(hierarchy_fixture, qtbot): + dialog, _, visible_btn, _, _, _ = hierarchy_fixture + item = _find_item_by_widget(dialog, visible_btn) + assert item is not None + + button = dialog._tree.itemWidget(item, 3) + assert isinstance(button, QToolButton) + assert button.isEnabled() + + with mock.patch.object(dialog._highlighter, "highlight") as highlight_mock: + qtbot.mouseClick(button, Qt.MouseButton.LeftButton) + highlight_mock.assert_called_once_with(visible_btn) + + +def test_find_button_disabled_for_hidden_widget(hierarchy_fixture): + dialog, _, _, hidden_label, _, _ = hierarchy_fixture + item = _find_item_by_widget(dialog, hidden_label) + assert item is not None + + button = dialog._tree.itemWidget(item, 3) + assert isinstance(button, QToolButton) + assert not button.isEnabled()