Source code for bauiv1._appsubsystem

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

from __future__ import annotations

import os
import time
import logging
import inspect
import weakref
import warnings
from enum import Enum
from dataclasses import dataclass
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._window import Window, MainWindow, MainWindowState
    import bauiv1

# Set environment variable BA_DEBUG_UI_CLEANUP_CHECKS to 1
# to print detailed info about what is getting cleaned up when.
DEBUG_UI_CLEANUP_CHECKS = os.environ.get('BA_DEBUG_UI_CLEANUP_CHECKS') == '1'


[docs] class UIV1AppSubsystem(babase.AppSubsystem): """Consolidated UI functionality for the app. 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._window 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.main_window_shared_states: dict = {} 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) self.window_auto_recreate_suppress_count = 0 self._uiscale: babase.UIScale self._update_ui_scale() self._upkeeptimer: babase.AppTimer | None = None self._cleanupchecks: list[_UICleanupCheck] = [] self._last_win_recreate_screen_size: tuple[float, float] | None = None self._last_win_recreate_uiscale: bauiv1.UIScale | None = None self._last_win_recreate_time: float | None = None self._win_recreate_timer: babase.AppTimer | None = None self._base_ids: dict[str, int] = {} # Elements in our root UI will call anything here when # activated. self.root_ui_calls: dict[ UIV1AppSubsystem.RootUIElement, Callable[[], None] ] = {}
[docs] def new_id_prefix(self, name: str) -> str: """Generate a unique id given a base name. Useful to ensure widgets have globally unique ids even if a particular window type is instantiated multiple times. """ val = self._base_ids[name] = self._base_ids.get(name, 0) + 1 return f'{name}{val}'
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._window 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: # Kick off our periodic UI upkeep. self._upkeeptimer = babase.AppTimer(2.6543, self._upkeep, repeat=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, *, back_state: MainWindowState | None, from_window: bauiv1.MainWindow | None | bool = True, is_back: bool = False, is_top_level: bool = False, is_auxiliary: bool = False, suppress_warning: bool = False, restore_shared_state: bool = True, ) -> None: """Set the current 'main' window. Generally this should not be called directly; The high level MainWindow methods main_window_replace() and main_window_back() should be used whenever possible to implement navigation. The caller is responsible for cleaning up any previous main window. """ # pylint: disable=too-many-branches # pylint: disable=too-many-statements # pylint: disable=too-many-locals from bauiv1._window import MainWindow # If we haven't grabbed initial uiscale or screen size for # recreate comparision purposes, this is a good time to do so. if self._last_win_recreate_screen_size is None: self._last_win_recreate_screen_size = ( babase.get_virtual_screen_size() ) if self._last_win_recreate_uiscale is None: self._last_win_recreate_uiscale = babase.app.ui_v1.uiscale # 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): # if callable(window): # window = window() # else: 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. assert back_state is None window.main_window_back_state = back_state self._main_window = window_weakref self._main_window_widget = window_widget # Now that we're all set up, restore any state. if restore_shared_state: window.main_window_restore_shared_state()
[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._window 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 save_current_main_window_state(self) -> MainWindowState | None: """Save state for the current window, if any.""" # Calc a back-state from the current window. current_main_win = self._main_window() if current_main_win 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. babase.uilog.warning( 'save_current_main_window_state: No old MainWindow found;' ' this should not happen.' ) return None return self.save_main_window_state(current_main_win)
[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] def should_suppress_window_recreates(self) -> bool: """Should we avoid auto-recreating windows at the current time?""" # This is slightly hack-ish and ideally we can get to the point # where we never need this and can remove it. # Currently string-edits grab a weak-ref to the exact text # widget they're targeting. So we need to suppress recreates # while edits are in progress. Ideally we should change that to # use ids or something that would survive a recreate. if babase.app.stringedit.active_adapter() is not None: return True # Suppress if anything else is requesting suppression (such as # generic Windows that don't handle being recreated). return babase.app.ui_v1.window_auto_recreate_suppress_count > 0
[docs] @override def on_ui_scale_change(self) -> None: # Update our stored UIScale. self._update_ui_scale() # Update native bits (allow root widget to rebuild itself/etc.) _bauiv1.on_ui_scale_change() self._schedule_main_win_recreate()
[docs] @override def on_screen_size_change(self) -> None: self._schedule_main_win_recreate()
[docs] def add_ui_cleanup_check(self, obj: Any, widget: bauiv1.Widget) -> None: """Checks to ensure a widget-owning object gets cleaned up properly. This adds a check which will print an error message if the provided object still exists ~5 seconds after the provided bauiv1.Widget dies. This is a good sanity check for any sort of object that wraps or controls a bauiv1.Widget. For instance, a 'Window' class instance has no reason to still exist once its root container bauiv1.Widget has fully transitioned out and been destroyed. Circular references or careless strong referencing can lead to such objects never getting destroyed, however, and this helps detect such cases to avoid memory leaks. """ if DEBUG_UI_CLEANUP_CHECKS: print(f'adding uicleanup to {obj}') if not isinstance(widget, _bauiv1.Widget): raise TypeError('widget arg is not a bauiv1.Widget') if bool(False): def foobar() -> None: """Just testing.""" if DEBUG_UI_CLEANUP_CHECKS: print('uicleanupcheck widget dying...') widget.add_delete_callback(foobar) self._cleanupchecks.append( _UICleanupCheck( obj=weakref.ref(obj), widget=widget, widget_death_time=None ) )
[docs] def auxiliary_window_activate( self, win_type: type[bauiv1.MainWindow], win_create_call: Callable[[], bauiv1.MainWindow], ) -> None: """Navigate to or away from an Auxiliary window. Auxiliary windows can be thought of as 'side quests' in the window hierarchy; places such as settings windows or league ranking windows that the user might want to visit without losing their place in the regular hierarchy. Calling this method with a MainWindow of the provided type already in the stack will back out past it (effectively toggling the 'side quest' back off). Calling this method with a *different* auxiliary window in the stack will back out past that and replace it with this (effectively ending the old side-quest and starting a new one). """ # pylint: disable=unidiomatic-typecheck current_main_window = self.get_main_window() # Scan our ancestors for auxiliary states matching our type as # well as auxiliary states in general. aux_matching_state: bauiv1.MainWindowState | None = None aux_state: bauiv1.MainWindowState | None = None if current_main_window is None: raise RuntimeError( 'Not currently handling no-top-level-window case.' ) state = current_main_window.main_window_back_state while state is not None: assert state.window_type is not None if state.is_auxiliary: if state.window_type is win_type: aux_matching_state = state else: aux_state = state state = state.parent # If there's an ancestor auxiliary window-state matching our # type, back out past it (example: poking settings, navigating # down a level or two, and then poking settings again should # back out of settings). if aux_matching_state is not None: current_main_window.main_window_back_state = ( aux_matching_state.parent ) current_main_window.main_window_back() return # If there's an ancestory auxiliary state *not* matching our # type, crop the state and swap in our new auxiliary UI # (example: poking settings, then poking account, then poking # back should end up where things were before the settings # poke). if aux_state is not None: # Blow away the window stack and build a fresh one. self.clear_main_window() self.set_main_window( win_create_call(), from_window=False, # Disable from-check. back_state=aux_state.parent, suppress_warning=True, is_auxiliary=True, ) return # Ok, no auxiliary states found. Now if current window is # auxiliary and the type matches, simply do a back. if ( current_main_window.main_window_is_auxiliary and type(current_main_window) is win_type ): current_main_window.main_window_back() return # If current window is auxiliary but type doesn't match, # swap it out for our new auxiliary UI. if current_main_window.main_window_is_auxiliary: self.clear_main_window() self.set_main_window( win_create_call(), from_window=False, # Disable from-check. back_state=current_main_window.main_window_back_state, suppress_warning=True, is_auxiliary=True, ) return # Ok, no existing auxiliary stuff was found period. Just # navigate forward to this UI. current_main_window.main_window_replace( win_create_call, is_auxiliary=True )
def _schedule_main_win_recreate(self) -> None: # If there is a timer set already, do nothing. if self._win_recreate_timer is not None: return # Recreating a MainWindow is a kinda heavy thing and it doesn't # seem like we should be doing it at 120hz during a live window # resize, so let's limit the max rate we do it. We also use the # same mechanism to defer window recreates while anything is # suppressing them. now = time.monotonic() # Up to 4 refreshes per second seems reasonable. interval = 0.25 # Ok; there's no timer. Schedule one. till_update = ( interval if self.should_suppress_window_recreates() else ( 0.0 if self._last_win_recreate_time is None else max(0.0, self._last_win_recreate_time + interval - now) ) ) self._win_recreate_timer = babase.AppTimer( till_update, self._do_main_win_recreate ) def _do_main_win_recreate(self) -> None: self._last_win_recreate_time = time.monotonic() self._win_recreate_timer = None # If win-recreates are currently suppressed, just kick off # another timer. We'll do our actual thing once suppression # finally ends. if self.should_suppress_window_recreates(): self._schedule_main_win_recreate() return mainwindow = self.get_main_window() # Can't recreate what doesn't exist. if mainwindow is None: return virtual_screen_size = babase.get_virtual_screen_size() uiscale = babase.app.ui_v1.uiscale # These should always get actual values when a main-window is # assigned so should never still be None here. assert self._last_win_recreate_uiscale is not None assert self._last_win_recreate_screen_size is not None # If uiscale hasn't changed and our screen-size hasn't either # (or it has but we don't care) then we're done. if uiscale is self._last_win_recreate_uiscale and ( virtual_screen_size == self._last_win_recreate_screen_size or not mainwindow.refreshes_on_screen_size_changes ): return # Do the recreate. winstate = self.save_main_window_state(mainwindow) self.clear_main_window(transition='instant') self.restore_main_window_state(winstate) # Store the size we created this for to avoid redundant # future recreates. self._last_win_recreate_uiscale = uiscale self._last_win_recreate_screen_size = virtual_screen_size def _upkeep(self) -> None: """Run UI cleanup checks, etc. should be called periodically.""" assert babase.app.classic is not None remainingchecks = [] now = babase.apptime() for check in self._cleanupchecks: obj = check.obj() # If the object has died, ignore and don't re-add. if obj is None: if DEBUG_UI_CLEANUP_CHECKS: print('uicleanupcheck object is dead; hooray!') continue # If the widget hadn't died yet, note if it has. if check.widget_death_time is None: remainingchecks.append(check) if not check.widget: check.widget_death_time = now else: # Widget was already dead; complain if its been too long. if now - check.widget_death_time > 5.0: print( 'WARNING:', obj, 'is still alive 5 second after its Widget died;' ' you might have a memory leak. Look for circular' ' references or outside things referencing your Window' ' class instance. See efro.debug module' ' for tools that can help debug this sort of thing.', ) else: remainingchecks.append(check) self._cleanupchecks = remainingchecks
@dataclass class _UICleanupCheck: """Holds info about a uicleanupcheck target.""" obj: weakref.ref widget: bauiv1.Widget widget_death_time: float | None # Docs-generation hack; import some stuff that we likely only forward-declared # in our actual source code so that docs tools can find it. from typing import (Coroutine, Any, Literal, Callable, Generator, Awaitable, Sequence, Self) import asyncio from concurrent.futures import Future from pathlib import Path from enum import Enum