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'''