#!/usr/bin/python3
"""
=====================================================================================
frigcal.py (main script) - A basic refrigerator-style calendar desktop GUI.
Uses Python 3.X, tkinter, and portable iCalendar ".ics" files to store event data.

Run this file to start the program; it uses other Python modules here and in folders.
frigcal can also be started by running "frigcal-launcher.pyw", which suppresses the
console window on Windows, and on all platforms issues a popup while files load.

Code structure: classes here for month windows and dialogs, ics file tools in module.
Most of the code is in this single file, to simplify searches (arguably, at least).

See UserGuide.html for all documentation: license, release, and usage details.
=====================================================================================
"""

print('Welcome to frigcal.')


# Python standard library
# [2.0] for standard dialogs on Mac OS X, use parent=window for slide-down,
# and focus_force() for refocus; custom dialogs are okay because transient()

import os, sys, calendar, datetime, time, traceback, webbrowser, mimetypes
from tkinter import *
from tkinter.messagebox import askokcancel, askyesno, showerror, showwarning
from tkinter.scrolledtext import ScrolledText

# for platform-specific choices
RunningOnMac     = sys.platform.startswith('darwin')       # all OS (X)
RunningOnWindows = sys.platform.startswith('win')          # all Windows
RunningOnLinux   = sys.platform.startswith('linux')        # all Linux


# 3rd-party: required for jpeg display (else just gifs, and png in Tk8.6+ (Py3.4+?) [1.6])

pillowwarning = """
Pillow 3rd-party package is not installed.
...This package is optional, but required for the month images feature when
...using some image file types and Pythons (though not for PNGs with Pythons
...that use Tk 8.6 or later, including standard Windows installs of Python 3.4+).
...Details - http://learning-python.com/books/python-changes-2014-plus.html#s359.
...To fetch Pillow - https://pypi.python.org/pypi/Pillow.
"""

try:
    from PIL.ImageTk import PhotoImage   # replace tkinter's version
except ImportError:
    print(pillowwarning)
    # but continue, and no popup yet (Image option will report any load errors in GUI)
    # [1.6] if no PIL, falls back on Tk/tkinter's native PhotoImage, for PNGs, GIFs, etc.


# [2.0] for frozen app/exes, fix module+resource visibility (sys.path)
import fixfrozenpaths

# local: ics files interface - init, parse, backup, save, update
import icsfiletools     # 3rd-party icalendar pkg required and used by this

# local: names used in both frigcal script and icsfiletools (avoid redundant code)
from sharednames import Configs, trace, PROGRAM, VERSION, startuperror

# local: a tkinter extension borrowed from Programming Python 4th Ed
from scrolledlist import ScrolledList

# local: part of PP4E's guimaker module, copied here to avoid dependency [2.0]
from guimaker_pp4e import fixAppleMenuBar

# more constants (others in sharednames)
PROTO    = False    # True = run initial prototype (now defunct)
MAXWEEKS = 6        # always show max poss size for simplicity (and visual clarity!)


# [2.0] data not in os.getcwd() if run from a cmdline elsewhere, and 
# __file__ may not work if running as a frozen PyInstaller executable;
# use __file__ of this file for Mac apps, not module: it's in a zipfile;

MYDIR    = fixfrozenpaths.fetchMyInstallDir(__file__)   # absolute
HELPFILE = os.path.join(MYDIR, 'UserGuide.html')

# [2.0] Mac OS X is pickier about file URLs
if RunningOnMac:
    HELPFILE = 'file:' + HELPFILE

# [2.0] ensure running in script's folder for relative calendars, images,
# and icon pathnames: may have been launched from elsewhere via cmdline
# there are no possibly-relative command-line arguments to this script

os.chdir(MYDIR)


# globals, now mostly defunct: originally coded as simple funtions with globals,
# but that grew unmanageable at around 1K lines - classes provide much needed
# structure, and can support multiple month displays, a later addition (Clone);

# [MonthWindow()]: month windows open, >1 if Clone, for tandem moves and updates
OpenMonthWindows = []

# one Eventdata(), global to support cut/copy in one window and paste in another
CopiedEvent = None

# main data structures: parsed and indexed file data, used but not changed here
from icsfiletools import CalendarsTable     # {icsfilename: icalendar.Calendar()}
from icsfiletools import EventsTable        # {Edate(): {uid: EventData()} ]
from icsfiletools import CalendarsDirty     # {icsfilename: Boolean]

# data structure classes in EventsTable (see icsfiletools.py)
from icsfiletools import Edate, EventData


#====================================================================================
# Utility functions (multiple class clients)
#====================================================================================


# changeable defaults
BG_DEFAULT = 'white'
FG_DEFAULT = 'black'
FONT_DEFAULT = ('arial', 9, 'normal')


def configerrormsg(kind, value):
    """
    [1.7] factor this to common code (now too many copies)
    """
    print('Error in %s setting: %s - default used' % (kind, ascii(value)))
    print('Python error text follows:\n', '-' * 40)
    traceback.print_exc()
    print('-' * 40)


def tryfontconfig(widget, font):
    """
    don't fail and/or exit on bad configuration file settings - report and use
    a default font; this matters, because configs are user-edited Python module;
    """
    if font != None:   # None=tk default
        try:
            widget.config(font=font)
        except:
            widget.config(font=FONT_DEFAULT)
            configerrormsg('font', font)
            
    
def trybgconfig(widget, bg):
    """
    don't fail and/or exit on bad configuration file settings - report and use
    a default color; this matters, because configs are user-edited Python module;
    """
    if bg != None:   # None=tk default
        try:
            widget.config(bg=bg)
        except:
            widget.config(bg=BG_DEFAULT)
            configerrormsg('bg color', bg)


def tryfgconfig(widget, fg):
    """
    [1.7] added for bg + *fg* event text configuration, when color=('bg', 'fg');
    caveat: can leave black on black if black bg worked, but it's an error case; 
    """
    if fg != None:   # None=tk default
        try:
            widget.config(fg=fg)
        except:
            widget.config(fg=FG_DEFAULT)
            configerrormsg('fg color', fg)


def trybgitemconfig(listbox, index, bg):
    """
    [1.3] same, but item in new selection listbox, not an entry field
    """
    #[2.0] trace('trybgitemconfig', index, bg, listbox) 
    if bg != None:   # None=tk default
        try:
            listbox.itemconfig(index, bg=bg)
        except:
            listbox.itemconfig(index, bg=BG_DEFAULT)
            configerrormsg('bg color', bg)
            

def tryfgitemconfig(listbox, index, fg):
    """
    [1.7] added for bg + *fg* event text configuration, when color=('bg', 'fg');
    caveat: can leave black on black if black bg worked, but it's an error case;
    """
    #[2.0] trace('tryfgitemconfig', index, fg, listbox) 
    if fg != None:   # None=tk default
        try:
            listbox.itemconfig(index, fg=fg)
        except:
            listbox.itemconfig(index, fg=FG_DEFAULT)
            configerrormsg('fg color', fg)


def try_set_window_icon(window, iconname='frigcal'):
    """
    [1.2] replace a Tk() or Toplevel() window's generic Tk icon with a custom
    icon for this program;  this works on Windows (only?), and doesn't crash
    elsewhere;  applied to main window and all popup windows, including clones; 
    TBD: generalize for Linux, Macs -- this has always been platform-dependent;
    [1.6] use Tk 8.5+'s iconphoto() to set icon on Linux only (app bar icon);
    [2.0] recoded to rule out Mac explicitly, else a generic icon shows up;
    """
    icondir = 'icons'
    iconname += '.ico' if RunningOnWindows else '.gif'
    iconpath = os.path.join(icondir, iconname)
    try:
        if RunningOnWindows:
            # Windows (only?), all contexts
            window.iconbitmap(iconpath)
            
        elif RunningOnLinux:
            # Linux (only?), Tk 8.5+, app bar [1.6]
            imgobj = PhotoImage(file=iconpath)
            window.iconphoto(True, imgobj)
            
        elif RunningOnMac or True:
            # Mac OS X: neither of the above work [2.0]
            # on Macs, apps are required for most icon contexts
            raise NotImplementedError

    except Exception as why:
        pass   # bad file or platform


def fixTkBMP(text):
    """
    [2.0] (copied from PyMailGUI) Tk <= 8.6 cannot display Unicode characters
    outside the U+0000..U+FFFF BMP (UCS-2) code-point range, and generates
    uncaught exceptions when tried (emojis kill programs!).  To address this,
    call this function to sanitize all text passed to the GUI for display.
    It replaces any non-BMP characters with the standard Unicode replacement
    character U+FFFD, which Tk displays as a highlighted question mark diamond.
    
    This workaround is coded to assume that Tk 8.7 will lift the BMP restriction,
    per a dev rumor.  It also assumes TkVersion has been imported from tkinter.
    Use here: display calendar data created in other programs (rare, but true).
    Caveat: editing and saving such data will lose the characters thus replaced,
    though only in summary and description fields (others retain original text).
    Note: also must avoid Unicode in print() text as may fail on some consoles.
    """
    if TkVersion <= 8.6:
        text = ''.join((ch if ord(ch) <= 0xFFFF else '\uFFFD') for ch in text)
    return text 


#====================================================================================
# Month display window: main and clones
#====================================================================================

