Open-Source Internship opportunity by OpenGenus for programmers. Apply now.
In this article, we have developed Countdown timer as a command line tool in Python Programming Language. This is a good project for your portfolio.
Python Command Line Count Down Timer
In this article, I will guide you on how to build a command line version of a count down timer (see image above) using python.
This article assumes knowledge of the following:
- Python Object Oriented Programming
- ncurses. See curses documentation
- Reactive programming - In particular, using the timer and BehaviorSubject operator. See RxPy documentation
Features
- Restart timer.
- Pause timer.
- Alert user when count down is over by playing an alert sound.
- Allow user to specify duration for count down.
Dependencies
The countdown timer uses the following packages.
- curses - Screen painting and keyboarding handling library for text based terminals.
- RxPy - An implememtation of Reactive X that is used to build asynchronous and event based programs.
- pyfiglet - An ascii art package with a multitude of fonts for drawing text. It is on this list because of curses doesnot give control over font size of strings added to windows. Using pyfiglet fonts, we can achieve large text.
- pygame - It has lots of important modules but the only module of importance here is pygame.mixer. It has been used for playing sound.
Source code
The source code for the count down timer application can be found at this github repository
Application Folder Structure
The most important file is count_down_timer.py because it hosts the business logic for the count down timer. In this case, gui.py and cli.py are only frontends. Once you understand the count_down_timer.py file, the rest of files' code is basically regarding presentation.
count_down_timer.py
from collections import namedtuple
import rx
from rx.core import Observable
from rx.subject import BehaviorSubject
from rx.operators import do_action, map
Time = namedtuple("Time", ["minutes", "seconds"])
class CountDownTimer:
def __init__(self, duration: Time) -> None:
self._minutes = min(duration.minutes, 59)
self._remaining_minutes = self._minutes
self._seconds = min(duration.seconds, 59)
self._remaining_seconds = self._seconds + 1
self._tick_size = 1
self.depleted = BehaviorSubject(False)
@property
def time_remaining(self) -> Observable:
return rx.timer(0.0, period=1).pipe(
do_action(lambda _, this=self: this._tick()),
map(lambda _, this=self: Time(this._remaining_minutes, this._remaining_seconds))
)
def _tick(self) -> None:
is_time_depleted = self.depleted.value
if not is_time_depleted:
if self._remaining_minutes == 0 and self._remaining_seconds == 0:
self.depleted.on_next(True)
return
if self._remaining_seconds == 0: # A minute is complete
self._remaining_minutes -= 1
self._remaining_seconds = 60 # Reset the remaining seconds
self._remaining_seconds -= self._tick_size
def pause(self) -> None:
self._tick_size = 0
def resume(self) -> None:
self._tick_size = 1
At the top of this file after the import statements, we define a named tuple called Time. This named tuple is what will be emitted by the time_remaining subject so that observers can recieve data of a predefined structure rather than strings or integers.
The CountDownTimer class has two events that consumers can listen to:
- time_depleted: Its always False until there is no time left on the count down.
- time_remaining: It emits the time remainig every second
We use the timer operator to simulate a ticking clock i.e emit the time remiaining every second,
The interval at which the timer operator emits, is our only interest and not the values emited by the timer operator. In order to transform the stream in to desired stream of Time objects, we use the do_action operator to invoke the _tick() method to compute the remaining time.
After computing the time left in the _tick() method, the map operator transforms the stream into a stream of Time objects.
To implement pause and resume features, we use _tick_size
attribute. At each tick(ticking clock), we decrement the seconds left by the value of _tick_size
preferably one(1).
Therefore when the _tick_size
is set to zero(0), the time left remains constant because we are no longer the decrementing the seconds.
utils.py
import pyfiglet
def format_time(t):
minutes = " ".join(list("{:02d}".format(t.minutes)))
seconds = " ".join(list("{:02d}".format(t.seconds)))
return pyfiglet.figlet_format("{} : {}".format(minutes, seconds))
...
At the time of figuring out the UI of the countdown timer, I needed the time left to be visually conspicuous i.e To be drawn in a large font.
After reading through the python ncurses documentation, I didn't spot anything related to controlling the size of strings added to the window.
As a workaround, I used an external font library (pyfiglet) that conforms to the interface (string) understood by the ncurses package.
The format_time() function pads the seconds and minutes to two digits, formats them with the default pygfiglet font (standard). I am ok with the default font.
cli.py
The command line interface uses ncurses package. Read through the python ncurses documentation to acquaintancize yourself with it.
There are two windows used here besides the main window.
- TimerWindow: Displays the remainig time
- ControlsHelpWindow: Displays the help for keyboard controls and the status of the timer.(Running, Paused or Depleted)
There isnt much going on in the TimerWindow and ControlsHelpWindow classes than drawing text to the window.
TimerWindow
class TimerWindow:
def __init__(self, parent_window) -> None:
self.parent_window_height, self.parent_window_width = parent_window.getmaxyx()
self.window_width = 40
self.window_height = 10
self.window_begin_y = 5
self.window_begin_x = (self.parent_window_width - self.window_width) // 2 + 2
self.window = curses.newwin(self.window_height, self.window_width, self.window_begin_y, self.window_begin_x)
def render_remaining_time(self, t: Time) -> None:
self.window.clear()
time_remaining = format_time(t)
self.window.addstr(time_remaining)
self.window.refresh()
This class is responsible for rendering the remaining time. The remaining time is rendered in a window that is centered in the main window.
ControlsHelpWindow
class ControlsHelpWindow:
def __init__(self, parent_window) -> None:
self.parent_window_height, self.parent_window_width = parent_window.getmaxyx()
self.window_width = self.parent_window_width
self.window_height = 2
self.window_begin_y = self.parent_window_height - self.window_height
self.window_begin_x = 0
self.window = curses.newwin(self.window_height, self.window_width, self.window_begin_y, self.window_begin_x)
self.controls_help = {
"[q]": "Quit",
"[space]": "Pause | Resume",
"[r]": "Restart",
"[s]": "Start"
}
self.render_controls_help()
def render_timer_state(self, timer_state: int) -> None:
status_text = " [*] Running "
if timer_state == 1:
status_text = " [-] Paused "
elif timer_state == 2:
status_text = " [-] Depleted "
self.window.clear()
self.render_controls_help()
self.window.addstr(0, self.window_width - len(status_text), status_text, curses.A_REVERSE)
self.window.refresh()
def render_controls_help(self) -> None:
right_margin_size = 4
begin_x = 0
begin_y = 0
for key, action_text in self.controls_help.items():
control_help_text = " {} {} ".format(key, action_text)
self.window.addstr(begin_y, begin_x, control_help_text, curses.A_REVERSE)
begin_x += len(control_help_text) + right_margin_size
self.window.refresh()
This class isn't different from the timer window class, they are all literally rendering text.
And for this class, it simply displays the keys that are used to control the timer. Besides displaying the help for control keys, it also displays the state of timer. ie Running, Paused, or Depleted
init() method
After the application is started, control is passed to the init() method through the curses.wrapper() function. The reason behind using curses.wrapper() is to restore the terminal to its normal state even under the unimagined circumstances like when exceptions are raised.
def init(self, main_window) -> None:
curses.curs_set(0)
curses.init_pair(1, curses.COLOR_WHITE, curses.COLOR_BLUE)
self.main_window = main_window
self.main_window_width, self.main_window_height = (curses.COLS, curses.LINES)
self.timer_window = TimerWindow(self.main_window)
self.title = "Count Down Timer By Kirabo Ibrahim"
self.render_title()
self.timer_window.render_remaining_time(self.timer_duration)
self.controls_help_window = ControlsHelpWindow(self.main_window)
self.run_event_loop()
def run_event_loop(self) -> None:
try:
while not self.quit_timer:
pressed_key = self.main_window.getkey()
if pressed_key == "q":
self.quit()
if pressed_key == " " and (self.timer_paused and not self.time_depleted):
self.timer_paused_event.on_next(False)
elif pressed_key == " " and (not self.timer_paused and not self.time_depleted):
self.timer_paused_event.on_next(True)
elif pressed_key == "r" and (self.time_depleted or self.timer_paused):
self.restart()
elif pressed_key == "s" and not (self.timer_paused or self.time_depleted):
self.start()
except:
self.quit()
return
...
The init() method renders the help window for controls and then fills the timer window with the time remaining which at the instant the same as the duration.
After displaying the timer and controls' help, we listen for keyboard key events in the run_event_loop() method, in order to respond to user actions. The run_event_loop() listen for the following events.
- quit - When 'q' key is pressed
- timer_paused - When the space bar has been pressed and the timer is running.
- resume - When the space bar has been pressed and the timer is not running.
- restart - The timer is restarted when the time has depleted or when the timer has been paused.
- start - When the 's' key has been pressed and the timer is not running.
start() method
def start(self) -> None:
self.time_remaining_subscription = self.count_down_timer.time_remaining.subscribe(
self.timer_window.render_remaining_time)
self.time_depleted_subscription = self.count_down_timer.depleted.subscribe(self.on_time_depleted)
self.timer_paused_subscription = self.timer_paused_event.subscribe(self.on_timer_paused)
self.run_event_loop()
In this method, we subscribe to the time_remaining, time_depleted, and timer_paused events, after which, we go into the event loop.
restart() method
class CLICountDownTimer:
...
def restart(self):
self.dispose_subscriptions()
self.count_down_timer = CountDownTimer(self.timer_duration)
self.time_depleted = False
self.timer_paused = False
self.start()
def dispose_subscriptions(self) -> None:
if self.time_remaining_subscription:
self.time_remaining_subscription.dispose()
if self.time_depleted_subscription:
self.time_depleted_subscription.dispose()
if self.timer_paused_subscription:
self.timer_paused_subscription.dispose()
...
Before restarting the timer, we reset some attributes to have a clean state before restarting the timer. We unsubscribe from the timer_paused, time_remaining, and time_depleted streams in order not to have multiple subscriptions. Having mulitple subscritptions will result in having more than one observer modifying the application state. In that case, we end up with race conditions.
After reseting the variables, the start() method is invoked to restart the timer.
Happy Learning!