""" ======================================================================== 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