kakakakakku blog

Weekly Tech Blog: Keep on Learning!

Textual がスゴイ!Python と CSS でストップウォッチアプリを作ってみた

Textual は Python を使って TUI (Text User Interfaces) アプリを構築できるフレームワークで,ドキュメントには "電卓""Color Picker" を実装したサンプルが載っている.アプリのデザインには CSS を使う.Python x CSS という技術スタックで,入門しやすくなっているのもメリットだと思う.

textual.textualize.io

Textual に入門するために Tutorial に載っている「ストップウォッチアプリ」を実装してみた.それぞれのストップウォッチで計測できて,ストップウォッチの追加/削除もできる.また dark モードと light モードを切り替えることもできる.簡単に実装できるし,何よりも楽しかった❗️Textual スゴイ✌️

また以下のギャラリー (Projects using Textual) を見ると,Textual を使って実装されたアプリが紹介されている.Kaskade(Kafka クライアント)や Tic-Tac-Toe(三目並べ)などなど.アプリの実装アイデアさえあったら何でも実装できそう.

www.textualize.io

Textual は The Changelog Podcast のエピソードを聴いて知った👂

changelog.com

Tutorial を試す

さっそく Tutorial を試す.Textual の機能や実装例を詳細に調べるためにはドキュメントを読む必要があるけど,Tutorial を試すと全体的な流れは理解できる.

textual.textualize.io

以下に最終的に実装できるストップウォッチアプリの stopwatch.pystopwatch.css と載せておく.実際には Step ごとにコードを実装していくため,Tutorial も合わせて確認してもらえると良いかと!

stopwatch.py

from time import monotonic

from textual.app import App, ComposeResult
from textual.containers import Container
from textual.reactive import reactive
from textual.widgets import Button, Header, Footer, Static


class TimeDisplay(Static):
    """A widget to display elapsed time."""

    start_time = reactive(monotonic)
    time = reactive(0.0)
    total = reactive(0.0)

    def on_mount(self) -> None:
        """Event handler called when widget is added to the app."""
        self.update_timer = self.set_interval(1 / 60, self.update_time, pause=True)

    def update_time(self) -> None:
        """Method to update time to current."""
        self.time = self.total + (monotonic() - self.start_time)

    def watch_time(self, time: float) -> None:
        """Called when the time attribute changes."""
        minutes, seconds = divmod(time, 60)
        hours, minutes = divmod(minutes, 60)
        self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")

    def start(self) -> None:
        """Method to start (or resume) time updating."""
        self.start_time = monotonic()
        self.update_timer.resume()

    def stop(self):
        """Method to stop the time display updating."""
        self.update_timer.pause()
        self.total += monotonic() - self.start_time
        self.time = self.total

    def reset(self):
        """Method to reset the time display to zero."""
        self.total = 0
        self.time = 0


class Stopwatch(Static):
    """A stopwatch widget."""

    def on_button_pressed(self, event: Button.Pressed) -> None:
        """Event handler called when a button is pressed."""
        button_id = event.button.id
        time_display = self.query_one(TimeDisplay)
        if button_id == "start":
            time_display.start()
            self.add_class("started")
        elif button_id == "stop":
            time_display.stop()
            self.remove_class("started")
        elif button_id == "reset":
            time_display.reset()

    def compose(self) -> ComposeResult:
        """Create child widgets of a stopwatch."""
        yield Button("Start", id="start", variant="success")
        yield Button("Stop", id="stop", variant="error")
        yield Button("Reset", id="reset")
        yield TimeDisplay()


