Timer Tutorial
This will walk through taking the timer app tutorial from Textual's Documentation and turn it into a command.
Below is the code from the tutorial but with some slight changes.
import asyncio
from time import monotonic
from textual.app import ComposeResult
from textual.containers import HorizontalGroup, VerticalScroll
from textual.reactive import reactive
from textual.screen import Screen
from textual.widgets import Button, Digits, Footer, Header
from textual_shell.command import Command, CommandNode
from textual_shell.job import Job
class TimeDisplay(Digits):
"""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(HorizontalGroup):
"""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 TimerScreen(Screen):
""""""
BINDINGS = [
("d", "toggle_dark", "Toggle dark mode"),
("a", "add_stopwatch", "Add"),
("r", "remove_stopwatch", "Remove"),
("ctrl+z", "background", "Background the timer"),
("ctrl+d", "kill_timer", "Kill the timer")
]
DEFAULT_CSS = """
Stopwatch {
background: $boost;
height: 5;
margin: 1;
min-width: 50;
padding: 1;
Button {
width: 16;
}
}
TimeDisplay {
text-align: center;
color: $foreground-muted;
height: 3;
}
#start {
dock: left;
}
#stop {
dock: left;
display: none;
}
#reset {
dock: right;
}
.started {
background: $success-muted;
color: $text;
}
.started TimeDisplay {
color: $foreground;
}
.started #start {
display: none
}
.started #stop {
display: block
}
.started #reset {
visibility: hidden
}
"""
def __init__(
self,
task: asyncio.Task,
*args, **kwargs
) -> None:
super().__init__(*args, **kwargs)
self.timer_task = task
def compose(self) -> ComposeResult:
"""Called to add widgets to the app."""
yield Header()
yield Footer()
yield VerticalScroll(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.theme = (
"textual-dark" if self.theme == "textual-light" else "textual-light"
)
def action_background(self) -> None:
"""An action to background the timer screen
and return to the shell app."""
self.app.pop_screen()
def action_kill_timer(self) -> None:
"""An action to kill the timer and
return to the shell app."""
self.timer_task.cancel()
self.app.pop_screen()
Then, lets override the init method to also take in an asyncio.Task. That way the screen will know which Job it is related too. This will be used to implement the kill_timer action. Finally, add the action methods to handle the new key binds. They are pretty much the same except the kill action will cancel the asyncio task before it pops the timer screen.
Next lets implement the Timer Command and the TimerApp Job.
class TimerApp(Job):
"""A job to run an instance of the timer app."""
async def execute(self):
self.running()
self.screen = TimerScreen(self.task)
self.shell.app.install_screen(self.screen, name=self.id)
self.shell.app.push_screen(self.screen)
await self.wait_for_cancel()
self.shell.app.uninstall_screen(self.screen)
self.completed()
class Timer(Command):
"""A command to create a timer."""
DEFINITION = {
'timer': CommandNode(
name='timer',
description='Execute the timer app.'
)
}
def create_job(self, *args) -> TimerApp:
"""
Create a timer instance
Returns:
job (TimerApp): A job to run an instance of the timer app.
"""
if len(args) != 0:
self.shell.notify(
message='Invalid Arguments',
title='Command: timer',
severity='error'
)
return
return TimerApp(
shell=self.shell,
cmd=self.name
)
The TimerApp Job just needs to implement the async execute method. In it, we create an instance of the TimerScreen and set it to self.screen. Next, install the TimerScreen on the App's screen stack. This way if the user backgrounds the timer app it won't destroy the screen. Afterwards, push the screen to the top of the stack. This will make the timer screen immediately open up upon the execution of the command. Then, await the self.wait_for_cancel method. This basically just sleeps the job until the user cancels the task via the kill action or by the jobs kill command. Once the user does cancel, it will break out of the sleep loop and uninstall the screen, destroying the instance of the timer app.