1
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?

textual: Pythonで楽にターミナルツールを作れるライブラリ

Last updated at Posted at 2024-02-25

はじめに

作業効率化のために、環境にあまり依存せず、どこでも動くツールを作りたいときがあります。そんなとき、ターミナルで動くTerminal-based User Interface (TUI)は非常に有用です。

TUIを楽に開発するために、言語ごとにオープンソースのフレームワークが公開されています。
ここでは、PythonでCSSを使ったリッチかつインタラクティブ(マウスで操作可能)なTUIを作れるtextualというライブラリを紹介します。

リポジトリ: https://github.com/Textualize/textual

textualを使えば、下のようなTUIが作れます。
Introduction4.gif

準備

textualをインストール。

pip install textual

Widgetを表示する

Appクラスを継承したクラスを作成。そして、表示したいWidgetをcomposeメソッドに記載する。
このAppクラスのオブジェクトで、run()を実行すると、ターミナルにWidetが表示される。

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

class IntroductionApp(App):
    """Introduction to Textual."""

    def compose(self) -> ComposeResult:
        # "yield Widgetのクラス"で、ターミナルにWidgetを表示する
        yield Header("Introduction to Textual")
        yield Static("Hello, World!", id="hello")
        yield Button("Click me", id="click_me")
        yield Footer()

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

上記のコードでできたTUI

Introduction1.png

特徴1: CSSを適用できる

Widgetや画面全体に対し、CSSでスタイルを設定できる。
CSSは、Widgetのクラス名・ID・クラス名のいずれかで設定する。

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

class IntroductionApp(App):
    """Introduction to Textual."""

    CSS = """
    Screen {
        align: center top;
    }
    .hello{
        background: blue 50%;
    }
    #hello{
        background: red 50%;
    }
    Static{
        border: heavy white;
    }
    
    """

    def compose(self) -> ComposeResult:
        yield Header("Introduction to Textual")
        yield Static("Hello, World!")
        yield Static("Introduction ID=hello", id="hello")  # idを設定
        yield Static("Introduction CLASS=hello", classes="hello")  # class名を設定
        yield Button("Click me", id="click_me")
        yield Footer()

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

上記のコードでできたTUI

Introduction2.png

Appクラス内のCSSというクラス変数にCSSを記載する。
CSSは以下のように指定する。

  • Widget全体に適用する: PythonでのWidgetのクラス名 {cssの内容}
    • 例ならStatic Widgetに Static {align: center top;} で適用
  • Class名を指定してWidgetに適用する: .クラス名 {cssの内容}
    • 例ならclass名にhelloを持つWidgetに .hello {background: blue 50%;} で適用
  • ID名を指定してWidgetに適用する: #ID名 {cssの内容}
    • 例ならID名にhelloを持つWidgetに #hello {background: red 50%;} で適用

特徴2: キーバインド・ボタンのイベントをメソッドで設定できる

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

class IntroductionApp(App):
    """Introduction to Textual."""

    CSS = """
    Screen {
        align: center top;
    }
    Static{
        border: heavy white;
    }
    .hello{
        background: red 50%;
    }
    .hello2{
        background: blue 50%;
    }
    """

    BINDINGS = [("ctrl+u", "update_hello", "Update Hello"), ("c", "change_color", "Change Color")]

    def compose(self) -> ComposeResult:
        yield Header("Introduction to Textual")
        yield Static("Hello, World!")
        yield Static("Introduction", id="hello")
        yield Static("Introduction", classes="hello")
        yield Button("Click me", id="click_me")
        yield Footer()

    def action_update_hello(self) -> None:
        """ctrl+u のキーイベント。IDにhelloを持つWidgetの文字を Updated Hello! に変える"""
        self.query_one("#hello").update("Updated Hello!")

    def action_change_color(self) -> None:
        """c のキーイベント。class名がhelloのWidgetのclass名を、hello2に変える"""
        element = self.query_one(".hello")  # query_oneは、CSSセレクタのように指定する。
        element.remove_class("hello")  # remove_classでは、"-クラス名"で指定する。
        element.add_class("hello2")

    @on(Button.Pressed, "#click_me")
    def on_click_me(self) -> None:
        """ID名がclick_meのボタンがクリックされたときのイベント。画面の背景を緑にする。"""
        self.app.screen.styles.background = "green 50%"  # cssはstyles.{}の形でも設定できる。        

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

