本篇目的:讓表格套用自訂 delegate 後仍支援鍵盤移動編輯,並且設計一 function 簡化後續表格委派的設定
在 day 21 中,我們已做出可以指定儲存格輸入整數的 delegate class (QSpinBox),但我們的委派功能仍有以下問題:
其他 delegate class 要怎麼設定 (QDoubleSpinBox、QComboBox 等)
當 QTableWidget 使用 QSpinBox 委派後,不能用鍵盤的方向鍵改變編輯格子,按了上下鍵只會改變數值。
這個使用方式與 excel 有明顯差異, 所有人都會希望能全程用鍵盤輸入 SPEC,而不是一下鍵盤輸入一下滑鼠點選

委派需一行一行或一列一列設定,若一個表格需要多格委派設定需重複呼叫 setItemDelegateFor...... 。
產品 SPEC 那麼多種,很快這些委派 code 就像洗碗槽中的大腸桿菌,在 controller 不斷滋生
今天 day 22 便會說明如何因應 editor 特性來修改 delegate class。並且設計一個鍵盤事件安裝 class,讓我們在原本的 delegate class 中多加一行即可用方向鍵切換,做出滑順的輸入。最後會設計一個公開介面 (public interface) 讓一個表格的複雜委派也只需要一行 code 即可完成。
題外話:原生的
QTableWidget按了上下鍵也只會切換關注儲存格,要再點一下或按鍵才會進入編輯模式;另外按下左右鍵是沒有反應的,真的不好用...…
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,
)
套用了 delegate 的欄/列無法用方向鍵移動的原因是套用的 editor 有本身的方向鍵事件,例如 QSpinBox 按下上下鍵會讓數字 ± step。因此我們要用 day 20 攔截鍵盤事件的手法,將方向鍵事件改成移動儲存格並開啟編輯模式。
當我們設計此 class 時,需繼承 day 21 提到的 QStyledItemDelegate,這是因為若要做出"使用者按下方向鍵後,移動目標儲存格並開啟 editor“,需要用到 QStyledItemDelegate 內的訊號。
QStyledItemDelegate是預設的 delegate class,因此只要牽涉到 delegate 有的功能都要用到它
class ArrowKeyNavigateDelegate(QStyledItemDelegate):
這個 class 會設計成其他 delegate class 繼承它後,每次建立 editor 後只需要呼叫這個公開介面即可安裝方向鍵事件。這個 function 會執行以下事件:
對 editor 執行 editor.installEventFilter(self),讓我們自訂的 eventFilter() 可以攔截 editor 的鍵盤事件
將 option.widget(所屬的 QTableWidget)記錄到 editor property
將 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():偵測使用者按下的按鍵並呼叫對應 functioneventFilter() 只會在收到 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 的繼承對象改成 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: ["紅色", "藍色", "綠色"]},
)
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]
在這 2 天的文章中我們知道了設定表格輸入方式的方法,優化表格的輸入體驗。但回想一下 day 20 的內容,user 可能會在貼上資料的過程中,輸入錯誤的格式。
我們仍然需要最後一道防線防止錯誤設定進入計算。因此在明後天 day 23/ 24 的文章中,我們會使用專門儲存資料的 class,並瞭解如何在寫入資料時驗證資料的正確性。