"""
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)