#!/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 / events on minimize/restore # (and ditto for ), 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 / 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('', onMonthHide) # month minimize: image too root.bind('', 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('', 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 /, but then not usable to edit summary text! # map to more descriptive callback names of buttons, not vice-versa [1.3] root.bind('', lambda tkevent: self.onPrevMonthButton()) root.bind('', lambda tkevent: self.onNextMonthButton()) root.bind('', lambda tkevent: self.onPrevYearButton()) # Shift + arrow root.bind('', lambda tkevent: self.onNextYearButton()) root.bind('', 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('', lambda e: self.onLeftClick_Day__Create(reldaynum)) daylab.bind('', lambda e: self.onLeftClick_DayNum__Select(reldaynum)) elif Configs.clickmode == 'mouse': # single: move current day shade only [1.2] dayfrm.bind('', lambda e: self.set_and_shade_rel_day(reldaynum)) daylab.bind('', lambda e: self.set_and_shade_rel_day(reldaynum)) # double: open create-event or select-event [1.3] dialogs (moves shade) dayfrm.bind('', lambda e: self.onLeftClick_Day__Create(reldaynum)) daylab.bind('', lambda e: self.onLeftClick_DayNum__Select(reldaynum)) # single right, day and daynum, both modes: paste via prefilled create dialog dayfrm.bind('', lambda e: self.onRightClick_Day__Paste(reldaynum)) daylab.bind('', 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('', lambda e: self.onRightClick_Day__Paste(reldaynum)) daylab.bind('', lambda e: self.onRightClick_Day__Paste(reldaynum)) dayfrm.bind('', lambda e: self.onRightClick_Day__Paste(reldaynum)) daylab.bind('', 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: event single left-click or press = built-in focus for edit (and hover-in if touch), and performs the update; in touch mode: 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 - some text may require later scrolling """ if Configs.clickmode == 'mouse': # event double-left-click or double-press: open view/edit dialog efld.bind('', lambda e: self.onLeftClick_Event__Edit(edate, icsdata, efld)) # event Enter-key-press (after focus): update summary text only efld.bind('', 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('', 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('', 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('', 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('', lambda e: self.onRightClick_Event__CutCopy(e, edate, icsdata)) efld.bind('', 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 =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()