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