一. 前言:TUI 不是把 Widget 塞滿畫面就好 #
第一次寫 Textual 時,畫面通常很快就能跑起來。
Header、Button、DataTable 一個個 yield 出去,終端機裡立刻有一個像 App 的東西,成就感超高。
但視窗一縮小,卡片擠成一團;換到超寬螢幕,又只剩中間一小條。深色主題看起來正常,切成淺色後文字卻像隱形。
這不是 Widget 不夠多,而是 layout 還沒有被當成系統設計。
拍拍君今天不再做另一個資料庫或表單 App,而是專心拆解 Textual 的版面工具:
- 用 TCSS 把結構和外觀分開
- 用
fr、auto、百分比控制尺寸 - 用 Grid 排卡片、用 Dock 固定導覽區
- 用內建 breakpoint class 做 responsive layout
- 用 theme variables 避免把顏色寫死
- 用不同 terminal size 做 headless 測試
如果你還不熟 compose()、selector 或事件,可以先看 Python Textual 入門。這篇會直接從「已經能做出 App」的階段往下走。
二. 安裝與專案骨架 #
本文以目前 PyPI 上的 Textual 8.2.8 為基準,需要 Python 3.9 以上。
uv init textual-layout-demo
cd textual-layout-demo
uv add "textual==8.2.8"
uv add --dev pytest pytest-asyncio
先準備這個結構:
textual-layout-demo/
├── pyproject.toml
├── app.py
├── app.tcss
└── tests/
└── test_layout.py
app.py 管 widget tree、快捷鍵與 theme;app.tcss 只管 layout 和視覺規則。
開發時用 --dev 啟動:
uv run textual run --dev app.py
修改 TCSS 後會 live reload。調 padding、Grid 與顏色時,不必每次重開 App,這個回饋迴圈非常省時間。
三. 先理解 TCSS:像 CSS,但不是瀏覽器 CSS #
Textual CSS 通常使用 .tcss 副檔名。它借用了 selector、cascade、class 與 variable 的概念,但 layout engine 是為 terminal cell 設計的。
所以不要把網頁經驗整包搬過來。Textual 的 Grid 和 browser CSS Grid 並不相同,也沒有必要先想 DOM pixel。
Static {
color: $text;
}
#status {
border: round $primary;
}
.card {
padding: 1 2;
background: $surface;
}
id 適合唯一區塊,class 適合共享角色。常用尺寸則有固定 terminal cells、百分比、依內容決定的 auto,以及分配剩餘空間的 fr。
| 寫法 | 用途 |
|---|---|
12 |
固定 12 個 terminal cells |
50% |
父容器可用尺寸的一半 |
auto |
依內容計算 |
1fr |
分配剩餘空間的一份 |
fr 最適合主要內容區。它表達的是「固定區塊拿完後,剩下的給我」,比猜某台螢幕應該有幾格穩定得多。
四. Grid:把資訊卡排成可預測的矩陣 #
Grid 適合 dashboard、選單或表單摘要等有明確列欄關係的內容。Widget 會依 compose() 順序,從左到右、從上到下放入 cell:
#stats {
layout: grid;
grid-size: 2 2;
grid-columns: 1fr 1fr;
grid-rows: 7 7;
grid-gutter: 1 2;
}
.card {
padding: 1 2;
border: round $primary-muted;
background: $surface;
}
Textual Grid 不是 browser CSS Grid;它用 grid-size、grid-columns、grid-rows 與 grid-gutter。如果某張卡真的比較重要,還可用 column-span: 2 跨欄,但別只為填滿空間而濫用 span。
五. Dock:把固定區塊移出一般 Layout Flow #
Dock 會把 widget 從正常 layout flow 拿出來,固定在容器的 top、right、bottom 或 left。
#sidebar {
dock: left;
width: 24;
height: 100%;
padding: 1 2;
background: $panel;
border-right: tall $primary-muted;
}
#topbar {
dock: top;
height: 3;
padding: 1 2;
background: $primary;
color: $text;
}
#main {
width: 1fr;
height: 1fr;
padding: 1 2;
}
Docked widget 會占用邊緣空間,剩餘區域再交給 #main。Sidebar 不會隨主內容捲走,因此很適合 navigation、Header、Footer 和固定工具列。
多個 widget dock 到相同 edge 時可能重疊,compose() 的順序也會影響堆疊。
如果你的目標只是垂直排列兩個區塊,請用 Vertical 或 layout: vertical;不要把 Dock 當成「任何排版都能解」的萬用膠帶。
一個簡單判斷方式是:
- 應該固定在可視邊緣 → Dock
- 應該跟內容一起流動 → normal layout
- 應該形成二維矩陣 → Grid
六. 完整範例:先做 Wide Dashboard #
接著把 Topbar、Sidebar、Grid 和 Log panel 組成一個小型 release dashboard。
from textual.app import App, ComposeResult
from textual.binding import Binding
from textual.containers import Container
from textual.theme import Theme
from textual.widgets import Static
PYPY_THEME = Theme(
name="pypy-lavender",
primary="#8b7dd8",
secondary="#68b9a7",
accent="#f3b45f",
foreground="#ecebf4",
background="#181724",
surface="#242235",
panel="#302d46",
success="#7bd88f",
warning="#f3c969",
error="#ef767a",
dark=True,
)
class ReleaseDashboard(App[None]):
CSS_PATH = "app.tcss"
TITLE = "拍拍君 Release Dashboard"
BINDINGS = [
Binding("t", "toggle_theme", "Theme"),
Binding("q", "quit", "Quit"),
]
HORIZONTAL_BREAKPOINTS = [
(0, "-compact"),
(90, "-wide"),
(130, "-ultra-wide"),
]
def compose(self) -> ComposeResult:
yield Static("拍拍君 · Release Dashboard", id="topbar")
yield Static("Overview\nBuilds\nTests\nDeploys", id="sidebar")
with Container(id="main"):
yield Static("Today", id="section-title")
with Container(id="stats"):
yield Static("Build\npassing", classes="card success")
yield Static("Tests\n128", classes="card")
yield Static("Coverage\n92%", classes="card")
yield Static("Queue\n3 jobs", classes="card warning")
yield Static(
"10:42 build finished\n10:44 tests passed\n10:46 deploy queued",
id="activity",
)
yield Static("t: theme q: quit", id="footer")
def on_mount(self) -> None:
self.register_theme(PYPY_THEME)
self.theme = "pypy-lavender"
def action_toggle_theme(self) -> None:
self.theme = (
"textual-light"
if self.theme == "pypy-lavender"
else "pypy-lavender"
)
if __name__ == "__main__":
ReleaseDashboard().run()
先寫共同版面規則:
Screen {
background: $background;
color: $foreground;
}
#topbar {
dock: top;
height: 3;
padding: 1 2;
background: $primary;
color: $text;
text-style: bold;
}
#footer {
dock: bottom;
height: 1;
padding: 0 2;
color: $foreground-muted;
background: $panel;
}
#sidebar {
dock: left;
width: 24;
height: 100%;
padding: 1 2;
background: $panel;
color: $foreground-muted;
border-right: tall $primary-muted;
}
#main {
width: 1fr;
height: 1fr;
padding: 1 2;
}
#section-title {
height: 3;
text-style: bold;
color: $text-primary;
}
#stats {
layout: grid;
grid-size: 2 2;
grid-columns: 1fr 1fr;
grid-rows: 7 7;
grid-gutter: 1 2;
height: 15;
}
.card {
padding: 1 2;
border: round $primary-muted;
background: $surface;
}
.card.success {
border: round $success;
}
.card.warning {
border: round $warning;
}
#activity {
height: 1fr;
margin-top: 1;
padding: 1 2;
border: round $secondary-muted;
background: $surface;
}
這份版面在一般桌面 terminal 已經可用。下一步才是讓它在不同尺寸下改變結構。
七. Responsive:用 Textual 內建 Breakpoint Classes #
新版 Textual 可在 App 或 Screen 設定 HORIZONTAL_BREAKPOINTS。當寬度跨過門檻時,Textual 會自動把對應 class 加到目前的 Screen。
我們的三個範圍是:
- 0–89 cells:
-compact - 90–129 cells:
-wide - 130 cells 以上:
-ultra-wide
接著只要用 TCSS 覆寫:
Screen.-compact #sidebar {
display: none;
}
Screen.-compact #main {
padding: 1;
}
Screen.-compact #stats {
grid-size: 1 4;
grid-columns: 1fr;
grid-rows: 5 5 5 5;
height: 23;
grid-gutter: 1;
}
Screen.-compact .card {
padding: 0 1;
}
Screen.-wide #stats {
grid-size: 2 2;
grid-columns: 1fr 1fr;
}
Screen.-ultra-wide #stats {
grid-size: 4 1;
grid-columns: 1fr 1fr 1fr 1fr;
grid-rows: 7;
height: 7;
}
這比自己接 Resize event、算寬度、再手動改一堆 style 更乾淨。Python 宣告 breakpoint,TCSS 決定各 breakpoint 的視覺結果。
7.1 Breakpoint 不是裝置名稱 #
不要寫「手機版 80、桌面版 120」然後把數字當信仰。終端機不是瀏覽器 viewport,字型、split pane、SSH 與 IDE panel 都會改變可用 cells。
正確方式是從內容反推:
- 四張卡最少需要多少寬度?
- Sidebar 保留後,主區還剩多少?
- 表格在哪個寬度開始截斷關鍵欄位?
- 使用者縮到哪裡時,隱藏次要 navigation 反而更清楚?
讓資訊需求決定 breakpoint,而不是照抄網頁框架的數字。
7.2 垂直空間也可能是限制 #
Textual 也提供 VERTICAL_BREAKPOINTS。如果 App 常在很扁的 pane 裡執行,可以用高度 class 隱藏次要說明或改成可捲動容器。
但先從 horizontal 做好通常就夠。Breakpoints 太多會讓組合爆炸,測試成本也會跟著上升。
八. Theme 與 Design Tokens:不要把 Hex 色碼灑滿 TCSS #
前面 TCSS 使用 $background、$surface、$panel、$primary,這些都是 theme variables。
切換 App.theme 後,使用這些變數的 widget 會一起更新。這正是 design token 的價值:元件只說「這是 surface」,不用知道深色模式下究竟是哪個 hex。
8.1 Theme 的角色是語意,不是色票收藏 #
自訂 Theme 時,先整理這些語意:
background:最底層背景surface:卡片或內容表面panel:Sidebar、Footer 等分區primary/secondary:品牌與重點success/warning/error:狀態foreground:主要文字
Textual 會從 base colors 衍生 muted、lighten、darken 與可讀文字色。
例如卡片邊框用 $primary-muted,警告卡用 $warning,文字用 $foreground-muted,就比每個 selector 各放一個近似紫色可靠。
8.2 淺色與深色都要真的看過 #
深色主題下能讀,不代表淺色一定沒問題。常見錯誤是:
- 把
color: white寫死 - 使用低對比的 muted text 當正文
- 背景來自 theme,border 卻是固定暗色
- status 只靠顏色,沒有文字或符號語意
文章範例用 t 切換到 textual-light,就是要讓問題在開發時暴露,不要等使用者回報。
九. 用測試固定 Responsive Contract #
版面不是只能靠肉眼。Textual 的 run_test(size=(width, height)) 可以在 headless 模式指定 terminal size。
import pytest
from app import ReleaseDashboard
@pytest.mark.asyncio
@pytest.mark.parametrize(
("size", "expected_class"),
[
((72, 24), "-compact"),
((100, 32), "-wide"),
((140, 40), "-ultra-wide"),
],
)
async def test_breakpoint_class(size, expected_class):
app = ReleaseDashboard()
async with app.run_test(size=size) as pilot:
await pilot.pause()
assert app.screen.has_class(expected_class)
@pytest.mark.asyncio
async def test_theme_toggle():
app = ReleaseDashboard()
async with app.run_test(size=(100, 32)) as pilot:
assert app.theme == "pypy-lavender"
await pilot.press("t")
await pilot.pause()
assert app.theme == "textual-light"
執行:
uv run pytest -q
這些測試不會判斷「畫面漂不漂亮」,但會鎖住重要 contract:指定尺寸必須進入正確 layout mode,theme shortcut 也必須有效。
如果還要抓 border、對齊或文字截斷,可以接著用 SVG snapshot。詳細流程放在 Textual Pilot 與 Snapshot 測試,這裡不重複展開。
結語:把 Layout 當成行為的一部分 #
Textual 最有趣的地方,是它讓 terminal App 也能有清楚的資訊架構,而不只是漂亮邊框。
Grid 解決二維排列,Dock 解決固定邊緣,fr 解決剩餘空間,breakpoint classes 解決尺寸切換,Theme 則讓視覺語意在不同配色下保持一致。
真正穩定的 TUI 不會假設每個人都有 140 欄寬。它知道什麼資訊最重要、什麼可以折疊、什麼必須持續可操作,也能用測試把這些決策保存下來。
拍拍君建議先拿一個既有 Textual 小工具,做三件事:把 CSS 拆成 .tcss、加入三段 breakpoints、用 run_test() 跑三種尺寸。你會很快發現,原本那些「偶爾看起來怪怪的」問題,其實都能被明確描述和修好。