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

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

Custom Tree Model Class

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

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

因此需要一個模型去記錄從屬關係。底下實作一個萬用的基礎視圖模型 AbstractTreeModel,以提供 View 來綁定,其中:

  • _rootItem 為整個樹形圖看不到的頂點。你可以透過呼叫 QtCore.QModelIndex(),來取得一個不合法的 Index(通常代表此頂點)。
  • `_parents` 幫你記錄父子節點的從屬關係。
from typing import Any
from typing import Optional

from PyQt6 import QtCore


class AbstractTreeModel(QtCore.QAbstractItemModel):
    def __init__(self, root: Any, parent=None):
        super().__init__(parent)
        self._rootItem = root
        self._parents = {}  # {child_idx: parent_idx, ...}
        if post_init := getattr(self, "__post_init__", None):
            # pass all initialization arguments to __post_init__
            post_init()

    def index(self, row, column, parent: Optional[QtCore.QModelIndex]=None) -> QtCore.QModelIndex:
        """為視圖中所有能歷遍到的項目,給定一個Unique Index"""
        child_item = self.child(row, parent)
        # 雖然這裡使用 createIndex,但 Qt 有一套機制,在可能的情況下將會返回快取值
        # 每當視圖結構有改變時,例如載入新節點或移除現有節點,才會重新產生一個新的 Index
        index = self.createIndex(row, column, id(child_item))
        self._parents[index] = parent
        return index

    def itemFromIndex(self, index: Optional[QtCore.QModelIndex]=None) -> Any:
        """從Index獲取原始項目"""
        if index is None or not index.isValid():
            return self._rootItem
        return index.internalPointer()

    def parent(self, index) -> QtCore.QModelIndex:
        """從當前 Index 取得父層 Index"""
        return self._parents.get(index, QtCore.QModelIndex())

    # Implement or modify the following methods for the model to work properly

    def child(self, row: int, parent: Optional[QtCore.QModelIndex]=None) -> Any:
        """
        取得子項目
        you need to reimplement this method for the model work properly

        note: make sure this method always return the SAME child object
              with a given row number and a parent index,
              or python may crash by weird low-level memory error.
        """

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

    def columnCount(self, index: Optional[QtCore.QModelIndex]=None) -> int:
        """總欄數 You can overwrite this method for multi-column data model"""
        return 1

    def rowCount(self, index: Optional[QtCore.QModelIndex]=None) -> int:
        """
        取得當前 Index 的子項目數量
        LazyLoad 情況下也要計算出子項目數量,視圖中才會顯示展開圖示
        """
        return 0

    # Implement the following methods for lazy load model

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

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

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

以顯示 JSON 資料結構為例

SimpleDictModel

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

class SimpleDictModel(AbstractTreeModel):
    headers = ["Key", "Value", "Type"]

    def child(self, row: int, parent: QtCore.QModelIndex) -> Any:
        parent_item = self.itemFromIndex(parent)
        for i, (key, value) in enumerate(iter_children(parent_item)):
            if i == row:
                return value

    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 3

    def rowCount(self, index) -> int:
        item = self.itemFromIndex(index)
        return len(list(iter_children(item)))

    def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole):
        item = self.itemFromIndex(index)
        colname = self.headers[index.column()]
        if role == QtCore.Qt.ItemDataRole.DisplayRole:
            if colname == "Value":
                if len(list(iter_children(item))) == 0:
                    return str(item)
            elif colname == "Key":
                p = self.parent(index)
                pi = self.itemFromIndex(p)
                for r, (key, value) in enumerate(iter_children(pi)):
                    if r == index.row():
                        return key
            elif colname == "Type":
                return (type(item)).__name__

    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 iter_children(data: Any):
    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)
        self.tree_view.expandAll()

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 來載入子項目
from dataclasses import dataclass


@dataclass
class Data:
    cnt: int
    arr: list


