QTreeView, QAbstractItemModel. Падение при раскрытии узла модели
Нужно отобразить в QTreeView древовидную модель. Дочерние узлы должны создаваться при раскрытии узла пользователем. Количество дочерних узлов заранее неизвестно. Некоторые дочерние узлы могут быть созданы сразу после раскрытия родительского узла. О некоторых дочерних узлах необходимо запросить данные и только после получения этих данных они могут быть созданы.
Для этого создан бутерброд QTreeView + Прокси-модель для сортировки и фильтрации + Модель Qt (наследник QAbstractItemModel) + Модель данных. Код ниже отлично работает при наличии прокси-модели. При ее отсутствии попытка раскрыть узел приводит к падению: Process finished with exit code -1073741819 (0xC0000005) Боюсь, что это признак ошибки, которая рано или поздно проявится и при наличии прокси-модели.
Подскажите, в чем может быть причина/на что стоит обратить внимание?
UPD 1: добавлен сигнал _populate_request с QueuedConnection, чтобы "отделить" стек вызовов fetchMore от добавления/удаления узлов из модели. Это работает, но идея по-прежнему не кажется мне очевидной. И нет уверенности, что это самое верное решение. Какие-то комментарии или выдержки из документации помогли бы.
UPD 2: добавлено контекстное меню "Reload" для перезагрузки дочерних узлов. Чтобы проверить удаление старых узлов из модели.
import random
import sys
import weakref
from enum import Enum, auto
from typing import Optional, List
from PyQt5 import QtCore, QtTest
from PyQt5.QtWidgets import QMainWindow, QTreeView, QVBoxLayout, QApplication, QMenu
from PyQt5.QtCore import QModelIndex, Qt
class TreeNodeStatus(Enum):
NotPopulated = auto()
Populating = auto()
Populated = auto()
Error = auto()
class TreeNode(QtCore.QObject):
""" Узел дерева объектов; корневой узел по сути и есть модель данных """
status_changed = QtCore.pyqtSignal(object) # node
before_children_added = QtCore.pyqtSignal(object, int, int) # parent_node, pos, count
children_added = QtCore.pyqtSignal(object, int, int) # parent_node, pos, count
before_children_removed = QtCore.pyqtSignal(object, int, int) # parent_node, pos, count
children_removed = QtCore.pyqtSignal(object, int, int) # parent_node, pos, count
_populate_request = QtCore.pyqtSignal()
def __init__(self, name: str, parent: Optional['TreeNode']):
super().__init__()
self._name = name
self._parent_ref = weakref.ref(parent) if parent is not None else lambda: None
self._status: TreeNodeStatus = TreeNodeStatus.NotPopulated
self._children: List[TreeNode] = []
# чтобы следить за сигналами только корневого узла
if parent is not None:
self.status_changed.connect(parent.status_changed)
self.before_children_added.connect(parent.before_children_added)
self.children_added.connect(parent.children_added)
self.before_children_removed.connect(parent.before_children_removed)
self.children_removed.connect(parent.children_removed)
# чтобы отделить стек вызовов fetchMore > populate от добавления/удаления узлов из модели;
# для узлов, которые создаются сразу в populate, прямой вызов
# fetchMore > populate > _on_children_received приводит к падению;
# при вызове populate с помощью этого сигнала работает без падений
self._populate_request.connect(self._populate, Qt.ConnectionType.QueuedConnection)
# для узлов, которые создаются с задержкой, только после получения данных;
# чтобы имитировать задержку получения данных
self._timer = QtCore.QTimer()
self._timer.setSingleShot(True)
self._timer.setInterval(2 * 1000) # 2с
self._timer.timeout.connect(self._on_children_received)
def parent(self) -> Optional['TreeNode']:
return self._parent_ref()
@property
def status(self) -> TreeNodeStatus:
return self._status
def _set_status(self, status: TreeNodeStatus):
self._status = status
self.status_changed.emit(self)
def populate(self):
# # вызов через сигнал с QueuedConnection - работает
# self._populate_request.emit()
# прямой вызов приводит к падению для узлов, создающихся сразу, если нет прокси-модели
self._populate()
def _populate(self):
if self.status == TreeNodeStatus.Populating:
return
self._set_status(TreeNodeStatus.Populating)
# забываем старых детей
old_children_count = len(self._children)
self.before_children_removed.emit(self, 0, old_children_count)
# disconnect signals
for child in self._children:
child.status_changed.disconnect(self.status_changed)
child.before_children_added.disconnect(self.before_children_added)
child.children_added.disconnect(self.children_added)
child.before_children_removed.disconnect(self.before_children_removed)
child.children_removed.disconnect(self.children_removed)
self._children.clear()
self.children_removed.emit(self, 0, old_children_count)
# запрашиваем данные о дочерних узлах
# # таймер - для узлов, которые требуют запроса данных
# self._timer.start()
# прямой вызов - для узлов, которые можем создать сразу
self._on_children_received()
def children(self) -> List['TreeNode']:
return self._children
@property
def name(self) -> str:
return self._name
def _on_children_received(self):
print('!_on_children_received', self.name)
# создаем дочерние узлы
new_children_count = random.randint(0, 4)
self.before_children_added.emit(self, 0, new_children_count)
self._children = [TreeNode(self.name + ' ' + str(i), self) for i in range(new_children_count)]
self.children_added.emit(self, 0, new_children_count)
self._set_status(TreeNodeStatus.Populated)
class TreeModel(QtCore.QAbstractItemModel):
def __init__(self, root_node: TreeNode):
super().__init__()
# root node == data model
self._root_node = root_node
self._root_node.status_changed.connect(self._on_node_status_changed)
self._root_node.before_children_added.connect(self._before_children_added)
self._root_node.children_added.connect(self._on_children_added)
self._root_node.before_children_removed.connect(self._before_children_removed)
self._root_node.children_removed.connect(self._on_children_removed)
def index(self, row: int, column: int, parent=QModelIndex(), *args, **kwargs) -> QModelIndex:
# отбрасываем несуществующие индексы: внутри проверки на row/column для данного parent
if not self.hasIndex(row, column, parent):
return QModelIndex()
# получим родительский узел по индексу
if parent is None or not parent.isValid():
parent_node: TreeNode = self._root_node
else:
parent_node: TreeNode = parent.internalPointer()
# если есть ребенок с данных номером строки
if row < len(parent_node.children()):
# создаем индекс с узлом в качестве internalPointer
return self.createIndex(row, column, parent_node.children()[row])
return QModelIndex()
def parent(self, index: QModelIndex = None) -> QModelIndex:
# invalid index => root node
if not index.isValid():
return QModelIndex()
node: TreeNode = index.internalPointer()
parent_node: TreeNode = node.parent()
# если искомый родитель - корень, возвращаем невалидный индекс
if parent_node is self._root_node:
return QModelIndex()
# получим row для родительского узла; parent_node - не корень, гарантировано имеет собственного родителя
grandparent_node = parent_node.parent()
parent_row = grandparent_node.children().index(parent_node)
# создаем индекс с узлом в качестве internalPointer
return self.createIndex(parent_row, 0, parent_node)
def hasChildren(self, parent=QModelIndex(), *args, **kwargs) -> bool:
# можем ли мы развернуть узел? если да, слева от узла отображается треугольник
parent_node = self._node_from_index(parent)
# дети загружены - смотрим количество
if parent_node.status == TreeNodeStatus.Populated:
return len(parent_node.children()) > 0
# error - детей нет, развернуть нельзя
elif parent_node.status == TreeNodeStatus.Error:
return False
# дети не загружены/загружаются - предполагаем, что они есть
else:
return True
def canFetchMore(self, parent: QModelIndex) -> bool:
# можем ли мы получить больше данных (дочерних узлов) для parent?
# print('canFetchMore!', self._node_from_index(parent).name)
return self._can_fetch_more(parent)
def _can_fetch_more(self, parent: QModelIndex) -> bool:
parent_node = self._node_from_index(parent)
# дети не загружены - предполагаем, что они есть
if parent_node.status == TreeNodeStatus.NotPopulated:
return True
# в других случаях больше данных получить нельзя
elif parent_node.status in [TreeNodeStatus.Populating,
TreeNodeStatus.Populated,
TreeNodeStatus.Error]:
return False
assert False
def fetchMore(self, parent: QModelIndex) -> None:
# получаем больше данных (дочерних узлов) для parent
print('!FetchMore', self._node_from_index(parent).name)
if not self._can_fetch_more(parent):
return
parent_node = self._node_from_index(parent)
if parent_node.status != TreeNodeStatus.Populating:
parent_node.populate()
def rowCount(self, parent=QModelIndex(), *args, **kwargs):
parent_node = self._node_from_index(parent)
return len(parent_node.children())
def columnCount(self, parent=None, *args, **kwargs):
return 1
def _node_from_index(self, index: Optional[QModelIndex]) -> TreeNode:
# invalid index - root node
if index is None or not index.isValid():
return self._root_node
else:
return index.internalPointer()
def _index_from_node(self, node: TreeNode) -> Optional[QModelIndex]:
# root node - invalid index
if node is self._root_node:
return QModelIndex()
# по принципу из index
parent_node = node.parent()
row = parent_node.children().index(node)
return self.createIndex(row, 0, node)
def data(self, index, role=None):
node = self._node_from_index(index)
if role == Qt.DisplayRole:
return node.name
# узлы - по UserRole
elif role == Qt.UserRole:
return node
elif role == Qt.DecorationRole:
pass
def _on_node_status_changed(self, node: TreeNode):
index = self._index_from_node(node)
if index is not None:
# оповестим об изменениях - иконка, тултип
self.dataChanged.emit(index, index, [Qt.DecorationRole, Qt.ToolTipRole])
def _before_children_removed(self, parent_node: TreeNode, pos: int, count: int):
parent_index = self._index_from_node(parent_node)
if parent_index is not None:
self.beginRemoveRows(parent_index, pos, pos + count - 1)
def _on_children_removed(self, parent_node: TreeNode, pos: int, count: int):
self.endRemoveRows()
def _before_children_added(self, parent_node: TreeNode, pos: int, count: int):
parent_index = self._index_from_node(parent_node)
if parent_index is not None:
self.beginInsertRows(parent_index, pos, pos + count - 1)
print('!beginInsertRows', parent_node.name)
def _on_children_added(self, parent_node: TreeNode, pos: int, count: int):
self.endInsertRows()
print('!endInsertRows', parent_node.name)
class TreeView(QTreeView):
def __init__(self, *args, **kwargs):
super().__init__(*args, **kwargs)
self._menu = QMenu(self)
# загрузка детей заново
self._reload_act = self._menu.addAction('Reload')
self._reload_act.triggered.connect(self._on_reload)
def mouseReleaseEvent(self, event):
""" Вызываем контекстное меню """
super().mouseReleaseEvent(event)
if event.button() == Qt.MouseButton.RightButton:
index = self.indexAt(event.pos())
# above nodes only
if index.isValid():
self._menu.popup(self.viewport().mapToGlobal(event.pos()))
def _on_reload(self):
index = self.currentIndex()
node = index.data(role=Qt.UserRole)
if node.status != TreeNodeStatus.Populating:
node.populate()
class ClientWindow(QMainWindow):
def __init__(self):
super().__init__()
self._setup_ui()
root_node = TreeNode('root', None)
model = TreeModel(root_node)
# proxy = QtCore.QSortFilterProxyModel()
# proxy.setSourceModel(model)
# FixMe падает при раскрытии узла, если передаем сюда исходную модель
self._view.setModel(model)
def _setup_ui(self):
self._view = TreeView()
self._view.setSortingEnabled(True)
central_wdg = self._view
central_vlt = QVBoxLayout()
central_wdg.setLayout(central_vlt)
self.setCentralWidget(central_wdg)
if __name__ == '__main__':
app = QApplication([])
main_window = ClientWindow()
main_window.show()
sys.exit(app.exec())