File: Frigcal/code/Frigcal--source/searchgui.py

"""
========================================================================
CALENDAR SEARCH (part of Frigcal)

Implement the search screen's methods, used to find events by field 
values and open their months' screens.  A class for screen's methods.

Persistent screen:
    Field selector, with All
    Input search-term text field
    Search button
    Results list: taps open Month screen on event's day (not event)
========================================================================
"""

from common import *

# for dates and events
from storage import EventsTable

# py date tools
import calendar
from datetime import datetime

# text lines in list gui
from kivymd.uix.list import OneLineListItem

# many-matches warning
# import main for ConfirmDialog fails: use Kivy Factory



class SearchGUI:

    def __init__(self, app, screen):
        self.app = app
        self.screen = screen
        self.nicefields = ('All', 'Title', 'Note', 'Calendar', 'Category')
        self.make_field_name_menu(screen)



    def fix_widgets_for_theme(self):

        """
        Ad-hoc theme fixes, called on theme changes
        Some already-built widgets do not auto-adjust
        """

        ids = self.screen.ids
        for widget in (ids.searchfor, ids.searchin):
            widget.text_color_normal = self.app.manual_theme_color_fg()
            widget.text_color_focus  = self.app.manual_theme_color_fg()



    def make_field_name_menu(self, screen):

        """
        Build field-name pulldown ahead of time.
        A sequence of list items with a 'caller'. 
        """

        menu_items = [                              # a list of dicts
            dict(viewclass='OneLineListItem',
                 text=nicelabel,
                 on_release=
                     lambda nicelabel=nicelabel:    # else last loop-var value
                         self.on_search_field_menu_tap(nicelabel, screen),
            ) 
            for nicelabel in self.nicefields
        ]

        # bind nested MDLabels' font_size+name _after_ creation else kivmd overrides
        # PUNT/TBD: the OLLIs don't seem to be accessible here?

        self.field_menu = MDDropdownMenu(
            items=menu_items,
            caller=screen.ids.searchin,

            # else default 4 may run into android edge-to-edge navbar
            border_margin=dp(60),

            # else focus stuck on if using keyboard_mode
            # on_dismiss=lambda: self.on_search_category_menu_dismiss(screen),

            # set early in .kv to avoid onscreen keyboard (e.g., on android)
            # screen.ids.searchin.keyboard_mode = 'managed' 

            # width_mult=4,
            # max_height=dp(112),         # this helps edge-to-edge too (add requires it)
        )



    '''TBD
    def on_search_category_menu_dismiss(self, screen):
        
        """
        Required to clear focus on menu-caller textfield when menu closed.
        A kivy bug, possibly trigerred by keyboard_mode = 'managed'.  
        TBD: this wasn't called as coded, and proved moot post readonly.
        """

        screen.ids.searchin.focus = False    # unfocus the caller widget
        Window.release_all_keyboards()       # release any active keyboards
    TBD'''



    def on_search_field_touched(self, touch, screen):
        
        """
        Open field-name menu, per .kv's on_focus.
        Must use [if self.focus:] in .kv, else many calls.
        """
        
        trace('search.on_field_focus')
        if not screen.ids.searchin.collide_point(*touch.pos):    # or touch.x, touch.y
            return False

        #screen.ids.searchin.focus = True
        #screen.ids.searchin.keyboard_mode = 'managed'    # no android on-screen keyboard

        self.field_menu.open()
        return True    # consume touch event


    
    def on_search_field_menu_tap(self, nicelabel, screen):

        """
        Close field-name menu, set field text.
        Caution: user can still type anything in the textfield.
        kb 'managed' precludes keyboard input onscreen but not physical (e.g., PCs).
        """

        trace('search.on_field_menu_tap')
        self.field_menu.dismiss()
        screen.ids.searchin.text = nicelabel



    def on_search_button(self, screen, warnmatches=200):

        """
        Assume this is too quick to warrant a thread.
        screen is the SearchScreen MDScreen instance.
        """

        trace('search.on_search')

        # fetch inputs from gui
        searchfor = screen.ids.searchfor.text
        searchin  = screen.ids.searchin.text

        # validate textfields: user can input anything, despite menu
        if searchfor == '':
            self.app.info_message('Empty "Search for" value', usetoast=True)
            return 
        if searchin not in self.nicefields:
            self.app.info_message('Invalid "Search in" value', usetoast=True)
            return

        # nicelabel => ics attr name
        searchin = searchin.replace('Note', 'Description').replace('Title', 'Summary')

        # neutralize case differences for search
        searchfor = searchfor.casefold()                    # better than .lower()
        searchin  = searchin.casefold()

        # search calendars table for matches
        matches = []
        for (date, dateEvents) in EventsTable.items():      # for all days with events
            for (uid, event) in dateEvents.items():         # for all events in the day

                # get event's field text
                if searchin == 'all':                       # any() is more complex
                    searchtext = '\n'.join(
                        [event.summary, event.description, event.category, event.calendar])
                else:
                    searchtext = getattr(event, searchin, '')

                if searchfor in searchtext.casefold():
                    # order parts for sorting ahead
                    (m, d, y) = date.as_tuple()
                    dateforsort = (y, m, d)
                    matches.append((dateforsort, event.calendar))    # date + calname

        if not matches:
            # tell user and leave prior results in list
            # TBD: or clear priors? may be useful, and message should suffice
            self.app.info_message('No matches found', usetoast=True)
 
        elif len(matches) < warnmatches:
            # clear list and populate it with new search results
            self.fill_search_matches(matches, screen)

        else:
            # warn user about possible pause/hang
            # nit: RecycleView would lift size limits, but it's arguably
            # obfuscated, and too many matches is still arguably unusable

            message = (
                '[b]Many search matches[/b]'
                '\n\n'
               f'Your search found {len(matches)} matches, which may cause this '
                'app to pause or hang.  To avoid this, tap Cancel and refine your '
                'search.  To view results anyway, tap Confirm and accept the risk.')

            confirmer = Factory.ConfirmDialog(
                            message=message,
                            onyes=lambda root: 
                                [self.app.dismiss_popup(root),
                                 self.fill_search_matches(matches, screen)],
                            onno=self.app.dismiss_popup)

            popup = Popup(
                        title='Confirm Search Results',
                        content=confirmer,
                        auto_dismiss=False,            # ignore taps outside
                        size_hint=(0.9, 0.8),          # phones wide, e-2-e short
                        **self.app.themeForPopup())    # fg/bg colors if 'light'

            self.app.popupStack.push(popup)
            self.app.popupStack.open()                 # show top Popup with Dialog



    def fill_search_matches(self, matches, screen):

        """
        (Re)fill search-screen list with matches ordered by dates.
        Kivy 1.x docs say that add_widget() adds to the beginning 
        of the list for the default index=0; this does not seem so...
        """
 
        # mod results label
        labelwidget = screen.ids.resultslabel
        labelwidget.text = f'Matching Days: {len(matches)}'   # hint_text if textfield

        # clear prior-search matches
        listwidget = screen.ids.searchresults
        listwidget.clear_widgets()               # runs remove_widget() for each item 
 
        # sort descending = newest first
        matches.sort(reverse=True)

        # format and add matches to list
        for index, ((y, m, d), calname) in enumerate(matches):
            listtext = f'{y:4}-{calendar.month_abbr[m]}-{d:02} ({calname})'

            listitem = OneLineListItem(
                           text=listtext,
                           #shorten=False,                  # won't do hscroll: see .kv
                           on_press=self.on_result_tap)

            listwidget.add_widget(listitem)                 # index=index else widget top?

            # bind nested MDLabel's font_size+name _after_ creation else kivmd overrides
            listitem.ids._lbl_primary.font_size = self.app.dynamic_font_size
           #listitem.ids._lbl_primary.font_name = self.app.dynamic_font_name    # CUT



    def on_result_tap(self, gotoitem):

        """
        Open data in Month screen on result-list tap.
        Same as Go-To picker-dialog OK, with parsing.
        """

        trace('search.on_result_tap')          
        gototext = gotoitem.text[:11]                         # yyyy-mmm-dd: 2025-Dec-02
        gotodate = datetime.strptime(gototext, '%Y-%b-%d')    # py datetime.date object
        self.app.on_goto_date(None, gotodate, None)           # same as Go To dialog OK