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

Textual Modes 實戰:Workspace 切換、畫面生命週期與路由測試

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

一. 前言:切回工作台,剛才打的字呢?
#

拍拍君最近遇到一種 TUI 問題:工具有工作台,也有設定頁。 在工作台打了一半備註,切去改設定,再回來,輸入框竟然清空。 不是資料庫壞掉,是程式每次換頁都重新建立 Screen。

如果不同頁面只是一次性的流程,換掉畫面很合理。 但工作台、檢查器、設定區是可以來回切的長期空間。 這時候要保留的,不只是資料,還有各自的畫面上下文。

這篇不重做 多步驟表單 Wizard, 也不再做 SQLite CRUD 的 ModalScreen。 我們專心處理「平行 Workspace」的路由與生命週期。

二. 先選對抽象:換畫面,還是換一疊畫面?
#

Textual 的幾個 API 看起來很像,但動的是不同東西:

操作 改動範圍 本篇使用情境
push_screen() 目前模式的 Stack 頂端 工作台中的暫時詳細頁
pop_screen() 目前模式的 Stack 頂端 從詳細頁回工作台
switch_screen() 替換目前 Stack 的頂端 不需要保留上一頁的流程
switch_mode() 切換目前使用的整個 Stack 工作台與檢查器互切

Mode 不是網站 URL,也不是每次切換都重建頁面的 Router。 它比較像一組獨立工作區,各自記住自己的 Screen Stack。 回到某個模式時,顯示的是那一疊最上面的畫面。 這個行為可在 官方 Screens Guide 核對。

work 模式                 inspect 模式
┌──────────────────┐     ┌──────────────────┐
│ Detail(若已開啟) │     │ InspectScreen    │
├──────────────────┤     └──────────────────┘
│ WorkScreen       │
└──────────────────┘

切到 inspect,不代表 work 的 Detail 消失。 這件事是保留上下文的優點,也是需要寫測試的地方。

三. 安裝與可執行目標
#

先建一個乾淨專案:

uv init textual-workspaces
cd textual-workspaces
uv add 'textual==8.2.8'
uv add --dev pytest pytest-asyncio

本文範例以 Python 3.12、Textual 8.2.8 實測。 這是版本快照,不代表之後安裝只能用這個版本。 升級時保留後面的行為測試,比背版本號有用。

我們做兩個模式:

  • work:一個留在畫面中的備註輸入框。
  • inspect:修改 App 共享的專案名稱。
  • 切回 work 時,備註不丟、摘要會反映新專案。
  • 重複導向同一模式不觸發多餘切換,未知路由被拒絕。

四. 完整 App:讓狀態的所有權清楚
#

把以下程式存成 workspace_app.py。 這是完整檔案,不必把各節片段自行拼裝。

from dataclasses import dataclass
from typing import cast

from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Button, Footer, Input, Label, Static


@dataclass
class ProjectState:
    project: str = "拍拍君工具箱"


class WorkspaceScreen(Screen):
    """Counters make lifecycle behavior observable in tests."""

    def __init__(self) -> None:
        super().__init__()
        self.mount_count = 0
        self.resume_count = 0
        self.suspend_count = 0

    @property
    def owner(self) -> "WorkspaceApp":
        return cast("WorkspaceApp", self.app)

    def on_mount(self) -> None:
        self.mount_count += 1
        self.refresh_summary()

    def on_screen_resume(self) -> None:
        self.resume_count += 1
        self.refresh_summary()

    def on_screen_suspend(self) -> None:
        self.suspend_count += 1

    def refresh_summary(self) -> None:
        self.query_one("#summary", Static).update(
            f"目前專案:{self.owner.state.project}"
        )


class WorkScreen(WorkspaceScreen):
    def compose(self) -> ComposeResult:
        yield Label("工作台:Ctrl+2 切到檢查器")
        yield Static("", id="summary", markup=False)
        yield Input(placeholder="切換模式後仍保留的備註", id="note")
        yield Footer()


class InspectScreen(WorkspaceScreen):
    def compose(self) -> ComposeResult:
        yield Label("檢查器:Ctrl+1 回工作台")
        yield Static("", id="summary", markup=False)
        yield Input(value=self.owner.state.project, id="project")
        yield Button("套用專案名稱", id="apply", variant="primary")
        yield Footer()

    def on_button_pressed(self, event: Button.Pressed) -> None:
        if event.button.id != "apply":
            return
        project = self.query_one("#project", Input).value.strip()
        if not project:
            self.notify("專案名稱不能空白", severity="warning")
            return
        self.owner.state.project = project
        self.refresh_summary()