class StopwatchApp(App):
    """A Textual app to manage stopwatches."""

    CSS_PATH = "stopwatch.css"

    BINDINGS = [
        ("d", "toggle_dark", "Toggle dark mode"),
        ("a", "add_stopwatch", "Add"),
        ("r", "remove_stopwatch", "Remove"),
    ]

    def compose(self) -> ComposeResult:
        """Called to add widgets to the app."""
        yield Header()
        yield Footer()
        yield Container(Stopwatch(), Stopwatch(), Stopwatch(), id="timers")

    def action_add_stopwatch(self) -> None:
        """An action to add a timer."""
        new_stopwatch = Stopwatch()
        self.query_one("#timers").mount(new_stopwatch)
        new_stopwatch.scroll_visible()

    def action_remove_stopwatch(self) -> None:
        """Called to remove a timer."""
        timers = self.query("Stopwatch")
        if timers:
            timers.last().remove()

    def action_toggle_dark(self) -> None:
        """An action to toggle dark mode."""
        self.dark = not self.dark


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

stopwatch.css

Stopwatch {
    layout: horizontal;
    background: $boost;
    height: 5;
    margin: 1;
    min-width: 50;
    padding: 1;
}

TimeDisplay {
    content-align: center middle;
    text-opacity: 60%;
    height: 3;
}

Button {
    width: 16;
}

#start {
    dock: left;
}

#stop {
    dock: left;
    display: none;
}

#reset {
    dock: right;
}

.started {
    text-style: bold;
    background: $success;
    color: $text;
}

.started TimeDisplay {
    text-opacity: 100%;
}

.started #start {
    display: none
}

.started #stop {
    display: block
}

.started #reset {
    visibility: hidden
}

Step.1: stopwatch01.py

Step.1 では,アプリの「雛形」を作る.Textual では App class を継承してアプリを実装するため,コードでは StopwatchApp class を実装して,最後に run() を実行している.また,Textual よく使うコンポーネントは「ウィジェット」としてプリセットされていて,今回は Header(画面上部のバー) と Footer(画面下部のバー)を組み込んでいる.ウェジェットには他にも CheckboxDataTable などもある.

またフッターに Toggle dark mode と表示されている.Textual では BINDINGS = [("d", "toggle_dark", "Toggle dark mode")] のように BINDINGS を設定すると,簡単に「キーバインド」を実装できる.今回は d キーを押すと「dark モード / light モード」を切り替えられる.

Step.2: stopwatch02.py

Step.2 では,ストップウォッチアプリに必要なコンポーネントとして「ボタン」「タイマー」を実装する.今回は Start / Stop / Reset ボタンと時間を表示する Stopwatch class を実装して,UI を構成する compose 関数の中で yield Container(Stopwatch(), Stopwatch(), Stopwatch()) のように3個並べるように組み込んでいる.実際にキャプチャを見ると「縦に」並んでいる.

Step.3: stopwatch03.py

Step.3 では,アプリのデザインを設定する.最初は驚いたけど,Textual では Python と CSS を組み合わせられるようになっている.StopwatchApp class に CSS_PATH = "stopwatch03.css" のように CSS ファイルを指定できる.CSS を適用すると,キャプチャのように「横に」並んだ.また計測をする前には不要な Stop ボタンも display: none; で非表示になっている.

Step.4: stopwatch04.py

Step.4 では,Start ボタンを押したときに Stop ボタンを表示できるようにする.「ボタンを押した」というイベントは on_button_pressed() 関数で検知できて,また add_class() 関数や remove_class() 関数を使って CSS のクラスも制御できる.結果的にストップウォッチアプリとして使えるように Start / Stop / Reset ボタンの可視制御を行えるようになった.

class Stopwatch(Static):
    """A stopwatch widget."""

    def on_button_pressed(self, event: Button.Pressed) -> None:
        """Event handler called when a button is pressed."""
        if event.button.id == "start":
            self.add_class("started")
        elif event.button.id == "stop":
            self.remove_class("started")

Step.5: stopwatch05.py

Step.5 では,Textual の「リアクティブ機能」を使う.reactive class を使って値を初期化すると,値の変化を監視できるようになる.監視する場合は watch_xxx という関数を実装する必要があって,今回は watch_time() 関数を実装している.そして,on_mount 関数でアプリ開始時の初期化実装ができる.最終的にストップウォッチで経過した時間を表示できるようになる.Step.5 時点では,アプリの起動と同時に計測が開始される.

