"""
app tour base classes
---------------------
"""
from copy import deepcopy
from typing import Any, Optional, Union
from ae.dynamicod import try_eval # type: ignore
from ae.i18n import get_f_string, get_text # type: ignore
from .utils import (
REGISTERED_TOURS, TOUR_EXIT_DELAY_DEF, TOUR_START_DELAY_DEF,
ExplainedMatcherType,
flow_action, id_of_flow, id_of_tour_help, tour_help_translation, translation_short_help_id,
update_tap_kwargs, widget_page_id)
[docs]
class TourBase: # pylint: disable=too-many-instance-attributes,too-many-public-methods
""" abstract tour base class, automatically registering subclasses as app tours.
subclass this generic, UI-framework-independent base class to bundle pages of a tour and make sure that the
attr:`~TourBase.page_ids` and :attr:`~TourBase.page_data` attributes are correctly set. a UI-framework-dependent
tour overlay/layout instance, created and assigned to main_app.tour_layout, will automatically create an instance
of your tour-specific subclass on tour start.
"""
[docs]
def __init_subclass__(cls, **kwargs):
""" register tour class; called on declaration of tour subclass. """
super().__init_subclass__(**kwargs)
REGISTERED_TOURS[cls.__name__] = cls
# noinspection PyUnresolvedReferences
[docs]
def __init__(self, main_app: 'MainAppBase'): # type: ignore # noqa: F821
super().__init__()
main_app.vpo(f"TourBase.__init__(): tour overlay={main_app.tour_layout}")
self._auto_switch_page_request = None
self._delayed_setup_layout_call = None
self._initial_page_data = None
self._saved_app_states: dict[str, Any] = {}
self.auto_switch_pages: Union[bool, int] = False
""" enable/disable automatic switch of tour pages.
set to `True`, `1` or `-1` to automatically switch tour pages; `True` and `1` will switch to the next page
until the last page is reached, while `-1` will switch back to the previous pages until the first page is
reached; `-1` and `1` automatically toggles at the first/last page the to other value (endless ping-pong until
back/next button gets pressed by the user).
the seconds to display each page before switching to the next one can be specified via the item value of the
the dict :attr:`.page_data` dict with the key `'next_page_delay'`.
"""
self.page_data: dict[str, Any] = {
'help_vars': {},
'tour_start_delay': TOUR_START_DELAY_DEF,
'tour_exit_delay': TOUR_EXIT_DELAY_DEF}
""" additional/optional help variables (in `help_vars` key), tour and page text/layout/timing settings.
the class attribute values are default values for all tour pages and get individually overwritten for each tour
page by the i18n translations attributes on tour page change via :meth:`.load_page_data`.
supported/implemented dict keys:
* `app_flow_delay`: time in seconds to wait until app flow change is completed (def=1.2, >0.9 for auto-width).
* `back_text`: caption of tour previous page button (def=get_text('back')).
* `fade_out_app`: set to 0.0 to prevent the fade out of the app screen (def=1.0).
* `help_vars`: additional help variables, e.g. `help_translation` providing context help translation dict/text.
* `next_text`: caption of tour next page button (def=get_text('next')).
* `next_page_delay`: time in seconds to read the current page before next request_auto_page_switch() (def=9.6).
* `page_update_delay`: time in seconds to wait until tour layout/overlay is completed (def=0.9).
* `tip_text` or '' (empty string): tour page tooltip text fstring message text template. alternatively put as
first character a `'='` character followed by a tour page flow id to initialize the tip_text to the help
translation text of the related flow widget, and the `self` help variable to the related flow widget instance.
* `tour_start_delay`: seconds between tour.start() and on_tour_start main app event (def=TOUR_START_DELAY_DEF).
* `tour_exit_delay`: seconds between tour.stop() and the on_tour_exit main app event (def=TOUR_EXIT_DELAY_DEF).
"""
self.pages_explained_matchers: dict[str, Union[ExplainedMatcherType, tuple[ExplainedMatcherType, ...]]] = {}
""" matchers (specified as callable or id-string) to determine the explained widget(s) of each tour page.
each key of this dict is a tour page id (for which the explained widget(s) will be determined).
the value of each dict item is a matcher or a tuple of matchers. each matcher specifies a widget to be
explained/targeted/highlighted. for matcher tuples the minimum rectangle enclosing all widgets get highlighted.
the types of matchers, to identify any visible widget, are:
* :meth:`~ae.gui.app.MainAppBase.find_widget` matcher callable (scanning framework_win.children)
* evaluation expression resulting in :meth:`~ae.gui.app.MainAppBase.find_widget` matcher callable
* widget id string, declared via kv lang, identifying widget in framework_root.ids
* page id string, compiled from widgets app state/flow/focus via :func:`widget_page_id` to identify widget
"""
self.page_ids: list[str] = []
""" list of tour page ids, either initialized via this class attribute or dynamically. """
self.page_idx: int = 0 #: index of the current tour page (in :attr:`.page_ids`)
self.last_page_idx: Optional[int] = None #: last tour page index (`None` on tour start)
self.main_app = main_app #: shortcut to main app instance
self.layout = main_app.tour_layout #: tour overlay layout instance
self.top_popup = None #: top most popup widget (in an app tour simulation)
self.backup_app_states()
main_app.call_method('on_tour_init', self) # notify the main app to back up additional app-specific states
[docs]
def backup_app_states(self):
""" back up the current states of this app, including flow. """
main_app = self.main_app
main_app.vpo("TourBase.backup_app_states")
self._saved_app_states = deepcopy(main_app.retrieve_app_states())
[docs]
def cancel_auto_page_switch_request(self, reset: bool = True):
""" cancel auto switch callback if requested, called e.g., from tour layout/overlay next/back buttons. """
if self._auto_switch_page_request:
self._auto_switch_page_request.cancel()
self._auto_switch_page_request = None
if reset:
self.auto_switch_pages = False
[docs]
def cancel_delayed_setup_layout_call(self):
""" cancel delayed setup layout call request. """
if self._delayed_setup_layout_call:
self._delayed_setup_layout_call.cancel()
self._delayed_setup_layout_call = None
@property
def last_page_id(self) -> Optional[str]:
""" determine the last displayed tour page id. """
return None if self.last_page_idx is None else self.page_ids[self.last_page_idx]
[docs]
def load_page_data(self):
""" load a page before switching to it; maybe reload after preparing app flow and before setup of layout. """
page_idx = self.page_idx
page_cnt = len(self.page_ids)
assert 0 <= page_idx < page_cnt, f"page_idx ({page_idx}) has to be equal or greater zero and below {page_cnt}"
page_id = self.page_ids[page_idx]
if self._initial_page_data is None: # reset page data to tour class default: dict(help_vars={}, ...)
self._initial_page_data = self.page_data
page_data = deepcopy(self.page_data)
else:
page_data = deepcopy(self._initial_page_data)
help_translation = tour_help_translation(page_id)
tour_translation = translation_short_help_id(id_of_tour_help(page_id))[0]
if help_translation:
if tour_translation:
page_data['help_vars']['help_translation'] = help_translation
else:
tour_translation = help_translation
page_data.update(tour_translation if isinstance(tour_translation, dict) else {'tip_text': tour_translation})
self.main_app.vpo(f"TourBase.load_page_data(): tour page{page_idx}/{page_cnt} id={page_id} data={page_data}")
self.page_data = page_data
[docs]
def next_page(self):
""" switch to the next tour page. """
self.teardown_app_flow()
ids = self.page_ids
assert self.page_idx + 1 < len(ids), f"TourBase.next_page missing {self.__class__.__name__}:{self.page_idx + 1}"
self.last_page_idx = self.page_idx
self.page_idx += 1
self.main_app.vpo(f"TourBase.next_page #{self.page_idx} id={ids[self.last_page_idx]}->{ids[self.page_idx]}")
self.setup_app_flow()
[docs]
def prev_page(self):
""" switch to the previous tour page. """
self.teardown_app_flow()
ids = self.page_ids
assert self.page_idx > 0, f"TourBase.prev_page wrong/missing page {self.__class__.__name__}:{self.page_idx - 1}"
self.last_page_idx = self.page_idx
self.page_idx -= 1
self.main_app.vpo(f"TourBase.prev_page #{self.page_idx} id={ids[self.last_page_idx]}->{ids[self.page_idx]}")
self.setup_app_flow()
[docs]
def request_auto_page_switch(self):
""" initiate automatic switch to the next tour page. """
self.cancel_auto_page_switch_request(reset=False)
next_idx = self.page_idx + self.auto_switch_pages
if not 0 <= next_idx < len(self.page_ids):
if self.auto_switch_pages is True:
self.cancel_auto_page_switch_request() # only switch to next until the last page reached
return
self.auto_switch_pages = -self.auto_switch_pages
next_idx += 2 * self.auto_switch_pages
main_app = self.main_app
delay = self.page_data.get('next_page_delay', 30.9)
main_app.vpo(f"TourBase.request_auto_page_switch from #{self.page_idx} to #{next_idx} delay={delay}")
self._auto_switch_page_request = main_app.call_method_delayed(
delay, self.prev_page if self.auto_switch_pages < 0 else self.next_page)
[docs]
def restore_app_states(self):
""" restore app states of this app - saved via :meth:`.backup_app_states`. """
main_app = self.main_app
main_app.vpo("TourBase.restore_app_states")
main_app.setup_app_states(self._saved_app_states)
[docs]
def setup_app_flow(self):
""" set up app flow and load page data to prepare a tour page. """
self.main_app.vpo(f"TourBase.setup_app_flow page_data={self.page_data}")
self.update_page_ids()
self.load_page_data()
app_flow_delay = self.page_data.get('app_flow_delay', 1.2) # > 0.9 to complete auto width animation
self._delayed_setup_layout_call = self.main_app.call_method_delayed(app_flow_delay, self.setup_layout)
[docs]
def setup_layout(self):
""" setup/prepare tour overlay/layout after switch of tour page. """
self._delayed_setup_layout_call = None
main_app = self.main_app
layout = self.layout
main_app.vpo(f"TourBase.setup_layout(): page id={self.page_ids[self.page_idx]}")
try:
self.top_popup = main_app.popups_opened()[0]
except IndexError:
self.top_popup = None
self.setup_explained_widget()
self.setup_texts()
main_app.ensure_top_most_z_index(layout)
if self.auto_switch_pages:
self.request_auto_page_switch()
main_app.call_method_delayed(self.page_data.get('page_update_delay', 0.9), layout.page_updated)
[docs]
def setup_texts(self):
""" setup texts in tour layout from page_data. """
main_app = self.main_app
layout = self.layout
page_data = self.page_data
page_idx = self.page_idx
main_app.vpo(f"TourBase.setup_texts page_data={page_data}")
glo_vars = main_app.global_variables(layout=layout, tour=self)
help_vars = page_data['help_vars']
help_vars['self'] = layout.explained_widget
if self.top_popup:
glo_vars['root'] = self.top_popup
# pylint: disable-next=unnecessary-lambda-assignment
_txt = lambda _t: _t is not None and get_f_string(_t, glo_vars=glo_vars, loc_vars=help_vars) or "" # noqa: E731
layout.title_text = _txt(page_data.get('title_text'))
layout.page_text = _txt(page_data.get('page_text'))
tip_text = page_data.get('tip_text', page_data.get(''))
if tip_text is None:
help_tra = help_vars.get('help_translation')
tip_text = help_tra.get('', "") if isinstance(help_tra, dict) else help_tra
if tip_text and tip_text[0] == '=':
page_id = tip_text[1:]
tip_text = tour_help_translation(page_id)
if help_vars['self'] in (None, layout.ids.explained_placeholder):
help_vars['self'] = main_app.widget_by_page_id(page_id)
layout.tip_text = _txt(tip_text)
layout.next_text = page_data.get('next_text', get_text('next')) if page_idx < len(self.page_ids) - 1 else ""
layout.prev_text = page_data.get('back_text', get_text('back')) if page_idx > 0 else ""
[docs]
def start(self):
""" prepare app tour start. """
self.main_app.vpo("TourBase.start")
self.main_app.close_popups()
self.main_app.call_method_delayed(self.page_data.get('tour_start_delay', TOUR_START_DELAY_DEF),
'on_tour_start', self)
self.setup_app_flow()
[docs]
def stop(self):
""" stop/cancel tour. """
self.main_app.vpo("TourBase.stop")
self.teardown_app_flow()
# notify the main app to restore additional app-specific states (delayed, to be called after teardown events)
self.main_app.call_method_delayed(self.page_data.get('tour_exit_delay', TOUR_EXIT_DELAY_DEF),
'on_tour_exit', self)
[docs]
def teardown_app_flow(self):
""" restore app flow and app states before tour finishing or before preparing/switching to prev/next page. """
self.main_app.vpo("TourBase.teardown_app_flow")
self.cancel_delayed_setup_layout_call()
self.cancel_auto_page_switch_request(reset=False)
self.restore_app_states()
[docs]
def update_page_ids(self):
""" update/change page ids on app flow setup (before tour page loading and the tour overlay/layout setup).
override this method to dynamically change the page_ids in a running tour. after adding/removing a page, the
attribute values of :attr:`.last_page_idx` and :attr:`.page_idx` have to be corrected accordingly.
"""
self.main_app.vpo(f"TourBase.update_page_ids {self.page_ids}")
# ====== app tours =============================================================
_OPEN_USER_PREFERENCES_FLOW_ID = id_of_flow('open', 'user_preferences')
[docs]
class OnboardingTour(TourBase):
""" onboarding tour for first app start. """
# noinspection PyUnresolvedReferences
[docs]
def __init__(self, main_app: 'MainAppBase'): # type: ignore # noqa: F821
""" count and persistently store in config variable, the onboarding tour starts since app installation. """
started = main_app.get_variable('onboarding_tour_started', default_value=0) + 1
# finally, :meth:`~ae.gui.app.MainAppBase.register_user` disables automatic tour start on app start
main_app.set_variable('onboarding_tour_started', started)
super().__init__(main_app)
self.page_ids.extend([
'', 'page_switching', 'responsible_layout', 'tip_help_intro', 'tip_help_tooltip', 'layout_font_size',
'tour_end', 'user_registration'])
self.pages_explained_matchers.update({
'tip_help_intro': lambda widget: widget.__class__.__name__ == 'HelpToggler',
'tip_help_tooltip': _OPEN_USER_PREFERENCES_FLOW_ID,
'layout_font_size': lambda widget: getattr(widget, 'app_state_name', None) == 'font_size'})
if started > main_app.get_variable('onboarding_tour_max_started', default_value=9):
# this would remove welcome and base pages, unreachable for the user: ids[:] = ids[ids.index('tour_end'):]
self.page_idx = self.page_ids.index('tour_end') # instead, jump to the last page before user registration
[docs]
def setup_app_flow(self):
""" overridden to open user preferences dropdown in the responsible_layout tour page. """
super().setup_app_flow()
page_id = self.page_ids[self.page_idx]
if page_id == 'layout_font_size':
main_app = self.main_app
flow_id = _OPEN_USER_PREFERENCES_FLOW_ID
wid = main_app.widget_by_flow_id(flow_id)
main_app.change_flow(flow_id, **update_tap_kwargs(wid))
elif page_id == 'user_registration':
self.layout.stop_tour()
self.main_app.change_flow(id_of_flow('open', 'user_name_editor'))
[docs]
def teardown_app_flow(self):
""" overridden to close the opened user preferences dropdown on leaving layout_font_size tour page. """
if self.top_popup and self.page_ids[self.page_idx] == 'layout_font_size':
# self.top_popup.close() no longer works; redirected via kivy.uix.DropDown.dismiss() and Clock.schedule to:
# noinspection PyProtectedMember
self.top_popup._real_dismiss() # pylint: disable=protected-access # needed for this layout_font_size page
super().teardown_app_flow()
[docs]
def update_page_ids(self):
""" overridden to remove 2nd-/well-done-page (only showing once on next-page-jump from 1st-/welcome-page). """
super().update_page_ids()
if 'page_switching' in self.page_ids and self.last_page_id: # last page id not in (None=tour-start,''=1st page)
self.page_ids.remove('page_switching')
if self.page_idx: # correct if not back from the removed page: self.page_idx == 0; self.last_page_idx == 1
self.last_page_idx -= 1
self.page_idx -= 1
[docs]
class UserPreferencesTour(TourDropdownFromButton):
""" user preferences menu tour. """
# noinspection PyUnresolvedReferences
[docs]
def __init__(self, main_app: 'MainAppBase'): # type: ignore # noqa: F821
super().__init__(main_app)
self.auto_switch_pages = 1
self.page_data['next_page_delay'] = 3.6
self.page_ids.extend([_OPEN_USER_PREFERENCES_FLOW_ID, TourDropdownFromButton.determine_page_ids])