Source code for ae.kivy.apps

"""
ae.kivy.apps module
-------------------

this module is providing two application classes, one of them extending :class:`the Kivy App class <kivy.app.App>`.
the other app class is used as main app class, extending :class:`~ae.gui_help.HelpAppBase` with additional
attributes and helper methods.


application classes
^^^^^^^^^^^^^^^^^^^

the class :class:`~ae.kivy.apps.KivyMainApp` is implementing a main app class, reducing the amount of
code needed to create a Python application based on the `Kivy framework <https://kivy.org>`_.

:class:`~ae.kivy.apps.KivyMainApp` is based on the following classes:

    * the abstract base class :class:`~ae.gui_help.HelpAppBase` which adds context-sensitive help.
    * the abstract base class :class:`~ae.gui_app.MainAppBase` which adds :ref:`application status`,
      :ref:`app-state-variables`, :ref:`app-state-constants`, :ref:`application flow` and :ref:`application events`.
    * :class:`~ae.console.ConsoleApp` is adding :ref:`config-files`, :ref:`config-variables` and :ref:`config-options`.
    * :class:`~ae.core.AppBase` is adding :ref:`application logging` and :ref:`application debugging`.

this namespace portion is also encapsulating the :class:`Kivy App class <kivy.app.App>` via the
:class:`~ae.kivy.apps.FrameworkApp` class. this Kivy app class instance can be directly accessed from the
main app class instance via the :attr:`~ae.gui_app.MainAppBase.framework_app` attribute.


kivy app config variables
^^^^^^^^^^^^^^^^^^^^^^^^^

all the :ref:`config-variables` and app constants inherited from the base app classes are available.

.. hint::
    please see the documentation of the namespace portions/modules :mod:`ae.console`, :mod:`ae.gui_app`
    and :mod:`ae.gui_help` for more detailed information on all the inherited :ref:`config-variables`,
    :ref:`config-options`, :ref:`config-files` and :ref:`app-state-constants`.

the additional :ref:`config-variables` `win_min_width` and `win_min_height`, added by this portion, you can optionally
restrict the minimum size of the kivy main window of your app. their default values are set on app startup in the
method :meth:`~ae.kivy.apps.KivyMainApp.on_app_run`.

more constants provided by this portion are declared in the :mod:`~ae.kivy.widgets` module.


kivy application events
^^^^^^^^^^^^^^^^^^^^^^^

the main app class is firing :ref:`application events`, additional to the ones provided by
:class:`~ae.gui_app.MainAppBase`, by redirecting events of Kivy's :class:`~kivy.app.App` class.
these framework app events get fired after the event :meth:`~ae.gui_app.MainAppBase.on_app_run`,
in the following order (the Kivy event/callback-method name is given in brackets):

    * on_app_build (kivy.app.App.build, after the main kv file get loaded).
    * on_app_built (kivy.app.App.build, after the root widget get build).
    * on_app_start (kivy.app.App.on_start)
    * on_app_started (one clock tick after on_app_start/kivy.app.App.on_start)
    * on_app_pause (kivy.app.App.on_pause)
    * on_app_resume (kivy.app.App.on_resume)
    * on_app_stop (kivy.app.App.on_stop)
    * on_app_stopped (one clock tick after on_app_stop)

"""
import os
from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union

from plyer import vibrator                                                                              # type: ignore

from kivy.app import App                                                                                # type: ignore
from kivy.clock import Clock                                                                            # type: ignore
from kivy.core.audio import SoundLoader                                                                 # type: ignore
from kivy.core.window import Window                                                                     # type: ignore
from kivy.factory import Factory, FactoryException                                                      # type: ignore
from kivy.metrics import dp                                                                             # type: ignore
from kivy.properties import (                                                                           # type: ignore
    BooleanProperty, DictProperty, ListProperty, NumericProperty, ObjectProperty, StringProperty)
from kivy.uix.dropdown import DropDown                                                                  # type: ignore
from kivy.uix.popup import Popup                                                                        # type: ignore
from kivy.uix.widget import Widget                                                                      # type: ignore
from kivy.utils import escape_markup, get_hex_from_color                                                # type: ignore

