""" ===================================================================================== [3.0] Provide calendar search in GUI, instead of former command-line script. In the GUI, a Find button replaces prior and rarely (if ever) used Images toggle. This supercedes the former (still included) searchcals.py 2.0 command-line script, and is much faster, and much more convenient. To use in main frigcal.py code: (install Find button in gui) import searchcals_dialog def onFindButton(self): search_dialog.FindInputsDialog(self) ===================================================================================== """ import frigcal as fc # mind the circular import: frigcal-main.py from sharednames import Configs, PROGRAM, VERSION import calendar # py stdlib: for month num -> abbr from tkinter import * from tkinter.messagebox import showinfo #==================================================================================== # Find-inputs dialog [3.0] #==================================================================================== class FindInputsDialog(fc.Dialog): """ [3.0] Top-level entry to search: inputs -> matches -> month, all nonmodal. Use already-parsed-and-loaded events index, instead of reparsing ics files like the former (but still supported) searchcals.py script; this also ensures that any new mods not yet saved to ics files are picked up. """ def __init__(self, monthwindow): self.root = monthwindow # creator's window, for Dialog [1.6] self.modal = False # False=nonblocking, >one allowed self.make_widgets() self.run() # wait for user action [1.4] def make_widgets(self): # new window specs popup = Toplevel() # new dialog window, default Tk root self.dialogwin = popup # [1.4] for Dialog.run() fc.try_set_window_icon(popup) # replace red tk window icon [1.2] popup.title('%s %.1f - Find: Inputs' % (PROGRAM, VERSION)) fc.trybgconfig(popup, Configs.eventdialogbg) # pack first = clip last on shrink (retain on resizes) toolbar = Frame(popup, relief=RIDGE) toolbar.pack(side=BOTTOM, fill=X) fc.trybgconfig(toolbar, Configs.eventdialogbg) # action buttons in toolbar at bottom searchbtn = Button(toolbar, text='Search', command=self.onSearch) cancelbtn = Button(toolbar, text='Cancel', command=self.onCancel) for btn in searchbtn, cancelbtn: fc.tryfontconfig(btn, Configs.controlsfont) fc.killbtnpaddingmacos(btn) # [3.0] else padded fc.killbtnbordermacos(btn, Configs.eventdialogbg) # [3.0] else border searchbtn.pack(side=LEFT, expand=NO) cancelbtn.pack(side=RIGHT, expand=NO) # bind Enter keypress (where available) to be same as Search button popup.bind('', lambda e: self.onSearch()) # frame for gridding inputs inputsfrm = Frame(popup, relief=RIDGE, border=2) inputsfrm.pack(side=TOP, expand=YES, fill=BOTH) fc.trybgconfig(inputsfrm, Configs.eventdialogbg) inputsfrm.grid_columnconfigure(1, weight=1) # make text grid col expandable # label + entry for search text input searchlbl = Label(inputsfrm, text='Text to find:', relief=RIDGE) searchfor = Entry(inputsfrm, width=30) # 30 chars wide initially searchlbl.grid(row=0, column=0, padx=2, sticky=NSEW) searchfor.grid(row=0, column=1, sticky=NSEW) # focused on open ahead self.searchfor = searchfor # label + pull-down menu for field to search (!= MenuButton for cut/paste) modelbl = Label(inputsfrm, text='Field to search:', relief=RIDGE) searchmodes = 'Summary', 'Description', 'Category', 'All' modevar = StringVar() modemnu = OptionMenu(inputsfrm, modevar, *searchmodes) modelbl.grid(row=1, column=0, padx=2, sticky=NSEW) modemnu.grid(row=1, column=1, sticky=NSEW) modevar.set('All') # initialize to common case self.modevar = modevar # apply configs """no: match event dialogs labels, make dark/light-mode dependent for lbl in searchlbl, modelbl: fc.trybgconfig(lbl, Configs.eventdialogbg) # [2.0] Unicode replacement fc.tryfgconfig(lbl, Configs.eventdialogfg) # [3.0] else white fc.tryfontconfig(lbl, Configs.eventdialogfont) """ for inp in searchfor, modemnu: fc.trybgconfig(inp, Configs.eventdialoginpbg) # [3.0] != all dlg fc.tryfgconfig(inp, Configs.eventdialogfg) fc.tryfontconfig(inp, Configs.eventdialogfont) searchfor.config(insertbackground=Configs.eventdialogfg) # [3.0] else white searchfor.focus() def onCancel(self): self.dialogwin.destroy() def onSearch(self): # assume too quick to warrant thread searchfor = self.searchfor.get() searchmode = self.modevar.get() matches = [] for (date, dateEvents) in fc.EventsTable.items(): for (uid, event) in dateEvents.items(): # get event's text if searchmode == 'All': searchtxt = '\n'.join([event.summary, event.description, event.category]) else: searchtxt = getattr(event, searchmode.lower(), '') # neutralize case differences (probably a good thing) searchkey = searchfor.lower() searchtxt = searchtxt.lower() if searchkey in searchtxt: # order parts for later sorting here (m, d, y) = date.as_tuple() dateforsort = (y, m, d) matches.append((dateforsort, event.calendar)) # date + calname if not matches: # notfound dialog, leave search dialog open showinfo(f'{PROGRAM}: no matches found', f'No matches found for "{searchfor}" in {searchmode}.', parent=self.dialogwin) self.dialogwin.focus_force() else: # popup nonmodal select list of ordered dates, leave search dialog open #self.dialogwin.destroy() FindMatchesDialog(self.root, matches, (searchfor, searchmode)) #==================================================================================== # Find-matches dialog [3.0] #==================================================================================== class FindMatchesDialog(fc.Dialog): """ Display matches for click/tap to open. """ def __init__(self, monthwindow, matches, searchinfo): self.modal = False self.root = monthwindow.root # creator's window, for Dialog [1.6] self.make_widgets(monthwindow, matches, searchinfo) self.run() # wait for user action [1.4] def make_widgets(self, monthwindow, matches, searchinfo): # new window specs popup = Toplevel() # new dialog window, default Tk root self.dialogwin = popup # [1.4] for Dialog.run() fc.try_set_window_icon(popup) # replace red tk window icon [1.2] popup.title('%s %.1f - Find: Matches' % (PROGRAM, VERSION)) fc.trybgconfig(popup, Configs.eventdialogbg) # sort descending = newest first matches.sort(reverse=True) # format matches for display and opens listtxts, gototxts = [], [] for ((y, m, d), calname) in matches: listtxts.append(f'{y:4}-{calendar.month_abbr[m]}-{d:02} ({calname})') gototxts.append(f'{m}/{d}/{y}') # 'mm/dd/yyyy', size+pad not needed # label at top (default colors/font) topmsg = Label(popup, text=f'{len(gototxts)} ' f"match{'es' if len(gototxts) > 1 else ''} " # py<3.12 f'for "{searchinfo[0]}" in {searchinfo[1]}') topmsg.pack(side=TOP, fill=X) # draw selection list in this dialog window ScrolledMatchesList(popup, monthwindow, listtxts, gototxts, True, Configs.eventdialogfont) #==================================================================================== # Scrolled-list widget [3.0] #==================================================================================== class ScrolledMatchesList(Frame): """ Selections list inside matches dialog. Taken from PP4E's Gui.Tour and adapted. And custom version of ./scrolledlist.py. """ def __init__(self, parent, monthwindow, listtxts, gototxts, horizscroll, listfont): Frame.__init__(self, parent) self.pack(expand=YES, fill=BOTH) # make me expandable self.gototxts = gototxts self.monthwindow = monthwindow self.parent = parent self.make_widgets(listtxts, horizscroll, listfont) def make_widgets(self, listtxts, horizscroll, listfont): sbar = Scrollbar(self) list = Listbox(self, relief=RIDGE, border=2, width=30) # else width is 20 if listfont: list.config(font=listfont) if horizscroll: hbar = Scrollbar(self, orient='horizontal') sbar.config(command=list.yview) # xlink sbar and list list.config(yscrollcommand=sbar.set) # move one moves other if horizscroll: hbar.config(command=list.xview) list.config(xscrollcommand=hbar.set) sbar.pack(side=RIGHT, fill=Y) # pack first=clip last if horizscroll: hbar.pack(side=BOTTOM, fill=X) list.pack(side=LEFT, expand=YES, fill=BOTH) # list clipped first for (pos, label) in enumerate(listtxts): # add to listbox, 0..N-1 list.insert(pos, f' {label} ') # or insert(END,label) selector = '' if Configs.clickmode == 'mouse' else '' list.bind(selector, self.onListSelect) # set event handler #list.selection_set(0) # no: fix tap1 fail for 'mouse' self.listbox = list #use defaults #list.config(selectmode=SINGLE, setgrid=1) # select,resize modes def onListSelect(self, event): # failed in single-tap mode: not yet selected """CUT index = self.listbox.curselection() # on list double-click #label = self.listbox.get(index) # fetch selection text gototxt = self.gototxts[index[0]] # [3.0] just index here: (6,) CUT""" # works for both single- and double-tap modes # but fails for double if do focus_force below... choice = self.listbox.nearest(event.y) # on list single-click gototxt = self.gototxts[choice] # [3.0] just index here: 6 #self.update() # doesn't help self.monthwindow.onGoToDate(datetxt=gototxt) self.monthwindow.root.lift() #self.monthwindow.root.focus_force() #==================================================================================== # Self-test [3.0] #==================================================================================== if __name__ == '__main__': root = Tk() Button(root, text='Open', command=lambda: FindInputsDialog(root)).pack() mainloop()