class MonthWindow:
    """
    the main display, with its state and callback handlers:
    - created by main() and Clone button, kept on OpenMonthWindows;
    - uses local ViewDateManager object to manage viewed date and days list;
    - creates local EventDialog subclass dialogs on user actions and pastes;
    - uses CalendarsTable and EventsTable globals, created by ics files parser;
    - subclassed to customize onQuit for popup Clone windows to close silently;
    """

    def __init__(self, root, startdate=None, windowtype='Main'):

        # window's state informaton
        self.root = root               # the Tk (or a Toplevel) main window, with root.bind
        self.monthlabel = None         # monthname label, for refills on navigation
        self.daywidgets = []           # [(dayframe, daynumlabel)], all displayed, for refills
        self.eventwidgets = {}         # {uid: evententry}, all displayed, for update/delete, refill
        self.tandemvar = None          # if get(), all windows respond to any prev/next navigate

        # set up current view date data
        self.viewdate = ViewDateManager()    # displayed month date and day-numbers list manager
        self.viewdate.settoday()             # initialize date object and days list to current date
        if startdate:
            self.viewdate.setdate('%s/%s/%4s' % startdate.mdy())

        # more options state information
        self.imgfiles = None                              # loaded month image file names [1.5]
        self.imgwin = self.imglab = self.imgobj = None    # for month images option only
        self.footerframe = self.footertext = None         # for optional footer text fill/toggle

        # build the window, register callbacks
        self.make_widgets(root, windowtype)
        self.fill_days()                        # make_widgets sets day callbacks once at build
        self.fill_events()                      # fill_event sets event callbacks on each refill
        OpenMonthWindows.append(self)           # global list of open windows for updates, tandem


    #------------------------------------------------------------------------------------
    # GUI builder
    #------------------------------------------------------------------------------------

    def make_widgets(self, root, windowtype):
        """
        build the calendar's month display, attached to root, retain month/days widgets;
        sets up day-related callback handlers for day widgets here, once, at build time; 
        """

        #++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
        # WINDOW: title and color, close button, sizes, position, icon 
        #++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
        
        root.title('%s %.1f - %s' % (PROGRAM, VERSION, windowtype))
        trybgconfig(root, Configs.rootbg)

        # close button = backup/save ics file, only now and only if confirmed
        root.protocol('WM_DELETE_WINDOW', self.onQuit)
        
        # initial and minimum sizes, None=auto/none (see config file)
        if Configs.initwinsize:
            initsize = Configs.initwinsize
            if isinstance(initsize, str) and 'x' in initsize:
                # 'WxH' = 'intxint' = absolute pixel size ('WxH+X+Y' adds position)
                root.geometry(Configs.initwinsize)

            elif isinstance(initsize, float) and initsize <= 1.0:
                # float = % screen size
                scrwide = root.winfo_screenwidth()    # full screen size, in pixels
                scrhigh = root.winfo_screenheight()   # ditto (e.g., 1920, 1080)
                root.geometry('%dx%d' % (scrwide * initsize, scrhigh * initsize))

            elif isinstance(initsize, tuple):
                # (float, float) = (% screen wide, % screen high)
                scrwide = root.winfo_screenwidth()    # full screen size, in pixels
                scrhigh = root.winfo_screenheight()
                root.geometry('%dx%d' % (scrwide * initsize[0], scrhigh * initsize[1]))

            else:
                print('Bad initwinsize setting %s - ignored' % ascii(initsize))

        # minimum size: e.g., else some widgets may vanish if window shrunk
        if Configs.minwinsize:
            root.minsize(*Configs.minwinsize.split('x'))   # width, height
        
        # start position for all month windows (or at end of initwinsize) [1.2]
        # can be set separately and regardless of any prior geometry() calls
        if Configs.initwinposition: 
            root.geometry(Configs.initwinposition)         # '+X+Y' offset from top left
        
        # replace red (no, blue...) tk window icon if possible [1.2]
        try_set_window_icon(root)

        #----------------------------------------------------------------------------------
        # [1.4] minimize/restore image window with its month window, if enabled;
        # this treats an image window as a dependent extension to its month window;
        # subtle: tk issues hides/unhides during resizes too--must skip these for
        # widgets other than the month window itself (else resizes hide/unhide image);
        #
        # [1.5] on unhide, use focus_set to focus on month, not image, for keyboard
        # users, else requires a click to activate (e.g., for Esc); focus_set also lifts;
        #
        # [1.6] Caveat: Linux doesn't fire <Unmap>/<Map> events on minimize/restore
        # (and ditto for <configure>), so there is no good way to make this work on Linux;
        # must use withdraw() on Linux to restore later with deiconify(), but this seems
        # a moot point given the events issue; withdraw() also works on Windows, but the
        # image does not then appear in the taskbar with the month (TBD: preference?);
        #
        # [2.0] Update: Mac OS X correctly hides/unhides image windows with their month
        # windows using the code here, just like Windows (Linux is the only exception);
        # nits: on Mac (only), must call lift() after focus_set() or else the month window
        # must be clicked to raise it above image; either way, the month window must still
        # be clicked to restore its active-window styling when deiconified, but this is a
        # general Mac Tk issue (really, bug: see __main__ comment below) for all windows;
        # at least with image unhides, this requires just 1 click, not a click elsewhere;
        # UPDATE: focus_force() now sets month-window active styling without a user click;
        # UPDATE: see also __main__ logic that refocuses window when deiconified on Macs;
        #----------------------------------------------------------------------------------

        def onMonthHide(tkevent):
            if tkevent.widget == self.root:               # skip nested widget events
                trace('Got month hide')                   # self is in-scope here
                if self.imgwin:                           # iff img enabled/open
                    if RunningOnLinux:                    # but no <Unmap>/<Map> on Linux!
                        self.imgwin.withdraw()            # [1.6] works on Windows+Linux        
                    else:
                        self.imgwin.iconify()             # but then Linux can't deiconify!
                #self.root.iconify()                      # not root: tk does auto

        def onMonthUnhide(tkevent):
            if tkevent.widget == self.root:               # skip nested widget events
                trace('Got month unhide')                 # self is in-scope here
                if self.imgwin:                           # iff img enabled/open
                    self.imgwin.deiconify()               # open first=under (maybe)
                self.root.focus_set()                     # [1.5] month window focus+lift         
                #self.root.deiconify()                    # not root: tk does auto
                if RunningOnMac:                          # focus_set raises month above img
                    self.root.lift()                      # [2.0] but not on the Mac! - call
                    self.root.focus_force()               # [2.0] and activate without click

        root.bind('<Unmap>', onMonthHide)     # month minimize: image too
        root.bind('<Map>',   onMonthUnhide)   # month restore:  image too


        #++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
        # TOP: GoTo entry/button, Footer+Images toggles, Tandem/Clone, month+day names, help
        #++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
        
        datefrm = Frame(root)
        datefrm.pack(side=TOP, fill=X)
        trybgconfig(datefrm, Configs.rootbg)

        dateent = Entry(datefrm)
        dateent.insert(END, 'mm/dd/yyyy')
        dateent.pack(side=LEFT)
        dateent.bind('<Return>', lambda e: self.onGoToDate(dateent))  # not Enter: mousein

        datebtn = Button(datefrm, text='GoTo', relief=RIDGE,          # ridge for all [1.2]
                         command=lambda: self.onGoToDate(dateent))
        datebtn.pack(side=LEFT)

        tryfontconfig(dateent, Configs.controlsfont)
        tryfontconfig(datebtn, Configs.controlsfont)

        # help='?': pop up the html help file in a web browser [1.2]
        helpbtn = Button(datefrm, text='?', relief=RIDGE,
                         command=lambda: webbrowser.open(HELPFILE))
        helpbtn.pack(side=RIGHT)
        tryfontconfig(helpbtn, Configs.controlsfont)

        # [2.0] a single '?' is almost too small to click on Mac OS X (only)
        if RunningOnMac:
            helpbtn.config(text=' ? ')

        spacer = Label(datefrm, text='')
        spacer.pack(side=RIGHT)
        trybgconfig(spacer, Configs.rootbg)

        # option checkbuttons, tandem clones checkbuton, and Clone
        clonebtn = Button(datefrm, text='Clone', relief=RIDGE, command=self.onClone)
        clonebtn.pack(side=RIGHT)

        tndvar = IntVar()
        tndtoggle = Checkbutton(datefrm, text='Tandem', relief=RIDGE,
                        variable=tndvar, command=lambda: self.onTandemFlip(tndvar))
        tndtoggle.pack(side=RIGHT)

        tryfontconfig(clonebtn,  Configs.controlsfont)
        tryfontconfig(tndtoggle, Configs.controlsfont)

        if OpenMonthWindows:
            # pick up current tandem setting from first, if others open
            # possible alternative: use a single, global, shared IntVar
            tndvar.set(OpenMonthWindows[0].tandemvar.get())

        # the next two toggles apply to this window only
        spacer = Label(datefrm, text='', )
        spacer.pack(side=RIGHT)
        trybgconfig(spacer, Configs.rootbg)
        
        imgvar = IntVar()
        imgtoggle = Checkbutton(datefrm, text='Images', relief=RIDGE,
            variable=imgvar, command=lambda: self.onImageFlip(imgvar))
        imgtoggle.pack(side=RIGHT)
        
        ftrvar = IntVar()
        ftrtoggle = Checkbutton(datefrm, text='Footer', relief=RIDGE,
            variable=ftrvar, command=lambda: self.onFooterFlip(ftrvar))
        ftrtoggle.pack(side=RIGHT)

        tryfontconfig(imgtoggle, Configs.controlsfont)
        tryfontconfig(ftrtoggle, Configs.controlsfont)
            
        # month name and year (on datefrm not root), day names row
        monthlabel = Label(datefrm, text='Month YYYY', font=('times', 12, 'bold italic'), fg='white')
        monthlabel.pack(side=TOP)
        trybgconfig(monthlabel,   Configs.rootbg)
        tryfontconfig(monthlabel, Configs.monthnamefont)
        
        daynames = Frame(root)
        daynames.pack(side=TOP, fill=X)
        trybgconfig(daynames, Configs.rootbg)

        days = ['Sun', 'Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat']
        for dayname in days:
            dayname = Label(daynames, text=dayname, fg='white')
            dayname.pack(side=LEFT, expand=YES)
            trybgconfig(dayname,   Configs.rootbg)
            tryfontconfig(dayname, Configs.daynamefont)


        #++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
        # BOTTOM: mo/yr navigation buttons = keys (pack first = clip last!: retain on resizes)
        # when enabled, the Footer shows up above these and below the middle days grid
        #++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

        toolbar = Frame(root)
        toolbar.pack(side=BOTTOM, fill=X)
        trybgconfig(toolbar, Configs.rootbg)

        toolbtns = [('PrevYr', LEFT,  self.onPrevYearButton),     # packing order matters
                    ('NextYr', LEFT,  self.onNextYearButton),     # expand=YES to space
                    ('NextMo', RIGHT, self.onNextMonthButton),
                    ('PrevMo', RIGHT, self.onPrevMonthButton),    # expand=YES to space   
                    ('Today',  TOP,   self.onTodayButton)]        # today shows up in middle

        for (text, side, handler) in toolbtns:
            btn = Button(toolbar, text=text, relief=RIDGE, command=handler)
            btn.pack(side=(side or TOP))
            tryfontconfig(btn, Configs.controlsfont)

        # keys = mo/yr navigation buttons (with extra event arg)
        # these used to be <Left>/<Right>, but then not usable to edit summary text!
        # map to more descriptive callback names of buttons, not vice-versa [1.3]
        root.bind('<Up>',         lambda tkevent: self.onPrevMonthButton())    
        root.bind('<Down>',       lambda tkevent: self.onNextMonthButton()) 
        root.bind('<Shift-Up>',   lambda tkevent: self.onPrevYearButton())    # Shift + arrow
        root.bind('<Shift-Down>', lambda tkevent: self.onNextYearButton())
        root.bind('<Escape>',     lambda tkevent: self.onTodayButton())       # [1.5] Esc=Today


        #+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
        # MIDDLE: expandable month of [weeks of days] (pack last = clip first!)
        #+++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++

        alldaysfrm = Frame(root)
        alldaysfrm.pack(side=TOP, expand=YES, fill=BOTH)
        daywidgets = []
        for week in range(MAXWEEKS):
            for day in range(7):
                reldaynum = (week * 7) + day
                dayfrm = Frame(alldaysfrm, border=2, relief=RAISED)      # not Label! (an early bug)
                dayfrm.grid(row=week, column=day, stick=NSEW)
                daylab = Label(dayfrm, text=str(reldaynum))              # initial value, reset later
                daylab.pack(side=TOP, fill=X)                            # events entries added later

                trybgconfig(dayfrm,   Configs.daysbg)
                trybgconfig(daylab,   Configs.daysbg)
                tryfontconfig(daylab, Configs.daysfont)

                self.register_day_actions(dayfrm, daylab, reldaynum)     # once, when window built
                daywidgets.append((dayfrm, daylab))                      # save for gui updates

        # all same resize priority, uniform size groups
        for week in range(MAXWEEKS):
            alldaysfrm.rowconfigure(week, weight=1, uniform='a')
        for day in range(7):
            alldaysfrm.columnconfigure(day, weight=1, uniform='a')

        # save state for callbacks
        self.monthlabel, self.daywidgets, self.tandemvar = monthlabel, daywidgets, tndvar


    def register_day_actions(self, dayfrm, daylab, reldaynum):
        """
        day events registered once on month window build, for both num and frame;
        event events registered later in fill_events, and on each navigation;
        don't need var=var defaults in lambdas: not using var in loop's scope here!
        
        single or double left-click (press) on day = open add event dialog for day;
        single-right-click (press+hold) on day = paste cut/copy event on day;
        both events ignored later if click on day not in current viewed month;
        use double-click for mouse mode left-click: more natural and same as events;

        [1.3] specialize day number to open listbox of all events in day, in case
        there are too many to display in the day's widget of the month window; the
        listbox is modal to avoid the need to update potentially many, and includes
        a 'create' button for adding a new event on this day via the Create dialog
        as a fallback option, just like a click on the day's background in general;
        """
        # daylab.config(border=1)   # or should this be a button? see callback
        
        # day left-clicks: differ
        if Configs.clickmode == 'touch':
            # single: open create-event or select-event [1.3] dialogs (moves shade)
            dayfrm.bind('<Button-1>', lambda e: self.onLeftClick_Day__Create(reldaynum))
            daylab.bind('<Button-1>', lambda e: self.onLeftClick_DayNum__Select(reldaynum))

        elif Configs.clickmode == 'mouse':
            # single: move current day shade only [1.2]
            dayfrm.bind('<Button-1>', lambda e: self.set_and_shade_rel_day(reldaynum))
            daylab.bind('<Button-1>', lambda e: self.set_and_shade_rel_day(reldaynum))

            # double: open create-event or select-event [1.3] dialogs (moves shade)
            dayfrm.bind('<Double-1>', lambda e: self.onLeftClick_Day__Create(reldaynum))
            daylab.bind('<Double-1>', lambda e: self.onLeftClick_DayNum__Select(reldaynum))
       
        # single right, day and daynum, both modes: paste via prefilled create dialog
        dayfrm.bind('<Button-3>', lambda e: self.onRightClick_Day__Paste(reldaynum))
        daylab.bind('<Button-3>', lambda e: self.onRightClick_Day__Paste(reldaynum))

        # [2.0] on Mac OS X, also allow Control-click as an equivalent for right-click,
        # and support Mac mice that trigger Button-2 on right button click (on Macs,
        # right=Button-2 and middle=Button-3; it's the opposite on Windows and Linux!)
        
        if RunningOnMac:
            dayfrm.bind('<Control-Button-1>', lambda e: self.onRightClick_Day__Paste(reldaynum))
            daylab.bind('<Control-Button-1>', lambda e: self.onRightClick_Day__Paste(reldaynum))

            dayfrm.bind('<Button-2>', lambda e: self.onRightClick_Day__Paste(reldaynum))
            daylab.bind('<Button-2>', lambda e: self.onRightClick_Day__Paste(reldaynum))            


    #------------------------------------------------------------------------------------
    # GUI content filler: days
    #------------------------------------------------------------------------------------

    def fill_days(self, prototype=PROTO):
        """
        given window's viewdate, fill calendar's month name and day numbers;
        maps relative day grid indexes to true day numbers received from stdlib;
        doesn't register day widget callbacks: done at build time for reldaynum;
        """
        if prototype:
            # show mocked-up month (defunct)
            self.monthlabel.config(text='Somemonth 2014')
            for (count, (dayframe, daynumlabel)) in enumerate(self.daywidgets):
                daynumlabel.config(text=str(count))

        else:
            # fill-in month for current view date
            # day click events already registered in make_widgets

            # reset all days' colors
            for (dayframe, daynumlabel) in self.daywidgets:
                self.colorize_day(dayframe)
                self.colorize_day(daynumlabel)

            # set month name at top
            moname = calendar.month_name[self.viewdate.month()]
            motext = '%s %s' % (moname, self.viewdate.year())
            self.monthlabel.config(text=motext)

            # set true day numbers, erase nondays            
            numsandwidgets = zip(self.viewdate.currdays, self.daywidgets)
            for (daynum, (dayframe, daynumlabel)) in numsandwidgets:
                if not self.viewdate.trueday_is_in_month(daynum):
                    dayframe.config(bg='black')
                    daynumlabel.config(bg='black')
                else:
                    daynumlabel.config(text=str(daynum))

            # shade current day of this window
            self.prior_shaded_day = None
            self.shade_current_day()


    def colorize_day(self, widget):
        # TBD: default isn't clear: require a config setting?
        if Configs.daysbg:
            trybgconfig(widget, Configs.daysbg)          # user choice first?
        else:
            try:
                widget.config(bg='SystemButtonFace')     # default on Win+Mac; others?
            except:
                widget.config(bg=Configs.GRAY)           # else a reasonable default? [1.6]


    def shade_current_day(self, shadecolor=Configs.currentdaycolor):
        """
        called by fill_days (create/navigate), and after any day/event click;
        for window-specific day only (even if other windows on same month);
        [1.6] allow shade color config (was 'gray' that changed in Tk 8.6);
        """
        # unshade prior shaded day frame
        if self.prior_shaded_day:
            self.colorize_day(self.prior_shaded_day)
        
        # shade frame for new/current day of this month
        reldaynum = self.viewdate.day_to_index(self.viewdate.day())
        thisdayframe, thisdaynumlabel = self.daywidgets[reldaynum]
        thisdayframe.config(bg=shadecolor or Configs.GRAY)   # default if not set
        self.prior_shaded_day = thisdayframe


    def set_and_shade_day(self, truedaynum):
        """
        on day and event left/right clicks: move current day shading;
        daynum is true day, not index (event clicks have true only);
        """
        self.viewdate.setday(truedaynum)
        self.shade_current_day()


    def set_and_shade_rel_day(self, reldaynum):
        """
        on day single-left-click in 'mouse' mode [1.2];
        may be set > once on double-clicks, but harmless,
        and onLeftClick_Day/DayNum also used by 'touch'
        mode single-clicks and wouldn't trigger this auto;
        """
        if self.viewdate.relday_is_in_month(reldaynum):        # a true day in displayed month?
            trueday = self.viewdate.index_to_day(reldaynum)    # convert to actual day number
            self.set_and_shade_day(trueday)


    #------------------------------------------------------------------------------------
    # GUI content filler: events
    #------------------------------------------------------------------------------------

    def fill_events(self, prototype=PROTO):
        """
        given month+year of viewdate, fill calendar's days with any/all events' labels;
        the events table has the union of all calendars' events, indexed by true date;
        sets up event-related callback handlers for event widgets here, on each refill; 
        """
        # erase month's current displayed event entry widgets from day frames
        for efld in self.eventwidgets.values():    
            efld.destroy()                           # pack_forget() retains memory
        self.eventwidgets = {}

        if prototype:
            # show mocked-up event labels
            prototype_events(self.daywidgets)
            return  # minimize indents
        
        # fill-in events from ics file data
        monthnum = self.viewdate.month()                               # displayed month
        yearnum  = self.viewdate.year()                                # displayed year
        numsandwidgets = zip(self.viewdate.currdays, self.daywidgets)

        for (daynum, (dayframe, daynumlabel)) in numsandwidgets:       # for all days/labels displayed
            if self.viewdate.trueday_is_in_month(daynum):              # a real day in this month (or 0)? 
                edate = Edate(monthnum, daynum, yearnum)               # make true date of dayframe
                if edate in EventsTable.keys():                        # any events for this day?

                    dayeventsdict = EventsTable[edate]                 # events on this date (uid table) 
                    dayeventslist = list(dayeventsdict.values())       # day's event object (all calendars)
                    dayeventslist.sort(
                               key=lambda d: (d.calendar, d.orderby))  # order for gui by calendar + creation 

                    for icsdata in dayeventslist:                      # for all ordered events in this day
                        # continue in separate method
                        self.add_event_entry(dayframe, edate, icsdata)


    def add_event_entry(self, dayframe, edate, icsdata):
        """
        for one event: create summary entry, register its event handlers;
        separate (but not static) so can reuse for event edit dialog's Add;

        Nov15: @staticmethod not required here, as this method always needs a
        self (MonthWindow) argument, regardless of how and where it's called;
        """
        # add editable summary text to day frame
        efld = Entry(dayframe, relief=RIDGE)         # no color yet
        efld.pack(side=TOP, fill=X) 
        tryfontconfig(efld, Configs.daysfont)
        efld.insert(0, fixTkBMP(icsdata.summary))    # [2.0] Unicode replace

        # [2.0] Mac OS X adds too much extra space around event entries
        if RunningOnMac:
            #efld.config(borderwidth=2)
            efld.config(highlightthickness=0)

        # colorize field: category overrides calendar
        category, calendar = icsdata.category, icsdata.calendar
        self.colorize_event(efld, category, calendar)

        # event-specific and footer-related actions: mouse/kb or touch
        self.register_event_actions(efld, edate, icsdata)

        # save for erase on delete, cut, navigate
        self.eventwidgets[icsdata.uid] = efld  


    @staticmethod
    def colorize_event(entry, category, calendar):
        """
        set one event's summary color per config file tables;
        category overrides calendar (and category '' = all other, despite calendar);
        static and separate so can reuse for event edit dialog's Update (category change);
        [1.7] add foreground color configuration when color is a tuple (str still means bg);

        in 3.X, @staticmethod is optional if called through class only (and never through
        self), but the decorator helps make the method's external visibility more explicit;
        statics simply supress self for through-instance calls: they are not c++ "public",
        but support method calls with no instance argument from same or other classes; 
        """
        color = MonthWindow.pick_event_color(category, calendar)   # no self to pass here
        if isinstance(color, str):
            trybgconfig(entry, color)                              # color='bg' [None=>dflt]
            tryfgconfig(entry, FG_DEFAULT)                         # reset to dflt if changed 
        elif isinstance(color, tuple):
            trybgconfig(entry, color[0])                           # color=('bg', 'fg') [1.7]
            tryfgconfig(entry, color[1])
        else:
            print('Warning: color setting: %s is not str=bg or tuple=(bg, fg)' % ascii(color))
            trybgconfig(entry, color)                              # use common error handler
            tryfgconfig(entry, FG_DEFAULT)


    def colorize_listitem(self, listbox, index, category, calendar):
        """
        [1.3] set one list item's summary color per config file tables;
        [1.7] add foreground color configuration when color is a tuple (str still means bg);
        """
        color = self.pick_event_color(category, calendar)          # use self if there is one
        if isinstance(color, str):
            trybgitemconfig(listbox, index, color)                 # color='bg' [None=>dflt]
            tryfgitemconfig(listbox, index, FG_DEFAULT)            # reset to dflt if changed
        elif isinstance(color, tuple):
            trybgitemconfig(listbox, index, color[0])              # color=('bg', 'fg') [1.7]
            tryfgitemconfig(listbox, index, color[1])
        else:
            print('Warning: color setting: %s is not str=bg or tuple=(bg, fg)' % ascii(color))
            trybgitemconfig(listbox, index, color)                 # use common error handler
            tryfgitemconfig(listbox, index, FG_DEFAULT)


    @staticmethod
    def pick_event_color(category, calendar):
        """
        [1.3] select color for event, by category or then calendar;
        factored out because now also needed for selection list items;
        this must be static because colorize_event caller is: no self;
        False value or non-match to categories or calendars => default;
        """
        color = None                                             # None=Tk default? (defunct)
        catkeys   = list(Configs.category_colors.keys())         # need list() for poss .index()
        catvalues = list(Configs.category_colors.values())       # need list() for poss []

        if Configs.category_ignorecase:
            # neutralize case in both
            category = category.lower()                          # or .caseless()=.lower()+Unicode
            catkeys  = [catname.lower() for catname in catkeys]
            
        if category in catkeys:                                  # 'in' works on list or iterable
            color = catvalues[catkeys.index(category)]           # list() required for both here
        else:
            # must match filename case
            if calendar in Configs.calendar_colors:
                 color = Configs.calendar_colors[calendar]       # this is a dict key index
                 
        return color or BG_DEFAULT   # default if no category/calendar match (str = bg only)
        

    def register_event_actions(self, efld, edate, icsdata):
        """"
        register mouse-mode or touch-mode actions on event entry display;
        day events are registered once at gui build time by make_widgets;
        don't need var=var defaults in lambdas here: not using a var in loop's scope! 

        in mouse mode: <Button-1> event single left-click or press = built-in
        focus for edit (and hover-in if touch), and <Return> performs the update;

        in touch mode: <Double-1> double left-click unusable - single-click run
        first and its dialog precludes doubles; could time clicks, but overkill;

        in both modes: paste is via right-click on day, not event, and don't clear
        Footer on mouse <Leave> - some text may require later scrolling
        """
        if Configs.clickmode == 'mouse':
            # event double-left-click or double-press: open view/edit dialog
            efld.bind('<Double-1>',
                      lambda e: self.onLeftClick_Event__Edit(edate, icsdata, efld))

            # event Enter-key-press (after <Button-1> focus): update summary text only
            efld.bind('<Return>',
                      lambda e: self.onReturn_Event__Update(efld, icsdata))

        elif Configs.clickmode == 'touch':
            # event single-left-click or single-press: fill footer AND open view/edit 
            efld.bind('<Button-1>',
                      lambda e: (self.onEnter_Event__Footer(edate, icsdata),
                                 self.onLeftClick_Event__Edit(edate, icsdata, efld)) ) 

        # both: event single-right-click, or press+hold: cut/copy/open (paste on day)
        efld.bind('<Button-3>',
                  lambda e: self.onRightClick_Event__CutCopy(e, edate, icsdata))

        # both: event mouse-hover-in, if you have one: fill Footer (description or not)
        efld.bind('<Enter>',
                  lambda e: self.onEnter_Event__Footer(edate, icsdata))

        # [2.0] on Mac OS X, also allow Control-click as an equivalent for right-click,
        # and support Mac mice that trigger Button-2 on right button click (on Macs,
        # right=Button-2 and middle=Button-3; it's the opposite on Windows and Linux!)

        if RunningOnMac:
            efld.bind('<Control-Button-1>',
                  lambda e: self.onRightClick_Event__CutCopy(e, edate, icsdata))
            
            efld.bind('<Button-2>',
                  lambda e: self.onRightClick_Event__CutCopy(e, edate, icsdata))


    def prototype_events(self, daywidgets):
        """
        show mocked-up event labels
        defunct and no longer mantained: see etc\frigcal--preclasses.py for original code
        """
        pass


    #------------------------------------------------------------------------------------
    # Exit: verify, backup, save
    #------------------------------------------------------------------------------------

    def onQuit(self):
        """
        => main window quit/close "X" button: [backup, then save]?, then [exit]?
        backup current ics file(s), then save new data (only after verify+backup!);
        saves changed files only, but don't even ask if there have been no changes [1.1];
        only the main month window does backup/save: clone windows are erased silently;
        """
        # backup+save?
        if any(CalendarsDirty.values()):                   # else don't even ask [1.1]
            answer = askyesno('Verify %s save' % PROGRAM,
                              'Backup and save changed calendar files now?',
                              parent=self.root)            # [2.0] Mac slide-down, don't lift root
            if answer:
                trace('backup/save')
                if icsfiletools.backup_ics_files():        # catches+shows own errors, False=failed
                    icsfiletools.generate_ics_files()      # catches+shows own errors (TBD: do here?)

        # exit program?
        answer = askokcancel('Verify %s exit' % PROGRAM,
                             'Really quit frigcal now?',
                             parent=self.root)             # [2.0] Mac slide-down, don't lift root
        if answer:
            # exit now, backp/save or not
            trace('exit')
            self.root.quit()          # close all windows and end program (mainloop())
        else:
            self.root.focus_force()   # [2.0] else user must click on Mac to activate


    #------------------------------------------------------------------------------------
    # Date navigation callbacks (keys + buttons + entry)
    #------------------------------------------------------------------------------------

    def refill_display(self):
        self.fill_days()
        self.fill_events()
        self.showImage()           # image for new month
        self.clearfooter()         # TBD: clear (or retain?--see method) 


    # [1.3] use descriptive callbacks names, to which keys are mapped
    
    def onNextMonthButton(self):
        """
        => button or arrow-key: display next month (all windows if tandem)
        """
        trace('Got NextMo/DownArrow')
        if not self.tandemvar.get():
            self.viewdate.setnextmonth()         # move just this window
            self.refill_display()
        else:
            for window in OpenMonthWindows:      # else all open windows move
                window.viewdate.setnextmonth()   # move this window
                window.refill_display()
                        
    def onPrevMonthButton(self):
        """
        => button or arrow-key: display previous month (all windows if tandem)
        """
        trace('Got PrevMo/UpArrow')
        if not self.tandemvar.get():
            self.viewdate.setprevmonth()
            self.refill_display()
        else:
            for window in OpenMonthWindows:     
                window.viewdate.setprevmonth()  
                window.refill_display()
          
    def onNextYearButton(self):
        """
        => button or arrow-key: display next year (all windows if tandem)
        """
        trace('Got NextYr/ShiftDownArrow')
        if not self.tandemvar.get():
            self.viewdate.setnextyear()
            self.refill_display()
        else:
            for window in OpenMonthWindows:     
                window.viewdate.setnextyear()  
                window.refill_display()

    def onPrevYearButton(self):
        """
        => button or arrow-key: display previous year (all windows in Tandem)
        """
        trace('Got PrevYr/ShiftUpArrow')
        if not self.tandemvar.get():
            self.viewdate.setprevyear()
            self.refill_display()
        else:
            for window in OpenMonthWindows:     
                window.viewdate.setprevyear()  
                window.refill_display()


    def onTodayButton(self):
        """
        => button or Esc-key: display today's date (this window only) 
        """
        trace('Got TodayPress')
        self.viewdate.settoday()    
        self.refill_display()

    def onGoToDate(self, dateent):
        """
        => GoTo or Enter-key in date: display entered date (this window only) 
        [2.0] parent=window for Mac slide-down, focus_force for Mac refocus
        """
        trace('Got GoToDate:', dateent.get())
        if not self.viewdate.setdate(dateent.get()):
            showerror('%s: date format error' % PROGRAM,
                      'Please enter a valid date as "MM/DD/YYYY".',
                      parent=self.root)
            self.root.focus_force()
        else:
            self.refill_display()

            
    #------------------------------------------------------------------------------------
    # Event edits: in memory (till file save on exit)
    #------------------------------------------------------------------------------------

    #
    # DAY AND DAYNUM CLICKS
    #
    
    def onLeftClick_Day__Create(self, reldaynum):
        """
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
         => left click (or press) on day frame outside events
        open add new event dialog for this day to create event;
        
        this day is now also selected in GUI and set in viewdate
        manually here, as this may be run by single or double click;
        
        Resolved: a listbox of day's events may be useful if too many to see?
          =>addressed in [1.3] with a popup on daynum clicks: see next method;
        Resolved: should handlers be named by event trigger or action they take?
          =>addressed in [1.3] by callback names having both trigger+__action;
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        """
        trace('Got Day LeftClick', reldaynum)
        if self.viewdate.relday_is_in_month(reldaynum):        # a true day in displayed month?
            trueday = self.viewdate.index_to_day(reldaynum)
            clickdate = Edate(month=self.viewdate.month(),
                              day=trueday,
                              year=self.viewdate.year()) 
            self.set_and_shade_day(trueday)
            AddEventDialog(self.root, clickdate)


    def onLeftClick_DayNum__Select(self, reldaynum):
        """
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        => left click (or press) on day number area above any events
        [1.3] this is a new handler that opens day's events selection listbox,
        with 'create' button; dialog is modal, to avoid update issues if many;
        in list, left-double => edit dialog, right-single => cut/copy dialog,
        like event clicks in day frame (left-single simply selects item);

        this day is now also selected in GUI and set in viewdate
        manually here, as this may be run by single or double click;

        TBD: should the daynum be a button instead of label to make it more obvious?
        at present, no: because button takes up more space, limiting number events;
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        """
        trace('Got DayNum LeftClick', reldaynum)
        if self.viewdate.relday_is_in_month(reldaynum):        # a true day in displayed month?
            trueday = self.viewdate.index_to_day(reldaynum)
            clickdate = Edate(month=self.viewdate.month(),
                              day=trueday,
                              year=self.viewdate.year()) 
            self.set_and_shade_day(trueday)
            if not clickdate in EventsTable.keys():            # any events for this day?
                AddEventDialog(self.root, clickdate)           # no: go to create dialog now
            else:
                # open list dialog for all [1.3]
                SelectListDialog(self, clickdate)              # [1.4] moved to a class
                                

    def onRightClick_Day__Paste(self, reldaynum):
        """
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        => right click (or press+hold) on day or daynum
        paste latest cut/copy event on this day via prefilled dialog;
        reuses create dialog to allow calendar selection and cancel;
        
        pastes are performed by right-clicks on day/daynum after
        an earlier right-click on an event to cut/copy the event;

        [2.0] parent=window for Mac slide-down, focus_force for Mac refocus
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        """
        global CopiedEvent
        trace('Got Day RightClick', reldaynum)
        if self.viewdate.relday_is_in_month(reldaynum):
            if not CopiedEvent:
                showerror('%s: no event to paste' % PROGRAM,
                          'Please cut/copy before paste',
                          parent=self.root)
                self.root.focus_force()
            else:
                trueday = self.viewdate.index_to_day(reldaynum)
                clickdate = Edate(month=self.viewdate.month(),
                                  day=trueday,
                                  year=self.viewdate.year())
                self.set_and_shade_day(trueday)
                # default to this event's calendar 
                AddEventDialog(self.root, clickdate, titletype='Paste',
                               icsdata=CopiedEvent, initcalendar=CopiedEvent.calendar)

    #
    # EVENT CLICKS AND RETURNS
    #
    
    def onLeftClick_Event__Edit(self, edate, icsdata, efld=None):
        """
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        => left click (or press) on event
        open view/update/delete edit dialog for event;
        
        event clicks/presses vary per mouse|touch mode: may be called for
        single or double click; also called for right-click Open: efld is None;
        bypassed by select list clicks: opens edit dialog directly [1.3];

        TBD: clear selection on entry?, else may retain word highlight after
        double-clicks in 'mouse' mode; efld is the entry widget on left-clicks,
        but None for Open in right-click menu (no highlight to be cleared);
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        """
        trace('Got Event LeftClick')
        #if efld: efld.selection_clear()     # else a clicked word left highlighted
        self.set_and_shade_day(edate.day)
        icsfilename = icsdata.calendar
        EditEventDialog(self.root, edate, icsfilename, icsdata)


    def onRightClick_Event__CutCopy(self, tkevent, edate, icsdata):
        """
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        => right click (or press+hold) on Event
        open copy/cut/open menu dialog for this event;
        Cut reuses Delete code, Open reuses LeftClick code;
        
        cut/copy is run by right-click on event, and paste of
        the event is run by later right-clicks on day/daynum;

        also has Open option: equivalent to an event left-click,
        but must first cancel the diaog here, because event may be
        deleted in the Open dialog, invalidating a later cut here;

        TBD: probably should be a balloon-type text, not a dialog;
        TBD: could use drag-and-drop, but error prone (see tablets!);
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        """
        trace('Got Event RightClick')
        self.set_and_shade_day(edate.day)
        CutCopyDialog(self, tkevent, edate, icsdata)   # [1.4] moved to class


    def onReturn_Event__Update(self, efld, icsdata):
        """
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        => Enter key press on event field with focus
        update event's summary text only from current field text;

        updates summary in both gui and data structures=calendar+index
        like all updates, propogates to all windows open on this month;
        caveat: does not update any footer text (but should it?)
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        """
        trace('Got Event Return')
        newsummary = efld.get()

        # data strucures
        icsdata.summary = newsummary                            # update index data (in-place!)
        icsfiletools.update_event_summary(icsdata, newsummary)  # update icalendar data (in-place!)

        # gui
        for ow in OpenMonthWindows:                       # update other gui windows?
            if icsdata.uid in ow.eventwidgets.keys():     # no need to match viewdate
                entry = ow.eventwidgets[icsdata.uid]      # set this entry in this window
                if entry != efld:
                    entry.delete(0, END)                  # else adds to current text
                    entry.insert(0, newsummary)           # has not set()


    #------------------------------------------------------------------------------------
    # Footer option: overview text display
    #------------------------------------------------------------------------------------

    def onFooterFlip(self, footervar):
        """
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        => Footer toggle checked on or off: open/close text display

        this seems useful, but could require click to see extra text in dialog;
        as is, mouse-only, and not much more useful than clicked edit/view dialog;
        update: single press on tablet activates a mouse hover-in event too--keep;

        caveat: scrollbar may be difficult to reach without entering another event,
        but this is really just a convenience and a redundant display anyhow;
        caveat: this may not appear if you have limited screen space and/or many
        events in a month's days: use te daynum selection list or event clicks;
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        """
        trace('Got FooterFlip:', footervar.get())
        if footervar.get():
            # toggle on: draw footer
            footerframe = Frame(self.root, relief=RIDGE, border=2)
            footertext  = ScrolledText(footerframe)

            if Configs.footerheight:
                footertext.config(height=Configs.footerheight)
            trybgconfig(footertext, Configs.footercolor)
            tryfontconfig(footertext, Configs.footerfont)

            # appears above navigation buttons (former bottom) and below days grid (top)
            if Configs.footerresize:
                footerframe.pack(side=BOTTOM, expand=YES, fill=BOTH)    # grow proportionally
                footertext.pack(side=TOP, expand=YES, fill=BOTH)
            else:
                footerframe.pack(side=BOTTOM, fill=X)                   # retain fixed size
                footertext.pack(side=TOP, expand=YES, fill=BOTH)

            self.footerframe = footerframe           # save for erase on toggle
            self.footertext  = footertext            # save for fills on enter
            self.footertext.config(state=DISABLED)   # else editable till filled [1.2]
        else:
            # toggle off: erase footer
            self.footerframe.destroy()       # or .pack()/pack_forget() to show/hide
            self.footertext = None           # but won't happen often enough to optimize


    def onEnter_Event__Footer(self, edate, icsdata):
        """
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        => mouse hover-in (or single-press) on event
        show overview in footer, if currently open
        
        discarded <Leave>=erase text: some may require later scrolling;
        discarded popup version: flashed if popup appeared over mouse;
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        """
        trace('Got EventEnter')
        if self.footertext:
            displaytext = ("Date: %s\nSummary: %s\n%s" %
                (edate.as_string(),
                 icsdata.summary,
                 icsdata.description))
            displaytext = fixTkBMP(displaytext)          # [2.0] Unicode replacements
            self.footertext.config(state=NORMAL)         # allow deletes/inserts [1.2]
            self.footertext.delete('1.0', END)           # delete current text (if any)
            self.footertext.insert('1.0', displaytext)   # add text at line 1, col 0
            self.footertext.config(state=DISABLED)       # restore readonly state [1.2]


    def clearfooter(self):
        """
        on month navigations, erase current footer text if open (optionally)
        """
        if self.footertext and Configs.clearfooter:      # optional: default=None/False
            self.footertext.config(state=NORMAL)         # allow changes now [1.2]
            self.footertext.delete('1.0', END)           # delete current text (if any)
            self.footertext.config(state=DISABLED)       # restore readonly state [1.2]


    #------------------------------------------------------------------------------------
    # Images option: display image for a month window's month number
    #------------------------------------------------------------------------------------

    def onImageFlip(self, imagevar):
        """
        => Images toggle checked on/off: build and display, or erase
        [2.0] parent=window for Mac slide-down, focus_force for Mac refocus
        """
        trace('Got ImageFlip:', imagevar.get())
        if imagevar.get():
            # toggle on: popup and show

            # [1.6] the PIL/Pillow requirement is no longer absolute
            """
            try:
                import PIL
            except ImportError:
                # don't fail here, or on later navigates or toggles
                imagevar.set(False)
                showerror('%s: Images not available' % PROGRAM,
                          'Please install Pillow to use the Images option.')
                return  # avoid nesting
            """

            if not self.imgfiles:
                # get image names at window's first toggle-on [1.5]
                imgdir = Configs.imgpath
                try:
                    imgs = os.listdir(imgdir)
                except:
                    imagevar.set(False)
                    showerror('%s: Images Error' % PROGRAM,
                              'Image files path is invalid:\n%s\n\n'
                              'Check your "imgpath" setting in frigcal_configs.py.' 
                              % fixTkBMP(imgdir),
                              parent=self.root)
                    self.root.focus_force()
                    return  # avoid nesting
                              
                # [1.4] skip non-files (subdirs)  
                imgs = [img for img in imgs
                            if os.path.isfile(os.path.join(imgdir, img))]

                # [1.5] skip non-image files by filename mimetype
                for img in imgs.copy():                         # yes, must .copy()!
                    filetype = mimetypes.guess_type(img)[0]
                    if filetype == None or filetype.split('/')[0] != 'image':
                        imgs.remove(img)
                
                # [1.5] issue warning if 12 images not present
                if len(imgs) != 12:
                    # console always, popup just once per window (not on each navigate)
                    # showImage indexing may eventually print console exception traceback
                    showwarning('%s: Images Error' % PROGRAM,
                                'Image files missing or extraneous.\n\n'
                                'There are not 12 images in folder:\n%s\n\n'
                                'Some months may fail to display.' 
                                % fixTkBMP(Configs.imgpath),
                                parent=self.root)
                    # no self.root.focus_force() here: obscures image popup
                self.imgfiles = imgs
                
            imgwin = Toplevel()                                 # make new window (post popup?)
            imgwin.protocol('WM_DELETE_WINDOW', lambda: None)   # quit = no-op: tied to month
            imglab = Label(imgwin)
            imglab.pack()
            self.imgwin, self.imglab = imgwin, imglab           # save for showImage(), hide, quit
            self.showImage()                                    # show first image now

            # replace red tk window icon [1.2]
            try_set_window_icon(imgwin)

            # make window non-user-resizable, as image never resized [1.4]
            imgwin.resizable(width=False, height=False)                

            # start position for all image windows [1.4]
            if Configs.initimgposition: 
                imgwin.geometry(Configs.initimgposition)        # '+X+Y' offset from top left

        else:
            # toggle off: destroy popup
            self.imgwin.destroy()
            self.imgwin = self.imglab = self.imgobj = None


    def showImage(self, prototype=PROTO):
        """
        on month navigations, and when toggled on: show photo for viewed month;
        the window sizes itself to the image's size (but never vice versa);
        """
        if self.imgwin:
            if len(self.imgfiles) != 12:
                trace('There are not 12 images in ' + Configs.imgpath)

            if prototype:
                import random
                imgfile = random.choice(self.imgfiles)
            else:
                monthnum = self.viewdate.month()               # pick by name sort order
                imgfile = sorted(self.imgfiles)[monthnum-1]    # 1..N => 0..N-1

            imgpath = os.path.join(Configs.imgpath, imgfile)

            # [1.6] use PhotoImage from PIL/Pillow if installed for all image types and Pys;
            # else use Tk/tkinter version for PNG on some Py3.4+, and GIF/PPM/PPG on all Py3.X;

            imageloaded = imagedefault = False
            try:
                imgobj = PhotoImage(file=imgpath)                  # Pillow or native version
                imageloaded = True
            except:
                try:
                    imgpath = os.path.join('icons', 'montherr.gif') 
                    imgobj = PhotoImage(file=imgpath)              # works on all pys, pillow or not
                    imagedefault = True
                except:                                            # cwd should work, but universal?
                    pass

            if imageloaded or imagedefault: 
                self.imglab.config(image=imgobj)                   # draw photo
                self.imgobj = imgobj                               # must keep a reference
                trace(imgpath, imgobj.width(), imgobj.height())    # size in pixels
                self.imgwin.title('%s %.1f - %s' % (PROGRAM, VERSION, imgfile))
                # TBD: self.root.lift()  # don't hide main month window? (lift=tkraise)

            if not imageloaded:
                # after img window configured, else popup + empty img window ([1.7] typo fix)
                msgtext = 'Image file failed to load in Python %s.\nImage: %s'
                msgtext %= (Configs.pyversion, imgpath)
                trace(msgtext)
                showerror('%s: Image not available' % PROGRAM, msgtext +
                          '\n\nPlease install Pillow to use the Images option with this image,'
                          ' or use an image type that is supported in your Python version.'
                          '\n\nAs of frigcal 1.6, PNG images work in all Pythons using Tk 8.6+'
                          ' (including standard Windows installs of Python 3.4+), and GIF/PPM/PPG'
                          ' work in all Python 3.X; all other combinations require a Pillow install.'
                          '\n\nToggle-off Images to avoid seeing this error message again.',
                          parent=self.root)
                self.root.focus_force()   # obscures image popup iff first month bad: allow
  

    #------------------------------------------------------------------------------------
    # Clone option: multiple month view windows, moved in tandem or not
    #------------------------------------------------------------------------------------

    def onClone(self):
        """
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        => Clone button pressed in any open window
        make a new, independent month view window, with custom quit action;
        
        this essentially _requires_ classes with their own state (not globals);
        open this at the same date as cloner, with custom type text in title bar;
        [1.2] the popup windows get an icon via the normal month window code;
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        """
        MonthWindowClone(Toplevel(), startdate=self.viewdate, windowtype='Popup')


    def onTandemFlip(self, tandemvar):
        """
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        => Tandem toggle clicked on/off in any open window     
        if checked on, main+clone windows all move on any prev/next navigation;
        toggle setting in any is propagated to all windows' GUI and navigations;

        TBD: possible alternative: use a single, global, shared tkiner IntVar,
        both here and when making a new window in make_widgets();
        ~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
        """
        trace('Got TandemFlip:', tandemvar.get())
        tandemtoggle = tandemvar.get()
        for window in OpenMonthWindows:
            window.tandemvar.set(tandemtoggle)
        