class TimeDisplay(Static):
    """A widget to display elapsed time."""

    start_time = reactive(monotonic)
    time = reactive(0.0)

    def on_mount(self) -> None:
        """Event handler called when widget is added to the app."""
        self.set_interval(1 / 60, self.update_time)

    def update_time(self) -> None:
        """Method to update the time to the current time."""
        self.time = monotonic() - self.start_time

    def watch_time(self, time: float) -> None:
        """Called when the time attribute changes."""
        minutes, seconds = divmod(time, 60)
        hours, minutes = divmod(minutes, 60)
        self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")

Step.6: stopwatch06.py

Step.6 では,Start ボタンを押したら計測を開始できるように実装する.TimeDisplay class に start() / stop() / reset() 関数をそれぞれ追加して,ボタンを押したら関数を実行するように連携する.すると,ストップウォッチごとに(現状だと3個)計測できるようになる.スゴイ❗️もうストップウォッチアプリを実装できちゃった (>ω<)

class TimeDisplay(Static):
    """A widget to display elapsed time."""

    start_time = reactive(monotonic)
    time = reactive(0.0)
    total = reactive(0.0)

    def on_mount(self) -> None:
        """Event handler called when widget is added to the app."""
        self.update_timer = self.set_interval(1 / 60, self.update_time, pause=True)

    def update_time(self) -> None:
        """Method to update time to current."""
        self.time = self.total + (monotonic() - self.start_time)

    def watch_time(self, time: float) -> None:
        """Called when the time attribute changes."""
        minutes, seconds = divmod(time, 60)
        hours, minutes = divmod(minutes, 60)
        self.update(f"{hours:02,.0f}:{minutes:02.0f}:{seconds:05.2f}")

    def start(self) -> None:
        """Method to start (or resume) time updating."""
        self.start_time = monotonic()
        self.update_timer.resume()

    def stop(self):
        """Method to stop the time display updating."""
        self.update_timer.pause()
        self.total += monotonic() - self.start_time
        self.time = self.total

    def reset(self):
        """Method to reset the time display to zero."""
        self.total = 0
        self.time = 0

完成すると: stopwatch.py

最終的に完成すると,a キーと r キーを押すとストップウォッチ(デフォルト3個)を増やしたり減らしたりできる.Container class を使って組み込んだ UI を「動的に変更できること」は覚えておくと良さそう.

class StopwatchApp(App):
    """A Textual app to manage stopwatches."""

    CSS_PATH = "stopwatch.css"

    BINDINGS = [
        ("d", "toggle_dark", "Toggle dark mode"),
        ("a", "add_stopwatch", "Add"),
        ("r", "remove_stopwatch", "Remove"),
    ]

    def compose(self) -> ComposeResult:
        """Called to add widgets to the app."""
        yield Header()
        yield Footer()
        yield Container(Stopwatch(), Stopwatch(), Stopwatch(), id="timers")

    def action_add_stopwatch(self) -> None:
        """An action to add a timer."""
        new_stopwatch = Stopwatch()
        self.query_one("#timers").mount(new_stopwatch)
        new_stopwatch.scroll_visible()

    def action_remove_stopwatch(self) -> None:
        """Called to remove a timer."""
        timers = self.query("Stopwatch")
        if timers:
            timers.last().remove()

まとめ

Python と CSS を組み合わせて TUI アプリを実装できるフレームワーク Textual を試した.Tutorial に沿って進めると,簡単に「ストップウォッチアプリ」を実装できて楽しかった❗️個人的に「Tomato 2」を使ってポモドーロを回しているけど,今度 Textual を使ってポモドーロアプリを自作してみるのも良さそう.Textual 本当にスゴイから引き続き使っていくぞー✌️

kakakakakku.hatenablog.com