一. 前言:切回工作台,剛才打的字呢? #
拍拍君最近遇到一種 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 管理顯示與活動邊界。 再用幾個針對路由的測試,才能確認「來回切換」真的不丟上下文。
先讓兩個模式切得穩,再擴充第三個。 不要讓一個按鈕的導航,變成一場資料失蹤案。🔧