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

Python Textual 實戰:終端機 TUI 應用開發完全攻略

·9 分鐘· loading · loading · ·
Python Textual TUI Cli Terminal
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 45: 本文

featured

一. 前言:CLI 很強,但有些工具真的需要一點 UI
#

很多 Python 工具一開始都只是幾個命令列參數,例如 python main.py --input xxx --verboseuv 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() 用來抓指定 widget
  • Button.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_pressedon_input_changedon_input_submittedon_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 屬性
#

剛開始最常用的大概是 heightwidthmarginpaddingbordercontent-alignbackgroundcolor。如果你想快速做出有層次的畫面,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 開發者來說,你可以直接把既有的 httpxsqlite3subprocessasyncio 與各種資料處理邏輯接進 Textual 裡,做成真正可操作的工具。很多「其實很實用,但不值得做成完整網站」的工具,終於有了一個很漂亮的家。

如果你最近正好在做 server 管理工具、開發者 dashboard、互動式資料檢查器,甚至是 LLM 或 agent 的 terminal 控制台,那不妨把 Textual 放進你的工具箱。你很可能會寫上癮。


延伸閱讀
#

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

相關文章

Python tenacity 實戰:重試、退避與容錯機制完全攻略
·9 分鐘· loading · loading
Python Tenacity Retry Backoff 容錯
Python loguru 實戰:告別複雜的 logging 設定,寫出漂亮的日誌
·6 分鐘· loading · loading
Python Logging Loguru 除錯 工具
Python match/case:結構化模式匹配完全攻略
·6 分鐘· loading · loading
Python Match Pattern-Matching Python 3.10
Python enum:打造型別安全的常數管理
·5 分鐘· loading · loading
Python Enum 型別安全 設計模式
Python contextlib:掌握 Context Manager 的進階魔法
·7 分鐘· loading · loading
Python Contextlib Context Manager With 資源管理
sqlite3:Python 內建輕量資料庫完全攻略
·9 分鐘· loading · loading
Python Sqlite3 SQL 資料庫 Database