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

Textual CSS Layout 實戰:Grid、Dock、Responsive 與 Theme

·7 分鐘· loading · loading · ·
Python Textual TCSS TUI Layout Responsive-Design Theme
每日拍拍
作者
每日拍拍
科學家 X 科技宅宅
目錄
Python 學習 - 本文屬於一個選集。
§ 121: 本文

featured

一. 前言:TUI 不是把 Widget 塞滿畫面就好
#

第一次寫 Textual 時,畫面通常很快就能跑起來。

HeaderButtonDataTable 一個個 yield 出去,終端機裡立刻有一個像 App 的東西,成就感超高。

但視窗一縮小,卡片擠成一團;換到超寬螢幕,又只剩中間一小條。深色主題看起來正常,切成淺色後文字卻像隱形。

這不是 Widget 不夠多,而是 layout 還沒有被當成系統設計

拍拍君今天不再做另一個資料庫或表單 App,而是專心拆解 Textual 的版面工具:

  • 用 TCSS 把結構和外觀分開
  • frauto、百分比控制尺寸
  • 用 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-sizegrid-columnsgrid-rowsgrid-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() 的順序也會影響堆疊。

如果你的目標只是垂直排列兩個區塊,請用 Verticallayout: 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 可在 AppScreen 設定 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。

正確方式是從內容反推:

  1. 四張卡最少需要多少寬度?
  2. Sidebar 保留後,主區還剩多少?
  3. 表格在哪個寬度開始截斷關鍵欄位?
  4. 使用者縮到哪裡時,隱藏次要 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() 跑三種尺寸。你會很快發現,原本那些「偶爾看起來怪怪的」問題,其實都能被明確描述和修好。


延伸閱讀
#

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

相關文章

Textual 測試實戰:Pilot、pytest-asyncio 與 Snapshot Regression
·7 分鐘· loading · loading
Python Textual TUI Pytest Pytest-Asyncio Pilot Snapshot Testing
Textual Form Wizard 實戰:多步驟表單、Validation 與狀態切換
·5 分鐘· loading · loading
Python Textual TUI Forms Validation Developer-Tools
Textual Background Workers 實戰:長任務、Progress、取消與 Log Console
·9 分鐘· loading · loading
Python Textual TUI Background-Workers Async Developer-Tools
Textual + DuckDB 實戰:終端機資料 Dashboard 小工具
·6 分鐘· loading · loading
Python Textual DuckDB TUI Dashboard Data-Analysis
Textual + SQLite 實戰:做一個終端機資料管理小工具
·8 分鐘· loading · loading
Python Textual SQLite TUI Database Developer-Tools
Python Textual 實戰:終端機 TUI 應用開發完全攻略
·9 分鐘· loading · loading
Python Textual TUI Cli Terminal