iT邦幫忙

2026 iThome 鐵人賽

DAY 20
0
Software Development

用 Python 打造最順手的良率統計工具:半導體工程師的模組化開發之道系列 第 20 篇

Day 20:複製貼上才不簡單 - 掌控 QTableWidget 的鍵盤事件來快速貼上設定

  • 分享至 

  • xImage
  •  

本篇目的:讀者可以透過擷取使用者鍵盤的行為,結合 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用,很麻煩所以希望我增加功能。這篇文章便是為該同事而生,歡迎大家多多跟我抱怨給我鐵人賽靈感)

複製儲存格時,剪貼簿 (clipboard) 裡裝著什麼

當我們在 windows 視窗中按下複製貼上時,便是將我們所選擇之項目複製到系統的剪貼簿 、或是從剪貼簿取得資料並貼在指定位置。而在 PySide6 環境時,建立一個 clipboard = QGuiApplication.clipboard() 物件即可讀取或寫入 windows 的剪貼簿。

我們從 excel 複製以下資料時,可以透過 clipboard.text() 讀取剪貼簿內的內容:

(我只有女武神,其他都買不到)

https://ithelp.ithome.com.tw/upload/images/20261003/20182319KEcqLZZ1qi.png

"""
在 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 對應的行為。

https://ithelp.ithome.com.tw/upload/images/20261003/20182319TzuanbvGgM.png

要在 QTableWidget 中安裝自訂 eventFilter() ,可以使用 table.installEventFilter(過濾器)。這個"過濾器"是一個有著 eventFilter() 的 class,實例化後的物件。我們接下來會設計這樣子的過濾器,當我們將想要有複製貼上功能的 table 傳入後,即可掛上 eventFilter() 並應用相關功能。

應用例:一個可以複製與貼上的 UI 內表格

以下應用例主要會用到 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()

如何偵測使用者的鍵盤行為並觸發對應 function

剛剛有提到要安裝 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

Controller 中的連接

這個示範例中有2個表格,我們一樣將這兩個表格當作引數傳入 KBshortCutOnTable 與 TableManager 。

https://ithelp.ithome.com.tw/upload/images/20261003/20182319PeMJwMKZJB.png

  • 貼上流程的訊號連接:將 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)

To be continued →

今天說明了如何讀寫系統剪貼簿,以及用管理器的方式掛載複製貼上功能。

但 QTableWidget 還有無法限制使用者輸入內容的問題,讓使用者自由的輸入很容易出現各種 bug (然後又要來找我們)。因此明天會用同樣建立功能模組的方式,說明如何在 QTableWidget 的指定欄位掛載 QSpinBox、QComboBox 等物件,既可限制內容也方便使用者輸入。


上一篇
Day 19: 解放你的表格 - QTableWidget 的一站式功能管理
下一篇
Day 21:別問 A 答 B - 使用 QStyledItemDelegate 限縮使用者在表格中的輸入內容
系列文
用 Python 打造最順手的良率統計工具:半導體工程師的模組化開發之道 共 21 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言