上記のコードでできたTUI

Introduction3.gif

キーバインド

AppクラスのBINDINGSというクラス変数で設定する。
BINDINGS = [("バインドしたいキー", "メソッド名", "説明文")] で設定する。
キーのイベントは、action_{BINDINGSで記載したメソッド名}というメソッドで設定する。説明文はFooterに表示される。

  • 例のctrl+uの場合、BINDINGS = [("ctrl+u", "update_hello", "Update Hello")]action_update_hello で設定している。

ボタンをクリックしたときのイベント

イベントとして呼び出すメソッドに@on(Button.Pressed, {ボタンのID名})というデコレータをつける。

  • 例のID名がclick_meのボタンの場合、on_click_me(self)というメソッドにボタンを押したときのイベントを設定し、@on(Button.Pressed, "#click_me")というデコレータをつけている。

特徴3: Markdownも表示できる

from textual import on
from textual.app import App, ComposeResult
from textual.widgets import Static, Header, Footer, Button, MarkdownViewer
from textual.containers import VerticalScroll, Horizontal, Vertical
from textual.screen import ModalScreen

# 表示するMarkdown。Textualのレポジトリから拝借した。
EXAMPLE_MARKDOWN = """\
# Markdown Viewer

This is an example of Textual's `MarkdownViewer` widget.


## Features

Markdown syntax and extensions are supported.

- Typography *emphasis*, **strong**, `inline code` etc.
- Headers
- Lists (bullet and ordered)
- Syntax highlighted code blocks
- Tables!

## Code Blocks

Code blocks are syntax highlighted, with guidelines.

```python
class ListViewExample(App):
    def compose(self) -> ComposeResult:
        yield ListView(
            ListItem(Label("One")),
            ListItem(Label("Two")),
            ListItem(Label("Three")),
        )
        yield Footer()
``` 
"""

# IntroductionAppから呼び出すモーダル画面
class MarkdownScreen(ModalScreen):
    """Simple Help screen with Markdown and a few links."""

    CSS = """
    MarkdownScreen MarkdownViewer{
        background: black;
        margin: 5 5;        
        border: heavy blue;    
        height: 100%;    
    }    
    """

    BINDINGS =[("escape", "dismiss")]

    def compose(self) -> ComposeResult:
        mv = MarkdownViewer(EXAMPLE_MARKDOWN, show_table_of_contents=True)
        mv.border_subtitle = "Click ESCAPE"
        yield mv


class IntroductionApp(App):
    """Introduction to Textual."""

    CSS = """
    Screen {
        align: center top;
    }
    MarkdownViewer{
        border: heavy red;
        border-title-style: bold;
    }
    """

    def compose(self) -> ComposeResult:
        yield Header("Introduction to Textual")
        # With containers で、レイアウトを設定する。
        with Horizontal():
            with Vertical():
                yield Button("Show markdown", id="show_markdown")
                yield Button("Show markdown in a modal screen", id="show_markdown_modal")
            with VerticalScroll():
                mv = MarkdownViewer()
                mv.border_title = "Markdown Viewer"  # 枠線上に表示されるタイトル
                yield mv
        yield Footer()

    @on(Button.Pressed, "#show_markdown")
    def on_show_markdown(self) -> None:
        """ID名がshow_markdownのボタンがクリックされたときのイベント。
        MarkdownViewerのMarkdownを更新する。"""
        self.query_one(MarkdownViewer).document.update(EXAMPLE_MARKDOWN)

    @on(Button.Pressed, "#show_markdown_modal")
    def on_show_markdown_modal(self) -> None:
        """ID名がshow_markdown_modalのボタンがクリックされたときのイベント。
        画面上にモーダルの画面を表示する。"""
        self.push_screen(MarkdownScreen())


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

上記のコードでできたTUIが冒頭のもの。

まとめ

このように、Textualを使えば楽にインタラクティブなTUIを作成できます。
Subprocessと連携すれば、インタラクティブな操作でコマンド実行ができると思います。

ここで紹介した特徴はほんの一部です。特に、Widgetはもっといろんなものが提供されています。
Widget一覧: https://textual.textualize.io/widget_gallery/ サンプルコードもあってわかりやすい。

1
9
0

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
1
9

Delete article

Deleted articles cannot be recovered.

Draft of this article would be also deleted.

Are you sure you want to delete this article?