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

## はじめに

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

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

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

textualを使えば、下のようなTUIが作れます。
![Introduction4.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1223968/fcf5face-aa9c-7424-f41b-1b6663d84df8.gif)

## 準備

textualをインストール。
```
pip install textual
```

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

```python
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1223968/2df574cd-ba3a-c92b-bfba-9d3532810887.png)



## 特徴1: CSSを適用できる

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

```python
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1223968/b52461bf-4344-4d23-9118-a486e4b958e9.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: キーバインド・ボタンのイベントをメソッドで設定できる

```python
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](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/1223968/34832246-4601-ceeb-3542-8c352c398133.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も表示できる

``` python
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/ サンプルコードもあってわかりやすい。