A multiple timer application in Python/wxPython

Reverend Jim 2 Tallied Votes 1K Views Share

This project implements a multiple timer application. It was written in

  1. Python 3.8.2
  2. wxPython 4.1.0

Feel free to experiment. Here are some possible enhancements:

  1. Add the ability to run a program when the timer expires. With a little scripting you could, for example, schedule the sending of an email.
  2. Add the option to auto-restart a timer after it has alarmed.
  3. Autosave timers on close and reload them on restart.
  4. Add a taskbar icon with pop-up summary of timers on mouse over.

The Files:

Timer.pyw

This file contains the mainline GUI code. It displays a list of custom timer entries and three control buttons. The three buttons allow the user to:

  1. create a new timer
  2. start all existing timers
  3. stop all existing timers

Timer entries are displayed one per line. Each timer contains the following controls:

  1. a button which will run/stop the timer
  2. a button that will stop and reset the timer (countdown only)
  3. a button that will delete a timer
  4. a checkbox to enable a popup message when the timer expires
  5. display of the time remaining
  6. description of the timer

TimerEntry.py

This is a custom control that is subclassed from a wx.BoxSizer. The fields mentioned above are arranged horizontally in this sizer.

A timer entry object can delete all of the controls within it, however, it is up to the parent object to delete the actual timer entry object. I decided that the easiest way to do this was to pass the TimerEntry constructor the address of a delete method from the parent object.

Countdown timers are updated once per second by subtracting one second from the time remaining. Absolute timers, however, must recalculate the time remaining on every timer event otherwise, if you put the computer to sleep then wake it up the time remaining would not account for the sleep period.

TimerDialog.py

This is a custom control that is subclassed from wx.Dialog. This control displays a GUI where the user can select a timer type (absolute or countdown), and specify timer values and a description. For absolute timers, the values entered represent an absolute date/time at which the alarm is to sound. Countdown timers represent a time span after which the alarm will sound. The dialog offers three closing options:

  1. Create - creates the timer but does not start it
  2. Create & Run - creates the timer and automatically starts it
  3. Cancel - does not create a timer

GetMutex.py

This module is used to ensure that only one copy of Timer.pyw can run at a time. It does this by creating a mutex which uses the app name (Timer.pyw) as the mutex prefix. If you want to be able to run multiple copies you can remove the lines:

from GetMutex import *
if (single := GetMutex()).AlreadyRunning(): 
    wx.MessageBox(__file__ + " is already running", __file__, wx.OK)
    sys.exit()

alarm.wav

This is the wav file that will be played whenever a timer expires. If you do not like the one provided just copy a wav file of your choice to a file of the same name.

The entire project is attached as a zip file.

########## Timer.pyw

"""                                                                             
    Name:                                                                       
                                                                                
        Timer.pyw                                                               
                                                                                
    Description:                                                                
                                                                                
        Implements a graphical timer interface. The user can create any number  
        of timers that will either sound an alarm when a countdown expires, or  
        when a given date/time is reached. Each timer entry has its own timer   
        and can be paused, reset, or deleted without affecting other timers.    
                                                                                
    Notes:                                                                      
                                                                                
        Python 3.8.2                                                            
        wxPython 4.1.0                                                          
                                                                                
        Copy whatever .wav file you want to use for the alarm into the app      
        folder as alarm.wav.                                                    
                                                                                
        Uses the custom module Single.py to ensure that only one copy of this   
        script can run at a time. Remove it if you want to allow multiple       
        copies.                                                                 
                                                                                
    Audit:                                                                      
                                                                                
        2020-08-20  rj  Original code                                           
                                                                                
"""

import wx
import sys

from TimerDialog import *
from TimerEntry  import *

