PythonとTextualでプロフェッショナルなターミナルUIを構築する:ウィジェット、レイアウト、リアルタイム更新

Programming tutorial - IT technology blog
Programming tutorial - IT technology blog

6ヶ月前、私はtkinterを使ってPythonの監視ダッシュボードをリリースしていた。動いてはいたが、ギリギリの状態だった。イベントループは脆弱で、クロスプラットフォームのレンダリングは安定せず、新しいウィジェットを追加するたびに2003年製のフレームワークと格闘しているような気分だった。同僚からTextualを教えてもらい、週末でそのダッシュボードを書き直した。それ以来ずっと本番環境で稼働しており、tkinterには一切触れていない。

以下は、Textualで実際のTUIアプリを構築する中で学んだことだ — READMEのサンプルコードではなく、リアクティブな状態管理、ライブデータ、マルチスクリーンナビゲーションを備えた本番グレードの実践的な内容である。

クイックスタート:5分で動作するアプリを作る

devエクストラ付きでTextualをインストールする — CSSインスペクターだけでも十分価値がある:

pip install textual[dev]

最小限で実用的なTextualアプリを示す — ライブシステムモニターの骨格だ:

from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Static
from textual.reactive import reactive

class MonitorApp(App):
    CSS = """
    Static {
        border: solid green;
        padding: 1;
        margin: 1;
    }
    """

    def compose(self) -> ComposeResult:
        yield Header()
        yield Static("CPU: 読み込み中...", id="cpu")
        yield Static("メモリ: 読み込み中...", id="mem")
        yield Footer()

if __name__ == "__main__":
    app = MonitorApp()
    app.run()

実行:

python monitor.py

ヘッダー、フッター、スタイル付きボックスを備えたフルスクリーンのターミナルアプリが起動する — 設定不要、Tkルートウィンドウ不要、mainloopのボイラープレートも不要だ。初めて実行した瞬間、tkinterとは終わりだと悟った。

詳細解説:ウィジェット、レイアウト、CSSシステム

レイアウトの仕組み

TextualはターミナルのみでCSSにインスパイアされたレイアウトエンジンを使用している。フロントエンド開発の経験があれば、ポジショニングモデルはすぐに馴染めるはずだ。レイアウトはインライン(クラスレベルのCSS文字列)または外部の.tcssファイルで定義できる。

最もよく使うレイアウトモードはhorizontalverticalの2つだ。分割パネルレイアウトは以下のようになる:

from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Static
from textual.containers import Horizontal, Vertical

class SplitApp(App):
    CSS = """
    #sidebar {
        width: 30%;
        background: $panel;
        border-right: solid $accent;
    }
    #main {
        width: 70%;
    }
    """

    def compose(self) -> ComposeResult:
        yield Header()
        with Horizontal():
            with Vertical(id="sidebar"):
                yield Static("ナビゲーション")
                yield Static("設定")
            with Vertical(id="main"):
                yield Static("メインコンテンツエリア")
        yield Footer()

if __name__ == "__main__":
    SplitApp().run()

$panel$accent変数はTextualの組み込みテーマから来ている。デフォルトのキーバインドでユーザーがダーク/ライトモードを切り替えると、自動的に適応する。

リアクティブプロパティ:核心となる概念

リアクティブプロパティはTextualで最も重要な概念であり、他のすべてのTUIライブラリとの差別化要因だ。値が変わると自動的にUIの再レンダリングをトリガーするクラスレベルの属性だ。手動のリフレッシュ呼び出しも、イベントのディスパッチも不要。新しい値を代入するだけでウィジェットが更新される。

from textual.app import App, ComposeResult
from textual.widgets import Static
from textual.reactive import reactive

class CounterWidget(Static):
    count = reactive(0)

    def render(self) -> str:
        return f"カウント: {self.count}"

class CounterApp(App):
    def compose(self) -> ComposeResult:
        yield CounterWidget(id="counter")

    def on_mount(self) -> None:
        self.set_interval(1, self.increment)

    def increment(self) -> None:
        self.query_one("#counter", CounterWidget).count += 1

if __name__ == "__main__":
    CounterApp().run()

手動リフレッシュなしでカウンターが毎秒インクリメントされる。reactiveはダーティチェックと再レンダリングのスケジューリングを内部で処理する。本番の監視ダッシュボードでこれを6ヶ月間運用し、CPUとメモリとディスクのメトリクスを毎秒読み取り続けたが、メモリリークはゼロ、持続的な負荷下でも更新の欠落はゼロだった。

応用:リアルタイム更新とDataTable

ライブデータのためのバックグラウンドワーカー

リアルタイムデータ(メトリクス、ログ、APIポーリング)には、Textualがworkデコレーターを提供しており、UIスレッドをブロックせずにコルーチンをバックグラウンドタスクとして実行できる:

import asyncio
import psutil
from textual.app import App, ComposeResult
from textual.widgets import Header, Footer, Static
from textual.reactive import reactive
from textual import work

class CPUWidget(Static):
    cpu_pct = reactive(0.0)

    def render(self) -> str:
        bar = "█" * int(self.cpu_pct / 5)
        return f"CPU [{bar:<20}] {self.cpu_pct:.1f}%"

