2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

[備忘録] Python Textualを試してみた TUIフレームワークでモダンなターミナルアプリを作る

Last updated at Posted at 2025-08-05

はじめに

image.png

最近、TextualというPython製のTUI(テキストユーザーインターフェース)フレームワークを発見し、実際に触ってみました。ターミナル上でまるでGUIアプリのような美しいインターフェースが作れるということで、どの程度のものが作れるのか検証してみました。

Textualとは?

Textualは、Rich(リッチなターミナル出力ライブラリ)の開発者が手がけるモダンなTUIフレームワークです。

Textualは、色やレイアウトを自由にカスタマイズできるリッチな見た目が特徴で、CSS風のスタイリングにも対応しています。非同期処理(async/await)やマウス操作も可能で、ターミナルサイズに応じたレスポンシブな表示も実現できます。ターミナル上でありながら、直感的でモダンなUIが構築できるのが魅力です。

インストール

pip install textual

開発時に便利なdevツールも一緒にインストールすることをお勧めします:

pip install textual[dev]

基本的な使い方

まずは簡単な「Hello World」から始めてみます。

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

class HelloApp(App):
    """シンプルなHello Worldアプリ"""
    
    CSS_PATH = "hello.tcss"
    
    def compose(self) -> ComposeResult:
        """アプリのUIを構成"""
        yield Header()
        yield Static("Hello, Textual!", id="hello")
        yield Footer()

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

対応するCSSファイル(hello.tcss):

#hello {
    width: 100%;
    height: 100%;
    content-align: center middle;
    text-style: bold;
    color: cyan;
}

image.png

タスク管理アプリを作ってみる

image.png

基本を理解したところで、実際に使えるタスク管理アプリを作ってみました。

from datetime import datetime
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.widgets import Header, Footer, Input, Button, DataTable, Label
from textual.binding import Binding

class Task:
    def __init__(self, title: str):
        self.title = title
        self.created_at = datetime.now()
        self.completed = False

    def toggle_complete(self):
        self.completed = not self.completed

class TaskManagerApp(App):
    """シンプルなタスク管理アプリ"""
    
    BINDINGS = [
        Binding("q", "quit", "終了"),
        Binding("a", "add_task", "タスク追加"), 
        Binding("d", "delete_task", "タスク削除"),
        Binding("space", "toggle_task", "完了切替"),
    ]
    
    def __init__(self):
        super().__init__()
        self.tasks = []
    
    def compose(self) -> ComposeResult:
        """UIの構成"""
        yield Header()
        
        # 入力部分
        yield Label("新しいタスクを追加")
        yield Input(placeholder="タスクのタイトルを入力...", id="task-input")
        with Horizontal():
            yield Button("追加", id="add-btn", variant="primary")
            yield Button("削除", id="delete-btn", variant="error")
            yield Button("完了切替", id="toggle-btn", variant="success")
        
        # タスク一覧
        yield Label("タスク一覧")
        yield DataTable(id="task-table")
        
        yield Footer()
    
    def on_mount(self) -> None:
        """アプリ起動時の初期化"""
        table = self.query_one("#task-table", DataTable)
        table.add_columns("ID", "タイトル", "作成日時", "状態")
        table.cursor_type = "row"
        
        # サンプルタスクを追加
        self.add_sample_tasks()
    
    def add_sample_tasks(self):
        """サンプルタスクの追加"""
        sample_tasks = ["Textualの勉強", "Qiita記事を書く", "コードのリファクタリング"]
        
        for task_title in sample_tasks:
            task = Task(task_title)
            self.tasks.append(task)
        
        self.refresh_task_table()
    
    def refresh_task_table(self):
        """タスクテーブルの更新"""
        table = self.query_one("#task-table", DataTable)
        table.clear()
        
        for i, task in enumerate(self.tasks):
            status = "✅ 完了" if task.completed else "⏳ 未完了"
            created_time = task.created_at.strftime("%m/%d %H:%M")
            
            table.add_row(
                str(i + 1),
                task.title, 
                created_time,
                status,
                key=str(i)
            )
    
    def on_button_pressed(self, event: Button.Pressed) -> None:
        """ボタンクリック時の処理"""
        if event.button.id == "add-btn":
            self.action_add_task()
        elif event.button.id == "delete-btn":
            self.action_delete_task()
        elif event.button.id == "toggle-btn":
            self.action_toggle_task()
    
    def on_input_submitted(self, event: Input.Submitted) -> None:
        """入力フィールドでEnterが押された時"""
        if event.input.id == "task-input":
            self.action_add_task()
    
    def action_add_task(self) -> None:
        """タスクの追加"""
        task_input = self.query_one("#task-input", Input)
        title = task_input.value.strip()
        
        if title:
            task = Task(title)
            self.tasks.append(task)
            task_input.value = ""
            self.refresh_task_table()
            self.notify(f"タスク '{title}' を追加しました")
    
    def action_delete_task(self) -> None:
        """選択されたタスクの削除"""
        table = self.query_one("#task-table", DataTable)
        
        if table.cursor_row is not None and self.tasks:
            task_index = table.cursor_row
            if 0 <= task_index < len(self.tasks):
                deleted_task = self.tasks.pop(task_index)
                self.refresh_task_table()
                self.notify(f"タスク '{deleted_task.title}' を削除しました")
    
    def action_toggle_task(self) -> None:
        """選択されたタスクの完了状態を切り替え"""
        table = self.query_one("#task-table", DataTable)
        
        if table.cursor_row is not None and self.tasks:
            task_index = table.cursor_row
            if 0 <= task_index < len(self.tasks):
                task = self.tasks[task_index]
                task.toggle_complete()
                self.refresh_task_table()
                status = "完了" if task.completed else "未完了"
                self.notify(f"タスク '{task.title}'{status}にしました")

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

