Source code for bauiv1._appsubsystem

# Released under the MIT License. See LICENSE for details.
#
"""User interface related functionality."""

from __future__ import annotations

import logging
import inspect
import weakref
import warnings
from enum import Enum
from typing import TYPE_CHECKING, override

from efro.util import empty_weakref
import babase

import _bauiv1

if TYPE_CHECKING:
    from typing import Any, Callable

    from bauiv1._uitypes import (
        UICleanupCheck,
        Window,
        MainWindow,
        MainWindowState,
    )
    import bauiv1


class UIV1AppSubsystem(babase.AppSubsystem):
    """Consolidated UI functionality for the app.

    Category: **App Classes**

    To use this class, access the single instance of it at 'ba.app.ui'.
    """

[docs] class RootUIElement(Enum): """Stuff provided by the root ui.""" MENU_BUTTON = 'menu_button' SQUAD_BUTTON = 'squad_button' ACCOUNT_BUTTON = 'account_button' SETTINGS_BUTTON = 'settings_button' INBOX_BUTTON = 'inbox_button' STORE_BUTTON = 'store_button' INVENTORY_BUTTON = 'inventory_button' ACHIEVEMENTS_BUTTON = 'achievements_button' GET_TOKENS_BUTTON = 'get_tokens_button' TICKETS_METER = 'tickets_meter' TOKENS_METER = 'tokens_meter' TROPHY_METER = 'trophy_meter' LEVEL_METER = 'level_meter' CHEST_SLOT_0 = 'chest_slot_0' CHEST_SLOT_1 = 'chest_slot_1' CHEST_SLOT_2 = 'chest_slot_2' CHEST_SLOT_3 = 'chest_slot_3'
def __init__(self) -> None: from bauiv1._uitypes import MainWindow super().__init__() # We hold only a weak ref to the current main Window; we want it # to be able to disappear on its own. That being said, we do # expect MainWindows to keep themselves alive until replaced by # another MainWindow and we complain if they don't. self._main_window = empty_weakref(MainWindow) self._main_window_widget: bauiv1.Widget | None = None self.quit_window: bauiv1.Widget | None = None # For storing arbitrary class-level state data for Windows or # other UI related classes. self.window_states: dict[type, Any] = {} self._uiscale: babase.UIScale self._update_ui_scale() self.cleanupchecks: list[UICleanupCheck] = [] self.upkeeptimer: babase.AppTimer | None = None self.title_color = (0.72, 0.7, 0.75) self.heading_color = (0.72, 0.7, 0.75) self.infotextcolor = (0.7, 0.9, 0.7) # Elements in our root UI will call anything here when # activated. self.root_ui_calls: dict[ UIV1AppSubsystem.RootUIElement, Callable[[], None] ] = {} def _update_ui_scale(self) -> None: uiscalestr = babase.get_ui_scale() if uiscalestr == 'large': self._uiscale = babase.UIScale.LARGE elif uiscalestr == 'medium': self._uiscale = babase.UIScale.MEDIUM elif uiscalestr == 'small': self._uiscale = babase.UIScale.SMALL else: logging.error("Invalid UIScale '%s'.", uiscalestr) self._uiscale = babase.UIScale.MEDIUM @property def available(self) -> bool: """Can uiv1 currently be used? Code that may run in headless mode, before the UI has been spun up, while other ui systems are active, etc. can check this to avoid likely erroring. """ return _bauiv1.is_available()
[docs] @override def reset(self) -> None: from bauiv1._uitypes import MainWindow self.root_ui_calls.clear() self._main_window = empty_weakref(MainWindow) self._main_window_widget = None
@property def uiscale(self) -> babase.UIScale: """Current ui scale for the app.""" return self._uiscale
[docs] @override def on_app_loading(self) -> None: from bauiv1._uitypes import ui_upkeep # IMPORTANT: If tweaking UI stuff, make sure it behaves for # small, medium, and large UI modes. (doesn't run off screen, # etc). The overrides below can be used to test with different # sizes. Generally small is used on phones, medium is used on # tablets/tvs, and large is on desktop computers or perhaps # large tablets. When possible, run in windowed mode and resize # the window to assure this holds true at all aspect ratios. # UPDATE: A better way to test this is now by setting the # environment variable BA_UI_SCALE to "small", "medium", or # "large". This will affect system UIs not covered by the values # below such as screen-messages. The below values remain # functional, however, for cases such as Android where # environment variables can't be set easily. if bool(False): # force-test ui scale self._uiscale = babase.UIScale.SMALL with babase.ContextRef.empty(): babase.pushcall( lambda: babase.screenmessage( f'FORCING UISCALE {self._uiscale.name} FOR TESTING', color=(1, 0, 1), log=True, ) ) # Kick off our periodic UI upkeep. # FIXME: Can probably kill this if we do immediate UI death # checks. self.upkeeptimer = babase.AppTimer(2.6543, ui_upkeep, repeat=True)
[docs] def auto_set_back_window(self, from_window: MainWindow) -> None: """Sets the main menu window automatically from a parent WindowState.""" main_window = self._main_window() # This should never get called for top-level main-windows. assert ( main_window is None or main_window.main_window_is_top_level is False ) back_state = ( None if main_window is None else main_window.main_window_back_state ) if back_state is None: raise RuntimeError( f'Main window {main_window} provides no back-state;' f' cannot use auto-back.' ) # Valid states should have values here. assert back_state.is_top_level is not None assert back_state.is_auxiliary is not None assert back_state.window_type is not None backwin = back_state.create_window(transition='in_left') self.set_main_window( backwin, from_window=from_window, is_back=True, back_state=back_state, suppress_warning=True, )
[docs] def get_main_window(self) -> bauiv1.MainWindow | None: """Return main window, if any.""" return self._main_window()
[docs] def set_main_window( self, window: bauiv1.MainWindow, *, from_window: bauiv1.MainWindow | None | bool = True, is_back: bool = False, is_top_level: bool = False, is_auxiliary: bool = False, back_state: MainWindowState | None = None, suppress_warning: bool = False, ) -> None: """Set the current 'main' window, replacing any existing. Generally this should not be called directly; The high level MainWindow methods main_window_replace() and main_window_back() should be used when possible for navigation. """ # pylint: disable=too-many-locals # pylint: disable=too-many-branches # pylint: disable=too-many-statements from bauiv1._uitypes import MainWindow # Encourage migration to the new higher level nav calls. if not suppress_warning: warnings.warn( 'set_main_window() should usually not be called directly;' ' use the main_window_replace() or main_window_back()' ' methods on MainWindow objects for navigation instead.' ' If you truly need to use set_main_window(),' ' pass suppress_warning=True to silence this warning.', DeprecationWarning, stacklevel=2, ) # We used to accept Widgets but now want MainWindows. if not isinstance(window, MainWindow): raise RuntimeError( f'set_main_window() now takes a MainWindow as its "window" arg.' f' You passed a {type(window)}.', ) window_weakref = weakref.ref(window) window_widget = window.get_root_widget() if not isinstance(from_window, MainWindow): if from_window is not None and not isinstance(from_window, bool): raise RuntimeError( f'set_main_window() now takes a MainWindow or bool or None' f'as its "from_window" arg.' f' You passed a {type(from_window)}.', ) existing = self._main_window() # If they passed a back-state, make sure it is fully filled out. if back_state is not None: if ( back_state.is_top_level is None or back_state.is_auxiliary is None or back_state.window_type is None ): raise RuntimeError( 'Provided back_state is incomplete.' ' Make sure to only pass fully-filled-out MainWindowStates.' ) # If a top-level main-window is being set, complain if there already # is a main-window. if is_top_level: if existing: logging.warning( 'set_main_window() called with top-level window %s' ' but found existing main-window %s.', window, existing, ) else: # In other cases, sanity-check that the window asking for # this switch is the one we're switching away from. try: if isinstance(from_window, bool): # For default val True we warn that the arg wasn't # passed. False can be explicitly passed to disable # this check. if from_window is True: caller_frame = inspect.stack()[1] caller_filename = caller_frame.filename caller_line_number = caller_frame.lineno logging.warning( 'set_main_window() should be passed a' " 'from_window' value to help ensure proper" ' UI behavior (%s line %i).', caller_filename, caller_line_number, ) else: # For everything else, warn if what they passed # wasn't the previous main menu widget. if from_window is not existing: caller_frame = inspect.stack()[1] caller_filename = caller_frame.filename caller_line_number = caller_frame.lineno logging.warning( "set_main_window() was passed 'from_window' %s" ' but existing main-menu-window is %s.' ' (%s line %i).', from_window, existing, caller_filename, caller_line_number, ) except Exception: # Prevent any bugs in these checks from causing problems. logging.exception('Error checking from_window') if is_back: # These values should only be passed for forward navigation. assert not is_top_level assert not is_auxiliary # Make sure back state is complete. assert back_state is not None assert back_state.is_top_level is not None assert back_state.is_auxiliary is not None assert back_state.window_type is type(window) window.main_window_back_state = back_state.parent window.main_window_is_top_level = back_state.is_top_level window.main_window_is_auxiliary = back_state.is_auxiliary else: # Store if the window is top-level so we won't complain later if # we go back from it and there's nowhere to go to. window.main_window_is_top_level = is_top_level window.main_window_is_auxiliary = is_auxiliary # When navigating forward, generate a back-window-state from # the outgoing window. if is_top_level: # Top level windows don't have or expect anywhere to # go back to. window.main_window_back_state = None elif back_state is not None: window.main_window_back_state = back_state else: oldwin = self._main_window() if oldwin is None: # We currenty only hold weak refs to windows so that # they are free to die on their own, but we expect # the main menu window to keep itself alive as long # as its the main one. Holler if that seems to not # be happening. logging.warning( 'set_main_window: No old MainWindow found' ' and is_top_level is False;' ' this should not happen.' ) window.main_window_back_state = None else: window.main_window_back_state = self.save_main_window_state( oldwin ) self._main_window = window_weakref self._main_window_widget = window_widget
[docs] def has_main_window(self) -> bool: """Return whether a main menu window is present.""" return bool(self._main_window_widget)
[docs] def clear_main_window(self, transition: str | None = None) -> None: """Clear any existing main window.""" from bauiv1._uitypes import MainWindow main_window = self._main_window() if main_window: main_window.main_window_close(transition=transition) else: # Fallback; if we have a widget but no window, nuke the widget. if self._main_window_widget: logging.error( 'Have _main_window_widget but no main_window' ' on clear_main_window; unexpected.' ) self._main_window_widget.delete() self._main_window = empty_weakref(MainWindow) self._main_window_widget = None
[docs] def save_main_window_state(self, window: MainWindow) -> MainWindowState: """Fully initialize a window-state from a window. Use this to get a complete state for later restoration purposes. Calling the window's get_main_window_state() directly is insufficient. """ winstate = window.get_main_window_state() # Store some common window stuff on its state. winstate.parent = window.main_window_back_state winstate.is_top_level = window.main_window_is_top_level winstate.is_auxiliary = window.main_window_is_auxiliary winstate.window_type = type(window) return winstate
[docs] def restore_main_window_state(self, state: MainWindowState) -> None: """Restore UI to a saved state.""" existing = self.get_main_window() if existing is not None: raise RuntimeError('There is already a MainWindow.') # Valid states should have a value here. assert state.is_top_level is not None assert state.is_auxiliary is not None assert state.window_type is not None win = state.create_window(transition=None) self.set_main_window( win, from_window=False, # disable check is_top_level=state.is_top_level, is_auxiliary=state.is_auxiliary, back_state=state.parent, suppress_warning=True, )
[docs] @override def on_screen_change(self) -> None: # Update our stored UIScale. self._update_ui_scale() # Update native bits (allow root widget to rebuild itself/etc.) _bauiv1.on_screen_change() # Lastly, if we have a main window, recreate it to pick up the # new UIScale/etc. mainwindow = self.get_main_window() if mainwindow is not None: winstate = self.save_main_window_state(mainwindow) self.clear_main_window(transition='instant') self.restore_main_window_state(winstate)