上一篇提到了表格的顯示,這一篇來實作看看樹形圖的顯示。
Custom Tree Model Class
樹形圖(下圖中的樹狀模型)和一般的列表、表格不同的地方在於,有階層關係:
因此需要一個模型去記錄從屬關係。底下實作一個萬用的基礎視圖模型 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())
就大功告成啦~來看效果: