iT邦幫忙

2026 iThome 鐵人賽

DAY 22
0
Software Development

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

Day 22:台下十年功,台上一分鐘 - 只用一行 code 完成表格輸入格式設定

  • 分享至 

  • xImage
  •  

本篇目的:讓表格套用自訂 delegate 後仍支援鍵盤移動編輯,並且設計一 function 簡化後續表格委派的設定

前言

在 day 21 中,我們已做出可以指定儲存格輸入整數的 delegate class (QSpinBox),但我們的委派功能仍有以下問題:

  • 其他 delegate class 要怎麼設定 (QDoubleSpinBox、QComboBox 等)

  • 當 QTableWidget 使用 QSpinBox 委派後,不能用鍵盤的方向鍵改變編輯格子,按了上下鍵只會改變數值。
    這個使用方式與 excel 有明顯差異, 所有人都會希望能全程用鍵盤輸入 SPEC,而不是一下鍵盤輸入一下滑鼠點選

    https://i.meee.com.tw/Io7Cwpn.gif

  • 委派需一行一行或一列一列設定,若一個表格需要多格委派設定需重複呼叫 setItemDelegateFor...... 。
    產品 SPEC 那麼多種,很快這些委派 code 就像洗碗槽中的大腸桿菌,在 controller 不斷滋生

今天 day 22 便會說明如何因應 editor 特性來修改 delegate class。並且設計一個鍵盤事件安裝 class,讓我們在原本的 delegate class 中多加一行即可用方向鍵切換,做出滑順的輸入。最後會設計一個公開介面 (public interface) 讓一個表格的複雜委派也只需要一行 code 即可完成。

題外話:原生的 QTableWidget 按了上下鍵也只會切換關注儲存格,要再點一下或按鍵才會進入編輯模式;另外按下左右鍵是沒有反應的,真的不好用...…

其他輸入方式的 delegate class 編輯方法

Delegate class 的基本架構就是 createEditor() 、setEditorData()、setModelData() ,不會因 editor 而改變。因此不同 editor 的 delegate class 差異主要會在 editor 本身的初始化設定與取值方式。

用以下的 QComboBox editor 為例,這個 class 與 day 21 IntSpinDelegate 的主要差異如下

  • QComboBox 沒有最大最小值,但有多個可選選項,因此在 createEditor() 中會把選項加入 QComboBox 內

  • 在 setEditorData() 中將 model 值載入的方式,變成先確認 model 值是否為 editor 選項後才載入

  • 在 setModelData() 的取值方式不同,變成 QComboBox 的取值方式 currentText()

從以上差異可以看出,若我們要為不同的輸入介面設計 delegate class,針對不同 editor 本身的 property、取值與賦值方式修改即可。

class ComboDelegate(QStyledItemDelegate):

    def __init__(self,choices: Sequence,parent=None):
      
        super().__init__(parent)

        self.choices = list(choices)

    def createEditor(self,parent,option,index):
        """建立表格儲存格的 QComboBox 編輯器。"""

        editor = QComboBox(parent)

        editor.addItems(
            [str(choice) for choice in self.choices]
        )

        return editor

    def setEditorData(self,editor,index):
        """將 Model 中的資料載入 QComboBox。"""

        value = (
            index.data(Qt.ItemDataRole.EditRole)
            or index.data(Qt.ItemDataRole.DisplayRole)
            or ""
        )

        value = str(value)

        # 尋找模型值在 QComboBox 內的位置
        current_index = editor.findText(value)
        # 將 QComboBox 設定為模型值
        if current_index >= 0:
            editor.setCurrentIndex(current_index)
        else:
            editor.setCurrentIndex(-1)

    def setModelData(self,editor,model,index):
        """將 QComboBox 的資料寫回 Model。"""

        model.setData(
            index,
            editor.currentText(),
            Qt.ItemDataRole.EditRole,
        )

套用方向鍵移動的 class 結構

套用了 delegate 的欄/列無法用方向鍵移動的原因是套用的 editor 有本身的方向鍵事件,例如 QSpinBox 按下上下鍵會讓數字 ± step。因此我們要用 day 20 攔截鍵盤事件的手法,將方向鍵事件改成移動儲存格並開啟編輯模式。

初始化

當我們設計此 class 時,需繼承 day 21 提到的 QStyledItemDelegate,這是因為若要做出"使用者按下方向鍵後,移動目標儲存格並開啟 editor“,需要用到 QStyledItemDelegate 內的訊號。

QStyledItemDelegate 是預設的 delegate class,因此只要牽涉到 delegate 有的功能都要用到它

