I l@ve RuBoard Previous Section Next Section

9.7 PyClock: An Analog/Digital Clock Widget

One of the first things I always look for when exploring a new computer interface is a clock. Because I spend so much time glued to computers, it's essentially impossible for me to keep track of the time unless it is right there on the screen in front of me (and even then, it's iffy). The next program, PyClock, implements such a clock widget in Python. It's not substantially different than clock programs you may be used to seeing on the X Windows system. Because it is coded in Python, though, this one is both easily customized, and fully portable among Windows, the X Windows system, and Macs, like all the code in this chapter. In addition to advanced GUI techniques, this example demonstrates Python math and time module tools.

9.7.1 A Quick Geometry Lesson

Before I show you PyClock, though, a little background and a confession. Quick -- how do you plot points on a circle? This, along with time formats and events, turns out to be a core concept in clock widget programs. To draw an analog clock face on a canvas widget, you essentially need to be able to sketch a circle -- the clock face itself is composed of points on a circle, and the second, minute, and hour hands of the clock are really just lines from a circle's center out to a point on the circle. Digital clocks are simpler to draw, but not much to look at.

Now the confession: when I started writing PyClock, I couldn't answer the last paragraph's opening question. I had utterly forgotten the math needed to sketch out points on a circle (as had most of the professional software developers I queried about this magic formula). It happens. After going unused for a few decades, such knowledge tends to be garbage-collected. I finally was able to dust off a few neurons long enough to code the plotting math needed, but it wasn't my finest intellectual hour.

If you are in the same boat, I don't have space to teach geometry in depth here, but I can show you one way to code the point-plotting formulas in Python in simple terms. Before tackling the more complex task of implementing a clock, I wrote the plotterGui script shown in Example 9-21 to focus on just the circle-plotting logic.

Its point function is where the circle logic lives -- it plots the (X,Y) coordinates of a point on the circle, given the relative point number, the total number of points to be placed on the circle, and the circle's radius (the distance from the circle's center to the points drawn upon it). It first calculates the point's angle from the top by dividing 360 by the number of points to be plotted, and then multiplying by the point number; in case you've forgotten, too, it's 360 degrees around the whole circle (e.g., if you plot 4 points on a circle, each is 90 degrees from the last, or 360/4). Python's standard math module gives all the required constants and functions from that point forward -- pi, sine, and cosine. The math is really not too obscure if you study this long enough (in conjunction with your old geometry text if necessary). See the book's CD for alternative ways to code the number crunching.[3]

[3] And if you do enough number crunching to have followed this paragraph, you will probably also be interested in exploring the NumPy numeric programming extension for Python. It adds things like vector objects and advanced mathematical operations, and effectively turns Python into a scientific programming tool. It's been used effectively by many organizations, including Lawrence Livermore National Labs. NumPy must be fetched and installed separately; see Python's web site for links. Python also has a built-in complex number type for engineering work; see the library manual for details.

Even if you don't care about the math, though, check out this script's circle function. Given the (X,Y) coordinates of a point on the circle returned by point, it draws a line from the circle's center out to the point and a small rectangle around the point itself -- not unlike the hands and points of an analog clock. Canvas tags are used to associate drawn objects for deletion before each plot.

Example 9-21. PP2E\Gui\Clock\plotterGui.py
# plot circles (like I did in high school)

import math, sys
from Tkinter import *

def point(tick, range, radius):
    angle = tick * (360.0 / range)
    radiansPerDegree = math.pi / 180
    pointX = int( round( radius * math.sin(angle * radiansPerDegree) )) 
    pointY = int( round( radius * math.cos(angle * radiansPerDegree) ))
    return (pointX, pointY)

def circle(points, radius, centerX, centerY, slow=0):
    canvas.delete('lines')
    canvas.delete('points')
    for i in range(points):
        x, y = point(i+1, points, radius-4)
        scaledX, scaledY = (x + centerX), (centerY - y)
        canvas.create_line(centerX, centerY, scaledX, scaledY, tag='lines')
        canvas.create_rectangle(scaledX-2, scaledY-2, 
                                scaledX+2, scaledY+2, 
                                           fill='red', tag='points')
        if slow: canvas.update(  )

