A multiple timer application in Python/wxPython

Reverend Jim 2 Tallied Votes 2K 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
close