class ArrowKeyNavigateDelegate(QStyledItemDelegate):

公開介面:安裝鍵盤按鍵偵測到編輯器(editor)上

這個 class 會設計成其他 delegate class 繼承它後,每次建立 editor 後只需要呼叫這個公開介面即可安裝方向鍵事件。這個 function 會執行以下事件:

  1. 對 editor 執行 editor.installEventFilter(self),讓我們自訂的 eventFilter() 可以攔截 editor 的鍵盤事件

  2. 將 option.widget(所屬的 QTableWidget)記錄到 editor property

  3. 將 index.row() 與 index.column() 記錄到 editor property,方便之後計算相鄰儲存格的位置

我們要將 widget 、row 、column 記錄到 editor property 的原因是 eventFilter() 固定只能有兩個引數 obj, event,此處的 obj 就是 editor (要作用的物件),但我們又需要這些屬性達成移動編輯中表格的效果,因此把它們記錄到 editor 中。

另外Day 20 是將 eventFilter() 掛在整張 QTableWidget 上,攔截 Ctrl+C 與 Ctrl+V;這裡則是掛在正在編輯的 editor 上 (QSpinBox/ QDoubleSpinBox / QComboBox ),來攔截方向鍵。

    def install_nav_props(
        self,
        editor: QSpinBox | QDoubleSpinBox | QComboBox,
        option: QStyleOptionViewItem,
        index: QModelIndex,
    ):
        """在表格編輯器中安裝鍵盤事件偵測,並記錄目前儲存格資訊。"""

        editor.installEventFilter(self)

        # 將必須屬性記錄到 editor 中,這樣才能在 eventFilter 中使用
        editor.setProperty("view", option.widget)
        editor.setProperty("row", index.row())
        editor.setProperty("col", index.column())

按下方向鍵後須執行的流程

當 user 在 editor 中按下方向鍵時,我們不能只切換編輯儲存格,否則目前剛輸入的值可能還沒有寫回 model。

handle_arrow_navigation() 會依序完成「確認按鍵方向 → 檢查新座標 → 儲存目前資料 → 關閉目前 editor → 開啟相鄰 editor」的流程。這個 function 會在 eventFilter() 偵測倒是方向鍵事件後觸發。

    def handle_arrow_navigation(self, editor, key):
        """處理方向鍵移動邏輯。"""
        view = editor.property("view")
        row = int(editor.property("row"))
        col = int(editor.property("col"))

        dr, dc = 0, 0
        if key == Qt.Key.Key_Left:
            dc = -1
        elif key == Qt.Key.Key_Right:
            dc = 1
        elif key == Qt.Key.Key_Up:
            dr = -1
        elif key == Qt.Key.Key_Down:
            dr = 1
        else:
            return False

        new_row = row + dr
        new_col = col + dc
        model = view.model()

        if (
            0 <= new_row < model.rowCount()
            and 0 <= new_col < model.columnCount()
        ):
            # 將目前 editor 的值寫回 Model
            self.commitData.emit(editor)

            # 關閉目前 editor
            self.closeEditor.emit(
                editor,
                QAbstractItemDelegate.EndEditHint.NoHint,
            )

            # 切換到相鄰儲存格,並立即進入編輯模式
            new_index = model.index(new_row, new_col)
            view.setCurrentIndex(new_index)
            view.edit(new_index)

            return True

        return False

eventFilter():偵測使用者按下的按鍵並呼叫對應 function

eventFilter() 只會在收到 QEvent.KeyPress 時檢查按鍵。若是方向鍵,就交給 handle_arrow_navigation();其他按鍵則回傳給 editor原本的處理流程,保留數字輸入、下拉選擇等既有行為。

當 handle_arrow_navigation() 成功切換儲存格時會回傳 True,代表事件已處理完畢,editor 不會再把方向鍵解讀成自己的預設動作。

    def eventFilter(self, editor, event):
        """監聽 editor 的鍵盤事件。"""
        if event.type() == QEvent.Type.KeyPress:
            key = event.key()

            if key in (
                Qt.Key.Key_Left,
                Qt.Key.Key_Right,
                Qt.Key.Key_Up,
                Qt.Key.Key_Down,
            ):
                return self.handle_arrow_navigation(editor, key)

        return super().eventFilter(editor, event)

讓 delegate class 產生之 editor 套用方向鍵移動功能

我們只要將 delegate class 的繼承對象改成 ArrowKeyNavigateDelegate,並在 createEditor() 建立 editor 後加入 self.install_nav_props(editor, option, index),就能套用方向鍵移動。