def plotter(  ):
    circle(scaleVar.get(), (Width / 2), originX, originY, checkVar.get(  ))

def makewidgets(  ):
    global canvas, scaleVar, checkVar
    canvas = Canvas(width=Width, height=Width)
    canvas.pack(side=TOP)
    scaleVar = IntVar(  )
    checkVar = IntVar(  )
    scale = Scale(label='Points on circle', variable=scaleVar, from_=1, to=360)
    scale.pack(side=LEFT)
    Checkbutton(text='Slow mode', variable=checkVar).pack(side=LEFT)
    Button(text='Plot', command=plotter).pack(side=LEFT, padx=50)

if __name__ == '__main__':
    Width = 500                                       # default width, height
    if len(sys.argv) == 2: Width = int(sys.argv[1])   # width cmdline arg?
    originX = originY = Width / 2                     # same as circle radius
    makewidgets(  )                                     # on default Tk root
    mainloop(  )

The circle defaults to 500 pixels wide unless you pass a width on the command line. Given a number of points on a circle, this script marks out the circle in clockwise order every time you press Plot, by drawing lines out from the center to small rectangles at points on the circle's shape. Move the slider to plot a different number of points, and click the checkbutton to make the drawing happen slow enough to notice the clockwise order in which lines and points are drawn (this forces the script to update the display after each line is drawn). Figure 9-19 shows the result for plotting 120 points with the circle width set to 400 on the command line; if you ask for 60 and 12 points on the circle, the relationship to clock faces and hands starts becoming more clear.

Figure 9-19. plotterGui in action
figs/ppy2_0919.gif

For more help, the book CD also includes text-based versions of this plotting script that print circle point coordinates to the stdout stream for review, rather than rendering them in a GUI. See the plotterText scripts in the clock's directory. Here is the sort of output they produce when plotting 4 and 12 points on a circle that is 400 points wide and high; the output format is simply:

pointnumber : angle = (Xcoordinate, Ycoordinate)

and assumes that the circle is centered at coordinate (0,0):

----------
1 : 90.0 = (200, 0)
2 : 180.0 = (0, -200)
3 : 270.0 = (-200, 0)
4 : 360.0 = (0, 200)
----------
1 : 30.0 = (100, 173)
2 : 60.0 = (173, 100)
3 : 90.0 = (200, 0)
4 : 120.0 = (173, -100)
5 : 150.0 = (100, -173)
6 : 180.0 = (0, -200)
7 : 210.0 = (-100, -173)
8 : 240.0 = (-173, -100)
9 : 270.0 = (-200, 0)
10 : 300.0 = (-173, 100)
11 : 330.0 = (-100, 173)
12 : 360.0 = (0, 200)
----------

To understand how these points are mapped to a canvas, you first need to know that the width and height of a circle are always the same -- the radius x 2. Because Tkinter canvas (X,Y) coordinates start at (0,0) in the upper left corner, the plotter GUI must offset the circle's center point to coordinates (width/2, width/2) -- the origin point from which lines are drawn. For instance, in a 400-by-400 circle, the canvas center is (200,200). A line to the 90-degree angle point on the right side of the circle runs from (200,200) to (400,200) -- the result of adding the (200,0) point coordinates plotted for the radius and angle. A line to the bottom at 180 degrees runs from (200,200) to (200,400) after factoring in the (0,-200) point plotted.

This point-plotting algorithm used by plotterGui, along with a few scaling constants, is at the heart of the PyClock analog display. If this still seems a bit much, I suggest you focus on the PyClock script's digital display implementation first; the analog geometry plots are really just extensions of underlying timing mechanisms used for both display modes. In fact, the clock itself is structured as a generic Frame object that embeds digital and analog display objects, and dispatches time change and resize events to both the same way. The analog display is an attached Canvas that knows how to draw circles, but the digital object is simply an attached Frame with labels to show time components.

9.7.2 Running PyClock

