Source code for ae.kivy.tours

"""
ae.kivy.tours module
--------------------

this module provides the following classes to augment the user interface of your apps with animated product tours,
tutorials, walkthroughs and user onboarding/welcome features:

    * :class:`~ae.kivy.tours.AnimatedTourMixin`
    * :class:`~ae.kivy.tours.AnimatedOnboardingTour`
    * :class:`~ae.kivy.tours.TourOverlay`


the class :class:`~ae.kivy.tours.TourOverlay` is implementing an overlay layout widget to display the animations,
shaders, tour page texts, tooltip text and the navigation buttons of an active/running app tour.

the :class:`~ae.kivy.tours.AnimatedTourMixin` can be mixed-into a tour class that inherits from
:class:`~ae.gui_help.TourBase` to extend it with animation and glsl shader features.

the class :class:`~ae.kivy.tours.AnimatedOnboardingTour` is based on :class:`~ae.gui_help.OnboardingTour` and
:class:`~ae.kivy.tours.AnimatedTourMixin` to extend the generic app onboarding tour
class with animations. it provides a generic app onboarding tour that covers the core features, that can be easily
extended with app-specific tour pages.

to integrate a more app-specific onboarding tour into your app, simply declare a class with a name composed by the name
of your app (:attr:`~ae.gui_app.MainAppBase.app_name`) in camel-case, followed by the suffix `'OnboardingTour'`.
"""
import traceback
from copy import deepcopy
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union

# noinspection PyProtectedMember
from kivy.animation import Animation, CompoundAnimation                                                 # type: ignore
from kivy.clock import Clock                                                                            # type: ignore
from kivy.core.window import Window                                                                     # type: ignore
from kivy.metrics import sp                                                                             # type: ignore
from kivy.properties import BooleanProperty, ListProperty, NumericProperty, ObjectProperty              # type: ignore
from kivy.uix.behaviors import ButtonBehavior, ToggleButtonBehavior                                     # type: ignore
from kivy.uix.floatlayout import FloatLayout                                                            # type: ignore
from kivy.uix.textinput import TextInput                                                                # type: ignore
from kivy.uix.widget import Widget                                                                      # type: ignore

from ae.base import snake_to_camel                                                                      # type: ignore
from ae.dynamicod import try_eval                                                                       # type: ignore
from ae.gui_help import REGISTERED_TOURS, HelpAppBase, OnboardingTour, TourBase, help_id_tour_class     # type: ignore
from ae.kivy_glsl import ShaderIdType, ShadersMixin                                                     # type: ignore

from .behaviors import ModalBehavior
from .widgets import AbsolutePosSizeBinder


PageAnimationType = Tuple[str, Union[Animation, str]]
""" tuple of a widget id string and an :class:`~kivy.animation.Animation` instance/evaluation-expression.

    if the first character of the widget id is a `@` then the :attr:`~kivy.animation.Animation.repeat` attribute of
    the :class:`~kivy.animation.Animation` instance will be set to True. the rest of the widget id string specifies
    the widget to be animated which is either:

    * one of the widgets of the :class:`TourOverlay` layout class, identified by the on of the following strings:
      `'next_but'`, `'page_lbl'`, `'tap_pointer'`, `'prev_but'`, `'title_lbl'`, `'tooltip'`, `'tour_page_texts'`.
    * the explained widget if an empty string is given.
    * the :class:`TourOverlay` layout class instance for any other string (e.g. `'layout'` or `'overlay'`).

    alternative to an animation instance, a evaluation string can be specified. these evaluations allow to use the
    following globals: :class:`~kivy.animation.Animation` (also abbreviated as `A`), :class:`~kivy.clock.Clock`,
    :attr:`~ae.gui_help.TourBase.layout`, :attr:`~kivy.metrics.sp`, :class:`~kivy.core.window.Window` and a
    reference to the instance of this app tour via `tour`.
"""

PageAnimationsType = Tuple[PageAnimationType, ...]  #: tuple of :data:`PageAnimationType` items

WidgetValues = Dict[str, Union[list, tuple, dict, float]]
""" a key of this dict specifies the name, the dict value the value of a widget property/attribute. """


