添加链接
link管理
链接快照平台
  • 输入网页链接,自动生成快照
  • 标签化管理网页链接
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
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 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,應該可以看到以下畫面,就大功告成啦!

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

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