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

1""" 

2base class for python applications with a graphical user interface 

3================================================================== 

4 

5the abstract base class :class:`MainAppBase` provided by this ae namespace portion allows the integration of any Python 

6GUI framework into the ae namespace. 

7 

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`. 

10 

11 

12extended console application environment 

13---------------------------------------- 

14 

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. 

18 

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. 

22 

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. 

25 

26 

27application events 

28------------------ 

29 

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>`. 

33 

34the following application events are fired exactly one time at startup in the following order: 

35 

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. 

45 

46.. note:: 

47 the application events `on_app_build` and `on_app_started` have to be fired by the used GUI framework. 

48 

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`. 

55 

56 

57when an application gets stopped then the following events get fired in the following order: 

58 

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. 

63 

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. 

67 

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`. 

73 

74 

75application status 

76------------------ 

77 

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. 

80 

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. 

83 

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. 

87 

88 

89.. _app-state-variables: 

90 

91app state variables 

92^^^^^^^^^^^^^^^^^^^ 

93 

94this module is providing/pre-defining the following application state variables: 

95 

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` 

109 

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`. 

114 

115if no config-file is provided then this package ensures at least the proper initialization of the following 

116app state variables: 

117 

118 * :attr:`~MainAppBase.font_size` 

119 * :attr:`~MainAppBase.lang_code` 

120 * :attr:`~MainAppBase.light_theme` 

121 * :attr:`~MainAppBase.win_rectangle` 

122 

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. 

127 

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. 

131 

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. 

135 

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. 

140 

141always call the method :meth:`~MainBaseApp.change_app_state` to change an app state value to ensure: 

142 

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. 

145 

146 

147.. _app-state-constants: 

148 

149app state constants 

150^^^^^^^^^^^^^^^^^^^ 

151 

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`: 

155 

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` 

164 

165 

166app state events 

167^^^^^^^^^^^^^^^^ 

168 

169there are three types of notification events get fired in relation to the app state variables, using the method names: 

170 

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. 

174 

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. 

178 

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. 

184 

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. 

191 

192 

193application flow 

194---------------- 

195 

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. 

199 

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. 

203 

204.. note:: 

205 never concatenate a flow id string manually, use the :func:`id_of_flow` function instead. 

206 

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. 

210 

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. 

213 

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`. 

216 

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`. 

219 

220 

221application flow change events 

222^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ 

223 

224the flow actions specified by :data:`ACTIONS_CHANGING_FLOW_WITHOUT_CONFIRMATION` don't need a flow change confirmation 

225event handler: 

226 

227* `'enter'` or `'leave'` extend/reduce the flow path. 

228* `'focus'` pass/change the input focus. 

229* `'suggest'` for autocompletion or other suggestions. 

230 

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. 

234 

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`. 

239 

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`. 

244 

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. 

248 

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. 

251 

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. 

254 

255 

256flow actions `'open'` and `'close'` 

257___________________________________ 

258 

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. 

262 

263when the popup is visible the flow path will be extended with the respective flow id. 

264 

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. 

267 

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. 

271 

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`). 

276 

277 

278key press events 

279---------------- 

280 

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. 

284 

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

287 

288 * Alt 

289 * Ctrl 

290 * Meta 

291 * Shift 

292 

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: 

295 

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 

310 

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. 

314 

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. 

318 

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. 

321 

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. 

324 

325 

326integrate new gui framework 

327--------------------------- 

328 

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`. 

331 

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. 

335 

336a minimal implementation of the :meth:`~MainAppBase.init_app` method would look like the following:: 

337 

338 def init_app(self): 

339 self.call_method('on_app_build') 

340 return None, None 

341 

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`. 

346 

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. 

350 

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`. 

354 

355a typical implementation of a framework-specific main app class looks like:: 

356 

357 from new_gui_framework import NewFrameworkApp, MainWindowClassOfNewFramework 

358 

359 class NewFrameworkMainApp(MainAppBase): 

360 def init_app(self): 

361 self.framework_app = NewFrameworkAppClass() 

362 self.framework_win = MainWindowClassOfNewFramework() 

363 

364 # return callables to start/stop the event loop of the GUI framework 

365 return self.framework_app.start, self.framework_app.stop 

366 

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. 

369 

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. 

373 

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. 

377 

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`. 

381 

382 

383optional configuration and extension 

384------------------------------------ 

385 

386most of the base implementation helper methods can be overwritten by either the inheriting framework portion or directly 

387by user main app class. 

388 

389 

390base resources for your gui app 

391------------------------------- 

392 

393this portion is also providing base resources for commonly used images and sounds. 

394 

395the image file resources provided by this portion are taken from: 

396 

397* `iconmonstr <https://iconmonstr.com/interface/>`_. 

398 

399 

400the sound files provides by this portion are taken from: 

401 

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>`_. 

404 

405.. hint:: the i18n translation texts of this module are provided by the ae namespace portion :mod:`ae.gui_help`. 

406 

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 

414 

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 

419 

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 

434 

435 

436__version__ = '0.3.93' 

437 

438 

439APP_STATE_SECTION_NAME = 'aeAppState' #: config section name to store app state 

440 

441#: config variable name to store the current application state version 

442APP_STATE_VERSION_VAR_NAME = 'app_state_version' 

443 

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) 

450 

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 

453 

454 

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 

462 

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 

465 

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

471 

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 

474 

475USER_NAME_MAX_LEN = 12 #: maximal length of a username/id (restricting :attr:`~ae.console.ConsoleApp.user_id`) 

476 

477 

478AppStateType = Dict[str, Any] #: app state config variable type 

479EventKwargsType = Dict[str, Any] #: change flow event kwargs type 

480 

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 

484 

485 

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 

489 

490 

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. 

493 

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) 

500 

501 

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']. 

504 

505 :param init_kwargs: kwargs of the widgets __init__ method. 

506 :param tap_widget: reference to the tap widget. 

507 

508 this alternative version is only 10 % faster but much less clean than the current implementation:: 

509 

510 if 'tap_kwargs' not in init_kwargs: 

511 init_kwargs['tap_kwargs'] = {} 

512 tap_kwargs = init_kwargs['tap_kwargs'] 

513 

514 if 'tap_widget' not in tap_kwargs: 

515 tap_kwargs['tap_widget'] = tap_widget 

516 

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'] 

522 

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) 

528 

529 

530def flow_action(flow_id: str) -> str: 

531 """ determine the action string of a flow_id. 

