mirror of
https://github.com/pyapp-kit/superqt.git
synced 2025-12-16 03:00:05 +01:00
Searchable tree widget from a mapping (#158)
* Crude searchable tree widget with example * Add logging and fix hiding bug * style: [pre-commit.ci] auto fixes [...] * Add factory method * Use regular expression instead * Reduce API * Make setData public * Clear filter when setting data * Visible instead of hidden * Show item when parent is visible * Add docs * Empty commit to [skip ci] * style: [pre-commit.ci] auto fixes [...] * Empty commit to [skip ci] * Add test coverage * Improve readability of tests * Use python not json names * Simplify example * Some optimizations * Clean up tests * Fix visible siblings * Modify test to cover visible sibling * style: [pre-commit.ci] auto fixes [...] * fix lint * Update src/superqt/selection/_searchable_tree_widget.py Co-authored-by: Talley Lambert <talley.lambert@gmail.com> * Search by value too * Remove optimizations * Clean up formatting * style: [pre-commit.ci] auto fixes [...] --------- Co-authored-by: pre-commit-ci[bot] <66853113+pre-commit-ci[bot]@users.noreply.github.com> Co-authored-by: Talley Lambert <talley.lambert@gmail.com>
This commit is contained in:
29
examples/searchable_tree_widget.py
Normal file
29
examples/searchable_tree_widget.py
Normal file
@@ -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_()
|
||||
@@ -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",
|
||||
]
|
||||
|
||||
|
||||
|
||||
@@ -1,3 +1,4 @@
|
||||
from ._searchable_list_widget import QSearchableListWidget
|
||||
from ._searchable_tree_widget import QSearchableTreeWidget
|
||||
|
||||
__all__ = ("QSearchableListWidget",)
|
||||
__all__ = ("QSearchableListWidget", "QSearchableTreeWidget")
|
||||
|
||||
114
src/superqt/selection/_searchable_tree_widget.py
Normal file
114
src/superqt/selection/_searchable_tree_widget.py
Normal file
@@ -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
|
||||
151
tests/test_searchable_tree.py
Normal file
151
tests/test_searchable_tree.py
Normal file
@@ -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")
|
||||
Reference in New Issue
Block a user