File: frigcal-products/unzipped/searchcals_dialog.py

"""
=====================================================================================
[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('<Return>', 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 = '<Double-1>' if Configs.clickmode == 'mouse' else '<Button-1>'
        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()



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