一. 前言:明明選的是拍拍醬,刷新後怎麼換人了? #
表格的「第三列」不是資料的身分。
你先記住 cursor_row = 2,重新排序後再移回第二個索引,
畫面看起來很穩,實際上卻可能指到另一筆資料。
若接下來按的是刪除,這就不只是 UI 小瑕疵了。
拍拍君今天做一個小型唯讀 TUI,專門解決這個問題。 資料只有三筆,但操作包含排序、篩選與快照刷新。 不接資料庫、不呼叫 API,也不寫回任何檔案。 小資料比較容易看出「位置沒變,對象卻變了」的錯誤。
Textual + SQLite 已介紹利用 row key 找資料庫 id; Textual + DuckDB 則負責查詢與填表。 這次不是再做一次管理工具,而是把兩篇沒有深入的互動契約補齊。
二. 先寫下四個狀態,別用一個 selected 混到底 #
| 狀態 | 意義 | 本文的保存方式 |
|---|---|---|
| Domain ID | 來源資料的穩定身分 | Item.id,例如 p2 |
| Row Key | 這個 Widget 中對應的列 | add_row(..., key=item.id) |
| Cursor | 現在鍵盤指向的位置 | 操作前轉成 ID,操作後找新索引 |
| Committed selection | 使用者按 Enter 確認的對象 | App 的 selected_id |
Row Key 可以沿用 Domain ID,但它不會替你驗證來源資料。 我們要求同一份快照的 ID 非空且唯一,而且跨刷新不得拿舊 ID 代表新人。 名稱可能改、分數可能改,身分不能跟著變。 若是多租戶資料,應先定義包含租戶的無歧義複合 ID。
本文選擇的產品政策如下;它不是 Textual 強迫的預設行為:
- 方向鍵只移動游標,Enter 才更新已選定對象。
- 排序後,游標跟著同一個 ID 移動。
- 篩選藏起已選定資料時保留選取,狀態列明確標成隱藏。
- 游標對象不在可見集合時,回退到排序後第一列。
- 新來源快照真的刪除該 ID 時,清除選取;空表不顯示游標。
- 取消篩選不自動跳回舊位置,也不把回退列自動當成已選定。
三. 安裝與操作目標 #
本文實測環境為 Python 3.12.12、Textual 8.2.8、Rich 15.0.0, 測試使用 pytest 9.2.0 與 pytest-asyncio 1.4.1。 版本固定是為了重跑相同行為,不表示所有未來版本都會完全相同。
uv init datatable-identity
cd datatable-identity
uv add 'textual==8.2.8' 'rich==15.0.0'
uv add --dev 'pytest==9.2.0' 'pytest-asyncio==1.4.1'
建立 app.py,貼上下一節的完整程式。
按 s 切換分數排序、f 切換高分篩選、r 換一份合成快照。
同分時以 ID 排序;降冪模式會把這兩個排序鍵一起反轉。
分數保留 int,不把格式化後的字串當數字排序。
ID 顯示使用 Rich Text,同分時要取 .plain 來比較;
否則只有遇到同分,才會暴露 Text 不能直接做大小比較的錯誤。
四. 完整 App:先記住 ID,再重建畫面 #
這個範例刻意不用 RowHighlighted 寫入 selected_id。
重建表格也可能改變高亮位置;那不是使用者的確認動作。
我們只在 RowSelected 訊息處理選取,並以訊息攜帶的 key 找資料。
from dataclasses import dataclass
from rich.text import Text
from textual.app import App, ComposeResult
from textual.widgets import DataTable, Footer, Static
@dataclass(frozen=True)
class Item:
id: str
name: str
score: int
SEED = (
Item("p1", "拍拍君", 20),
Item("p2", "拍拍醬", 10),
Item("p3", "chatPTT", 30),
)
def admit(items):
result = {}
for item in items:
if not isinstance(item.id, str) or not item.id.strip():
raise ValueError("id 必須是非空字串")
if item.id in result:
raise ValueError(f"重複 id: {item.id}")
if type(item.score) is not int:
raise ValueError("score 必須是 int")
result[item.id] = item
return result
class IdentityApp(App):
CSS = "DataTable {height: 1fr;} #status {height: 3;}"
BINDINGS = [
("s", "sort_rows", "排序"),
("f", "filter_rows", "高分篩選"),
("r", "reload_rows", "更新快照"),
("q", "quit", "離開"),
]
def __init__(self):
super().__init__()
self.records = admit(SEED)
self.selected_id = None
self.high_only = False
self.descending = False
def compose(self) -> ComposeResult:
yield DataTable(id="items", cursor_type="row")
yield Static(id="status", markup=False)
yield Footer()
@property
def table(self):
return self.query_one("#items", DataTable)
def on_mount(self):
for label, key in [("ID", "id"), ("名稱", "name"), ("分數", "score")]:
self.table.add_column(label, key=key)
self.render_rows()
self.table.focus()
def cursor_id(self):
table = self.table
if not table.is_valid_coordinate(table.cursor_coordinate):
return None
return table.coordinate_to_cell_key(table.cursor_coordinate).row_key.value
def restore_cursor(self, wanted):
table = self.table
table.show_cursor = bool(table.row_count)
if table.row_count:
row = table.get_row_index(wanted) if wanted in table.rows else 0
table.move_cursor(row=row, column=0, animate=False)
def sort_table(self):
# score 儲存 int,不能先格式化成 "10 分" 再排序。
self.table.sort("score", "id", key=lambda cells: (cells[0], cells[1].plain),
reverse=self.descending)
def render_rows(self):
wanted = self.cursor_id()
self.table.clear() # 保留欄位及穩定 column key。
for item in self.records.values():
if not self.high_only or item.score >= 20:
self.table.add_row(
Text(item.id), Text(item.name), item.score, key=item.id
)
self.sort_table()
self.restore_cursor(wanted)
self.update_status()
def replace_snapshot(self, items):
incoming = admit(items) # 驗證失敗時,舊資料和畫面都不變。
self.records = incoming
if self.selected_id not in incoming:
self.selected_id = None
self.render_rows()
def update_status(self):
item = self.records.get(self.selected_id)
if item is None:
text = "尚未選定;方向鍵移動,Enter 選定"
else:
hidden = item.id not in self.table.rows
suffix = "(被篩選隱藏)" if hidden else ""
text = f"已選定 {item.id} / {item.name} / {item.score}{suffix}"
self.query_one("#status", Static).update(text)
def on_data_table_row_selected(self, event: DataTable.RowSelected):
key = event.row_key.value
if event.data_table is not self.table:
return
if key not in self.records or key not in self.table.rows:
return # 快照改變後,忽略已不存在的舊訊息。
self.selected_id = key
self.update_status()
def action_sort_rows(self):
wanted = self.cursor_id()
self.descending = not self.descending
self.sort_table()
self.restore_cursor(wanted)
def action_filter_rows(self):
self.high_only = not self.high_only
self.render_rows()
def action_reload_rows(self):
# 只換唯讀的合成快照:順序改變、名稱/分數更新。
self.replace_snapshot(tuple(
Item(x.id, x.name + "!", x.score + 1)
for x in reversed(tuple(self.records.values()))
))
if __name__ == "__main__":
IdentityApp().run()
啟動後,先按 Enter 選定低分的 p2,再按 f。
你應該看到游標移向 p1,狀態列卻仍顯示 p2 被隱藏。
按回 f 會重新顯示三列,但不會憑空替使用者改選一筆。
uv run python app.py
五. 排序不是只改一個 reverse #
關鍵順序是「取舊 key → 改排列 → 由 key 找新索引」。
cursor_row 只是當下的位置,不應跨過排序當成長期資料。
table.rows 用來判斷 key 是否仍存在;不要把它的迭代順序當畫面順序。
需要位置時才呼叫 get_row_index(),不要自己維護第二份位置快取。
例如 p2 在升冪是索引 0,降冪變成索引 2。
如果只保存數字 0,排序之後就會把游標留給 p3。
本文在測試中真的驗證這個位置變化,而不只檢查資料列數。
clear() 保留欄位,clear(columns=True) 才連欄位一起清掉。
欄位 key 也要穩定,因此程式明確指定 id、name、score。
這份表的 schema 固定,不需要每次刷新都重建欄位。
六. 刷新與篩選:來源消失和畫面隱藏是兩件事 #
records 是完整來源快照,table.rows 是目前可見集合。
這兩層分開,才有辦法回答「這筆還存在,只是被篩掉」的問題。
狀態列每次從最新 records 取名稱與分數,不長期保存舊 Item 物件。
所以 r 之後即使選定 ID 不變,顯示內容仍會更新。
admit() 在清表之前完成驗證。
若先清表再逐列檢查,遇到重複 ID 時,使用者只會看到半張表。
目前的保證是「不合法 ID / 分數在變動畫面前被拒絕」,
不是所有 render 例外都具有交易回滾能力。
這裡的 replace_snapshot() 只接受完整且具權威性的快照。
若 API 回傳的只是搜尋結果或一頁資料,未出現不等於已刪除,
不能直接套用「來源沒有 ID 就清除選取」的政策。
分頁工具應另外取得存在性結果,或清楚區分未知與確定刪除。
範例以同步方法替換少量資料,並忽略已不存在的 key 訊息。 這不等於解決所有非同步競態:若要接背景查詢,還要加請求世代, 避免慢到的舊回應覆蓋新快照;同 ID 的舊訊息也需要另外定義版本語意。 資料更新請回到 UI 所在的事件流程,不要從 thread 直接動 Widget。
七. 用 Pilot 真的按鍵,不只直接呼叫 action #
建立 test_identity.py。
四組錯誤快照加上三個互動測試,共七個 pytest case。
pilot.press() 走鍵盤與訊息流程;快照刪除則直接走資料更新入口。
pilot.pause() 用於等待前面的 UI 訊息處理,不拿固定秒數猜完成時間。
import pytest
from app import IdentityApp, Item, SEED, admit
@pytest.mark.parametrize("items", [
[SEED[0], SEED[0]], [Item("", "x", 1)],
[Item(" ", "x", 1)], [Item("x", "x", "10")],
])
def test_reject_bad_snapshot(items):
with pytest.raises(ValueError):
admit(items)
@pytest.mark.asyncio
async def test_keyboard_selection_sort_refresh():
app = IdentityApp()
async with app.run_test(size=(90, 24)) as pilot:
assert app.cursor_id() == "p2"
await pilot.press("enter")
assert app.selected_id == "p2"
await pilot.press("down")
assert app.cursor_id() == "p1"
assert app.selected_id == "p2" # 移動游標不偷偷換選定對象。
await pilot.press("up")
assert app.table.get_row_index("p2") == 0
await pilot.press("s")
assert app.cursor_id() == "p2"
assert app.table.get_row_index("p2") == 2
await pilot.press("r")
assert app.selected_id == "p2"
assert app.records["p2"].name == "拍拍醬!"
assert app.cursor_id() == "p2"
app.replace_snapshot([Item("p2", "拍拍醬", 50), Item("p1", "拍拍君", 50)])
await pilot.pause()
assert app.table.get_row_index("p2") == 0 # 同分依 ID 降冪。
assert app.cursor_id() == "p2"
@pytest.mark.asyncio
async def test_filter_missing_and_empty():
app = IdentityApp()
async with app.run_test() as pilot:
await pilot.press("enter") # 選低分 p2。
assert app.selected_id == "p2"
await pilot.press("f")
assert app.selected_id == "p2" # 篩掉不是來源刪除。
assert "p2" not in app.table.rows
assert app.cursor_id() == "p1" # 固定回退到排序後第一列。
await pilot.press("f")
assert app.selected_id == "p2"
app.replace_snapshot([SEED[0]])
await pilot.pause()
assert app.selected_id is None # 來源真的刪除,清除選定。
app.replace_snapshot([])
await pilot.pause()
await pilot.press("enter", "s", "f")
assert app.cursor_id() is None
assert app.selected_id is None
assert not app.table.show_cursor
@pytest.mark.asyncio
async def test_duplicate_does_not_damage_old_view():
app = IdentityApp()
async with app.run_test() as pilot:
await pilot.press("enter")
before = (app.records.copy(), app.cursor_id(), app.selected_id)
with pytest.raises(ValueError, match="重複"):
app.replace_snapshot([SEED[0], SEED[0]])
assert (app.records, app.cursor_id(), app.selected_id) == before
assert app.table.row_count == 3
執行:
uv run pytest -q -o asyncio_default_fixture_loop_scope=function
本文七個 case 實測通過,包含方向鍵、Enter、排序、刷新、篩選與空表。 這些是 headless 的鍵盤與狀態測試,不是所有終端模擬器的視覺保證。 游標色彩、寬字元對齊與滑鼠操作,仍應在目標 terminal 另外驗收。
八. 接回真正工具前,再確認三個邊界 #
第一,選取不是授權,也不是資料鎖。 真的要編輯或刪除時,送出穩定 ID,並重新確認來源版本與存取權限。 不能因為畫面還顯示該列,就假設伺服器上的資料沒有改變。
第二,全部清掉重建是可理解的基準,不是大型表格最佳化。 這篇只處理三筆資料,沒有量測萬列的重建成本。 資料變大後可以以 key 做差異更新,但還是得維持相同的身分與回退政策。 若要保留捲動視窗、列高或多選集合,請另外定義並測試,別偷塞進單一游標變數。
第三,顯示文字不該改變資料意義。
名稱用 Text() 當普通文字呈現,狀態列關閉 markup。
標題含方括號時,不該被當成樣式指令;數字排序也不應受顯示格式影響。
真實系統仍要限制資料量與字串長度,本文不把小型示範包裝成任意輸入處理器。
結語:保留對象,不是保留位置 #
DataTable 最難的部分,常常不是把資料加進去。 而是使用者操作幾次之後,畫面還是不是在說同一件事。
拍拍君會先固定 ID,再拆開游標與已確認選取, 把篩選隱藏、來源刪除、空表與失敗更新都寫成可重跑的行為。 這樣之後換成 SQLite、HTTP API 或分頁來源,才不會把「第三列」誤認成「那個人」。