#====================================================================================
# Popup clone window: multiple month windows may be opened (and navigate separately)
#====================================================================================

class MonthWindowClone(MonthWindow):
    """
    same as its super, but just close this window on quit button;
    no need to save/backup here: main (and other?) view still open,
    and all month windows are just portals on the same calendar data;
    """
    def onQuit(self):
        """
        => popup clone window's quit button press
        silently close this month view window with no backup/save
        """
        OpenMonthWindows.remove(self)
        self.root.destroy()                      # popup's main window (only)
        if self.imgwin: self.imgwin.destroy()    # and my image window popup?

               
#====================================================================================
# Date set/increment/decrement, with rollovers, calendar module days list
#====================================================================================

class ViewDateManager:
    """
    manage the viewed day's date object and month daynumbers list;
    
    created for and embedded in each MonthWindow object;
    maps relative day indexes in GUI to/from true month day numbers;
    caveat: uses datetime module dates, not later icsfiletools.Edate;
    
    subtle: must set day to 1 on mo/yr navigations, else current date's
    day may be out range for new month when mo/yr reset by date.replace()
    (e.g, 30, for Feb);  restores prev view day later if in new month,
    else sets it to the highest day number in the new month; 
    """
    def __init___(self):
        self.currdate = None    # displayed date's Date object: with .month/.year/.day #s
        self.currdays = None    # list of displayed month's day #s: 0 if not part of month

    def relday_is_in_month(self, reldaynum):
        return self.currdays[reldaynum] != 0      # display widget index is a true day?
    def trueday_is_in_month(self, truedaynum):
        return truedaynum != 0                    # when already pulled from currdays

    def index_to_day(self, reldaynum):
        return self.currdays[reldaynum]           # true day for display label index            
    def day_to_index(self, truedaynum):
        return self.currdays.index(truedaynum)    # display label index for true day

    def month(self):
        return self.currdate.month
    def day(self):
        return self.currdate.day
    def year(self):
        return self.currdate.year
    def mdy(self):
        return (self.month(), self.day(), self.year())

    def setday(self, daynum):
        # on clicks
        self.currdate = self.currdate.replace(day=daynum)


    def get_pad_daynums(self):
        """
        fetch day numbers list from python's calendar module for currdate;
        non-month days are zero, pad with extra zeroes for maxweeks displayed;
        calhelper 6=starts on Sunday (0=Monday, but can't change GUI as is);
        """
        calhelper = calendar.Calendar(firstweekday=6)   # start on Sunday
        currdays = list(calhelper.itermonthdays(self.currdate.year, self.currdate.month))
        currdays += [0] * ((MAXWEEKS * 7) - len(currdays))
        return currdays


    def settoday(self):
        # run initially and on demand
        self.currdate = datetime.date.today()
        self.currdays = self.get_pad_daynums()

    def setdate(self, datestr):
        # TBD: could check if day is in month's range explicitly; as is,
        # .replace() generates exception + general error popup on bad day;
        trace(datestr)
        try:
            mm, dd, yyyy = datestr.split('/')       # k.i.s.s. for now
            assert len(yyyy) == 4
            self.currdate = self.currdate.replace(
                                month=int(mm), day=int(dd), year=int(yyyy))
            self.currdays = self.get_pad_daynums()
            return True
        except:
            trace(sys.exc_info())
            return False


    def nav_neutral_day(self):
        # set day=1 to avoid out-of-range on replace()
        prevday = self.currdate.day                   # save daynum to reset if possible
        self.currdate = self.currdate.replace(day=1)  # else may be out of new month's range
        return prevday

    def nav_restore_day(self, prevday):
        # restore prev day if in bounds for new month
        if prevday in self.currdays:
            self.currdate = self.currdate.replace(day=prevday)  # restore if in new month
        else:
            # set day to last (i.e., highest #) day in new month
            # TBD: or leave at 1? (later navs on prior lastday)
            for lastday in reversed(self.currdays):
                if lastday != 0:
                    self.currdate = self.currdate.replace(day=lastday)
                    break

    def setnextmonth(self):
        prevday = self.nav_neutral_day()
        currdate = self.currdate
        if currdate.month != 12:
            currdate = currdate.replace(month=currdate.month + 1)
        else:
            currdate = currdate.replace(month=1, year=currdate.year + 1)
        self.currdate = currdate
        self.currdays = self.get_pad_daynums()
        self.nav_restore_day(prevday)

    def setprevmonth(self):
        prevday = self.nav_neutral_day()
        currdate = self.currdate
        if currdate.month != 1:
            currdate = currdate.replace(month=currdate.month - 1)
        else:
            currdate = currdate.replace(month=12, year=currdate.year - 1)
        self.currdate = currdate
        self.currdays = self.get_pad_daynums()
        self.nav_restore_day(prevday)

    def setnextyear(self):
        prevday = self.nav_neutral_day()
        self.currdate = self.currdate.replace(year=self.currdate.year + 1)
        self.currdays = self.get_pad_daynums()
        self.nav_restore_day(prevday)

    def setprevyear(self):
        prevday = self.nav_neutral_day()
        self.currdate = self.currdate.replace(year=self.currdate.year - 1)
        self.currdays = self.get_pad_daynums()
        self.nav_restore_day(prevday)


