×

Search anything:

Python Command Line Countdown Timer

Learn Algorithms and become a National Programmer
Indian Technical Authorship Contest starts on 1st July 2023. Stay tuned.

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

countdown-opengenus

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:

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

countdown-directory

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.

countdown-opengenus

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!

Kirabo Ibrahim

Kirabo Ibrahim

BSTE Student in Computer Science at Makerere University, Uganda.

Read More

Vote for Author of this article:

Improved & Reviewed by:


OpenGenus Foundation OpenGenus Foundation