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

Textual Command Palette + Actions:快捷鍵、命令搜尋與可測試操作

·8 分鐘· loading · loading · ·
Python Textual TUI Command-Palette Actions Key-Bindings Testing
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 130: 本文

featured

一. 前言:功能做得出來,使用者卻找不到
#

一個 TUI 小工具剛誕生時,可能只有三個操作:重新整理、切換畫面、離開。

半年後,它長出二十個功能。快捷鍵開始像祕密咒語:r 是 refresh、R 是 retry、Ctrl+R 又是 reset。Footer 放不下,README 也沒人一直開著。

Textual 內建的 Command Palette 正好處理這個問題:使用者按 Ctrl+P,輸入幾個字,就能用 fuzzy search 找到操作。不過如果快捷鍵、palette command 和按鈕各自寫一份邏輯,很快又會出現三套行為。

這篇拍拍君要做的是一個「操作層」:

  1. 用 action_* 定義可以執行的操作;
  2. 用 BINDINGS 把高頻操作綁到鍵盤與 Footer;
  3. 用 SystemCommand 暴露少量固定命令;
  4. 用自訂 Provider 搜尋動態資料;
  5. 用 check_action() 表達操作目前能不能執行;
  6. 用 Pilot 測試真正的快捷鍵與 palette 流程。

如果你還不熟 App、widget 與 reactive state,先看 Textual 入門;本文不重做基本 TUI,而是把命令的可發現性與測試性補完整。

二. 建立專案:先準備一個能長大的命令層
#

使用 uv 建立最小專案:

uv init textual-command-center
cd textual-command-center
uv add textual
uv add --dev pytest pytest-asyncio

專案先保持簡單:

textual-command-center/
├── pyproject.toml
├── app.py
└── tests/
    └── test_app.py

我們會做一個專案切換器。畫面顯示目前選取的專案,使用者可以:

  • 按 r 重新整理;
  • 按 d 切換詳細資訊;
  • 按 x 清除選取;
  • 按 Ctrl+P 搜尋並開啟專案。

重點不是這個 demo 有多華麗,而是四種入口最後都落到同一組操作。

三. Actions:把「做什麼」變成穩定介面
#

Textual 會把 action_refresh() 對應到 action 字串 refresh;有參數時,也可以寫成 open_project('atlas')。Action 字串看起來像 Python call,但 Textual 不會使用 eval()。參數必須是字串、數字、list、dict 等 Python literal,不能寫成 open_project(project_slug) 來引用執行期變數。

若需要動態值,不要硬拼 action 字串;在 Python callback 裡直接呼叫方法,或把值包進 partial()。這也會是自訂 command provider 的做法。

Action 也有 namespace。app.refresh 會找 App,screen.close 會找目前的 Screen,focused.submit 則交給聚焦中的 widget。大型 TUI 裡,這能避免所有操作都堆進 App。

四. Bindings:讓高頻操作一按就到
#

簡單 binding 可以用三個字串的 tuple;需要 ID、顯示控制或優先權時,改用 Binding。完整範例稍後會把 r、d、x 與隱藏於 Footer 的 q 放進同一份 BINDINGS。

這裡有四個設計原則:

  1. key 是效率入口,不是操作本身;
  2. action 指向穩定的語意名稱;
  3. description 會成為 Footer 的可發現提示;
  4. id 可作為 keymap 覆寫的穩定識別,不必把使用者偏好綁死在 action 名稱上。

show=False 只是不在 Footer 顯示,快捷鍵仍然有效。若某個 app-level 熱鍵不能被聚焦中的 widget 蓋掉,可設 priority=True,但應保守使用,否則 widget 會失去合理的鍵盤控制權。

Textual 尋找 binding 時,會先從目前聚焦的 widget 往 DOM 上層搜尋,最後才到 App。因此 enter、方向鍵等常用按鍵最好留給 widget;全域操作則用較明確的組合。

五. 狀態式操作:不能執行時,Footer 也要說實話
#

「沒有選取專案時仍顯示清除」會讓使用者以為程式壞掉。Textual 的 check_action() 可以讓 action 隨狀態停用或隱藏,完整範例會在 selected_slug is None 時停用 clear_selection。

回傳值的語意要分清楚:

  • True:可以執行;
  • False:不執行,並從 Footer 隱藏;
  • None:不執行,但保留 disabled 提示。

若判斷依賴 reactive state,可用 reactive("atlas", bindings=True) 自動刷新 binding 狀態,不必手動呼叫 refresh_bindings()。不能執行的命令,應該在觸發前就清楚呈現,而不是按下去才跳出模糊錯誤。

六. SystemCommand:固定命令的最短路徑
#

Textual 預設已經提供 command palette。按 Ctrl+P 叫出後,可以輸入關鍵字、用上下鍵選擇,再按 Enter 執行。

少量 app-wide 固定命令,最簡單的方式是覆寫 get_system_commands(),再 yield SystemCommand(title, help, callback)。完整範例會加入 refresh 與 details 兩個命令。