#====================================================================================
# Events edits/adds dialog: in-memory updates, saved on exit
#====================================================================================

class Dialog:
    """
    common super to avoid repeating wait state code [1.4]
    """
    modal = True       # redefine in sub or self as needed
    dialogwin = None   # set me in self to dialog window object 
    root = None        # set me to month window parent (Tk or Toplevel)

    def try_grab_set(self):
        """
        workaround for grab_set modal dialog oddness on Linux only (not on Win/Mac);
        without the wait_visibility() the grab_set() fails; without the grab_set(),
        the window isn't modal;  suggested on the web: an infinite loop with a 'try'
        to catch grab_set() failures until it works -- wait_visibiity() seems better;
        
        TBD: transient() may keep the dialog above parent? (Linux modals still odd);
        [1.6] YES: without this, Linux custom modal dialog windows can be covered,
        which is bad for small dialogs like right-click popup: disabled month on top!
        not required on Windows: does right thing for modals (this is WM dependent);
        
        [2.0] same issue and fix for Mac OS X (a.k.a. darwin), and the fix here also
        solves the issue of edit dialogs posting with their right portions off-screen;
        """
        if RunningOnLinux or RunningOnMac:
            # linux and mac - no issue on Windows
            self.dialogwin.wait_visibility()       # must wait till open, else exc
            self.dialogwin.transient(self.root)    # make modals stay on top [1.6] [2.0]
        self.dialogwin.grab_set()                  # now catch all app events

    def run(self):
        """
        go modal and wait for user action
        """
        if self.modal:                          # always modal (blocking) so far
            self.dialogwin.focus_set()          # take over input focus,
            self.try_grab_set()                 # disable other windows while this is open,
            self.dialogwin.wait_window()        # and wait here until window closed/destroyed


