File: pygadgets-products/unzipped/_PyPhoto/PIL/pyphoto.py

#!/usr/bin/env python3
"""
############################################################################
PyPhoto 2.2: a basic but open-source and portable image viewer,
with scrolling, resizing, saves, and cached thumbnails for speed.

Copyright: 2006-2018 by M. Lutz, https://learning-python.com.
License:   provided freely, but with no warranties of any kind.
Platforms: This program runs on Mac OS, Windows, and Linux
Requires:  Source-code versions (only) require Python 3.X and Pillow
Originally from the book "Programming Python, 4th Edition" by M. Lutz

Release History: 
   2.2, Sep-2018: image auto-rotation, Pillow file/format workarounds.
   2.1, Nov-2017: store a folder's thumbnails in a single pickle file.
   2.0, Sep-2017: standalone release with PyGadgets, numerous changes.
   Earlier versions appeared in the book noted above in 2010 and 2006.
     
============================================================================
OVERVIEW
============================================================================

This Python 3.X desktop-GUI program displays cached image-folder thumbnails 
which open full-size images when clicked; scrolls both images and thumbnail
displays; and provides a variety of viewing functionality.

It supports multiple image-directory thumb windows.  The initial image 
folder is either passed in via user 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 also coded for other programs).
The image viewer automatically resizes and scrolls images too large for 
the screen, and supports scaling to display sides and full-size views.
A limited save option also allows resized images to be saved to files.
See the in-program Help popup for more usage details. 

PyPhoto never modifies your image files in any way.  The only change it
makes to your computer is to add a single file named "_PyPhoto-thumbs.pkl"
to each folder you open in the program.  This file is the folder's 
thumbnails cache; it allows the folder to be opened quickly after its 
first open builds thumbs, and is automatically kept in synch with changes 
in the folder's images, but can be removed freely if PyPhoto is not used.

This program is still something of a placeholder for more photo editing 
ops, but it demonstrates PIL basics well enough to ship and use as is.  
For more Python photo-related tools, see the "thumbspage" web-page gallery 
generator (at learning-python.com/thumbspage.html) as well as the "tagpix" 
photo-folder organizer (at learning-python.com/tagpix.html).

============================================================================
VERSION DETAILS
============================================================================

NEW IN 2.2, Sep-2018: 

   This version was released with a new PyGadgets (all packages), to 
   incorporate two changes drawn from the thumbspage program noted above:

   1) Image auto-rotation

   Automatically rotate images and their thumbnails to display right-side 
   up, if they have "Orientation" Exif tags and tilted content.  This format
   is common for photos shot on smartphones: the natural portrait (vertical)
   device orientation yields an image tilted to the side.  Less commonly, 
   photos shot on physically tilted digital cameras may also be stored in 
   this tilted format.  Without rotation, such images would not display 
   with their top side on top in either thumbnail or full-size form, and 
   PyPhoto has no general rotate operation to manually adjust images.

   Auto-rotation is used both for thumbnails and source images, though only 
   thumbnails record rotation permanently in their files here - source images
   are rotated in memory only, and the original image file is never changed. 
   This differs from the implementation in thumbspage, which inspired the 
   auto-rotation change (learning-python.com/thumbspage.html).  Thumbspage 
   must update image files too, because the viewing browser won't rotate 
   images displayed as in-page elements; PyPhoto can rotate when viewed.

   Users of the prior version should delete their "_PyPhoto-thumbs.pkl"
   cache files in image folders to enable the auto-rotation enhancement.
   Else images will rotate when viewed, but thumbnails will remain askew.

   2) Pillow files auto-close bug workaround

   This version also adds a workaround for a Pillow (PIL) library bug, which
   could trigger too-many-open-files errors when generating many thumbnails 
   in some contexts (most commonly, Mac OS source-code usage).  The thumbspage
   program, from which this work around was borrowed, has additional notes on 
   the bug; see learning-python.com/thumbspage/viewer_thumbs.py.  

   3) Pillow buffer-file format issue workaround

   This version also codes a workaround for an obscure buffer-file format 
   issue in older Pillows, that could impact some source-code users only,
   and is not required in thumbspage; see file viewer_thumbs.py here.

NEW IN 2.1, Nov-2017: 

   This version was released with a new PyGadgets (all packages), to 
   incorporate one major change and a set of minor enhancements:

   1) Thumbnails pickle-file storage

   Store a folder's thumbnails in a single pickle file, instead of individual
   image files in a subfolder as before.  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 (thumbnails
   might not be backed up when their size was changed).  See viewer_thumbs.py 
   for more details.  

   Version 2.0 users: run the included delete-pyphoto2.0-thumbs-folders.py 
   Python script (or its executable), to delete all 2.0 subfolders when 
   upgrading to 2.1; else, the subfolders will be unused and wasted space.

   2) Miscellaneous enhancements
 
   Version 2.1 also now reports thumbnail-save errors in GUI popups; prints 
   all exceptions; and supports a new config-file setting to ignore image 
   modtime-based change tests during thumbnail generation where desirable
   (e.g., to accommodate modtime skew between incompatible filesystems).
   There were also other minor improvements to thumbnail processing (see
   viewer_thumbs.py for details), and images with errors are caught and 
   reported here and their thumbnails are no longer omitted in the GUI. 

NEW IN 2.0, Sep-2017 [SA]: 

   This version was released as part of PyGadgets' first standalone
   release (outside the book), along with PyCalc, PyClock, and PyToe.  
   It incorporated multiple and 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 on views
   - 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 window to upper-left corner if too large for display or partially 
     off-screen, but do not center if smaller than display (a 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 versions appeared as examples in the book "Programming Python."
The current PyPhoto derives from the original in 2010's 4th Edition.

NEW IN 1.1, 2010, PP4E
   Updated to run in Python 3.1 and latest PIL.

NEW IN 1.0, 2006, PP3E
   Now does two flavors of resizing: the 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: images seem to lose quality
   and/or pixels after many resizes (this is probably a limitation of PIL).

============================================================================
SCALING 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

The related thumbspage program (at learning-python.com/thumbspage.html) 
uses an alternative display-fit scaling method which might work here too;
it's coded and run in JavaScript:

    // per smallest-fit side 
    ratio = Math.min(displayWidth / trueWidth, displayHeight / trueHeight);

    if (! stretchBeyondActual) {
        ratio = Math.min(ratio, 1.0);    // don't expand beyond actual size
    }
    return {width:  (trueWidth  * ratio), 
            height: (trueHeight * ratio)};

============================================================================
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 (this project is open ended, and awaits user feedback)

Other TBDs include: 
- Rearrange thumbnails when window resized, based on current window size? 
- [DONE] Resize images to fit display/window size when first opened?
- Avoid scrolls if image size is less than window max size (e.g., use 
  a simple Label if imgwide <= scrwide and imghigh <= scrhigh)? 
- Better fit scaled images to display (it's not a 100% match everywhere)?
- Add filename labels to the thumbs window (as it, it's like a proof sheet)?
- Image cropping, annotation, drawing, ... (this gets silly at some point)?

############################################################################
"""

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