class MyFrame(wx.Frame):
    
    def __init__(self, *args, **kwds):

        kwds["style"] = kwds.get("style", 0) | wx.DEFAULT_FRAME_STYLE
        wx.Frame.__init__(self, *args, **kwds)
        
        self.SetTitle("Timer")
        self.SetSize((500, 80))
        self.SetMinSize(self.Size)
        
        self.panel = wx.Panel (self, wx.ID_ANY)
        
        self.btnNew     = wx.Button(self.panel, wx.ID_ANY, "New Timer")
        self.btnRunAll  = wx.Button(self.panel, wx.ID_ANY, "Run All")
        self.btnStopAll = wx.Button(self.panel, wx.ID_ANY, "Stop All")

        self.Bind(wx.EVT_BUTTON, self.btnNew_OnClick,     self.btnNew)
        self.Bind(wx.EVT_BUTTON, self.btnRunAll_OnClick,  self.btnRunAll)
        self.Bind(wx.EVT_BUTTON, self.btnStopAll_OnClick, self.btnStopAll)

        self.Bind(wx.EVT_CLOSE,  self.OnClose)
        
        # This panel will contain the created timers
        self.pnlTimers = wx.ScrolledWindow(self.panel, wx.ID_ANY, style=wx.TAB_TRAVERSAL)
        self.pnlTimers.SetScrollRate(10, 10)

        self.timers = []

        # DO layout of controls
        szrMain  = wx.BoxSizer(wx.VERTICAL)        
        szrOuter = wx.BoxSizer(wx.VERTICAL)

        # Outer slot 0 - Main controls
        szrCtrls = wx.BoxSizer(wx.HORIZONTAL)
        szrCtrls.Add(self.btnNew,     1, wx.ALL | wx.ALIGN_LEFT, 5)
        szrCtrls.Add(50,-1)
        szrCtrls.Add(self.btnRunAll,  1, wx.ALL | wx.ALIGN_CENTRE, 5)
        szrCtrls.Add(50,-1)
        szrCtrls.Add(self.btnStopAll, 1, wx.ALL | wx.EXPAND, 5)
        szrOuter.Add(szrCtrls, 0, wx.EXPAND, 0)

        # Outer slot 1 - Timers
        self.szrTimers = wx.BoxSizer(wx.VERTICAL)
        szrOuter.Add(self.pnlTimers, 1, wx.EXPAND, 0)

        self.pnlTimers.SetSizer(self.szrTimers)
        self.panel.SetSizer(szrOuter)
        szrMain.Add(self.panel, 1, wx.EXPAND, 0)
        self.SetSizer(szrMain)
        
        self.Layout()       
        self.Resize()
        
    def OnClose(self, event):
        """Stop any timers that may be running"""
        for timer in self.timers: timer.timer.Stop()
        event.Skip()

    def btnNew_OnClick(self, event):
        """Prompt for timer parameters and create a new timer entry"""
        
        dlg = TimerDialog()
        
        if dlg.ShowModal() == wx.OK:
            timer = TimerEntry(self.pnlTimers, dlg, self.DeleteTimer)
            self.szrTimers.Add(timer, 1, wx.EXPAND | wx.LEFT | wx.RIGHT | wx.BOTTOM, 5)
            self.timers.append(timer)
            self.Resize()
            
        dlg.Destroy()
        event.Skip()

    def btnRunAll_OnClick(self, event):
        """Start all timers"""
        for timer in self.timers: timer.Run()
        event.Skip()
        
    def btnStopAll_OnClick(self, event):
        """Stop all timers"""
        for timer in self.timers: timer.Stop()
        event.Skip()
        
    def DeleteTimer(self, timer):
        """This is the callback function passed to the timer object"""
        self.timers.remove(timer)       # remove the timer from the list
        self.szrTimers.Remove(timer)    # and from the sizer
        self.Resize()
        
    def Resize(self):
        """Size window to fit all timers"""
        self.Layout()
        newsize = (self.GetSize().width, 80+26*len(self.timers))
        self.SetSize(newsize)
        self.SetMaxClientSize(newsize)
        
        
if __name__ == "__main__":
    
    app = wx.App(0)
    
    from GetMutex import *
    if (single := GetMutex()).AlreadyRunning(): 
        wx.MessageBox(__file__ + " is already running", __file__, wx.OK)
        sys.exit()

    frame = MyFrame(None, wx.ID_ANY, "")            
    frame.Show()    
    app.MainLoop()

########## TimerDialog.py

"""                                                                             
    this is a custom dialog to create a timer entry. You can specify the timer  
    type as datetime (terminates when given date/time is reached) or as count   
    down (terminates when time runs out). When the dialog returns you will have 
    access to the following values:                                             
                                                                                
        auto    True if the timer is to be started automatically                
        desc    A string describing the timer (not required)                    
        type    timer type as 'DateTime' or 'Countdown'                         
        target  alarm time as a datetime.datetime object                        
                                                                                
"""

