為 PyQt5 中的資料綁定 Model 與 View — QTreeView 樹形圖篇

上一篇提到了表格的顯示,這一篇來實作看看樹形圖的顯示。

Custom Tree Model Class

樹形圖(下圖中的樹狀模型)和一般的列表、表格不同的地方在於,有階層關係:

圖片來源:https://muzing.top/posts/5ff61cbd/

因此需要一個結構體去記錄從屬關係。底下實作一個萬用基礎節點 TreeItem 來包裝樹形圖上的每一點資料,其中:

  • node 用來擺放真正的資料,可以為任何類型的物件。
  • _parent 用來記錄目前 TreeItem 的上層 TreeItem 節點
  • _childs 用來記錄目前 TreeItem 的子 TreeItem 節點
from dataclasses import dataclass, field
from typing import List

@dataclass
class TreeItem:
    node: Any = None
    _parent: TreeItem = field(default=None, repr=False)
    _childs: List[TreeItem] = field(default_factory=list, repr=False)

    def child(self, row):
        return self._childs[row]

    def childCount(self):
        return len(self._childs)

    def parent(self):
        return self._parent

    def row(self):
        if self._parent:
            self_id = id(self)
            child_ids = [id(x) for x in self._parent._childs]
            if self_id in child_ids:
                return child_ids.index(self_id)
        return 0

    def addChild(self, node: Any):
        child = self.__class__(node, _parent=self)
        self._childs.append(child)
        return child
如果你的Python版本不支援Any型別,可以加入 from __future__ import annotations將型別視為普通字串。

以及一個萬用基礎視圖模型 AbstractTreeModel 提供 View 來綁定,其中:

  • rootItem 為整個樹形圖看不到的頂點。你可以透過呼叫 QtCore.QModelIndex(),來取得一個不合法的 Index(通常代表此頂點)。
from PyQt5 import QtCore

class AbstractTreeModel(QtCore.QAbstractItemModel):
    def __init__(self, *args, parent=None, **kwargs):
        super().__init__(parent)
        self.rootItem = TreeItem()
        if post_init := getattr(self, "__post_init__", None):
            # pass all initialization arguments to __post_init__
            post_init(*args, **kwargs)

    def index(self, row, column, parent=QtCore.QModelIndex()) -> QtCore.QModelIndex:
        """為視圖中所有能歷遍到的項目,給定一個Unique Index"""
        if not self.hasIndex(row, column, parent):
            return QtCore.QModelIndex()

        if not parent.isValid():
            parent_item = self.rootItem
        else:
            parent_item = self.itemFromIndex(parent)

        if row >= parent_item.childCount():
            return QtCore.QModelIndex()

        child_item = parent_item.child(row)
        assert type(child_item) == type(self.rootItem), "%r, %r" % (
            type(child_item),
            type(self.rootItem),
        )
        # 雖然這裡使用 createIndex,但 Qt 有一套機制,在可能的情況下將會返回快取值
        # 每當視圖結構有改變時,例如載入新節點或移除現有節點,才會重新產生一個新的 Index
        index = self.createIndex(row, column, child_item)
        return index

    def itemFromIndex(self, index) -> TreeItem:
        """從Index獲取原始項目"""
        return index.internalPointer()

    def indexFromItem(self, item, column=0):
        """從項目獲取其Index"""
        if item is None:
            return QtCore.QModelIndex()
        assert type(item) == type(self.rootItem), "%r, %r" % (
            type(item),
            type(self.rootItem),
        )
        return self.createIndex(item.row(), column, item)

    def columnCount(self, index) -> int:
        """總欄數 You can overwrite this method for multi-column data model"""
        return 1

    def parent(self, index) -> QtCore.QModelIndex:
        """從當前 Index 取得父層 Index"""
        if not index.isValid():
            return QtCore.QModelIndex()
        child_item = self.itemFromIndex(index)
        parent_item = child_item.parent()
        if parent_item == self.rootItem:
            return QtCore.QModelIndex()
        elif parent_item is None:
            return QtCore.QModelIndex()
        else:
            assert type(parent_item) == type(self.rootItem), "%r, %r" % (
                type(parent_item),
                type(self.rootItem),
            )
            # 無論Column為何,均返回 Column 為 0 的父項目
            # 這裡弄錯欄的話,子項目會顯示不出來
            return self.createIndex(parent_item.row(), 0, parent_item)

    def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole) -> Any:
        """
        取得當前 Index 的 role 資料(文字顯示使用 DisplayRole,其他請參考 Document)
        you need to reimplement this method for correct data output
        """
        if not index.isValid():
            return None
        return None

    # You should implant/modify the following method for lazylod model

    def rowCount(self, index=QtCore.QModelIndex()) -> int:
        """
        取得當前 Index 的子項目數量
        LazyLoad 情況下也要計算出子項目數量,視圖中才會顯示展開圖示
        """
        if index.column() > 0:
            return 0
        if not index.isValid():
            item = self.rootItem
        else:
            item = self.itemFromIndex(index)
        return item.childCount()

    def canFetchMore(self, parent: QtCore.QModelIndex) -> bool:
        """視圖用來判斷當前 Index 是否可展開"""
        return False

    def fetchMore(self, parent: QtCore.QModelIndex) -> None:
        """展開節點時將會呼叫此 Method,請在此實作插入新資料/節點的程式碼"""
        ...

