本篇目的:讀者可以延續 day 18 的模組化設計,將 QTableWidget 的讀寫、行列增減等寫成一個模組,未來 UI 內的表格都可以用這個模組管理
我們在 day 11 中有提到 QTableWidget 的操作較為複雜,需要獨立一篇文章說明。原因是因為 QTableWidget 可以同時當作顯示介面與輸入介面 (例如從下拉式選單選擇設定後載入至表格中,然後修改內容並另存成一個新設定),但原生的 method 功能上又很簡陋,例如要讀取資料只能一格一格讀取。所以我們需要自己編寫 function 應對較複雜的功能需求。
因此今天的文章會先針對我們實際在使用 GUI 程式時會使用到的讀取、寫入、表格外觀控制等功能,設計出功能符合需求的 function。並且結合 day 18 所提到的模組化設計,將所有與 QTableWidget 有關的參數都收集在同一模組內,未來我們在任何有表格的專案都可以套用此模組,徹底簡化表格管理的麻煩。
QTableWidget 的讀取非常笨重,只能一格一格讀取,而且讀取後回傳的內容全都是字串。我們會在表格中輸入的內容通常是要當成計算的 SPEC 使用,所以我們的目的就是將表格內的資料輸出成適合分類讀取的 dict 格式,而且型別要正確。因此本 method 的流程大致如下:
抓取表格的行列數
逐行或逐列的讀取資料 (根據一開始的設定,畢竟表格輸入方向可能有直有橫)
將逐行或逐列的資料整理成 dict,並且最後輸出成 list[dict]
這裡先設定一個轉換型別的方法,會將輸入字串轉換成應有之格式。以下轉換的順序有其用意,布林值一定是對應到 True 或 False,所以優先處理;而剩下的選項能轉換的範圍由小到大為 整數→浮點數→字串 ,例如 '0' 可以是整數 0、也可以是浮點數 0.0、還可以是字串 '0',但我們應該會預期它是整數 0。
判斷布林值時,不可以使用
bool(text),因為只要不是空字串"",都會回傳 True。
def _parse_value(text: str) -> bool | int | float | str:
# 依序嘗試轉換型別: 布林 -> 整數 -> 浮點數 -> 字串
lower_text = text.lower()
if lower_text == "true":
return True
if lower_text == "false":
return False
# 嘗試轉換整數
try:
return int(text)
except ValueError:
pass
# 嘗試轉換浮點數
try:
return float(text)
except ValueError:
pass
# 保留原始字串
return text
以下是完整的表格讀取 method。可以看到轉換型別前多了一行 item.text().strip(),這個目的是去除字串前面或後面的空白,避免前後空白干擾型別轉換。
def extract_table_data(
table: QTableWidget,
data_keys: list[str],
read_direction: Literal["v", "h"] = "v"
) -> list[dict[str, Any]]:
"""讀取 QTableWidget 表格內容並轉型為字典列表.
Args:
table: 欲讀取資料之表格元件.
keys: 要存進 dict 時對應的 key
read_direction: 讀取方向.'v' 為垂直(標題在最上方),'h' 為水平(標題在最左側).預設為 'v'.
Returns:
list[dict[str, Any]]: 萃取後之資料清單,每筆資料以標題為 key.
Raises:
ValueError: 若儲存格內容為空,或指定欄位未填寫項目時拋出.
"""
rows = table.rowCount()
cols = table.columnCount()
result: list[dict[str, Any]] = []
if read_direction == "v":
# 逐列讀取資料
for r in range(rows):
row_data: dict[str, Any] = {}
for c in range(cols):
item = table.item(r, c)
# Fail-Fast 防呆: 檢查儲存格是否為 None 或未輸入文字
if item is None or not item.text().strip():
raise ValueError(f"表格資料不完整: 第 {r + 1} 列、第 {c + 1} 行 ({data_keys[c]}) 不得為空值.")
row_data[data_keys[c]] = _parse_value(item.text().strip())
result.append(row_data)
elif read_direction == "h":
# 省略垂直讀取過程
# 將 "逐列讀取資料" 的 rows 與 cols 對調即可
return result
這個 function 的目的是讓 user 可以透過其他 UI 元件 (例如 QSpinBox) 改變表格的大小。QTableWidget 改變大小的方式很簡單,只要透過 setRowCount 或是 setColumnCount 就可以。另外可以看到這個 method 也有選擇方向的功能。
def table_size_control(
table: QTableWidget,
size:int,
read_direction: Literal["v", "h"] = "v",
) -> None:
# 如果表格增減方向為直向
if read_direction == 'v':
table.setRowCount(size)
# 如果表格增減方向為橫向
elif read_direction == 'h':
table.setColumnCount(size)
這個 function 的應用場景是初始化或是使用者選擇設定檔後,將指定資料寫入表格中。因為我們不知道寫入資料會有幾筆 (單筆資料個數倒是知道),因此我們會先修改表格的大小後才開始寫入。
接著我們根據現在所在之欄與列,將對應的 key 與資料 dict 取出準備寫入。QTableWidget 的寫入也是一格一格作業,且只接受 QTableWidgetItem(字串),因此我們要跑行/列的迴圈將資料寫入。
def write_context_to_table(
table: QTableWidget,
data: list[dict[str, Any]],
data_keys: list[str],
read_direction: Literal["v", "h"] = "v"
) -> None:
# 如果表格增減方向為直向
if read_direction == 'v':
cols = table.columnCount()
if len(data_keys) != cols:
raise ValueError(
f"欄位名稱數量與表格欄數不一致:data_keys 有 {len(data_keys)} 個,表格有 {cols} 個。"
)
table.setRowCount(len(data)) # 修改表格大小
rows = table.rowCount()
for r in range(rows):
single_data = data[r]
for c in range(cols):
key = data_keys[c]
if key not in single_data:
raise ValueError(f"第 {r + 1} 筆資料缺少必要欄位:{key}")
# QTableWidgetItem只接受字串
item = QTableWidgetItem(str(single_data[key]))
table.setItem(r, c, item)
# 如果表格增減方向為橫向
# 編寫方式同上,不過 cols 在外層、rows 在內層
接續上面的 function,我們可以將這些方法打包成一個 class。打包 class 的好處是呼叫這些 function 時,不用每次都要傳入 table、read_direction 等引數,可以簡化在 Controller 中connect 時的手續。
我們的目的是簡化讀取流程、將該表格的對應屬性打包成一個 class,因此這個 class 會設計成只能對應一個表格。在初始化時,我們就會先將 table、data_keys、read_direction 存在此 class 中。
class TableManager:
def __init__(
self,
table: QTableWidget,
data_keys: list[str],
read_direction: Literal["v", "h"] = "v"
):
# 將 UI 元件實例與基本規格綁定在 self 狀態中
self.table = table
self.data_keys = data_keys
self.read_direction = read_direction
這樣後續我們呼叫 method 時,不用再傳入重複的屬性,只需要傳入必要的行/列數、寫入資料即可。
def extract_table_data(self):
# 步驟同上方,只需將式中其他引數改成 self.xxx 即可
def table_size_control(self, size:int):
# 步驟同上方,但class內該式只需傳入行列數即可運作
def write_context_to_table(self, data: list[dict[str, Any]]):
# 步驟同上方,但class內該式只需傳入寫入資料即可運作
本專欄提到的 class 實作方式都是在 controller 中將 class 實例化,然後將 controller 當麵包板使用,將裡面將不同 class 之間的 property、method 等串在一起。以今天的 TableManager class 為例,我們一樣先在 __init__ 內將其實例化。其實這些 class 可以在任何 method 中實例化,我們在 __init__ 內將其實例化的原因是它所有的引數都是已存在或是我們決定的。
另外要注意實例化的 TableManager 前面要加上 self.,這樣程式執行期間實例化的 TableManager 才不會被清除造成執行上的問題。
加上
self.的物件才會被當 property 保存下來,否則一般 function 內的物件在 function 執行完後就會被清掉。
class Controller(QMainWindow):
def __init__(self):
super().__init__()
self.ui = Ui_MainWindow()
# 建立 TableManager 物件並傳入表格與欄位定義
self.spec_table_manager = TableManager(
table=self.ui.table_spec,
data_keys=["item_name", "lower_limit", "upper_limit", "is_required"],
read_direction="v"
)
# 建立設定檔管理器
self.json_manager = JsonManager(
project_name="MyWaferTool",
user_config_name=["user_spec", "last_status"]
)
當我們將 QSpinBox 的值變化訊號連接到 TableManager 的 table_size_control 後,只要使用者變更 QSpinBox 裡的值,table 的大小就會產生變化。另外我個人習慣是將訊號連接獨立一個 function 並在 main 中呼叫它。
def make_connect(self):
# 設定 spinbox 內值變更後,表格大小會產生變化
self.ui.spin_spec_count.valueChanged.connect(
self.spec_table_manager.table_size_control
)
自訂 class 的最大魅力就是可以將複雜的行為做成 api,我們日後使用時只要呼叫 api 並處理回傳資料即可。例如當我們要將表格資料寫入到 json 設定檔與系統暫存區時,在 controller 中只需簡單 2~3 行程式碼即可完成:
# 這個 function 設定在 controller 中
def add_data_to_user_spec(self):
"""將UI上的SPEC輸入寫入到設定檔中"""
write_spec = "user_spec" # 設定檔名稱
# 讀取相關設定
new_item_name = self.ui.a_lineedit.text() # SPEC 名稱
data = self.spec_table_manager.extract_table_data() # SPEC 內容
# 寫入到管理器之中
self.json_manager.save_user_config(write_spec, new_item_name, data)
並且我們只要在 make_connect 中加入連接指令,使用者按下按鈕後就會呼叫儲存功能
# 連接按鍵到設定檔寫入功能
self.ui.a_button.clicked.connect(self.add_data_to_user_spec)
另外我們也同樣在 controller 中設定讀取指定 SPEC 後寫入到 table 的 function,再將其連接到 self.ui.a_combobox.activated.connect(self.on_user_spec_cb_changed) 中,即可讓使用者在使用下拉是選單後,帶入所選之項目。
def on_user_spec_cb_changed(self):
# 從 json_manager 中讀取整個 SPEC
read_spec = "user_spec" # 設定檔名稱
all_spec = self.json_manager.get_user_spec(read_spec)
# 抓出使用者選擇的 SPEC
select_keys = self.a_combobox.currentText() # 從使用者選擇的 SPEC 名稱
data = all_spec.get(select_keys)
# 若有該 SPEC 則將資料寫入 table
if data is not None:
self.spec_table_manager.write_context_to_table(data)
若有多個
TableManager的情況,上面的寫法需要個別編寫 function 對應多個TableManager。若不想寫這麼多次,可以修改 function 將
TableManager、QCombobox等物件作為引數,然後用 day 16 提到的lambda方式連接這些 function
今天介紹了 QTableWidget 的讀寫方法,並將相關方法包裝成可以一次讀寫所有資料的 function 與 class。
我們今天簡化了 QTableWidget 的讀取流程,但使用者在 GUI 中要一行一行輸入 SPEC 還是很麻煩。因此明天 day 20 我們會探討如何在表格中套用鍵盤滑鼠事件與 clipboard,在 QTableWidget 中實現複製貼上等功能。