1
0
mirror of https://github.com/bec-project/bec_widgets.git synced 2026-03-04 16:02:51 +01:00

feat(widget_hierarchy_tree): widget displaying parent child hierarchy from the app widgets

This commit is contained in:
2026-01-05 15:13:17 +01:00
committed by Jan Wyzula
parent 38d593941b
commit d42c9fccae
4 changed files with 417 additions and 1 deletions

View File

@@ -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()

View File

@@ -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 "<unnamed>"
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

View File

@@ -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()