""" =============================================================================== 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 [] 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: : 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) - [:] in .kv for GUI def - [dialog = Factory.EventDialog()] in .py - ref main.py [app] methods in .kv, set [dialog.ids.name] here 2) - [] 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'''