到這邊就打完樹形圖的基礎了,心裡 OS:程式碼也太長。這也是爲什麽要寫成 Abstract Class 的原因,之後要創建新 Tree Model 只要 Subclass 這些即可。

以顯示 JSON 資料結構為例

SimpleDictModel

這裡示範如何透過前述的 AbstractTreeModel 來實作一個視圖模型,以顯示多層結構的 JSON 資料。注意此模型會在初始化時載入所有資料,所以遇到肥胖的 JSON 資料 GUI 介面會卡頓一下。下一段將介紹如何實現異步載入。

class SimpleDictModel(AbstractTreeModel):

    def __post_init__(self, data):
        self.rootItem = TreeItem(("", {}))  # key value pair

        self._insert_items(data)

    def _insert_items(self, data: dict, parent=None):
        """custom private method to insert all children item in dict"""
        parent_item = parent or self.rootItem

        for key, value in iter_children(data):
            item = parent_item.addChild((key, value))
            if len(list(iter_children(value))):
                self._insert_items(value, item)

    def columnCount(self, index) -> int:
        return 3

    def rowCount(self, index=QtCore.QModelIndex()) -> int:
        if not index.isValid():
            return self.rootItem.childCount()

        item = self.itemFromIndex(index)
        return item.childCount()

    def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole):
        item = self.itemFromIndex(index)
        if role == QtCore.Qt.ItemDataRole.DisplayRole:
            key, val = item.node
            if index.column() == 0:
                return key
            elif index.column() == 1:
                if len(list(iter_children(val))) == 0:
                    return val
                else:
                    return ""
            else:
                return str(type(val))

def iter_children(data: Any):
    """function to iterate data children"""
    if isinstance(data, list):
        iter_func = lambda x: enumerate(x)
    elif isinstance(data, dict):
        iter_func = lambda x: x.items()
    else:
        iter_func = lambda x: []

    for x in iter_func(data):
        yield x

另外寫了一個 iter_children 以處理不同型別的歷遍。現在讓我們在 GUI 中使用吧,底下直接把欲顯示的資料 data 寫在程式碼裡面,實務上通常會從 API 擷取或是讀取外部檔案:

class MyWidget(QtWidgets.QWidget):
    def __init__(self):
        super().__init__()
        self.setup_ui()
        self.setup_model()

    def setup_ui(self):
        self.tree_view = QtWidgets.QTreeView()
        self.layout = QtWidgets.QVBoxLayout(self)
        self.layout.addWidget(self.tree_view)

    def setup_model(self):
        data = {
            "TrainNo": "TR0334",
            "Direction": 1,
            "Stops": [
                {
                    "Name": {
                        "En": "Tainan",
                        "Zh_tw": "台南",
                    },
                    "Name": {
                        "En": "Kaohsiung",
                        "Zh_tw": "高雄",
                    }
                }
            ],
            "TrainType": "Local",
        }
        tree_model = SimpleDictModel(data)
        self.tree_view.setModel(tree_model)

if __name__ == "__main__":
    app = QtWidgets.QApplication([])
    widget = MyWidget()
    widget.resize(800, 600)
    widget.show()

    sys.exit(app.exec())

開啟終端機輸入:

$ python3 SimpleDictModel.py

成功!

LazyLoadDictModel

現在來示範如何實作一個異步載入的視圖模型,使得資料只有在需要的時候才會載入視圖。針對一個有子項目的父層實作心法:

  • 在未載入資料時仍去計算子項目數量,這樣 View 上才會顯示 ▶
  • 在尚未完全載入的時候 canFetchMore 都要回 True
  • View 將呼叫 fetchMore 來載入子項目
class LazyDictModel(AbstractTreeModel):

    def __post_init__(self, data):
        self.rootItem = TreeItem(("", {}))  # key value pair
        self._insert_items(data)

    def _insert_items(self, data: dict, parent=None):
        """custom private method to insert all children item in dict"""
        parent_item = parent or self.rootItem

        for key, value in iter_children(data):
            parent_item.addChild((key, value))

    def columnCount(self, index) -> int:
        return 3

    def rowCount(self, index=QtCore.QModelIndex()) -> int:
        if not index.isValid():
            return self.rootItem.childCount()

        item = self.itemFromIndex(index)
        _, val = item.node
        return item.childCount() or len(list(iter_children(val)))

    def canFetchMore(self, parent: QtCore.QModelIndex) -> bool:
        if not parent.isValid():
            return False
        item = self.itemFromIndex(parent)
        _, val = item.node
        return item.childCount() == 0 and len(list(iter_children(val)))

    def fetchMore(self, parent: QtCore.QModelIndex) -> None:
        item = self.itemFromIndex(parent)
        key, val = item.node
        print("FetchMore", key)  # print for debug
        self._insert_items(val, item)

    def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole):
        item = self.itemFromIndex(index)
        if role == QtCore.Qt.ItemDataRole.DisplayRole:
            key, val = item.node
            if index.column() == 0:
                if len(list(iter_children(val))) == 0:
                    return val
                else:
                    return ""
            elif index.column() == 1:
                return key
            elif index.column() == 2:
                return str(type(val))

