File: android-tkinter/CODE/frigcal.py (original) (raw)
#!/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. Copyright and author: © M. Lutz, 2014-2019, learning-python.com.
#------------------------------------------------------------------------------------
ANDROID version, Jan-Apr 2019 (see "# ANDROID" for all changes)
These changes may be merged into the original code in a later release.
Recent changes (search for date to see changed code):
[Apr2119] Pydroid 3 3.0 broke webbrowser: use os.system(cmd) with a
hardcoded Android activity-manager command line instead
(3.0's DISPLAYbreaksmodule,DISPLAY breaks module, DISPLAYbreaksmodule,BROWSER kills "file://").
[Apr1919] Go back to using Py's webbrowser for help's user guide: it does
work, but iff files use '"file://" and HTML docs use online URLs;
see _openbrowser.py for a demo and more details.
[Apr1219] Reenable "?" help button, and fix it to open the user guide with an
os.system() spawn of an Android activity-manager command instead of
webbrowser; open the online version of help, for its latest changes.
Also: set width of "mm/dd/yyyy" entry field to save some screen space,
and reduce font presets for month and buttons for small-phone fit.
#------------------------------------------------------------------------------------
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
ANDROID [Apr1219] - use online version to pick up latest changes;
other platforms should eventually do this too - web pages morph for browsers often
HELPURL = 'https://www.learning-python.com/frigcal-products/unzipped/UserGuide.html'
[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
ANDROID - avoid two (or more) modal dialog opens if multiple events for one gesture;
pydroid 3's tkinter either triggers multiple events in parallel for a rightclick swipe,
and/or has issues with the modal-dialog code here; the result is that there can be 2+
cut/copy dialogs at once, or a cut/copy along with an event edit/view dialog that hangs;
not clear what triggers this, but the global here is a quick work around for now;
this may have to use a thread lock acquire/release if events are freethreaded (TBD);
EventDialogIsOpen = False
#====================================================================================
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: # ANDROID - (doc only) most fonts were preset to None in frigcal_configs.py # to avoid crashes, but still allow users to reinstate those that work; # courier, helvetica, times, and a few synonyms work - all others crash; # 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
#++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++++
# ANDROID - fill_days() adds month/day to title to accomodate shrunken windows,
# but need to save window type on the window object here for use on navigation;
#
self.windowtype = windowtype
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
# ANDROID [Apr1219] - fix/shrink width to reclaim some screen space on phones.
# Also force monospace courier - tkinter should give enough room for 10 "0"s for
# the default non-mono helvetica, but on some phones truncates the last char.
# Caveat: this font's size should really be configurable, as a separate setting.
#
dateent.config(width=11) # 10 (+1 so can tap after last char)
dateent.config(font='courier 7 normal') # monospace, and large enough to read
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) # ANDROID [Apr1219] forced above
tryfontconfig(datebtn, Configs.controlsfont) # ANDROID [Apr1219] smaller preset
# help='?': pop up the html help file in a web browser [1.2];
#
# ANDROID [Apr1219]: Py webbrowser does not work on Android (yet?),
# so spawn a shell command using the $BROWSER preset in Pydroid 3:
# "am start --user 0 -a android.intent.action.VIEW -d %s".
# ANDROID [Apr1919]: webbrowser works if use an online URL for HTML.
# ANDROID [Apr2119]: not anymore in Pydroid 3 3.0 - back to os.system.
#
brw = 'am start --user 0 -a android.intent.action.VIEW -d %s'
helpbtn = Button(datefrm, text='?', relief=RIDGE,
command=lambda: os.system(brw % HELPURL))
#command=lambda: webbrowser.open(HELPURL))
#command=lambda: webbrowser.open(HELPFILE))
helpbtn.pack(side=RIGHT)
tryfontconfig(helpbtn, Configs.controlsfont)
#helpbtn.config(state=DISABLED) # ANDROID [Apr1219] - webbrowser failed initially
# [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) # ANDROID [Apr1219] smaller preset
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!)
# ANDROID - +True: enables pydroid 3 rightclick = drive-by swipe
if True or 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)
# ANDROID - add ": mmm yyyy" to title for smaller windows on phones,
# else month label may be truncated - and window's content unknown...
#
self.root.title('%s %.1f - %s: %s %s' %
(PROGRAM, VERSION, self.windowtype,
calendar.month_abbr[self.viewdate.month()], # use 3-letter form
self.viewdate.year()) ) # this window's year
# 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
bgcolor = color # ANDROID
elif isinstance(color, tuple):
trybgconfig(entry, color[0]) # color=('bg', 'fg') [1.7]
tryfgconfig(entry, color[1])
bgcolor = color[0] # ANDROID
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)
bgcolor = color # ANDROID
# ANDROID - avoid keyboard opens on event taps in default touch mode;
# with readonly, no <return 'break'> is required in the click handler,
# which seems to retain focus (and keyboard) if widget scroll is reset;
# readonly avoids focus, but also requires readonlybackground else grey;
# still must reset the widget in tap handler to remove selection/scroll;
#
if Configs.clickmode == 'touch':
try:
entry.config(readonlybackground=bgcolor)
entry.config(state='readonly')
except:
pass # already got a message
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
# ANDROID - (doc only) formerly <return 'break'> (via <'break')[2]>) but that
# cannot remove focus if selection/scroll is reset, and keyboard opens anyhow;
# instead: used readonly mode, and reset widget state in callback handlers;
#
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, efld)) # ANDROID: +efld
# 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!)
# ANDROID - +True to enable pydroid 3 rightclick = drive-by swipe
if True or RunningOnMac:
efld.bind('<Control-Button-1>',
lambda e: self.onRightClick_Event__CutCopy(e, edate, icsdata, efld)) # ANDROID: +efld
efld.bind('<Button-2>',
lambda e: self.onRightClick_Event__CutCopy(e, edate, icsdata, efld)) # ANDROID: +efld
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);
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
"""
# ANDROID - see global def
global EventDialogIsOpen
if EventDialogIsOpen:
trace('Spurious Event LeftClick')
return
else:
EventDialogIsOpen = True
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)
# ANDROID - dialog may delete event, but must do last to take effect:
# check for existence; select_clear is a synonym for selection_clear;
#
if Configs.clickmode == 'touch' and efld and efld.winfo_exists():
efld.select_clear() # ANDROID - remove selection (may be moot for readonly)
efld.xview(0) # ANDROID - remove scroll (or xview_scroll(-999, UNITS))
# ANDROID - see global def
EventDialogIsOpen = False
def onRightClick_Event__CutCopy(self, tkevent, edate, icsdata, efld=None): # ANDROID - +efld
"""
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
=> 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!);
~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~~
"""
# ANDROID - see global def
global EventDialogIsOpen
if EventDialogIsOpen:
trace('Spurious Event LeftClick')
return
else:
EventDialogIsOpen = True
trace('Got Event LeftClick')
trace('Got Event RightClick')
self.set_and_shade_day(edate.day)
CutCopyDialog(self, tkevent, edate, icsdata) # [1.4] moved to class
# ANDROID - dialog may delete event, but must do last to take effect:
# check for existence; select_clear is a synonym for selection_clear;
#
if Configs.clickmode == 'touch' and efld and efld.winfo_exists():
efld.select_clear() # ANDROID - remove selection (may be moot for readonly)
efld.xview(0) # ANDROID - remove scroll (or xview_scroll(-999, UNITS))
# ANDROID - see global def
EventDialogIsOpen = False # also cleared before cut/copy dialog's Open
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():
# ANDROID - see global def
global EventDialogIsOpen
EventDialogIsOpen = False # enable Open dialog now
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()