Home > Mobile >  Qt - rootIndex gets reset each time QSortFilterProxyModel::invalidateFilter is called
Qt - rootIndex gets reset each time QSortFilterProxyModel::invalidateFilter is called

Time:11-28

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)
  • Related