可以在每次打開節點時,查看終端機的輸出,是否印出 FetchMore XXX,來確認 Lazy Load 的效果。

標題列顯示

標題列現在顯示1、2、3很醜,想要顯示文字?試著加入 headerData 來顯示標題列資料吧:

class LazyDictModel(AbstractTreeModel):
    HEADERS = ["Name", "Value", "Type"]
    # ...
    def headerData(
        self,
        section: int,
        orientation: QtCore.Qt.Orientation,
        role=QtCore.Qt.ItemDataRole.DisplayRole,
    ) -> Any:
        assert orientation == QtCore.Qt.Orientation.Horizontal
        if role == QtCore.Qt.ItemDataRole.DisplayRole:
            return self.HEADERS[section]
        return super().headerData(section, orientation, role)

    def columnCount(self, index) -> int:
        return len(self.HEADERS)

現在重新執行一次GUI,應該可以看到以下畫面,就大功告成啦!

以 File Explorer 爲例

雖然 PyQt 已經有內建 QFileSystemModel,這邊還是讓我們重新打造一次輪胎,練習實作 Model。

顯示資料夾内的檔案:

我們 Subclass 前述的兩個基礎 Class 分別實作

from __future__ import annotations

import contextlib
from pathlib import Path

from PyQt6 import QtCore, QtWidgets


@contextlib.contextmanager
def layoutChange(model: AbstractTreeModel):
    try:
        model.layoutAboutToBeChanged.emit()
        yield
    finally:
        model.layoutChanged.emit()


class FSModel(AbstractTreeModel):
    def __post_init__(self):
        self.rootItem = TreeItem(Path())

    def headerData(
        self,
        section: int,
        orientation: QtCore.Qt.Orientation,
        role=QtCore.Qt.ItemDataRole.DisplayRole,
    ) -> Any:
        assert orientation == QtCore.Qt.Orientation.Horizontal
        if role == QtCore.Qt.ItemDataRole.DisplayRole:
            if section == 0:
                return "Name"
        return super().headerData(section, orientation, role)

    def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole):
        item = self.itemFromIndex(index)
        if role == QtCore.Qt.ItemDataRole.DisplayRole:
            return item.node.name

    def setRootPath(self, path: str):
        """useful method to set the root path of this model"""
        with layoutChange(self):
            self.beginRemoveRows(QtCore.QModelIndex(), 0, self.rootItem.childCount())
            self.rootItem = TreeItem(Path(path))
            self.endRemoveRows()

        self.fetchMore(self.indexFromItem(self.rootItem))

    def rowCount(self, index=QtCore.QModelIndex()):
        if not self.canFetchMore(index):
            return super().rowCount(index)
        item = self.itemFromIndex(index)
        if item.node.is_dir():
            try:
                return len(list(item.node.iterdir()))
            except PermissionError:
                return 0
        else:
            return 0

    def canFetchMore(self, parent: QtCore.QModelIndex) -> bool:
        if not parent.isValid():
            return False
        item = self.itemFromIndex(parent)
        if item is None:
            return False
        return item.childCount() == 0 and item.node.is_dir()

    def fetchMore(self, parent: QtCore.QModelIndex) -> None:
        parent_item = self.itemFromIndex(parent)
        files = list(parent_item.node.iterdir())
        with layoutChange(self):
            self.beginInsertRows(parent, 0, len(files))
            for f in files:
                parent_item.addChild(f)
            self.endInsertRows()

然後在主程式裡面使用此模型:

import os
import sys
from PyQt5 import QtCore, QtWidgets

class MyWidget(QtWidgets.QWidget):
    def __init__(self):
        super().__init__()
        self.btn_openfolder = QtWidgets.QPushButton()
        self.btn_openfolder.setText("&Open Folder")
        self.tree_view = QtWidgets.QTreeView()

        """ add a button to open other folder """
        self.layout = QtWidgets.QVBoxLayout(self)
        self.layout.addWidget(self.btn_openfolder)
        self.layout.addWidget(self.tree_view)

        """ use our model """
        self.tree_model = FSModel()
        self.tree_model.setRootPath(os.path.join(os.path.dirname(__file__), ".."))
        self.tree_view.setModel(self.tree_model)

        """ init event binding"""
        self.btn_openfolder.clicked.connect(self._select_folder)

    def _select_folder(self):
        folderpath = QtWidgets.QFileDialog.getExistingDirectory(self, 'Select Folder')
        if folderpath:
            self.tree_model.setRootPath(folderpath)


if __name__ == "__main__":
    app = QtWidgets.QApplication([])

    widget = MyWidget()
    widget.resize(800, 600)
    widget.show()

    sys.exit(app.exec())

就大功告成啦~來看效果:

References