一定要 yield from super(),否則會不小心丟掉 Textual 的預設 system commands。

SystemCommand 很適合固定且數量少的命令。若命令來自檔案、資料庫、API 或目前畫面資料,就應該改用 Provider,避免每次開 palette 都建立一大串固定物件。

七. 自訂 Provider:搜尋動態專案
#

Provider 有四個可覆寫的 async method:

  • startup():palette 開啟時準備資源;
  • search(query):依輸入產生 Hit;
  • discover():輸入空白時提供少量推薦;
  • shutdown():palette 關閉時釋放資源。

完整範例會用 dataclass 建立三個專案,並用 partial() 把每筆動態 slug 傳給同一個 action。

matcher.match() 回傳 0 到 1 的分數;0 代表不符合,正值才建立 Hit。matcher.highlight() 會把 fuzzy match 的字元標出來,不必自己重做 Rich markup。

discover() 應該快而少,只放最重要的兩三個入口。大量資料留給 search(),昂貴 I/O 則放到 worker 或在 startup() 預先準備。Provider 裡的錯誤不一定會讓 App 退出,所以開發時要看 Textual console/log,別把「沒有搜尋結果」誤判成資料真的不存在。

八. 完整 App:快捷鍵與 Palette 共用同一套操作
#

把所有零件組起來:

from collections.abc import Iterable
from dataclasses import dataclass
from functools import partial

from textual.app import App, ComposeResult, SystemCommand
from textual.binding import Binding
from textual.command import DiscoveryHit, Hit, Hits, Provider
from textual.reactive import reactive
from textual.screen import Screen
from textual.widgets import Footer, Header, Static


@dataclass(frozen=True)
class Project:
    slug: str
    title: str
    summary: str


PROJECTS = (
    Project("atlas", "Atlas API", "Public API gateway"),
    Project("beacon", "Beacon Worker", "Background jobs"),
    Project("canvas", "Canvas UI", "Internal dashboard"),
)


class ProjectCommands(Provider):
    async def startup(self) -> None:
        self.projects = PROJECTS

    async def discover(self) -> Hits:
        app = self.app
        assert isinstance(app, ProjectApp)
        for project in self.projects[:2]:
            yield DiscoveryHit(
                f"Open {project.title}",
                partial(app.action_open_project, project.slug),
                help=project.summary,
            )

    async def search(self, query: str) -> Hits:
        matcher = self.matcher(query)
        app = self.app
        assert isinstance(app, ProjectApp)
        for project in self.projects:
            candidate = f"Open {project.title} {project.slug}"
            score = matcher.match(candidate)
            if score > 0:
                yield Hit(
                    score,
                    matcher.highlight(candidate),
                    partial(app.action_open_project, project.slug),
                    help=project.summary,
                )


class ProjectApp(App[None]):
    COMMANDS = App.COMMANDS | {ProjectCommands}
    BINDINGS = [
        Binding("r", "refresh", "Refresh", id="refresh"),
        Binding("d", "toggle_details", "Details", id="details"),
        Binding("x", "clear_selection", "Clear", id="clear"),
        Binding("q", "quit", "Quit", show=False),
    ]

    CSS = """
    Screen { align: center middle; }
    #panel { width: 64; height: auto; padding: 1 2; border: round $accent; }
    """

    selected_slug: reactive[str | None] = reactive("atlas", bindings=True)
    show_details: reactive[bool] = reactive(True)

    def compose(self) -> ComposeResult:
        yield Header()
        yield Static(id="panel")
        yield Footer()

    def on_mount(self) -> None:
        self.render_project()

    def get_system_commands(
        self, screen: Screen
    ) -> Iterable[SystemCommand]:
        yield from super().get_system_commands(screen)
        yield SystemCommand("Refresh projects", "Reload status", self.action_refresh)
        yield SystemCommand(
            "Toggle details", "Show or hide details", self.action_toggle_details
        )

    def action_open_project(self, slug: str) -> None:
        self.selected_slug = slug
        self.render_project()

    def action_refresh(self) -> None:
        self.notify("Project status refreshed")

    def action_toggle_details(self) -> None:
        self.show_details = not self.show_details
        self.render_project()

    def action_clear_selection(self) -> None:
        self.selected_slug = None
        self.render_project()

    def check_action(
        self, action: str, parameters: tuple[object, ...]
    ) -> bool | None:
        if action == "clear_selection" and self.selected_slug is None:
            return None
        return True

    def render_project(self) -> None:
        panel = self.query_one("#panel", Static)
        project = next(
            (item for item in PROJECTS if item.slug == self.selected_slug), None
        )
        if project is None:
            panel.update("No project selected — press Ctrl+P to search")
        elif self.show_details:
            panel.update(f"[b]{project.title}[/b]\n{project.summary}\nslug={project.slug}")
        else:
            panel.update(f"[b]{project.title}[/b]")


if __name__ == "__main__":
    ProjectApp().run()