532 

533 :param flow_id: flow id. 

534 :return: flow action string. 

535 """ 

536 return flow_action_split(flow_id)[0] 

537 

538 

539def flow_action_split(flow_id: str) -> Tuple[str, str]: 

540 """ split flow id string into action part and the rest. 

541 

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

549 

550 

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. 

553 

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}' 

560 

561 

562def flow_class_name(flow_id: str, name_suffix: str) -> str: 

563 """ determine class name for the given flow id and class name suffix. 

564 

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}' 

575 

576 

577def flow_key(flow_id: str) -> str: 

578 """ return the key of a flow id. 

579 

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 

585 

586 

587def flow_key_split(flow_id: str) -> Tuple[str, str]: 

588 """ split flow id into action with object and flow key. 

589 

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

597 

598 

599def flow_object(flow_id: str) -> str: 

600 """ determine the object string of the passed flow_id. 

601 

602 :param flow_id: flow id. 

603 :return: flow object string. 

604 """ 

605 return flow_action_split(flow_key_split(flow_id)[0])[1] 

606 

607 

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. 

610 

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 '' 

618 

619 

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. 

622 

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] 

630 

631 

632def flow_popup_class_name(flow_id: str) -> str: 

633 """ determine name of the Popup class for the given flow id. 

634 

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

640 

641 

642def id_of_flow(action: str, obj: str = '', key: str = '') -> str: 

643 """ create flow id string. 

644 

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 

658 

659 

660def register_package_images(): 

661 """ call from module scope of the package to register/add image/img resources path. 

662 

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 

671 

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) 

675 

676 

677def register_package_sounds(): 

678 """ call from module scope of the package to register/add sound file resources. 

679 

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 

688 

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) 

692 

693 

694def replace_flow_action(flow_id: str, new_action: str): 

695 """ replace action in given flow id. 

696 

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

702 

703 

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

706 

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 

720 

721 

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 

726 

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 

731 

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 

735 

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) 

738 

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) 

741 

742 win_rectangle: tuple = (0, 0, 1920, 1080) #: window coordinates (x, y, width, height) 

743 

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 

748 

749 # optional app resources caches 

750 image_files: Optional[FilesRegister] = None #: image/icon files 

751 sound_files: Optional[FilesRegister] = None #: sound/audio files 

752 

753 def __init__(self, **console_app_kwargs): 

754 """ create instance of app class. 

755 

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 

760 

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 

763 

764 self.flow_path = [] # init for literal type recognition - will be overwritten by setup_app_states() 

765 

766 add_common_storage_paths() # determine platform specific path placeholders, like e.g. {pictures}, {downloads}.. 

767 

768 super().__init__(**console_app_kwargs) 

769 

770 self.call_method('on_app_init') 

771 

772 self._start_event_loop, self._stop_event_loop = self.init_app() 

773 

774 self.load_app_states() 

775 

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 } 

792 

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. 

796 

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

801 

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`. 

804 

805 :return: tuple of all app state item keys (config variable names). 

806 """ 

