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

"""
===============================================================================
MONTH SCREEN'S DAYS, EVENTS, AND DIALOGS (part of Frigcal)

Manage the display of days and events in the app's GUI.
===============================================================================
"""


from common import *
import calendar, time    # py modules

# for dates and events
from storage import Edate, EventData, EventsTable, CalendarsTable

# for icsdata ops
import icsfiletools

# for event-close verify
# import main for ConfirmDialog fails: use Kivy Factory


# for colored emois (a fail: see 'Noto' ahead)
# use [font_name='NotoColorEmoji', markup=True] in event label and text

from kivy.core.text import LabelBase
LabelBase.register('NotoColorEmoji', 
                   'fonts/Noto_Color_Emoji/NotoColorEmoji-Regular.ttf')   # cwd set, / ok


# gui shows 6 weeks, 7 days each
NUM_GUI_WEEKS = 6
NUM_GUI_DAYS  = 7




#====================================================================================
# Tappable and bordered day and event widgets
#====================================================================================


class BorderedDayLayout(MDGridLayout): 

    """
    -------------------------------------------------------
    Define day-cell widget, a grid within the Month-screen
    grid, with borders added by canvas ops as is required
    by Kivy (this code is subtle, and probably be automated).
    Coded here for widgets added dynamically in Python code.

    Originally coded in allscreens.vy in Kivy lang for canvas,
    but coding both here and there generated a startup warning
    and ignored the .kv version.  See allscreens.kv for more.
    
    NB: The wildly implicit Python/Kivy-lang Factory linkage...
 
    - A dynamic class declared in Kvlang as [<MyClass@Button>] 
      is the same as a Python class [MyClass(Button):], and 
      can be both used in Kvlang as [MyClass:] and instantiated
      in Python with [Factory.MyClass()].

    - A class defined in Python as class [MyClass(Widget):] can 
      be used as [MyClass:] in Kvlang because widget classes are
      automaticallyregistered in the global Factory when they 
      are first evaluated.
   
    Hence, the prior Kivy+Python defs of BorderedDayLayout were 
    redundant: define in one place and reference from the other.
    A Kivylang def could have been used here via Factory(MyClass),
    but we need additional methods here for taps.

    (We still get a KivyMD startup warning "Version 1.2.0 is 
    deprecated and is no longer supported. Use KivyMD version
    2.0.0 from the master branch..." - even though 1.2.0 is 
    the pip install default for 'kivy'.  How rude is that?)

    Two absolute pixels is min width: one renders uneven lines.
    Color alt: [r, g, b, a], rgb=(0.0..1.0 or 0..255), a=0..1.

    ~~~
    The prior equivalent coding in Kivy lang:
    
    <BorderedDayLayout@MDGridLayout>:
        cols: 1
        border_width: 2
        border_color: app.theme_cls.primary_color    

        canvas.after:
            Color:
                rgba: root.border_color
            Line:
                width: root.border_width
                rectangle: self.x, self.y, self.width, self.height
    ~~~
    -------------------------------------------------------
    """

    cols         = 1                   # a stack of widgets
    border_width = 2                   # abs pixels, 1 too narrow
    #border_color = ListProperty()     # (unused) bind for dynamic updates: [r, g, b, a]


    def __init__(self, **kwargs):
        super().__init__(cols=self.cols, spacing=[0, dp(6)])      # space for daynum taps: [hor, ver]
        self.bind(pos=self.draw_border, size=self.draw_border)    # moves+resizes
        self.draw_border()                                        # initial draw

    def draw_border(self, *args):
        self.canvas.after.clear()                           # avoid mutiple borders?
        with self.canvas.after:
            Color(rgba=self.app.theme_cls.primary_color)    # set border color by theme
            Line(                                           # draw border rectangle
                width=self.border_width,
                rectangle=(self.x, self.y, self.width, self.height)
             )




class Tappable_Bordered_DayCell(ButtonBehavior, BorderedDayLayout):

    """
    -------------------------------------------------------
    Combine button taps, grid, and border.
 
    UPDATE: this code's on_release() is now largely unused 
    because day-cell area taps are now rarely triggered: the 
    tappable daynum label (ahead) spans most of the top of 
    the day cell, and the scrolled event grid overlays the 
    rest of the day cell (even if empty) and is very useful.

    Nevertheless, there is a small area between daynum label
    and event grid that can still be tapped (especially with
    a mouse).  To avoid confusion, this area's press/release
    events are simply rerouted to the daynum cell above it.
    As coded, this also supports long-press in the small area.

    In general, users now make new events for days with events
    by using Add in the event-list dialog via daynum area taps.
    Superclass functionality is still used, but taps caught 
    here are just rerouted; prior code retained for reference.
    -------------------------------------------------------
    """

    doubletap_threshold = 0.2     # time to watch for tap 2 in MonthScreen


    def __init__(self, app, **kwargs):

        self.app = app
        super().__init__(**kwargs)


    def on_press(self):
        
        # reroute to label
        self.daynumlabel.on_press()    # daynumlabel set by register_day_actions()


    def on_release(self):

        # reroute to label
        self.daynumlabel.on_release()

        """CUT
        UNUSED: daynum label and scrolled event grid overlay day.
        Formerly: never run if MonthScreen catches swipe, and 
        deferred event cancelled by MonthScreen if double tap

        super().on_release()
        if not self.app.pending_tap_event:
            self.app.pending_tap_event = \
                Clock.schedule_once(
                       (lambda dt: self.on_single_tap()), 
                       self.doubletap_threshold)
        CUT"""




class Tappable_DayNumLabel(ButtonBehavior, MDLabel): 
 
    """
    -------------------------------------------------------
    Combine button taps and label, no border.
    Or use on_touch_down() and on_touch_up().
    Or use MDTextButton, but GridLayout less clearcut.
    -------------------------------------------------------
    """

    longpress_threshold = 0.3    # time down till longpress here
    doubletap_threshold = 0.2    # time to watch for tap 2 in MonthScreen
    clock = time.perf_counter    # time.time works too


    def __init__(self, app, **kwargs):

        self.app = app
        super().__init__(**kwargs)


    def on_press(self):

        super().on_press()
        self.starttime = self.clock()    # for longpress


    def on_release(self):

        """
        Never run if MonthScreen catches swipe
        Deferred event cancelled by MonthScreen if double tap
        Also times down~up duration to detect longpress gesture
        """

        super().on_release()
        endtime = self.clock()
        duration = endtime - self.starttime          # time since one press down

        if duration >= self.longpress_threshold:
            self.on_longpress()                      # disjoint from double-tap

        elif not self.app.pending_tap_event:         # double-tap may override 
            self.app.pending_tap_event = \
                Clock.schedule_once(
                    (lambda dt: self.on_single_tap()), 
                    self.doubletap_threshold)




class Colorize:
    
    """
    -------------------------------------------------------
    Used to pick and set event color bar.  This cannot be 
    part of a Tappable label beause there is none for Add
    when it need to get the '(none)' default for category.
    Every widget that needs this makes an instance; could 
    use a global or singleton, but it's very lightweight.
    -------------------------------------------------------
    """

    def __init__(self, app):
        self.app = app
        self.default_color = app.theme_cls.accent_color



    def pick_border_color(self, calendar, category):

        """
        Pick a color for event's top line from event calendar
        and/or category fields along with calendar color settings, 
        defaulting to the per-theme color.  Category takes 
        precedence of calendar if both have color settings,
        and is simply a color name in 4.0 so that it will be
        interpreted the same on all devices sans settings sync.

        UPDATE: in FC 4.0, calendars can no longer be colorized,
        because doing so requires users to sync or reset the settings
        file on each device they use.  Instead, colors are now based 
        only on event category, which must be set to one of a list of 
        preset and unchangeable colors.  This ensures that a calendar 
        file renders the same on all devices with app sans user action.

        A conversion script in tools/ maps old calendar and category 
        colors to new category colors for users of legacy Frigcal.
        black/white map to off-black/white to match Month bg color,
        which means no color bar in the matching color theme only.
        """
        
        allcolors = self.app.settings.categorycolors.keys()    # presets

        # calendar per Settings, skip if not a known color name
        # calcolor = self.app.settings.calendarcolors.get(calendar, None)
        # calcolor = calcolor if calcolor in allcolors else None

        # category=color per event dialog, always a known color name
        calcolor = None
        catcolor = category if category in allcolors else None

        pickcolor = catcolor or calcolor                # cat (if any) before cal (if any)
        if pickcolor:
            if pickcolor[0] == '#':
                return get_color_from_hex(pickcolor)    # kivy.utils: #rrggbb => (rbga)
            elif pickcolor == 'black':
                return get_color_from_hex('#121212')    # black-ish: match dark Month bg
            elif pickcolor == 'white':
                return get_color_from_hex('#fafafa')    # white-ish: match light Month bg
            else:
                return colormap.get(pickcolor)          # kivy.utils: name => # => (rgba)
        else:
            return self.default_color                   # noneempty=default per app theme
 


    def change_border_color(self, eventlabel, calendar, category):

        """
        Change event's top line from event calendar/category.
        eventlabel auto updates because its Color instruction is
        bound to watch for updates on its border_color property.

        Updates could just run fill_events(), but it's overkill.
        [UPDATE: updates do run fill_events() now, else lengths
        of event color bars may be uneven till next Month refresh.]
 
        Editorial: this uses a bound property because that's the
        Kivy paradigm, but this seems wildly implicit to the point
        of being obfuscating.  Why not just run the update call
        explicity in code where the attribute is changed?  Bound
        properties may help in kivy lang but seem dubious in py.
        """

        eventlabel.border_color = self.pick_border_color(calendar, category)

        # or hey, we could just run this and skip the implicit bind...
        # Clock.schedule_once(lambda dt: eventlabel.draw_border())