# [2.2] auto-rotation of tilted images, avoid Pillow too-many-open-files bug
from viewer_thumbs import reorientImage, openImageSafely

# [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.2'


############################################################################
# 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:
            # load img object to be reused by ops
            imgpil = openImageSafely(imgpath)     # [2.2] avoid pillow files bug
            imgpil.load()                         # [2.1] load now so errors here
            imgpil = reorientImage(imgpil)        # [2.2] right-side up, iff needed 
        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('<Button-1>', self.onSizeToDisplayHeight)
        self.canvas.bind('<Button-3>', self.onSizeToDisplayWidth)
        if RunningOnMac:
            self.canvas.bind('<Button-2>',  self.onSizeToDisplayWidth)   # [SA]
            self.bind('<Control-Button-1>', self.onSizeToDisplayWidth)

        self.bind('<KeyPress-i>', self.onZoomIn)
        self.bind('<KeyPress-o>', self.onZoomOut)
        self.bind('<KeyPress-w>', self.onSaveImage)
        self.bind('<KeyPress-d>', 
            lambda event: onDirectoryOpen(self, dirwinsize, viewsize, nothumbchanges))

        # [SA] question=? but portable, help key in all gadgets
        self.bind('<KeyPress-question>', lambda event: onHelp(self))
 
        # [SA] bind arrow keys to scroll canvas too (tbd: increment?)
        self.bind('<Up>',    lambda event: self.canvas.yview_scroll(-1, 'units'))
        self.bind('<Down>',  lambda event: self.canvas.yview_scroll(+1, 'units'))
        self.bind('<Left>',  lambda event: self.canvas.xview_scroll(-1, 'units'))
        self.bind('<Right>', lambda event: self.canvas.xview_scroll(+1, 'units'))

        # [SA] add next/prior image in this image's folder
        self.bind('<KeyPress-n>', self.onNextImage)
        self.bind('<KeyPress-p>', self.onPrevImage)
        self.imgdir, self.imgfile, self.dirwinsize = imgdir, imgfile, dirwinsize

        # [SA] add actual/scaled size (actual=former version's only mode)
        self.bind('<KeyPress-a>', lambda event: self.drawImageSized(self.trueimage))
        self.bind('<KeyPress-s>', 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:
            # open image object to be reused
            nextimgpil = openImageSafely(imgpath)     # [2.2] avoid Pillow files bug
            nextimgpil.load()                         # [2.1] load now so errors here
            nextimgpil = reorientImage(nextimgpil)    # [2.2] right-side up, iff needed 
        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('<KeyPress-question>', 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('<KeyPress-d>', 
        lambda event: onDirectoryOpen(win, dirwinsize, viewsize, nothumbchanges))

    # [SA] bind arrow keys to scroll canvas too (tbd: increment?)
    win.bind('<Up>',    lambda event: canvas.yview_scroll(-1, 'units'))
    win.bind('<Down>',  lambda event: canvas.yview_scroll(+1, 'units'))
    win.bind('<Left>',  lambda event: canvas.xview_scroll(-1, 'units'))
    win.bind('<Right>', 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.2\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-2018.\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()



[Home page] Books Code Blog Python Author Train Find ©M.Lutz