class EventDialog(Dialog):
    """
    custom dialog for event display and/or edit;
    created and run in MonthWindow callback handlers above;
    this is an abstract superclass: its Add/Edit subclasses
    fill in differing category builder and action buttons/callbacks;
    """    
    def __init__(self, root, edate, titletype, modal=True):
        """
        subclasses fill in their differing bits with icsdata=EventData() first
        """
        self.root  = root      # my creator's window (not mine), for Dialog [1.6]
        self.edate = edate     # dialog's event's date
        self.modal = modal     # true=blocking: not currently used 
        
        # make a new window
        self.dialogwin = Toplevel(root)
        self.dialogwin.title('%s %.1f - %s Event' % (PROGRAM, VERSION, titletype))

        # replace red tk window icon [1.2]
        try_set_window_icon(self.dialogwin)

        # [1.4] route window quit/close to changes checker (formerly closed silently)
        self.dialogwin.protocol('WM_DELETE_WINDOW', self.onCancel)   # or (lambda: None)

        # [2.0] on Mac OS X, dialog appears with part off-screen - adjust post location
        if RunningOnMac:
            pass
            # no, but making the dialog transient above (as on Linux) fixed this issue
            # self.dialogwin.geometry('+%d+%d' % (scrwide / 5, scrhigh / 2))  

        self.make_widgets(edate)
        self.run()   # wait for user action [1.4]

    def onCancel(self):
        """
        => on Cancel and window close "X"
        [1.4] verify event edit Cancel/close if any input field changed
        [2.0] parent=window for Mac slide-down, focus_force for Mac refocus
        """
        if ((self.start_common_inputs == self.fetch_from_widgets() and
             self.start_custom_inputs == self.fetch_from_customs())
           or
             askyesno('Verify %s edits cancel' % PROGRAM,
                      'Inputs have changed: cancel edits anyhow?',
                      parent=self.dialogwin)
           ):
            self.dialogwin.destroy()       # no changes or verified: close window
        else:
            self.dialogwin.focus_force()   # restore active style+focus on Mac
        

    def make_widgets(self, edate):
        """
        make custom dialog for event display and/or edit
        """
        dialogwin = self.dialogwin
        icsdata = self.icsdata

        # buttons to run context-specific action and close dialog window
        # pack first = clip last! (retain on resizes)
        toolbar = Frame(dialogwin, relief=RIDGE)
        toolbar.pack(side=BOTTOM, fill=X)
        trybgconfig(toolbar, Configs.eventdialogbg)

        # differs in subclasses
        self.make_action_buttons(toolbar)
        # all contexts: close window only
        cancelbtn = Button(toolbar, text='Cancel', command=self.onCancel)  # [1.4]
        cancelbtn.pack(side=RIGHT)
        tryfontconfig(cancelbtn, Configs.controlsfont)

        # main portion of window
        formfrm = Frame(dialogwin, relief=RIDGE, border=2)
        formfrm.pack(side=TOP, expand=YES, fill=BOTH)
        trybgconfig(formfrm, Configs.eventdialogbg)

        # date known, never editable (cut/paste to move to another date)
        clickdatestr = edate.as_string()  
        self.formlabel(formfrm, 'Date:', 0, 0)                  
        datefld = Label(formfrm, text=clickdatestr, relief=RIDGE)
        datefld.grid(row=0, column=1, sticky=W)                    # left side
        trybgconfig(datefld,   Configs.eventdialogfg)              # yes, bg=fg (2 colors)
        tryfontconfig(datefld, Configs.eventdialogfont)

        # differs in subclasses
        self.formlabel(formfrm, 'Calendar:', 1, 0)
        self.make_calendar_field(formfrm)

        self.formlabel(formfrm, 'Summary:', 2, 0)
        summaryfld = Entry(formfrm)
        summaryfld.grid(row=2, column=1, sticky=EW)
        summaryfld.insert(0, fixTkBMP(icsdata.summary))            # [2.0] Unicode replace 
        trybgconfig(summaryfld,   Configs.eventdialogfg)
        tryfontconfig(summaryfld, Configs.eventdialogfont)
        self.summaryfld = summaryfld

        self.formlabel(formfrm, 'Description:', 3, 0)    
        descriptionfld = ScrolledText(formfrm)
        descriptionfld.config(height=5)                            # default initial height
        descriptionfld.grid(row=3, column=1, sticky=NSEW)          # but grows with window

        # [2.0] omit blank line if it's empty (\n added on fetch)
        if icsdata.description != '\n':
            descdisplay = fixTkBMP(icsdata.description)            # [2.0] Unicode replace 
            descriptionfld.insert(0.0, descdisplay)

        trybgconfig(descriptionfld,   Configs.eventdialogfg)
        tryfontconfig(descriptionfld, Configs.eventdialogfont)

        # [2.0] text initial height/width now configurable
        if Configs.eventdialogtextheight != None:
            descriptionfld.config(height=Configs.eventdialogtextheight)   # else 5 above (Tk=24)
        if Configs.eventdialogtextwidth != None:
            descriptionfld.config(width=Configs.eventdialogtextwidth)     # else Tk default=80
        self.descriptionfld = descriptionfld

        # category = pulldown of configs, plus entry for (possibly new) values
        # TBD: might be able to crosslink the two on a shared StringVar?
        # as is, setting optionmenu simply sets entry's text, and not vice versa
        self.formlabel(formfrm, 'Category:', 4, 0)
        categoryfrm = Frame(formfrm)
        categoryfrm.grid(row=4, column=1, sticky=W)
       
        categoryfld = Entry(categoryfrm)
        categoryfld.pack(side=LEFT)                              
        categoryfld.insert(0, fixTkBMP(icsdata.category))          # initialize to curr val, if any
        trybgconfig(categoryfld,   Configs.eventdialogfg)          # [2.0] Unicode replacement 
        tryfontconfig(categoryfld, Configs.eventdialogfont)
        self.categoryfld = categoryfld

        # str.lower for ordering, but doesn't change keys
        categories1 = sorted(Configs.category_colors.keys(), key=str.lower) or ['']
        categories2 = [fixTkBMP(x) for x in categories1]           # [2.0] Unicode replacement

        # [2.0] map back to the original later for use as a configs dict key
        # caveat: though unlikely, 'ccXcc' and 'ccYcc' may be the same fixed (punt!)
        self.categoryfixmap = dict(zip(categories2, categories1))
        categories = categories2

        def pickhandler(pick):
            categoryfld.delete(0, END)
            categoryfld.insert(0, pick)    # no need for categoryvar.get() here

        categoryvar = StringVar()          # required for init value only here 
        categorymnu = OptionMenu(categoryfrm, categoryvar, *categories, command=pickhandler)
        categorymnu.pack(side=LEFT)
        trybgconfig(categorymnu,   Configs.eventdialogfg)
        tryfontconfig(categorymnu, Configs.eventdialogfont)
        categoryvar.set('Choose...')                         # initialize to usage reminder
        
        # resizing precedence: description text highest
        formfrm.rowconfigure(3, weight=1)    
        formfrm.columnconfigure(1, weight=1)

        # [1.4] save common initial inputs dict to detect changes on Cancel
        self.start_common_inputs = self.fetch_from_widgets()
        self.start_custom_inputs = self.fetch_from_customs()
         
    def formlabel(self, frame, text, row, column, sticky=NSEW):
        label = Label(frame, text=text, relief=RIDGE)       # standardize look
        label.grid(row=row, column=column, sticky=sticky) 
        
    def fetch_from_widgets(self):
        """
        [1.4] strip extra trailing \n added to description by Text widget's
        get(), else can wind up adding one '\n' per an event's update or paste;
        could also fetch through END+'-1c', but drop any already present too;
        nit: rstrip() also drops any intended but useless blank lines at end;
        always keep one \n at end in case some ics parsers require non-blank;
        [2.0] but don't display a sole '\n' = bogus blank line (see above);
        [2.0] and map category back to non-BMP-fixed value, if in table;
        """
        # category may be empty or typed, and may be new value not in menu
        categoryfld = self.categoryfld.get()                   # get GUI field value
        if categoryfld in self.categoryfixmap:                 # [2.0] map to original
            categoryfld = self.categoryfixmap[categoryfld]     # a no-op if unfixed

        return dict(summary=     self.summaryfld.get(),
                    description= self.descriptionfld.get('1.0', END).rstrip('\n') + '\n',
                    category=    categoryfld)
        
    # subclass protocol, plus any action handlers
    def fetch_from_customs(self):             return None  # [1.4]
    def make_calendar_field(self, formframe): raise NotImplementedError
    def make_action_buttons(self, toolbar):   raise NotImplementedError


