↓快轉到主要內容
  1. 教學文章/

Textual DataTable 身分契約:Row Key、排序篩選與選取還原

·7 分鐘· loading · loading · ·
Python Textual DataTable RowKey TUI Testing
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 150: 本文

featured

一. 前言:明明選的是拍拍醬,刷新後怎麼換人了?
#

表格的「第三列」不是資料的身分。 你先記住 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 或分頁來源,才不會把「第三列」誤認成「那個人」。

延伸閱讀
#

Python 學習 - 本文屬於一個選集。
§ 150: 本文

相關文章

Textual Modes 實戰:Workspace 切換、畫面生命週期與路由測試
·7 分鐘· loading · loading
Python Textual TUI Modes Screen-Routing Lifecycle Testing
Textual DirectoryTree 檔案瀏覽器:安全路徑、非同步預覽與互動測試
·8 分鐘· loading · loading
Python Textual DirectoryTree TUI Pathlib Asyncio Testing
Textual Command Palette + Actions:快捷鍵、命令搜尋與可測試操作
·8 分鐘· loading · loading
Python Textual TUI Command-Palette Actions Key-Bindings Testing
Textual RichLog 串流檢視器:有界緩衝、暫停追蹤與批次刷新
·7 分鐘· loading · loading
Python Textual RichLog TUI Streaming-Logs Bounded-Buffer
Textual CSS Layout 實戰:Grid、Dock、Responsive 與 Theme
·7 分鐘· loading · loading
Python Textual TCSS TUI Layout Responsive-Design Theme
Textual 測試實戰:Pilot、pytest-asyncio 與 Snapshot Regression
·7 分鐘· loading · loading
Python Textual TUI Pytest Pytest-Asyncio Pilot Snapshot Testing