class WorkspaceApp(App[None]):
    DEFAULT_MODE = "work"
    MODES = {"work": WorkScreen, "inspect": InspectScreen}
    BINDINGS = [
        ("ctrl+1", "route('work')", "工作台"),
        ("ctrl+2", "route('inspect')", "檢查器"),
        ("ctrl+q", "quit", "結束"),
    ]
    CSS = """
    WorkspaceScreen { padding: 1 2; }
    Label, Static { margin-bottom: 1; }
    Input { margin-bottom: 1; }
    """

    def __init__(self) -> None:
        super().__init__()
        self.state = ProjectState()

    async def action_route(self, mode: str) -> bool:
        if mode not in self.MODES:
            self.notify("未知工作區", severity="warning")
            return False
        if mode == self.current_mode:
            return False
        await self.switch_mode(mode)
        return True


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

啟動:

uv run python workspace_app.py

先在備註打字,再按 Ctrl+2,改名並套用,最後按 Ctrl+1。 你應該看到:專案摘要已改,工作台備註仍在。 若終端攔截 Ctrl+數字,可以修改 Binding;測試中的 key 也要一起改。

五. MODES 放 Class,不是在每次切換時 new
#

這行是架構重點:

MODES = {"work": WorkScreen, "inspect": InspectScreen}

這裡交給 Textual 建立各模式的根畫面。 首次進入 inspect 才需要初始化它;之後切回原模式時保留原 Stack。 因此 work 的 Input 還是同一個 Widget,而不是看起來相同的新物件。

MODES 也能接受 Screen 實例或已安裝 Screen 的名稱。 本文選 Class,讓每次建立 App 都有自己的模式畫面。 不要把一個 Screen 實例拿去共用給多個 App 或多次測試。

也不要在 action_route() 裡寫 switch_screen(WorkScreen())。 那是在替換目前的頂端,不是在恢復 work 模式。 App API 說明了 switch_mode() 的可等待回傳值; 範例用 await 等新畫面掛載,再把後續行為交給事件迴圈。

六. 留在 Widget 的狀態,不等於永久資料
#

本文故意把兩種狀態分開:

狀態 所有者 理由
專案名稱 App.state 不同模式都要讀寫的 Domain State
尚未提交的備註 WorkScreen 的 Input 工作台局部 UI 狀態
當前路由 Textual current_mode 不再另維護一份真假可能不一致的字串

保留 Screen 只能保留這次 App 執行期間的內容。 關掉程式後,備註仍會消失;Modes 不是儲存系統。 若備註是必須保存的工作成果,要先寫進 Model,再存到檔案或資料庫。

共享資料更新後,隱藏中的畫面不必立刻重畫。 本例在 resume 時從 App 讀回摘要,避免每個模式各藏一份 project。 但 inspect 的輸入框是編輯 buffer;不能每次 resume 都強制覆寫,否則未套用的輸入會被吃掉。

七. Mount、Resume、Suspend:各自做不同的事
#

on_mount() 用來做一次性的 Widget 初始化。 on_screen_resume() 適合重新顯示時更新摘要。 on_screen_suspend() 適合在畫面失去活動狀態時降低無用工作。 事件定義可查 ScreenResume / ScreenSuspend API。

不要把「每次進入頁面」誤寫成「重新 Mount」。 也不要把 Suspend 當作 Unmount 或自動取消 Worker 的保證。 Modes 保留畫面,背景工作是否繼續,需要你自己訂政策。

若要加輪詢,可以沿用這個模式:在 mount 建立 Timer; 在 suspend 暫停,resume 恢復,unmount 停止。 Timer API 提供 pause()、resume()、stop()。 不要在每次 resume 都建立新的 interval,否則切五次可能就有五條輪詢。

本文先用三個計數器驗證生命週期,沒有加入網路請求。 這樣測試失敗時,不會把路由問題誤判為 API 超時。

八. 路由入口:白名單與冪等性
#

action_route() 是本工具唯一的模式切換入口。 未知名字先拒絕,同模式直接返回 False,成功切換才返回 True。 這不是 Textual 強制規則,而是我們自己選的產品契約。

當你之後加入側邊選單、Command Palette 或外部啟動參數, 都呼叫同一個入口,不要各自發明不同切換規則。

有未儲存內容時,也要在這裡定義策略: 允許切換並保留 buffer?先要求套用?還是讓使用者明確放棄? 本例選保留工作台 buffer,因此正常換模式沒有阻擋。 但「切換專案」和「切換模式」是不同操作;前者可能必須先保存資料。