import wx
import wx.adv       #for datepicker control
import datetime


class TimerDialog(wx.Dialog):

    def __init__(self):
    
        super().__init__(None, wx.ID_ANY)

        self.SetTitle("Create New Timer")
        self.SetSize((320, 260))
       
        self.txtDesc    = wx.TextCtrl(self, wx.ID_ANY, "")
        self.radType    = wx.RadioBox(self, wx.ID_ANY, "Type", choices=["Countdown", "Date/Time"], majorDimension=1, style=wx.RA_SPECIFY_ROWS)
        self.dtpDate    = wx.adv.DatePickerCtrl(self, wx.ID_ANY)
        self.spnHrs     = wx.SpinCtrl(self, wx.ID_ANY, "0", min=0, max=23)
        self.spnMin     = wx.SpinCtrl(self, wx.ID_ANY, "0", min=0, max=59)
        self.spnSec     = wx.SpinCtrl(self, wx.ID_ANY, "0", min=0, max=59)
        self.btnCreate  = wx.Button  (self, wx.ID_ANY, "Create")
        self.btnCreateR = wx.Button  (self, wx.ID_ANY, "Create && Run")
        self.btnCancel  = wx.Button  (self, wx.ID_ANY, "Cancel")

        self.radType.SetSelection(0)
        self.dtpDate.Disable()
        
        self.__do_layout()

        self.Bind(wx.EVT_RADIOBOX, self.RadioBox,          self.radType)
        self.Bind(wx.EVT_BUTTON,   self.Create_OnClick,    self.btnCreate)
        self.Bind(wx.EVT_BUTTON,   self.CreateRun_OnClick, self.btnCreateR)
        self.Bind(wx.EVT_BUTTON,   self.Cancel_OnClick,    self.btnCancel)

        self.dtpDate.initial = self.dtpDate.GetValue()

    def __do_layout(self):

        # Description

        szrDesc = wx.StaticBoxSizer(wx.StaticBox(self, wx.ID_ANY, "Description:"), wx.VERTICAL)
        szrDesc.Add(self.txtDesc, 0, wx.EXPAND, 0)

        # Date/Time controls

        szrDateTime = wx.StaticBoxSizer(wx.StaticBox(self, wx.ID_ANY, "Date/Time"), wx.HORIZONTAL)

        szrDays = wx.StaticBoxSizer(wx.StaticBox(self, wx.ID_ANY, "Date:"), wx.HORIZONTAL)
        szrHrs  = wx.StaticBoxSizer(wx.StaticBox(self, wx.ID_ANY, "Hrs:"),  wx.HORIZONTAL)
        szrMin  = wx.StaticBoxSizer(wx.StaticBox(self, wx.ID_ANY, "Min:"),  wx.HORIZONTAL)
        szrSec  = wx.StaticBoxSizer(wx.StaticBox(self, wx.ID_ANY, "Sec:"),  wx.HORIZONTAL)

        szrDays.Add(self.dtpDate, 0, 0, 0)
        szrHrs.Add (self.spnHrs,  0, 0, 0)
        szrMin.Add (self.spnMin,  0, 0, 0)
        szrSec.Add (self.spnSec,  0, 0, 0)

        sizer = wx.BoxSizer(wx.HORIZONTAL)
        sizer.Add(szrDays, 1, wx.EXPAND, 0)
        sizer.Add(szrHrs,  0, wx.EXPAND, 0)
        sizer.Add(szrMin,  0, wx.EXPAND, 0)
        sizer.Add(szrSec,  0, wx.EXPAND, 0)
        
        szrDateTime.Add(sizer, 1, wx.EXPAND, 4)

        # Command Buttons

        szrButtons = wx.BoxSizer(wx.HORIZONTAL)
        
        szrButtons.Add(self.btnCreate, 1,  wx.EXPAND | wx.ALIGN_LEFT, 0)
        szrButtons.Add(20, 20)
        szrButtons.Add(self.btnCreateR, 1, wx.EXPAND | wx.ALIGN_LEFT, 0)
        szrButtons.Add(20, 20)
        szrButtons.Add(self.btnCancel, 1,  wx.EXPAND, 0)        

        # Main
        
        szrMain = wx.BoxSizer(wx.VERTICAL)

        szrMain.Add(szrDesc,      0, wx.EXPAND, 0)
        szrMain.Add(self.radType, 1, wx.ALL | wx.EXPAND, 5)
        szrMain.Add(szrDateTime,  1, wx.ALL | wx.EXPAND, 5)
        szrMain.Add(szrButtons,   1, wx.ALL | wx.EXPAND, 5)
        
        self.SetSizer(szrMain)
        
        self.Layout()

    def RadioBox(self, event):
        """Enable or disable date picker depending on which timer type is selected"""
        self.dtpDate.Enabled = event.GetEventObject().GetSelection()
        if self.radType.GetSelection() == 1:
            now = datetime.datetime.now()
            self.spnHrs.SetValue(now.hour)
            self.spnMin.SetValue(now.minute)
            self.spnSec.SetValue(0)

        event.Skip()

    def Create_OnClick(self, event):
        """Set the return values to create the timer entry"""
        if self.SetTimerValues(auto=False):
            self.EndModal(wx.OK)
        event.Skip()

    def CreateRun_OnClick(self, event):
        """Set the return values to create and run the timer entry"""
        if self.SetTimerValues(auto=True):
            self.EndModal(wx.OK)
        event.Skip()

    def SetTimerValues(self, auto):
        """Sets the returned timer values and checks for too long a period"""
        self.auto   = auto
        self.desc   = self.txtDesc.GetValue()
        self.abs    = self.radType.GetSelection()
       
        hh = self.spnHrs.GetValue()
        mm = self.spnMin.GetValue()
        ss = self.spnSec.GetValue() 
        
        self.target = datetime.datetime.fromisoformat(
            str(self.dtpDate.GetValue()).split()[0] +
            ' %02d:%02d:%02d' % (hh,mm,ss))

        diff = self.target - datetime.datetime.now()

        if diff.days > 5:
            msg = "That is more than 5 days from now. Is this what you want?"
            return  wx.MessageBox(msg,'Timer Dialog', wx.YES_NO) == wx.YES
                
        return True      

    def Cancel_OnClick(self, event):
        self.EndModal(wx.CANCEL)
        event.Skip()

