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:
@@ -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()
|
||||
|
||||
@@ -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
|
||||
150
tests/unit_tests/test_widget_hierarchy_tree.py
Normal file
150
tests/unit_tests/test_widget_hierarchy_tree.py
Normal 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()
|
||||
Reference in New Issue
Block a user