class LiveMonitor(App):
    def compose(self) -> ComposeResult:
        yield Header()
        yield CPUWidget(id="cpu")
        yield Footer()

    def on_mount(self) -> None:
        self.poll_metrics()

    @work(exclusive=True)
    async def poll_metrics(self) -> None:
        while True:
            cpu = psutil.cpu_percent(interval=None)
            self.query_one("#cpu", CPUWidget).cpu_pct = cpu
            await asyncio.sleep(1)

if __name__ == "__main__":
    LiveMonitor().run()

構造化データのためのDataTable

DataTableは大規模な表形式データセットを効率的に処理する — 行を仮想化するため、10,000行のレンダリングコストは10行とほぼ同じだ:

from textual.app import App, ComposeResult
from textual.widgets import DataTable

ROWS = [
    ("プロセス", "PID", "CPU%", "MEM%"),
    ("nginx", 1234, "0.1", "0.8"),
    ("postgres", 5678, "2.3", "12.4"),
    ("python", 9101, "18.7", "3.2"),
]

class TableApp(App):
    def compose(self) -> ComposeResult:
        yield DataTable()

    def on_mount(self) -> None:
        table = self.query_one(DataTable)
        table.add_columns(*ROWS[0])
        table.add_rows(ROWS[1:])

if __name__ == "__main__":
    TableApp().run()

ソート可能な列、キーボードナビゲーション、カーソルハイライトが無料で手に入る。コールバック付きの行選択を追加するのはあと2行のコードだ。

Textual DevTools

CSSインスペクターはターミナルフレームワークで見つけるとは思っていなかった機能だ。アプリをこのように実行する:

textual run --dev monitor.py

次に、別のターミナルでインスペクターを開く:

textual console

ホバー時にウィジェットをハイライトし、計算済みスタイルを表示し、CSSをリアルタイムで変更できるライブCSSインスペクターが使える。ブラウザのDevToolsの領域だ — ここで見つけるとは思っていなかった。以前は完全な再起動が必要だったレイアウトの変更が、今では約10秒でテストできる。

6ヶ月の本番運用から得た実践的なヒント

20行を超えたら.tcssファイルを使う

インラインCSSは素早いプロトタイプに適している。それ以上の規模では、スタイルをapp.tcssに分離してこのように参照する:

class MyApp(App):
    CSS_PATH = "app.tcss"

エディターのシンタックスハイライトが認識してくれる上に、DevToolsコンソールはアプリを再起動せずにスタイル変更をホットリロードできる。

マルチビューアプリのためのスクリーンベースナビゲーション

マルチページのナビゲーションにはTextualのScreenシステムを使う。スタックのようにスクリーンをプッシュ/ポップできる:

from textual.screen import Screen
from textual.app import App
from textual.widgets import Button

class DetailScreen(Screen):
    def compose(self):
        yield Button("戻る", id="back")

    def on_button_pressed(self, event):
        self.app.pop_screen()

class MainApp(App):
    def compose(self):
        yield Button("詳細を開く", id="detail")

    def on_button_pressed(self, event):
        self.push_screen(DetailScreen())

これが監視ダッシュボードで設定パネルやドリルダウンビューを扱う方法だ。各ビューは独立したScreenで、キーボードショートカットによりナビゲーションが本物のアプリのように感じられる。

スレッドセーフティに注意

TextualのUIはasyncioのイベントループ上で動作する。通常の(非同期でない)スレッド — たとえばサブプロセスやソケットリスナー — からデータを取得する場合は、app.call_from_thread()を使って安全に更新をポストする:

import threading

def background_reader(app):
    # 通常スレッドで実行中
    while True:
        data = fetch_some_data()
        app.call_from_thread(app.update_display, data)

thread = threading.Thread(target=background_reader, args=(app,), daemon=True)
thread.start()

これを省略すると、負荷がかかったときにしか現れない微妙な競合状態が生じる — 私も最初にこれに当たり、1日デバッグに費やした。

キーバインドとFooterウィジェット

クラスレベルでキーバインドを宣言すると、Footerウィジェットが画面下部のヘルプバーとして自動的にレンダリングする:

class MyApp(App):
    BINDINGS = [
        ("q", "quit", "終了"),
        ("r", "refresh", "更新"),
        ("d", "toggle_dark", "ダークモード"),
    ]

    def action_refresh(self) -> None:
        # カスタム更新ロジック
        self.poll_metrics()

メソッド名はaction_<binding_name>と一致する必要がある。action_quitaction_toggle_darkは組み込み済みだ — 自分で実装する必要はない。

Textualが適切でない場合

Textualはシンプルなスクリプトに対して本当の複雑さをもたらす。ワンショットコマンドにプログレスバーが必要?代わりにRichを使おう — Textualはそもそもその上に構築されている。Textualが複雑さに見合う価値を発揮するのは、持続的なインタラクティビティが必要な場合だ:ダッシュボード、設定マネージャー、ログビューアー、データベースブラウザー。ユーザーがライブデータをナビゲート、フィルター、操作する必要がある場合、それが適切なツールだ。

私の現在の本番スタックでは、3つの環境にまたがるサービスを追跡するデプロイメントモニターにTextualを使っている。キーボードショートカット、ダークモード、マウスサポートを備え、XフォワーディングやVNCなしで完全にSSH経由で動作するため、チームはすぐに採用した。

Share: