Search code examples
qtpysidepyside6qt6

Why doesn't QTreeView show newly added node to non-root node in QAbstractItemModel


I created a custom model for QTreeView. The complete minimal code to show the problem is below. If I add a new node to the root node, by clicking "Add Level 1", it shows up. But if I add a new node to the second level by clicking "Add Level 2", it does not show up. That node only shows up if I collapse the parent node and then expand it again. What part of my MyTreeModel is wrong?

enter image description here

I am adding the QT tag, even though my code is PySide6, because the fault is probably in my understanding of the methods of QAbstractItemModel, which is not something specific to Python or to PySide.

Full Code

from __future__ import annotations
from typing import Optional

from PySide6.QtCore import QAbstractItemModel, QModelIndex
from PySide6.QtGui import Qt
from PySide6.QtWidgets import QApplication, QMainWindow, QWidget, QVBoxLayout, QPushButton, QHBoxLayout, QTreeView


class TreeNode:
    def __init__(self, name: str, parent_node):
        self.name = name
        self.parent_node = parent_node
        self.children: list[TreeNode] = []

    def get_child_by_name(self, name) -> Optional[TreeNode]:
        for child in self.children:
            if child.name == name:
                return child

        return None


class MyTreeModel(QAbstractItemModel):
    def __init__(self):
        super().__init__()
        self.root_node = TreeNode("root", None)

    def headerData(self, section, orientation, role=Qt.DisplayRole):
        if orientation == Qt.Horizontal and role == Qt.DisplayRole:
            return "Name"

    def rowCount(self, parentIndex):
        if not parentIndex.isValid():
            parentNode = self.root_node
        else:
            parentNode = parentIndex.internalPointer()

        return len(parentNode.children)

    def columnCount(self, parent):
        return 1

    def data(self, index, role):
        if not index.isValid():
            return None

        if role == Qt.DisplayRole:
            node: TreeNode = index.internalPointer()
            column = index.column()
            match column:
                case 0:
                    return node.name
                case _:
                    return None
        else:
            return None

    def parent(self, index):
        if not index.isValid():
            return QModelIndex()

        childNode: TreeNode = index.internalPointer()
        parentNode = childNode.parent_node

        if parentNode == self.root_node:
            return QModelIndex()

        row_within_parent = parentNode.children.index(childNode)

        return self.createIndex(row_within_parent, 0, parentNode);

    def index(self, row, column, parentIndex):
        if not self.hasIndex(row, column, parentIndex):
            return QModelIndex()

        if not parentIndex.isValid():
            parentNode = self.root_node
        else:
            parentNode = parentIndex.internalPointer()

        child_node = parentNode.children[row]
        if child_node:
            return self.createIndex(row, column, child_node)
        else:
            return QModelIndex()

    def set_data(self, data: []):
        self.beginResetModel()
        self.apply_data(data)
        self.endResetModel()

    def update_data(self, data: []):
        self.apply_data(data, True)

    def apply_data(self, data, notify=False):
        for item in data:
            parent_node = self.root_node;
            for part in item.split("/"):
                existing = parent_node.get_child_by_name(part)
                if existing:
                    parent_node = existing
                else:
                    if notify:
                        parent_index = self.get_index(parent_node)
                        count = len(parent_node.children)
                        self.beginInsertRows(parent_index, count, count)

                    new_node = TreeNode(part, parent_node)
                    parent_node.children.append(new_node)
                    parent_node = new_node

                    if notify:
                        self.endInsertRows()

    def get_index(self, node: TreeNode):
        if not node.parent_node:
            return QModelIndex()

        row = node.parent_node.children.index(node)
        return self.createIndex(row, 0, node.parent_node)


class MainWindow(QMainWindow):
    def __init__(self):
        super().__init__()
        self.resize(600, 400)

        button1 = QPushButton("Add Level 1")
        button1.clicked.connect(self.add1)
        button2 = QPushButton("Add Level 2")
        button2.clicked.connect(self.add2)

        row1 = QHBoxLayout()
        row1.setAlignment(Qt.AlignLeft)
        row1.addWidget(button1)
        row1.addWidget(button2)

        self.tree_view = QTreeView()

        layout = QVBoxLayout()
        layout.addLayout(row1)
        layout.addWidget(self.tree_view, stretch=1)

        self.central_widget = QWidget()
        self.central_widget.setLayout(layout)
        self.setCentralWidget(self.central_widget)

    def showEvent(self, event):
        data = ["mammals", "birds", "mammals/dog", "birds/eagle", "mammals/cat"]

        my_model = MyTreeModel()
        my_model.set_data(data)
        self.tree_view.setModel(my_model)
        self.tree_view.expandAll()

    def add1(self):
        data = ["reptiles"]
        m = self.tree_view.model()
        m.update_data(data)

    def add2(self):
        data = ["mammals/rat"]
        m = self.tree_view.model()
        m.update_data(data)


app = QApplication([])
win = MainWindow()
win.show()
app.exec()

Solution

  • The problem is that you're creating QModelIndexes inconsistently with createIndex().

    In the index() override, you use the node itself as pointer for createIndex():

        def index(self, row, column, parentIndex):
            ...
            if child_node:
                return self.createIndex(row, column, child_node)
    

    In get_index(), instead, you use the parent:

        def get_index(self, node: TreeNode):
            ...
            return self.createIndex(row, 0, node.parent_node)
    

    This results in an inconsistent behavior, which you can verify by printing the data of the parent_index = self.get_index(parent_node) created within the if notify block:

            if notify:
                parent_index = self.get_index(parent_node)
                print('parent:', parent_index.data()) # < add this line
    

    Which will output:

    parent: root
    

    The reason for which it "works" (meaning that the child is properly created) is that the child item is actually added to the correct parent node. The reason for which you don't see it immediately is because you're providing the wrong parent QModelIndex() in beginInsertRow(), so the view doesn't know what to do with it, as the given parent is not the one that should show further child items, therefore it simply ignores it.

    The solution is then simple: change the last argument of createIndex() to reflect the same behavior:

        def get_index(self, node: TreeNode):
            ...
            return self.createIndex(row, 0, node)
    

    Note that your parent() implementation is also inconsistent, as you're using the wrong pointer and reference. Let's analyze its code:

        def parent(self, index):
            # correct
            if not index.isValid():
                return QModelIndex()
    
            childNode = index.internalPointer()
            parentNode = childNode.parent_node
    
            # again, correct
            if parentNode == self.root_node:
                return QModelIndex()
    
            # this only finds the row within the parent!
            row_within_parent = parentNode.children.index(childNode)
    
            # this returns an inconsistent index!
            return self.createIndex(row_within_parent, 0, parentNode)
    

    parent() should return the QModelIndex of a given item, but row_within_parent actually represents the row of the item relative to the parent. createIndex() is then inconsistent, because the above row does not represent the correct pointer used for it. Instead, you should find the row of the parent through the grand parent.

    This is also reflected by the inconsistent display of the branch lines (which may not be shown on some OS/QStyles):

    wrong node display

    As you can see, the "cat" item doesn't show the branch line of the parent (unlike "dog" does), while the top level "birds" item shows a branch that shouldn't exist, since there are no other siblings after it.

    Here is the fixed version of the parent() function:

        def parent(self, index):
            if not index.isValid():
                return QModelIndex()
    
            childNode = index.internalPointer()
            parentNode = childNode.parent_node
    
            if parentNode == self.root_node:
                return QModelIndex()
    
            grandParent = parentNode.parent_node
    
            row_within_parent = grandParent.children.index(parentNode)
    
            return self.createIndex(row_within_parent, 0, parentNode)
    

    And the branches properly drawn:

    proper node display

    Finally, you probably did this for testing purposes, but you should be very careful in what you do within a showEvent(), because it can be triggered in many ways and also completely out of control of the user.

    If you want to do something when a widget is shown the first time, then either use a QTimer.singleShot(0, someFunction), or use an internal bool flag.

    class MainWindow(QMainWindow):
        def __init__(self):
            ...
            QTimer.singleShot(0, self.initModel)
    
        def initModel(self):
            data = ["mammals", "birds", "mammals/dog", "birds/eagle", "mammals/cat"]
    
            my_model = MyTreeModel()
            my_model.set_data(data)
            self.tree_view.setModel(my_model)
            self.tree_view.expandAll()
    
    # alternatively:
    
    class MainWindow(QMainWindow):
        _firstShow = False
        def showEvent(self, event):
            if not self._firstShow:
                self._firstShow = True
                my_model = MyTreeModel()
                ...
    

    In reality, the above is probably unnecessary, since the view/model is normally unaffected by delayed show events, and they have internal ways to deal with updates and geometry requirements delayed by later displaying.