diff --git a/examples/searchable_tree_widget.py b/examples/searchable_tree_widget.py new file mode 100644 index 0000000..115f5d5 --- /dev/null +++ b/examples/searchable_tree_widget.py @@ -0,0 +1,29 @@ +import logging + +from qtpy.QtWidgets import QApplication + +from superqt import QSearchableTreeWidget + +logging.basicConfig( + level=logging.DEBUG, + format="%(asctime)s : %(levelname)s : %(filename)s : %(message)s", +) + +data = { + "none": None, + "str": "test", + "int": 42, + "list": [2, 3, 5], + "dict": { + "float": 0.5, + "tuple": (22, 99), + "bool": False, + }, +} + +app = QApplication([]) + +tree = QSearchableTreeWidget.fromData(data) +tree.show() + +app.exec_() diff --git a/src/superqt/__init__.py b/src/superqt/__init__.py index 6751b68..f779207 100644 --- a/src/superqt/__init__.py +++ b/src/superqt/__init__.py @@ -12,7 +12,7 @@ if TYPE_CHECKING: from ._eliding_label import QElidingLabel from .collapsible import QCollapsible from .combobox import QEnumComboBox, QSearchableComboBox -from .selection import QSearchableListWidget +from .selection import QSearchableListWidget, QSearchableTreeWidget from .sliders import ( QDoubleRangeSlider, QDoubleSlider, @@ -43,6 +43,7 @@ __all__ = [ "QRangeSlider", "QSearchableComboBox", "QSearchableListWidget", + "QSearchableTreeWidget", ] diff --git a/src/superqt/selection/__init__.py b/src/superqt/selection/__init__.py index 66b3d1a..77f7633 100644 --- a/src/superqt/selection/__init__.py +++ b/src/superqt/selection/__init__.py @@ -1,3 +1,4 @@ from ._searchable_list_widget import QSearchableListWidget +from ._searchable_tree_widget import QSearchableTreeWidget -__all__ = ("QSearchableListWidget",) +__all__ = ("QSearchableListWidget", "QSearchableTreeWidget") diff --git a/src/superqt/selection/_searchable_tree_widget.py b/src/superqt/selection/_searchable_tree_widget.py new file mode 100644 index 0000000..1cb8cdc --- /dev/null +++ b/src/superqt/selection/_searchable_tree_widget.py @@ -0,0 +1,114 @@ +import logging +from typing import Any, Iterable, Mapping + +from qtpy.QtCore import QRegularExpression +from qtpy.QtWidgets import QLineEdit, QTreeWidget, QTreeWidgetItem, QVBoxLayout, QWidget + + +class QSearchableTreeWidget(QWidget): + """A tree widget for showing a mapping that can be searched by key. + + This is intended to be used with a read-only mapping and be conveniently + created using `QSearchableTreeWidget.fromData(data)`. + If the mapping changes, the easiest way to update this is by calling `setData`. + + The tree can be searched by entering a regular expression pattern + into the `filter` line edit. An item is only shown if its, any of its ancestors', + or any of its descendants' keys or values match this pattern. + The regular expression follows the conventions described by the Qt docs: + https://doc.qt.io/qt-5/qregularexpression.html#details + + Attributes + ---------- + tree : QTreeWidget + Shows the mapping as a tree of items. + filter : QLineEdit + Used to filter items in the tree by matching their key against a + regular expression. + """ + + def __init__(self, parent=None): + super().__init__(parent) + + self.tree: QTreeWidget = QTreeWidget(self) + self.tree.setHeaderLabels(("Key", "Value")) + + self.filter: QLineEdit = QLineEdit(self) + self.filter.setClearButtonEnabled(True) + self.filter.textChanged.connect(self._updateVisibleItems) + + layout = QVBoxLayout(self) + layout.addWidget(self.filter) + layout.addWidget(self.tree) + + def setData(self, data: Mapping) -> None: + """Update the mapping data shown by the tree.""" + self.tree.clear() + self.filter.clear() + top_level_items = [_make_item(name=k, value=v) for k, v in data.items()] + self.tree.addTopLevelItems(top_level_items) + + def _updateVisibleItems(self, pattern: str) -> None: + """Recursively update the visibility of items based on the given pattern.""" + expression = QRegularExpression(pattern) + for i in range(self.tree.topLevelItemCount()): + top_level_item = self.tree.topLevelItem(i) + _update_visible_items(top_level_item, expression) + + @classmethod + def fromData( + cls, data: Mapping, *, parent: QWidget = None + ) -> "QSearchableTreeWidget": + """Make a searchable tree widget from a mapping.""" + widget = cls(parent) + widget.setData(data) + return widget + + +def _make_item(*, name: str, value: Any) -> QTreeWidgetItem: + """Make a tree item where the name and value are two columns. + + Iterable values other than strings are recursively traversed to + add child items and build a tree. In this case, mappings use keys + as their names whereas other iterables use their enumerated index. + """ + if isinstance(value, Mapping): + item = QTreeWidgetItem([name, type(value).__name__]) + for k, v in value.items(): + child = _make_item(name=k, value=v) + item.addChild(child) + elif isinstance(value, Iterable) and not isinstance(value, str): + item = QTreeWidgetItem([name, type(value).__name__]) + for i, v in enumerate(value): + child = _make_item(name=str(i), value=v) + item.addChild(child) + else: + item = QTreeWidgetItem([name, str(value)]) + logging.debug("_make_item: %s, %s, %s", item.text(0), item.text(1), item.flags()) + return item + + +def _update_visible_items( + item: QTreeWidgetItem, expression: QRegularExpression, ancestor_match: bool = False +) -> bool: + """Recursively update the visibility of a tree item based on an expression. + + An item is visible if any of its, any of its ancestors', or any of its descendants' + column's text matches the expression. + Returns True if the item is visible, False otherwise. + """ + match = ancestor_match or any( + expression.match(item.text(i)).hasMatch() for i in range(item.columnCount()) + ) + visible = match + for i in range(item.childCount()): + child = item.child(i) + descendant_visible = _update_visible_items(child, expression, match) + visible = visible or descendant_visible + item.setHidden(not visible) + logging.debug( + "_update_visible_items: %s, %s", + tuple(item.text(i) for i in range(item.columnCount())), + visible, + ) + return visible diff --git a/tests/test_searchable_tree.py b/tests/test_searchable_tree.py new file mode 100644 index 0000000..c656a5f --- /dev/null +++ b/tests/test_searchable_tree.py @@ -0,0 +1,151 @@ +from typing import List, Tuple + +import pytest +from pytestqt.qtbot import QtBot +from qtpy.QtCore import Qt +from qtpy.QtWidgets import QTreeWidget, QTreeWidgetItem + +from superqt import QSearchableTreeWidget + + +@pytest.fixture +def data() -> dict: + return { + "none": None, + "str": "test", + "int": 42, + "list": [2, 3, 5], + "dict": { + "float": 0.5, + "tuple": (22, 99), + "bool": False, + }, + } + + +@pytest.fixture +def widget(qtbot: QtBot, data: dict) -> QSearchableTreeWidget: + widget = QSearchableTreeWidget.fromData(data) + qtbot.addWidget(widget) + return widget + + +def columns(item: QTreeWidgetItem) -> Tuple[str, str]: + return item.text(0), item.text(1) + + +def all_items(tree: QTreeWidget) -> List[QTreeWidgetItem]: + return tree.findItems("", Qt.MatchContains | Qt.MatchRecursive) + + +def shown_items(tree: QTreeWidget) -> List[QTreeWidgetItem]: + items = all_items(tree) + return [item for item in items if not item.isHidden()] + + +def test_init(qtbot: QtBot): + widget = QSearchableTreeWidget() + qtbot.addWidget(widget) + assert widget.tree.topLevelItemCount() == 0 + + +def test_from_data(qtbot: QtBot, data: dict): + widget = QSearchableTreeWidget.fromData(data) + qtbot.addWidget(widget) + tree = widget.tree + + assert tree.topLevelItemCount() == 5 + + none_item = tree.topLevelItem(0) + assert columns(none_item) == ("none", "None") + assert none_item.childCount() == 0 + + str_item = tree.topLevelItem(1) + assert columns(str_item) == ("str", "test") + assert str_item.childCount() == 0 + + int_item = tree.topLevelItem(2) + assert columns(int_item) == ("int", "42") + assert int_item.childCount() == 0 + + list_item = tree.topLevelItem(3) + assert columns(list_item) == ("list", "list") + assert list_item.childCount() == 3 + assert columns(list_item.child(0)) == ("0", "2") + assert columns(list_item.child(1)) == ("1", "3") + assert columns(list_item.child(2)) == ("2", "5") + + dict_item = tree.topLevelItem(4) + assert columns(dict_item) == ("dict", "dict") + assert dict_item.childCount() == 3 + assert columns(dict_item.child(0)) == ("float", "0.5") + tuple_item = dict_item.child(1) + assert columns(tuple_item) == ("tuple", "tuple") + assert tuple_item.childCount() == 2 + assert columns(tuple_item.child(0)) == ("0", "22") + assert columns(tuple_item.child(1)) == ("1", "99") + assert columns(dict_item.child(2)) == ("bool", "False") + + +def test_set_data(widget: QSearchableTreeWidget): + tree = widget.tree + assert tree.topLevelItemCount() != 1 + + widget.setData({"test": "reset"}) + + assert tree.topLevelItemCount() == 1 + assert columns(tree.topLevelItem(0)) == ("test", "reset") + + +def test_search_no_match(widget: QSearchableTreeWidget): + widget.filter.setText("no match here") + items = shown_items(widget.tree) + assert len(items) == 0 + + +def test_search_all_match(widget: QSearchableTreeWidget): + widget.filter.setText("") + tree = widget.tree + assert all_items(tree) == shown_items(tree) + + +def test_search_match_one_key(widget: QSearchableTreeWidget): + widget.filter.setText("int") + items = shown_items(widget.tree) + assert len(items) == 1 + assert columns(items[0]) == ("int", "42") + + +def test_search_match_one_value(widget: QSearchableTreeWidget): + widget.filter.setText("test") + items = shown_items(widget.tree) + assert len(items) == 1 + assert columns(items[0]) == ("str", "test") + + +def test_search_match_many_keys(widget: QSearchableTreeWidget): + widget.filter.setText("n") + items = shown_items(widget.tree) + assert len(items) == 2 + assert columns(items[0]) == ("none", "None") + assert columns(items[1]) == ("int", "42") + + +def test_search_match_one_show_unmatched_descendants(widget: QSearchableTreeWidget): + widget.filter.setText("list") + items = shown_items(widget.tree) + assert len(items) == 4 + assert columns(items[0]) == ("list", "list") + assert columns(items[1]) == ("0", "2") + assert columns(items[2]) == ("1", "3") + assert columns(items[3]) == ("2", "5") + + +def test_search_match_one_show_unmatched_ancestors(widget: QSearchableTreeWidget): + widget.filter.setText("tuple") + items = shown_items(widget.tree) + assert len(items) == 4 + assert columns(items[0]) == ("dict", "dict") + assert columns(items[1]) == ("tuple", "tuple") + assert columns(items[2]) == ("0", "22") + assert columns(items[3]) == ("1", "99")