input() 很快,後來使用者打錯版本號、忘記填通知頻道、按到 Enter 才發現要重來,就會開始想念表單。
今天拍拍君要用 Textual 做一個 release checklist wizard。
它不是網站,也不是單純 CLI。
它是一個本機終端機 TUI,適合內部工具、部署流程、資料清理任務、設定檔產生器。
如果你還沒寫過第一個 Textual App,可以先看 Python Textual 實戰。
如果你想處理長任務與進度條,請看 Textual Background Workers。
這篇專心處理另一個痛點:多步驟表單、validation、狀態切換。
一. 我們要做的工具 #
這次的工具會收集一份 release draft:
Step 1: 基本資訊
- release name
- version
- target environment
Step 2: 風險設定
- run migration?
- requires downtime?
- rollback owner
Step 3: 通知設定
- Slack channel
- summary
Step 4: Review and save
最後輸出一個 JSON:
{
"name": "checkout-api",
"version": "2026.06.18",
"environment": "staging",
"run_migration": true,
"requires_downtime": false,
"rollback_owner": "maho",
"notify_channel": "#release",
"summary": "Deploy checkout API with schema migration"
}
這種工具用 Web App 略重,用普通 CLI 又太陽春。 Textual 的位置剛好在中間:有 UI、有焦點、有按鈕,但仍然是本機命令列工具。
二. 安裝與專案結構 #
先建立專案:
uv init textual-form-wizard
cd textual-form-wizard
uv add textual
不用 uv 也可以:
python -m venv .venv
source .venv/bin/activate
pip install textual
這篇用單檔示範:
release_wizard.py
release-draft.json
正式專案可以再拆:
app/
models.py
screens/
basic.py
risk.py
notify.py
review.py
拍拍君建議先不要急著拆。 wizard 最難的不是檔案位置,而是狀態要放哪裡。 原則很簡單:
畫面可以分散,資料狀態要集中。 每一頁都直接修改同一份 draft。 這樣 Back、Next、Review、Save 才不會各自藏一份資料。
三. 先寫 Draft Model #
先不碰 UI,先定義資料。
from dataclasses import asdict, dataclass
import json
from pathlib import Path
@dataclass
class ReleaseDraft:
name: str = ""
version: str = ""
environment: str = "staging"
run_migration: bool = False
requires_downtime: bool = False
rollback_owner: str = ""
notify_channel: str = ""
summary: str = ""
def save(self, path: Path) -> None:
text = json.dumps(asdict(self), indent=2, ensure_ascii=False)
path.write_text(text + "\n", encoding="utf-8")
接著寫 validation。 這裡不用魔法,普通 function 就夠。
def validate_basic(draft: ReleaseDraft) -> list[str]:
errors: list[str] = []
if not draft.name.strip():
errors.append("Release name 不能空白")
if not draft.version.strip():
errors.append("Version 不能空白")
if draft.environment not in {"dev", "staging", "prod"}:
errors.append("Environment 必須是 dev、staging 或 prod")
return errors
def validate_risk(draft: ReleaseDraft) -> list[str]:
errors: list[str] = []
if draft.requires_downtime and not draft.rollback_owner.strip():
errors.append("需要 downtime 時,必須填 rollback owner")
if draft.environment == "prod" and draft.run_migration and not draft.rollback_owner.strip():
errors.append("Production migration 必須指定 rollback owner")
return errors
def validate_notify(draft: ReleaseDraft) -> list[str]:
errors: list[str] = []
if not draft.notify_channel.startswith("#"):
errors.append("Slack channel 要用 # 開頭,例如 #release")
if len(draft.summary.strip()) < 10:
errors.append("Summary 至少寫 10 個字元")
return errors
欄位格式錯誤,可以在該步驟擋下來。 跨步驟規則,例如 production migration 必須有 rollback owner,最好放在 model 附近,不要散在 UI handler 裡。
四. 建立 Textual App #
App 持有 draft。 Screen 負責顯示與更新欄位。
from textual.app import App, ComposeResult
from textual.containers import Horizontal, Vertical
from textual.screen import Screen
from textual.widgets import Button, Checkbox, Input, Label, Select, Static, TextArea
def bullet_list(errors: list[str]) -> str:
return "\n".join(f"- {error}" for error in errors)
class ReleaseWizard(App[None]):
CSS = """
Screen { align: center middle; }
.panel {
width: 72;
border: solid $primary;
padding: 1 2;
}
.title {
text-style: bold;
margin-bottom: 1;
}
.error {
color: $error;
min-height: 3;
margin-top: 1;
}
.actions {
height: auto;
margin-top: 1;
}
Input, Select, TextArea { margin-bottom: 1; }
"""
BINDINGS = [("q", "quit", "Quit")]
def __init__(self) -> None:
super().__init__()
self.draft = ReleaseDraft()
def on_mount(self) -> None:
self.push_screen(BasicScreen())
這裡有一個重要選擇:
draft 放在 ReleaseWizard,不是放在每個 screen。
Screen 可以 push、pop、replace。
App 會活到程式結束。
所以 App 是放共享狀態的自然位置。
五. Step 1:基本資訊 #
第一頁收集 name、version、environment。
class BasicScreen(Screen[None]):
def compose(self) -> ComposeResult:
draft = self.app.draft
with Vertical(classes="panel"):
yield Static("Step 1 / 4 - 基本資訊", classes="title")
yield Label("Release name")
yield Input(value=draft.name, id="name", placeholder="checkout-api")
yield Label("Version")
yield Input(value=draft.version, id="version", placeholder="2026.06.18")
yield Label("Environment")
yield Select(
[("dev", "dev"), ("staging", "staging"), ("prod", "prod")],
value=draft.environment,
id="environment",
)
yield Static("", id="errors", classes="error")
with Horizontal(classes="actions"):
yield Button("Next", id="next", variant="primary")
def update_draft(self) -> None:
draft = self.app.draft
draft.name = self.query_one("#name", Input).value.strip()
draft.version = self.query_one("#version", Input).value.strip()
draft.environment = str(self.query_one("#environment", Select).value)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id != "next":
return
self.update_draft()
errors = validate_basic(self.app.draft)
if errors:
self.query_one("#errors", Static).update(bullet_list(errors))
return
self.app.push_screen(RiskScreen())
這個 pattern 會一直重複:
- 從 widgets 把值寫回 draft
- validate draft
- 有錯就停在原頁
- 沒錯才切到下一頁 不要在使用者每打一個字時就跑全部 validation。 wizard 裡按 Next 再檢查,通常比較不吵。
六. Step 2:風險設定 #
第二頁用 checkbox 表示 yes/no。
不要叫使用者手打 yes、no、true、false。
class RiskScreen(Screen[None]):
def compose(self) -> ComposeResult:
draft = self.app.draft
with Vertical(classes="panel"):
yield Static("Step 2 / 4 - 風險設定", classes="title")
yield Checkbox("Run database migration", value=draft.run_migration, id="migration")
yield Checkbox("Requires downtime", value=draft.requires_downtime, id="downtime")
yield Label("Rollback owner")
yield Input(value=draft.rollback_owner, id="rollback_owner", placeholder="maho")
yield Static("", id="errors", classes="error")
with Horizontal(classes="actions"):
yield Button("Back", id="back")
yield Button("Next", id="next", variant="primary")
def update_draft(self) -> None:
draft = self.app.draft
draft.run_migration = self.query_one("#migration", Checkbox).value
draft.requires_downtime = self.query_one("#downtime", Checkbox).value
draft.rollback_owner = self.query_one("#rollback_owner", Input).value.strip()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "back":
self.update_draft()
self.app.pop_screen()
return
if event.button.id != "next":
return
self.update_draft()
errors = validate_risk(self.app.draft)
if errors:
self.query_one("#errors", Static).update(bullet_list(errors))
return
self.app.push_screen(NotifyScreen())
Back button 也先 update_draft()。
不然使用者改完欄位按上一頁,再回來時資料可能消失。
這種小 bug 很討厭,因為它不像 crash 那麼明顯,但會讓工具變得不可信。
七. Step 3:通知設定 #
第三頁收集通知頻道與摘要。
class NotifyScreen(Screen[None]):
def compose(self) -> ComposeResult:
draft = self.app.draft
with Vertical(classes="panel"):
yield Static("Step 3 / 4 - 通知設定", classes="title")
yield Label("Slack channel")
yield Input(value=draft.notify_channel, id="notify_channel", placeholder="#release")
yield Label("Summary")
yield TextArea(draft.summary, id="summary")
yield Static("", id="errors", classes="error")
with Horizontal(classes="actions"):
yield Button("Back", id="back")
yield Button("Review", id="next", variant="primary")
def update_draft(self) -> None:
draft = self.app.draft
draft.notify_channel = self.query_one("#notify_channel", Input).value.strip()
draft.summary = self.query_one("#summary", TextArea).text.strip()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "back":
self.update_draft()
self.app.pop_screen()
return
if event.button.id != "next":
return
self.update_draft()
errors = validate_notify(self.app.draft)
if errors:
self.query_one("#errors", Static).update(bullet_list(errors))
return
self.app.push_screen(ReviewScreen())
TextArea 很適合 release summary。
如果只是短字串,Input 就好。
表單 UI 的小訣竅是:能限制輸入就限制,不能限制的才用自由文字。
八. Step 4:確認與儲存 #
確認頁不再收集新欄位。 它讓使用者看一次所有資料,然後儲存。
class ReviewScreen(Screen[None]):
def compose(self) -> ComposeResult:
with Vertical(classes="panel"):
yield Static("Step 4 / 4 - 確認送出", classes="title")
yield Static(self.summary_text(), id="summary")
yield Static("", id="errors", classes="error")
with Horizontal(classes="actions"):
yield Button("Back", id="back")
yield Button("Save", id="save", variant="success")
def summary_text(self) -> str:
draft = self.app.draft
return "\n".join(
[
f"Release name: {draft.name}",
f"Version: {draft.version}",
f"Environment: {draft.environment}",
f"Run migration: {draft.run_migration}",
f"Requires downtime: {draft.requires_downtime}",
f"Rollback owner: {draft.rollback_owner or '(none)'}",
f"Notify channel: {draft.notify_channel}",
"",
"Summary:",
draft.summary,
]
)
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "back":
self.app.pop_screen()
return
if event.button.id != "save":
return
errors = (
validate_basic(self.app.draft)
+ validate_risk(self.app.draft)
+ validate_notify(self.app.draft)
)
if errors:
self.query_one("#errors", Static).update(bullet_list(errors))
return
self.app.draft.save(Path("release-draft.json"))
self.app.exit()
確認頁再跑一次全部 validation 是值得的。 因為未來你可能會加上:
- 從 JSON 載入草稿
- 用 CLI arguments 預填資料
- 根據 environment 跳過某些步驟
- 從 API 帶入預設值 最後一道 validation 可以防止髒資料存出去。
九. 啟動程式 #
把最後一段接上:
if __name__ == "__main__":
ReleaseWizard().run()
執行:
uv run python release_wizard.py
填完後檢查:
cat release-draft.json
如果你想做 autosave,可以在每次 Back 或 Next 後呼叫:
self.app.draft.save(Path("release-draft.json"))
但要記得:草稿可以不完整。 如果有 final output,最好分成兩個檔名:
release-draft.json
release-final.json
名字清楚,比註解清楚。
結語 #
多步驟表單不是把畫面切成很多頁而已。 真正重要的是狀態管理。 今天的模式可以記成四句話:
- draft model 集中管理資料
- 每個 screen 只處理自己的欄位
- Next 前先 update draft 再 validate
- Review 頁再做一次全域 validation 這樣寫出來的 Textual wizard 比較耐改。 你可以加新步驟、加草稿、接 API、補測試,而不會到處追資料藏在哪裡。 好的內部工具不一定要很大。 它只要讓人少重來一次,就已經很值得了。拍拍君認真覺得,這種小工具很有投資報酬率。