class Tappable_Bordered_EventLabel(ButtonBehavior, MDLabel): 

    """
    -------------------------------------------------------
    Combine button taps and label, embed border.
    Used for events in both Month-screen day grid and 
    event-list popop.  The event-list popup is the same 
    but has no double-tap or swipes.
    -------------------------------------------------------
    """

    doubletap_threshold = 0.2         # time to wait for tap 2 in MonthScreen

    border_width = 2                  # abs pixels, 1 too narrow
    border_color = ListProperty()     # bind for dynamic updates: [r, g, b, a]


    def __init__(self, app, dayeventsgrid, color, **kwargs):

        super().__init__(**kwargs)
        self.app = app
        self.dayeventsgrid = dayeventsgrid
        self.border_color = color

        self.bind(pos=lambda *args:  
                      Clock.schedule_once(lambda dt: self.draw_border()),    # defer till sizes set 
                  size=lambda *args: 
                      Clock.schedule_once(lambda dt: self.draw_border()),    # moves+resizes
                  border_color=lambda *args: 
                      Clock.schedule_once(lambda dt: self.draw_border()),    # event color updates
        )

        self.draw_border()                                                   # initial draw


    def draw_border(self, *args):

        """
        Use simple (x1, y1, x2, y2) line above text.
        Day-cell rectangles work too like this but are too busy:
        Line(..., rectangle=(self.x, self.y, self.width, self.height))
        """

        self.canvas.after.clear()                           # avoid mutiple borders?
        with self.canvas.after:
            Color(rgba=self.border_color)                   # set border line color 
            Line(                                           # draw border rectangle
                width=self.border_width, 
                points=(self.x, 
                        (self.y + self.height), 
                        (self.x + self.dayeventsgrid.width), 
                        (self.y + self.height))
            )


    def on_release(self):

        """
        Never run if MonthScreen caught swipe, no longpress
        Deferred event cancelled by MonthScreen if double tap
        """

        super().on_release()
        if not self.app.pending_tap_event:
            self.app.pending_tap_event = \
                Clock.schedule_once(
                       (lambda dt: self.on_single_tap()),
                       self.doubletap_threshold)




class Tappable_Bordered_EventLabel_List_Dialog(Tappable_Bordered_EventLabel): 

    """
    -------------------------------------------------------
    Reuse event day-grid class for border in events list, 
    sans double taps and swipes.
    -------------------------------------------------------
    """

    def on_release(self):

        """
        A modal popup: no Month screen or double taps here
        """

        self.on_single_tap()




#====================================================================================
# Month-screen widgets manager
#====================================================================================