807 return self.cfg_section_variable_names(APP_STATE_SECTION_NAME) 

808 

809 @staticmethod 

810 def backup_config_resources() -> str: # pragma: no cover 

811 """ backup config files and image/sound/translations resources to {ado}<now_str>. 

812 

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`). 

815 

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) 

820 

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

826 

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

831 

832 return backup_root 

833 

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. 

836 

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

846 

847 self.change_observable(app_state_name, state_value, is_app_state=True) 

848 

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

851 

852 if send_event: 

853 self.call_method('on_' + app_state_name) 

854 

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

857 

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) 

869 

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`. 

872 

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']). 

875 

876 :param event_kwargs: optional args to pass additional data or info onto and from the flow change confirmation 

877 event handler. 

878 

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

881 

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

899 

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. 

904 

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. 

907 

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 

921 

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) 

948 

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 

954 

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) 

966 

967 changed_event_name = event_kwargs.get('changed_event_name', '') 

968 if changed_event_name: 

969 self.call_method(changed_event_name) 

970 

971 if flow_action(flow_id) == 'focus': 

972 self.call_method('on_flow_widget_focused') 

973 self._last_focus_flow_id = flow_id 

974 

975 self.vpo(f"{prefix}CHANGED path={self.flow_path} args={event_kwargs} last_foc='{self._last_focus_flow_id}'") 

976 

977 return True 

978 

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. 

982 

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) 

987 

988 @staticmethod 

989 def dpi_factor() -> float: 

990 """ dpi scaling factor - override if the used GUI framework supports dpi scaling. """ 

991 return 1.0 

992 

993 def close_popups(self, classes: tuple = ()): 

994 """ close all opened popups (starting with the foremost popup). 

995 

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

1004 

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

1007 

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. 

1015 

1016 :param file: RegisteredFile instance. 

1017 :return: True if theme is matching. 

1018 """ 

1019 return bool(file.properties.get('light', 0)) == light_theme 

1020 

1021 def file_sorter(file) -> float: 

1022 """ sort images files by height delta. 

1023 

1024 :param file: RegisteredFile instance. 

1025 :return: height delta. 

1026 """ 

1027 return abs(file.properties.get('height', -MAX_FONT_SIZE) - height) 

1028 

1029 if self.image_files: 

1030 return self.image_files(image_name, property_matcher=property_matcher, file_sorter=file_sorter) 

1031 return None 

1032 

1033 def find_sound(self, sound_name: str) -> Optional[RegisteredFile]: 

1034 """ find sound by name. 

1035 

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 

1042 

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

1045 

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 

1058 

1059 return child_wid(self.widget_children(self.framework_win)) 

1060 

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. 

1063 

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

1071 

1072 def global_variables(self, **patches) -> Dict[str, Any]: 

1073 """ determine generic/most-needed global variables to evaluate expressions/macros. 

1074 

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 

1083 

1084 self.vpo(f"MainAppBase.global_variables patching {patches} over {glo_vars}") 

1085 

1086 glo_vars.update(**patches) 

1087 

1088 return glo_vars 

1089 

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. 

1092 

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 

1102 

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 '' 

1107 

1108 def key_press_from_framework(self, modifiers: str, key: str) -> bool: 

1109 """ dispatch key press event, coming normalized from the UI framework. 

1110 

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 

1124 

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 

1133 

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 

1143 

1144 app_states[key] = value 

1145 

1146 self.setup_app_states(app_states) 

1147 

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 

1155 

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 

1163 

1164 def load_translations(self, lang_code: str): 

1165 """ load translation texts for the passed language code. 

1166 

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 

1171 

1172 lang_code = load_language_texts(lang_code) 

1173 self.change_app_state('lang_code', lang_code) 

1174 

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 

1178 

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

1183 

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

1187 

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

1191 

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

1195 

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

1199 

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

1203 

1204 self.load_images() 

1205 

1206 self.load_sounds() 

1207 

1208 register_translations_path() 

1209 register_translations_path("{ado}") 

1210 self.load_translations(self.lang_code) 

1211 

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

1216 

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. 

1219 

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 

1228 

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. 

1231 

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. 

1236 

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

1241 

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 

1249 

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

1253 

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

1257 

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`. 

1261 

1262 :param _flow_key: unused flow key. 

1263 :param _event_kwargs: unused popup args. 

1264 :return: always returning True. 

1265 """ 

1266 return True 

1267 

1268 def on_lang_code_change(self, lang_code: str, _event_kwargs: EventKwargsType) -> bool: 