########## TimerEntry.py

import wx
import winsound
import datetime

class TimerEntry(wx.BoxSizer):
    """                                                                         
    A timer entry consists of a horizontal BoxSizer containing five controls:   
                                                                                
        Run   - Click to start/stop the timer                                   
        Reset - Click to reset the timer to its original value                  
        Del   - Click to delete the timer (handled by the callback routine)     
        Rem   - Displays the time remaining in HH:MM:SS                         
        Desc  - Displays the descriptions of the timer                          
                                                                                
    Init parameters are:                                                        
                                                                                
        parent       container that will own the TimerEntry                     
        dlg          a TimerDialog object with the timer parameters             
        DelCallback  parent function that will delete this entry                
                                                                                
    """

    # btnRun button colours for various states
    RUNNING = wx.GREEN
    PAUSED  = wx.Colour(80,220,220)
    STOPPED = wx.RED

    def __init__(self, parent, dlg, DelCallback):

        super().__init__()

        self.deleteHandler = DelCallback
        self.SetOrientation(wx.HORIZONTAL)

        self.abs    = dlg.abs       # True for absolute timer, False for countdown
        self.target = dlg.target

        remaining = self.GetRemainingTime()

        self.btnRun   = wx.Button  (parent, wx.ID_ANY, "Run" ,    size=(50,23))
        self.btnReset = wx.Button  (parent, wx.ID_ANY, "Reset",   size=(50,23))
        self.btnDel   = wx.Button  (parent, wx.ID_ANY, "Delete",  size=(50,23))
        self.chkPopup = wx.CheckBox(parent, wx.ID_ANY, "", size=(23,23))
        self.txtRem   = wx.TextCtrl(parent, wx.ID_ANY, remaining, size=(80,23), style=wx.TE_CENTRE)
        self.txtDesc  = wx.TextCtrl(parent, wx.ID_ANY, dlg.desc)

        self.timer = wx.Timer()

        self.btnRun.SetToolTip  ('Click to start/stop this timer')
        self.btnDel.SetToolTip  ('Click to delete this timer')
        self.chkPopup.SetToolTip('Select for popup message on alarm')
        
        if self.abs:
            self.btnReset.Disable()
            self.btnReset.SetToolTip('Absolute timers cannot be reset')
            self.txtRem.SetToolTip('Alarm will sound at ' + str(self.target).split('.')[0])
        else:
            self.btnReset.SetToolTip('Click to reset timer to its initial value')
        
        self.Add(self.btnRun,   0, wx.LEFT | wx.RIGHT, 2)
        self.Add(self.btnReset, 0, wx.LEFT | wx.RIGHT, 2)
        self.Add(self.btnDel,   0, wx.LEFT | wx.RIGHT, 2)
        self.Add(self.chkPopup, 0, wx.LEFT | wx.RIGHT, 2)
        self.Add(self.txtRem,   0, wx.LEFT | wx.RIGHT, 2)
        self.Add(self.txtDesc,  1, wx.LEFT | wx.RIGHT, 2)

        self.btnRun.Bind  (wx.EVT_BUTTON, self.btnRun_OnClick)
        self.btnReset.Bind(wx.EVT_BUTTON, self.btnReset_OnClick)
        self.btnDel.Bind  (wx.EVT_BUTTON, self.btnDel_OnClick)
        
        self.timer.Bind(wx.EVT_TIMER, self.OnTimer)
        self.txtRem.initial = self.txtRem.GetValue()

        self.SetButtonColour()

        self.BumpFont(self.txtRem, 1)

        if dlg.auto: self.Run()
       
    def Stop(self):
        """Stop the timer"""
        if self.btnRun.Label == 'Stop':
            self.timer.Stop()
            self.btnRun.Label = 'Run'
            
        self.SetButtonColour()

    def Run(self):
        """Run the timer"""
        
        # This is a lttle obtuse. If the current time remaining is zero 
        # and this is a countdown timer then reset the timer to its     
        # original time and start it. If the time remaining is not zero 
        # then start the timer. Note that a paused absolute timer needs 
        # to recalculate the time remaining before restarting.          
        
        if self.btnRun.Label == 'Run':
            if self.txtRem.GetValue() == '00:00:00':
                if not self.abs:
                    self.txtRem.SetValue(self.txtRem.initial)
                    self.timer.Start(1000)
                    self.btnRun.Label = 'Stop'
            else:
                if self.abs:
                    self.txtRem.SetValue(self.GetRemainingTime())
                self.timer.Start(1000)
                self.btnRun.Label = 'Stop'

        self.SetButtonColour()

    def SetButtonColour(self):
        """Set the button background colour to reflect the current state"""

        if self.btnRun.Label == 'Stop':
            colour = self.RUNNING
        elif self.txtRem.GetValue() == '00:00:00':
            colour = self.STOPPED
        else:
            colour = self.PAUSED
        
        self.btnRun.SetBackgroundColour(colour)

    def btnRun_OnClick(self, event):
        """Run or stop the timer depending on the current state"""
        if self.btnRun.Label == 'Run':
            self.Run()
        else:
            self.Stop()
        event.Skip()

    def btnReset_OnClick(self, event):
        """Reset he timer to its original value"""
        self.Stop()
        self.txtRem.SetValue(self.txtRem.initial)
        self.SetButtonColour()
        event.Skip()

    def btnDel_OnClick(self, event):
        """Delete the timer"""
        self.Stop()
        self.Clear(True)
        self.deleteHandler(self)

    def OnTimer(self, event):
        """Decrement time remaining and sound alarm if expired"""
        if self.abs:
            self.txtRem.SetValue(self.GetRemainingTime())
        else:
            time = self.TimeFromStr(self.txtRem.GetValue())
            self.txtRem.SetValue(self.TimeToStr(time-1))        

        # Stop timer and sound alarm if timer expired
        if self.txtRem.GetValue() == '00:00:00':
            self.Stop()
            winsound.PlaySound('alarm.wav',winsound.SND_FILENAME | winsound.SND_ASYNC)
            if self.chkPopup.IsChecked():
                dlg = TimerMessage('Timer Expired', self.txtDesc.GetValue())
                dlg.Show(1)
            
    def TimeFromStr(self, str):
        """Convert 'HH:MM:SS' or 'DD:HH:MM:SS' to seconds"""
        
        # This handles ##:##:## as well as ##:##:##:## formats
        dd,hh,mm,ss = ('00:' + str).split(':')[-4:]       
        seconds = int(ss) + (60 * (int(mm) + 60 * (int(hh) + 24 * int(dd))))
        return seconds

    def TimeToStr(self, ss):
        """Convert seconds to 'HH:MM:SS' or 'DD:HH:MM:SS'"""
        dd = ss // (24 * 60 * 60)
        ss = ss -   24 * 60 * 60 * dd
        hh = ss // (60 * 60)
        ss = ss -   60 * 60 * hh
        mm = ss //  60
        ss = ss -   60 * mm

        if dd > 0:  str = ('%d:%02d:%02d:%02d') % (dd,hh,mm,ss)
        else:       str = ('%02d:%02d:%02d') % (hh,mm,ss)
        
        return str

    def GetRemainingTime(self):
        """Return time remaining as 'D:HH:MM:SS'. Note that for absolute timers this
        is calculated based on the current time. For countdown timers this is taken 
        from the user entered time.                                                 
        """
        if self.abs:
            if self.target <= datetime.datetime.now():
                return '00:00:00'
            else:
                diff = int((self.target - datetime.datetime.now()).total_seconds())
                return self.TimeToStr(diff)
        else:
            return '%02d:%02d:%02d' % (self.target.hour,self.target.minute,self.target.second)

    @staticmethod
    def BumpFont(control, incr):
        """Make the control font size bigger by <incr>"""
        font = control.GetFont()
        font.PointSize += incr
        control.SetFont(font)

