I need a QTabWidget
where each tab contains a QTreeView
. And each QTreeView
shows
one "branch" of a larger model. The user has a QLineEdit
which live-filters
the view based on whatever they type. I have a reproduction of the GUI issue below.
The problem is that I use QTreeView::setRootIndex
in order to display a single,
inner branch of the main model. The QLineEdit
"filterer" is implemented with a
QSortFilterProxyModel
. Whenever the user types, I call
QSortFilterProxyModel::invalidateFilter
to get re-filter from scratch. These two
methods, QTreeView::setRootIndex
and QSortFilterProxyModel::invalidateFilter
, do not
mix well together.
When QSortFilterProxyModel::invalidateFilter
is called, the index that I had
previously set using QTreeView::setRootIndex
is now invalid. The QTreeView
then
shows the whole tree, instead of the "branch" that I had previously selected using setRootIndex
. The QTreeView
effectively "forgets" the root index I had set.
I have a reproduction of the issue below
import functools
from Qt import QtCore, QtWidgets
_NO_ROW = -1
_NUMBER_OF_BRANCHES = 3
class _MatchProxy(QtCore.QSortFilterProxyModel):
"""Use a callable function to determine if an index should be hidden from view or not."""
def __init__(self, matcher, parent=None):
super(_MatchProxy, self).__init__(parent=parent)
self._matcher = matcher
def filterAcceptsRow(self, source_row, source_index):
return self._matcher(source_index)
class _MultiTree(QtWidgets.QWidget):
"""A widget which makes smaller, individual QTreeViews for each section of an given index."""
def __init__(self, source_index, parent=None):
super(_MultiTree, self).__init__(parent=parent)
self.setLayout(QtWidgets.QVBoxLayout())
model = source_index.model()
self._views = []
for index in range(model.rowCount(source_index)):
section = model.index(index, 0, parent=source_index)
label = model.data(section, QtCore.Qt.DisplayRole)
view = QtWidgets.QTreeView()
view.setModel(model)
view.setRootIndex(section)
self.layout().addWidget(QtWidgets.QLabel(label))
self.layout().addWidget(view)
self._views.append(view)
def iter_models(self):
for view in self._views:
yield view.model()
def iter_views(self):
for view in self._views:
yield view
class Node(object):
"""A generic name children parent graph node class."""
def __init__(self, name, parent=None):
super(Node, self).__init__()
self._name = name
self._children = []
self._parent = parent
if self._parent:
self._parent.add_child(self)
def add_child(self, node):
node._parent = self
self._children.append(node)
def get_child(self, row):
return self._children[row]
def get_children(self):
return list(self._children)
def get_label(self):
return self._name
def get_parent(self):
return self._parent
def get_row(self):
parent = self.get_parent()
if not parent:
return _NO_ROW
return parent.get_children().index(self)
def __repr__(self):
return "{self.__class__.__name__}({self._name!r}, parent={self._parent!r})".format(
self=self
)
class Branch(Node):
"""Syntax sugar for debugging. This class isn't "necessary" for the reproduction."""
pass
class Model(QtCore.QAbstractItemModel):
"""The basic Qt model which contains the entire tree, including each branch."""
def __init__(self, roots, parent=None):
super(Model, self).__init__(parent=parent)
self._roots = roots
def _get_node(self, index):
return index.internalPointer()
def columnCount(self, _):
return 1
def data(self, index, role=QtCore.Qt.DisplayRole):
if role != QtCore.Qt.DisplayRole:
return None
node = self._get_node(index)
return node.get_label()
def index(self, row, column, parent=QtCore.QModelIndex()):
if not parent.isValid():
return self.createIndex(row, 0, self._roots[row])
parent_node = self._get_node(parent)
child_node = parent_node.get_child(row)
return self.createIndex(row, column, child_node)
def parent(self, index):
if not index.isValid():
return QtCore.QModelIndex()
node = self._get_node(index)
parent = node.get_parent()
if not parent:
return QtCore.QModelIndex()
return self.createIndex(node.get_row(), 0, parent)
def rowCount(self, index):
if not index.isValid():
return len(self._roots)
node = self._get_node(index)
return len(node.get_children())
class Widget(QtWidgets.QWidget):
"""The main widget / window which has the filterer QTreeViews."""
def __init__(self, model, parent=None):
super(Widget, self).__init__(parent=parent)
self.setLayout(QtWidgets.QVBoxLayout())
self._filterer = QtWidgets.QLineEdit()
self._tabs = QtWidgets.QTabWidget()
self._set_model(model)
self.layout().addWidget(self._filterer)
self.layout().addWidget(self._tabs)
self._filterer.textChanged.connect(self._update_current_view)
def _replace_model_with_filterer_proxy(self, view):
def _match(line_edit, index):
if not index.isValid():
return True # Show everything, don't filter anything
current = line_edit.text().strip()
if not current:
return True # Show everything, don't filter anything.
return current in index.data(QtCore.Qt.DisplayRole)
model = view.model()
current_root = view.rootIndex()
proxy = _MatchProxy(functools.partial(_match, self._filterer))
proxy.setSourceModel(model)
proxy.setRecursiveFilteringEnabled(True)
view.setModel(proxy)
view.setRootIndex(proxy.mapFromSource(current_root))
def _set_model(self, model):
tabs_count = model.rowCount(QtCore.QModelIndex())
for row in range(tabs_count):
branch_index = model.index(row, 0)
tab_name = model.data(branch_index, QtCore.Qt.DisplayRole)
widget = _MultiTree(branch_index)
for view in widget.iter_views():
self._replace_model_with_filterer_proxy(view)
self._tabs.addTab(widget, tab_name)
def _update_current_view(self):
widget = self._tabs.currentWidget()
for proxy in widget.iter_models():
proxy.invalidateFilter()
def _make_branch_graph():
default = Node("default")
optional = Node("optional")
Node("camera", parent=default)
Node("set", parent=default)
light = Node("light", parent=default)
Node("directional light", parent=light)
spot = Node("spot light", parent=light)
Node("light center", parent=spot)
Node("volume light", parent=light)
Node("model", parent=optional)
surfacing = Node("surfacing", parent=optional)
Node("look", parent=surfacing)
Node("hair", parent=surfacing)
Node("fur", parent=surfacing)
Node("rig", parent=optional)
return (default, optional)
def _make_full_graph():
roots = []
for index in range(_NUMBER_OF_BRANCHES):
branch = Branch("branch_{index}".format(index=index))
for node in _make_branch_graph():
branch.add_child(node)
roots.append(branch)
return roots
def main():
application = QtWidgets.QApplication([])
roots = _make_full_graph()
model = Model(roots)
window = Widget(model)
window.show()
application.exec_()
if __name__ == "__main__":
main()
- Run the code above to open the GUI. The tabs inner QTreeViews should look like this:
QTabWidget tab - branch_0
QTreeView (default)
- camera
- set
- light
- directional light
- spot light
- light center
- volume light
QTreeView (optional)
- model
- surfacing
- look
- hair
- fur
- rig
QTabWidget tab - branch_1
- Same as branch_0
QTabWidget tab - branch_2
- Same as branch_0
That is the correct, expected node display. Now in the filterer QLineEdit, type in "light". You'll now get:
QTabWidget tab - branch_0
QTreeView (default)
- light
- directional light
- spot light
- light center
- volume light
QTreeView (optional)
- branch_0
- default
- light
- directional light
- spot light
- light center
- volume light
- branch_1
- default
- light
- directional light
- spot light
- light center
- volume light
- branch_2
- default
- light
- directional light
- spot light
- light center
- volume light
QTabWidget tab - branch_1
- Same as branch_0
QTabWidget tab - branch_2
- Same as branch_0
Here you can see that the QTreeView labelled "optional" now displays the contents of every branch, not just the one branch like it was supposed to.
This is not the intended behavior. For reference, this is the view which I was hoping to get:
QTabWidget tab - branch_0
QTreeView (default)
- default
- light
- directional light
- spot light
- light center
- volume light
QTreeView (optional) [EMPTY, no children]
QTabWidget tab - branch_1
- Same as branch_0
QTabWidget tab - branch_2
- Same as branch_0
Also notice that if you clear the filterer QLineEdit's text, "", you don't go back to the first graph. The "optional" QTreeView is stuck being shown everything, in every QTreeView.
Now in the filterer QLineEdit text, type "asdf". Now the graph is
QTabWidget tab - branch_0
QTreeView (default)
- branch_0
- branch_1
- branch_2
QTreeView (optional)
- branch_0
- branch_1
- branch_2
QTabWidget tab - branch_1
- Same as branch_0
QTabWidget tab - branch_2
- Same as branch_0
When my intended view was
QTabWidget tab - branch_0
QTreeView (default) [EMPTY]
QTreeView (optional) [EMPTY]
QTabWidget tab - branch_1
- Same as branch_0
QTabWidget tab - branch_2
- Same as branch_0
And if you clear the filterer text with "", now both QTreeViews show everything across all branches.
Is there a simple way to get the intended graph that I'm describing? Maybe there's a way to re-run QSortFilterProxyModel without invalidating indices, or some other mechanism I can use to get the same effect? At the moment I'm getting around the problem by "saving and restoring" the rootIndex for each view, pre and post invalidateFilter. But my approach for that doesn't work in all cases and feels like a hack.
Any advice on this would be appreciated.
Edit: Solution found. Not 100% tested but so far working well.
import functools
from Qt import QtCore, QtWidgets
_NO_ROW = -1
_NUMBER_OF_BRANCHES = 3
class _MatchProxy(QtCore.QSortFilterProxyModel):
"""Use a callable function to determine if an index should be hidden from view or not."""
def __init__(self, matcher, parent=None):
super(_MatchProxy, self).__init__(parent=parent)
self._matcher = matcher
def filterAcceptsRow(self, source_row, source_index):
if not source_index.isValid():
return True
model = source_index.model()
# print('modeasd', model, source_index.isValid())
# print(source_index.index(source_row))
index = model.index(source_row, 0, parent=source_index)
return self._matcher(index)
class _MultiTree(QtWidgets.QWidget):
"""A widget which makes smaller, individual QTreeViews for each section of an given index."""
def __init__(self, source_index, parent=None):
super(_MultiTree, self).__init__(parent=parent)
self.setLayout(QtWidgets.QVBoxLayout())
model = source_index.model()
self._views = []
for index in range(model.rowCount(source_index)):
section = model.index(index, 0, parent=source_index)
label = model.data(section, QtCore.Qt.DisplayRole)
view = _TreePersistentRootIndex()
view.setModel(model)
view.setRootIndex(section)
self.layout().addWidget(QtWidgets.QLabel(label))
self.layout().addWidget(view)
self._views.append(view)
def iter_models(self):
for view in self._views:
yield view.model()
def iter_views(self):
for view in self._views:
yield view
class _TreePersistentRootIndex(QtWidgets.QTreeView):
_sourceRootIndex = QtCore.QModelIndex()
def setModel(self, model):
if self.model() and isinstance(self.model(), QtCore.QSortFilterProxyModel):
self.model().layoutChanged.disconnect(self.checkRootIndex)
self.model().rowsRemoved.disconnect(self.checkRootIndex)
self.model().rowsInserted.disconnect(self.checkRootIndex)
super(_TreePersistentRootIndex, self).setModel(model)
self._model = model
if isinstance(model, QtCore.QSortFilterProxyModel):
model.layoutChanged.connect(self.checkRootIndex)
model.rowsRemoved.connect(self.checkRootIndex)
model.rowsInserted.connect(self.checkRootIndex)
def checkRootIndex(self):
if (
not self._sourceRootIndex.isValid()
or not isinstance(self.model(), QtCore.QSortFilterProxyModel)
):
return
rootIndex = self.model().mapFromSource(self._sourceRootIndex)
if rootIndex != self.rootIndex():
super(_TreePersistentRootIndex, self).setRootIndex(rootIndex)
if rootIndex.isValid() != self._sourceRootIndex.isValid():
hide = not rootIndex.isValid()
for row in range(self.model().rowCount(rootIndex)):
self.setRowHidden(row, rootIndex.parent(), hide)
def setRootIndex(self, rootIndex):
super(_TreePersistentRootIndex, self).setRootIndex(rootIndex)
if isinstance(self.model(), QtCore.QSortFilterProxyModel):
rootIndex = self.model().mapToSource(rootIndex)
self._sourceRootIndex = rootIndex
class Node(object):
"""A generic name children parent graph node class."""
def __init__(self, name, parent=None):
super(Node, self).__init__()
self._name = name
self._children = []
self._parent = parent
if self._parent:
self._parent.add_child(self)
def add_child(self, node):
node._parent = self
self._children.append(node)
def get_child(self, row):
return self._children[row]
def get_children(self):
return list(self._children)
def get_label(self):
return self._name
def get_parent(self):
return self._parent
def get_row(self):
parent = self.get_parent()
if not parent:
return _NO_ROW
return parent.get_children().index(self)
def __repr__(self):
return "{self.__class__.__name__}({self._name!r}, parent={self._parent!r})".format(
self=self
)
class Branch(Node):
"""Syntax sugar for debugging. This class isn't "necessary" for the reproduction."""
pass
class Model(QtCore.QAbstractItemModel):
"""The basic Qt model which contains the entire tree, including each branch."""
def __init__(self, roots, parent=None):
super(Model, self).__init__(parent=parent)
self._roots = roots
def _get_node(self, index):
return index.internalPointer()
def columnCount(self, _):
return 1
def data(self, index, role=QtCore.Qt.DisplayRole):
if role != QtCore.Qt.DisplayRole:
return None
node = self._get_node(index)
return node.get_label()
def index(self, row, column, parent=QtCore.QModelIndex()):
if not parent.isValid():
return self.createIndex(row, 0, self._roots[row])
parent_node = self._get_node(parent)
child_node = parent_node.get_child(row)
return self.createIndex(row, column, child_node)
def parent(self, index):
if not index.isValid():
return QtCore.QModelIndex()
node = self._get_node(index)
parent = node.get_parent()
if not parent:
return QtCore.QModelIndex()
return self.createIndex(node.get_row(), 0, parent)
def rowCount(self, index):
if not index.isValid():
return len(self._roots)
node = self._get_node(index)
return len(node.get_children())
class Widget(QtWidgets.QWidget):
"""The main widget / window which has the filterer QTreeViews."""
def __init__(self, model, parent=None):
super(Widget, self).__init__(parent=parent)
self.setLayout(QtWidgets.QVBoxLayout())
self._filterer = QtWidgets.QLineEdit()
self._tabs = QtWidgets.QTabWidget()
self._set_model(model)
self.layout().addWidget(self._filterer)
self.layout().addWidget(self._tabs)
self._filterer.textChanged.connect(self._update_current_view)
self._tabs.currentChanged.connect(self._update_current_view)
def _replace_model_with_filterer_proxy(self, view):
def _match(line_edit, index):
if not index.isValid():
return True # Show everything, don't filter anything
current = line_edit.text().strip()
if not current:
return True # Show everything, don't filter anything.
return current in index.data(QtCore.Qt.DisplayRole)
model = view.model()
current_root = view.rootIndex()
proxy = _MatchProxy(functools.partial(_match, self._filterer))
proxy.setSourceModel(model)
proxy.setRecursiveFilteringEnabled(True)
view.setModel(proxy)
view.setRootIndex(proxy.mapFromSource(current_root))
def _set_model(self, model):
tabs_count = model.rowCount(QtCore.QModelIndex())
for row in range(tabs_count):
branch_index = model.index(row, 0)
tab_name = model.data(branch_index, QtCore.Qt.DisplayRole)
widget = _MultiTree(branch_index)
for view in widget.iter_views():
self._replace_model_with_filterer_proxy(view)
self._tabs.addTab(widget, tab_name)
def _update_current_view(self):
widget = self._tabs.currentWidget()
for proxy in widget.iter_models():
proxy.invalidateFilter()
def _make_branch_graph():
default = Node("default")
optional = Node("optional")
Node("camera", parent=default)
Node("set", parent=default)
light = Node("light", parent=default)
Node("directional light", parent=light)
spot = Node("spot light", parent=light)
Node("light center", parent=spot)
Node("volume light", parent=light)
Node("model", parent=optional)
surfacing = Node("surfacing", parent=optional)
Node("look", parent=surfacing)
Node("hair", parent=surfacing)
Node("fur", parent=surfacing)
Node("rig", parent=optional)
return (default, optional)
def _make_full_graph():
roots = []
for index in range(_NUMBER_OF_BRANCHES):
branch = Branch("branch_{index}".format(index=index))
for node in _make_branch_graph():
branch.add_child(node)
roots.append(branch)
return roots
def main():
application = QtWidgets.QApplication([])
roots = _make_full_graph()
model = Model(roots)
window = Widget(model)
window.show()
application.exec_()
if __name__ == "__main__":
main()
CodePudding user response:
The problem comes from the fact that when applying filtering, unaccepted indexes become invalid indexes, and for Qt an invalid index is the same as the root index.
Since you're setting the root index of the view to an index that the filter makes invalid, the result the same as doing setRootIndex(QModelIndex())
, which shows the whole model.
In fact, the same problem you see in the "optional" view can be also experienced if you try to filter the model using a string that doesn't match any of the "default" branch items: it will show the whole root tree model, and that's because your _match
function returns True
if the index is invalid, so the root will be shown anyway (in normal situations, it would show an empty model).
Note on this aspect: I'm not sure about the desired behavior, but if the filter doesn't match anything, it shouldn't return True
, as that will make valid all root indexes, which in your case will show the root even if it wasn't supposed to be the shown index.
The source of the problem comes from the fact that the model doesn't know about the root index of the view (nor it should!) and the view cannot know when an invalid root index becomes valid again (and, again, nor it should). See this discussion on a similar matter.
A possible solution (which doesn't solve the question of the accepted invalid root), is to keep track of the root index and then update the view by connecting to rowsRemoved
and, most importantly, rowsInserted
signals of the model so that we can restore the original root index whenever it becomes "available" again:
class TreePersistentRootIndex(QtWidgets.QTreeView):
_sourceRootIndex = QtCore.QModelIndex()
def setModel(self, model):
if self.model() and isinstance(self.model(), QtCore.QSortFilterProxyModel):
self.model().layoutChanged.disconnect(self.checkRootIndex)
self.model().rowsRemoved.disconnect(self.checkRootIndex)
self.model().rowsInserted.disconnect(self.checkRootIndex)
super().setModel(model)
self._model = model
if isinstance(model, QtCore.QSortFilterProxyModel):
model.layoutChanged.connect(self.checkRootIndex)
model.rowsRemoved.connect(self.checkRootIndex)
model.rowsInserted.connect(self.checkRootIndex)
def checkRootIndex(self):
if (not self._sourceRootIndex.isValid() or
not isinstance(self.model(), QtCore.QSortFilterProxyModel)):
return
rootIndex = self.model().mapFromSource(self._sourceRootIndex)
if rootIndex != self.rootIndex():
super().setRootIndex(rootIndex)
def setRootIndex(self, rootIndex):
super().setRootIndex(rootIndex)
if isinstance(self.model(), QtCore.QSortFilterProxyModel):
rootIndex = self.model().mapToSource(rootIndex)
self._sourceRootIndex = rootIndex
class _MultiTree(QtWidgets.QWidget):
def __init__(self, source_index, parent=None):
# ...
for index in range(model.rowCount(source_index)):
section = model.index(index, 0, parent=source_index)
label = model.data(section, QtCore.Qt.DisplayRole)
view = TreePersistentRootIndex()
# ...
To overcome the problem of the accepted root index, you might consider changing the filtering behavior, or eventually (if you're completely sure that if the filter doesn't match any child item it shouldn't show anything in child-root indexes), use setRowHidden()
using True
(as in hide) if the resulting index is invalid or False
(as in show) otherwise.
A possible implementation (not fully tested) might be the following:
def checkRootIndex(self):
# ...as above, then:
if rootIndex.isValid() != self._sourceRootIndex.isValid():
hide = not rootIndex.isValid()
for row in range(self.model().rowCount(rootIndex)):
self.setRowHidden(row, rootIndex.parent(), hide)