1269 """ language app state change flow change confirmation event handler. 

1270 

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 

1278 

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. 

1281 

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 

1290 

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

1294 

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

1298 

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. 

1301 

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 

1313 

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 

1321 

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 

1327 

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 

1335 

1336 user_data = dict(user_name=user_name) 

1337 self.register_user(**user_data) 

1338 

1339 return True 

1340 

1341 def play_beep(self): 

1342 """ make a short beep sound, should be overwritten by GUI framework. """ 

1343 self.po(chr(7), "MainAppBase.BEEP") 

1344 

1345 def play_sound(self, sound_name: str): 

1346 """ play audio/sound file, should be overwritten by GUI framework. 

1347 

1348 :param sound_name: name of the sound to play. 

1349 """ 

1350 self.po(f"MainAppBase.play_sound {sound_name}") 

1351 

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. 

1354 

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

1358 

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. 

1361 

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. 

1365 

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 

1376 

1377 def popups_opened(self, classes: Tuple = ()) -> List: 

1378 """ determine all popup-like container widgets that are currently opened. 

1379 

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

1390 

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) 

1396 

1397 return popups 

1398 

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. 

1401 

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) 

1407 

1408 self.dpo(f"MainAppBase.retrieve_app_states {app_states}") 

1409 return app_states 

1410 

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

1415 

1416 self.call_method('on_app_run') 

1417 

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 

1424 

1425 def save_app_states(self) -> str: 

1426 """ save app state in config file. 

1427 

1428 :return: empty string if app status could be saved into config files else error message. 

1429 """ 

1430 err_msg = "" 

1431 

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) 

1436 

1437 new_state = self.call_method(f'on_app_state_{key}_save', state) 

1438 if new_state is not None: 

1439 state = new_state 

1440 

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) 

1445 

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 

1450 

1451 self.load_cfg_files() 

1452 

1453 if self.debug_level: 

1454 self.play_sound('error' if err_msg else 'debug_save') 

1455 

1456 return err_msg 

1457 

1458 def setup_app_states(self, app_states: AppStateType): 

1459 """ put app state variables into main app instance to prepare framework app.run_app. 

1460 

1461 :param app_states: dict of app states. 

1462 """ 

1463 self.vpo(f"MainAppBase.setup_app_states {app_states}") 

1464 

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 

1475 

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 

1480 

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) 

1487 

1488 def show_message(self, message: str, title: str = "", is_error: bool = True): 

1489 """ display (error) message popup to the user. 

1490 

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

1498 

1499 popup_kwargs = dict(message=message) 

1500 if title: 

1501 popup_kwargs['title'] = title 

1502 

1503 self.change_flow(id_of_flow('show', 'message'), popup_kwargs=popup_kwargs) 

1504 

1505 def stop_app(self, exit_code: int = 0): 

1506 """ quit this application. 

1507 

1508 :param exit_code: optional exit code. 

1509 """ 

1510 self.dpo(f"MainAppBase.stop_app {exit_code}") 

1511 self._exit_code = exit_code 

1512 

1513 if self.framework_win: 

1514 self.framework_win.close() # close window to save app state data and fire on_app_stop 

1515 

1516 self.call_method('on_app_exit') 

1517 

1518 if self._stop_event_loop: 

1519 self._stop_event_loop() # will exit the self._start_event_loop() method called by self.run_app() 

1520 

1521 def upgraded_config_app_state_version(self) -> int: 

1522 """ determine app state version of an app upgrade. 

1523 

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) 

1531 

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. 

1534 

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) 

1540 

1541 def widget_by_flow_id(self, flow_id: str) -> Optional[Any]: 

1542 """ determine the first (top-most) widget having the passed flow_id. 

1543 

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) 

1549 

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

1552 

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) 

1558 

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

1561 

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

1568 

1569 @staticmethod 

1570 def widget_pos(wid) -> Tuple[float, float]: 

1571 """ return the absolute window x and y position of the passed widget. 

1572 

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 

1577 

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. 

1580 

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 

1586 

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 

1593 

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 

1599 

1600 return min_x, min_y, max_x - min_x, max_y - min_y 

1601 

1602 @staticmethod 

1603 def widget_size(wid) -> Tuple[float, float]: 

1604 """ return the size (width and height) in pixels of the passed widget. 

1605 

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 

1610 

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

1614 

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

1621 

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. 

1624 

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 

1630 

1631 self.vpo(f"MainAppBase.win_pos_size_change {win_pos_size}: landscape={app.landscape}") 

1632 

1633 self.change_app_state('win_rectangle', win_pos_size) 

1634 self.call_method('on_win_pos_size') 

1635 

1636 

1637register_package_images() # register base image files of this portion 

1638register_package_sounds() # register base sound files of this portion 

1639 

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