Textual は Python を使って TUI (Text User Interfaces) アプリを構築できるフレームワークで,ドキュメントには "電卓" や "Color Picker" を実装したサンプルが載っている.アプリのデザインには CSS を使う.Python x CSS という技術スタックで,入門しやすくなっているのもメリットだと思う.
Textual に入門するために Tutorial に載っている「ストップウォッチアプリ」を実装してみた.それぞれのストップウォッチで計測できて,ストップウォッチの追加/削除もできる.また dark モードと light モードを切り替えることもできる.簡単に実装できるし,何よりも楽しかった❗️Textual スゴイ✌️
また以下のギャラリー (Projects using Textual) を見ると,Textual を使って実装されたアプリが紹介されている.Kaskade(Kafka クライアント)や Tic-Tac-Toe(三目並べ)などなど.アプリの実装アイデアさえあったら何でも実装できそう.
Textual は The Changelog Podcast のエピソードを聴いて知った👂
Tutorial を試す
さっそく Tutorial を試す.Textual の機能や実装例を詳細に調べるためにはドキュメントを読む必要があるけど,Tutorial を試すと全体的な流れは理解できる.
以下に最終的に実装できるストップウォッチアプリの stopwatch.py
と stopwatch.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
(画面下部のバー)を組み込んでいる.ウェジェットには他にも Checkbox
や DataTable
などもある.
またフッターに 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 本当にスゴイから引き続き使っていくぞー✌️