#====================================================================================
# factored event dialog subclass
#====================================================================================

class AddEventDialog(EventDialog):
    """
    dialog used to add a new event, and paste a copied event to day;
    factored differing parts to subclasses to avoid false uniformity;
    [1.3] titletype now passed as Create _or_ Paste, to differentiate;
    """
    def __init__(self, root, edate, titletype='Create', icsdata=None, initcalendar=None):
        # edate is clicked day's true date, not relative index
        # icsdata and initcalendar are not None when used for paste
        self.icsdata = icsdata or EventData(summary='Enter...')   # all else blank
        self.initcalendar = initcalendar
        EventDialog.__init__(self, root, edate, titletype)

    def make_calendar_field(self, formframe):
        # calendar unknown (Create) or changeable (Paste):
        # editable pulldown options-list of all calendars
        icsfiles1 = sorted(CalendarsTable.keys())      # iterable does *, but need list for [0]
        icsfiles2 = [fixTkBMP(x) for x in icsfiles1]   # [2.0] Unicode replacements for display 

        # [2.0] map back to the original later for use as a calendars table key 
        # caveat: though unlikely, 'ccXcc' and 'ccYcc' may be the same fixed (punt!)
        self.icsfilesfixmap = dict(zip(icsfiles2, icsfiles1))
        icsfiles = icsfiles2

        calendarvar = StringVar()
        calendarmnu = OptionMenu(formframe, calendarvar, *icsfiles)
        calendarmnu.grid(row=1, column=1, sticky=W)
        trybgconfig(calendarmnu,   Configs.eventdialogfg)
        tryfontconfig(calendarmnu, Configs.eventdialogfont)
        self.calendarvar = calendarvar              # save for Create callback
        
        # [1.5] for new adds, init to default calendar, if present:
        # use paste's, else frigcal-default, else 1st by sort order
        dfltcal = [name for name in icsfiles if name.startswith('frigcal-default-calendar')]
        calendarvar.set(self.initcalendar or (dfltcal and dfltcal[0]) or icsfiles[0])

    def make_action_buttons(self, toolbar):
        createbtn = Button(toolbar, text='Create', command=self.onAddEvent)
        createbtn.pack(side=LEFT, expand=NO)
        tryfontconfig(createbtn, Configs.controlsfont)

    def fetch_from_customs(self):
        # [1.4] verify Cancel if any inputs changed
        # there is no entry field here, so value must be in list
        return self.icsfilesfixmap[self.calendarvar.get()]   # [2.0] map to original
    
    def onAddEvent(self):
        # add new event in both gui and data structures
        # widgetdata.{.vevent, .orderby} set in add_event_data
        # each Paste creates a new event with same text data
        edate = self.edate
        newuid = icsfiletools.icalendar_unique_id()
        icsfilename = self.icsfilesfixmap[self.calendarvar.get()]   # [2.0] map to original
        widgetdata = EventData(uid=newuid,
                               calendar=icsfilename,
                               **self.fetch_from_widgets())
        trace('Adding:', widgetdata.summary)
        icsfiletools.add_event_data(edate, widgetdata)      # data structures
        self.add_event_gui(edate, widgetdata)               # then GUI: >=1 windows
        self.dialogwin.destroy()                            # and close dialog

    def add_event_gui(self, edate, widgetdata):
        """
        add new Entry to display; not static: Paste posts full dialog;
        add_event_entry both adds widget and registers event handlers;
        "ow" is a MonthWindow: non-static methods don't require calling
        through the class name, but doing so makes external more explicit;
        
        TBD: reorder now?--don't care about .orderby here (new events
        are added to end of day's list), but .calendar ordering is not
        applied until next navigation/refill, and can skew select lists;
        [1.4] this could do just ow.fill_events(), but may flash the GUI;
        """
        for ow in OpenMonthWindows:
            if ow.viewdate.month() == edate.month and ow.viewdate.year() == edate.year:
                reldaynum = ow.viewdate.day_to_index(edate.day)
                (dayframe, daynumlabel) = ow.daywidgets[reldaynum]
                MonthWindow.add_event_entry(ow, dayframe, edate, widgetdata)   # or ow.add...


