本篇目的:讀者可以透過擷取使用者鍵盤的行為,結合 day 16 的訊號連接以及 day 19 建立的表格寫入 class,實現複製 excel 試算表內容到 GUI 表格中
當我們實際編譯專案,將 UI 叫出來後,可以發現 QTableWidget 不只在讀取很笨重、連輸入也是很笨重。原生 QTableWidget 在輸入上難用的點如下:
不支援 excel 的複製貼上 → 今天 day 20 會說明的內容
無法根據該欄/列目的,限制 user 輸入的內容 (例如下拉式選單、只輸入數字等) → day 21/ 22 會說明的內容
在實務上我們工程師或產線人員很習慣將 SPEC 紀錄在 excel 表格中,但原生的 QTableWidget 不支援 excel 表格的複製貼上,因此我們只能一格一格輸入,多個人員分析相同產品就大家一起一格一格輸入,非常浪費時間。
今天 day 20 便會說明如何讓使用者關注 QTableWidget 時 (例如滑鼠點了該表格),可以讓 QTableWidget 偵測特定鍵盤行為並呼叫對應 function 達成複製貼上效果。本篇文章會延續 day 19 的建構方式,一樣做出一個傳入 table 即可複製貼上的管理器 class。並且會跟 day 19 的管理 QTableWidget class 結合,完整示範如何實現從 excel 試算表複製內容到 GUI 表格中。
(曾有人跟我反應人員A的設定不能複製給人員B用,很麻煩所以希望我增加功能。這篇文章便是為該同事而生,歡迎大家多多跟我抱怨給我鐵人賽靈感)
當我們在 windows 視窗中按下複製貼上時,便是將我們所選擇之項目複製到系統的剪貼簿 、或是從剪貼簿取得資料並貼在指定位置。而在 PySide6 環境時,建立一個 clipboard = QGuiApplication.clipboard() 物件即可讀取或寫入 windows 的剪貼簿。
我們從 excel 複製以下資料時,可以透過 clipboard.text() 讀取剪貼簿內的內容:
(我只有女武神,其他都買不到)

"""
在 jupyter 內的示範例
"""
from PySide6.QtGui import QGuiApplication
import sys
# 建立一個 QGuiApplication
# 這樣才能讀取系統剪貼簿
# 當我們執行 GUI 程式時已在 main 內建立 QApplication,因此不用額外建立此物件
app = QGuiApplication(sys.argv)
clipboard = QGuiApplication.clipboard()
print(repr(clipboard.text()))
我們要確認剪貼簿內的資料格式,所以我們使用 repr(clipboard.text()) 將完整的字串印出,此時會得到下面結果:
'1\t鯊魚\t1-60\tR\n2\t神杖\t3-60\tFB\n3\t女武神\t\tK\n'
我們可以看到以下3個特徵
無論儲存格內容,剪貼簿內就是一個字串 (str)
同列儲存格之間,以 \t 做分隔:例如”女武神”右側的儲存格是空白 (因為它沒有固鎖) ,因此接著2個連續的 \t
儲存格換列時,以 \n 做分隔
因此我們今天會特別針對剪貼簿的分隔方式,編寫 function 去除或寫入這些分隔符號,以達成跟剪貼簿的互動。
這種
\t、\n的分隔格式稱為 TSV 格式
當我們在 UI 中點選想互動的 table 並按下對應按鍵後,會經過從作業系統->GUI的下圖一連串過程。我們可以看到 QT 有內部的機制可以判斷 user 做了什麼動作 (例如按下 ctrl + v、改變視窗大小、在某物件上按了滑鼠左鍵),並且也可以將該訊號傳送給 user 選擇的物件,但 QTableWidget 並沒有支援整張表格的複製 / 貼上功能。
要讓 GUI 內的 QTableWidget 可以支援複製貼上功能,我們必須在 QTableWidget.event(...) 收到訊號前,設定一個自訂的 eventFilter() 攔截訊號並觸發 ctrl + v/ ctrl + c 對應的行為。

