PythonのTextualでおしゃれなTUIアプリを作る

## 概要

PythonのTUIフレームワーク[Textual](https://textual.textualize.io/)を使って、ターミナル上で画像を表示するTUIアプリを作ってみましたので紹介します。

## Textualとは？

Textualとは公式では以下のように紹介されています。
> Textual is a Rapid Application Development framework for Python, built by Textualize.io.
Build sophisticated user interfaces with a simple Python API. Run your apps in the terminal or a web browser!

Rapidと謳っているように、実際にこのような計算機を簡単に作ることができます。(もちろんボタンもマウスで押せます)
![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/248216/f84c4f4a-1ad5-e22c-41ca-9f12bfcef0fc.png)

こちらのページではTextualを使用して開発されたアプリも紹介されています。

https://www.textualize.io/

## 導入

pipでインストールするだけです。

```
$ pip install textual textual-dev
```

以下で簡単なデモが起動します。
```
$ python -m textual
```

基本的にはウィジェットと呼ばれる部品を配置して、cssでレイアウトを調整する仕組みです。
ストップウォッチを作成するチュートリアルがあるので、それをやってみると一通り理解できます。

## 画像ビューワーを作ってみる

画像ビューワーアプリを作ってみました。ターミナル上で画像を見たいときはimg2sixelコマンドを使って確認していましたが、ざっと画像一覧を確認したいときが面倒だったので、ファイル一覧から選択して表示できるようなものを作りました。  
画質は落ちてしまいますが、どういう画像か確認するくらいなら問題ないレベルです。

![imagebrowser.gif](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/248216/6f2b807d-9da1-f77d-1043-f97ae463945b.gif)

コードは以下になります。

<details><summary>image_browser.py</summary>

```python
from datetime import datetime
import sys
import pathlib

from PIL import Image
import numpy as np

from rich.traceback import Traceback
from rich.text import Text
from rich_pixels import Pixels

from textual import log
from textual.app import App, ComposeResult
from textual.containers import Container, VerticalScroll, Vertical
from textual.reactive import var
from textual.widgets import DirectoryTree, Footer, Header, Static, Label

import plotext as plt


class ImageView(Static):
    path: pathlib.Path = None
    hists = None
    last_updated = None
    file_size = None

    def set_path(self, path: pathlib.Path):
        self.path = path
        self._show_image(self.path)

    def on_resize(self):
        if self.path:
            self._show_image(self.path)

    def _show_image(self, path: pathlib.Path):
        try:
            with Image.open(path) as image:
                stat = path.stat()
                self.last_updated = datetime.fromtimestamp(stat.st_mtime)
                self.file_size = self._sizeof_fmt(stat.st_size)

                self.hists = np.split(np.array(image.histogram()), len(image.getbands()))
                scale = self.size.width / image.width
                image = image.resize((round(image.width * scale), round(image.height * scale)))
                image = Pixels.from_image(image)
        except Exception as e:
            log.error(e)
            self.update(Traceback(theme="github-dark", width=None))
        else:
            self.update(image)
            self.scroll_home(animate=False)

    def _sizeof_fmt(self, num, suffix="B"):
        for unit in ("", "Ki", "Mi", "Gi", "Ti", "Pi", "Ei", "Zi"):
            if abs(num) < 1024.0:
                return f"{num:3.1f}{unit}{suffix}"
            num /= 1024.0
        return f"{num:.1f}Yi{suffix}"


class HistgramView(Static):
    def render_graph(self, hists):
        plt.clf()
        plt.title("Histogram Plot")
        plt.plotsize(self.size.width, self.size.height)

        for hist, ch in zip(hists, ['R', 'G', 'B']):
            plt.plot(hist,  label=ch, color=[(255, 0, 0), (0, 255, 0), (0, 0, 255)])
            plt.canvas_color('black')
            plt.axes_color([(255, 0, 0), (0, 255, 0), (0, 0, 255)])

        try:
            self.update(Text.from_ansi(plt.build()))
        except OSError as e:
            log.error(e)


class ImageBrowser(App):
    CSS_PATH = "image_browser.tcss"
    BINDINGS = [
        ("f", "toggle_files", "Toggle Files"),
        ("h", "toggle_hist", "Toggle Histgram"),
        ("q", "quit", "Quit"),
    ]

    show_tree = var(True)
    show_hist = var(True)

    def watch_show_tree(self, show_tree: bool) -> None:
        self.set_class(show_tree, "-show-tree")

    def watch_show_hist(self, show_hist: bool) -> None:
        self.set_class(show_hist, "-show-hist")

    def compose(self) -> ComposeResult:
        path = "./" if len(sys.argv) < 2 else sys.argv[1]
        yield Header()
        with Container(id='app-grid'):
            yield DirectoryTree(path, id="tree-view")
            with VerticalScroll(id="image-view"):
                yield Label(id="meta-view")
                yield ImageView(id="image", expand=True)
            yield HistgramView(id="hist-view")
        yield Footer()

    def on_mount(self) -> None:
        self.query_one(DirectoryTree).focus()

    def on_directory_tree_file_selected(
        self, event: DirectoryTree.FileSelected
    ) -> None:
        event.stop()
        image_view = self.query_one("#image", ImageView)
        image_view.set_path(event.path)
        self.sub_title = event.path
        self.show_hist = False

        hist_view = self.query_one("#hist-view", HistgramView)
        hist_view.render_graph(image_view.hists)

        meta_view = self.query_one("#meta-view", Label)
        meta_view.update(f'last updated: {image_view.last_updated}\nsize: {image_view.file_size}')

    def action_toggle_files(self) -> None:
        self.show_tree = not self.show_tree

    def action_toggle_hist(self) -> None:
        self.show_hist = not self.show_hist


if __name__ == "__main__":
    ImageBrowser().run()
```
</details>



<details><summary>image_browser.tcss</summary>

```css
Screen {
    background: $surface-darken-1;
    &:inline {
        height: 50vh;
    }
}

#app-grid {
    layout: grid;
    grid-size: 1;
    grid-columns: 1fr;
    grid-rows: 7fr 100fr;
}

#tree-view {
    display: none;
    scrollbar-gutter: stable;
    overflow: auto;
    width: auto;
    height: 100%;
    dock: left;
}

ImageBrowser.-show-tree #tree-view {
    display: block;
    max-width: 50%;
}

#image-view {
    overflow: auto scroll;
    min-width: 100%;
    height: 100%;
}

#image {
    width: auto;
}

#hist-view {
    display: none;
    width: 100%;
    height: 10000;
}

ImageBrowser.-show-hist #hist-view {
    display: block;
    width: 100%;
    height: 10000;
}

```
</details>

依存ライブラリは以下です。

```
$ pip install textual textual-dev rich-pixels pillow plotext numpy
```

### レイアウト部分

以下のImageBrowserのcomposeメソッドでレイアウトを定義しています。  
Containerの中で左側をファイル選択View([DirectoryTree](https://textual.textualize.io/widget_gallery/#directorytree))、右側を画像View(ImageView)としています。

```python
class ImageBrowser(App):
    ...

    def compose(self) -> ComposeResult:
        path = "./" if len(sys.argv) < 2 else sys.argv[1]
        yield Header()
        with Container(id='app-grid'):
            yield DirectoryTree(path, id="tree-view")
            with VerticalScroll(id="image-view"):
                yield Label(id="meta-view")
                yield ImageView(id="image", expand=True)
            yield HistgramView(id="hist-view")
        yield Footer()
```

### ファイル選択

ファイル一覧で画像ファイルを選択するとDirectoryTreeのコールバックが実行されます。ここで各Viewに情報を渡し更新をかけます。

```python
    def on_directory_tree_file_selected(
        self, event: DirectoryTree.FileSelected
    ) -> None:
        event.stop()
        image_view = self.query_one("#image", ImageView)
        image_view.set_path(event.path)
        self.sub_title = event.path
        self.show_hist = False

        hist_view = self.query_one("#hist-view", HistgramView)
        hist_view.render_graph(image_view.hists)

        meta_view = self.query_one("#meta-view", Label)
        meta_view.update(f'last updated: {image_view.last_updated}\nsize: {image_view.file_size}')
```

### 画像表示

画像表示は[Static](https://textual.textualize.io/widget_gallery/#static)というウィジェットを継承したImageViewを定義しました。  
画像を表示するために、[Rich Pixels](https://github.com/darrenburns/rich-pixels)を使用しています。Textualizeは[Rich](https://github.com/Textualize/rich)というターミナル出力を美しくするライブラリも公開しており、Rich PixelsもRichを元に作られています。

参考：https://qiita.com/cvusk/items/0c9fdf1fd12097f4a1b4

やっていることは単純で、Pillowで画像を読み込み、`Pixels.from_image`を呼び出しているだけです。この結果をImageView自身の`update`で渡せば画像が表示されます。  
また、ImageViewの横幅に合わせて画像をリサイズしています。レスポンシブデザインっぽく、画面幅を変えると追従してサイズが変わるようにしました。

```python
class ImageView(Static):
    ...

    def _show_image(self, path: pathlib.Path):
        try:
            with Image.open(path) as image:
                stat = path.stat()
                self.last_updated = datetime.fromtimestamp(stat.st_mtime)
                self.file_size = self._sizeof_fmt(stat.st_size)

                self.hists = np.split(np.array(image.histogram()), len(image.getbands()))
                scale = self.size.width / image.width
                image = image.resize((round(image.width * scale), round(image.height * scale)))
                image = Pixels.from_image(image)
        except Exception as e:
            log.error(e)
            self.update(Traceback(theme="github-dark", width=None))
        else:
            self.update(image)
            self.scroll_home(animate=False)
```

### おまけ：ヒストグラムを表示する

[これ](https://github.com/charles-001/dolphie)のようなグラフを作ってみたかったので、画像のヒストグラムをグラフ化してみました。  
[plotext](https://github.com/piccolomo/plotext)を使用しています。  
これも単純で、画像読み込み時に計算したヒストグラムをplotextに与えて描画しているだけになります。

```python
class HistgramView(Static):
    def render_graph(self, hists):
        plt.clf()
        plt.title("Histogram Plot")
        plt.plotsize(self.size.width, self.size.height)

        for hist, ch in zip(hists, ['R', 'G', 'B']):
            plt.plot(hist,  label=ch, color=[(255, 0, 0), (0, 255, 0), (0, 0, 255)])
            plt.canvas_color('black')
            plt.axes_color([(255, 0, 0), (0, 255, 0), (0, 0, 255)])

        try:
            self.update(Text.from_ansi(plt.build()))
        except OSError as e:
            log.error(e)
```

このように表示できます。(グラフのデータとしてはイマイチですね。。。)  
plotext側で色を調節すれば親和性を高めることもできそうです。

![image.png](https://qiita-image-store.s3.ap-northeast-1.amazonaws.com/0/248216/58ce29a4-d05c-77d4-9ce7-303b7cf85af8.png)


## まとめ

Textualで簡単にTUIアプリを作成することができました。ウィジェットが多数用意されているので、パーツの配置が本当に楽でした。  
[ロードマップ](https://textual.textualize.io/roadmap/)も公開されていて、今後機能も増えていきそうです。  
オレオレツールを作るのに役立てるのではないかと思います。