File: pygadgets-products/unzipped/_PyClock/Clock/clock.py
""" ############################################################################### PyClock 3.0: a clock GUI in Python/tkinter. [SA] Sep-2017: Standalone release of PyCalc, PyClock, PyPhoto, PyToe. Copyright 2017 M.Lutz, from book "Programming Python, 4th Edition". License: provided freely, but with no warranties of any kind. With both analog and digital display modes, a pop-up date label, clock face images, general resizing, countdown loops, etc. May be run both standalone, or embedded (attached) in other GUIs that need a clock display. New in 3.0.1 [SA2]: -Fix to update min and hour hands immediately after a deiconify, switch to analog display, resume from suspend, and some dialog/menu views -- not only at seconds=12; else, update delayed too long and time is inaccurate -Avoid updating analog ampm label every second just like min/hour hands; this seems to have further reduced the memory leak while open on Macs: open-window growth is now just 1M/20mins, 3M/hour, 72M/day, and 500M/week; this is roughly half what it was, and some popular Mac apps do worse -Verify that no attrs here clash with those in tkinter's classes (which use "_x" for all but methods, 'tk', 'master', 'children', 'widgetName', and 2 'name*' oddballs.); this was further/futile memory-leak research New in 3.0 [SA]: -Mac OS port -Add '?' help -Use new configs model -Build standalone app+exes -Use PIL/Pillow if present -Cache the background/center image for speed -Work around a memory leak on Mac OS (see onUpdate() and ./MacMemoryLeak/) -Save cpu/memory in general by skipping redraw while minimized everywhere TBD: auto restart after N days on Macs to address gradual memory leak? TBD: DemoMode (like PyToe) that starts clockStyles.py script/exe? TBD: digital mode now leaks more memory than analog when open; why? New in 2.1: updated to run under Python 3.X (2.X no longer supported). New in 2.0: s/m keys set seconds/minutes timer for pop-up msg; window icon. TBD: requiring PIL or Tk 8.6+ would allow other types of images besides GIF; in 3.0, both are now used is present (e.g., apps), but are not required. ############################################################################### """ from tkinter import * from tkinter.simpledialog import askinteger from tkinter.messagebox import showinfo import math, time, sys, os # show versions print('Using Python %s, Tk %s' % (sys.version.split()[0], TkVersion)) # monitor updates trace = lambda *args: None # or print RunningOnMac = sys.platform.startswith('darwin') # [SA] Mac port RunningOnWindows = sys.platform.startswith('win') # [SA] Win backport RunningOnLinux = sys.platform.startswith('linux') # and so on # [SA] use PIL iff present (but disable PNG on Macs due to memory leak) pillowwarning = """ Pillow 3rd-party package is not installed. ...This package is optional, but required for the clock images feature when ...using some image file types and Pythons (though not for GIFs with any Python, ...or PNGs with Pythons that use Tk 8.6 or later, including standard Windows ...installs of Python 3.4+). Pillow home: https://pypi.python.org/pypi/Pillow. """ try: from PIL.ImageTk import PhotoImage # replace tkinter's version except ImportError: from tkinter import PhotoImage # else use the basic lib version print(pillowwarning) # but continue: falls back on Tk/tkinter's native PhotoImage, for PNGs, GIFs, etc. # Pillow install is required for source-code only: apps/exes have it "baked in" # [SA]: set window icons on Windows and Linux from windowicons import trySetWindowIcon # [SA] try to limit memory leaks? - no: see onUpdate() import gc #gc.set_debug(gc.DEBUG_LEAK) def defaultImage(): "[SA] get image path in either standalone or PyGadgets context" if os.path.isdir('images'): # when CWD is my dir return 'images/PyGadgets1024_128x128.gif' # '/' okay on Windows elif os.path.isdir('_PyClock'): # when CWD is PyGadgets dir return '_PyClock/Clock/images/PyGadgets1024_128x128.gif' else: return None # bail nicely (no image displayed) ############################################################################### # Option configuration classes and arg parser ############################################################################### # [SA] DEFUNCT: now loaded from file or args ############################################################################### # Digital display object ############################################################################### class DigitalDisplay(Frame): """ Digital display mode (too simple to doc much) """ def __init__(self, parent, cfg): # [SA] tbd: help here too? 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.BgColor, fg=cfg.DigitalFgColor or cfg.FgColor) label.config(font=cfg.DigitalFont) # [SA] new digital configs label.pack(side=LEFT) # TBD: could expand, and scale font on resize # [SA] yes, make frame expandable; else tiny if window grows for digital for widget in (self, self.hour, self.mins, self.secs, self.ampm): widget.pack(expand=YES, fill=BOTH) def onUpdate(self, hour, mins, secs, ampm, timenow, cfg): """ On timer update in parent Clock object, change time labels. [SA] No longer called if window minimized, to save cpu/memory. [SA2] TBD: should these avoid redraws unless secs=12 too? """ mins = str(mins).zfill(2) # or '%02d' % x 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): """ Draw analog clock at startup, to be shown/hidden by user on request. A Canvas, within a Clock Frame (parent), within a Tk or Toplevel window. This in turn nests a cached PhotoImage object, if one is being used. """ self.size = int(cfg.InitialSize.split('x')[0]) # [SA] one: square Canvas.__init__(self, parent, width=self.size, height=self.size, bg=cfg.BgColor) self.parent = parent # Clock Frame self.image = None self.lastredrawtime = 0.0 self.drawClockface(cfg) self.cog = None # [SA] and wait for first unUpdate() to draw clock hands def drawClockface(self, cfg): """ On start and resize (not timer): draw ovals+picture on empty canvas. This is followed by an onUpdated() run from the timer loop logic. """ self.loadImage(cfg) if self.image != None: imgx = (self.size - self.image.width()) // 2 # center it imgy = (self.size - self.image.height()) // 2 # 3.x // div self.create_image(imgx+1, imgy+1, anchor=NW, image=self.image) originX = originY = radius = self.size // 2 # 3.x // div 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.FgColor) # 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.FgColor) # hours self.ampm = self.create_text(3, 3, anchor=NW, fill=cfg.FgColor) # [SA] add help popup via '?' click; see Clock.makeWidgets help = self.create_text(self.size, 3, text='?', anchor=NE, fill=cfg.FgColor) self.tag_bind(help, '<Button-1>', lambda event: self.parent.onHelp(click=True)) """ [SA] this didn't work on Windows, and looked odd anyhow help = Button(self, text='?', command=self.parent.onHelp, fg=cfg.FgColor) self.create_window(self.size+1, 0, window=help, anchor=NE) """ def point(self, tick, units, radius, originX, originY): """ The geometry bit (see your favorite math textbook). """ 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 loadImage(self, cfg): """ Load analog background image (if any) just once. [SA] Factored off to cache, and make this more robust and explicit. [SA] Substitue a default for PNGs on Mac to avoid a memory leak in the both Tk 8.5 and 8.6 libs; see onUpdate() and ./MacMemoryLeak/. """ if (cfg.PictureFile and cfg.PictureFile.lower().endswith('.png') and RunningOnMac): self.after_idle(lambda: showinfo('PyClock: PNG Replaced', 'Your analog clockface image is being replaced with a ' 'default, because PyClock disallows PNGs on Mac OS to ' 'prevent a memory leak in the Tk GUI library.\n' '\n' 'Please update your configurations file or arguments to ' 'use a non-PNG image in the future. You can use the PyPhoto ' 'gadget or Mac\'s Preview app to convert and resize images ' 'for use in PyClock.' )) cfg.PictureFile = defaultImage() if cfg.PictureFile and self.image == None: # configured + not loaded? try: self.image = PhotoImage(file=cfg.PictureFile) # try first except: try: self.image = BitmapImage(file=cfg.PictureFile) # save ref except: pass return self.image def onUpdate(self, hour, mins, secs, ampm, timenow, cfg): """ On timer update callback: redraw three clock hands and cog. Run each second by the after() timer-loop logic in the parent object's Clock.onTimer(), *unless* the window is minimized, or the digital-mode object is currently displayed. [SA] Recoded to just _move_ hands on updates 2..N by changing their coords(), instead of deleting and recreating their objects each time. Subtlety: this must also recreate hands after resize's delete('all'). To save cpu/memory, also no longer called if the window is minimized, and avoids moving min+hour hands except on minute rollovers, and all redisplays of a formerly idle or hidden analog clock (see SA2 below). Moves are likely more efficient, but this was initially an attempt to fix a memory leak on Macs that proved futile. The leak was determined later to be a Tk bug when using PNG images only, and PNGs are now fully disallowed on Macs. Other leak-fix attempts (forced gc.collect(), and earlier recodings to erase and redraw both 'all' and the entire Canvas) also failed; "batteries included" and "software stacks" are a mixed bag. """ #gc.collect() # [SA] had no effect, and used .x% more cpu """ # [SA] change coords instead of deleting+recreating if self.cog: self.delete(self.cog) self.delete(self.hourHand) # erase prior hands self.delete(self.minsHand) self.delete(self.secsHand) """ originX = originY = radius = self.size // 2 # 3.x div hour = hour + (mins / 60.0) # between points 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) if not self.cog: # # [SA] create lines on first update and resizes (original code) # self.hourHand = self.create_line(originX, originY, hx, hy, width=(self.size * .04), arrow='last', arrowshape=(25,25,15), fill=cfg.HhColor) self.minsHand = self.create_line(originX, originY, mx, my, width=(self.size * .03), arrow='last', arrowshape=(20,20,10), fill=cfg.MhColor) self.secsHand = self.create_line(originX, originY, sx, sy, width=1, arrow='last', arrowshape=(5,10,5), fill=cfg.ShColor) cogsz = self.size * .01 self.cog = self.create_oval(originX-cogsz, originY+cogsz, originX+cogsz, originY-cogsz, fill=cfg.CogColor) self.dchars(self.ampm, 0, END) self.insert(self.ampm, END, ampm) # update am/pm text trace('created hands:', self.secsHand, (originX, originY), (sx, sy)) else: # # [SA] move lines by changing their coords on later updates (new scheme) # if (secs == 0) or (timenow > self.lastredrawtime + 1.5): # # [SA] optimization: update hour+min+ampm only at min rollover; # this reduces both cpu use everywhere and memory use on Macs; # # [SA2] _but_ also do so on the first timer update after window # deiconify, switch from digital to analog display modes, resume # after system suspend, some menu and modal-dialog views on some # platforms, and any other state that precludes clock updates; # else won't redraw till secs hand reaches twelve (a former bug); # # subltle bits: setting self.cog=None won't suffice, because # prior hands are erased on resize only; display-mode switches # run resize (forcing creates) but only until first manual resize; # unlike flags, comparing new-to-last update time handles all cases, # even those without portable Tk events like system suspend/resume; # self.coords(self.hourHand, (originX, originY, hx, hy)) self.coords(self.minsHand, (originX, originY, mx, my)) self.dchars(self.ampm, 0, END) self.insert(self.ampm, END, ampm) # update am/pm text trace('updated hour+min+ampm') self.coords(self.secsHand, (originX, originY, sx, sy)) # last=top trace('changed hands:', self.secsHand, (originX, originY), (sx, sy)) # [SA2] per docs above self.lastredrawtime = timenow def onResize(self, newWidth, newHeight, cfg): """ On user resize of window, redraw clock face at new size. onUpdate will be run on next second to redraw clock hands. """ newSize = min(newWidth, newHeight) if newSize != self.size+4: self.size = newSize-4 # 4 for canvas border self.delete('all') # erase all canvas objects self.drawClockface(cfg) self.cog = None # to be followed by next onUpdate() for hands ############################################################################### # Clock composite object ############################################################################### ChecksPerSec = 10 # second change timer class Clock(Frame): """ A custom Frame, with embedded AnalogDisplay and DigitalDisplay objects. Nested in a Tk or Toplevel (parent), and AnalogDisplay embeds an image. """ def __init__(self, configs=object(), parent=None): Frame.__init__(self, parent) self.parent = parent # [SA] Tk or Toplevel self.cfg = configs self.makeWidgets(parent) # children are packed but self.labelOn = 0 # clients pack or grid me self.display = self.digitalDisplay self.lastSec = self.lastMin = -1 self.countdownSeconds = 0 self.onSwitchMode(None) # flip to draw analog now self.onToggleLabel(None) # [SA] label starts on self.onTimer() def makeWidgets(self, parent): """ Make widgets, bind global actions (analog canvas also binds help click). [SA] Label now uses new config or default colors (not red/blue from PP4E). """ self.digitalDisplay = DigitalDisplay(self, self.cfg) self.analogDisplay = AnalogDisplay(self, self.cfg) self.dateLabel = Label(self, bd=3) self.justClickedHelp = False # [SA] keys: same on all platforms parent.bind('<KeyPress-x>', self.onSwitchMode) parent.bind('<KeyPress-d>', self.onToggleLabel) parent.bind('<KeyPress-s>', self.onCountdownSec) parent.bind('<KeyPress-m>', self.onCountdownMin) # [SA] question=? but portable, help key in all gadgets parent.bind('<KeyPress-question>', lambda event: self.onHelp()) # [SA] clicks: platform-specific if not RunningOnMac: # original code: Windows+Linux; left-click B1 also fires when # the new help text is clicked, and returning 'break' doesn't # work for tag_bind, so we have to set and check a flag # parent.bind('<ButtonPress-1>', self.onSwitchMode) # leftclick parent.bind('<ButtonPress-3>', self.onToggleLabel) # rightclick else: # on Mac OS, can't bind B1 to root window, else also fires # on border clicks (e.g., window moves); also, use B2 instead # of B3 for right-click, and allow Control-B1 as a synonym; # parent.bind('<ButtonPress-2>', self.onToggleLabel) # rightlick parent.bind('<Control-ButtonPress-1>', self.onToggleLabel) # rightclick # redraw analog on user resize parent.bind('<Configure>', self.onResize) def onSwitchMode(self, event): """ [SA] This is also fired on non-Macs when analog's "?" help text is clicked, and returning "break" doesn't work to cancel other bindings in tag_bind callbacks: set and check a state flag. """ trace('onSwitchMode', self.display.__class__.__name__) if self.justClickedHelp and not RunningOnMac: self.justClickedHelp = False return # the normal bits 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): """ Show or hide the date label at window bottom. This is external to the analog/digitl displays. """ 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): """ Check widget match, delegate resize to display object. [SA] A return 'break' here doesn't stop B1 events on Mac. """ if event.widget == self.display: trace('onResize', event.widget.__class__.__name__) self.display.onResize(event.width, event.height, self.cfg) def onTimer(self): """ On timer event: check second rollover, delegate redraw to display object. Checks for rollover N times per second to avoid display stuttering. time.time() is seconds since the epoch, as a floating point number. Caveat: both Tk's widget.after() and Python's time.time() are vulnerable to user changes to the system's clock. This can hang PyClock: restart. """ secsSinceEpoch = time.time() timeTuple = time.localtime(secsSinceEpoch) hour, min, sec = timeTuple[3:6] if sec != self.lastSec: self.lastSec = sec if self.parent.force_state == 'Hidden': # [SA] skip update if minimized to save cpu+memory trace('skipped redraw') else: # Visible state: redraw trace('redrawing') ampm = ((hour >= 12) and 'PM') or 'AM' # 0...23 hour = (hour % 12) or 12 # 12..11 # update current display self.display.onUpdate(hour, min, sec, ampm, secsSinceEpoch, self.cfg) # [SA] better time-label format #self.dateLabel.config(text=time.ctime(secsSinceEpoch)) timeFormat = '%a %b %d, %Y %X' self.dateLabel.config(text=time.strftime(timeFormat, timeTuple)) # check countdown timer self.countdownSeconds -= 1 if self.countdownSeconds == 0: self.onCountdownExpire() # post alarm notice now self.after(1000 // ChecksPerSec, self.onTimer) # run N times per second # 3.x // trunc int div def onCountdownSec(self, event): secs = askinteger('PyClock Alarm', 'Seconds?') # self-validating if secs: self.countdownSeconds = secs # new sole timer def onCountdownMin(self, event): secs = askinteger('PyClock Alarm', 'Minutes') # self-validating if secs: self.countdownSeconds = secs * 60 # new sole timer def onCountdownExpire(self): """ Display an expired-timer message fullscreen for attention. Caveat: only one active user timer, no progress indicator. """ win = Toplevel() win.title('PyClock Alarm') trySetWindowIcon(win, 'icons', 'pygadgets') # [SA] for win+lin if RunningOnMac or RunningOnLinux: # [SA] emulate colored buttons on Mac # [SA] avoid reversed colors on Linux msg = Label(win, text='Timer Expired!') msg.bind('<Button-1>', lambda e: win.destroy()) else: msg = Button(win, text='Timer Expired!', command=win.destroy) msg.config(font=('courier', 80, 'normal'), fg='white', bg='navy') msg.config(padx=10, pady=10) msg.pack(expand=YES, fill=BOTH) win.lift() # raise above siblings if RunningOnWindows or RunningOnMac: # go full screen mode win.state('zoomed') elif RunningOnLinux: # [SA] works on Mac too win.wm_attributes('-fullscreen', 1) def onHelp(self, click=False): """ [SA] Analog clock display's new '?' click help callback; also run for '?' keypress and Mac menus in both modes. For Canvas tag_bind clicks only, set a flag to skip global click event next in onSwitchMode(); unlike other contexts, a return 'break' here doesn't stop B1 events for tag_bind. On Macs only, the display skips time updates during Help; must redraw all clock hands when any modal dialog is closed. """ # [SA] ignore next click event self.justClickedHelp = click from helpmessage import showhelp showhelp(self.parent, 'PyClock', self.HelpText, forcetext=False, setwinicon=lambda win: trySetWindowIcon(win, 'icons', 'pygadgets')) #if self.parent: self.parent.focus_force() # now done in helpmessage HelpText = ('PyClock 3.0\n' '\n' 'A Python/tkinter clock GUI.\n' 'For Mac OS, Windows, and Linux.\n' 'From the book Programming Python.\n' 'Author and © M. Lutz 2001-2017.\n' '\n' 'Usage:\n' '▶ Key "X" switches between analog and digital ' '(or leftclick on non-Macs)\n' '▶ Key "D" shows/hides date (or rightclick, ' 'Mac 2-finger press or control+click)\n' '▶ Keys "S" and "M" set the seconds or minutes alarm ' 'timer, respectively\n' '▶ Key "?" shows this help (the same as clicking the ' 'analog display\'s "?")\n' '▶ Resizing the window resizes the current clock display.\n' '\n' 'Clock photos: analog clocks in app and executable ' 'PyClocks support most image types. For source-code, ' 'GIF always works, PNG works for Tk 8.6+, and all types ' 'work after Pillow install (see README.txt).\n' '\n' 'Mac OS users: use a GIF or JPEG for clocks; PNGs cause ' 'rapid memory leaks in the Mac\'s Tk library. You can also ' 'limit memory use in general by minimizing PyClock to the ' 'Dock when not in use (see README.txt).\n' '\n' 'On all platforms: minimizing PyClock may reduce the amount ' 'of CPU resources it consumes, as display updates are ' 'skipped.\n' '\n' 'Version history:\n' '● 3.0: Sep 2017, standalone release\n' '● 2.1: May 2010, Programming Python 4E\n' '● 2.0: 2006 PP3E, 1.0: 2001 PP2E\n' '\n' 'For downloads and more apps, visit:\n' 'http://learning-python.com/programs.html' ) ############################################################################### # Standalone clocks ############################################################################### appname = 'PyClock 3.0' # use custom Tk, Toplevel for icons, etc. from PP4E.Gui.Tools.windows import PopupWindow, MainWindow class ForceState: """ Mac Tk doesn't set win.state() to 'iconic' when in Dock: do manually, by catching window iconify/deiconify events to track visibility state. Needed to disable update/redraw when clock minimized to save cpu+memory. Caveat: in Tk versions tested, <Map> and <Unmap> do not fire on Linux (except once, to set state to Visible), and Linux Tk also fails to set win.state() like Mac Tk. Only Windows Tk both fires <Map>/<Unmap> and sets state() correctly (to normal or iconic). Luckily, this is also a moot point: on Linux, CPU is 0% and memory doesn't grow in any state. """ def __init__(self): self.force_state = None self.bind('<Map>', lambda event: self.setState(event, 'Visible')) self.bind('<Unmap>', lambda event: self.setState(event, 'Hidden')) def setState(self, event, what): if event.widget == self: trace('setting state to', what) self.force_state = what # enable/ disable redraws class ClockMain(MainWindow, ForceState): """ A custom Tk, with an attached Clock Frame, which embeds a Canvas. """ def __init__(self, configs=object(), name=''): MainWindow.__init__(self, appname, name) ForceState.__init__(self) clock = Clock(configs, self) clock.pack(expand=YES, fill=BOTH) self.clock = clock class ClockPopup(PopupWindow, ForceState): """ A custom Toplevel, with an attached Clock Frame, , which embeds a Canvas. """ def __init__(self, configs=object(), name=''): PopupWindow.__init__(self, appname, name) ForceState.__init__(self) clock = Clock(configs, self) clock.pack(expand=YES, fill=BOTH) self.clock = clock class ClockWindow(Clock, ForceState): """ B/W compat: manual window borders, passed-in parent. """ def __init__(self, config=object(), parent=None, name=''): Clock.__init__(self, configs, parent) ForceState.__init__(self) self.pack(expand=YES, fill=BOTH) title = appname if name: title = appname + ' - ' + name self.master.title(title) # master=parent or default self.master.protocol('WM_DELETE_WINDOW', self.quit) ############################################################################### # Program run ############################################################################### if __name__ == '__main__': from getConfigs import getConfigs # [SA] common gadgets utility defaults = dict(InitialSize='240x240', DigitalFont=None, # default family and size DigitalFgColor=None, # None=FgColor BgColor='beige', # canvas FgColor='brown', # ticks HhColor='black', # hour hand MhColor='navy', # minute hand ShColor='blue', # second hand CogColor='white' , # center point PictureFile=defaultImage()) # middle photo path (or None) configs = getConfigs('PyClock', defaults) # load from file or args # alternatives #myclock = ClockWindow(Tk(), configs) #myclock = ClockPopup('popup', configs) # parent is Tk root if standalone myclock = ClockMain(configs) trySetWindowIcon(myclock, 'icons', 'pygadgets') # [SA] for win+lin if RunningOnMac: # Mac requires menus, deiconifies, focus # [SA] on Mac, customize app-wide automatic top-of-display menu from guimaker_pp4e import fixAppleMenuBar fixAppleMenuBar(window=myclock, appname='PyClock', helpaction=myclock.clock.onHelp, aboutaction=None, quitaction=myclock.quit) # app-wide quit: ask # [SA] reopen auto on dock/app click and fix tk focus loss on deiconify def onReopen(): myclock.lift() myclock.update() temp = Toplevel() temp.lower() temp.destroy() myclock.createcommand('::tk::mac::ReopenApplication', onReopen) myclock.mainloop()