要在 QTableWidget 中安裝自訂 eventFilter() ,可以使用 table.installEventFilter(過濾器)。這個"過濾器"是一個有著 eventFilter() 的 class,實例化後的物件。我們接下來會設計這樣子的過濾器,當我們將想要有複製貼上功能的 table 傳入後,即可掛上 eventFilter() 並應用相關功能。
以下應用例主要會用到 day 19 的 TableManager (負責表格寫入讀取的 class)、 controller 以及等一下會說明的複製貼上管理器。三者的用途如下
KBshortCutOnTable:專門接收指定 QTableWidget 鍵盤事件與對接系統剪貼簿
TableManager:專門處理表格內部資料的轉檔、格式驗證與填表
Controller:負責將鍵盤訊號與表格動作以 Signal & Slot 跨層串接
整個示範例的架構
project/
│
├── main.py
├── ui/
│ └── UI.py
├── controller/
│ └── Controller.py
└── table_module/
├── KBshortCutOnTable.py # 複製貼上管理器
└── TableManager.py # 表格寫入讀取管理器
當 user 按下鍵盤按鍵時,並沒有按下 UI 上的任何東西,因此鍵盤按鍵與 method 的連接必須要用 signal & slot。若要讓 KBshortCutOnTable 可以發射訊號,須執行以下設定:
類別定義:讓 KBshortCutOnTable 繼承 QObject,使其具備 Signal & Slot 能力
訊號宣告:定義 text_sig = Signal(list),其中的 list 即為要貼上的資料;以及 copy_sig = Signal(),接收端收到此訊號後會執行複製 method
在 KBshortCutOnTable 初始化過程中,我們會將目標表格傳入此 class 並執行 self.table.installEventFilter(self) 安裝 eventFilter()。其中 installEventFilter(self) 的 self 便是已實例化的 KBshortCutOnTable 物件,因此實際運行時訊號就是傳入物件的 eventFilter(),再由 eventFilter() 進行判斷並呼叫對應 method。
from PySide6.QtWidgets import QTableWidget
from PySide6.QtGui import QGuiApplication
from PySide6.QtCore import QObject, Signal, Slot, QEvent, Qt
class KBshortCutOnTable(QObject):
text_sig = Signal(list)
copy_sig = Signal()
def __init__(self, table: QTableWidget) -> None:
super().__init__()
self.table = table
# 監聽 table 的鍵盤事件
self.table.installEventFilter(self)
# 取得系統剪貼簿實例
self.clipboard = QGuiApplication.clipboard()
剛剛有提到要安裝 eventFilter() 到 QTableWidget 上才能執行複製貼上行為,eventFilter() 就是一個接收 event 訊號並分類、呼叫對應行為的過濾器。eventFilter() 固定有 obj, event 兩個引數,其中 obj 是要作用的 QT 物件、event 就是訊號。
return True 代表是事件已處理完畢,obj 不須再處理事件。另外 return super().eventFilter(obj, event) 指的是若訊號不是 ctrl + v/ ctrl + c,則回到 table 原生的 eventFilter() 處理。
event.key(): 擷取按下的一般按鍵;event.modifiers(): 擷取按下的修飾鍵
def eventFilter(self, obj, event):
"""處理 Table 的鍵盤事件"""
if obj is self.table and event.type() == QEvent.KeyPress:
# Ctrl+C 複製
if event.key() == Qt.Key_C and event.modifiers() == Qt.ControlModifier:
self.copy_event()
return True
# Ctrl+V 貼上
if event.key() == Qt.Key_V and event.modifiers() == Qt.ControlModifier:
self.paste_event()
return True
return super().eventFilter(obj, event)
若 eventFilter() 收到的是 ctrl + v/ ctrl + c 訊號,會呼叫對應的 method。其中 copy_event() 會發射訊號叫 controller 抓取 TableManager 內 table 資料並回傳給 update_context_clipboard ;paste_event() 會將剪貼簿內之字串轉換後傳給 TableManager 貼上。
def copy_event(self):
"""發射訊號給接收端,通知外部準備輸出資料"""
self.copy_sig.emit()
def paste_event(self):
"""發射剪貼簿內的資料給接收端"""
context = self.convert_clipboard_str_to_list(self.clipboard.text())
self.text_sig.emit(context)
@Slot(str)
def update_context_clipboard(self, context: str):
"""將收到的表格資料更新至系統剪貼簿"""
self.clipboard.setText(context)
另外剪貼簿內之單一字串需經過轉換,這裡依照分行符號轉換成 list 方便貼上於 table 中。
@staticmethod
def convert_clipboard_str_to_list(context: str):
"""將剪貼簿內的 TSV 字串解析為 list[list[str | None]]"""
lines = context.strip("\n").split("\n")
res: list[list[str | None]] = []
for line in lines:
parts = [
x.strip() if x.strip() != "" else None
for x in line.split("\t")
]
res.append(parts)
return res
因應剪貼簿的 TSV 格式,我們需在 TableManager 中增加以下功能
複製支援(extract_table_data_directly):將表格資料萃取為帶有 \t 與 \n 的純文字字串
貼上支援(paste_context_from_clipboard):作為 Slot 接收已轉換的 list,並隨著資料量調整行列數並貼上資料
"""在 TableManager 中"""
def extract_table_data_directly(self) -> str:
"""將表格資料萃取為 TSV 字串,供複製至剪貼簿使用 (以直向為例)"""
rows = self.table.rowCount()
cols = self.table.columnCount()
res = []
for r in range(rows):
row_text = []
for c in range(cols):
item = self.table.item(r, c)
# Fail-Fast 防呆: 檢查儲存格不得為空
if item is None or not item.text().strip():
raise ValueError(f"表格第 {r + 1} 列、第 {c + 1} 行 ({self.data_keys[c]}) 不得為空值.")
row_text.append(item.text().strip())
res.append("\t".join(row_text))
return "\n".join(res)
@Slot(list)
def paste_context_from_clipboard(self, context: list[list[str | None]]):
"""接收剪貼簿解析後的二維資料,驗證格式並自動擴展寫入表格"""
if self.read_direction == "v":
rows = self.table.rowCount()
cols = self.table.columnCount()
# 檢查欄位數量是否匹配
if len(context[0]) != cols:
raise ValueError(f"貼上資料為 {len(context[0])} 欄,與原始表格 {cols} 欄不符")
# 依剪貼簿筆數動態設定列數
self.table.setRowCount(len(context))
for r, part in enumerate(context):
for c, data in enumerate(part):
if data is None:
raise ValueError("貼上資料含有空白儲存格")
# QTableWidgetItem 只接受字串
item = QTableWidgetItem(data)
self.table.setItem(r, c, item)
elif self.read_direction == "h":
# 橫向表格處理邏輯同上 (行列對調)
pass
這個示範例中有2個表格,我們一樣將這兩個表格當作引數傳入 KBshortCutOnTable 與 TableManager 。