class TimerMessage(wx.Dialog):

    def __init__(self, title, message):

        wx.Dialog.__init__(self, None, wx.ID_ANY)

        self.SetSize(200,80)
        self.SetMinSize(self.Size)
        self.Title = title
        self.SetExtraStyle(wx.TOP)
        
        sizer  = wx.BoxSizer(wx.VERTICAL)
        txtMsg = wx.StaticText(self, wx.ID_ANY, label=message, style=wx.ALIGN_CENTER)
        sizer.Add(txtMsg, 1, wx.ALL, 10)
        self.SetSizer(sizer)
        self.Layout()
        

        self.Bind(wx.EVT_CLOSE, self.OnClose)

    def OnClose(self, event):
        self.Destroy()
        event.Skip()

########## GetMutex.py

"""                                                                             
    Name:                                                                       
                                                                                
        GetMutex.py                                                             
                                                                                
    Description:                                                                
                                                                                
        Provides a method by which an application can ensure that only one      
        instance of it can be running at any given time.                        
                                                                                
    Usage:                                                                      
                                                                                
        if (app := GetMutex()).AlreadyRunning():                                
            print("Application is already running")                             
            sys.exit()                                                          
                                                                                
    Audit:                                                                      
                                                                                
        2020-08-20  rj  Original code                                           
                                                                             """

import os,sys

from win32event import CreateMutex
from win32api   import CloseHandle, GetLastError
from winerror   import ERROR_ALREADY_EXISTS

class GetMutex:
    """ Limits application to single instance """

    def __init__(self):
        thisfile = os.path.split(sys.argv[0])[-1]
        self.mutexname = thisfile + "_{D0E858DF-985E-4907-B7FB-8D732C3FC3B9}"
        self.mutex     = CreateMutex(None, False, self.mutexname)
        self.lasterror = GetLastError()
    
    def AlreadyRunning(self):
        return (self.lasterror == ERROR_ALREADY_EXISTS)
        
    def __del__(self):
        if self.mutex: CloseHandle(self.mutex)

if __name__ == "__main__":

    import sys

    # check if another instance of same program running
    if (myapp := GetMutex()).AlreadyRunning():
        print("Another instance of this program is already running")
        sys.exit(0)

    # not running, safe to continue...
    print("No another instance is running, can continue here")
    
    try:
        while True: pass
    except KeyboardInterrupt: pass