Coverage for ae/gui_app/__init__.py: 100%
536 statements
« prev ^ index » next coverage.py v7.4.3, created at 2024-03-13 21:47 +0000
« prev ^ index » next coverage.py v7.4.3, created at 2024-03-13 21:47 +0000
1"""
2base class for python applications with a graphical user interface
3==================================================================
5the abstract base class :class:`MainAppBase` provided by this ae namespace portion allows the integration of any Python
6GUI framework into the ae namespace.
8on overview about the available GUI-framework-specific ae namespace portion implementations can be found in the
9documentation of the ae namespace portion :mod:`ae.lisz_app_data`.
12extended console application environment
13----------------------------------------
15the abstract base class :class:`MainAppBase` inherits directly from the ae namespace class
16:class:`ae console application environment class <ae.console.ConsoleApp>`. the so inherited helper methods are useful
17to log, configure and control the run-time of your GUI app via command line arguments.
19.. hint::
20 please see the documentation of :ref:`config-options` and :ref:`config-files` in the :mod:`ae.console` namespace
21 portion/module for more detailed information.
23:class:`MainAppBase` adds on top of the :class:`~ae.console.ConsoleApp` the concepts of :ref:`application events`,
24:ref:`application status` and :ref:`application flow`, explained further down.
27application events
28------------------
30the events described in this section are fired on application startup and shutdown. additional events get fired e.g. in
31relation to the app states (documented further down in the section :ref:`app state events`) or on start or stop of an
32:ref:`app tour <app tour start and stop events>`.
34the following application events are fired exactly one time at startup in the following order:
36* `on_app_init`: fired **after** :class:`ConsoleApp` app instance got initialized (detected config files)
37 and **before** the image and sound resources and app states get loaded and the GUI framework app class instance gets
38 initialized.
39* `on_app_run`: fired **from within** the method :meth:`~MainAppBase.run_app`, **after** the parsing of the command line
40 arguments and options, and **before** all portion resources got imported.
41* `on_app_build`: fired **after** all portion resources got loaded/imported, and **before** the framework event
42 loop of the used GUI framework gets started.
43* `on_app_started`: fired **after** all app initializations, and the start of and the initial processing of the
44 framework event loop.
46.. note::
47 the application events `on_app_build` and `on_app_started` have to be fired by the used GUI framework.
49.. hint::
50 depending on the used gui framework there can be more app start events. e.g. the :mod:`ae.kivy.apps` module
51 fires the events :meth:`~ae.kivy.apps.KivyMainApp.on_app_built` and :meth:`~ae.kivy.apps.KivyMainApp.on_app_start`
52 (all of them fired after
53 :meth:`~ae.kivy.apps.KivyMainApp.on_app_run` and :meth:`~ae.kivy.apps.KivyMainApp.on_app_build`).
54 see also :ref:`kivy application events`.
57when an application gets stopped then the following events get fired in the following order:
59* `on_app_exit`: fired **after* framework win got closed and just **before** the event loop of the GUI framework will be
60 stopped and the app shutdown.
61* `on_app_quit`: fired **after** the event loop of the GUI framework got stopped and before the :meth:`AppBase.shutdown`
62 method will be called.
64.. note::
65 the `on_app_exit` events will only be fired if the app is explicitly calling the
66 :meth:`~MainAppBase.stop_app` method.
68.. hint::
69 depending on the used gui framework there can be more events. e.g. the :mod:`~ae.kivy.apps` module fires the events
70 :meth:`~ae.kivy.apps.KivyMainApp.on_app_stop` and clock tick later :meth:`~ae.kivy.apps.KivyMainApp.on_app_stopped`
71 (both of them before :meth:`~ae.kivy.apps.KivyMainApp.on_app_quit` get fired).
72 see also :ref:`kivy application events`.
75application status
76------------------
78any application- and user-specific configurations like e.g. the last window position/size, the app theme/font/language
79or the last selected flow within your app, could be included in the application status.
81this namespace portion introduces the section `aeAppState` in the app :ref:`config-files`, where any status values can
82be stored persistently to be recovered on the next startup of your application.
84.. hint::
85 the section name `aeAppState` is declared by the :data:`APP_STATE_SECTION_NAME` constant. if you need to access this
86 config section directly then please use this constant instead of the hardcoded section name.
89.. _app-state-variables:
91app state variables
92^^^^^^^^^^^^^^^^^^^
94this module is providing/pre-defining the following application state variables:
96 * :attr:`~MainAppBase.app_state_version`
97 * :attr:`~MainAppBase.flow_id`
98 * :attr:`~MainAppBase.flow_id_ink`
99 * :attr:`~MainAppBase.flow_path`
100 * :attr:`~MainAppBase.flow_path_ink`
101 * :attr:`~MainAppBase.font_size`
102 * :attr:`~MainAppBase.lang_code`
103 * :attr:`~MainAppBase.light_theme`
104 * :attr:`~MainAppBase.selected_item_ink`
105 * :attr:`~MainAppBase.sound_volume`
106 * :attr:`~MainAppBase.unselected_item_ink`
107 * :attr:`~MainAppBase.vibration_volume`
108 * :attr:`~MainAppBase.win_rectangle`
110which app state variables are finally used by your app project is (fully data-driven) depending on the app state
111:ref:`config-variables` detected in all the :ref:`config-files` that are found/available at run-time of your app. the
112names of all the available application state variables can be determined with the main app helper method
113:meth:`~MainAppBase.app_state_keys`.
115if no config-file is provided then this package ensures at least the proper initialization of the following
116app state variables:
118 * :attr:`~MainAppBase.font_size`
119 * :attr:`~MainAppBase.lang_code`
120 * :attr:`~MainAppBase.light_theme`
121 * :attr:`~MainAppBase.win_rectangle`
123if your application is e.g. supporting a user-defined font size, using the provided/pre-defined app state variable
124:attr:`~MainAppBase.font_size`, then it has to call the method :meth:`change_app_state` with the argument of
125:paramref:`~MainAppBase.change_app_state.app_state_name` set to `font_size` every time when the user has changed the
126font size of your app.
128.. hint::
129 the two built-in app state variables are :attr:`~MainAppBase.flow_id` and :attr:`~MainAppBase.flow_path` will be
130 explained detailed in the next section.
132the :meth:`~MainBaseApp.load_app_states` method is called on instantiation from the implemented main app class to
133load the values of all app state variables from the :ref:`config-files`, and is then calling
134:meth:~MainAppBase.setup_app_states` for pass them into their corresponding instance attributes.
136use the main app instance attribute to read/get the actual value of a single app state variable. the actual values of
137all app state variables as a dict is determining the method :meth:`~MainBaseApp.retrieve_app_states`, and can be saved
138into the :ref:`config-files` for the next app run via the method :meth:`~MainBaseApp.save_app_states` - this could be
139done e.g. after the app state has changed or at least on quiting the application.
141always call the method :meth:`~MainBaseApp.change_app_state` to change an app state value to ensure:
143 (1) the propagation to any duplicated (observable/bound) framework property and
144 (2) the event notification of the related (optionally declared) main app instance method.
147.. _app-state-constants:
149app state constants
150^^^^^^^^^^^^^^^^^^^
152this module is also providing some pre-defined constants that can be optionally used in your application in relation to
153the app states data store and for the app state config variables :attr:`~MainAppBase.app_state_version`,
154:attr:`~MainAppBase.font_size` and :attr:`~MainAppBase.light_theme`:
156 * :data:`APP_STATE_SECTION_NAME`
157 * :data:`APP_STATE_VERSION_VAR_NAME`
158 * :data:`MIN_FONT_SIZE`
159 * :data:`MAX_FONT_SIZE`
160 * :data:`THEME_LIGHT_BACKGROUND_COLOR`
161 * :data:`THEME_LIGHT_FONT_COLOR`
162 * :data:`THEME_DARK_BACKGROUND_COLOR`
163 * :data:`THEME_DARK_FONT_COLOR`
166app state events
167^^^^^^^^^^^^^^^^
169there are three types of notification events get fired in relation to the app state variables, using the method names:
171* `on_<app_state_name>`: fired if the user of the app is changing the value of an app state variable.
172* `on_<app_state_name>_save`: fired if an app state gets saved to the config file.
173* `on_app_state_version_upgrade`: fired if the user upgrades a previously installed app to a higher version.
175the method name of the app state change notification event consists of the prefix ``on_`` followed by the variable name
176of the app state. so e.g. on a change of the `font_size` app state the notification event `on_font_size` will be
177fired/called (if exists as a method of the main app instance). these events don't provide any event arguments.
179the second event gets fired for each app state value just after the app states getting retrieved from the app class
180instance, and before they get stored into the main config file. the method name of this event includes also the name of
181the app state with the suffix `_save`, so e.g. for the app state `flow_id` the event method name will result in
182:meth:`on_app_state_flow_id_save`. this event is providing one event argument with the value of the app state. if the
183event method returns a value that is not `None` then this value will be stored/saved.
185the third event gets fired on app startup when the app got upgraded to a higher version of the app state variable
186APP_STATE_VERSION_VAR_NAME (`app_state_version`). it will be called providing the version number for each version to
187upgrade, starting with the version of the previously installed main config file, until the upgrade version of the main
188config file get reached. so if e.g. the previously installed app state version was 3 and the new version number is
1896 then this event will be fired 3 times with the argument 3, 4 and 5. it can be used e.g. to change or add app state
190variables or to adapt the app environment.
193application flow
194----------------
196to control the current state and UX flow (or context) of your application, and to persist it until the
197next app start, :class:`MainBaseApp` provides two :ref:`app-state-variables`: :attr:`~MainAppBase.flow_id` to store the
198currently working flow and :attr:`~MainAppBase.flow_path` to store the history of nested flows.
200an application flow is represented by an id string that defines three things: (1) the action to enter into the flow, (2)
201the data or object that gets currently worked on and (3) an optional key string that is identifying/indexing a widget or
202data item of your application context/flow.
204.. note::
205 never concatenate a flow id string manually, use the :func:`id_of_flow` function instead.
207the flow id is initially an empty string. as soon as the user is starting a new work flow or the current selection your
208application should call the method :meth:`~MainBaseApp.change_flow` passing the flow id string into the
209:paramref:`~MainAppBase.change_flow.new_flow_id` argument to change the app flow.
211for more complex applications you can specify a path of nested flows. this flow path gets represented by the app state
212variable :attr:`~MainAppBase.flow_path`, which is a list of flow id strings.
214to enter into a deeper/nested flow you simply call :meth:`~MainBaseApp.change_flow` with one of the actions defined
215in :data:`ACTIONS_EXTENDING_FLOW_PATH`.
217to go back to a previous flow in the flow path call :meth:`~MainBaseApp.change_flow` passing one of the actions
218defined in :data:`ACTIONS_REDUCING_FLOW_PATH`.
221application flow change events
222^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^
224the flow actions specified by :data:`ACTIONS_CHANGING_FLOW_WITHOUT_CONFIRMATION` don't need a flow change confirmation
225event handler:
227* `'enter'` or `'leave'` extend/reduce the flow path.
228* `'focus'` pass/change the input focus.
229* `'suggest'` for autocompletion or other suggestions.
231all other flow actions need to confirmed to be changed by :meth:`~MainAppBase.change_flow`, either by a custom flow
232change confirmation method/event-handler or by declaring a popup class. the name of the event handler and of the
233popup class gets determined from the flow id.
235.. hint::
236 the name of the flow change confirmation method that gets fired when the app want to change the flow (via the method
237 :meth:`~MainAppBase.change_flow`) gets determined by the function :func:`flow_change_confirmation_event_name`,
238 whereas the name of the popup class get determined by the function :func:`flow_popup_class_name`.
240if the flow-specific change confirmation event handler does not exist or returns in a boolean `False` or `None` then
241:meth:`~MainAppBase.on_flow_change` will be called. if this call also returns `False` then the action of the new flow id
242will be searched within :data:`ACTIONS_CHANGING_FLOW_WITHOUT_CONFIRMATION` and if not found then the flow change will be
243rejected and :meth:`~MainAppBase.change_flow` returns `False`.
245if in contrary either the flow change confirmation event handler exists and does return `True` or
246:meth:`~MainAppBase.on_flow_change` returns True or the flow action of the new flow id is in
247:data:`ACTIONS_CHANGING_FLOW_WITHOUT_CONFIRMATION` then the flow id and path will be changed accordingly.
249after the flow id/path change confirmation the method :meth:`~MainAppBase.change_flow` checks if the optional
250event_kwargs key `changed_event_name` got specified and if yes then it calls this method.
252finally, if a confirmed flow change results in a `'focus'` flow action then the event `on_flow_widget_focused` will be
253fired. this event can be used by the GUI framework to set the focus to the widget associated with the new focus flow id.
256flow actions `'open'` and `'close'`
257___________________________________
259to display an instance of a properly named popup class, simply initiate the change the app flow to an appropriate
260flow id (with an `'open'` flow action). in this case no change confirmation event handler is needed, because
261:meth:`~MainAppBase.on_flow_change` is then automatically opening the popup.
263when the popup is visible the flow path will be extended with the respective flow id.
265calling the `close` method of the popup will hide it. on closing the popup the flow id will be reset and the opening
266flow id will be removed from the flow path.
268all popup classes are providing the events `on_pre_open`, `on_open`, `on_pre_dismiss` and `on_dismiss`.
269the `on_dismiss` event handler can be used for data validation: returning a non-False value from it will cancel
270the close.
272.. hint::
273 see the documentation of each popup class for more details on the features of popup classes (for Kivy apps e.g.
274 :class:`~ae.kivy.widgets.FlowDropDown`, :class:`~ae.kivy.widgets.FlowPopup` or
275 :class:`~ae.kivy.widgets.FlowSelector`).
278key press events
279----------------
281to provide key press events to the applications that will use the new GUI framework you have to catch the key press
282events of the framework, convert/normalize them and then call the :meth:`~MainAppBase.key_press_from_framework` with the
283normalized modifiers and key args.
285the :paramref:`~MainAppBase.key_press_from_framework.modifiers` arg is a string that can contain several of the
286following sub-strings, always in the alphabetic order (like listed below):
288 * Alt
289 * Ctrl
290 * Meta
291 * Shift
293the :paramref:`~MainAppBase.key_press_from_framework.key` arg is a string that is specifying the last pressed key. if
294the key is not representing a single character but a command key, then `key` will be one of the following strings:
296 * escape
297 * tab
298 * backspace
299 * enter
300 * del
301 * enter
302 * up
303 * down
304 * right
305 * left
306 * home
307 * end
308 * pgup
309 * pgdown
311on call of :meth:`~MainAppBase.key_press_from_framework` this method will try to dispatch the key press event to your
312application. first it will check the app instance if it has declared a method with the name
313`on_key_press_of_<modifiers>_<key>` and if so it will call this method.
315if this method does return False (or any other value resulting in False) then method
316:meth:`~MainAppBase.key_press_from_framework` will check for a method with the same name in lower-case and if exits it
317will call this method.
319if also the second method does return False, then it will try to call the event method `on_key_press` of the app
320instance (if exists) with the modifiers and the key as arguments.
322if the `on_key_press` method does also return False then :meth:`~MainAppBase.key_press_from_framework` will finally pass
323the key press event to the original key press handler of the GUI framework for further processing.
326integrate new gui framework
327---------------------------
329to integrate a new Python GUI framework you have to declare a new class that inherits from :class:`MainAppBase` and
330implements at least the abstract method :meth:`~MainAppBase.init_app`.
332additionally and to load the resources of the app (after the portions resources got loaded) the event `on_app_build` has
333to be fired, executing the :meth:`MainAppBase.on_app_build` method. this could be done directly from within
334:meth:`~MainAppBase.init_app` or by redirecting one of the events of the app instance of the GUI framework.
336a minimal implementation of the :meth:`~MainAppBase.init_app` method would look like the following::
338 def init_app(self):
339 self.call_method('on_app_build')
340 return None, None
342most GUI frameworks are providing classes that need to be instantiated on application startup, like e.g. the instance of
343the GUI framework app class, the root widget or layout of the main GUI framework window(s). to keep a reference to
344these instances within your main app class you can use the attributes :attr:`~MainAppBase.framework_app`,
345:attr:`~MainAppBase.framework_root` and :attr:`~MainAppBase.framework_win` of the class :class:`MainAppBase`.
347the initialization of the attributes :attr:`~MainAppBase.framework_app`, :attr:`~MainAppBase.framework_root` and
348:attr:`~MainAppBase.framework_win` is optional and can be done e.g. within :meth:`~MainAppBase.init_app` or in the
349`on_app_build` application event fired later by the framework app instance.
351.. note::
352 if :attr:`~MainAppBase.framework_win` is set to a window instance, then the window instance has to provide a `close`
353 method, which will be called automatically by the :meth:`~MainAppBase.stop_app`.
355a typical implementation of a framework-specific main app class looks like::
357 from new_gui_framework import NewFrameworkApp, MainWindowClassOfNewFramework
359 class NewFrameworkMainApp(MainAppBase):
360 def init_app(self):
361 self.framework_app = NewFrameworkAppClass()
362 self.framework_win = MainWindowClassOfNewFramework()
364 # return callables to start/stop the event loop of the GUI framework
365 return self.framework_app.start, self.framework_app.stop
367in this example the `on_app_build` application event gets fired either from within the `start` method of the framework
368app instance or by an event provided by the GUI framework.
370:meth:`~MainAppBase.init_app` will be executed only once at the main app class instantiation. only the main app instance
371has to initialize the GUI framework to prepare the app startup and has to return at least a callable to start the event
372loop of the GUI framework.
374.. hint::
375 although not recommended because of possible namespace conflicts, one could e.g. alternatively integrate the
376 framework application class as a mixin to the main app class.
378to initiate the app startup the :meth:`~MainAppClass.run_app` method has to be called from the main module of your
379app project. :meth:`~MainAppBase.run_app` will then start the GUI event loop by calling the first method that got
380returned by :meth:`~MainAppBase.init_app`.
383optional configuration and extension
384------------------------------------
386most of the base implementation helper methods can be overwritten by either the inheriting framework portion or directly
387by user main app class.
390base resources for your gui app
391-------------------------------
393this portion is also providing base resources for commonly used images and sounds.
395the image file resources provided by this portion are taken from:
397* `iconmonstr <https://iconmonstr.com/interface/>`_.
400the sound files provides by this portion are taken from:
402* `Erokia <https://freesound.org/people/Erokia/>`_ at `freesound.org <https://freesound.org>`_.
403* `plasterbrain <https://freesound.org/people/plasterbrain/>`_ at `freesound.org <https://freesound.org>`_.
405.. hint:: the i18n translation texts of this module are provided by the ae namespace portion :mod:`ae.gui_help`.
407TODO:
408implement OS-independent detection of dark/light screen mode and automatic notification on day/night mode switch.
409- see https://github.com/albertosottile/darkdetect for MacOS, MSWindows and Ubuntu
410- see https://github.com/kvdroid/Kvdroid/blob/master/kvdroid/tools/darkmode.py for Android
411"""
412import os
413import re
415from abc import ABC, abstractmethod
416from copy import deepcopy
417from math import cos, sin, sqrt
418from typing import Any, Callable, Dict, List, Optional, Tuple, Type, Union
420from ae.base import ( # type: ignore
421 CFG_EXT, INI_EXT, NAME_PARTS_SEP, UNSET,
422 instantiate_config_parser, norm_name, norm_path, now_str, os_platform, request_app_permissions, snake_to_camel,
423 stack_var)
424from ae.files import RegisteredFile # type: ignore
425from ae.paths import ( # type: ignore
426 add_common_storage_paths, copy_file, copy_tree, normalize, coll_folders, path_name, placeholder_path,
427 Collector, FilesRegister, placeholder_key)
428from ae.updater import MOVES_SRC_FOLDER_NAME, check_all # type: ignore
429from ae.dynamicod import try_call # type: ignore
430from ae.i18n import ( # type: ignore
431 default_language, get_f_string, get_text, load_language_texts, register_translations_path)
432from ae.core import DEBUG_LEVELS, registered_app_names # type: ignore
433from ae.console import ConsoleApp # type: ignore
436__version__ = '0.3.93'
439APP_STATE_SECTION_NAME = 'aeAppState' #: config section name to store app state
441#: config variable name to store the current application state version
442APP_STATE_VERSION_VAR_NAME = 'app_state_version'
444COLOR_BLACK = [0.009, 0.006, 0.003, 1.0] #: != 0/1 to differentiate from framework pure black/white colors
445COLOR_WHITE = [0.999, 0.996, 0.993, 1.0]
446THEME_DARK_BACKGROUND_COLOR = COLOR_BLACK #: dark theme background color in rgba(0.0 ... 1.0)
447THEME_DARK_FONT_COLOR = COLOR_WHITE #: dark theme font color in rgba(0.0 ... 1.0)
448THEME_LIGHT_BACKGROUND_COLOR = COLOR_WHITE #: light theme background color in rgba(0.0 ... 1.0)
449THEME_LIGHT_FONT_COLOR = COLOR_BLACK #: light theme font color in rgba(0.0 ... 1.0)
451MIN_FONT_SIZE = 15.0 #: minimum (s.a. :attr:`~ae.kivy.apps.FrameworkApp.min_font_size`) and
452MAX_FONT_SIZE = 99.0 #: .. maximum font size in pixels
455ACTIONS_EXTENDING_FLOW_PATH = ['add', 'confirm', 'edit', 'enter', 'open', 'show', 'suggest']
456""" flow actions that are extending the flow path. """
457ACTIONS_REDUCING_FLOW_PATH = ['close', 'leave']
458""" flow actions that are shrinking/reducing the flow paths. """
459ACTIONS_CHANGING_FLOW_WITHOUT_CONFIRMATION = ['', 'enter', 'focus', 'leave', 'suggest']
460""" flow actions that are processed without the need to be confirmed. """
461FLOW_KEY_SEP = ':' #: separator character between flow action/object and flow key
463FLOW_ACTION_RE = re.compile("[a-z0-9]+") #: regular expression detecting invalid characters in flow action string
464FLOW_OBJECT_RE = re.compile("[A-Za-z0-9_]+") #: regular expression detecting invalid characters in flow object string
466HIDDEN_GLOBALS = (
467 'ABC', 'abstractmethod', '_add_base_globals', 'Any', '__builtins__', '__cached__', 'Callable', '_d_', 'Dict',
468 '__doc__', '__file__', 'List', '__loader__', 'module_globals', '__name__', 'Optional', '__package__', '__path__',
469 '__spec__', 'Tuple', 'Type', '__version__')
470""" tuple of global/module variable names that are hidden in :meth:`~MainAppBase.global_variables` """
472PORTIONS_IMAGES = FilesRegister() #: register of image files found in portions/packages at import time
473PORTIONS_SOUNDS = FilesRegister() #: register of sound files found in portions/packages at import time
475USER_NAME_MAX_LEN = 12 #: maximal length of a username/id (restricting :attr:`~ae.console.ConsoleApp.user_id`)
478AppStateType = Dict[str, Any] #: app state config variable type
479EventKwargsType = Dict[str, Any] #: change flow event kwargs type
481ColorRGB = Union[Tuple[float, float, float], List[float]] #: color red, green and blue parts
482ColorRGBA = Union[Tuple[float, float, float, float], List[float]] #: ink is rgb color and alpha
483ColorOrInk = Union[ColorRGB, ColorRGBA] #: color or ink type
486# check/install any outstanding updates or new app versions if imported directly from main app module
487if stack_var('__name__') == '__main__':
488 check_all() # pragma: no cover
491def ellipse_polar_radius(ell_a: float, ell_b: float, radian: float) -> float:
492 """ calculate the radius from polar for the given ellipse and radian.
494 :param ell_a: ellipse x-radius.
495 :param ell_b: ellipse y-radius.
496 :param radian: radian of angle.
497 :return: ellipse radius at the angle specified by :paramref:`.radian`.
498 """
499 return ell_a * ell_b / sqrt((ell_a * sin(radian)) ** 2 + (ell_b * cos(radian)) ** 2)
502def ensure_tap_kwargs_refs(init_kwargs: EventKwargsType, tap_widget: Any):
503 """ ensure that the passed widget.__init__ kwargs dict contains a reference to itself within kwargs['tap_kwargs'].
505 :param init_kwargs: kwargs of the widgets __init__ method.
506 :param tap_widget: reference to the tap widget.
508 this alternative version is only 10 % faster but much less clean than the current implementation::
510 if 'tap_kwargs' not in init_kwargs:
511 init_kwargs['tap_kwargs'] = {}
512 tap_kwargs = init_kwargs['tap_kwargs']
514 if 'tap_widget' not in tap_kwargs:
515 tap_kwargs['tap_widget'] = tap_widget
517 if 'popup_kwargs' not in tap_kwargs:
518 tap_kwargs['popup_kwargs'] = {}
519 popup_kwargs = tap_kwargs['popup_kwargs']
520 if 'opener' not in popup_kwargs:
521 popup_kwargs['opener'] = tap_kwargs['tap_widget']
523 """
524 init_kwargs['tap_kwargs'] = tap_kwargs = init_kwargs.get('tap_kwargs', {})
525 tap_kwargs['tap_widget'] = tap_widget = tap_kwargs.get('tap_widget', tap_widget)
526 tap_kwargs['popup_kwargs'] = popup_kwargs = tap_kwargs.get('popup_kwargs', {})
527 popup_kwargs['opener'] = popup_kwargs.get('opener', tap_widget)
530def flow_action(flow_id: str) -> str:
531 """ determine the action string of a flow_id.
533 :param flow_id: flow id.
534 :return: flow action string.
535 """
536 return flow_action_split(flow_id)[0]
539def flow_action_split(flow_id: str) -> Tuple[str, str]:
540 """ split flow id string into action part and the rest.
542 :param flow_id: flow id.
543 :return: tuple of (flow action string, flow obj and key string)
544 """
545 idx = flow_id.find(NAME_PARTS_SEP)
546 if idx != -1:
547 return flow_id[:idx], flow_id[idx + 1:]
548 return flow_id, ""
551def flow_change_confirmation_event_name(flow_id: str) -> str:
552 """ determine the name of the event method for the change confirmation of the passed flow_id.
554 :param flow_id: flow id.
555 :return: tuple with 2 items containing the flow action and the object name (and id).
556 """
557 flow, _index = flow_key_split(flow_id)
558 action, obj = flow_action_split(flow)
559 return f'on_{obj}_{action}'
562def flow_class_name(flow_id: str, name_suffix: str) -> str:
563 """ determine class name for the given flow id and class name suffix.
565 :param flow_id: flow id.
566 :param name_suffix: class name suffix.
567 :return: name of the class. please note that the flow action `open` will not be added
568 to the returned class name.
569 """
570 flow, _index = flow_key_split(flow_id)
571 action, obj = flow_action_split(flow)
572 if action == 'open':
573 action = ''
574 return f'{snake_to_camel(obj)}{action.capitalize()}{name_suffix}'
577def flow_key(flow_id: str) -> str:
578 """ return the key of a flow id.
580 :param flow_id: flow id string.
581 :return: flow key string.
582 """
583 _action_object, index = flow_key_split(flow_id)
584 return index
587def flow_key_split(flow_id: str) -> Tuple[str, str]:
588 """ split flow id into action with object and flow key.
590 :param flow_id: flow id to split.
591 :return: tuple of (flow action and object string, flow key string).
592 """
593 idx = flow_id.find(FLOW_KEY_SEP)
594 if idx != -1:
595 return flow_id[:idx], flow_id[idx + 1:]
596 return flow_id, ""
599def flow_object(flow_id: str) -> str:
600 """ determine the object string of the passed flow_id.
602 :param flow_id: flow id.
603 :return: flow object string.
604 """
605 return flow_action_split(flow_key_split(flow_id)[0])[1]
608def flow_path_id(flow_path: List[str], path_index: int = -1) -> str:
609 """ determine the flow id of the newest/last entry in the flow_path.
611 :param flow_path: flow path to get the flow id from.
612 :param path_index: index in the flow_path.
613 :return: flow id string or empty string if flow path is empty or index does not exist.
614 """
615 if len(flow_path) >= (abs(path_index) if path_index < 0 else path_index + 1):
616 return flow_path[path_index]
617 return ''
620def flow_path_strip(flow_path: List[str]) -> List[str]:
621 """ return copy of passed flow_path with all non-enter actions stripped from the end.
623 :param flow_path: flow path list to strip.
624 :return: stripped flow path copy.
625 """
626 deep = len(flow_path)
627 while deep and flow_action(flow_path_id(flow_path, path_index=deep - 1)) != 'enter':
628 deep -= 1
629 return flow_path[:deep]
632def flow_popup_class_name(flow_id: str) -> str:
633 """ determine name of the Popup class for the given flow id.
635 :param flow_id: flow id.
636 :return: name of the Popup class. please note that the action `open` will not be added
637 to the returned class name.
638 """
639 return flow_class_name(flow_id, 'Popup')
642def id_of_flow(action: str, obj: str = '', key: str = '') -> str:
643 """ create flow id string.
645 :param action: flow action string.
646 :param obj: flow object (defined by app project).
647 :param key: flow index/item_id/field_id/... (defined by app project).
648 :return: complete flow_id string.
649 """
650 assert action == '' or FLOW_ACTION_RE.fullmatch(action), \
651 f"flow action only allows lowercase letters and digits: got '{action}'"
652 assert obj == '' or FLOW_OBJECT_RE.fullmatch(obj), \
653 f"flow object only allows letters, digits and underscores: got '{obj}'"
654 cid = f'{action}{NAME_PARTS_SEP if action and obj else ""}{obj}'
655 if key:
656 cid += f'{FLOW_KEY_SEP}{key}'
657 return cid
660def register_package_images():
661 """ call from module scope of the package to register/add image/img resources path.
663 no parameters needed because we use here :func:`~ae.base.stack_var` helper function to determine the
664 module file path via the `__file__` module variable of the caller module in the call stack. in this call
665 we have to overwrite the default value (:data:`~ae.base.SKIPPED_MODULES`) of the
666 :paramref:`~ae.base.stack_var.skip_modules` parameter to not skip ae portions that are providing
667 package resources and are listed in the :data:`~ae.base.SKIPPED_MODULES`, like e.g. :mod:`ae.gui_app` and
668 :mod:`ae.gui_help` (passing empty string '' to overwrite default skip list).
669 """
670 global PORTIONS_IMAGES
672 package_path = os.path.dirname(norm_path(stack_var('__file__', '')))
673 search_path = os.path.join(package_path, 'img/**')
674 PORTIONS_IMAGES.add_paths(search_path)
677def register_package_sounds():
678 """ call from module scope of the package to register/add sound file resources.
680 no parameters needed because we use here :func:`~ae.base.stack_var` helper function to determine the
681 module file path via the `__file__` module variable of the caller module in the call stack. in this call
682 we have to overwrite the default value (:data:`~ae.base.SKIPPED_MODULES`) of the
683 :paramref:`~ae.base.stack_var.skip_modules` parameter to not skip ae portions that are providing
684 package resources and are listed in the :data:`~ae.base.SKIPPED_MODULES`, like e.g. :mod:`ae.gui_app`
685 :mod:`ae.gui_help` (passing empty string '' to overwrite default skip list).
686 """
687 global PORTIONS_SOUNDS
689 package_path = os.path.dirname(norm_path(stack_var('__file__', '')))
690 search_path = os.path.join(package_path, 'snd/**')
691 PORTIONS_SOUNDS.add_paths(search_path)
694def replace_flow_action(flow_id: str, new_action: str):
695 """ replace action in given flow id.
697 :param flow_id: flow id.
698 :param new_action: action to be set/replaced within passed flow id.
699 :return: flow id with new action and object/key from passed flow id.
700 """
701 return id_of_flow(new_action, *flow_key_split(flow_action_split(flow_id)[1]))
704def update_tap_kwargs(widget: Any, popup_kwargs: Optional[EventKwargsType] = None, **tap_kwargs) -> EventKwargsType:
705 """ update or simulate widget's tap_kwargs property and return the updated dictionary (for kv rule of tap_kwargs).
707 :param widget: widget with tap_kwargs property to be updated.
708 :param popup_kwargs: dict with items to update popup_kwargs key of tap_kwargs
709 :param tap_kwargs: additional tap_kwargs items to update.
710 :return:
711 """
712 handle = dict(tap_kwargs=widget.tap_kwargs) if hasattr(widget, 'tap_kwargs') else {}
713 ensure_tap_kwargs_refs(handle, widget)
714 new_kwargs = handle['tap_kwargs']
715 if popup_kwargs:
716 new_kwargs['popup_kwargs'].update(popup_kwargs)
717 if tap_kwargs:
718 new_kwargs.update(tap_kwargs)
719 return new_kwargs
722class MainAppBase(ConsoleApp, ABC):
723 """ abstract base class to implement a GUIApp-conform app class """
724 # app states
725 app_state_version: int = 0 #: version number of the app state variables in <config>.ini
727 flow_id: str = "" #: id of the current app flow (entered by the app user)
728 flow_path: List[str] #: list of flow ids, reflecting recent user actions
729 flow_id_ink: ColorOrInk = [0.99, 0.99, 0.69, 0.69] #: rgba color for flow id / drag&drop node placeholder
730 flow_path_ink: ColorOrInk = [0.99, 0.99, 0.39, 0.48] #: rgba color for flow_path/drag&drop item placeholder
732 font_size: float = 21.0 #: font size used toolbar and flow screens
733 lang_code: str = "" #: optional language code (e.g. 'es_ES' for Spanish)
734 light_theme: bool = False #: True=light theme/background, False=dark theme
736 selected_item_ink: ColorOrInk = [0.69, 1.0, 0.39, 0.18] #: rgba color for list items (selected)
737 unselected_item_ink: ColorOrInk = [0.39, 0.39, 0.39, 0.18] #: rgba color for list items (unselected)
739 sound_volume: float = 0.12 #: sound volume of current app (0.0=mute, 1.0=max)
740 vibration_volume: float = 0.3 #: vibration volume of current app (0.0=mute, 1.0=max)
742 win_rectangle: tuple = (0, 0, 1920, 1080) #: window coordinates (x, y, width, height)
744 # optional generic run-time shortcut references
745 framework_app: Any = None #: app class instance of the used GUI framework
746 framework_win: Any = None #: window instance of the used GUI framework
747 framework_root: Any = None #: app root layout widget
749 # optional app resources caches
750 image_files: Optional[FilesRegister] = None #: image/icon files
751 sound_files: Optional[FilesRegister] = None #: sound/audio files
753 def __init__(self, **console_app_kwargs):
754 """ create instance of app class.
756 :param console_app_kwargs: kwargs to be passed to the __init__ method of :class:`~ae.console_app.ConsoleApp`.
757 """
758 self._exit_code: int = 0 #: init by stop_app() and passed onto OS by run_app()
759 self._last_focus_flow_id: str = id_of_flow('') #: id of the last valid focused window/widget/item/context
761 self._start_event_loop: Optional[Callable] #: callable to start event loop of GUI framework
762 self._stop_event_loop: Optional[Callable] #: callable to start event loop of GUI framework
764 self.flow_path = [] # init for literal type recognition - will be overwritten by setup_app_states()
766 add_common_storage_paths() # determine platform specific path placeholders, like e.g. {pictures}, {downloads}..
768 super().__init__(**console_app_kwargs)
770 self.call_method('on_app_init')
772 self._start_event_loop, self._stop_event_loop = self.init_app()
774 self.load_app_states()
776 def _init_default_user_cfg_vars(self):
777 super()._init_default_user_cfg_vars()
778 self.user_specific_cfg_vars |= {
779 (APP_STATE_SECTION_NAME, 'flow_id'),
780 (APP_STATE_SECTION_NAME, 'flow_id_ink'),
781 (APP_STATE_SECTION_NAME, 'flow_path'),
782 (APP_STATE_SECTION_NAME, 'flow_path_ink'),
783 (APP_STATE_SECTION_NAME, 'font_size'),
784 (APP_STATE_SECTION_NAME, 'lang_code'),
785 (APP_STATE_SECTION_NAME, 'light_theme'),
786 (APP_STATE_SECTION_NAME, 'selected_item_ink'),
787 (APP_STATE_SECTION_NAME, 'sound_volume'),
788 (APP_STATE_SECTION_NAME, 'unselected_item_ink'),
789 (APP_STATE_SECTION_NAME, 'vibration_volume'),
790 (APP_STATE_SECTION_NAME, 'win_rectangle'),
791 }
793 @abstractmethod
794 def init_app(self, framework_app_class: Any = None) -> Tuple[Optional[Callable], Optional[Callable]]:
795 """ initialize framework app instance and root window/layout, return GUI event loop start/stop methods.
797 :param framework_app_class: class to create app instance (optionally extended by app project).
798 :return: tuple of two callable, the 1st to start and the 2nd to stop/exit
799 the GUI event loop.
800 """
802 def app_state_keys(self) -> Tuple[str, ...]:
803 """ determine current config variable names/keys of the app state section :data:`APP_STATE_SECTION_NAME`.
805 :return: tuple of all app state item keys (config variable names).
806 """
807 return self.cfg_section_variable_names(APP_STATE_SECTION_NAME)
809 @staticmethod
810 def backup_config_resources() -> str: # pragma: no cover
811 """ backup config files and image/sound/translations resources to {ado}<now_str>.
813 config files are collected from {ado}, {usr} or {cwd} (the first found file name only - see/sync-with
814 :meth:`ae.console.ConsoleApp.add_cfg_files`).
816 resources are copied from {ado} or {cwd} (only the first found resources root path).
817 """
818 backup_root = normalize("{ado}") + now_str(sep="_")
819 os.makedirs(backup_root)
821 coll = Collector()
822 app_configs = tuple(ana + ext for ana in registered_app_names() for ext in (INI_EXT, CFG_EXT))
823 coll.collect("{ado}", "{usr}", "{cwd}", append=app_configs, only_first_of=())
824 for file in coll.files:
825 copy_file(file, os.path.join(backup_root, placeholder_key(file) + "_" + os.path.basename(file)))
827 coll = Collector(item_collector=coll_folders)
828 coll.collect("{ado}", "{cwd}", append=('img', 'loc', 'snd'), only_first_of=())
829 for path in coll.paths:
830 copy_tree(path, os.path.join(backup_root, placeholder_key(path) + "_" + os.path.basename(path)))
832 return backup_root
834 def change_app_state(self, app_state_name: str, state_value: Any, send_event: bool = True, old_name: str = ''):
835 """ change app state to :paramref:`~change_app_state.state_value` in self.<app_state_name> and app_states dict.
837 :param app_state_name: name of the app state to change.
838 :param state_value: new value of the app state to change.
839 :param send_event: pass False to prevent send/call of the main_app.on_<app_state_name> event.
840 :param old_name: pass to add state to the main config file: old state name to rename/migrate or
841 :data:`~ae.base.UNSET` to only add a new app state variable with the name specified in
842 :paramref:`~change_app_state.app_state_name`.
843 """
844 self.vpo(f"MainAppBase.change_app_state({app_state_name}, {state_value!r}, {send_event}, {old_name!r})"
845 f" flow={self.flow_id} last={self._last_focus_flow_id} path={self.flow_path}")
847 self.change_observable(app_state_name, state_value, is_app_state=True)
849 if old_name or old_name is UNSET:
850 self.set_var(app_state_name, state_value, section=APP_STATE_SECTION_NAME, old_name=old_name or "")
852 if send_event:
853 self.call_method('on_' + app_state_name)
855 def change_observable(self, name: str, value: Any, is_app_state: bool = False):
856 """ change observable attribute/member/property in framework_app instance (and shadow copy in main app).
858 :param name: name of the observable attribute/member or key of an observable dict property.
859 :param value: new value of the observable.
860 :param is_app_state: pass True for an app state observable.
861 """
862 setattr(self, name, value)
863 if is_app_state:
864 if hasattr(self.framework_app, 'app_states'): # has observable DictProperty duplicates
865 self.framework_app.app_states[name] = value
866 name = 'app_state_' + name
867 if hasattr(self.framework_app, name): # has observable attribute duplicate
868 setattr(self.framework_app, name, value)
870 def change_flow(self, new_flow_id: str, **event_kwargs) -> bool:
871 """ try to change/switch the current flow id to the value passed in :paramref:`~change_flow.new_flow_id`.
873 :param new_flow_id: new flow id (maybe overwritten by flow change confirmation event handlers by assigning a
874 flow id to event_kwargs['flow_id']).
876 :param event_kwargs: optional args to pass additional data or info onto and from the flow change confirmation
877 event handler.
879 the following keys are currently supported/implemented by this module/portion
880 (additional keys can be added by the modules/apps using this method):
882 * `changed_event_name`: optional main app event method name to be called if the flow got
883 confirmed and changed.
884 * `count`: optional number used to render a pluralized help text for this flow change
885 (this number gets also passed to the help text formatter by/in
886 :meth:`~ae.gui_help.HelpAppBase.change_flow`).
887 * `edit_widget`: optional widget instance for edit/input.
888 * `flow_id`: process :attr:`~MainAppBase.flow_path` as specified by the
889 :paramref:`~change_flow.new_flow_id` argument, but then overwrite this flow id with
890 this event arg value to set :attr:`~MainAppBase.flow_id`.
891 * `popup_kwargs`: optional dict passed to the Popup `__init__` method, like e.g.
892 dict(opener=opener_widget_of_popup, data=...).
893 * `popups_to_close`: optional sequence of widgets to be closed by this method after flow
894 change got confirmed.
895 * 'reset_last_focus_flow_id': pass `True` to reset the last focus flow id, pass `False`
896 or `None` to ignore the last focus id (and not use to set flow id) or pass a flow id
897 string value to change the last focus flow id to the passed value.
898 * `tap_widget`: optional tapped button widget instance (initiating this flow change).
900 some of these keys get specified directly on the call of this method, e.g. via
901 :attr:`~ae.kivy.widgets.FlowButton.tap_kwargs` or
902 :attr:`~ae.kivy.widgets.FlowToggler.tap_kwargs`,
903 where others get added by the flow change confirmation handlers/callbacks.
905 :return: True if flow got confirmed by a declared custom flow change confirmation event handler
906 (either event method or Popup class) of the app and changed accordingly, else False.
908 some flow actions are handled internally independent of the return value of a
909 custom event handler, like e.g. `'enter'` or `'leave'` will always extend or reduce the
910 flow path and the action `'focus'` will give the indexed widget the input focus (these
911 exceptions are configurable via :data:`ACTIONS_CHANGING_FLOW_WITHOUT_CONFIRMATION`).
912 """
913 self.vpo(f"MainAppBase.change_flow({new_flow_id!r}, {event_kwargs}) id={self.flow_id!r} path={self.flow_path}")
914 prefix = " " * 12
915 action = flow_action(new_flow_id)
916 if not self.call_method(flow_change_confirmation_event_name(new_flow_id), flow_key(new_flow_id), event_kwargs) \
917 and not self.on_flow_change(new_flow_id, event_kwargs) \
918 and action not in ACTIONS_CHANGING_FLOW_WITHOUT_CONFIRMATION:
919 self.vpo(f"{prefix}REJECTED")
920 return False
922 has_flow_focus = flow_action(self.flow_id) == 'focus'
923 empty_flow = id_of_flow('')
924 if action in ACTIONS_EXTENDING_FLOW_PATH:
925 if action == 'edit' and self.flow_path_action() == 'edit' \
926 and flow_key_split(self.flow_path[-1])[0] == flow_key_split(new_flow_id)[0]:
927 _flow_id = self.flow_path.pop()
928 self.vpo(f"{prefix}PATH EDIT: removed '{_flow_id}', to replace it with ...")
929 self.vpo(f"{prefix}PATH EXTEND: appending '{new_flow_id}' to {self.flow_path}")
930 self.flow_path.append(new_flow_id)
931 self.change_app_state('flow_path', self.flow_path)
932 flow_id = empty_flow if action == 'enter' else new_flow_id
933 elif action in ACTIONS_REDUCING_FLOW_PATH:
934 # dismiss gets sent sometimes twice (e.g. on heavy double-clicking on drop-down-open-buttons)
935 # .. therefore prevent run-time error
936 if not self.flow_path:
937 self.dpo(f"{prefix}CANCELLED change_flow({new_flow_id}, {event_kwargs}) because of empty flow path")
938 return False
939 ended_flow_id = self.flow_path.pop()
940 self.vpo(f"{prefix}PATH REDUCE: popped '{ended_flow_id}' from {self.flow_path}")
941 self.change_app_state('flow_path', self.flow_path)
942 if action == 'leave':
943 flow_id = replace_flow_action(ended_flow_id, 'focus')
944 else:
945 flow_id = self.flow_id if has_flow_focus else empty_flow
946 else:
947 flow_id = new_flow_id if action == 'focus' else (self.flow_id if has_flow_focus else empty_flow)
949 popups_to_close = event_kwargs.get('popups_to_close', ())
950 for widget in reversed(popups_to_close):
951 widget.close()
952 # REMOVED in ae.gui_app v0.3.90: if hasattr(widget, 'attach_to'):
953 # widget.attach_to = None # mainly for DropDown widgets to prevent weakref-error/-exception
955 if action not in ACTIONS_REDUCING_FLOW_PATH or not has_flow_focus:
956 flow_id = event_kwargs.get('flow_id', flow_id) # update flow_id from event_kwargs
957 if 'reset_last_focus_flow_id' in event_kwargs:
958 last_flow_id = event_kwargs['reset_last_focus_flow_id']
959 if last_flow_id is True:
960 self._last_focus_flow_id = empty_flow
961 elif isinstance(last_flow_id, str):
962 self._last_focus_flow_id = last_flow_id
963 elif flow_id == empty_flow and self._last_focus_flow_id and action not in ACTIONS_EXTENDING_FLOW_PATH:
964 flow_id = self._last_focus_flow_id
965 self.change_app_state('flow_id', flow_id)
967 changed_event_name = event_kwargs.get('changed_event_name', '')
968 if changed_event_name:
969 self.call_method(changed_event_name)
971 if flow_action(flow_id) == 'focus':
972 self.call_method('on_flow_widget_focused')
973 self._last_focus_flow_id = flow_id
975 self.vpo(f"{prefix}CHANGED path={self.flow_path} args={event_kwargs} last_foc='{self._last_focus_flow_id}'")
977 return True
979 @staticmethod
980 def class_by_name(class_name: str) -> Optional[Type]:
981 """ search class name in framework modules as well as in app main.py to return class object.
983 :param class_name: name of the class.
984 :return: class object with the specified class name or :data:`~ae.base.UNSET` if not found.
985 """
986 return stack_var(class_name)
988 @staticmethod
989 def dpi_factor() -> float:
990 """ dpi scaling factor - override if the used GUI framework supports dpi scaling. """
991 return 1.0
993 def close_popups(self, classes: tuple = ()):
994 """ close all opened popups (starting with the foremost popup).
996 :param classes: optional class filter - if not passed then only the first foremost widgets underneath
997 the app win with an `open` method will be closed. pass tuple to restrict found popup
998 widgets to certain classes. like e.g. by passing (Popup, DropDown, FlowPopup) to get
999 all popups of an app (in Kivy use Factory.WidgetClass if widget is declared only in
1000 kv lang).
1001 """
1002 for popup in self.popups_opened(classes=classes):
1003 popup.close()
1005 def find_image(self, image_name: str, height: float = 32.0, light_theme: bool = True) -> Optional[RegisteredFile]:
1006 """ find best fitting image in img app folder (see also :meth:`~MainAppBase.img_file` for easier usage).
1008 :param image_name: name of the image (file name without extension).
1009 :param height: preferred height of the image/icon.
1010 :param light_theme: preferred theme (dark/light) of the image.
1011 :return: image file object (RegisteredFile/CachedFile) if found else None.
1012 """
1013 def property_matcher(file) -> bool:
1014 """ find images with matching theme.
1016 :param file: RegisteredFile instance.
1017 :return: True if theme is matching.
1018 """
1019 return bool(file.properties.get('light', 0)) == light_theme
1021 def file_sorter(file) -> float:
1022 """ sort images files by height delta.
1024 :param file: RegisteredFile instance.
1025 :return: height delta.
1026 """
1027 return abs(file.properties.get('height', -MAX_FONT_SIZE) - height)
1029 if self.image_files:
1030 return self.image_files(image_name, property_matcher=property_matcher, file_sorter=file_sorter)
1031 return None
1033 def find_sound(self, sound_name: str) -> Optional[RegisteredFile]:
1034 """ find sound by name.
1036 :param sound_name: name of the sound to search for.
1037 :return: cached sound file object (RegisteredFile/CachedFile) if sound name was found else None.
1038 """
1039 if self.sound_files: # prevent error on app startup (setup_app_states() called before load_images()
1040 return self.sound_files(sound_name)
1041 return None
1043 def find_widget(self, match: Callable[[Any], bool]) -> Optional[Any]:
1044 """ search the widget tree returning the first matching widget in reversed z-order (top-/foremost first).
1046 :param match: callable called with the widget as argument, returning True if widget matches.
1047 :return: first found widget in reversed z-order (top-most widget first).
1048 """
1049 def child_wid(children):
1050 """ bottom up search within children for a widget with matching attribute name and value. """
1051 for widget in children:
1052 found = child_wid(self.widget_children(widget))
1053 if found:
1054 return found
1055 if match(widget):
1056 return widget
1057 return None
1059 return child_wid(self.widget_children(self.framework_win))
1061 def flow_path_action(self, flow_path: Optional[List[str]] = None, path_index: int = -1) -> str:
1062 """ determine the action of the last (newest) entry in the flow_path.
1064 :param flow_path: optional flow path to get the flow action from (default=self.flow_path).
1065 :param path_index: optional index in the flow_path (default=-1).
1066 :return: flow action string or empty string if flow path is empty or index does not exist.
1067 """
1068 if flow_path is None:
1069 flow_path = self.flow_path
1070 return flow_action(flow_path_id(flow_path=flow_path, path_index=path_index))
1072 def global_variables(self, **patches) -> Dict[str, Any]:
1073 """ determine generic/most-needed global variables to evaluate expressions/macros.
1075 :param patches: dict of variable names and values to add/replace on top of generic globals.
1076 :return: dict of global variables patched with :paramref:`~global_variables.patches`.
1077 """
1078 glo_vars = {k: v for k, v in module_globals.items() if k not in HIDDEN_GLOBALS}
1079 glo_vars.update((k, v) for k, v in globals().items() if k not in HIDDEN_GLOBALS)
1080 glo_vars['app'] = self.framework_app
1081 glo_vars['main_app'] = self
1082 glo_vars['_add_base_globals'] = "" # instruct ae.dynamicod.try_eval to add generic/base globals
1084 self.vpo(f"MainAppBase.global_variables patching {patches} over {glo_vars}")
1086 glo_vars.update(**patches)
1088 return glo_vars
1090 def img_file(self, image_name: str, font_size: Optional[float] = None, light_theme: Optional[bool] = None) -> str:
1091 """ shortcutting :meth:`~MainAppBase.find_image` method w/o bound property to get image file path.
1093 :param image_name: image name (file name stem).
1094 :param font_size: optional font size in pixels.
1095 :param light_theme: optional theme (True=light, False=dark).
1096 :return: file path of image file or empty string if image file not found.
1097 """
1098 if font_size is None:
1099 font_size = self.font_size
1100 if light_theme is None:
1101 light_theme = self.light_theme
1103 img_obj = self.find_image(image_name, height=font_size, light_theme=light_theme)
1104 if img_obj:
1105 return img_obj.path
1106 return ''
1108 def key_press_from_framework(self, modifiers: str, key: str) -> bool:
1109 """ dispatch key press event, coming normalized from the UI framework.
1111 :param modifiers: modifier keys.
1112 :param key: key character.
1113 :return: True if key got consumed/used else False.
1114 """
1115 self.vpo(f"MainAppBase.key_press_from_framework({modifiers}+{key})")
1116 event_name = f'on_key_press_of_{modifiers}_{key}'
1117 en_lower = event_name.lower()
1118 if self.call_method(en_lower):
1119 return True
1120 if event_name != en_lower and self.call_method(event_name):
1121 return True
1122 # call default handler; pass lower key code (enaml/Qt sends upper-case key code if Shift modifier is pressed)
1123 return self.call_method('on_key_press', modifiers, key.lower()) or False
1125 def load_app_states(self):
1126 """ load application state from the config files to prepare app.run_app """
1127 app_states = {}
1128 for key in self.app_state_keys():
1129 pre = f" # app state '{key}' "
1130 if not hasattr(self, key):
1131 self.dpo(f"{pre}ignored because it is not declared as MainAppBase attribute")
1132 continue
1134 type_class = type(getattr(self, key))
1135 value = self.get_var(key, section=APP_STATE_SECTION_NAME)
1136 if not isinstance(value, type_class): # type mismatch - try to autocorrect
1137 self.dpo(f"{pre}type mismatch: attr={type_class} config-var={type(value)}")
1138 corr_val = try_call(type_class, value, ignored_exceptions=(Exception, TypeError, ValueError))
1139 if corr_val is UNSET:
1140 self.po(f"{pre}type mismatch in '{value}' could not be corrected to {type_class}")
1141 else:
1142 value = corr_val
1144 app_states[key] = value
1146 self.setup_app_states(app_states)
1148 def load_images(self):
1149 """ load images from app folder img. """
1150 file_reg = FilesRegister()
1151 file_reg.add_register(PORTIONS_IMAGES)
1152 file_reg.add_paths('img/**')
1153 file_reg.add_paths('{ado}/img/**')
1154 self.image_files = file_reg
1156 def load_sounds(self):
1157 """ load audio sounds from app folder snd. """
1158 file_reg = FilesRegister()
1159 file_reg.add_register(PORTIONS_SOUNDS)
1160 file_reg.add_paths('snd/**')
1161 file_reg.add_paths('{ado}/snd/**')
1162 self.sound_files = file_reg
1164 def load_translations(self, lang_code: str):
1165 """ load translation texts for the passed language code.
1167 :param lang_code: the new language code to be set (passed as flow key). empty on first app run/start.
1168 """
1169 is_empty = not lang_code
1170 old_lang = self.lang_code
1172 lang_code = load_language_texts(lang_code)
1173 self.change_app_state('lang_code', lang_code)
1175 if is_empty or lang_code != old_lang:
1176 default_language(lang_code)
1177 self.set_var('lang_code', lang_code, section=APP_STATE_SECTION_NAME) # add optional app state var to config
1179 def mix_background_ink(self):
1180 """ remix background ink if one of the basic back colours change. """
1181 self.framework_app.mixed_back_ink = [sum(_) / len(_) for _ in zip(
1182 self.flow_id_ink, self.flow_path_ink, self.selected_item_ink, self.unselected_item_ink)]
1184 def on_app_build(self):
1185 """ default/fallback flow change confirmation event handler. """
1186 self.vpo("MainAppBase.on_app_build default/fallback event handler called")
1188 def on_app_exit(self):
1189 """ default/fallback flow change confirmation event handler. """
1190 self.vpo("MainAppBase.on_app_exit default/fallback event handler called")
1192 def on_app_init(self):
1193 """ default/fallback flow change confirmation event handler. """
1194 self.vpo("MainAppBase.on_app_init default/fallback event handler called")
1196 def on_app_quit(self):
1197 """ default/fallback flow change confirmation event handler. """
1198 self.vpo("MainAppBase.on_app_quit default/fallback event handler called")
1200 def on_app_run(self):
1201 """ default/fallback flow change confirmation event handler. """
1202 self.vpo("MainAppBase.on_app_run default/fallback event handler called - loading resources (img, audio, i18n)")
1204 self.load_images()
1206 self.load_sounds()
1208 register_translations_path()
1209 register_translations_path("{ado}")
1210 self.load_translations(self.lang_code)
1212 def on_app_started(self):
1213 """ app initialization event - the last one on app startup. """
1214 self.vpo("MainAppBase.on_app_started default/fallback event handler called - requesting app permissions")
1215 request_app_permissions()
1217 def on_debug_level_change(self, level_name: str, _event_kwargs: EventKwargsType) -> bool:
1218 """ debug level app state change flow change confirmation event handler.
1220 :param level_name: the new debug level name to be set (passed as flow key).
1221 :param _event_kwargs: unused event kwargs.
1222 :return: True to confirm the debug level change.
1223 """
1224 debug_level = next(num for num, name in DEBUG_LEVELS.items() if name == level_name)
1225 self.vpo(f"MainAppBase.on_debug_level_change to {level_name} -> {debug_level}")
1226 self.set_opt('debug_level', debug_level)
1227 return True
1229 def on_flow_change(self, flow_id: str, event_kwargs: EventKwargsType) -> bool:
1230 """ checking if exists a Popup class for the new flow and if yes then open it.
1232 :param flow_id: new flow id.
1233 :param event_kwargs: optional event kwargs; the optional item with the key `popup_kwargs`
1234 will be passed onto the `__init__` method of the found Popup class.
1235 :return: True if Popup class was found and displayed.
1237 this method is mainly used as the last fallback clicked flow change confirmation event handler of a FlowButton.
1238 """
1239 class_name = flow_popup_class_name(flow_id)
1240 self.vpo(f"MainAppBase.on_flow_change '{flow_id}' {event_kwargs} lookup={class_name}")
1242 if flow_id:
1243 popup_class = self.class_by_name(class_name)
1244 if popup_class:
1245 popup_kwargs = event_kwargs.get('popup_kwargs', {})
1246 self.open_popup(popup_class, **popup_kwargs)
1247 return True
1248 return False
1250 def on_flow_id_ink(self):
1251 """ redirect flow id back ink app state color change event handler to actualize mixed_back_ink. """
1252 self.mix_background_ink()
1254 def on_flow_path_ink(self):
1255 """ redirect flow path back ink app state color change event handler to actualize mixed_back_ink. """
1256 self.mix_background_ink()
1258 @staticmethod
1259 def on_flow_popup_close(_flow_key: str, _event_kwargs: EventKwargsType) -> bool:
1260 """ default popup close handler of FlowPopup widget, ensuring update of :attr:`flow_path`.
1262 :param _flow_key: unused flow key.
1263 :param _event_kwargs: unused popup args.
1264 :return: always returning True.
1265 """
1266 return True
1268 def on_lang_code_change(self, lang_code: str, _event_kwargs: EventKwargsType) -> bool:
1269 """ language app state change flow change confirmation event handler.
1271 :param lang_code: the new language code to be set (passed as flow key). empty on first app run/start.
1272 :param _event_kwargs: unused event kwargs.
1273 :return: True to confirm the language change.
1274 """
1275 self.vpo(f"MainAppBase.on_lang_code_change to {lang_code}")
1276 self.load_translations(lang_code)
1277 return True
1279 def on_light_theme_change(self, _flow_key: str, event_kwargs: EventKwargsType) -> bool:
1280 """ app theme app state change flow change confirmation event handler.
1282 :param _flow_key: flow key.
1283 :param event_kwargs: event kwargs with key `'light_theme'` containing True|False for light|dark theme.
1284 :return: True to confirm change of flow id.
1285 """
1286 light_theme: bool = event_kwargs['light_theme']
1287 self.vpo(f"MainAppBase.on_light_theme_change to {light_theme}")
1288 self.change_app_state('light_theme', light_theme)
1289 return True
1291 def on_selected_item_ink(self):
1292 """ redirect selected item back ink app state color change event handler to actualize mixed_back_ink. """
1293 self.mix_background_ink()
1295 def on_unselected_item_ink(self):
1296 """ redirect unselected item back ink app state color change event handler to actualize mixed_back_ink. """
1297 self.mix_background_ink()
1299 def on_user_add(self, user_name: str, event_kwargs: Dict[str, Any]) -> bool:
1300 """ called on close of UserNameEditorPopup to check user input and create/register the current os user.
1302 :param user_name: new user id.
1303 :param event_kwargs: optionally pass True in the `unique_user_name` key to prevent duplicate usernames.
1304 :return: True if user got registered else False.
1305 """
1306 if not user_name:
1307 self.show_message(get_text("please enter your user or nick name"))
1308 return False
1309 if len(user_name) > USER_NAME_MAX_LEN:
1310 self.show_message(get_f_string(
1311 "please shorten your user name to not more than {USER_NAME_MAX_LEN} characters", glo_vars=globals()))
1312 return False
1314 chk_id = norm_name(user_name)
1315 if user_name != chk_id:
1316 self.show_message(get_f_string(
1317 "please remove spaces and the characters "
1318 "'{''.join(ch for ch in user_name if ch not in chk_id)}' from your user name",
1319 glo_vars=locals().copy()))
1320 return False
1322 if self.user_id in self.registered_users:
1323 if event_kwargs.get('unique_user_name', False) and \
1324 any(usr for usr in self.registered_users.values() if usr.get('user_name') == user_name):
1325 self.show_message(get_f_string("user name {user_name} already exists", glo_vars=locals().copy()))
1326 return False
1328 idx = 1
1329 while True:
1330 user_id = f'usr_id_{idx}'
1331 if user_id not in self.registered_users:
1332 break
1333 idx += 1
1334 self.user_id: str = user_id
1336 user_data = dict(user_name=user_name)
1337 self.register_user(**user_data)
1339 return True
1341 def play_beep(self):
1342 """ make a short beep sound, should be overwritten by GUI framework. """
1343 self.po(chr(7), "MainAppBase.BEEP")
1345 def play_sound(self, sound_name: str):
1346 """ play audio/sound file, should be overwritten by GUI framework.
1348 :param sound_name: name of the sound to play.
1349 """
1350 self.po(f"MainAppBase.play_sound {sound_name}")
1352 def play_vibrate(self, pattern: Tuple = (0.0, 0.09, 0.21, 0.3, 0.09, 0.09, 0.21, 0.09)):
1353 """ play vibrate pattern, should be overwritten by GUI framework.
1355 :param pattern: optional tuple of pause and vibrate time sequence - use error pattern if not passed.
1356 """
1357 self.po(f"MainAppBase.play_vibrate {pattern}")
1359 def open_popup(self, popup_class: Type, **popup_kwargs) -> Any:
1360 """ open Popup/DropDown, calling the `open`/`show` method of the instance created from the passed popup class.
1362 :param popup_class: class of the Popup/DropDown widget/window.
1363 :param popup_kwargs: args to instantiate and show/open the popup.
1364 :return: created and displayed/opened popup class instance.
1366 .. hint::
1367 overwrite this method if framework is using different method to open popup window or if
1368 a widget in the Popup/DropDown need to get the input focus.
1369 """
1370 self.dpo(f"MainAppBase.open_popup {popup_class} {popup_kwargs}")
1371 popup_instance = popup_class(**popup_kwargs)
1372 open_method = getattr(popup_instance, 'open', getattr(popup_instance, 'show', None))
1373 if callable(open_method):
1374 open_method()
1375 return popup_instance
1377 def popups_opened(self, classes: Tuple = ()) -> List:
1378 """ determine all popup-like container widgets that are currently opened.
1380 :param classes: optional class filter - if not passed then only the widgets underneath win/root with an
1381 `open` method will be added. pass tuple to restrict found popup widgets to certain
1382 classes. like e.g. by passing (Popup, DropDown, FlowPopup) to get all popups of an
1383 ae/Kivy app (in Kivy use Factory.WidgetClass if widget is declared only in kv lang).
1384 :return: list of the foremost opened/visible popup class instances (children of the app window),
1385 matching the :paramref:`classes` or having an `open` method, ordered by their
1386 z-coordinate (most front widget first).
1387 """
1388 filter_func = (lambda _wg: isinstance(_wg, classes)) if classes else \
1389 (lambda _wg: callable(getattr(_wg, 'open', None)))
1391 popups = []
1392 for wid in self.framework_win.children: # REMOVED in ae.gui_app v0.3.90: + self.framework_root.children:
1393 if not filter_func(wid):
1394 break
1395 popups.append(wid)
1397 return popups
1399 def retrieve_app_states(self) -> AppStateType:
1400 """ determine the state of a running app from the main app instance and return it as dict.
1402 :return: dict with all app states available in the config files.
1403 """
1404 app_states = {}
1405 for key in self.app_state_keys():
1406 app_states[key] = getattr(self, key)
1408 self.dpo(f"MainAppBase.retrieve_app_states {app_states}")
1409 return app_states
1411 def run_app(self):
1412 """ startup main and framework applications. """
1413 super().run_app() # parse command line arguments into config options
1414 self.dpo(f"MainAppBase.run_app {self.app_name}")
1416 self.call_method('on_app_run')
1418 if self._start_event_loop: # not needed for SubApp or additional Window instances
1419 try:
1420 self._start_event_loop()
1421 finally:
1422 self.call_method('on_app_quit')
1423 self.shutdown(self._exit_code or None) # don't call sys.exit() for zero exit code
1425 def save_app_states(self) -> str:
1426 """ save app state in config file.
1428 :return: empty string if app status could be saved into config files else error message.
1429 """
1430 err_msg = ""
1432 app_states = self.retrieve_app_states()
1433 for key, state in app_states.items():
1434 if isinstance(state, (list, dict)):
1435 state = deepcopy(state)
1437 new_state = self.call_method(f'on_app_state_{key}_save', state)
1438 if new_state is not None:
1439 state = new_state
1441 if key == 'flow_id' and flow_action(state) != 'focus':
1442 state = id_of_flow('')
1443 elif key == 'flow_path':
1444 state = flow_path_strip(state)
1446 err_msg = self.set_var(key, state, section=APP_STATE_SECTION_NAME)
1447 self.vpo(f"MainAppBase.save_app_state {key}={state} {err_msg or 'OK'}")
1448 if err_msg:
1449 break
1451 self.load_cfg_files()
1453 if self.debug_level:
1454 self.play_sound('error' if err_msg else 'debug_save')
1456 return err_msg
1458 def setup_app_states(self, app_states: AppStateType):
1459 """ put app state variables into main app instance to prepare framework app.run_app.
1461 :param app_states: dict of app states.
1462 """
1463 self.vpo(f"MainAppBase.setup_app_states {app_states}")
1465 # init/add most needed app states (e.g. for self.img_file() calls in .kv with font_size/light_theme bindings)
1466 font_size = app_states.get('font_size', 0.0)
1467 if not MIN_FONT_SIZE <= font_size <= MAX_FONT_SIZE:
1468 if font_size < 0.0:
1469 font_size = self.dpi_factor() * -font_size # adopt device scaling on very first app start
1470 elif font_size == 0.0:
1471 font_size = self.font_size
1472 app_states['font_size'] = min(max(MIN_FONT_SIZE, font_size), MAX_FONT_SIZE)
1473 if 'light_theme' not in app_states:
1474 app_states['light_theme'] = self.light_theme
1476 for key, val in app_states.items():
1477 self.change_app_state(key, val, send_event=False) # don't send on_-events as framework is not initialized
1478 if key == 'flow_id' and flow_action(val) == 'focus':
1479 self._last_focus_flow_id = val
1481 current_version = app_states.get(APP_STATE_VERSION_VAR_NAME, 0)
1482 upgrade_version = self.upgraded_config_app_state_version()
1483 if upgrade_version > current_version:
1484 for from_version in range(current_version, upgrade_version):
1485 self.call_method('on_app_state_version_upgrade', from_version)
1486 self.change_app_state(APP_STATE_VERSION_VAR_NAME, upgrade_version, send_event=False, old_name=UNSET)
1488 def show_message(self, message: str, title: str = "", is_error: bool = True):
1489 """ display (error) message popup to the user.
1491 :param message: message string to display.
1492 :param title: title of message box.
1493 :param is_error: pass False to not emit error tone/vibration.
1494 """
1495 if is_error:
1496 self.play_vibrate()
1497 self.play_beep()
1499 popup_kwargs = dict(message=message)
1500 if title:
1501 popup_kwargs['title'] = title
1503 self.change_flow(id_of_flow('show', 'message'), popup_kwargs=popup_kwargs)
1505 def stop_app(self, exit_code: int = 0):
1506 """ quit this application.
1508 :param exit_code: optional exit code.
1509 """
1510 self.dpo(f"MainAppBase.stop_app {exit_code}")
1511 self._exit_code = exit_code
1513 if self.framework_win:
1514 self.framework_win.close() # close window to save app state data and fire on_app_stop
1516 self.call_method('on_app_exit')
1518 if self._stop_event_loop:
1519 self._stop_event_loop() # will exit the self._start_event_loop() method called by self.run_app()
1521 def upgraded_config_app_state_version(self) -> int:
1522 """ determine app state version of an app upgrade.
1524 :return: value of app state variable APP_STATE_VERSION_VAR_NAME if the app got upgraded (and has
1525 a config file from a previous app installation), else 0.
1526 """
1527 cfg_file_name = os.path.join(MOVES_SRC_FOLDER_NAME, self.app_name + INI_EXT)
1528 cfg_parser = instantiate_config_parser()
1529 cfg_parser.read(cfg_file_name, encoding='utf-8')
1530 return cfg_parser.getint(APP_STATE_SECTION_NAME, APP_STATE_VERSION_VAR_NAME, fallback=0)
1532 def widget_by_attribute(self, att_name: str, att_value: str) -> Optional[Any]:
1533 """ determine the first (top-most) widget having the passed attribute name and value.
1535 :param att_name: name of the attribute of the searched widget.
1536 :param att_value: attribute value of the searched widget.
1537 :return: widget that has the specified attribute with the specified value or None if not found.
1538 """
1539 return self.find_widget(lambda widget: getattr(widget, att_name, None) == att_value)
1541 def widget_by_flow_id(self, flow_id: str) -> Optional[Any]:
1542 """ determine the first (top-most) widget having the passed flow_id.
1544 :param flow_id: flow id value of the searched widget's `tap_flow_id`/`focus_flow_id` attribute.
1545 :return: widget that has a `tap_flow_id`/`focus_flow_id` attribute with the value of the passed
1546 flow id or None if not found.
1547 """
1548 return self.widget_by_attribute('tap_flow_id', flow_id) or self.widget_by_attribute('focus_flow_id', flow_id)
1550 def widget_by_app_state_name(self, app_state_name: str) -> Optional[Any]:
1551 """ determine the first (top-most) widget having the passed app state name (app_state_name).
1553 :param app_state_name: app state name of the widget's `app_state_name` attribute.
1554 :return: widget that has a `app_state_name` attribute with the passed app state name
1555 or None if not found.
1556 """
1557 return self.widget_by_attribute('app_state_name', app_state_name)
1559 def widget_children(self, wid: Any, only_visible: bool = False) -> List:
1560 """ determine the children of widget or its container (if exists) in z-order (top-/foremost first).
1562 :param wid: widget to determine the children from.
1563 :param only_visible: pass True to only return visible widgets.
1564 :return: list of children widgets of the passed widget.
1565 """
1566 wid_visible = self.widget_visible
1567 return [chi for chi in getattr(wid, 'container', wid).children if not only_visible or wid_visible(chi)]
1569 @staticmethod
1570 def widget_pos(wid) -> Tuple[float, float]:
1571 """ return the absolute window x and y position of the passed widget.
1573 :param wid: widget to determine the position of.
1574 :return: tuple of x and y screen/window coordinate.
1575 """
1576 return wid.x, wid.y
1578 def widgets_enclosing_rectangle(self, widgets: Union[list, tuple]) -> Tuple[float, float, float, float]:
1579 """ calculate the minimum bounding rectangle all the passed widgets.
1581 :param widgets: list/tuple of widgets to determine the minimum bounding rectangle for.
1582 :return: tuple of floats with the x, y, width, height values of the bounding rectangle.
1583 """
1584 min_x = min_y = 999999.9
1585 max_x = max_y = 0.0
1587 for wid in widgets:
1588 w_x, w_y = self.widget_pos(wid)
1589 if w_x < min_x:
1590 min_x = w_x
1591 if w_y < min_y:
1592 min_y = w_y
1594 w_w, w_h = self.widget_size(wid)
1595 if w_x + w_w > max_x:
1596 max_x = w_x + w_w
1597 if w_y + w_h > max_y:
1598 max_y = w_y + w_h
1600 return min_x, min_y, max_x - min_x, max_y - min_y
1602 @staticmethod
1603 def widget_size(wid) -> Tuple[float, float]:
1604 """ return the size (width and height) in pixels of the passed widget.
1606 :param wid: widget to determine the size of.
1607 :return: tuple of width and height in pixels.
1608 """
1609 return wid.width, wid.height
1611 @staticmethod
1612 def widget_visible(wid: Any) -> bool:
1613 """ determine if the passed widget is visible (has width and height and (visibility or opacity) set).
1615 :param wid: widget to determine visibility of.
1616 :return: True if widget is visible (or visibility cannot be determined), False if hidden.
1617 """
1618 return bool(wid.width and wid.height and
1619 getattr(wid, 'visible', True) in (True, None) and # containers/BoxLayout.visible is None ?!?!?
1620 getattr(wid, 'opacity', True))
1622 def win_pos_size_change(self, *win_pos_size):
1623 """ screen resize handler called on window resize or when app will exit/stop via closed event.
1625 :param win_pos_size: window geometry/coordinates: x, y, width, height.
1626 """
1627 app = self.framework_app
1628 win_width, win_height = win_pos_size[2:]
1629 app.landscape = win_width >= win_height # update landscape flag
1631 self.vpo(f"MainAppBase.win_pos_size_change {win_pos_size}: landscape={app.landscape}")
1633 self.change_app_state('win_rectangle', win_pos_size)
1634 self.call_method('on_win_pos_size')
1637register_package_images() # register base image files of this portion
1638register_package_sounds() # register base sound files of this portion
1640# reference imported but unused names in pseudo variable `_d_`, to be available in :meth:`MainAppBase.global_variables`
1641_d_ = (os_platform, path_name, placeholder_path)
1642module_globals = globals()
1643#: used. e.g. by :mod:`ae.gui_help` for execution/evaluation of dynamic code, expressions and f-strings