class MonthGUI:

    def __init__(self, app, startdate=None):
        self.app = app                  # link back to main.py's App instance

        self.monthlabel = None          # monthname label, for refills on navigation
        self.daywidgets = []            # [(daycell, daynumlabel, dayeventsgrid)], all, for refills
        self.eventwidgets = {}          # {uid: evententry}, all displayed, for update/delete, refill
        self.app.copied_event = None    # clipboard for copy/cut + paste (longpress) ops 

        # set up current view-date data
        self.viewdate = ViewDateManager()    # displayed month date and day-numbers list manager
        self.viewdate.settoday()             # initialize date object and days list to current date
        if startdate:
            self.viewdate.setdate('%s/%s/%4s' % startdate.mdy())

        # make days grid and month label, register day callbacks
        self.make_month_screen_widgets()

        # make colorize utility 
        self.colorize = Colorize(app)
        
        # populate Month screen, register event callbacks
        # this must be done _after_ calendars ask + load in main.py

        #self.fill_days()                    # day nums + month name, callbacks preset
        #self.fill_events()                  # resets event callbacks on each refill



    #------------------------------------------------------------------------------------
    # GUI widget setup: grid + month, with callbacks
    #------------------------------------------------------------------------------------


    def make_month_screen_widgets(self): 
 
        """
        Build empty month-screen label and day widgets and register
        their callbacks once at startup.  Fill/clear/refill later:
        initially in on_start, on navigations, on load thread exits.

        ~~~
        The scroll/grid events py code began as inline kivylang code:

        class EventsScroll(ScrollView): pass
        scroller = Builder.load_string('''
        #:import ScrollEffect kivy.effects.scroll.ScrollEffect
        EventsScroll:
            size_hint: (1, 1)
            effect_cls: ScrollEffect
            GridLayout:
                id: eventscell
                cols: 1
                size_hint: (None, None)
                size: self.minimum_size
        ''')
        scrollee = scroller.ids['eventscell']
        daycell.add_widget(scroller)

        ~~~
        Workaround: ScrollView initially wouldn't stay at the top of its 
        container (it stuck to the bottom with an empty area above it) 
        if it was scrolled to content bottom and the window was resized 
        larger.  A single bogus manual scroll-up repositioned the scroll 
        correctly.  This happened on both PCs and phones (on rotations).

        The fix, found only by trial-and-error mods to every #$!@ ScrollView 
        attribute: [always_overscroll=False].  An [effect_cls=ScrollEffect]
        was unrelated, as the default snap-back effect has the same glitch
        in Kivy 2.3.1 and also often makes borders "throb" post scrolls.
        Scrolled labels may also blank on pause+resume: see App.on_pause().

        The following did not help... and neither did adding a [pos_hint = 
        {'top': 1}'] to the events grid; changing both day and events 
        grids from GridLayout to BoxLayout with orientation='vertical';
        using MDScrollView and MDGridlayout universally for KivyMD; nor
        the usual Kivy Hail Mary of Clock.schedule_once().  Doc, please!
       
        def kivy_scroll_bug_workaround(*args):
            trace('on_size')
            dayeventscroll.do_scroll_y = True
            dayeventscroll.scroll_y = 1  # or 0
            dayeventsgrid.size_hint_y = None
            dayeventsgrid.height = dayeventsgrid.minimum_height
                
        Window.bind(on_resize=kivy_scroll_bug_workaround)
        dayeventscroll.bind(size=kivy_scroll_bug_workaround)
        # nor this
        dummy = Widget(size_hint=(1, 1))
        daycell.add_widget(dummy)
        ~~~
        """
        
        guiroot = self.app.root
        monthscreen = guiroot.ids.navscreenmgr.get_screen('month')
        monthgrid = monthscreen.ids.monthgrid

        self.daywidgets = []
        for week in range(NUM_GUI_WEEKS):
            for day in range(NUM_GUI_DAYS):
                reldaynum = (week * 7) + day              # grid index, not true

                # entire day package
                daycell = Tappable_Bordered_DayCell(self.app)

                # day number at top
                daynumlabel = Tappable_DayNumLabel(
                    self.app,
                    text='',
                    adaptive_height=True,
                    halign='center',
                    theme_text_color='Secondary',
                )
                daycell.add_widget(daynumlabel)       # spans entire top of day
 
                # scrollable events in rest
                dayeventscroll = MDScrollView(        # auto scrolls on short swipes
                    size_hint=(1, 1),                 # MonthScreen detects long swipes
                    effect_cls=ScrollEffect,          # avoid snap-back animation throb
                    always_overscroll=False,          # Kivy scroll-defect workaround: see above
                )
                dayeventsgrid = MDGridLayout(
                    cols=1,                              # space was 8, 2: more content vs hard to tap
                    spacing=[0, dp(10)],                 # event-tap space: [hor, ver]
                    padding=(dp(4), dp(4), dp(4), 0),    # num-tap space: (l, t, r, b), pad top else top event's line narrower
                    size_hint_y=None,                    # pad left/right to offset from day-cell border
                    size_hint_x=None,                    # enable scrolling: a kivy scourge
                    # plus height and width per ahead
                    # events added later in fill_events
                )

                # set height on minheight == kivylang "height: self.minimum_height" 
                # required for scrolls, with size_hints above: must size grid to scroll

                dayeventsgrid.bind(minimum_height=dayeventsgrid.setter('height'))
                dayeventsgrid.bind(minimum_width =dayeventsgrid.setter('width'))

                dayeventscroll.add_widget(dayeventsgrid)     # scrolled content
                daycell.add_widget(dayeventscroll)           # overlays rest of day cell

                # add entire day cell to month grid
                monthgrid.add_widget(daycell)                                  # 3 grid levels
                self.daywidgets.append((daycell, daynumlabel, dayeventsgrid))  # save for gui updates
                self.register_day_actions(daycell, daynumlabel, reldaynum)     # bind once, at statup

        self.monthlabel = monthscreen.ids.screenlabel
        self.monthlabel.text = '[i]No calendars[/i]'    # fill_days not run if initial chooser closed
        self.prior_shaded_daynumlabel = None



    def register_day_actions(self, daycell, daynumlabel, reldaynum):

        """
        Register all day events once at startup, for both day num and cell.
        Event events registered later in fill_events, and on each navigation.
        """


        def daynumlabel_single_tap(daynumlabel, reldaynum):
            trace(f'daynumlabel single tap: {reldaynum}')
            if self.viewdate.relday_is_in_month(reldaynum):        # a true day in displayed month?
 
                trueday = self.viewdate.index_to_day(reldaynum)
                clickdate = Edate(month=self.viewdate.month(),
                                  day=trueday,
                                  year=self.viewdate.year()) 
                self.set_and_shade_day(trueday)
 
                if not clickdate in EventsTable.keys():            # any events for this day?
                    AddEventDialog(self.app, clickdate)            # no: go to create dialog now
                else:
                    EventListDialog(self.app, clickdate)           # open list dialog for all in day
            
            self.app.pending_tap_event = None                      # clear for next tap


 
        def daynumlabel_longpress(daynumlabel, reldaynum):
            trace(f'daynumlabel longpress: {reldaynum}')

            # though rare, if both single-tap and press+hold quickly,
            # longpress may fire after a single tap has already opened 
            # either an empty day's add-event dialog or 'No calendars 
            # for event' info_message, or a full day's event-list dialog;
            # this may be user error, but Month double-taps are valid; 
            # see also related double-tap checks in all event dialogs;

            if AddEventDialog.is_open or EventListDialog.is_open:
                trace('skipped longpress after add')
                return

            if self.viewdate.relday_is_in_month(reldaynum):    # a true day in this month?

                if not self.app.copied_event:
                    self.app.info_message(
                        '[b]No event to paste[/b]'
                        '\n\n'
                        'Please copy or delete before paste by tapping an event.',
                         usetoast=False)

                else:
                    trueday = self.viewdate.index_to_day(reldaynum)
                    clickdate = Edate(month=self.viewdate.month(),
                                      day=trueday,
                                      year=self.viewdate.year())
                    self.set_and_shade_day(trueday)

                    AddEventDialog(                                # paste=prefilled Add
                        self.app, clickdate,                       # add from clipboard
                        titletype='Paste',                         # copied by event dialog,
                        widgetdata=self.app.copied_event)          # from all gui fields



        def daycell_single_tap(daycell, reldaynum):

            # NO LONGER TRIGGERED - daynum spans entire top, and rest 
            # of day overlayed by scrollable events grid, even if empty:
            # eventful days use Add in event-list dialog on daynum tap,
            # and the day cell's class reroutes taps to the daynum label.

            trace(f'daycell single tap: {reldaynum}')
            if self.viewdate.relday_is_in_month(reldaynum):        # a true day in displayed month?

                trueday = self.viewdate.index_to_day(reldaynum)
                clickdate = Edate(month=self.viewdate.month(),
                                  day=trueday,
                                  year=self.viewdate.year()) 
                self.set_and_shade_day(trueday)

                AddEventDialog(self.app, clickdate)                # add new event, all fields empty
 
            self.app.pending_tap_event = None                      # clear for next tap



        daynumlabel.on_single_tap = \
            lambda: daynumlabel_single_tap(daynumlabel, reldaynum)    # defer to watch taps

        daynumlabel.on_longpress = \
            lambda: daynumlabel_longpress(daynumlabel, reldaynum)     # defer to watch taps

        daycell.on_single_tap = \
            lambda: daycell_single_tap(daycell, reldaynum)            # iff on_release run
        
        daycell.daynumlabel = daynumlabel                             # reroutes (hack)



    #------------------------------------------------------------------------------------
    # GUI content filler: days
    #------------------------------------------------------------------------------------


    def fill_days(self):

        """
        Fill in month's name and days for the current view date.
        Day click/tap events were already registered in make_widgets.
        Maps relative grid's day grid indexes to stdlib's true day numbers.
        """

        # clear all days' prior numbers
        for (daycell, daynumlabel, dayeventsgrid) in self.daywidgets:
            daynumlabel.text = ''

        # set month name at top
        if False:
            moname = calendar.month_abbr[self.viewdate.month()]    # short name: tbd?
        else:
            moname = calendar.month_name[self.viewdate.month()]    # full month name
        self.monthlabel.text = f'[i]{moname} {self.viewdate.year()}[/i]'

        # set true day numbers, erase nondays            
        numsandwidgets = zip(self.viewdate.currdays, self.daywidgets)
        for (daynum, (daycell, daynumlabel, dayeventsgrid)) in numsandwidgets:
            if not self.viewdate.trueday_is_in_month(daynum):
                daynumlabel.text = ''                              # empty 0=non-day cells
            else:
                daynumlabel.text = str(daynum)                     # number on day cells

        # shade current day of this window
        self.shade_current_day()



    def shade_current_day(self):

        """
        Called by fill_days (create/navigate), and after any day/event click.
        For window-specific day only (even if other windows on same month).
        Use bold and black-or-white color to set off subtly.  
 
        Color options: self.app.theme_cls.primary_color (muted grid color), 
        [0, 150, 1.0, 1] (rgba value), self.app.manual_theme_color_fg()
        per theme toggle).  Bold can also be had via text markup, with:
        label.text = f'[b]{label.text}[/b]' plus label.text = label.text[3:-4].
        """

        # unshade prior shaded day frame
        if self.prior_shaded_daynumlabel:
            priorlabel = self.prior_shaded_daynumlabel
            priorlabel.bold  = False
            priorlabel.color = self.app.theme_cls.secondary_text_color
        
        # shade frame for new/current day of this month
        reldaynum = self.viewdate.day_to_index(self.viewdate.day())
        daycell, daynumlabel, dayeventsgrid = self.daywidgets[reldaynum]
        daynumlabel.bold  = True
        daynumlabel.color = self.app.manual_theme_color_fg()

        self.prior_shaded_daynumlabel = daynumlabel



    def set_and_shade_day(self, truedaynum):

        """
        On day and event taps/clicks: move current day shading.
        Daynum is true day, not index (event clicks have true only).
        """

        self.viewdate.setday(truedaynum)
        self.shade_current_day()



    def set_and_shade_rel_day(self, reldaynum):

        """
        TBD: still used on 4.0?
        On day single-left-click in 'mouse' mode [1.2].
        """

        if self.viewdate.relday_is_in_month(reldaynum):        # a true day in displayed month?
            trueday = self.viewdate.index_to_day(reldaynum)    # convert to actual day number
            self.set_and_shade_day(trueday)



    #------------------------------------------------------------------------------------
    # GUI content filler: events
    #------------------------------------------------------------------------------------


    def fill_events(self):

        """
        Given month+year of viewdate, fill MonthScreen's days with 
        any/all events' labels for the month, after erasing any events
        curently displayed.  The events table has the union of all 
        calendars' events, indexed by true date.  Sets up event-related
        callback handlers for event widgets on each refill.  

        Run on navs, and on event-dialog add/update/delete.  fill_days 
        populates day-number labels on nav refills, and days and their 
        event scrolls are made on startup.
        """

        # erase month's currently displayed event widgets from day-cell scrolls
        for eventlabel in self.eventwidgets.values():    
            eventlabel.parent.remove_widget(eventlabel)
        self.eventwidgets = {}
        
        # fill-in events from ics file data
        monthnum = self.viewdate.month()                               # displayed month
        yearnum  = self.viewdate.year()                                # displayed year
        numsandwidgets = zip(self.viewdate.currdays, self.daywidgets)

        # for all true days, with labels and events
        for (daynum, (daycell, daynumlabel, dayeventsgrid)) in numsandwidgets:
            if self.viewdate.trueday_is_in_month(daynum):              # a real day in this month (!= 0)? 

                edate = Edate(monthnum, daynum, yearnum)               # make true date of daycell
                if edate in EventsTable.keys():                        # any events for this day?

                    dayeventsdict = EventsTable[edate]                 # events on this date (uid table) 
                    dayeventslist = list(dayeventsdict.values())       # day's event object (all calendars)
                    dayeventslist.sort(
                               key=lambda d: (d.calendar, d.orderby))  # order for gui by calendar + creation 

                    for icsdata in dayeventslist:                      # for all ordered events in this day
                        # continue in separate method
                        self.add_event_entry(dayeventsgrid, edate, icsdata)



    def add_event_entry(self, dayeventsgrid, edate, icsdata):

        """
        For one event: create summary label, register its event handlers.
        Separate (but not static) so can reuse for event edit dialog's Add.

        TBD: @staticmethod not required here, as this method always needs a
        self (MonthWindow) argument, regardless of how and where it's called.

        KivyMD-only size-per-content properties:
        adaptive_height == [size_hint_y: None] + [height: self.minimum_height]
        adaptive_width  == [size_hint_x: None] + [width: self.minimum_width]

        ~~~
        Other eventlabel options (Kivy is as tweaky as CSS):
            text_size=eventlabel.size                   # (via bind/setter)
            font_style='Body1' #'Body2' #'Caption'      # canned fontsizes in kivymd" l,m,s
            font_size='30sp',                           # seems moot
            max_lines=1,                                # fail: avoid line splits
            shorten=True                                # to truncate text 
            shorten_from = 'right'                      # where elippsis appears
            ellipsis_options = {'font_size': sp(10)}    # 'color': (1, 0, 0, 1)
        ~~~
        """

        # kivy label goes multiline if any newline \n after \ unescapes
        # (e.g., via web copy/paste): drop to keep summary on one line

        sanitizesummary = icsdata.summary.replace('\n', '')

        eventlabel = Tappable_Bordered_EventLabel(
            self.app,
            dayeventsgrid,
            self.colorize.pick_border_color(icsdata.calendar, icsdata.category),
            text=sanitizesummary, 
            halign='left', 
            markup=True,
            #font_name='NotoColorEmoji',        # a fail in kivy 2.3.1 (below)
            font_style='Body2',
            adaptive_height=True,               # size per content (kivymd)
            adaptive_width=True,                # required for fit+scroll
        )

        dayeventsgrid.add_widget(eventlabel)

        # bind font_size and font_name _after_ creation else kivmd overrides per MD
        eventlabel.font_size = self.app.dynamic_font_size
       #eventlabel.font_name = self.app.dynamic_font_name    # CUT

        # EMOJI-FONT FAIL (for now):
        # Like font_size, must reset font_name post create here to negate KivyMD.
        # BUT: 'NotoColorEmoji' then renders all characters as empty rectangles.
        # AND: on macOS, '/System/Library/Fonts/Apple Color Emoji.ttc' here 
        # yelds empties, but '/System/.../Courier.ttc' and 'DejaVuSans' both work.
        # Hence, it's an SDL2 limitation, not the font file or coding.  Tried 
        # installing latest SDL2 but it didn't help (and no get for SDL2 version#?).
        # Use one of the built-in fonts - which all respond to color theme properly,
        # but don't do emojis and presumably much else, unfortunately.
        #
        # eventlabel.font_name = 'NotoColorEmoji'

        # event-specific actions
        self.register_event_actions(eventlabel, edate, icsdata)

        # save for erase on delete, cut, navigate
        self.eventwidgets[icsdata.uid] = eventlabel  



    def register_event_actions(self, eventlabel, edate, icsdata):

        """
        Register event events on each fill/refill/navigation.
        Day events are registered on build/startup instead.
        """

        def eventlabel_single_tap(eventlabel, edate, icsdsata):
            trace(f'eventlabel single tap: {edate.day}')
            self.set_and_shade_day(edate.day)
            icsfilename = icsdata.calendar
            EditEventDialog(self.app, edate, eventlabel, icsdata)
            self.app.pending_tap_event = None

        eventlabel.on_single_tap = \
            lambda: eventlabel_single_tap(eventlabel, edate, icsdata)