Apart from the circle geometry bit, the rest of PyClock is straightforward. It simply draws a clock face to represent the current time and uses widget after methods to wake itself up 10 times per second to check if the system time has rolled over to the next second. On second rollovers, the analog second, minute, and hour hands are redrawn to reflect the new time (or the text of the digital display's labels is changed). In terms of GUI construction, the analog display is etched out on a canvas, is redrawn whenever the window is resized, and changes to a digital format upon request.

PyClock also puts Python's standard time module into service to fetch and convert system time information as needed for a clock. In brief, the onTimer method gets system time with time.time, a built-in tool that returns a floating-point number giving seconds since the "epoch" -- the point from which your computer counts time. The time.localtime call is then used to convert epoch time into a tuple that contains hour, minute, and second values; see the script and Python library manual for additional time-related call details.

Checking the system time 10 times per second may seem intense, but it guarantees that the second hand ticks when it should without jerks or skips (after events aren't precisely timed), and is not a significant CPU drain on systems I use.[4] On Linux and Windows, PyClock uses negligible processor resources, and what it does use is spent largely on screen updates in analog display mode, not after events. To minimize screen updates, PyClock redraws only clock hands on second rollovers; points on the clock's circle are redrawn at startup and on window resizes only. Figure 9-20 shows the default initial PyClock display format you get when file clock.py is run directly.

[4] Speaking of performance, I've run multiple clocks on all test machines -- from a 650 MHz Pentium III to an "old" 200 MHz Pentium I -- without seeing any degraded performance in any running clocks. The PyDemos script, for instance, launches six clocks running in the same process, and all update smoothly. They probably do on older machines, too, but mine have collected too much dust to yield useful metrics.

Figure 9-20. PyClock default analog display
figs/ppy2_0920.gif

The clock hand lines are given arrows at their endpoints with the canvas line object's arrow and arrowshape options. The arrow option can be "first", "last", "none", or "both"; the arrowshape option takes a tuple giving the length of the arrow touching the line, its overall length, and its width.

Like PyView, PyClock also uses the widget pack_forget and pack methods to dynamically erase and redraw portions of the display on demand (i.e., in response to bound events). Clicking on the clock with a left mouse button changes its display to digital by erasing the analog widgets and drawing the digital interface; you get the simpler display captured in Figure 9-21.

Figure 9-21. PyClock goes digital
figs/ppy2_0921.gif

This digital display form is useful if you want to conserve real estate on your computer screen and minimize PyClock CPU utilization (it incurs very little screen update overhead). Left-clicking on the clock again changes back to the analog display. The analog and digital displays are both constructed when the script starts, but only one is ever packed at any given time.

A right mouseclick on the clock in either display mode shows or hides an attached label that gives the current date in simple text form. Figure 9-22 shows a PyClock running with a digital display, a clicked-on date label, and a centered photo image object.

Figure 9-22. PyClock extended display with an image
figs/ppy2_0922.gif

The image in the middle of Figure 9-22 is added by passing in a configuration object with appropriate settings to the PyClock object constructor. In fact, almost everything about this display can be customized with attributes in PyClock configuration objects -- hand colors, clock tick colors, center photos, and initial size.

Because PyClock's analog display is based upon a manually sketched figure on a canvas, it has to process window resize events itself: whenever the window shrinks or expands, the clock face has to be redrawn and scaled for the new window size. To catch screen resizes, the script registers for the <Configure> event with bind; surprisingly, this isn't a top-level window manager event like the close button. As you expand a PyClock, the clock face gets bigger with the window -- try expanding, shrinking, and maximizing the clock window on your computer. Because the clock face is plotted in a square coordinate system, PyClock always expands in equal horizontal and vertical proportions, though; if you simply make the window only wider or taller, the clock is unchanged.

Finally, like PyEdit, PyClock can be run either standalone or attached to and embedded in other GUIs that need to display the current time. To make it easy to start preconfigured clocks, a utility module called clockStyles provides a set of clock configuration objects you can import, subclass to extend, and pass to the clock constructor; Figure 9-23 shows a few of the preconfigured clock styles and sizes in action, ticking away in sync.

Figure 9-23. A few canned clock styles (Guido's photo reprinted with permission from Dr. Dobb's Journal)
figs/ppy2_0923.gif

Each of these clocks uses after events to check for system-time rollover 10 times per second. When run as top-level windows in the same process, all receive a timer event from the same event loop. When started as independent programs, each has an event loop of its own. Either way, their second hands sweep in unison each second.

9.7.3 PyClock Source Code

PyClock source code all lives in one file, except for the precoded configuration style objects. If you study the code at the bottom of the file shown in Example 9-22, you'll notice that you can either make a clock object with a configuration object passed in, or specify configuration options by command line arguments (in which case, the script simply builds a configuration object for you). More generally, you can run this file directly to start a clock, import and make its objects with configuration objects to get a more custom display, or import and attach its objects to other GUIs. For instance, PyGadgets runs this file with command line options to tailor the display.

Example 9-22. PP2E\Gui\Clock\clock.py
###############################################################################
# PyClock: a clock GUI, with both analog and digital display modes, a 
# popup date label, clock face images, resizing, etc.  May be run both 
# stand-alone, or embbeded (attached) in other GUIs that need a clock.
###############################################################################

from Tkinter import *
import math, time, string


###############################################################################
# Option configuration classes
###############################################################################


class ClockConfig:     
    # defaults--override in instance or subclass
    size = 200                                        # width=height
    bg, fg = 'beige', 'brown'                         # face, tick colors
    hh, mh, sh, cog = 'black', 'navy', 'blue', 'red'  # clock hands, center
    picture = None                                    # face photo file

class PhotoClockConfig(ClockConfig):       
    # sample configuration
    size    = 320
    picture = '../gifs/ora-pp.gif'
    bg, hh, mh = 'white', 'blue', 'orange'


###############################################################################
# Digital display object
###############################################################################

class DigitalDisplay(Frame):
    def __init__(self, parent, cfg):
        Frame.__init__(self, parent)
        self.hour = Label(self)
        self.mins = Label(self)
        self.secs = Label(self)
        self.ampm = Label(self)
        for label in self.hour, self.mins, self.secs, self.ampm:
            label.config(bd=4, relief=SUNKEN, bg=cfg.bg, fg=cfg.fg)
            label.pack(side=LEFT)

    def onUpdate(self, hour, mins, secs, ampm, cfg):
        mins = string.zfill(str(mins), 2)
        self.hour.config(text=str(hour), width=4)  
        self.mins.config(text=str(mins), width=4)  
        self.secs.config(text=str(secs), width=4)  
        self.ampm.config(text=str(ampm), width=4)  

    def onResize(self, newWidth, newHeight, cfg): 
        pass  # nothing to redraw here


###############################################################################
# Analog display object
###############################################################################

class AnalogDisplay(Canvas):
    def __init__(self, parent, cfg):
        Canvas.__init__(self, parent, 
                        width=cfg.size, height=cfg.size, bg=cfg.bg)
        self.drawClockface(cfg)
        self.hourHand = self.minsHand = self.secsHand = self.cog = None 

    def drawClockface(self, cfg):                         # on start and resize 
        if cfg.picture:                                   # draw ovals, picture
            try:
                self.image = PhotoImage(file=cfg.picture)          # bkground
            except:
                self.image = BitmapImage(file=cfg.picture)         # save ref
            imgx = (cfg.size - self.image.width(  ))  / 2            # center it
            imgy = (cfg.size - self.image.height(  )) / 2
            self.create_image(imgx+1, imgy+1,  anchor=NW, image=self.image) 
        originX =  originY = radius = cfg.size/2
        for i in range(60):
            x, y = self.point(i, 60, radius-6, originX, originY)
            self.create_rectangle(x-1, y-1, x+1, y+1, fill=cfg.fg)  # mins
        for i in range(12):
            x, y = self.point(i, 12, radius-6, originX, originY)
            self.create_rectangle(x-3, y-3, x+3, y+3, fill=cfg.fg)  # hours
        self.ampm = self.create_text(3, 3, anchor=NW, fill=cfg.fg)

    def point(self, tick, units, radius, originX, originY):
        angle = tick * (360.0 / units)
        radiansPerDegree = math.pi / 180
        pointX = int( round( radius * math.sin(angle * radiansPerDegree) )) 
        pointY = int( round( radius * math.cos(angle * radiansPerDegree) ))
        return (pointX + originX+1), (originY+1 - pointY)

    def onUpdate(self, hour, mins, secs, ampm, cfg):        # on timer callback
        if self.cog:                                        # redraw hands, cog
            self.delete(self.cog) 
            self.delete(self.hourHand)
            self.delete(self.minsHand)
            self.delete(self.secsHand)
        originX = originY = radius = cfg.size/2
        hour = hour + (mins / 60.0)
        hx, hy = self.point(hour, 12, (radius * .80), originX, originY)
        mx, my = self.point(mins, 60, (radius * .90), originX, originY)
        sx, sy = self.point(secs, 60, (radius * .95), originX, originY)
        self.hourHand = self.create_line(originX, originY, hx, hy, 
                             width=(cfg.size * .04),
                             arrow='last', arrowshape=(25,25,15), fill=cfg.hh)
        self.minsHand = self.create_line(originX, originY, mx, my, 
                             width=(cfg.size * .03),
                             arrow='last', arrowshape=(20,20,10), fill=cfg.mh)
        self.secsHand = self.create_line(originX, originY, sx, sy, 
                             width=1,
                             arrow='last', arrowshape=(5,10,5), fill=cfg.sh)
        cogsz = cfg.size * .01
        self.cog = self.create_oval(originX-cogsz, originY+cogsz, 
                                    originX+cogsz, originY-cogsz, fill=cfg.cog)
        self.dchars(self.ampm, 0, END)
        self.insert(self.ampm, END, ampm)

    def onResize(self, newWidth, newHeight, cfg):
        newSize = min(newWidth, newHeight)
        #print 'analog onResize', cfg.size+4, newSize
        if newSize != cfg.size+4:
            cfg.size = newSize-4
            self.delete('all')
            self.drawClockface(cfg)  # onUpdate called next


###############################################################################
# Clock composite object
###############################################################################

ChecksPerSec = 10  # second change timer

class Clock(Frame):
    def __init__(self, config=ClockConfig, parent=None):
        Frame.__init__(self, parent)
        self.cfg = config
        self.makeWidgets(parent)                     # children are packed but
        self.labelOn = 0                             # clients pack or grid me
        self.display = self.digitalDisplay
        self.lastSec = -1
        self.onSwitchMode(None)
        self.onTimer(  )

    def makeWidgets(self, parent):
        self.digitalDisplay = DigitalDisplay(self, self.cfg)
        self.analogDisplay  = AnalogDisplay(self,  self.cfg)
        self.dateLabel      = Label(self, bd=3, bg='red', fg='blue')
        parent.bind('<ButtonPress-1>', self.onSwitchMode)
        parent.bind('<ButtonPress-3>', self.onToggleLabel)
        parent.bind('<Configure>',     self.onResize)

    def onSwitchMode(self, event):
        self.display.pack_forget(  )
        if self.display == self.analogDisplay:
            self.display = self.digitalDisplay
        else:
            self.display = self.analogDisplay
        self.display.pack(side=TOP, expand=YES, fill=BOTH)

    def onToggleLabel(self, event):
        self.labelOn = self.labelOn + 1
        if self.labelOn % 2:
            self.dateLabel.pack(side=BOTTOM, fill=X)
        else:
            self.dateLabel.pack_forget(  )
        self.update(  )

    def onResize(self, event):
        if event.widget == self.display:
            self.display.onResize(event.width, event.height, self.cfg)

    def onTimer(self):
        secsSinceEpoch = time.time(  ) 
        timeTuple      = time.localtime(secsSinceEpoch)        
        hour, min, sec = timeTuple[3:6] 
        if sec != self.lastSec:
            self.lastSec = sec
            ampm = ((hour >= 12) and 'PM') or 'AM'               # 0...23
            hour = (hour % 12) or 12                             # 12..11
            self.display.onUpdate(hour, min, sec, ampm, self.cfg)
            self.dateLabel.config(text=time.ctime(secsSinceEpoch))
        self.after(1000 / ChecksPerSec, self.onTimer)   # run N times per second


###############################################################################
# Stand-alone clocks
###############################################################################

class ClockWindow(Clock):
    def __init__(self, config=ClockConfig, parent=None, name=''):
        Clock.__init__(self, config, parent)
        self.pack(expand=YES, fill=BOTH)
        title = 'PyClock 1.0' 
        if name: title = title + ' - ' + name
        self.master.title(title)                # master=parent or default
        self.master.protocol('WM_DELETE_WINDOW', self.quit) 


###############################################################################
# Program run
###############################################################################

if __name__ == '__main__': 
    def getOptions(config, argv):
        for attr in dir(ClockConfig):              # fill default config obj,
            try:                                   # from "-attr val" cmd args
                ix = argv.index('-' + attr)
            except:
                continue
            else:
                if ix in range(1, len(argv)-1):
                    if type(getattr(ClockConfig, attr)) == type(0):
                        setattr(config, attr, int(argv[ix+1]))
                    else:
                        setattr(config, attr, argv[ix+1]) 
    import sys
    config = ClockConfig(  ) 
    #config = PhotoClockConfig(  )
    if len(sys.argv) >= 2:
        getOptions(config, sys.argv)         # clock.py -size n -bg 'blue'...
    myclock = ClockWindow(config, Tk(  ))      # parent is Tk root if standalone
    myclock.mainloop(  )

And finally, Example 9-23 shows the module that is actually run from the PyDemos launcher script -- it predefines a handful of clock styles, and runs six of them at once attached to new top-level windows for demo effect (though one clock per screen is usually enough in practice, even for me).[5]

[5] Note that images named in this script may be missing on your CD due to copyright concerns. Insert lawyer joke here.

Example 9-23. PP2E\Gui\Clock\clockStyles.py
from clock import *
from Tkinter import mainloop

gifdir = '../gifs/'
if __name__ == '__main__':
    from sys import argv
    if len(argv) > 1:
        gifdir = argv[1] + '/'

class PPClockBig(PhotoClockConfig):
    picture, bg, fg = gifdir + 'ora-pp.gif', 'navy', 'green'

class PPClockSmall(ClockConfig):
    size    = 175
    picture = gifdir + 'ora-pp.gif'
    bg, fg, hh, mh = 'white', 'red', 'blue', 'orange'

class GilliganClock(ClockConfig):
    size    = 550
    picture = gifdir + 'gilligan.gif'
    bg, fg, hh, mh = 'black', 'white', 'green', 'yellow'

class GuidoClock(GilliganClock):
    size = 400
    picture = gifdir + 'guido_ddj.gif'
    bg = 'navy'

class GuidoClockSmall(GuidoClock):
    size, fg = 278, 'black'

class OusterhoutClock(ClockConfig):
    size, picture = 200, gifdir + 'ousterhout-new.gif'
    bg, fg, hh    = 'black', 'gold', 'brown'

class GreyClock(ClockConfig):
    bg, fg, hh, mh, sh = 'grey', 'black', 'black', 'black', 'white'

class PinkClock(ClockConfig):
    bg, fg, hh, mh, sh = 'pink', 'yellow', 'purple', 'orange', 'yellow'

class PythonPoweredClock(ClockConfig):
    bg, size, picture = 'white', 175, gifdir + 'pythonPowered.gif'

if __name__ == '__main__':
    for configClass in [
        ClockConfig,
        PPClockBig,
        #PPClockSmall, 
        GuidoClockSmall,
        #GilliganClock,
        OusterhoutClock,
        #GreyClock,
        PinkClock,
        PythonPoweredClock
    ]:
        ClockWindow(configClass, Toplevel(  ), configClass.__name__)
    Button(text='Quit Clocks', command='exit').pack(  )
    mainloop(  )


    I l@ve RuBoard Previous Section Next Section