一. 前言:CLI 很強,但有些工具真的需要一點 UI #
很多 Python 工具一開始都只是幾個命令列參數,例如 python main.py --input xxx --verbose 或 uv run tool sync。這種純 CLI 介面很有效率,也很適合自動化。
但只要工具開始需要同時顯示多個資訊區塊、讓使用者在輸入框與按鈕之間切換、即時更新表格與 log,甚至想做出比 print() 更像應用程式的體驗,單靠 argparse 或 click 就會有點吃力。
這時候,TUI,也就是 text user interface,就很適合上場。而在 Python 世界裡,近年最亮眼的 TUI 框架之一,就是 Textual。
Textual 是 Rich 作者打造的 framework,所以你可以把它理解成一個很現代的終端機 UI 系統:有元件、有事件驅動、有自己的 CSS 樣式語法,也支援 async 與背景工作。它不只是把終端機畫面弄漂亮,而是讓你真的能做一個互動式 App。
如果你已經看過拍拍君前面的 argparse 教學 或 rich 教學,那今天這篇可以看成是下一步,也就是從「把 CLI 做好」進化到「把終端機 App 做漂亮」。
今天拍拍君就帶你從零開始,做出一個有互動、有排版、能更新資料的 Textual 小工具。
二. 安裝:先把第一個 App 跑起來 #
先安裝套件:
pip install textual
# 或用 uv(推薦)
uv add textual
如果你想在開發時有比較舒服的回饋,也可以直接用 Textual 的開發模式:
textual run --dev app.py
這個模式很適合調 UI,因為它會自動重新載入,改程式時回饋很快。
2.1 最小可執行範例 #
from textual.app import App
class HelloApp(App):
pass
if __name__ == "__main__":
HelloApp().run()
跑起來之後,你會看到一個空白的全螢幕終端機 App。雖然它幾乎什麼都沒做,但重點是 Textual 已經接管整個 terminal 畫面,鍵盤與滑鼠事件都會進入 Textual 的事件系統。你不再只是 print() 幾行字,而是在建立一個真正的 UI 應用。離開方式通常是 Ctrl+Q。
2.2 加上 Header 與 Footer #
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Static
class HelloApp(App):
def compose(self) -> ComposeResult:
yield Header()
yield Static("拍拍君的第一個 Textual App")
yield Footer()
if __name__ == "__main__":
HelloApp().run()
這裡第一次看到 Textual 很重要的概念,也就是 compose()。這就是你宣告畫面元件樹的地方。可以先把它理解成 React 的 component tree、GUI toolkit 的 widget hierarchy,或 HTML 的 DOM 結構。換句話說,你的 App 不是一段一段印出來,而是由 widget 組合出來。
三. compose() 與 widgets:先學會把畫面拼起來
#
Textual 的基本思路其實很直觀:先用 compose() 定義元件,再用事件處理函式回應互動,最後用 CSS 或 widget 屬性控制樣式。
我們先做一個簡單的小面板。
3.1 一個有輸入框與按鈕的範例 #
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.widgets import Header, Footer, Input, Button, Static
class GreeterApp(App):
def compose(self) -> ComposeResult:
yield Header()
yield Input(placeholder="輸入名字", id="name")
with Horizontal():
yield Button("打招呼", id="hello")
yield Button("清空", id="clear")
yield Static("等待操作中...", id="result")
yield Footer()
def on_button_pressed(self, event: Button.Pressed) -> None:
result = self.query_one("#result", Static)
name_input = self.query_one("#name", Input)
if event.button.id == "hello":
name = name_input.value.strip() or "拍拍醬"
result.update(f"哈囉,{name},今天也來寫工具吧。")
elif event.button.id == "clear":
name_input.value = ""
result.update("已清空")
if __name__ == "__main__":
GreeterApp().run()
這段已經很像真正的互動程式了。它展示了幾個 Textual 常見套路:
query_one()用來抓指定 widgetButton.Pressed事件會告訴你哪顆按鈕被按下update()可以更新Static內容- widget 用
id之後,後面查找與 styling 都很方便
3.2 為什麼這比手寫 ANSI escape 好很多 #
以前做 terminal UI,很多人會直接手刻座標、手刷畫面、自己管鍵盤事件,最後很快就變成 escape code 大戰。Textual 幫你處理掉最麻煩的部分,例如畫面刷新、widget lifecycle、focus 管理、事件路由,以及 layout 與 style。你可以把心力放在工具本身,而不是在那邊和 terminal escape code 打架。
四. 事件與 reactive state:讓 UI 真的活起來 #
光有 widget 還不夠,互動介面真正有趣的地方在於「狀態會推動畫面更新」。Textual 提供了很好用的 reactive 機制。
4.1 用 reactive 做一個計數器
#
from textual.app import App, ComposeResult
from textual.reactive import reactive
from textual.widgets import Button, Static
class CounterApp(App):
count = reactive(0)
def compose(self) -> ComposeResult:
yield Static("目前計數:0", id="counter")
yield Button("+1", id="plus")
yield Button("重設", id="reset")
def watch_count(self, value: int) -> None:
self.query_one("#counter", Static).update(f"目前計數:{value}")
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "plus":
self.count += 1
elif event.button.id == "reset":
self.count = 0
if __name__ == "__main__":
CounterApp().run()
這段最值得記住的是:count = reactive(0) 把狀態宣告成 reactive attribute,當 count 改變時,watch_count() 會自動被呼叫。你不用手動設計一堆「如果狀態變了就更新畫面」的 plumbing。
這種模式很適合用在進度更新、篩選條件切換、狀態列訊息,或即時計數與監控面板。對於會被多個地方改動的狀態,reactive 幾乎總是比散落在每個事件處理器裡的 update() 更乾淨。
4.2 Event handler 的命名規則很順手 #
Textual 的事件處理命名通常是這種形式:on_button_pressed、on_input_changed、on_input_submitted、on_mount。如果你本來就喜歡事件驅動架構,Textual 會讓你上手得很快。
一句話總結這段:UI 可以被狀態推動時,就盡量不要把更新邏輯散落在每個事件處理器裡。
五. 版面配置與 CSS:Textual 真正迷人的地方 #
很多人第一次看到 Textual 覺得驚訝,通常是因為它居然有點像在寫網頁。是的,Textual 有自己的 CSS 系統。這件事超重要,因為它意味著佈局不需要全部寫在 Python 邏輯裡,視覺樣式可以和互動邏輯分開,調整 spacing、border、height、width 也更方便。
5.1 用內嵌 CSS 做一個乾淨版面 #
from textual.app import App, ComposeResult
from textual.containers import Vertical
from textual.widgets import Header, Footer, Static
class LayoutApp(App):
CSS = """
Screen {
background: #0f172a;
}
#hero {
height: 5;
content-align: center middle;
border: round #7dd3fc;
margin: 1 2;
}
.card {
height: 7;
border: round #94a3b8;
margin: 1 2;
padding: 1 2;
}
"""
def compose(self) -> ComposeResult:
yield Header()
with Vertical():
yield Static("Textual Dashboard", id="hero")
yield Static("CPU:12%\nMemory:48%", classes="card")
yield Static("Queue:7 jobs\nWorkers:3 alive", classes="card")
yield Footer()
if __name__ == "__main__":
LayoutApp().run()
這時候你已經可以感受到 Textual 的魅力了。Python 負責結構與行為,CSS 負責畫面風格,看起來很像在寫 terminal 版的前端。
5.2 幾個超常用的 CSS 屬性 #
剛開始最常用的大概是 height、width、margin、padding、border、content-align、background 與 color。如果你想快速做出有層次的畫面,border + padding + margin 幾乎就是最先要熟的三件事。
5.3 真的建議把 CSS 分出去 #
小範例直接寫在 CSS = """...""" 裡面沒問題,但只要 App 稍微大一點,拍拍君會建議你用 .tcss 檔:
class MyApp(App):
CSS_PATH = "app.tcss"
好處很多,像是 Python 檔比較乾淨、調樣式時不容易碰到業務邏輯,跟隊友協作也比較清楚。如果你以前寫過前端,這種分工你應該會很有感。
六. Async 與背景工作:不要讓 UI 卡住 #
很多 TUI App 都會做打 API、查資料庫、跑 subprocess、掃目錄、讀 log 這些事。如果你直接在事件處理器裡做耗時工作,畫面就會卡住。Textual 的建議做法是把這些事情丟到 worker。
6.1 run_worker() 的基本用法
#
import httpx
from textual.app import App, ComposeResult
from textual.widgets import Input, Static
class WeatherApp(App):
def compose(self) -> ComposeResult:
yield Input(placeholder="輸入城市", id="city")
yield Static("尚未查詢", id="result")
async def on_input_changed(self, message: Input.Changed) -> None:
self.run_worker(self.update_weather(message.value), exclusive=True)
async def update_weather(self, city: str) -> None:
result = self.query_one("#result", Static)
if not city:
result.update("尚未查詢")
return
async with httpx.AsyncClient(timeout=5.0) as client:
response = await client.get(f"https://wttr.in/{city}?format=3")
result.update(response.text)
if __name__ == "__main__":
WeatherApp().run()
這裡的重點是 self.run_worker(...)。它的價值在於背景任務可以並行處理,UI 主迴圈不會被卡住,而 exclusive=True 又能避免使用者狂打字時排一堆過期請求。這種模式特別適合搜尋框即時查詢、自動補全、API 狀態板,或 log viewer。
6.2 Textual 為什麼特別適合做內部工具 #
很多內部工具其實不需要完整 web app,因為它們只有自己或團隊使用,不想管部署與登入,甚至想直接在 SSH 或 server 上跑。這種情況下,Textual 很有吸引力,因為它同時兼顧 CLI 的輕量、UI 的可讀性,以及 Python 生態的整合能力。
拍拍君會把它放在這個定位上看:不是拿來取代 Web,而是拿來取代那些原本只會愈長愈醜的 shell 工具。
七. 實戰範例:做一個服務健康檢查小面板 #
前面零碎功能都看過了,現在把幾個概念組起來。我們來做一個很實用的小工具:顯示多個服務 endpoint、按一下重新檢查、用表格展示狀態與延遲,並且在背景查詢,不阻塞 UI。
import time
import httpx
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.widgets import Header, Footer, Button, DataTable
class HealthDashboard(App):
BINDINGS = [
Binding("r", "refresh", "Refresh"),
Binding("q", "quit", "Quit"),
]
SERVICES = [
("auth", "https://example.com/auth/health"),
("billing", "https://example.com/billing/health"),
("search", "https://example.com/search/health"),
]
def compose(self) -> ComposeResult:
yield Header()
yield Button("重新整理", id="refresh")
yield DataTable(id="table")
yield Footer()
def on_mount(self) -> None:
table = self.query_one("#table", DataTable)
table.add_columns("服務", "狀態", "延遲(ms)")
self.action_refresh()
def on_button_pressed(self, event: Button.Pressed) -> None:
if event.button.id == "refresh":
self.action_refresh()
def action_refresh(self) -> None:
self.run_worker(self.refresh_services(), exclusive=True)
async def refresh_services(self) -> None:
table = self.query_one("#table", DataTable)
table.clear(columns=False)
async with httpx.AsyncClient(timeout=3.0) as client:
for name, url in self.SERVICES:
start = time.perf_counter()
try:
response = await client.get(url)
latency = int((time.perf_counter() - start) * 1000)
status = "ok" if response.is_success else f"http {response.status_code}"
except Exception:
latency = -1
status = "error"
table.add_row(name, status, "-" if latency < 0 else str(latency))
if __name__ == "__main__":
HealthDashboard().run()
這個範例已經很接近團隊內部工具的原型了。你可以很自然地往下擴充,例如加狀態顏色、加最後更新時間、加篩選條件、加自動輪詢,甚至加錯誤細節面板。
如果你以前會用 Flask 或 FastAPI 做這種小 dashboard,現在多了一個新選項。某些情況下,直接用 Textual 會更快。
7.1 什麼類型的產品特別適合 Textual #
拍拍君覺得 deployment / CI 狀態面板、資料清洗或同步工具、log 與 queue 監控器、資料表瀏覽器、管理員用的互動式內部工具,甚至 LLM prompt playground 的 terminal 版本都很搭。尤其你本來就常在 terminal 裡工作時,Textual 的切入感很低。
八. 新手最常踩的坑 #
Textual 很好玩,但也有幾個常見坑值得先提醒。
- 把耗時工作直接塞進事件處理器:如果
on_button_pressed()裡直接等網路回應,整個 UI 會變卡。更穩的做法通常是用run_worker(),搭配取消或exclusive=True。 - 一開始就把 widget tree 寫超大:拍拍君建議先切成小 widget,每個 widget 管自己的責任,主 App 只負責組裝與路由。
- Python 邏輯和 CSS 糾纏在一起:UI 變複雜後,樣式最好拆到
.tcss,不然後面很難維護。 - 忘了設計鍵盤操作流:TUI 的核心價值之一就是鍵盤效率,所以快捷鍵、focus 順序與 Footer 提示都很重要。
另外,Textual 和 Rich、argparse、Typer 也不是互相取代,而是可以分工。拍拍君最常見的組合是:CLI 子命令負責入口,Rich 負責漂亮輸出,真的需要互動式操作時再啟動 Textual App。
九. 結語:Textual 很適合那種「再寫 Web 太重,純 CLI 又太乾」的工具 #
如果今天你只是寫一次性的腳本,當然沒必要上 Textual。但只要你的工具會反覆使用、需要互動操作、畫面資訊比一行輸出複雜,而且主要使用者是工程師或內部團隊,那 Textual 真的值得學。
它最大的魅力不是「在 terminal 裡做 GUI」這件事本身,而是它提供了一個非常舒服的中間地帶:比純 CLI 更友善,比桌面 GUI 更輕,也比小型 web app 更容易起步。
尤其對 Python 開發者來說,你可以直接把既有的 httpx、sqlite3、subprocess、asyncio 與各種資料處理邏輯接進 Textual 裡,做成真正可操作的工具。很多「其實很實用,但不值得做成完整網站」的工具,終於有了一個很漂亮的家。
如果你最近正好在做 server 管理工具、開發者 dashboard、互動式資料檢查器,甚至是 LLM 或 agent 的 terminal 控制台,那不妨把 Textual 放進你的工具箱。你很可能會寫上癮。