#====================================================================================
# Date set/increment/decrement, with rollovers, calendar module days list
#====================================================================================


class ViewDateManager:

    """
    Copied almost verbatim from Frigcal 3.0.
    Manage the viewed day's date object and its month's daynumbers list.
    
    Created for and embedded in each Month screen object (if > one).
    Maps relative day indexes in GUI to/from true month day numbers.
    Caveat: uses datetime module dates, not later icsfiletools.Edate.
    
    Subtlety: must set day to 1 on mo/yr navigations, else current date's
    day may be out range for new month when mo/yr reset by date.replace()
    (e.g, 30, for Feb).  Restores prev view day later if in new month,
    else sets it to the highest day number in the new month. 
    """

    def __init___(self):
        self.currdate = None    # displayed date's Date object: with .month/.year/.day #s
        self.currdays = None    # list of displayed month's day #s: 0 if not part of month


    # Check if day's index or value is a day in the current month

    def relday_is_in_month(self, reldaynum):
        return self.currdays[reldaynum] != 0      # display widget index is a true day?

    def trueday_is_in_month(self, truedaynum):
        return truedaynum != 0                    # when already pulled from currdays


    # Map day's relative index number <=> true day number

    def index_to_day(self, reldaynum):
        return self.currdays[reldaynum]           # true day for display label index            

    def day_to_index(self, truedaynum):
        return self.currdays.index(truedaynum)    # display label index for true day


    # Accessors for current managed day's date

    def month(self):
        return self.currdate.month

    def day(self):
        return self.currdate.day

    def year(self):
        return self.currdate.year

    def mdy(self):
        return (self.month(), self.day(), self.year())

    def setday(self, daynum):
        # on clicks
        self.currdate = self.currdate.replace(day=daynum)


    # Day-numbers list for current day's month

    def get_pad_daynums(self):
        """
        Fetch day numbers list from python's calendar module for currdate.
        Non-month days are zero, pad with extra zeroes for maxweeks displayed.
        calhelper 6=starts on Sunday (0=Monday, but can't change GUI as is).
        """
        calhelper = calendar.Calendar(firstweekday=6)   # start on Sunday
        currdays = list(calhelper.itermonthdays(self.currdate.year, self.currdate.month))
        currdays += [0] * ((NUM_GUI_WEEKS * NUM_GUI_DAYS) - len(currdays))
        return currdays


    # Current date and days-list changes

    def settoday(self):
        # Run initially and on demand
        self.currdate = datetime.date.today()
        self.currdays = self.get_pad_daynums()

    def setdate(self, datestr):
        # TBD: could check if day is in month's range explicitly; as is,
        # .replace() generates exception + general error popup on bad day;
        trace(datestr)
        try:
            mm, dd, yyyy = datestr.split('/')       # k.i.s.s. for now
            assert len(yyyy) == 4
            self.currdate = self.currdate.replace(
                                month=int(mm), day=int(dd), year=int(yyyy))
            self.currdays = self.get_pad_daynums()
            return True
        except:
            trace(sys.exc_info())
            return False


    # Update current managed date for navigation ops 

    def nav_neutral_day(self):
        # set day=1 to avoid out-of-range on replace()
        prevday = self.currdate.day                     # save daynum to reset if possible
        self.currdate = self.currdate.replace(day=1)    # else may be out of new month's range
        return prevday

    def nav_restore_day(self, prevday):
        # restore prev day if in bounds for new month
        if prevday in self.currdays:
            self.currdate = self.currdate.replace(day=prevday)  # restore if in new month
        else:
            # set day to last (i.e., highest #) day in new month
            # TBD: or leave at 1? (later navs on prior lastday)
            for lastday in reversed(self.currdays):
                if lastday != 0:
                    self.currdate = self.currdate.replace(day=lastday)
                    break

    def setnextmonth(self):
        prevday = self.nav_neutral_day()
        currdate = self.currdate
        if currdate.month != 12:
            currdate = currdate.replace(month=currdate.month + 1)
        else:
            currdate = currdate.replace(month=1, year=currdate.year + 1)
        self.currdate = currdate
        self.currdays = self.get_pad_daynums()
        self.nav_restore_day(prevday)

    def setprevmonth(self):
        prevday = self.nav_neutral_day()
        currdate = self.currdate
        if currdate.month != 1:
            currdate = currdate.replace(month=currdate.month - 1)
        else:
            currdate = currdate.replace(month=12, year=currdate.year - 1)
        self.currdate = currdate
        self.currdays = self.get_pad_daynums()
        self.nav_restore_day(prevday)

    def setnextyear(self):
        prevday = self.nav_neutral_day()
        self.currdate = self.currdate.replace(year=self.currdate.year + 1)
        self.currdays = self.get_pad_daynums()
        self.nav_restore_day(prevday)

    def setprevyear(self):
        prevday = self.nav_neutral_day()
        self.currdate = self.currdate.replace(year=self.currdate.year - 1)
        self.currdays = self.get_pad_daynums()
        self.nav_restore_day(prevday)