image.png

image.png

image.png

image.png

筆者はWindows 11でコマンドプロンプトで動作確認しました。

ハマったポイント

実際に開発していて躓いた点をいくつか紹介します。

DataTableが表示されない問題

最初に作った版では、Verticalコンテナを深くネストしすぎて、DataTableが表示されない問題が発生しました。

# ❌ これだと表示されない場合がある
with Vertical(classes="container"):
    with Vertical(classes="input-section"):
        # ...
    with Vertical(classes="task-list"):
        yield DataTable(id="task-table")

# ✅ シンプルな構造にすると安定
yield Label("タスク一覧")
yield DataTable(id="task-table")

対策: レイアウトはできるだけシンプルに保ち、複雑なCSSよりも基本的な構造を重視する。

CSSの罠

Textualのカスタムスタイリングは強力ですが、間違ったCSS設定でレイアウトが崩れることがありました。

# ❌ 複雑すぎるCSS
CSS = """
.container { height: 100%; }
.task-list { height: 1fr; padding: 1; }
DataTable { height: 1fr; min-height: 10; }
"""

# ✅ まずはCSSなしで動作確認
# 動いたら少しずつスタイルを追加

対策: まずはCSSなしで機能を実装し、動作確認後にスタイリングを追加する。

作ってみた感想

良かった点

Textualは、開発体験がとても優れており、レスポンシブなレイアウトが自動で適用されるほか、キーボードショートカットや通知機能などのリッチなUIも簡単に実装できます。見た目も非常に美しく、カラフルなボタンやテーブルが映え、ターミナルアプリとは思えないほど洗練されたデザインが特徴です。

まとめ

image.png

Textualは、従来のCLIツールとGUIアプリの中間的な位置づけとして、とても興味深いフレームワークでした。特に、ターミナル上で動作しながらも直感的でリッチなUIを提供できる点は非常に画期的です。

まだ発展途上な部分もありますが、Pythonでターミナルアプリを開発したい方にとっては、一度試してみる価値があるでしょう。特に、個人用ツールなどでPythonをよく使う方にとっては、LLMとの連携アプリなど、活用の幅が広がる可能性もあると感じます。

今回作成したタスク管理アプリは基本的な機能にとどまりましたが、ファイル保存やフィルタリングなどの機能も、比較的簡単に追加できそうです。

ぜひ一度Textualを触ってみて、ターミナルアプリの新たな可能性を体験してみてください!

参考リンク

2
1
3

Register as a new user and use Qiita more conveniently

  1. You get articles that match your needs
  2. You can efficiently read back useful information
  3. You can use dark theme
What you can do with signing up
2
1

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?