from ae.base import os_platform                                                                         # type: ignore
from ae.files import CachedFile                                                                         # type: ignore
from ae.paths import app_docs_path                                                                      # type: ignore
from ae.core import DEBUG_LEVELS, DEBUG_LEVEL_ENABLED                                                   # type: ignore
from ae.gui_app import (                                                                                # type: ignore
    APP_STATE_SECTION_NAME, MAX_FONT_SIZE, MIN_FONT_SIZE,
    THEME_DARK_BACKGROUND_COLOR, THEME_DARK_FONT_COLOR, THEME_LIGHT_BACKGROUND_COLOR, THEME_LIGHT_FONT_COLOR)
from ae.gui_help import HelpAppBase                                                                     # type: ignore

from .i18n import get_txt
from .tours import TourOverlay
from .widgets import (
    ANI_SINE_DEEPER_REPEAT3, ERROR_VIBRATE_PATTERN, FlowPopup, MAIN_KV_FILE_NAME, Tooltip)


[docs]class FrameworkApp(App): """ Kivy framework app class proxy redirecting events and callbacks to the main app class instance. """ app_states = DictProperty() #: duplicate of MainAppBase app state for events/binds button_height = NumericProperty('45sp') #: default button height, dynamically calculated from font size displayed_help_id = StringProperty() #: help id of the currently explained/help-target widget font_color = ObjectProperty(THEME_DARK_FONT_COLOR) #: rgba color of the font used for labels/buttons/... help_layout = ObjectProperty(allownone=True) #: layout widget if help mode is active else None landscape = BooleanProperty() #: True if app win width is bigger than the app win height max_font_size = NumericProperty(MAX_FONT_SIZE) #: maximum font size in pixels bound to window size min_font_size = NumericProperty(MIN_FONT_SIZE) #: minimum - " - mixed_back_ink = ListProperty([.69, .69, .69, 1.]) #: background color mixed from available back inks tour_layout = ObjectProperty(allownone=True) #: overlay layout widget if tour is active else None
[docs] def __init__(self, main_app: 'KivyMainApp', **kwargs): """ init kivy app """ super().__init__(**kwargs) self.main_app = main_app #: set reference to KivyMainApp instance self.title = main_app.app_title #: set kivy.app.App.title self.icon = os.path.join("img", "app_icon.jpg") #: set kivy.app.App.icon self.use_kivy_settings = main_app.debug #: set kivy.app.App.use_kivy_settings
[docs] def build(self) -> Widget: """ kivy build app callback. :return: root widget (Main instance) of this app. """ self.main_app.vpo("FrameworkApp.build") self.main_app.call_method('on_app_build') Window.bind(on_resize=self.win_pos_size_change, left=self.win_pos_size_change, top=self.win_pos_size_change, on_key_down=self.key_press_from_kivy, on_key_up=self.key_release_from_kivy) def _set_button_height(*_args): new_height = round(self.main_app.font_size * 1.95) if self.button_height != new_height: self.button_height = new_height self.bind(app_states=_set_button_height) self.main_app.framework_root = root = Factory.Main() self.main_app.framework_win = Window # == root.parent (after the calling method has finished) self.main_app.call_method('on_app_built') return root
[docs] def key_press_from_kivy(self, keyboard: Any, key_code: int, _scan_code: int, key_text: Optional[str], modifiers: List[str]) -> bool: """ convert and redistribute key down/press events coming from Window.on_key_down. :param keyboard: used keyboard. :param key_code: key code of pressed key. :param _scan_code: key scan code of pressed key. :param key_text: key text of pressed key. :param modifiers: list of modifier keys (including e.g. 'capslock', 'numlock', ...) :return: True if key event got processed used by the app, else False. """ return self.main_app.key_press_from_framework( "".join(_.capitalize() for _ in sorted(modifiers) if _ in ('alt', 'ctrl', 'meta', 'shift')), keyboard.command_keys.get(key_code) or key_text or str(key_code))
[docs] def key_release_from_kivy(self, keyboard, key_code, _scan_code) -> bool: """ key release/up event. :return: return value of call to `on_key_release` (True if ke got processed/used). """ return self.main_app.call_method('on_key_release', keyboard.command_keys.get(key_code, str(key_code)))
[docs] def on_pause(self) -> bool: """ app pause event automatically saving the app states. emits the `on_app_pause` event. :return: True. """ self.main_app.vpo("FrameworkApp.on_pause") self.main_app.save_app_states() self.main_app.call_method('on_app_pause') return True
[docs] def on_resume(self) -> bool: """ app resume event automatically loading the app states. emits the `on_app_resume` event. :return: True. """ self.main_app.vpo("FrameworkApp.on_resume") self.main_app.load_app_states() self.main_app.call_method('on_app_resume') return True
[docs] def on_start(self): """ kivy app start event. called after :meth:`~ae.gui_app.MainAppBase.run_app` method, after Kivy created the main layout (by calling its :meth:`~kivy.app.App.build` method) and has attached it to the main window. emits the events: `on_app_start` and `on_app_started`. """ self.main_app.vpo("FrameworkApp.on_start") # self.win_pos_size_change() # init. app./self.landscape (on app startup and after build) self.main_app.call_method('on_app_start') Clock.schedule_once(lambda dt: self.main_app.call_method('on_app_started'))
[docs] def on_stop(self): """ quit app event automatically saving the app states. emits the `on_app_stopped` event whereas the method :meth:`~ae.gui_app.MainAppBase.stop_app` emits the `on_app_stop` event. """ self.main_app.vpo("FrameworkApp.on_stop") self.main_app.save_app_states() self.main_app.call_method('on_app_stop') Clock.schedule_once(lambda dt: self.main_app.call_method('on_app_stopped'))
[docs] def win_pos_size_change(self, *_): """ resize handler updates: :attr:`~ae.gui_app.MainAppBase.win_rectangle`, :attr:`~FrameworkApp.landscape`. """ self.main_app.win_pos_size_change(Window.left, Window.top, Window.width, Window.height)
[docs]class KivyMainApp(HelpAppBase): """ Kivy application """ documents_root_path: str = "." #: root file path for app documents, e.g. for import/export get_txt_: Any = get_txt #: make i18n translations available via main app instance kbd_input_mode: str = 'scale' #: optional app state to set Window[Base].softinput_mode tour_overlay_class: Optional[Any] = TourOverlay #: Kivy main app tour overlay class _debug_enable_clicks: int = 0 # implementation of abstract methods
[docs] def init_app(self, framework_app_class: Type[FrameworkApp] = FrameworkApp ) -> Tuple[Optional[Callable], Optional[Callable]]: """ initialize framework app instance and prepare app startup. :param framework_app_class: class to create app instance (optionally extended by app project). :return: callable to start and stop/exit the GUI event loop. """ self.documents_root_path = app_docs_path() self.framework_app = framework_app_class(self) if os.path.exists(MAIN_KV_FILE_NAME): self.framework_app.kv_file = MAIN_KV_FILE_NAME # pylint: disable=W0201 return self.framework_app.run, self.framework_app.stop
# overwritten and helper methods
[docs] def app_env_dict(self) -> Dict[str, Any]: """ collect run-time app environment data and settings. :return: dict with app environment data/settings. """ app_env_info = super().app_env_dict() app_env_info['dpi_factor'] = self.dpi_factor() if self.debug: app_env_info['image_files'] = self.image_files app_env_info['sound_files'] = self.sound_files app_states_data = {'app_state_version': self.app_state_version, 'app_state_keys': self.app_state_keys()} if self.verbose: app_states_data["framework app states"] = self.framework_app.app_states app_states_data['kbd_input_mode'] = self.kbd_input_mode app_env_info['help data'] = { 'displayed_help_id': self.displayed_help_id, 'global_variables': self.global_variables(), '_last_focus_flow_id': self._last_focus_flow_id, '_next_help_id': self._next_help_id, } app_env_info['app data']['documents_root_path'] = self.documents_root_path app_env_info['app states data'] = app_states_data return app_env_info
[docs] def call_method_delayed(self, delay: float, callback: Union[Callable, str], *args, **kwargs) -> Any: """ delayed call of passed callable/method with args/kwargs catching and logging exceptions preventing app exit. :param delay: delay in seconds before calling the callable/method specified by :paramref:`~call_method_delayed.callback`. :param callback: either callable or name of the main app method to call. :param args: args passed to the callable/main-app-method to be called. :param kwargs: kwargs passed to the callable/main-app-method to be called. :return: delayed call event (in Kivy of Type[ClockEvent]) providing a `cancel` method to allow the cancellation of the delayed call within the delay time. """ return Clock.schedule_once(lambda dt: self.call_method(callback, *args, **kwargs), timeout=delay)
[docs] def change_light_theme(self, light_theme: bool): """ change font and window clear/background colors to match 'light'/'black' themes. :param light_theme: pass True for light theme, False for black theme. """ Window.clearcolor = THEME_LIGHT_BACKGROUND_COLOR if light_theme else THEME_DARK_BACKGROUND_COLOR self.framework_app.font_color = THEME_LIGHT_FONT_COLOR if light_theme else THEME_DARK_FONT_COLOR
[docs] @staticmethod def class_by_name(class_name: str) -> Optional[Type]: """ resolve kv widgets """ try: return Factory.get(class_name) except (FactoryException, AttributeError): return None
[docs] @staticmethod def dpi_factor() -> float: """ dpi scaling factor - overridden to use Kivy's dpi scaling. """ return dp(1.0)
[docs] def ensure_top_most_z_index(self, widget: Widget): """ ensure visibility of the passed widget to be the foremost in the z index/order. :param widget: widget to check and possibly correct to be the foremost one. if other dropdown/popup opened after the passed widget/layout, then only correct z index/order to show this widget/layout as popup (in front, as foremost widget). if the passed widget has a method named `activate_modal` (like e.g. :meth:`ae.kivy.behaviors.ModalBehavior.activate_modal`) then it will be called. """ popups_parent = self.framework_win if widget not in popups_parent.children or popups_parent.children[0] == widget: return reactivate_modal = getattr(widget, 'activate_modal', None) if callable(reactivate_modal): reactivate_modal() else: popups_parent.remove_widget(widget) popups_parent.add_widget(widget)
[docs] def global_variables(self, **patches) -> Dict[str, Any]: """ overridden to add Kivy-specific globals. """ return super().global_variables(escape_markup=escape_markup, get_hex_from_color=get_hex_from_color, **patches)
[docs] def help_activation_toggle(self): # pragma: no cover """ button tapped event handler to switch help mode between active and inactive (also inactivating tour). """ activator = self.help_activator help_layout = self.help_layout tour_layout = self.tour_layout activate = help_layout is None and tour_layout is None help_id = '' help_vars = {} if activate: target, help_id = self.help_target_and_id(help_vars) help_layout = Tooltip(targeted_widget=target) self.framework_win.add_widget(help_layout) else: if help_layout: activator.ani_stop() ANI_SINE_DEEPER_REPEAT3.stop(help_layout) help_layout.ani_value = 0.99 self.framework_win.remove_widget(help_layout) help_layout = None self.change_observable('displayed_help_id', '') if tour_layout: tour_layout.stop_tour() self.change_observable('help_layout', help_layout) if activate: self.help_display(help_id, help_vars) # show found/initial help text (after self.help_layout got set) ANI_SINE_DEEPER_REPEAT3.start(help_layout) activator.ani_start()
[docs] def load_sounds(self): """ override to preload audio sounds from app folder snd into sound file cache. """ super().load_sounds() # load from sound file paths all files into :class:`~ae.files.RegisteredFile` instances self.sound_files.reclassify(object_loader=lambda f: SoundLoader.load(f.path)) # :class:`~ae.files.CachedFile`
[docs] def on_app_build(self): """ kivy App build event handler called at the beginning of :meth:`kivy.app.App.build`. """ super().on_app_build() self.vpo("KivyMainApp.on_app_build - reload image resources from kv file late imports, e.g. ae.kivy_user_prefs") self.load_images()
[docs] def on_app_built(self): """ kivy App build event handler called at the end of :meth:`kivy.app.App.build`. """ self.vpo("KivyMainApp.on_app_built default/fallback event handler called")
[docs] def on_app_pause(self): """ kivy :meth:`~kivy.app.App.on_pause` event handler. """ self.vpo("KivyMainApp.on_app_pause default/fallback event handler called")
[docs] def on_app_resume(self): """ kivy :meth:`~kivy.app.App.on_resume` event handler. """ self.vpo("KivyMainApp.on_app_resume default/fallback event handler called")
[docs] def on_app_run(self): # pragma: no cover """ run app event handler - used to set the user preference app states and initial window pos and size. """ super().on_app_run() self.vpo("KivyMainApp.on_app_run - setting lang, theme, win-pos/-size and softinput mode") get_txt.switch_lang(self.lang_code) self.change_light_theme(self.light_theme) Window.softinput_mode = self.kbd_input_mode Window.minimum_width = self.get_var('win_min_width', default_value=405) Window.minimum_height = self.get_var('win_min_height', default_value=303) if os_platform not in ('android', 'ios'): # ignore last win pos on android/iOS, use always the full screen win_rect = self.win_rectangle or KivyMainApp.win_rectangle # self val is empty tuple on first app start Window.left, Window.top = win_rect[:2] Window.size = win_rect[2:]
[docs] def on_app_start(self): # pragma: no cover """ app start event handler - triggered by FrameworkApp.on_start(). """ self.vpo("KivyMainApp.on_app_start")
[docs] def on_app_started(self): """ kivy :meth:`~kivy.app.App.on_start` event handler (called after on_app_build/on_app_built). """ super().on_app_started() # check user registration/onboarding tour start in ae.gui_help.HelpAppBase self.vpo("KivyMainApp.on_app_started event handler called - calling ae.gui_help.HelpAppBase.on_app_started")
[docs] def on_app_stopped(self): """ kivy :meth:`~kivy.app.App.on_stop` event handler (called after on_app_stop). """ self.vpo("KivyMainApp.on_app_stopped default/fallback event handler called")
[docs] def on_flow_widget_focused(self): """ set focus to the widget referenced by the current flow id. """ liw = self.widget_by_flow_id(self.flow_id) self.vpo(f"KivyMainApp.on_flow_widget_focused() '{self.flow_id}'" f" {liw} has={getattr(liw, 'focus', 'unsupported') if liw else ''}") if liw and getattr(liw, 'is_focusable', False) and not liw.focus: liw.focus = True
[docs] def on_kbd_input_mode_change(self, mode: str, _event_kwargs: Dict[str, Any]) -> bool: """ language app state change event handler. :param mode: the new softinput_mode string (passed as flow key). :param _event_kwargs: unused event kwargs. :return: True to confirm the language change. """ self.vpo(f"KivyMainApp.on_kbd_input_mode_change to {mode}") self.change_app_state('kbd_input_mode', mode) self.set_var('kbd_input_mode', mode, section=APP_STATE_SECTION_NAME) # add optional app state var to config Window.softinput_mode = mode return True
[docs] def on_lang_code(self): """ language code app-state-change-event-handler to refresh kv rules. """ self.vpo(f"KivyMainApp.on_lang_code: language got changed to {self.lang_code}") get_txt.switch_lang(self.lang_code)
[docs] def on_light_theme(self): """ theme app-state-change-event-handler. """ self.vpo(f"KivyMainApp.on_light_theme: theme got changed to {self.light_theme}") self.change_light_theme(self.light_theme)
[docs] def on_user_preferences_open(self, _flow_id: str, _event_kwargs: Dict[str, Any]) -> bool: """ enable debug mode after clicking 3 times within 6 seconds. :param _flow_id: (unused). :param _event_kwargs: (unused). :return: False for :meth:`~.on_flow_change` get called, opening user preferences popup. """ def _timeout_reset(_dt: float): self._debug_enable_clicks = 0 if not self.debug: self._debug_enable_clicks += 1 if self._debug_enable_clicks >= 3: self.on_debug_level_change(DEBUG_LEVELS[DEBUG_LEVEL_ENABLED], {}) # also enable for all sub-apps self._debug_enable_clicks = 0 elif self._debug_enable_clicks == 1: Clock.schedule_once(_timeout_reset, 6.0) return False # side-run:returning False (allowing user prefs dropdown to open)
[docs] def play_beep(self): """ make a short beep sound. """ self.play_sound('error')
[docs] def play_sound(self, sound_name: str): """ play audio/sound file. """ self.vpo(f"KivyMainApp.play_sound {sound_name}") file: Optional[CachedFile] = self.find_sound(sound_name) if file: try: sound_obj = file.loaded_object sound_obj.pitch = file.properties.get('pitch', 1.0) sound_obj.volume = ( file.properties.get('volume', 1.0) * self.framework_app.app_states.get('sound_volume', 1.)) sound_obj.play() except Exception as ex: # pragma: no cover self.po(f"KivyMainApp.play_sound exception {ex}") else: self.dpo(f"KivyMainApp.play_sound({sound_name}) not found")
[docs] def play_vibrate(self, pattern: Tuple = ERROR_VIBRATE_PATTERN): """ play vibrate pattern. """ self.vpo(f"KivyMainApp.play_vibrate {pattern}") if self.framework_app.app_states.get('vibration_volume', 1.): # no volume available, at least disable if 0.0 try: # added because it's crashing with current plyer version (master should work) vibrator.pattern(pattern) # except jnius.jnius.JavaException as ex: # self.po(f"KivyMainApp.play_vibrate JavaException {ex}, update plyer to git/master") except Exception as ex: self.po(f"KivyMainApp.play_vibrate exception {ex}")
[docs] def open_popup(self, popup_class: Type[Union[FlowPopup, Popup, DropDown]], **popup_kwargs) -> Widget: """ open Popup or DropDown using the `open` method. overwriting the main app class method. :param popup_class: class of the Popup or DropDown widget. :param popup_kwargs: args to be set as attributes of the popup class instance plus an optional `opener` kwarg that will pass the popup opener widget to the popup.open() method; if `opener` gets not specified then the framework window will be used. :return: created and displayed/opened popup class instance. """ self.dpo(f"KivyMainApp.open_popup {popup_class} {popup_kwargs}") # framework_win has absolute screen coordinates and lacks x, y properties, therefore use app.root as def opener opener = popup_kwargs.pop('opener', self.framework_win) popup_instance = popup_class(**popup_kwargs) popup_instance.open(opener) return popup_instance
[docs] def text_size_guess(self, text: str, font_size: float = 0.0, padding: Tuple[float, float] = (0.0, 0.0) ) -> Tuple[float, float]: """ quickly roughly pre-calculate texture size of a multi-line string without rendering. :param text: text string which can contain line feed characters. :param font_size: the font size to pseudo-render the passed text; using the value of :attr:`~ae.gui_app.MainAppBase.font_size` as default if not passed. :param padding: optional padding in pixels for x and y coordinate (totals for left+right/top+bottom). :return: roughly the size (width, height) to display the string passed into :paramref:`~text_size_guess.text`. more exactly size would need to use internal render methods of Kivy, like e.g. :meth:`~kivy.uix.textinput.TextInput._get_text_width` and :meth:`~kivy.core.text.LabelBase.get_extents`. """ if not font_size: font_size = self.font_size char_width = font_size / 1.77 line_height = font_size * 1.2 if text else 0 max_width = lines_height = 0.0 for line in text.split("\n"): line_width = len(line) * char_width if line_width > max_width: max_width = line_width lines_height += line_height return max_width + (padding[0] if text else 0.0), lines_height + (padding[1] if text else 0.0)
[docs] @staticmethod def widget_pos(wid) -> Tuple[float, float]: """ return widget's window x/y position (overridden for absolute coordinates relative/scrollable layouts). :param wid: widget to determine the position of. :return: tuple of x and y screen coordinate. """ return wid.to_window(*wid.pos)