現在 d、Footer 與 Toggle details system command 都呼叫 action_toggle_details();動態專案搜尋則呼叫 action_open_project(slug)。入口可以增加,但核心行為只有一份。

執行:

uv run python app.py

先按 Ctrl+P,輸入 atlas 或 worker。Fuzzy search 不要求完整前綴,所以使用者不必精確記住命令全名。

九. 測試:不要只測方法,要走真正的鍵盤入口
#

直接呼叫 app.action_open_project('atlas') 只能證明方法可執行,不能證明 binding、palette、搜尋結果和 Enter selection 串得起來。

用 run_test() 和 Pilot 寫兩條互動測試:

import pytest

from textual.command import CommandPalette

from app import ProjectApp


@pytest.mark.asyncio
async def test_details_binding() -> None:
    app = ProjectApp()
    async with app.run_test() as pilot:
        assert app.show_details is True
        await pilot.press("d")
        await pilot.pause()
        assert app.show_details is False


@pytest.mark.asyncio
async def test_command_palette_opens_atlas() -> None:
    app = ProjectApp()
    async with app.run_test() as pilot:
        app.action_clear_selection()

        await pilot.press("ctrl+p")
        await pilot.pause()
        assert CommandPalette.is_open(app)

        await pilot.press("a", "t", "l", "a", "s")
        await pilot.pause()
        await pilot.press("enter")
        await pilot.pause()

        assert app.selected_slug == "atlas"
        assert not CommandPalette.is_open(app)

執行測試:

uv run pytest -q

pilot.pause() 不是隨便 sleep;它會等待待處理的 message 跑完。這比猜 sleep(0.2) 穩定,也更能反映 Textual message loop 的真實狀態。

測試命令系統時,至少固定這四類 contract:

  • 快捷鍵是否找到正確 action;
  • disabled 狀態是否真的阻止操作;
  • 搜尋字是否產生預期 hit;
  • 選取 hit 後,palette 是否關閉且狀態正確更新。

視覺樣式仍可交給 snapshot test;操作語意則應該用精確 assertion,失敗時比較容易知道是搜尋、dispatch,還是 render 出問題。

十. 常見踩雷與設計檢查
#

1. Palette callback 又寫一份商業邏輯
#

Palette、按鈕與快捷鍵應共用 action 或 service method。否則權限檢查、錯誤處理與 telemetry 很容易分岔。

2. COMMANDS = {ProjectCommands} 蓋掉預設 provider
#

使用 App.COMMANDS | {ProjectCommands} 保留原本的 system command provider。若只指定新 set,內建命令會消失。

3. 把慢 I/O 塞進每次 search() #

search() 幾乎每次按鍵都會呼叫。資料載入應快取、移到 startup(),或交給 worker;不然 palette 會成為整個 App 最卡的地方。

4. discover() 一次列出所有東西
#

空白 palette 的目的在幫助發現重要命令,不是把資料庫 dump 出來。顯示少量高價值入口,其餘等使用者輸入再搜尋。

5. 使用舊文章裡的快捷鍵
#

目前 Textual 預設用 Ctrl+P 開啟 palette;早期版本曾用 Ctrl+\。若要改鍵,設定 COMMAND_PALETTE_BINDING,並在說明與測試中固定同一個 contract。

6. Binding 衝突只在某個 Widget 發生
#

先確認目前 focus 在哪裡,再看 binding 搜尋鏈。必要時用 textual keys 檢查終端實際傳進來的 key 名稱;不是所有作業系統與 terminal 都會傳遞每一種組合鍵。

結語:把命令當成產品介面
#

好用的 TUI 不只是畫面漂亮,而是使用者能找到操作、快速重複操作,也能預測現在什麼可以做。

拍拍君的建議是:

  1. 先用 action 定義語意;
  2. 高頻操作放 binding 與 Footer;
  3. 少量固定命令用 SystemCommand;
  4. 動態資料搜尋用 Provider;
  5. 狀態限制放進 check_action();
  6. 最後用 Pilot 從真實鍵盤入口驗證。

如此一來,功能數量增加時,不必把快捷鍵表越印越長。Ctrl+P 會成為可靠的入口,而每個入口仍共用同一套可維護、可測試的行為。

延伸閱讀
#

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

相關文章

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
Textual Form Wizard 實戰:多步驟表單、Validation 與狀態切換
·5 分鐘· loading · loading
Python Textual TUI Forms Validation Developer-Tools
Textual Background Workers 實戰:長任務、Progress、取消與 Log Console
·9 分鐘· loading · loading
Python Textual TUI Background-Workers Async Developer-Tools
Textual + DuckDB 實戰:終端機資料 Dashboard 小工具
·6 分鐘· loading · loading
Python Textual DuckDB TUI Dashboard Data-Analysis
Textual + SQLite 實戰:做一個終端機資料管理小工具
·8 分鐘· loading · loading
Python Textual SQLite TUI Database Developer-Tools