九. 用 Pilot 驗證:測的是上下文,不只是畫面標題
#

建立 test_workspace_app.py。 以下測試覆蓋真正容易壞的邊界,而不只是確認沒有 Exception。 測試工具的背景可參考 Textual Testing Guide。

import pytest
from textual.screen import Screen
from textual.widgets import Input, Static

from workspace_app import InspectScreen, WorkScreen, WorkspaceApp


@pytest.mark.asyncio
async def test_mode_preserves_widget_and_lifecycle():
    app = WorkspaceApp()
    async with app.run_test(size=(90, 26)) as pilot:
        work = app.screen
        assert app.current_mode == "work"
        assert isinstance(work, WorkScreen)
        note = work.query_one("#note", Input)
        note.value = "拍拍君還沒寫完"
        resumed = work.resume_count
        await pilot.press("ctrl+2")
        await pilot.pause()
        assert isinstance(app.screen, InspectScreen)
        assert work.suspend_count >= 1
        await pilot.press("ctrl+1")
        await pilot.pause()
        assert app.screen is work
        assert work.query_one("#note", Input) is note
        assert note.value == "拍拍君還沒寫完"
        assert work.mount_count == 1
        assert work.resume_count > resumed


@pytest.mark.asyncio
async def test_shared_state_refreshes_without_losing_note():
    app = WorkspaceApp()
    async with app.run_test(size=(90, 26)) as pilot:
        work = app.screen
        work.query_one("#note", Input).value = "本地 buffer"
        await app.action_route("inspect")
        await pilot.pause()
        app.screen.query_one("#project", Input).value = "拍拍醬報表"
        await pilot.click("#apply")
        assert app.state.project == "拍拍醬報表"
        await app.action_route("work")
        await pilot.pause()
        assert str(work.query_one("#summary", Static).content) == (
            "目前專案:拍拍醬報表"
        )
        assert work.query_one("#note", Input).value == "本地 buffer"


@pytest.mark.asyncio
async def test_router_rejects_unknown_and_same_mode():
    app = WorkspaceApp()
    async with app.run_test() as pilot:
        work = app.screen
        resumed = work.resume_count
        assert not await app.action_route("missing")
        assert not await app.action_route("work")
        await pilot.pause()
        assert app.screen is work
        assert work.resume_count == resumed


@pytest.mark.asyncio
async def test_each_mode_keeps_its_own_stack():
    app = WorkspaceApp()
    async with app.run_test() as pilot:
        work = app.screen
        detail = Screen()
        await app.push_screen(detail)
        await pilot.pause()
        assert app.screen is detail
        await app.action_route("inspect")
        await pilot.pause()
        assert len(app.screen_stack) == 1
        await app.action_route("work")
        await pilot.pause()
        assert app.screen is detail
        assert len(app.screen_stack) == 2
        await app.pop_screen()
        await pilot.pause()
        assert app.screen is work

執行 uv run pytest -q,應得到四個通過的測試。 這些斷言同時檢查物件 identity、局部文字、共享資料與 Stack 邊界。 如果某次重構讓回到工作台變成建立新 Screen,第一個測試就會抓到。

十. 上線前的幾個取捨
#

長期保留的 Workspace 會占用記憶體,不該把每筆資料都變成永久 Mode。 固定數量的工作區適合 Modes;無限的文件清單需要自己的關閉與清理策略。

也別用「隱藏畫面」當權限控制。 需要限制的資料操作仍要在服務層驗證;路由白名單只限制導航名稱。

最後,畫面恢復不代表外部資料仍是最新。 若跨模式會更新資料庫,resume 時應讀最新 Model 或已完成的查詢結果。 不要為了刷新資訊,順便重建使用者正在編輯的整棵 Widget Tree。

結語
#

拍拍君覺得,好的工作台應該記得你剛才正在做什麼。 Textual Modes 幫你留住每個 Workspace 的 Stack; App Model 保管共享資料;resume/suspend 管理顯示與活動邊界。 再用幾個針對路由的測試,才能確認「來回切換」真的不丟上下文。

先讓兩個模式切得穩,再擴充第三個。 不要讓一個按鈕的導航,變成一場資料失蹤案。🔧

延伸閱讀
#

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

相關文章

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 Form Wizard 實戰:多步驟表單、Validation 與狀態切換
·5 分鐘· loading · loading
Python Textual TUI Forms Validation Developer-Tools
Textual + SQLite 實戰:做一個終端機資料管理小工具
·8 分鐘· loading · loading
Python Textual SQLite TUI Database Developer-Tools
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