""" ############################################################################ PyPhoto 2.1: a basic but open-source and portable image viewer, with cached thumbnails for speed, scrolling, resizing, and saves. Copyright: 2017 M.Lutz, http://learning-python.com. License: provided freely, but with no warranties of any kind. Originally from the book "Programming Python, 4th Edition". VERSIONS: [SA] 2.1, Nov-2017: rereleased with new PyGadgets, to store a folder's thumbnails in a single pickle file, instead of individual image files in a subfolder. This has the same performance, but avoids extra files (15k images formerly meant 15k thumbs); multiple file loads and saves; and nasty modtime-copy issues for backup programs. See viewer_thumbs.py. Version 2.0 users: run the included delete-pyphoto2.0-thumbs-folders.py (or its executable), to delete all 2.0 subfolders when upgrading to 2.1. [SA] 2.0, Sep-2017: Standalone release of PyCalc, PyClock, PyPhoto, PyToe. Multiple changes (see ahead), included in the PyGadgets standalone release. [PP4E] 1.X, 2006: the original version appeared in the book named above. OVERVIEW: This program displays cached image-folder thumbnails which open images when clicked, scrolls both images and thumbnail pages, and provides a variety of viewing functionality (see the Help popup). It supports multiple image-directory thumb windows; the initial image folder is passed in via configs, uses the "images-mixed" default, or is selected via a main window button. Later directories are opened by pressing "D" in either image-view or thumbnail-view windows. For startup speed, folder thumbnails are cached in a pickle file in writeable folders (subfolder caching is available for other programs). The image viewer resizes and scrolls images too large for the screen. This program is still something of a placeholder for more photo editing ops, but it demonstrates PIL basics well enough to ship as is. VERSION DETAILS: ---- New in 2.1: store thumbnails in a single pickle file, not as multiple thumb-image files in a subfolder, and apply other minor improvements to thumbnail processing; see viewer_thumbs.py for details. Here, also catch/report images with errors, whose thumbnails are no longer omitted. Version 2.1 also reports save errors in GUI popups, prints all excs, and supports a new config setting to ignore modtime-based change tests. ---- New in 2.0 [SA]: multiple/major extensions to boost utility: - New help dialog, tweak hints in window titles - Mac OS port: menus, reopens, focus, clicks - Auto-resize large images to screen size - Bind arrow keys to scroll both thumbs and images - Add N/P=next/prior image in folder (as displayed) - Add A/S=actual/scaled size (a=prior viewer sole mode) - Old key S (save) is now W (write) - Show busy message in window while creating thumbnails - Use LANCZOS resize filter in all resize contexts in newer Pillows: now the best quality (if slowest) for both shrinking and expanding: pillow.readthedocs.io/en/4.2.x/handbook/concepts.html#concept-filters - Move to upper left corner if too large for display or partially off-screen, but do not center if smaller than display (user choice) - Get screen max size reliably (this is a bit loose in Tk) - Implement ViewSize fixed-size option for image views and scaling - Workaround a C libs hardcrash for tiff thumbnail saves: no compress PyPhoto is still a bit primitive, and something of a PIL demo (e.g. images are not resized on window resizes, and there is no cropping, etc.), but is also illustrative of general PIL use. ---- Older PP4E book versions New in 1.1: updated to run in Python 3.1 and latest PIL. New in 1.0: now does a form of (2) above: image is resized to one of the display's dimensions if clicked, and zoomed in or out in 10% increments on key presses; generalize me. Caveat: seems to lose quality/pixels after many resizes (this is probably a limitation of PIL). ---- NOTES The following scaler adapted from PIL's thumbnail code is similar to the screen height scaler here, but only shrinks: x, y = imgwide, imghigh if x > scrwide: y = max(y * scrwide // x, 1); x = scrwide if y > scrhigh: x = max(x * scrhigh // y, 1); y = scrhigh ---- CAVEATS/TBDS 1) TIFF drawing quality on Mac OS El Capitan On test and development machines, TIFF images and their thumbnails render poorly (with artifacts) in Mac OS apps and source-code on El Capitan, but accurately on Mac OS Sierra in all modes (and always flawlessly on Windows and Linux). This may indicate a newer shared libtiff on Sierra, which might be fixed by installs on El Capitan. 2) Image-save limitations Saves ("W") work well in typical roles, but are not always as simple as giving a ".xxx" filename extension (e.g., this may discard GIF transparencies), and some image-type saves may not work at all due to the Pillow library's constraints, especially for conversions (e.g., they may leave zero-length files). To be improved if usage warrants. Version 2.1 at least adds a GUI popup on save errors. 3) Image auto-placement is one-sided The automatic move-to-upper-left-corner works only when the image is off-screen to the right, not left; this could be generalized to work on either screen side, but that seems a bit superfluous. 4) Mac OS screenshot shadows on Mac OS El Capitan The default shadows that Mac OS adds around its screenshots sometimes render as all-black in Pillow images and thumbnails as they're used here. Oddly, this has been observed ONLY on a Mac OS El Capitan machine; it does not occur on a Mac OS Sierra machine, and Windows and Linux seem to always render the shadow properly too. Hence, this appears to be Mac-only, and specific to either Mac OS version or a single machine. (See also #1 above: El Capitan's image-drawing stack seems flaky.) If not, this may be prohibitively complex. A magic-but-sadly-undocumented Pillow remedy seems unlikely, and removing shadows manually seems absurd: https://stackoverflow.com/questions/18293290/ programmatically-remove-apple-screen-capture-shadow-borders 5) And so on Other TBDs include (1) rearrange thumbnails when window resized, based on current window size? (2) [DONE] resize images to fit display/window size? (3) avoid scrolls if image size is less than window max size: use Label if imgwide <= scrwide and imghigh <= scrhigh? (4) cropping, annotation,...? ############################################################################ """ import sys, math, os, traceback from tkinter import * from tkinter.filedialog import SaveAs, Directory from tkinter.messagebox import showerror # [SA] require PIL/Pillow install pillowerror = """ Pillow 3rd-party package is not installed. ...This package is required by PyPhoto for both thumbnails generation ...and image file types not supported by Pythons without Pillow. Any ...Python supports GIFs, and Pythons using Tk 8.6 or later add PNGs, but ...thumbnails expect Pillow. See https://pypi.python.org/pypi/Pillow. """ try: from PIL import Image # get image wrapper + widget from PIL.ImageTk import PhotoImage # replaces tkinter's version except ImportError: print(pillowerror) sys.exit(0) # don't continue: some image types may load, but need PIL to make thumbnails # Pillow install required for source-code only: apps/exes have PIL "baked in" # thumbnail generation code, developed earlier in book from viewer_thumbs import makeThumbs, isImageFileName, sortedDisplayOrder # [SA] Mac port (and other backports) RunningOnMac = sys.platform.startswith('darwin') RunningOnWindows = sys.platform.startswith('win') RunningOnLinux = sys.platform.startswith('linux') # [SA]: set window icons on Windows and Linux from windowicons import trySetWindowIcon # remember last dirs across all windows saveDialog = SaveAs(title='Save As (filename extension gives image type)') if RunningOnMac: openargs = dict(message='Select Image Directory To Open') # [SA] else: openargs = dict(title='Select Image Directory To Open') openDialog = Directory(**openargs) trace = print # or lambda *x: None appname = 'PyPhoto 2.1' ############################################################################ # Canvas with dual scroll bars, used by both thumbnail and image windows ############################################################################ class ScrolledCanvas(Canvas): """ a canvas in a container that automatically makes vertical and horizontal scroll bars for itself """ def __init__(self, container): Canvas.__init__(self, container) self.config(borderwidth=0) vbar = Scrollbar(container) hbar = Scrollbar(container, orient='horizontal') vbar.pack(side=RIGHT, fill=Y) # pack canvas after bars hbar.pack(side=BOTTOM, fill=X) # so clipped first self.pack(side=TOP, fill=BOTH, expand=YES) vbar.config(command=self.yview) # call on scroll move hbar.config(command=self.xview) self.config(yscrollcommand=vbar.set) # call on canvas move self.config(xscrollcommand=hbar.set) ############################################################################ # View a single image selected in thumbnails window ############################################################################ class ViewOne(Toplevel): """ -------------------------------------------------------------- A pop-up window that opens a single image when created. Scrollable vertically and horizontally if too big for display. This was initially coded as a class because PhotoImage objects must be saved, else images may be erased from GUI if reclaimed. On mouse clicks, resizes to display's height or width, either stretching or shrinking. On I/O keypress, zooms image in/out. Both resizing schemes maintain the original aspect ratio; its code is factored to avoid redundancy here as possible. [SA] See 2.0 changes list in main docstring for updates: there were too many extensions to list here. [2.1] Catch and report exceptions for images with errors, whose thumbnails are no longer omitted in 2.1. Pillow uses a "lazy" model in which open() just identifies the file, but the complete load() is deferred until later processing. This can be a major problem, as exceptions might occur anywhere and anytime for images with errors. Here, and on next/prior image, call load() explicitly to force errors to happen immediately. -------------------------------------------------------------- """ def __init__(self, imgdir, imgfile, # image path+name to display dirwinsize=(), # thumbs: pass along on "D" viewsize=(), # fixed display size opener=None, # refocus on errors? nothumbchanges=False): # thumbs: pass along on "D" Toplevel.__init__(self) self.setTitle(imgfile) trySetWindowIcon(self, 'icons', 'pygadgets') # [SA] for win+lin self.viewsize = viewsize # fixed scaling size # try to load image imgpath = os.path.join(imgdir, imgfile) # img file to open try: imgpil = Image.open(imgpath) # img object to be reused by ops imgpil.load() # [2.1] load now so errors here except: # [2.1] can fail on OSError+ in Pillow traceback.print_exc() self.destroy() showerror('PyPhoto: Image Load', 'Cannot load image file:\n%s' % imgpath) if opener: opener.focus_force() # abandon after error popup if RunningOnLinux: opener.lift() # else root win stays above return # abandon after error popup self.trueimage = imgpil # for all ops till N/P self.canvas = ScrolledCanvas(self) # tk canvas to be reused self.drawImageFirst() # show scaled or actual now # bind keys/events for this image-view window self.canvas.bind('', self.onSizeToDisplayHeight) self.canvas.bind('', self.onSizeToDisplayWidth) if RunningOnMac: self.canvas.bind('', self.onSizeToDisplayWidth) # [SA] self.bind('', self.onSizeToDisplayWidth) self.bind('', self.onZoomIn) self.bind('', self.onZoomOut) self.bind('', self.onSaveImage) self.bind('', lambda event: onDirectoryOpen(self, dirwinsize, viewsize, nothumbchanges)) # [SA] question=? but portable, help key in all gadgets self.bind('', lambda event: onHelp(self)) # [SA] bind arrow keys to scroll canvas too (tbd: increment?) self.bind('', lambda event: self.canvas.yview_scroll(-1, 'units')) self.bind('', lambda event: self.canvas.yview_scroll(+1, 'units')) self.bind('', lambda event: self.canvas.xview_scroll(-1, 'units')) self.bind('', lambda event: self.canvas.xview_scroll(+1, 'units')) # [SA] add next/prior image in this image's folder self.bind('', self.onNextImage) self.bind('', self.onPrevImage) self.imgdir, self.imgfile, self.dirwinsize = imgdir, imgfile, dirwinsize # [SA] add actual/scaled size (actual=former version's only mode) self.bind('', lambda event: self.drawImageSized(self.trueimage)) self.bind('', lambda event: self.drawImageFirst()) self.focus() # on Windows, make sure new window catches events now # [SA] set min size as partial fix for odd window shinkage on zoomout # on Mac; later made mostly moot by auto-resize to screen/fixed size """ self.minsize(500, 500) # w, h """ def setTitle(self, imgfile): """ [SA] split off from constructor for next/prior """ helptxt = 'I/O=zoom, N/P=move, A/S=size, arrows=scroll' self.title('%s: %s (%s)' % (appname, imgfile, helptxt)) def getMaxSize(self, constrained=True): """ [SA] fullscreen (W, H) size, factored to common code; viewsize is experimental, but suffices to limit scaling max; oddness: Tk's self.maxsize() height varies across calls, and on Mac is < self.winfo_screenwidth()/.winfo_screenheight(); """ #trace('==>', self.maxsize()) #trace('==>', (self.winfo_screenwidth(), self.winfo_screenheight())) scrwide, scrhigh = self.winfo_screenwidth(), self.winfo_screenheight() if not constrained: return (scrwide, scrhigh) # wm screen size (x,y) else: return self.viewsize or (scrwide, scrhigh) # user limit, or wm # # Drawing # def drawImageFirst(self): """ [SA] draw from self.trueimage, ether scaled or actual; called on first open, next/prior, and S=scale key, but trueimage is set by the first two of these only, and is used by most ops (viewimage is used by zooms and saves); resize large images to screen or fixed size initially; smaller images are still drawn by actual size, as before; viewsize is still experimental (scaling to screen size in onSizeToDisplayBoth is better), but suffices to limit max; [SA] imgpil.width and imgpil.height are not available in earlier Pillows: use the older .size tuple-pair instead; """ imgpil = self.trueimage imgwide, imghigh = imgpil.size # size in pixels (x,y) scrwide, scrhigh = self.getMaxSize() # wm screen size (x,y) if imgwide <= scrwide and imghigh <= scrhigh: # too big for display? self.drawImageSized(imgpil) # no: win size per img else: # yes: resize to screen self.onSizeToDisplayBoth() # scale to screen W and H """ # this else was not as good... diffwide = imgwide - scrwide # use most-exceeded edge diffhigh = imghigh - scrhigh if diffwide > diffhigh: # either may be negative self.onSizeToDisplayWidth(event=None) else: self.onSizeToDisplayHeight(event=None) """ def drawImageSized(self, imgpil): """ draw imgpil, as it is sized, in current window/canvas; imgpil may be the original actual size, or a temp resize; """ imgtk = PhotoImage(image=imgpil) # not file=imgpath imgwide = imgtk.width() # size in pixels imghigh = imgtk.height() # same as imgpil.size scrwide, scrhigh = self.getMaxSize() # wm screen size (x,y) fullsize = (0, 0, imgwide, imghigh) # scrollable viewwide = min(imgwide, scrwide) # viewable viewhigh = min(imghigh, scrhigh) canvas = self.canvas canvas.delete('all') # clear prior photo canvas.config(height=viewhigh, width=viewwide) # viewable window size canvas.config(scrollregion=fullsize) # scrollable area size canvas.create_image(0, 0, image=imgtk, anchor=NW) self.savephoto = imgtk # keep reference on me self.viewimage = imgpil # currently-shown size trace((scrwide, scrhigh), imgpil.size) # [SA] move to upper-left if too big or partially off-screen self.update() coords = self.geometry() # e.g., '721x546+521+23' wmsizeX, wmsizeY = \ [int(x) for x in coords.split('+')[0].split('x')] upleftX, upleftY = \ [int(x) for x in coords.split('+')[1:3]] truewide, truehigh = self.getMaxSize(constrained=False) if (upleftX + wmsizeX > truewide) or (upleftY + wmsizeY > truehigh): self.geometry('+0+0') #trace((upleftX, wmsizeX, truewide), (upleftY, wmsizeY, truehigh)) # [SA] the point of this seems lost in translation... """ if imgwide <= scrwide and imghigh <= scrhigh: # too big for display? self.state('normal') # no: win size per img elif sys.platform[:3] == 'win': # do windows fullscreen self.state('zoomed') # others use geometry() else: # [SA] Mac is different... win.wm_attributes('-fullscreen', 1) """ # # Scaling # def sizeToDisplaySide(self, scaler): """ resize to fill one side (or both) of the display; [SA] start from self.trueimage, not last view size; [SA] lanczos filter not available in earlier Pillows; """ imgpil = self.trueimage # scale from full size imgwide, imghigh = imgpil.size # img size in pixels scrwide, scrhigh = self.getMaxSize() # wm screen size (x,y) newwide, newhigh = scaler(scrwide, scrhigh, imgwide, imghigh) if hasattr(Image, 'LANCZOS'): filter = Image.LANCZOS # [SA] best for all, if available else: if (newwide * newhigh < imgwide * imghigh): filter = Image.ANTIALIAS # shrink: antialias else: # grow: bicub sharper filter = Image.BICUBIC newimg = imgpil.resize((newwide, newhigh), filter) self.drawImageSized(newimg) def onSizeToDisplayHeight(self, event): def scaleHigh(scrwide, scrhigh, imgwide, imghigh): newwide = int(imgwide * (scrhigh / imghigh)) # 3.x true div newhigh = scrhigh # [SA] -border return (newwide, newhigh) # proportional self.sizeToDisplaySide(scaleHigh) def onSizeToDisplayWidth(self, event): def scaleWide(scrwide, scrhigh, imgwide, imghigh): newhigh = int(imghigh * (scrwide / imgwide)) # 3.x true div newwide = scrwide # [SA] -border return (newwide, newhigh) self.sizeToDisplaySide(scaleWide) def onSizeToDisplayBoth(self): """ [SA] scale to fit both height and width of screen; the 90% scale-down is meant allow for window borders: should this happen in sizeToDisplaySide for all (tbd)? """ def scaleBoth(scrwide, scrhigh, imgwide, imghigh): scrwide, scrhigh = (int(x * .90) for x in (scrwide, scrhigh)) newwide, newhigh = imgwide, imghigh if imgwide > scrwide: newhigh = int(imghigh * (scrwide / imgwide)) newwide = scrwide if imghigh > scrhigh: newwide = int(imgwide * (scrhigh / imghigh)) newhigh = scrhigh return (newwide, newhigh) self.sizeToDisplaySide(scaleBoth) # # Zooming # def zoom(self, factor): """ zoom in or out in increments; [SA] must use viewimage here, else not cumulative; [SA] lanczos filter not available in earlier Pillows; """ imgpil = self.viewimage # zoom from last display size wide, high = imgpil.size # may be scaled, actual, zoomed if hasattr(Image, 'LANCZOS'): filter = Image.LANCZOS # [SA] best for all, if available else: if factor < 1.0: # antialias best if shrink filter = Image.ANTIALIAS # also nearest, bilinear else: filter = Image.BICUBIC newimg = imgpil.resize((int(wide * factor), int(high * factor)), filter) self.drawImageSized(newimg) def onZoomIn(self, event, incr=.10): self.zoom(1.0 + incr) def onZoomOut(self, event, decr=.10): self.zoom(1.0 - decr) # # Saving # def onSaveImage(self, event): """ save current image size/state to a file; per PIL, fiename extension gives image type; [SA] save from viewimage: currently-shown size; [SA] set initialfile name for convenience; [2.1] catch/report save errors, console+GUI """ filename = saveDialog.show(initialfile=self.imgfile) if filename: try: self.viewimage.save(filename) except: traceback.print_exc() print('Error saving image file: not saved') showerror('PyPhoto: Image Save', 'Cannot save image file:\n%s' % filename) self.focus_force() # [SA] for Mac # # Navigating # def switchimage(self, ixmod): """ [SA] add next/prior image in this image's folder; redraws in same window/canvas, to avoid flicker if draw in a new window and erase the old one (and the "ViewOne" class name now really means one at a time); [2.1] catch/report images with errors (whose thumbs are no longer omitted); error imgs stop the next/prior progression: users must click a new thumb past it; [2.1] must call viewer_thumb's sortedDisplayOrder(), not os.listdir() directly, so the next/prior order implemented here matches thumbs-display order; """ currdir, currfile = self.imgdir, self.imgfile allfiles = sortedDisplayOrder(currdir) imgfiles = list(filter(isImageFileName, allfiles)) currix = imgfiles.index(currfile) newix = currix + ixmod newix = len(imgfiles)-1 if newix < 0 else newix % len(imgfiles) nextfile = imgfiles[newix] # wrapped around imgpath = os.path.join(currdir, nextfile) # img file to open try: nextimgpil = Image.open(imgpath) # img object to be reused nextimgpil.load() # [2.1] load now so errors here except: # [2.1] can fail on OSError+ in Pillow traceback.print_exc() showerror('PyPhoto: Image Load', 'Cannot load image file:\n%s' % imgpath) self.focus_force() if RunningOnLinux: self.lift() # else root stays above else: # move iff image loaded self.imgfile = nextfile self.setTitle(nextfile) self.trueimage = nextimgpil # save for ops on image self.drawImageFirst() # new image, same win/canvas """ # or new window: this worked but was too twitchy... ViewOne(currdir, nextfile, self.dirwinsize, (plus new stuff)) self.destroy() """ def onNextImage(self, event): self.switchimage(+1) def onPrevImage(self, event): self.switchimage(-1) ############################################################################ # View the thumbnails window for an initial or chosen directory ############################################################################ def viewThumbs(imgdir, # open this folder kind=Toplevel, # thumbs window: Tk or Toplevel dirwinsize=(), # size of this thumbs window viewsize=(), # size of each image-view window numcols=None, # fixed, else per #thumbnails nothumbchanges=False): # don't detect image changes? """ -------------------------------------------------------------- Make main (Tk) or pop-up (Toplevel) thumbnail-buttons window. Uses fixed-size buttons, and a bi-scrollable canvas. Sets scrollable (full) size of canvas, and places thumbs at absoute x,y coordinates in the canvas. No longer assumes that all thumbs are the same size: uses max of all (x,y) for all, as some may be smaller. [SA] See 2.0 changes list in main docstring for updates: there were too many extensions to list here. CAUTION: changing thumb size can defeat backup programs; delete all thumbs on size changes (see viewer_thumbs.py). -------------------------------------------------------------- """ win = kind() helptxt = 'D=open' win.title('%s: %s (%s)' % (appname, imgdir, helptxt)) trySetWindowIcon(win, 'icons', 'pygadgets') # [SA] for win+lin # [SA] add new Help button # [SA] Quit=destroy (this window only unless last Tk), not quit (entire app) tools = Frame(win, bg='beige') tools.pack(side=BOTTOM, fill=X) quit = Button(tools, text=' Quit ', command=win.destroy) # [SA] no bg= on Mac quit.pack(side=RIGHT, expand=YES) help = Button(tools, text=' Help ', command=lambda: onHelp(win)) help.pack(side=LEFT, expand=YES) # [SA] question=? but portable, help key in all gadgets win.bind('', lambda event: onHelp(win)) # make or load thumbs ==> [(imgfile, imgobj)] thumbs = makeThumbs(imgdir, # all images in folder size=(128, 128), # fixed thumbnails size busywindow=win, # announce in GUI nothumbchanges=nothumbchanges) # don't detect changes? numthumbs = len(thumbs) if numthumbs == 0: # no-image dir? numcols = numrows = 0 # [SA] avoid / 0 exc else: if not numcols: numcols = int(math.ceil(math.sqrt(numthumbs))) # fixed or N x N numrows = int(math.ceil(numthumbs / numcols)) # 3.x true div width, height = dirwinsize # [SA] new configs model canvas = ScrolledCanvas(win) # init viewable window size canvas.config(height=height, width=width) # changes if user resizes # max w|h: thumb=(name, obj), obj.size=(width, height) if numthumbs == 0: linksize = 0 # [SA] avoid empty-seq max() exc else: linksize = max(max(thumb[1].size) for thumb in thumbs) trace(linksize) fullsize = (0, 0, # upper left X,Y (linksize * numcols), (linksize * numrows) ) # lower right X,Y canvas.config(scrollregion=fullsize) # scrollable area size rowpos = 0 savephotos = [] while thumbs: thumbsrow, thumbs = thumbs[:numcols], thumbs[numcols:] colpos = 0 for (imgfile, imgobj) in thumbsrow: photo = PhotoImage(imgobj) link = Button(canvas, image=photo) def handler(_imgfile=imgfile): ViewOne(imgdir, _imgfile, dirwinsize, viewsize, win, nothumbchanges) link.config(command=handler, width=linksize, height=linksize) link.pack(side=LEFT, expand=YES) canvas.create_window(colpos, rowpos, anchor=NW, window=link, width=linksize, height=linksize) colpos += linksize savephotos.append(photo) rowpos += linksize win.savephotos = savephotos # keep references to all to avoid gc # bind keys/events for this directory-view window win.bind('', lambda event: onDirectoryOpen(win, dirwinsize, viewsize, nothumbchanges)) # [SA] bind arrow keys to scroll canvas too (tbd: increment?) win.bind('', lambda event: canvas.yview_scroll(-1, 'units')) win.bind('', lambda event: canvas.yview_scroll(+1, 'units')) win.bind('', lambda event: canvas.xview_scroll(-1, 'units')) win.bind('', lambda event: canvas.xview_scroll(+1, 'units')) win.focus() # [SA] on Windows, make sure new window catches events now return win ############################################################################ # Utilities, having multiple class and non-class clients ############################################################################ def onDirectoryOpen(parentwin, dirwinsize, viewsize, nothumbchanges): """ open a new image directory in a new main window; available via "D" in both thumb and img windows """ dirname = openDialog.show() if dirname: viewThumbs(dirname, Toplevel, dirwinsize, viewsize, nothumbchanges=nothumbchanges) else: parentwin.focus_force() # [SA] for Mac def onHelp(parentwin): """ [SA] new help dialog - simple but sufficient; used for 'help' button click, '?' keyboard press, Mac menus; '?' keypress and Mac menus work in both thumb and image windows; """ from helpmessage import showhelp showhelp(parentwin, 'PyPhoto', HelpText, forcetext=False, setwinicon=lambda win: trySetWindowIcon(win, 'icons', 'pygadgets')) #parentwin.focus_force() # now done in helpmessage HelpText = ('PyPhoto 2.1\n' '\n' 'A Python/tkinter/PIL image-viewer GUI.\n' 'For Mac OS, Windows, and Linux.\n' 'From the book Programming Python.\n' 'Author and © M. Lutz 2006-2017.\n' '\n' 'In directory windows:\n' '▶ Key D opens another image directory\n' '▶ Clicking an image\'s thumbnail opens it in an ' 'image-view window at scaled size\n' '\n' 'In image-view windows:\n' '▶ Key D opens another image directory\n' '▶ Keys N/P open the next/prior image\n' '▶ Keys I/O zoom the image in/out\n' '▶ Keys A/S show actual/scaled size\n' '▶ Key W saves (writes) the current image\n' '▶ Leftclick and rightclick resize to screen height ' 'and width, respectively (on Macs, rightclick=two-finger ' 'click or control+click)\n' '\n' 'In both window types, key ?=Help, and the Up/Down ' 'and Left/Right arrow keys scroll vertically and ' 'horizontally, respectively.\n' '\n' 'For W saves, the filename\'s ".xxx" extension gives its ' 'image type. For example, to both shrink a PNG and convert ' 'it to GIF, zoom out with O and save as ".gif" with W.\n' '\n' 'A "_PyPhoto-thumbs.pkl" file is created in each opened image ' 'folder when possible, to store image thumbnails for fast access. ' 'Its thumbs are kept in sync with images.\n' '\n' 'PyPhoto source-code distributions (but not apps or ' 'executables) require installation of the Pillow extension ' 'package from https://pypi.python.org/pypi/Pillow.\n' '\n' 'For downloads and more apps, visit:\n' 'http://learning-python.com/programs.html' ) ############################################################################ # Top-level logic: get configs, get first folder, display thumbnails ############################################################################ if __name__ == '__main__': """ open dir = default or cmdline arg else show simple window to select """ from getConfigs import getConfigs # [SA] new gadgets utility defaults = dict(InitialSize='500x400', # size of dir/thumbs window InitialFolder='images-mixed', # None = ask for dir ViewSize=None, # None = scale to screen NoThumbChanges=False) # True = skip change detection [2.1] configs = getConfigs('PyPhoto', defaults) # load from file or args imgdir = configs.InitialFolder dirwinsize = configs.InitialSize.split('x') # 'WidthxHeight' dirwinsize = [int(x) for x in dirwinsize] # (Width, Height) viewsize = configs.ViewSize viewsize = viewsize.split('x') if viewsize else () # e.g., '800x600' viewsize = list(map(int, viewsize)) # (800, 600) nothumbchanges = configs.NoThumbChanges if imgdir and os.path.exists(imgdir): mainwin = viewThumbs(imgdir, Tk, dirwinsize, viewsize, nothumbchanges=nothumbchanges) else: mainwin = Tk() mainwin.geometry('250x50') mainwin.title(appname + 'Open') handler = lambda: onDirectoryOpen(mainwin, dirwinsize, viewsize, nothumbchanges) Button(mainwin, text='Open Image Directory', command=handler).pack(expand=YES, fill=BOTH) trySetWindowIcon(mainwin, '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=mainwin, appname='PyPhoto', helpaction=lambda: onHelp(mainwin), aboutaction=None, quitaction=mainwin.quit) # immediate, bound method # [SA] reopen auto on dock/app click and fix tk focus loss on deiconify def onReopen(): mainwin.lift() mainwin.update() temp = Toplevel() temp.lower() temp.destroy() mainwin.createcommand('::tk::mac::ReopenApplication', onReopen) mainwin.mainloop()