#====================================================================================
# Event dialog - common methods
#====================================================================================


class CommonEventMethods:

    """
    Delegation class with methods shared by the Edit and Add 
    variants of the Event dialog, factored here to reduce code
    redundancy.  The net effect conflates Edit and Add dialogs
    because Add is basically Edit, with a changeable calendar 
    field, different action buttons, and no target label.
 
    This class's common methods are fetched unbound (no instance
    of this class is ever made) and assume that widget id names 
    are the same in the .kv file for both the Edit and Add dialogs.

    The .kv file's kivy-lang UI code for Edit and Add still has 
    some redundancy, but tweaking its properties in the .py is 
    tricky.  This common class is not used in .kv definitions.

    Coding rationale:

    This class's methods are accessed with composition--really,
    delegation via __getattr__ that forges a bound method with
    lambda.  types.MethodType and __get__ can also be used for
    bound-method creation but seem more implicit and obfuscated.

    Multiple inheritance is _not_ used for this class, because that 
    coding failed for reasons unknown, despite passing a required
    **kwargs on to the Kivy BoxLayout super.  This appears to be 
    a limitation of Kivy's linkage for Widget subclasses coded in
    Py that are augmented by kivy-lang classes coded in .kv files.

    An alternative coding that did work reused Edit dialog methods
    in the Add dialog by calling them directly.  It called them by 
    explicit class name (i.e., EditEventDialog.method(self,...)),
    which means simple functions in py3.X sans @staticmethod.  This 
    was arguably less "magic" than the delegation settled on but 
    required a bound-method hack in one context that was subpar.

    Yet another alternative coding had Add inherit from Edit where 
    common methods resided, but that made _both_ dialogs' widgets 
    show up in the display.  Inheritance with .kv classes is subtle. 
    """



    def __init__(self, app, edate, titletype, eventlabel=None, icsdata=None):
      
        """
        self is an instance of the Edit or Add dialog, not of this class.
        edate is clicked event's true date as an Edate, not relative index.

        - Edit passes eventlabel+icsdata, where icsdata is the clicked 
          event's info and eventlabel is its GUI widget.  
        
        - Add passes just icsdata (not eventlabel) as either empty for 
          a new add or as widgetdata for an event copied for paste. 
        """

        self.app = app                # link to the MDApp instance in main.py
        self.edate = edate
        self.eventlabel = eventlabel
        self.icsdata = icsdata
        self.colorize = Colorize(app)

        def some(text):               # fix '\n' or '' fields
           text = text.rstrip()       # else botches widgets
           return text or ''          # was '(none)', now moot

        ids = self.ids
        ids['date'].text = edate.as_nice_string()               # mmm, locale neutral
        if icsdata:                                             # None for non-paste Add
            ids.summary.text = some(icsdata.summary)            # fill gui from event
            ids.description.text = some(icsdata.description)    # aka title and note
            ids.calendar.text = some(icsdata.calendar)
            ids.category.text = some(icsdata.category)

        # save for change tests
        self.start_widgetdata = self.get_widget_data_from_gui() 

        popup = Popup(                             # open modal popup showing dialog
                    title=f'{titletype} Event',
                    content=self,                  # was dialog 
                    auto_dismiss=False,            # ignore taps outside
                    size_hint=(0.9, 0.8),          # phones wide, e-2-e short
                    **app.themeForPopup())         # fg/bg colors if 'light'

        app.popupStack.push(popup)
        app.popupStack.open()                      # show top popup with dialog 

        # force note to top line else opens at bottom
        # workaround attempt, moot in final scroll code
        # ids.description.cursor = (0, 0)
        # def force_scroll(dt):
        #    ids.notescroll.scroll_y = 1           # 1=top on top, 0=bottom on bottom
        # Clock.schedule_once(force_scroll)



    def on_category_touched(self, touch, calendar=None):

        """
        Build and open categories menu on textfield tap.
        Category names come from settings, not loaded calendars;
        they are meant to be device and settings-file neutral.

        The special '(none)' added here means clear category
        field, and uses the calendar or else default color.

        Subtle: event_default_color handles calendar mods in Add,
        where 'calendar' is always a non-None str, possibly empty.

        UPDATE: calendars are no longer colorized, so '(none)' in 
        category pulldown is always just the app-theme default,
        and argument 'calendar' is moot even if passed.  For no
        colors, black/white map to Month bg in Dark/Light themes.
        """

        if not self.ids.category.collide_point(*touch.pos):    # or touch.x, touch.y
            return False

        # presets, tbd: changeable?
        allcolors = self.app.settings.categorycolors.keys()
        if calendar is None:
            calendar = self.icsdata.calendar      # if run by Edit, not Add 
        event_default_color = \
            self.colorize.pick_border_color(calendar, '')

        menu_items = [                            # a list of dicts
            dict(viewclass='OneLineListItem',
                 text=catname,

                 # colorize text as demo
                 theme_text_color='Custom',       # else uses default 'Primary'
                 text_color=catname               # ignored unless 'Custom'
                     if catname != '(none)' 
                     else event_default_color,

                 on_release=
                     lambda catname=catname:      # else last loop-var value
                         self.on_category_menu_tap(catname),
            ) 
            for catname in [*allcolors, '(none)']
        ]

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

        self.category_menu = MDDropdownMenu(
            items=menu_items,
            caller=self.ids.category,

            # else default 4 may run into android edge-to-edge navbar: see Add's calendar
            **(dict(border_margin=dp(60)) if RunningOnAndroid else {}),

            # but uncalled on non-menu tap (else focus stuck on if using keyboard_mode)
            # on_dismiss=lambda: self.on_menu_dismiss(self.ids.category),
        )

        #self.ids.category.focus = True
        #self.ids.category.keyboard_mode = 'managed'    # no on-screen keyboard

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



    def on_category_menu_tap(self, catname):

        """
        Set text field to menu pick, color in Month screen will be
        changed on close.  Text field is not verified: it's now 
        readonly in .kv to prevent a pointless onscreen keyboard, 
        and empty category is allowed and uses cal|default color.
        Keyboard 'managed' stops onscreen kb too but botches focus.
        """

        trace('on_category_menu_tap')
        self.category_menu.dismiss()
        self.ids.category.text = catname if catname != '(none)' else ''
 


    def on_note_focus(self, instance, focus, *args):

        """
        Part of the final Note keyboad-overlay workaround: make
        entire dialog scroll, and add/remove a dummy widget to fill 
        space below the keyboar on Note textfield focus callback.

        See on_note_cursor_pos() ahead for more on this workaround.
        Caveats: actual keyboard height is unavailable with SDL2 on 
        Android, and assumes onscreen keyboard overlay only on Android.
        """

        trace('on_note_focus:', focus, self.ids.description.focus)

        # just if onscreen keybord overlay: assume Android only
        if not RunningOnAndroid: 
            return

        inputsbox = self.ids.inputsbox
        if focus:
            buttons = self.ids.buttons
            self.notepadding = Label(size_hint_y=None, 
                                     height=(Window.height / 2) - buttons.height)
            inputsbox.add_widget(self.notepadding)
        else:
            inputsbox.remove_widget(self.notepadding)



    def get_widget_data_from_gui(self):

        """
        Load data from widgets, ensure description isn't empty.

        The odd rstrip() comes from 3.0's tkinter version and may be moot...
        [1.4] strip extra trailing \n added to description by Text widget's
        get(), else can wind up adding one '\n' per an event's update or paste;
        could also fetch through END+'-1c', but drop any already present too;
        nit: rstrip() also drops any intended but useless blank lines at end;
        always keep one \n at end in case some ics parsers require non-blank;
        [2.0] but don't display a sole '\n' = bogus blank line (see above);
        """

        ids = self.ids
        return EventData(
                   calendar=ids.calendar.text,
                   summary=ids.summary.text,
                   category=ids.category.text,
                   description=ids.description.text.rstrip('\n') + '\n',
        )



    def on_event_cancel(self, dialog):
 
        """
        Close dialog if either no changes or user verifies.
        Any and all changes are discarded if dialog closed.
        """

        # EditEventDialog or AddEventDialog: avoids redefs
        dialogclass = dialog.__class__
        trace(f'{dialogclass=}')

        # no field mods?
        if self.start_widgetdata == self.get_widget_data_from_gui():
            self.app.dismiss_popup(dialog)
            dialogclass.is_open = False

        # confirm close
        else:
            message = ("[b]Event fields have changed[/b]"
                       "\n\n"
                       "Some event data has been changed in this dialog.  "
                       "Close the event dialog and discard these changes?")

            confirmer = Factory.ConfirmDialog(
                            message=message,

                            onyes=lambda root:                            # root from .kv
                                [self.app.dismiss_popup(root),            # confirm dialog
                                 self.app.dismiss_popup(dialog),          # event dialog
                                 setattr(dialogclass, 'is_open', False)], # reenable opens

                            onno=self.app.dismiss_popup)                  # confirm dialog
 
            popup = Popup(
                        title='Confirm Event Close',
                        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




#====================================================================================
# Event dialog - view+edit
#====================================================================================


class EditEventDialog(MDBoxLayout):

    """
    Dialog used to view, edit, copy, and delete an existing event.
    Opened by event taps on Month screen and event-list dialog.
    Unlike adds, this variant includes copy/delete buttons.  This
    calls methods of the common dialog class to avoid redundancy. 

    Dev notes: .py/.kv linkage alternatives...

    1) - [<EventDialog@FloatLayout>:] in .kv for GUI def  
       - [dialog = Factory.EventDialog()] in .py
       - ref main.py [app] methods in .kv, set [dialog.ids.name] here

    2) - [<EventDialog>] in .kv for GUI def
       - [class EventDialog(FloatLayout):] and [EventDialog()] in .py 
       - ref [root] methods in .kv, pass [prop=value] fields+callbacks on creation?

    Used #2 because it provides better code proximity for the 
    callbacks (here instead of in main.py's app) and simplifies 
    field settings with properties.

    Tbd: pass data as constructor args == dialog root properties?
    NO: use explicit field sets/fetches in .py for better clarity
    date = StringProperty(), etc
    """



    # Ignore new opens if dialog is already open, else a second 
    # medium-speed tap causes the dialog to open twice.  This
    # happens because Kivy overlaps button presses oddly.  It's
    # a general Kivy scourge, but is more likely on the Month 
    # screen, where a fast double-tap is a user action that goes
    # to the 'today' date, and a slow single-tap opens the event, 
    # add, or event-list dialog.  Medium-speed taps are likely.

    is_open = False



    def __init__(self, app, edate, eventlabel, icsdata, **kwargs):
      
        """
        edate is clicked event's true date as an Edate, not relative index
        icsdata is clicked event's EventData, eventlabel is its gui widget
        """

        if EditEventDialog.is_open:
            trace('skipped double tap')
            return
        else:
            EditEventDialog.is_open = True

        # to BoxLayout, and beyond
        super().__init__(**kwargs)

        # run shared constructor with Edit instance
        CommonEventMethods.__init__(self, app, edate, 'Edit', eventlabel, icsdata)
  


    def __getattr__(self, name):
              
        """
        Route an undefined attribute to a method in the common class.
        Uses a lambda closure to bind a self of this class with the
        common method for a later call.  types.MethodType and __get__ 
        can both bind too but are obscure.  lambda is far more general.
        """

        commonmethod = getattr(CommonEventMethods, name)    # unbound function
        return (lambda *args, **kwargs:                     # function to be called, 
                   commonmethod(self, *args, **kwargs))     # with this class's instance



    def on_event_update(self, dialog):

        """
        Update data structures and gui from widgets.
        Gui requires only summary because that's all that Month shows.
        New event fields will be pulled from data structures on opens.

        Nit: could skip the update (and hence later Save nags) if no 
        fields have changed, via self.start_widgetdata.  But why?

        UPDATE: must redraw the entire Month screen, not just update 
        the event's widget content, else the color bars above events
        may be unevenly wide until the next Month refresh.
        """

        assert dialog == self
        widgetdata = self.get_widget_data_from_gui()    # in common

        # validate textfields: user can input anything, depite menu
        # calendar cannot be changed here, allow any or blank category

        if not widgetdata.summary:
            self.app.info_message('Empty "Title" value', usetoast=True)
            return 

        # close only if no validate errors
        self.app.dismiss_popup(dialog)
        EditEventDialog.is_open = False

        # update data structures
        icsfiletools.update_event_data(self.edate, self.icsdata, widgetdata)

        # cut: update Month-screen widgets
        # self.eventlabel.text = widgetdata.summary
        # self.colorize.change_border_color(
        #     self.eventlabel, widgetdata.calendar, widgetdata.category)

        # redraw all Month-screen events
        self.app.monthgui.fill_events()    # clears prior events, then adds all


 
    def on_event_copy(self):
        
        """
        Post event to clipboard from widgets, not data structures.
        Leave dialog open: may still delete for 'cut' or update.
        """

        self.app.copied_event = self.get_widget_data_from_gui()



    def on_event_delete(self, dialog):
        
        """
        Delete from data structures and gui if user verifies.

        TBD: is the verify really needed or annoying here?  
        Delete doesn't remove from calendar files till Save,
        but does disard any edits and remove from Month screen.

        UPDATE: must redraw entire month screen, not just delete 
        the event's widget from its parent, else color bars above 
        events may be unevenly wide until the next Month refresh.
        """

        def do_delete(root):

            self.app.dismiss_popup(root)        # confirm dialog
            self.app.dismiss_popup(dialog)      # event dialog
            EditEventDialog.is_open = False     # renable event opens

            # delete from data structures
            icsfiletools.delete_event_data(self.edate, self.icsdata) 

            # cut: delete from Month-screen widgets
            # del self.app.monthgui.eventwidgets[self.icsdata.uid]     # month's events table
            # self.eventlabel.parent.remove_widget(self.eventlabel)    # Month-screen GUI

            # redraw all Month-screen events
            self.app.monthgui.fill_events()     # clears prior events, then adds all

        message = ("[b]Deleting event[/b]"
                   "\n\n"
                   "This will discard any edits and remove this event "
                   "from the Month screen.  It won't be removed from "
                   "your ICS file until the next Save.  Proceed?")

        confirmer = Factory.ConfirmDialog(
                        message=message,
                        onyes=lambda root: do_delete(root),      # confirm+event dialogs
                        onno=self.app.dismiss_popup)             # confirm dialog

        popup = Popup(
                    title='Confirm Event Delete',
                    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




#====================================================================================
# Event dialog - add
#====================================================================================


class AddEventDialog(MDBoxLayout):

    """
    Dialog used to add new or paste copied event on date edate.
    Opened by Month day tap and long-press, event-list Create tap.
    Unlike edits, this variant does not have copy/delete buttons.
    Calls methods of the common dialog class to avoid redundancy. 
    """



    # Ignore new opens if dialog (or info popop) is already open, 
    # else a second medium-speed tap causes the dialog (or info) 
    # to open twice.  See more details at EventDialog's note.

    is_open = False



    def __init__(self, app, edate, titletype='Add', widgetdata=None, **kwargs):
      
        """
        edate is true date of day or copied event as an Edate.
        widgetdata is not None when used for Paste: a prefilled Add.
        Require a calendar, instead of making a default as in FC 3.0.
        """

        if AddEventDialog.is_open:
            trace('skipped double tap')
            return
        else:
            AddEventDialog.is_open = True

        if not CalendarsTable:
            app.info_message(

                '[b]No calendars for event[/b]'
                '\n\n'
                'To add events, you must first either create a calendar '
                'using the main menu\'s New Calendar, or add an existing '
                'calendar file to your calendars folder and restart the app.'
                '\n\n'
                'Please add a calendar and try again.',

                usetoast=False,
                nextop=lambda: setattr(AddEventDialog, 'is_open', False)
            )
            return

        # to BoxLayout, and beyond
        super().__init__(**kwargs)

        # run shared constructor with Add instance
        CommonEventMethods.__init__(self, app, edate, titletype, icsdata=widgetdata)
  


    def __getattr__(self, name):
              
        """
        Route an undefined attribute to a method in the common class.
        See the same-named method in the Edit dialog for more info.
        """

        commonmethod = getattr(CommonEventMethods, name)    # unbound function
        return (lambda *args, **kwargs:                     # function to be called, 
                   commonmethod(self, *args, **kwargs))     # with this class's instance



    def on_category_touched(self, touch):

        """
        Build and open categories menu on textfield tap.
        Overrides common's def to pass in a calendar name
        for the '(none)' default color: calendar may be
        changed here and can be '' but is always a str.

        UPDATE: this is now moot but harmless because 
        calendars cannot be colorized and are ignored.
        """

        CommonEventMethods.on_category_touched(self, touch,
            calendar=self.ids.calendar.text)



    def on_calendar_touched(self, touch):

        """
        Build and open calendars menu on textfield tap: unique
        to Add.  Calendar names come from loaded calendars, not
        settings, because users cannot add calendars != ICS files.

        UPDATE: calendars are no longer colored, so the app-theme
        default is used for colors in the pulldown here.
        """

        if not self.ids.calendar.collide_point(*touch.pos):    # or touch.x, touch.y
            return False

        calnames = sorted(CalendarsTable.keys())

        #calcolors = [self.app.settings.calendarcolors.get(calname)  
        #                 for calname in calnames]
        calcolors = [None for calname in calnames]      # retain prior code temp


        menu_items = [                                  # a list of dicts
            dict(viewclass='OneLineListItem',
                 text=calname,

                 # colorize text as demo
                 theme_text_color='Custom'  if calcolor else 'Primary',
                 **{'text_color': calcolor} if calcolor else {},

                 on_release=
                    lambda calname=calname:             # else last loop-var value
                        self.on_calendar_menu_tap(calname),
            )
            for (calname, calcolor) in zip(calnames, calcolors)
        ]

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

        # else default 4 may run menu into android edge-to-edge navbar...
        # but max_height makes it grow upwards and truncates on Windows,
        # and border_margin does the same on Android: a Kivy bug * 2!

        # UPDATE: revert to neither border_margin nor max_height on PCs, 
        # and revert to border_margin on Android; there's no reason to limit
        # menu size on PCs, have seen menus post to top of screen and be 
        # truncated+unscrollable on Windows with former b_m policy, and cannot 
        # create the glitch on Android; may need resize or position recalcs?;
        # may have been related to fontsize change, seemed to be resolved by
        # a window resize on Windows, and appears to have gone away... (TBD)

        # UPDATE: PCs seem ok now, but Android botches calendar menu with a 
        # partial menu at top of screen that does not scroll... though ONLY on 
        # the cover screen of a Fold7, not on the main screen or an S24 Ultra,
        # and this happens ONLY for calendar, not category just below it that
        # uses border_margin on Android and is coded identically here and in .kv.
        # This is clearly a KivyMD menu-positioning BUG.  For beta testing, go 
        # back to max_height on Android, and keep no-settings on PCs; calendar
        # menu is unlikely to be big enough for a size limit to matter.

        if RunningOnAndroid:
            extra_args = dict(max_height=dp(400))         # else truncated/busted at top?
            ##extra_args = dict(border_margin=dp(60))     # avoid running into navbar
        else:
            ##extra_args = dict(border_margin=dp(60))     # else truncated/busted at top?
            extra_args = {}                               # but why limit size at all

        self.calendar_menu = MDDropdownMenu(
            items=menu_items,
            caller=self.ids.calendar,
            **extra_args,

            # but doesn't make grow down with either max_height or border_margin
            # ver_growth='down', 

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

            # but uncalled on non-menu tap (else focus stuck on if using keyboard_mode)
            # on_dismiss=lambda: self.on_menu_dismiss(self.ids.calendar),
        )

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

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



    def on_calendar_menu_tap(self, calname):

        """
        Set text field to menu pick.
        Text field is not verified: it's now readonly in .kv to 
        prevent a pointless onscreen keyboard on Android.
        """

        self.calendar_menu.dismiss()
        self.ids.calendar.text = calname 



    def on_event_create(self, dialog):

        """
        Add to data structures and gui from widgets.
        Gui requires only summary because that's all that Month shows
        """

        assert dialog == self

        # fetch inputs from gui
        widgetdata = self.get_widget_data_from_gui()    # in Common

        # validate textfields: users may input anything, despite menus
        # require title and valid calendar, allow any or blank category

        calendar = widgetdata.calendar
        summary  = widgetdata.summary     # aka Title

        if not calendar:
            self.app.info_message('Empty "Calendar" value', usetoast=True)
            return 
        if calendar not in CalendarsTable.keys():    # s/b impossible, but...
            self.app.info_message('Invalid "Calendar" value', usetoast=True)
            return
        if not summary:
            self.app.info_message('Empty "Title" value', usetoast=True)
            return 

        # close only if no valdate errors
        self.app.dismiss_popup(dialog)
        AddEventDialog.is_open = False

        # add to data structures
        widgetdata.uid = icsfiletools.icalendar_unique_id()
        icsfiletools.add_event_data(self.edate, widgetdata)

        # add to Month-screen widgets
        self.app.monthgui.fill_events()    # clears prior events, convenient+ordered here


        """DEVNOTE
        [1.X] Reorder now?--we don't care about .orderby here (new events
        are added to end of day's list), but .calendar ordering is not
        applied until next navigation/refill, and can skew select lists.
        [1.4] This could do just fill_events(), but may flash the GUI.

        [4.0] FC 3.0 did the tkinter equivalent of the following, which 
        does work here and minimizes GUI ops:

            mg = self.app.monthgui
            reldaynum = mg.viewdate.day_to_index(self.edate.day)
            (daycell, daynumlabel, dayeventsgrid) = mg.daywidgets[reldaynum]
            mg.add_event_entry(dayeventsgrid, self.edate, widgetdata)

        With 4.0's Kivy, though, there is no visible flash from using 
        fill_events to refill the entire month, and doing so avoids the 
        temp ordering issue.  Go with a refill.  Event update/delete 
        now similarly use a refill, to avoid uneven color-bar lengths.
        DEVNOTE"""




#====================================================================================
# Event-list dialog 
#====================================================================================


class EventListDialog(MDBoxLayout):

    """
    Display a selection list for all the events on a tapped day.
    Opened by taps on Month-screen days that already have events.
    Shows all events for days with too many events for day cell.
    A zoom feature that works like a larger version of the events
    in a day cell: scrollable, tap event to open, Add for new event.
    """
    


    # Ignore new opens if dialog is already open, else a second 
    # medium-speed tap causes the dialog to open twice.  See more
    # details at EventDialog's note.

    is_open = False



    def __init__(self, app, edate, **kwargs):

        """
        edate is clicked day's true date as an Edate
        """

        if EventListDialog.is_open:
            trace('skipped double tap')
            return
        else:
            EventListDialog.is_open = True

        super().__init__(**kwargs)    # for BoxLayout and its kin                       

        self.app = app                # link to the MDApp instance in main.py
        self.edate = edate            # dialog's date, tapped
        self.colorize = Colorize(app)

        # get events for day, ordered
        dayeventsdict = EventsTable[edate]                 # events on this date (uid table) 
        dayeventslist = list(dayeventsdict.values())       # day's event object (all calendars)
        dayeventslist.sort(                                # mimic month window ordering
                   key=lambda d: (d.calendar, d.orderby))  # order for gui by calendar + creation 

        # populate list: variant of day cells
        dayeventsgrid = self.ids.eventsgrid                # dialog gui's grid defined in .kv
        for icsdata in dayeventslist:                      # for all ordered events in this day

            # \n makes Label multi-line 
            sanitizesummary = icsdata.summary.replace('\n', '')

            eventlabel = Tappable_Bordered_EventLabel_List_Dialog(
                self.app,
                dayeventsgrid,
                self.colorize.pick_border_color(icsdata.calendar, icsdata.category),
                text=sanitizesummary,
                halign='left', 
                markup=True,                         
                #font_name='NotoColorEmoji',        # a fail in kivy 2.3.1 (TBD)
                font_style='Body2',
                adaptive_height=True,               # size per content (kivymd)
                adaptive_width=True,                # required for fit+scroll (ditto)
            )

            dayeventsgrid.add_widget(eventlabel)

            # bind font_size and font_name _after_ creation else kivmd overrides per MD
            eventlabel.font_size = self.app.dynamic_font_size
           #eventlabel.font_name = self.app.dynamic_font_name    # CUT

            # event-specific actions: just single tap
            self.register_event_actions(self.edate, eventlabel, icsdata)

            # modal dialog: no need to save for erase

        # open modal popup showing dialog
        popup = Popup(                             
                    title=f'Event List for {edate.as_nice_string()}',
                    content=self,                  
                    auto_dismiss=False,            # ignore taps outside
                    size_hint=(0.9, 0.8),          # phones wide, e-2-e short
                    **app.themeForPopup())         # fg/bg colors if 'light' (now moot)

        # upate: match the background color of the Month screen
        # this is a zoom, and black/white categories must appear empty

        popup.background = ''
        popup.background_color = \
            '#121212' if self.app.theme_cls.theme_style == 'Dark' else '#fafafa'

        app.popupStack.push(popup)
        app.popupStack.open()                      # show top popup with dialog        



    def register_event_actions(self, edate, eventlabel, icsdata):

        """
        Register event-tap events on each list popup.
        eventlabel is the widget object, for colors.
        """
 
        def eventlabel_single_tap(edate, icsdsata):
            # popup dialog, not persistent screen
            # icsfilename = icsdata.calendar
            trace(f'eventlistlabel single tap: {edate.day}')

            # close event-list, open event-edit
            self.app.dismiss_popup(self)
            EventListDialog.is_open = False

            # updates mod event in Month, not dialog
            monthscreen_eventlabel = self.app.monthgui.eventwidgets[icsdata.uid]
            EditEventDialog(self.app, edate, monthscreen_eventlabel, icsdata)

        eventlabel.on_single_tap = \
            lambda: eventlabel_single_tap(edate, icsdata)



    def on_event_list_add(self):

        """
        Dialog button: add (create) newevent
        """

        # close event-list, open event-add
        self.app.dismiss_popup(self)                # verify top content for 2taps
        EventListDialog.is_open = False             # allow new opens: initial doubles
        AddEventDialog(self.app, self.edate)



    def on_event_list_cancel(self):

        """
        Dialog button: cancel dialog
        """

        # no changes to warn about here
        self.app.dismiss_popup(self)
        EventListDialog.is_open = False




#====================================================================================
# [UNUSED] Cut-Copy dialog 
#====================================================================================

"""
class CutCopyDialog:
    # UNUSED in FC 4.0: legacy copy/cut/open popup in FC 3.0
    # For 4.0, simpler to use buttons in EditEditDialog sans day/list right-clicks
    pass
"""




#====================================================================================
# [UNUSED] Temp trims and dev notes
#====================================================================================


class bogus:



    '''CUT
    def on_menu_dismiss(self, caller):

        """
        To clear focus on menu-caller textfield when menu closed.
        In allpopups.kv: [keyboard_mode: 'managed', on_dismiss: this]
        A kivy bug, possibly trigerred by keyboard_mode = 'managed'.   
        Also used by Add dialog: no @staticmethod needed in py3.x.
        PUNT: this wasn't called as coded and proved moot post readonly.    
        """

        trace('on_menu_dismiss', caller)
        caller.focus = False               # unfocus the caller widget
        Window.release_all_keyboards()     # release any active keyboards
    CUT'''



    '''CUT
    def on_note_focus(self, instance, focus, *args):

        """
        To manually scroll to text cursor in multiline Note field.
        In allpopups.kv: [on_cursor_pos: root.on_note_cursor_pos(*args)]
        Not called on Android only  when readonly is True: bug!
        PUNT: this proved moot after the final rokaround was found.
        """

        trace('on_note_focus:', focus, self.ids.description.focus)
        if focus:
            from kivy.uix.vkeyboard import VKeyboard
            self.notekeyboard = VKeyboard()
            self.add_widget(self.notekeyboard)
        else:
            self.remove_widget(self.notekeyboard)
    CUT'''



    '''CUT
    def on_note_cursor_pos(self, instance, cursor_pos):

        """
        In .kv textfield: [on_cursor: root.on_note_cursor_pos]
        """

        # schedule the scrolling to happen after the next frame render
        # to ensure that minimum_height is updated correctly

        Clock.schedule_once(
            lambda dt: self.scroll_to_cursor(instance, cursor_pos), 0)


        """
        --------------------------------------------------------------------
        The great workaround for keyboard overlays of the Note field...

        TEMP: 

        On any change in the cursor location of the focused multiline
        TextInput in a ScrollView, manually scroll the ScrollView to show
        the current cursor position of the nested TextInput.

        Else, newly input lines of text may show up be off-screen when 
        text grows larger than scroll - below the widgets and under an 
        onscreen keyboard on Android.  Unlike most GUIs, a Kivy scroll/
        text combo doesn't do this automatically, and this took days of 
        brutal dev work.  This seems either bug or glaring feature hole.

        PUNT: 

        As a more fundamental problem, on Android the onscreen keyboard also 
        covers the Note text-input field on focus.  The Note field scrolls 
        well in view-only mode but is impossible to see or mod when focused.  

        This is despite countless permutations of size settings and days of 
        workaround hell.  It's likely a defect in Kivy's handling of the 
        Window.softinput_mode's "below_target" keyboard panning for multiline 
        text (only), and may occur in the SDL2 library beneath Kivy itself.

        As a fallback, Kivy's builtin VKeyboard was also tried, but it's ugly,
        far too small in floating mode, and didn't fix the overlay.  Other 
        floating keyboards can help (e.g., Samsung's) but cannot be assumed.

        FIX: 

        The _only_ workaround that sufficed--and the one settled on after 
        a ~week of frustration--was to scroll the entire input-fields area 
        of the dialog instead of just the Note's text.  That still does not
        prevent keyboard overlay, so on Android it _also_ adds/removes a 
        dummy widget to occupy space under the keyboard on Note focus/defocus.

        This is subpar.  There's no way to accurately size the dummy widget, 
        because Window.keyboard_height is broken in SDL2 beneath Kivy per 
        testing and docs.  Hence, there is some device-dependent slack space.
        Users also still must manually scroll if/when the cursor slides under
        the keyboard; this might be addressed by manual autoscrolls, but this
        is complex (and iffy in Kivy), and the required scrolls are minor.

        Caveats aside, this works, and it beats another popup for Note edits 
        because it both makes more of the Note's text visible on screen and 
        also addresses small-screen landscape where Note may otherwise not 
        be viewable at all.  Phone users now at least have a shot.

        EDITORIAL: 

        All of which leads to the question--why is this so hard to do in 
        Kivy?  To be sure, Kivy is the most complete option for Python apps
        on Android today and is an amazing achievement.  BeeWare's Toga, 
        the main alternative, lacks both nav drawers and touch gestures 
        because its API by is oddly platform-native yet platform-neutral.

        At the same time, Kivy also feels too low level, and things that 
        should just work (and do in the much-maligned Tkinter) often 
        mushroom into major battles.  Among the gripes, these scrolled-text
        and keyboard struggles, unusable TextInput size limits, manual text
        splits across multiple Labels, non-orthogonal layout settings, and
        the implicit and obfuscating property bindings and Kivy-lang/Python
        linkage limit Kivy's audience to the most determined of engineers. 

        In the end, Kivy apps _can_ be had with workarounds, compromises, 
        and perseverance, but Kivy often seems more a library-construction
        'kit' than a finished library.  KivyMD improves app cosmetics but 
        also adds yet another level of toolkit thrashing to the stack.
        For the sake of Python on Android, improve, please.  This could 
        be a much more compelling story with some polishing and docs.
        --------------------------------------------------------------------
        """
    CUT'''



    '''CUT
    def scroll_to_cursor(self, instance, cursor_pos):

        scroll = self.ids.notescroll     # ScrollView instance (or MDScrollView sub)
        text   = self.ids.description    # TextInput  instance (or MDTextField sub)

        trace(f'{int(time.time())}: ' 
              f'cursor_pos=[{cursor_pos[0]:<4.0f}, {cursor_pos[1]:<4.0f}], ' 
              f'{text.height=:<4.0f}, '          # changes with insert/delete
              f'{text.to_window(*cursor_pos)=}' 
             #f'{scroll.height=:<4.0f}, '        # always 263, till window resize
             #f'{Window.top=:<4.0f}, '           # always 157
             #f'{scroll.y=:<4.0f}, '             # always 240, till window resize 
             #f'{text.y=:<4.0f}, '               # always 0
        )

        return
        # calc manual scroll position here (see _dev-misc/*PRE-ALL-INPUTS-SCROLL*)
    CUT'''