mcpField
T
textual

textual

github.com/johnlarkin1開発21256 回インストール13 週間前に更新
リポジトリ

textual について

textualは、ターミナルアプリを開発するためのPythonフレームワークです。App、Screen、Widgetの構成に、リアクティブな状態管理、メッセージイベント、CSSレイアウト、非同期処理、テスト機能を備えています。

主な機能

  1. 01App、Screen、Widgetの構成
  2. 02リアクティブな状態とメッセージイベント
  3. 03カスタムWidget
  4. 04CSSレイアウトとスタイル設定
  5. 05非同期ワーカーとアプリのテスト

活用シーン

  1. 01インタラクティブなターミナルアプリの構築
  2. 02再利用可能なカスタムWidgetの作成
  3. 03ターミナルアプリの操作をテスト

インストール

コマンドをコピーしてターミナルで実行します。

$npx skills add https://github.com/johnlarkin1/claude-code-extensions --skill textual

インストール後、Claude Code で/textualを入力して読み込まれたか確認します。

同梱ファイル

SKILL.md と一緒にインストールされる参考資料やスクリプト。

references/
css.md
examples.md
widgets.md

詳細

Textual TUI フレームワーク

Web に着想を得た Textual のアーキテクチャ(App → Screen → Widget)で、ターミナルアプリケーションを構築します。

クイックスタート

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

class MyApp(App):
    CSS_PATH = "styles.tcss"
    BINDINGS = [("q", "quit", "Quit"), ("d", "toggle_dark", "Dark Mode")]

    def compose(self) -> ComposeResult:
        yield Header()
        yield Static("Hello, World!")
        yield Footer()

    def action_toggle_dark(self) -> None:
        self.theme = "textual-dark" if self.theme == "textual-light" else "textual-light"

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

コアコンセプト

ウィジェットのライフサイクル
  1. __init__() → compose() → on_mount() → on_show()/on_hide() → on_unmount()
リアクティビティ
from textual.reactive import reactive, var

class MyWidget(Widget):
    count = reactive(0)  # Triggers refresh on change
    internal = var("")   # No automatic refresh

    def watch_count(self, new_value: int) -> None:
        """Called when count changes."""
        self.styles.background = "green" if new_value > 0 else "red"

    def validate_count(self, value: int) -> int:
        """Constrain values."""
        return max(0, min(100, value))
イベントとメッセージ
from textual import on
from textual.message import Message

class MyWidget(Widget):
    class Selected(Message):
        def __init__(self, value: str) -> None:
            self.value = value
            super().__init__()

    def on_click(self) -> None:
        self.post_message(self.Selected("item"))

class MyApp(App):
    # Handler naming: on_<widget>_<message>
    def on_button_pressed(self, event: Button.Pressed) -> None:
        self.log(f"Button {event.button.id} pressed")

    @on(Button.Pressed, "#submit")  # CSS selector filtering
    def handle_submit(self) -> None:
        pass
データフロー
  • 属性を下方向へ:親が子のプロパティを直接設定します
  • メッセージを上方向へ:子が post_message() を介して親にメッセージを送信します

画面

from textual.screen import Screen

class WelcomeScreen(Screen):
    BINDINGS = [("escape", "app.pop_screen", "Back")]

    def compose(self) -> ComposeResult:
        yield Static("Welcome!")
        yield Button("Continue", id="continue")

    def on_button_pressed(self, event: Button.Pressed) -> None:
        if event.button.id == "continue":
            self.app.push_screen("main")

class MyApp(App):
    SCREENS = {"welcome": WelcomeScreen, "main": MainScreen}

    def on_mount(self) -> None:
        self.push_screen("welcome")

カスタムウィジェット

シンプルなウィジェット
class Greeting(Widget):
    def render(self) -> RenderResult:
        return "Hello, [bold]World[/bold]!"
複合ウィジェット
class LabeledButton(Widget):
    DEFAULT_CSS = """
    LabeledButton { layout: horizontal; height: auto; }
    LabeledButton Label { width: 1fr; }
    """

    def __init__(self, label: str, button_text: str) -> None:
        self.label_text = label
        self.button_text = button_text
        super().__init__()

    def compose(self) -> ComposeResult:
        yield Label(self.label_text)
        yield Button(self.button_text)
フォーカス可能なウィジェット
class Counter(Widget):
    can_focus = True
    BINDINGS = [("up", "increment", "+"), ("down", "decrement", "-")]
    count = reactive(0)

    def action_increment(self) -> None:
        self.count += 1

レイアウトパターン

コンテナー
from textual.containers import Horizontal, Vertical, Grid, VerticalScroll

def compose(self) -> ComposeResult:
    with Vertical():
        with Horizontal():
            yield Button("Left")
            yield Button("Right")
        with VerticalScroll():
            for i in range(100):
                yield Label(f"Item {i}")
グリッド CSS
Grid {
    layout: grid;
    grid-size: 3 2;           /* columns rows */
    grid-columns: 1fr 2fr 1fr;
    grid-gutter: 1 2;
}
#wide { column-span: 2; }
ドッキング
#header { dock: top; height: 3; }
#sidebar { dock: left; width: 25; }
#footer { dock: bottom; height: 1; }

ワーカー(非同期)

from textual import work

class MyApp(App):
    @work(exclusive=True)  # Cancels previous
    async def fetch_data(self, url: str) -> None:
        async with httpx.AsyncClient() as client:
            response = await client.get(url)
            self.query_one("#result").update(response.text)

    @work(thread=True)  # For sync APIs
    def sync_operation(self) -> None:
        result = blocking_call()
        self.call_from_thread(self.update_ui, result)

テスト

async def test_app():
    app = MyApp()
    async with app.run_test() as pilot:
        await pilot.press("enter")
        await pilot.click("#button")
        await pilot.pause()  # Wait for messages
        assert app.query_one("#status").render() == "Done"

よく使う操作

# Query widgets
self.query_one("#id")
self.query_one(Button)
self.query(".class")

# CSS classes
widget.add_class("active")
widget.toggle_class("visible")
widget.set_class(condition, "active")

# Visibility
widget.display = True/False

# Mount/remove
self.mount(NewWidget())
widget.remove()

# Timers
self.set_interval(1.0, callback)
self.set_timer(5.0, callback)

# Exit
self.exit(return_code=0)

参考資料

コメント