#====================================================================================
# factored event dialog subclass
#====================================================================================

class EditEventDialog(EventDialog):
    """
    dialog used to view, update, and delete an existing displayed event;
    factored differing parts to subclasses to avoid false uniformity;
    """
    titletype = 'View/Edit'  # [1.3] not View/Update/Delete'
    
    def __init__(self, root, edate, icsfilename, icsdata):
        # edate is clicked event's true date
        # icsdata is clicked event's EventData
        self.icsfilename = icsfilename
        self.icsdata = icsdata
        EventDialog.__init__(self, root, edate, self.titletype)

    def make_calendar_field(self, formframe):
        # calendar known: not editable
        # must use cut/paste to move to another calendar
        icsfilename = fixTkBMP(self.icsfilename)                        # [2.0] Unicode fix
        calendarfld = Label(formframe, text=icsfilename, relief=RIDGE)
        calendarfld.grid(row=1, column=1, sticky=W)
        trybgconfig(calendarfld,   Configs.eventdialogfg)
        tryfontconfig(calendarfld, Configs.eventdialogfont)
 
    def make_action_buttons(self, toolbar):
        updatebtn = Button(toolbar, text='Update', command=self.onUpdateEvent)
        deletebtn = Button(toolbar, text='Delete', command=self.onDeleteEvent)
        updatebtn.pack(side=LEFT, expand=NO)
        deletebtn.pack(side=LEFT, expand=YES)
        tryfontconfig(updatebtn, Configs.controlsfont)
        tryfontconfig(deletebtn, Configs.controlsfont)
        
    def onUpdateEvent(self):
        # update event in both gui and data structures
        edate = self.edate
        icsdata = self.icsdata
        icsfilename = self.icsfilename
        widgetdata = EventData(calendar=icsfilename,
                               **self.fetch_from_widgets())
        icsfiletools.update_event_data(edate, icsdata, widgetdata)   # data structures
        self.update_event_gui(icsdata, widgetdata)                   # then GUI: >= 1 window
        self.dialogwin.destroy()                                     # and close dialog

    def update_event_gui(self, icsdata, widgetdata):
        """
        update displayed summary text on month display(s)
        not static, as used only by the dialog itself;
        caveat: does not update any footer text (but should it?)
        """
        for ow in OpenMonthWindows:
            if icsdata.uid in ow.eventwidgets.keys():     # no need to match viewdate
                # change summary text only
                entry = ow.eventwidgets[icsdata.uid]
                entry.delete(0, END)                      # not .config(text=x): for labels 
                entry.insert(0, widgetdata.summary)       # [2.0] already applied fixTkBMP

                # change color too if category changed (calendar not changeable)
                category = widgetdata.category            # ~white if new category unknown
                calendar = icsdata.calendar               # unless calendar is colored
                MonthWindow.colorize_event(entry, category, calendar)  # avoid redundant code!

    def onDeleteEvent(self):
        """
        delete event in both gui and data structures;
        TBD: verify this via popup too, like Cancel in 1.4?
        but doesn't discard inputs or update calendar files; 
        """
        edate = self.edate
        icsdata = self.icsdata
        icsfilename = self.icsfilename
        icsfiletools.delete_event_data(edate, icsdata)    # data structures
        self.delete_event_gui(icsdata)                    # then GUI: >=1 windows
        self.dialogwin.destroy()                          # and close dialog

    @staticmethod
    def delete_event_gui(icsdata):
        """
        delete summary text from month display(s)
        static so also callable from Cut operation without this edit dialog;
        staticmethod is optional in 3.X if class calls only, but makes explicit;
        """
        for ow in OpenMonthWindows:
            if icsdata.uid in ow.eventwidgets.keys():     # no need to match viewdate
                entry = ow.eventwidgets[icsdata.uid]      # erase this entry from gui+table             
                entry.destroy()
                del ow.eventwidgets[icsdata.uid]