DEF_FADE_OUT_APP = 0.39                                             #: default of tour layout fade out app screen factor


[docs]def ani_start_check(ani: Animation, wid: Widget): # pragma: no cover """ start animation if needed else skip animation start. :param ani: :class:`~kivy.animation.Animation` instance. :param wid: widget to start/skip the animation for. """ for attr, value in ani.animated_properties.items(): if getattr(wid, attr) != value: ani.start(wid) break
[docs]def animated_widget_values(wid: Widget, ani: Union[Animation, CompoundAnimation]) -> WidgetValues: # pragma: no cover """ determine from a widget the attribute/property values animated/changed by an animation. :param wid: widget of which the animation property values will get retrieved. :param ani: :class:`~kivy.animation.Animation`/:class:`kivy.animation.CompoundAnimation` instance. :return: dict with widget property names and values. """ wid_values = {} for key in ani.animated_properties.keys(): wid_values[key] = getattr(wid, key) return wid_values
[docs]def restore_widget_values(wid: Widget, values: WidgetValues): # pragma: no cover """ restore property values of a widget. :param wid: widget of which the animation property values will get restored. :param values: attribute/property values to restore on the widget. """ for attr, value in values.items(): setattr(wid, attr, value)
[docs]class AnimatedTourMixin: # pragma: no cover """ tour class mixin to add individual shaders to the tour layout and their children widgets. """ # abstracts layout: Widget main_app: Any page_ids: List[str] page_idx: int setup_texts: Callable
[docs] def __init__(self, main_app: HelpAppBase) -> None: super().__init__(main_app) # type: ignore # pylint: disable=no-member self._added_animations: List[Tuple[Widget, Animation, WidgetValues]] = [] self._added_shaders: List[Tuple[Widget, ShaderIdType]] = [] self._explained_binder = AbsolutePosSizeBinder() self.pages_animations: Dict[Optional[str], PageAnimationsType] = {} """ dict of compound animation instances of the pages of this tour. the key of this dict is the page id or None (for animations available in all pages of this tour). each value of this dict is of the type :data:`PageAnimationsType`. """ self.pages_shaders: Dict[Optional[str], Tuple[Tuple[str, ShaderIdType], ...]] = {} """ dict of widget shaders for the pages of this tour. the key of this dict is the page id or None (for shaders available in all pages of this tour). each value of this dict is a tuple of tuples of widget id and add_shader()-kwargs. the widget id string specifies the widget to which a shader will be added, which is either: * one of the widgets of the :class:`TourOverlay` layout class, identified by the on of the following strings: `'next_but'`, `'page_lbl'`, `'tap_pointer'`, `'prev_but'`, `'title_lbl'`, `'tooltip'`, `'tour_page_texts'`. * the explained widget if an empty string is given. * the :class:`TourOverlay` layout class instance for any other string (e.g. `'layout'` or `'overlay'`). before the add_shader()-kwargs dict will be passed to the :meth:`~ae.kivy_glsl.ShadersMixin.add_shader` method, all their non-string values, specifying as strings, will be evaluated/converted automatically. the evaluation provides the following globals: :attr:`~ae.gui_help.TourBase.layout`, :attr:`~kivy.metrics.sp`, :class:`~kivy.clock.Clock`, :class:`~kivy.core.window.Window` and the `tour` instance. """ self.switch_next_animations: Dict[Optional[str], PageAnimationsType] = {} """ dict of compound animation instances for the next page switch transition of the pages of this tour. the key of this dict is the page id or None (for animations available in all pages of this tour). each value of this dict is of the type :data:`PageAnimationsType`. """
[docs] def _add_animations(self, animations: PageAnimationsType): """ add animations to the tour page currently displayed in the tour layout/overlay. :param animations: tuple of tuples of widget id and animation instance/evaluation-string. :return: length of the longest animation added (in seconds). """ max_len = 0.0 layout = self.layout added = [] for wid_id, anim in animations: if isinstance(anim, str): glo_vars = self.main_app.global_variables(layout=layout, sp=sp, tour=self, A=Animation, Animation=Animation, Clock=Clock, Window=Window) anim = try_eval(anim, glo_vars=glo_vars) if wid_id[0:1] == '@': wid_id = wid_id[1:] anim.repeat = True wid = layout.ids.get(wid_id, layout) if wid_id else layout.explained_widget start_values = animated_widget_values(wid, anim) anim.start(wid) added.append((wid, anim, start_values)) if anim.duration > max_len: max_len = anim.duration self._added_animations.extend(added) return max_len
[docs] def next_page(self): """ overridden to add demo animations before/on switch to the next tour page. """ page_id = self.page_ids[self.page_idx] next_animations = self.switch_next_animations.get(None, ()) + self.switch_next_animations.get(page_id, ()) anim_length = self._add_animations(next_animations) if anim_length: # noinspection PyUnresolvedReferences self.main_app.call_method_delayed(anim_length + 0.123, super().next_page) # pylint: disable=no-member else: # noinspection PyUnresolvedReferences super().next_page() # pylint: disable=no-member
[docs] def setup_explained_widget(self) -> list: """ overridden to bind pos/size of explained widget(s) to the tour layout/overlay placeholder. :return: list of explained widget instances. """ self._explained_binder.unbind() # noinspection PyUnresolvedReferences widgets = super().setup_explained_widget() # type: ignore # pylint: disable=no-member layout = self.layout exp_wid = layout.explained_widget self._explained_binder = ebi = AbsolutePosSizeBinder(*widgets, bind_window_size=True) ebi.size_to_attribute(layout, 'explained_size') ebi.pos_to_attribute(layout, 'explained_pos') if exp_wid is layout.ids.explained_placeholder: ebi.size_to_attribute(exp_wid, 'size') ebi.pos_to_attribute(exp_wid, 'pos') return widgets
[docs] def setup_page_shaders_and_animations(self): """ setup shaders and animations of the current page. specified in :attr:`~AnimatedTourMixin.pages_shaders` and :attr:`~AnimatedTourMixin.pages_animations`. """ def _evaluated_shader_kwargs() -> dict: tour_shader_kwargs = deepcopy(shader_kwargs) # pylint: disable=undefined-loop-variable glo_vars = self.main_app.global_variables(layout=layout, sp=sp, tour=self, Clock=Clock, Window=Window) for key, arg in tour_shader_kwargs.items(): if isinstance(arg, str) and key not in ('add_to', 'render_shape', 'shader_code', 'shader_file'): tour_shader_kwargs[key] = try_eval(arg, glo_vars=glo_vars) return tour_shader_kwargs page_id = self.page_ids[self.page_idx] page_shaders = self.pages_shaders.get(None, ()) + self.pages_shaders.get(page_id, ()) layout = self.layout added = [] for wid_id, shader_kwargs in page_shaders: wid = layout.ids.get(wid_id, layout) if wid_id else layout.explained_widget added.append((wid, wid.add_shader(**_evaluated_shader_kwargs()))) self._added_shaders = added self._add_animations(self.pages_animations.get(None, ()) + self.pages_animations.get(page_id, ()))
[docs] def setup_layout(self): """ overridden to set up animations and shaders of the current tour page. """ # noinspection PyUnresolvedReferences super().setup_layout() # pylint: disable=no-member Clock.tick() # update position of explained widget self.setup_page_shaders_and_animations()
[docs] def simulate_text_input(self, text_input: TextInput, text_to_delay: str, text_to_insert: str = "", deltas: Tuple[float, ...] = (1.8, 0.6, 0.3)): """ simulate the typing of texts by a user entered into an explained TextInput widget of a tour page. :param text_input: text input widget, either of type :class:`~kivy.textinput.TextInput` or :class:`~ae.kivy.widgets.FlowInput`. :param text_to_delay: text string to be inserted delayed by the seconds specified in deltas[0]. :param text_to_insert: text string to be inserted directly into the passed text input widget. :param deltas: delay deltas in seconds between each character to simulate text inputted by a user. first delta default is a bit higher to finish navigation button y-pos-animation. """ if text_input.get_root_window(): for char_to_insert in text_to_insert: if text_input.interesting_keys.get(ord(char_to_insert), None) == 'backspace': # chr(8) text_input.do_backspace() else: text_input.insert_text(char_to_insert) if text_to_delay: next_delay = deltas[0] self.main_app.call_method_delayed(next_delay, self.simulate_text_input, text_input, text_to_delay[1:], text_to_insert=text_to_delay[0], deltas=deltas[1:] + (next_delay, ))
[docs] def tap_animation(self, wid_id: str = '', pos_delay: float = 2.34, press_delay: float = 0.69, release_delay: float = 0.39) -> PageAnimationType: """ create a compound animation instance simulating a user touch/tap on the specified widget. :param wid_id: specifies the widget to be tap simulated: either a widget id string (first item of the :data:`PageAnimationType` tuple), or (if prefixed with a column character) tap/focus/ state id of a widget, or an empty string (specifies the currently explained widget). :param pos_delay: time in seconds to position/move the pointer from the next button to the widget. :param press_delay: time in seconds of the button press simulation animation. :param release_delay: time in seconds of the button release simulation animation. :return: compound animation instance simulating a tap. .. note:: use as animation evaluation expression, to get the widget values on setup-time of the page (not tour). """ layout = self.layout if wid_id[0:1] == ':': tap_wid = self.main_app.widget_by_flow_id(wid_id[1:]) else: tap_wid = layout.ids.get(wid_id, layout) if wid_id else layout.explained_widget tap_wid_x, tap_wid_y = tap_wid.to_window(*tap_wid.center) nxt_wid = layout.ids.next_but poi_wid = layout.ids.tap_pointer poi_w, poi_h = poi_wid.size poi_x = tap_wid_x - poi_w * 13.0 / 30.0 # - tap_pointer.png index finger x position offset poi_y = tap_wid_y - poi_h * 29.0 / 30.0 poi_wid.center = nxt_wid.center ani = Animation(x=poi_x, y=poi_y, width=poi_w, height=poi_h, opacity=1.0, d=pos_delay, t='in_sine') \ + Animation(x=poi_x + poi_w * 0.156, y=poi_y + poi_h * 0.153, width=poi_w * 0.69, height=poi_h * 0.69, d=press_delay, t='out_sine') poi_values = animated_widget_values(poi_wid, ani) if isinstance(tap_wid, ButtonBehavior): release_ani = Animation(x=poi_x, y=poi_y, width=poi_w, height=poi_h, opacity=0.39, d=release_delay - 0.03) def _touched_anim(): wid_state = tap_wid.state tap_wid.state = 'normal' if wid_state == 'down' else 'down' if not isinstance(tap_wid, ToggleButtonBehavior): release_ani.start(poi_wid) self.main_app.call_method_delayed( release_delay, lambda *_args: (setattr(tap_wid, 'state', wid_state), self.setup_texts())) ani.bind(on_complete=lambda *_args: (_touched_anim(), self.setup_texts())) release_ani.bind(on_complete=lambda *_args: restore_widget_values(poi_wid, poi_values)) return ani
[docs] def teardown_shaders_and_animations(self): """ teardown all added shaders and animations of current tour page (including switch next page animations). """ for wid, anim, start_values in reversed(self._added_animations): anim.stop(wid) restore_widget_values(wid, start_values) self._added_animations = [] for wid, shader_id in reversed(self._added_shaders): wid.del_shader(shader_id) self._added_shaders = []
[docs] def teardown_app_flow(self): """ overridden to teardown the animations of the current/last-shown tour page. """ self.teardown_shaders_and_animations() # noinspection PyUnresolvedReferences super().teardown_app_flow() # pylint: disable=no-member
[docs]class AnimatedOnboardingTour(AnimatedTourMixin, OnboardingTour): # pragma: no cover """ onboarding tour, extended with animations and glsl shaders. """
[docs] def __init__(self, main_app: 'HelpAppBase') -> None: super().__init__(main_app) self._bound = None self.pages_animations.update({ None: ( ('@root', Animation(ani_value=0.999, t='in_out_sine', d=30) + Animation(ani_value=0.0, t='in_out_sine', d=9)), ), '': ( ('next_but', "A(font_size=layout.font_height, t='in_out_sine', d=24) + " "A(font_size=layout.main_app.framework_app.min_font_size, t='in_out_sine', d=3) + " "A(font_size=layout.main_app.framework_app.max_font_size, t='in_out_sine', d=6) + " "A(font_size=layout.font_height, t='in_out_sine', d=3)"), ), 'layout_font_size': ( ('@', "A(value=min(layout.main_app.font_size * 1.5, layout.main_app.framework_app.max_font_size)," " t='in_out_sine', d=12.9) + " "A(value=max(layout.main_app.font_size * 0.6, layout.main_app.framework_app.min_font_size)," " t='in_out_sine', d=4.2)"), ) }) self.pages_shaders.update({ '': ( ('layout', {'alpha': "lambda: 0.39 * layout.ani_value", 'center_pos': "lambda: list(map(float, layout.ids.next_but.center))", 'shader_code': "=plunge_waves", 'time': "lambda: -Clock.get_boottime()", 'tint_ink': [0.21, 0.39, 0.09, 0.9]}), ('tour_page_texts', {'add_to': 'before'}), ('next_but', {'add_to': 'before', 'alpha': "lambda: 0.3 + layout.ani_value / 3", 'render_shape': 'Ellipse', 'shader_code': '=plunge_waves'}), ), 'page_switching': ( ('layout', {'alpha': "lambda: 0.39 * layout.ani_value", 'center_pos': "lambda: list(map(float, layout.ids.prev_but.center))", 'shader_code': "=plunge_waves", 'time': "lambda: -Clock.get_boottime()", 'tint_ink': [0.21, 0.39, 0.09, 0.9]}), ('tour_page_texts', {'add_to': 'before'}), ('prev_but', {'add_to': 'before', 'alpha': "lambda: 0.12 + layout.ani_value / 3", 'render_shape': 'Ellipse', 'shader_code': '=plunge_waves', 'time': "lambda: -Clock.get_boottime()"}), ), 'tip_help_intro': ( ('tour_page_texts', {'add_to': 'before', 'alpha': "lambda: 0.12 + layout.ani_value / 3", 'render_shape': 'RoundedRectangle', 'shader_code': '=worm_whole', 'tint_ink': [0.021, 0.039, 0.009, 0.9]}), ('prev_but', {'add_to': 'before', 'alpha': "lambda: 0.12 + layout.ani_value / 3", 'render_shape': 'Ellipse', 'shader_code': '=worm_whole', 'time': "lambda: -Clock.get_boottime()"}), ('next_but', {'add_to': 'before', 'alpha': "lambda: 0.12 + layout.ani_value / 3", 'render_shape': 'Ellipse', 'shader_code': '=worm_whole'}), ), 'tip_help_tooltip': ( ('prev_but', {'add_to': 'before', 'render_shape': 'Ellipse', 'shader_code': '=fire_storm', 'tint_ink': [0.81, 0.39, 0.09, 0.39], 'time': "lambda: -Clock.get_boottime()"}), ('next_but', {'add_to': 'before', 'render_shape': 'Ellipse', 'shader_code': '=fire_storm', 'tint_ink': [0.03, 0.03, 0.9, 0.39]}), ), 'responsible_layout': ( ('prev_but', {'add_to': 'before', 'render_shape': 'Ellipse', 'shader_code': '=colored_smoke', 'time': "lambda: -Clock.get_boottime()"}), ('next_but', {'add_to': 'before', 'render_shape': 'Ellipse', 'shader_code': '=colored_smoke'}), ), 'layout_font_size': ( ('prev_but', {'add_to': 'before', 'render_shape': 'Ellipse', 'shader_code': '=circled_alpha', 'tint_ink': [0.51, 0.39, 0.9, 0.999]}), ('next_but', {'add_to': 'before', 'render_shape': 'Ellipse', 'shader_code': '=circled_alpha', 'tint_ink': [0.81, 0.39, 0.9, 0.999]}), ), 'tour_end': ( ('tour_page_texts', {'add_to': 'before'}), ('prev_but', {'add_to': 'before', 'render_shape': 'Ellipse', 'tint_ink': [0.51, 0.39, 0.9, 0.999], 'time': "lambda: -Clock.get_boottime()"}), ('next_but', {'add_to': 'before', 'render_shape': 'Ellipse', 'tint_ink': [0.81, 0.39, 0.9, 0.999]}), ), })
[docs] def next_page(self): """ overriding to remove next button size animation only visible in the first tour after app re/start. """ layout = self.layout layout.ani_value = 0.0 super().next_page() if self.last_page_id == '' and self.pages_animations.pop('', False): Animation(font_size=layout.font_height).start(layout.ids.next_but) # set font size back to original value
[docs] def setup_layout(self): """ overridden to update layout texts if app window/screen orientation (app.landscape) changes. """ super().setup_layout() page_id = self.page_ids[self.page_idx] if page_id == 'responsible_layout': self._bound = self.main_app.framework_app.fbind('landscape', lambda *_args: self.setup_texts()) elif page_id == 'layout_font_size': self._bound = self._added_animations[-1][1].fbind('on_progress', lambda *_args: self.setup_texts())
[docs] def teardown_shaders_and_animations(self): """ overridden to unbind setup_texts() on leaving the responsible_layout tour page. """ if self._bound: page_id = self.page_ids[self.page_idx] if page_id == 'responsible_layout': self.main_app.framework_app.unbind_uid('landscape', self._bound) elif page_id == 'layout_font_size': # noinspection PyUnresolvedReferences self._added_animations[-1][1].unbind_uid('on_progress', self._bound) self._bound = None super().teardown_shaders_and_animations()
[docs]class TourOverlay(ModalBehavior, ShadersMixin, FloatLayout): # pragma: no cover """ tour layout/view overlay singleton class to display an active/running modal app tour with optional glsl shaders. """ ani_value = NumericProperty() """ animated float value between 0.0 and 1.0, used e.g. by :attr:`AnimatedTourMixin.pages_animations`. :attr:`ani_value` is a :class:`~kivy.properties.NumericProperty` and is read-only. """ explained_pos = ListProperty([-9, -9]) """ window position (absolute x, y window coordinates) of the targeted/explained/highlighted widget. :attr:`explained_pos` is a :class:`~kivy.properties.ListProperty` and is read-only. """ explained_size = ListProperty([0, 0]) """ widget size (width, height) of the targeted/explained/highlighted widget. :attr:`explained_size` is a :class:`~kivy.properties.ListProperty` and is read-only. """ explained_widget = ObjectProperty() """ explained widget instance on actual tour (page). :attr:`explained_widget` is a :class:`~kivy.properties.ObjectProperty` and is read-only. """ fade_out_app = NumericProperty(DEF_FADE_OUT_APP) """ fade out app screen factor: 0.0 prevents fade out of the areas around TourPageTexts and the explained widget. 1.0 results in maximum app screen fade out. configurable for individual tour page via `page_data['fade_out_app']`. :attr:`fade_out_app` is a :class:`~kivy.properties.NumericProperty` and defaults to 0.39. """ label_height = NumericProperty() """ height in pixels of the page text labels and text lines. :attr:`label_height` is a :class:`~kivy.properties.NumericProperty` and is read-only. """ navigation_disabled = BooleanProperty() """ if this flag is True then the back/next buttons in the tour layout/overlay are disabled. :attr:`navigation_disabled` is a :class:`~kivy.properties.BooleanProperty` and is read-only. """ tour_instance = ObjectProperty() """ holding the :class:`~ae.gui_help.TourBase` instance of the current tour, initialized by :meth:`.start_tour`. :attr:`tour_instance` is a :class:`~kivy.properties.ObjectProperty` and is read-only. """
[docs] def __init__(self, main_app: HelpAppBase, tour_class: Optional[Type[TourBase]] = None, **kwargs): """ prepare app and tour overlay (singleton instance of this class) to start tour. :param main_app: main app instance. :param tour_class: optional tour (pages) class, default: tour class of current help id or OnboardingTour. """ self.main_app = main_app main_app.vpo("TourOverlay.__init__") self._tooltip_animation = None self.auto_dismiss = False self.explained_widget = main_app.help_activator # assign dummy init widget to prevent None errors super().__init__(**kwargs) if main_app.help_layout: main_app.help_activation_toggle() # deactivate help mode if activated self.start_tour(tour_class)
[docs] def next_page(self): """ switch to next tour page. """ self.main_app.vpo("TourOverlay.next_page") self.navigation_disabled = True self.tour_instance.cancel_auto_page_switch_request() self.tour_instance.next_page()
[docs] def on_navigation_disabled(self, *_args): """ navigation button disabled change event, used to hide page texts (blend-in-anim in page_updated()). """ if self.navigation_disabled: ani = Animation(opacity=0.123, d=0.6) ids = self.ids ani_start_check(ani, ids.tour_page_texts) ani_start_check(ani, ids.prev_but) ani_start_check(ani, ids.next_but) ani_start_check(ani, ids.stop_but)
[docs] def page_updated(self): """ callback from :meth:`~TourBase.setup_layout` for UI-specific patches, after tour layout/overlay setup. """ tooltip = self.ids.tooltip win_height = Window.height nav_y = self.label_height * 1.29 # default pos_y of navigation bar with prev/next buttons if self.main_app.widget_visible(tooltip): exp_y = self.explained_pos[1] pos1 = min(exp_y, tooltip.y) pos2 = max(exp_y + self.explained_size[1], tooltip.top) if pos1 < win_height - pos2: nav_y = max(nav_y + pos2, win_height - self.ids.tour_page_texts.height) ani_kwargs = {'t': 'in_out_sine', 'd': 2.1} ani_start_check(Animation(fade_out_app=self.tour_instance.page_data.get('fade_out_app', DEF_FADE_OUT_APP), navigation_pos_hint_y=nav_y / win_height, **ani_kwargs), self) ani = Animation(opacity=1.0, **ani_kwargs) ani_start_check(ani, self.ids.tour_page_texts) ani_start_check(ani, self.ids.prev_but) ani_start_check(ani, self.ids.next_but) ani_start_check(ani, self.ids.stop_but) self.navigation_disabled = False
[docs] def prev_page(self): """ switch to previous tour page. """ self.main_app.vpo("TourOverlay.prev_page") self.navigation_disabled = True self.tour_instance.cancel_auto_page_switch_request() self.tour_instance.prev_page()
[docs] def start_tour(self, tour_cls: Optional[Type[TourBase]] = None) -> bool: """ reset app state and prepare tour to start. :param tour_cls: optional tour (pages) class, default: tour of currently shown help id or OnboardingTour. :return: True if tour exists and got started. """ main_app = self.main_app if not tour_cls: tour_cls = help_id_tour_class(main_app.displayed_help_id) \ or REGISTERED_TOURS.get(snake_to_camel(main_app.app_name) + 'OnboardingTour') \ or AnimatedOnboardingTour main_app.vpo(f"TourOverlay.start_tour tour_cls={tour_cls.__name__}") try: main_app.change_observable('tour_layout', self) # set tour layout # noinspection PyArgumentList self.tour_instance = tour_instance = tour_cls(main_app) # initialize tour instance tour_instance.start() # start tour main_app.help_activator.ani_start() except Exception as ex: main_app.po(f"TourOverlay.start_tour exception {ex}") traceback.print_exc() main_app.help_activator.ani_stop() main_app.change_observable('tour_layout', None) # reset tour layout return False ani = Animation(ani_value=0.3, t='in_out_sine', d=6) + Animation(ani_value=0.999, t='in_out_sine', d=3) ani.repeat = True ani.start(self.ids.tooltip) self._tooltip_animation = ani self.activate_esc_key_close() self.activate_modal() return True
[docs] def stop_tour(self): """ stop tour and restore the initially backed-up app state. """ main_app = self.main_app main_app.vpo("TourOverlay.stop_tour") self.navigation_disabled = True if self._tooltip_animation: self._tooltip_animation.stop(self.ids.tooltip) if self.tour_instance: self.tour_instance.stop() else: main_app.po("TourOverlay.stop_tour error: called without tour instance") main_app.help_activator.ani_stop() main_app.change_observable('tour_layout', None) # set app./main_app.tour_layout to None self.deactivate_esc_key_close() self.deactivate_modal()