class LazyDictModel(AbstractTreeModel):

    def child(self, row: int, parent: QtCore.QModelIndex) -> Any:
        parent_item = self.itemFromIndex(parent)
        for i, value in enumerate(parent_item.arr):
            if i == row:
                return value

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

    def rowCount(self, index=QtCore.QModelIndex()) -> int:
        item = self.itemFromIndex(index)
        return item.cnt

    def canFetchMore(self, parent: QtCore.QModelIndex) -> bool:
        item = self.itemFromIndex(parent)
        return item.cnt > 0 and len(item.arr) == 0

    def fetchMore(self, parent: QtCore.QModelIndex) -> None:
        item = self.itemFromIndex(parent)
        print("FetchMore", item)
        for _ in range(item.cnt):
            item.arr.append(Data(0, []))

    def data(self, index, role=QtCore.Qt.ItemDataRole.DisplayRole):
        item = self.itemFromIndex(index)
        colname = index.column()
        match role:
            case QtCore.Qt.ItemDataRole.DisplayRole | QtCore.Qt.ItemDataRole.EditRole:
                if colname == 0
                    return str(item.cnt)
            case QtCore.Qt.ItemDataRole.ForegroundRole:
                if colname == 0:
                    return QtGui.QBrush(QtGui.QColor("res"))

    def flags(self, index):
        flag = super().flags(index)
        if self.canFetchMore(index) or self.rowCount(index):
            return flag

        if index.column() == 0:
            flag |= QtCore.Qt.ItemFlag.ItemIsEditable
        return flag

    def setData(self, index: QtCore.QModelIndex, value: Any, role: int = ...) -> bool:
        assert role == QtCore.Qt.ItemDataRole.EditRole

        try:
            new_value = eval(value)
        except:
            return False

        item = self.itemFromIndex(index)
        print(item.cnt, new_value)
        if new_value != item.cnt:
            item.cnt = new_value
            self.dataChanged.emit(index, index, [role])
            return True
        else:
            return False

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 = Data(2, [Data(3, []), Data(0, [])])
        tree_model = LazyDictModel(data)
        self.tree_view.setModel(tree_model)

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

    sys.exit(app.exec())

可以在每次打開節點時,查看終端機的輸出,是否印出 FetchMore XXX,來確認 Lazy Load 的效果。藉由通過 flags 標記為可編輯,並且實作 setData,就可以更改數值。上例若將 0 更改為其他數字,就可以該數量的新增子項目。

標題列顯示

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

class LazyDictModel(AbstractTreeModel):
    headers = ["Count"]
    # ...
    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 typing import Any
from pathlib import Path

from PyQt6 import QtCore, QtWidgets

class FSModel(AbstractTreeModel):

    def __post_init__(self):
        self.childs = {}  # cache childs objects

    def child(self, row: int, parent: QtCore.QModelIndex) -> Any:
        parent_item = self.itemFromIndex(parent)
        if files := self.childs.get(parent_item):
            return files[row]

        files = list(parent_item.iterdir())
        self.childs[parent_item] = files
        return files[row]

    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.name

    def rowCount(self, index=QtCore.QModelIndex()):
        parent_item = self.itemFromIndex(index)
        if not parent_item.is_dir():
            return 0
        files = list(parent_item.iterdir())
        return len(files)

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

    def fetchMore(self, parent: QtCore.QModelIndex) -> None:
        parent_item = self.itemFromIndex(parent)
        files = list(parent_item.iterdir())
        self.layoutAboutToBeChanged.emit()
        self.beginInsertRows(parent, 0, len(files))
        self.childs[parent_item] = files
        self.endInsertRows()
        self.layoutChanged.emit()

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

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()

        self.layout = QtWidgets.QVBoxLayout(self)
        self.layout.addWidget(self.btn_openfolder)
        self.layout.addWidget(self.tree_view)

        """ use our model """
        tree_model = FSModel(Path(os.path.join(os.path.dirname(__file__), "..")))
        self.tree_view.setModel(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:
            tree_model = FSModel(Path(folderpath))
            self.tree_view.setModel(tree_model)


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

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

    sys.exit(app.exec())

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

References