#====================================================================================
# Cut/Copy/Open event right-click dialog
#====================================================================================

class CutCopyDialog(Dialog):
    """
    post modal cut/copy/open dialog on event right-click;
    events cut/copied here are global, for later pastes;
    [1.4] split out from click handler to this class;
    """
    def __init__(self, monthwindow, tkevent, edate, icsdata):
        self.root = monthwindow.root         # creator's window, for Dialog [1.6] 
        self.make_widgets(monthwindow, tkevent, edate, icsdata)
        self.run()                           # wait for user action [1.4]
    
    def make_widgets(self, monthwindow, tkevent, edate, icsdata):

        # the following use names in enclosing function scope
        def onCancel():
            popup.destroy()
            
        def onCopy():
            global CopiedEvent
            CopiedEvent = icsdata
            popup.destroy()

        def onCut():
            global CopiedEvent
            CopiedEvent = icsdata
            icsfiletools.delete_event_data(edate, icsdata)    # delete from data structures
            EditEventDialog.delete_event_gui(icsdata)         # then delete from GUI: >=1 windows
            popup.destroy()

        popup = Toplevel()                              # new dialog window, default Tk root
        mbutton = Menubutton(popup, text='Action')      # a stand-alone pull-down
        picks = Menu(mbutton, tearoff=False)            # 'open' is just a redundant convenience
        mbutton.config(menu=picks)                      # 'open' must cancel too: dialog may delete!
        picks.add_command(label='Copy', command=onCopy)
        picks.add_command(label='Cut',  command=onCut)

        picks.add_separator()
        picks.add_command(label='Open',       # cancel this AND open view/edit dialog
                          command=lambda: (
                              onCancel(),
                              monthwindow.onLeftClick_Event__Edit(edate, icsdata)))

        picks.add_separator()
        picks.add_command(label='Cancel', command=onCancel)
        mbutton.pack(side=TOP)
        mbutton.config(bg='white', bd=4, relief=RAISED) 

        # [1.3] add summary text to give some event context
        msgtext = 'For "%s"' % fixTkBMP(icsdata.summary)               # [2.0] Unicode replace
        msg = Label(popup, text=msgtext, bg='white')
        msg.pack(side=BOTTOM)
        trybgconfig(msg, Configs.eventdialogbg)  # same as rest of dialog
        tryfontconfig(msg, Configs.daysfont)     # same as month window

        # [2.0] stretch window horizontally via min label size
        msg.config(width=max(40, len(msgtext)))
        
        # config window
        popup.title('%s %.1f - Event Actions' % (PROGRAM, VERSION))
        popup.geometry('+%d+%d' % (tkevent.x_root, tkevent.y_root))    # post popup at click spot
        trybgconfig(popup, Configs.eventdialogbg)

        # replace red tk window icon [1.2]
        try_set_window_icon(popup)
        self.dialogwin = popup   # [1.4] for run()


#====================================================================================
# Day's event selection list daynum left-click dialog [1.3]
#====================================================================================

class SelectListDialog(Dialog):
    """
    post modal select dialog on daynum left-click:
    listbox of all day's events + 'create' button;
    [1.4] split out from click handler to this class;
    """
    def __init__(self, monthwindow, clickdate):
        self.root = monthwindow.root         # creator's window, for Dialog [1.6]
        self.make_widgets(monthwindow, clickdate)
        self.run()                           # wait for user action [1.4]

    def make_widgets(self, monthwindow, clickdate):
        dialog = Toplevel()  # new window
        
        # config window: open anywhere, replace red tk icon
        dialog.title('%s %.1f - Select Event' % (PROGRAM, VERSION))
        try_set_window_icon(dialog)
        trybgconfig(dialog, Configs.eventdialogbg)

        msgtext = 'Select or create new event for %s' % clickdate.as_string()
        msg = Label(dialog, text=msgtext, bg='white')
        msg.pack(side=TOP)
        trybgconfig(msg, Configs.eventdialogbg)   # same as rest of dialog

        # button for new event as alternative (pack first = clip last on shrink)
        toolbar = Frame(dialog)
        toolbar.pack(fill=X, side=BOTTOM)
        trybgconfig(toolbar, Configs.eventdialogbg)

        # Create = same as clicking rest of day frame (if any!)
        create = Button(toolbar, text='Create', 
            command=lambda: (                              # erase select AND open create dialogs
                    dialog.destroy(),
                    monthwindow.root.update(),
                    AddEventDialog(monthwindow.root, clickdate)))
        create.pack(side=LEFT)
        tryfontconfig(create, Configs.controlsfont)

        # cancel (and other destroyers) ends wait on window
        cancel = Button(toolbar, text='Cancel', command=lambda: dialog.destroy())
        cancel.pack(side=RIGHT)
        tryfontconfig(cancel, Configs.controlsfont)

        # get events for day, ordered
        dayeventsdict = EventsTable[clickdate]             # events on this date (uid table) 
        dayeventslist = list(dayeventsdict.values())       # day's event object (all calendars)
        dayeventslist.sort(                                # mimic month window ordering
                   key=lambda d: (d.calendar, d.orderby))  # order for gui by calendar + creation 

        # create selection/action lists ([2.0] label is not a key here)
        labels, leftactions, rightactions = [], [], []
        for icsdata in dayeventslist:                      # for all ordered events in this day
            displaysummary = fixTkBMP(icsdata.summary)     # [2.0] apply Unicode replacements 
            labels.append(displaysummary)                  # add summary+callback to select list

            # list left-single callbacks (double not used)             
            leftactions.append(                            # retains state from this scope
                lambda tkevent, icsdata=icsdata: (         # save loop's current icsdata object
                    dialog.destroy(),                      # erase select AND open edit dialogs
                    monthwindow.root.update(),
                    EditEventDialog(monthwindow.root, clickdate, icsdata.calendar, icsdata)))

            # list right-single callbacks (post dialog at former listbox spot)
            rightactions.append(
                lambda tkevent, icsdata=icsdata: (
                    dialog.destroy(),
                    monthwindow.root.update(),
                    monthwindow.onRightClick_Event__CutCopy(tkevent, clickdate, icsdata)))

        # reuse PP4E component, modified ([2.0] NOTE: this binds its own mouse buttons - Mac)
        select = ScrolledList(labels, leftactions, rightactions, parent=dialog, side=TOP)
        select.listbox.config(width=60)
        trybgconfig(select.listbox,   Configs.daysbg)      # mimic day frames color, font    
        tryfontconfig(select.listbox, Configs.daysfont)
        select.listbox.config(border=2, relief=RAISED)     # mimic day frames appearance
        select.config(border=5, bg='black')                # mimic month window appearance

        # colorize events in the listbox; items have color only 
        for (index, icsdata) in enumerate(dayeventslist):
            monthwindow.colorize_listitem(
                select.listbox, index, icsdata.category, icsdata.calendar)
        
        self.dialogwin = dialog   # [1.4] for run()


#====================================================================================
# Main logic
#====================================================================================

def main(prototype=PROTO):                         # prototype now fully deprecated
    try:
        icsfiletools.init_default_ics_file()       # if none on first run (or bad path)
        icsfiletools.parse_ics_files()             # makes CalendarsTable, EventsTable
    except:
        startuperror(                              # [1.5] GUI popup, not console only
            'Error while loading calendar.\n\n'
            'Check your "icspath" setting in frigcal_configs.py first.  '
            "Then check your calendar folder's permissions, and your "
            "calendar data's validity.\n\n"
            'Python exception text follows:\n\n%s\n%s'
            % (sys.exc_info()[0], sys.exc_info()[1]))
    else:
        # [2.0] make sentinel file in cwd to signal
        # launcher to close (if run), ignore errors
        try:
            open('.frigcal-is-active', 'w').close()
        except:
            pass
        
        # the normal bit
        root = Tk()
        main = MonthWindow(root)
        
        # [2.0] on Mac, customize app-wide automatic top-of-display menu
        fixAppleMenuBar(window=root,
                        appname=PROGRAM,
                        helpaction=lambda: webbrowser.open(HELPFILE),
                        aboutaction=None,
                        quitaction=main.onQuit)    # app-wide quit: save/ask

        if RunningOnMac:
            #--------------------------------------------------------------------
            # [2.0] required on Mac OS X (only), else the checkbuttons in the
            # main window are not displayed in Aqua (blue) active-window style
            # until users click another window and click this program's window;
            #
            # this is a bug in AS's Mac Tk 8.5 -- it's not present in other Tk 
            # ports, and IDLE search dialogs have the same issue;  for reasons 
            # TBD, it's enough to use just the lift() below for frigcal when 
            # it is run from a command line, but the full bit here is required 
            # when run by mac pylaucher on a click;  ditto for pymailgui, but 
            # mergeall requires all 3 steps in both contexts (it's special?...);
            # caveat: can still lose active style on iconify and common dialogs;
            #
            # UPDATE: focus is now restored after common dialog closes by a 
            # focus_force(), and on deiconifies (unhides) by catching Dock 
            # clicks and running the heinous hack copied from mergeall below;
            #--------------------------------------------------------------------
  
            # fix tk focus loss on startup
            root.withdraw()
            root.lift()
            root.after_idle(root.deiconify)

            # fix tk focus loss on deiconify
            def onReopen():
                #print(root.state())    # always normal
                root.lift()
                root.update()
                temp = Toplevel()
                temp.lower()
                temp.destroy()
            root.createcommand('::tk::mac::ReopenApplication', onReopen)

        root.mainloop()

        # [2.0] clean up sentinel file on exit,
        # else it's deleted on next launcher run
        try:
            os.remove('.frigcal-is-active')
        except:
            pass


if __name__ == '__main__':
    main()