貼上流程的訊號連接:將 kb.text_sig 直接連接至 mg.paste_context_from_clipboard,一行程式碼實現貼上
複製流程的調度:連接 kb.copy_sig 至 Controller 的 Slot,由 Controller 調度 mg 提取字串後回傳給 kb 更新剪貼簿
class MainController:
def __init__(self, ui_window: Ui_MainWindow) -> None:
self.ui_window = ui_window
self.set_manager()
self.set_connection()
def set_manager(self):
"""實例化快捷鍵管理器與表格內容管理器"""
self.vert_tb_kb = KBshortCutOnTable(self.ui_window.vertical_table)
self.vert_tb_mg = TableManager(
self.ui_window.vertical_table,
["Item", "color", "plus_factor", "product_factor"]
)
接著我們連接訊號,貼上部分因為 text_sig 帶有貼上資料,直接將訊號跟 TableManager 的貼上功能連接即可。複製部分會由 controller 抓取 TableManager 內資訊並發射給 KBshortCutOnTable ,我們需要把複製訊號連接到 controller 中的複製 function。
def set_connection(self):
"""以 Signal & Slot 跨層串接兩大模組"""
# 貼上:鍵盤發射資料訊號 -> 表格管理器直接接收並填表 (一行搞定)
self.vert_tb_kb.text_sig.connect(self.vert_tb_mg.paste_context_from_clipboard)
# 複製:鍵盤發射請求 -> Controller 調度取值後回寫剪貼簿
self.vert_tb_kb.copy_sig.connect(self.copy_vert_tb)
這個 function 便是 controller 中的複製 function。
@Slot()
def copy_vert_tb(self):
"""收到複製訊號後,向 TableManager 提取文字並更新至系統剪貼簿"""
context = self.vert_tb_mg.extract_table_data_directly()
self.vert_tb_kb.update_context_clipboard(context)
今天說明了如何讀寫系統剪貼簿,以及用管理器的方式掛載複製貼上功能。
但 QTableWidget 還有無法限制使用者輸入內容的問題,讓使用者自由的輸入很容易出現各種 bug (然後又要來找我們)。因此明天會用同樣建立功能模組的方式,說明如何在 QTableWidget 的指定欄位掛載 QSpinBox、QComboBox 等物件,既可限制內容也方便使用者輸入。