class IntSpinDelegate(ArrowKeyNavigateDelegate):
    def createEditor(self, parent, option, index):
        editor = QSpinBox(parent)

        # 將 editor 掛上方向鍵導航功能
        self.install_nav_props(editor, option, index)

        # 省略其他 editor 初始化設定

        return editor

表格委派公開介面的設定

接下來我們會把”設定多種 delegate 到欄或列” 包成公開介面,日後設定表格時只需描述各欄位的輸入規格即可套用委派。

針對行/ 列套用委派

這個 function _set_delegate() 是公開介面的內部 helper function,只負責將 delegate 套用到 table 上,因此 delegate 是外面建立完成後作為引數傳入。

直向表格中,同一欄通常代表同一種資料型別,因此使用 setItemDelegateForColumn();橫向表格則由同一列代表同一種資料型別,使用 setItemDelegateForRow()。

def _set_delegate(
    table: QTableWidget,
    index: int,
    delegate_obj: QStyledItemDelegate,
    direction: Literal["v", "h"] = "v",
) -> None:
    """將指定的 Delegate 套用到表格的指定列或欄。"""

    if direction == "v":
        table.setItemDelegateForColumn(index, delegate_obj)

    elif direction == "h":
        table.setItemDelegateForRow(index, delegate_obj)

    else:
        raise ValueError(
            f"direction 必須為 'v' 或 'h',目前收到:{direction!r}"
        )

公開介面的結構設計

set_delegate_to_table() 是整個模組對外提供的公開介面。我們只要在 controller 呼叫此 function 並傳入表格、方向與欄位規格即可完成欄位設定。

這個 function 的引數說明如下:

  • int_line:要限制整數輸入的欄/列索引

  • float_line:要限制浮點數輸入的欄/列索引

  • combo_line:以 {索引: 選項清單} 指定要使用下拉式選單地的欄/列,可以用 dict 方式為不同欄/列指定選單內容

def set_delegate_to_table(
    table: QTableWidget,
    *,
    direction: Literal["v", "h"] = "v",
    int_line: Sequence[int] | None = None,
    float_line: Sequence[int] | None = None,
    combo_line: Mapping[int, Sequence] | None = None,
) -> None:
    """將不同類型的 Delegate 設定到表格指定的列或欄。"""

    if int_line:
        int_delegate = IntSpinDelegate(parent=table)
        for index in int_line:
            _set_delegate(table, index, int_delegate, direction)

    if float_line:
        float_delegate = DoubleSpinDelegate(parent=table)
        for index in float_line:
            _set_delegate(table, index, float_delegate, direction)

    if combo_line:
        for index, choices in combo_line.items():
            combo_delegate = ComboDelegate(choices, table)
            _set_delegate(table, index, combo_delegate, direction)

在 controller 中,只要一行設定就能將整數、小數與固定選項的輸入規格掛到表格上:

set_delegate_to_table(
    table=self.ui.vertical_table,
    int_line=[2],
    float_line=[1, 3],
    combo_line={0: ["紅色", "藍色", "綠色"]},
)

補充說明:套用委派之 table 內容的讀取方式

Delegate 在編輯完成後,仍會將資料寫回 model;而 QTableWidgetItem.text() 仍可取得表格顯示的文字。

因此 day 19 的 _parse_value() 仍可將 "123"、"3.14"、"True" 等文字轉回適合的 Python 型別,不需要再額外為 Delegate 寫一套表格讀取方法。

QSpinBox / QDoubleSpinBox / QComboBox
        ↓ setModelData()
QTableWidget Model
        ↓ item.text()
TableManager._parse_value()
        ↓
list[dict]

To be continued →

在這 2 天的文章中我們知道了設定表格輸入方式的方法,優化表格的輸入體驗。但回想一下 day 20 的內容,user 可能會在貼上資料的過程中,輸入錯誤的格式。

我們仍然需要最後一道防線防止錯誤設定進入計算。因此在明後天 day 23/ 24 的文章中,我們會使用專門儲存資料的 class,並瞭解如何在寫入資料時驗證資料的正確性。


上一篇
Day 21:別問 A 答 B - 使用 QStyledItemDelegate 限縮使用者在表格中的輸入內容
下一篇
Day 23:拒絕混亂資料 - 使用 data transfer object 紀錄結構化資料與驗證正確性
系列文
用 Python 打造最順手的良率統計工具:半導體工程師的模組化開發之道 共 23 篇
圖片
  熱門推薦
圖片
{{ item.channelVendor }} | {{ item.webinarstarted }} |
{{ formatDate(item.duration) }}
直播中

尚未有邦友留言

立即登入留言