Source code for bauiv1._uitypes

# Released under the MIT License. See LICENSE for details.
#
"""Provide top level UI related functionality."""

from __future__ import annotations

import os
import weakref
from dataclasses import dataclass
from typing import TYPE_CHECKING, override

import babase

import _bauiv1

if TYPE_CHECKING:
    from typing import Any, Type

    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'


class Window:
    """A basic window.

    Category: User Interface Classes
    """

    def __init__(self, root_widget: bauiv1.Widget, cleanupcheck: bool = True):
        self._root_widget = root_widget

        # Complain if we outlive our root widget.
        if cleanupcheck:
            uicleanupcheck(self, root_widget)

[docs] def get_root_widget(self) -> bauiv1.Widget: """Return the root widget.""" return self._root_widget
@dataclass class UICleanupCheck: """Holds info about a uicleanupcheck target.""" obj: weakref.ref widget: bauiv1.Widget widget_death_time: float | None class UILocation: """Defines a specific 'place' in the UI the user can navigate to. Category: User Interface Classes """ def __init__(self) -> None: pass def save_state(self) -> None: """Serialize this instance's state to a dict.""" def restore_state(self) -> None: """Restore this instance's state from a dict.""" def push_location(self, location: str) -> None: """Push a new location to the stack and transition to it.""" class UILocationWindow(UILocation): """A UILocation consisting of a single root window widget. Category: User Interface Classes """ def __init__(self) -> None: super().__init__() self._root_widget: bauiv1.Widget | None = None def get_root_widget(self) -> bauiv1.Widget: """Return the root widget for this window.""" assert self._root_widget is not None return self._root_widget class UIEntry: """State for a UILocation on the stack.""" def __init__(self, name: str, controller: UIController): self._name = name self._state = None self._args = None self._instance: UILocation | None = None self._controller = weakref.ref(controller) def create(self) -> None: """Create an instance of our UI.""" cls = self._get_class() self._instance = cls() def destroy(self) -> None: """Transition out our UI if it exists.""" if self._instance is None: return print('WOULD TRANSITION OUT', self._name) def _get_class(self) -> Type[UILocation]: """Returns the UI class our name points to.""" # pylint: disable=cyclic-import # TEMP HARD CODED - WILL REPLACE THIS WITH BA_META LOOKUPS. if self._name == 'mainmenu': # Shut pylint up. if bool(False): return UILocation raise RuntimeError('FIXME UNIMPLEMENTED') # from bauiv1lib import mainmenu # return cast(Type[UILocation], mainmenu.MainMenuWindow) raise ValueError('unknown ui class ' + str(self._name)) class UIController: """Wrangles bauiv1.UILocations. Category: User Interface Classes """ def __init__(self) -> None: # FIXME: document why we have separate stacks for game and menu... self._main_stack_game: list[UIEntry] = [] self._main_stack_menu: list[UIEntry] = [] # This points at either the game or menu stack. self._main_stack: list[UIEntry] | None = None # There's only one of these since we don't need to preserve its state # between sessions. self._dialog_stack: list[UIEntry] = [] def show_main_menu(self, in_game: bool = True) -> None: """Show the main menu, clearing other UIs from location stacks.""" self._main_stack = [] self._dialog_stack = [] self._main_stack = ( self._main_stack_game if in_game else self._main_stack_menu ) self._main_stack.append(UIEntry('mainmenu', self)) self._update_ui() def _update_ui(self) -> None: """Instantiate the topmost ui in our stacks.""" # First tell any existing UIs to get outta here. for stack in (self._dialog_stack, self._main_stack): assert stack is not None for entry in stack: entry.destroy() # Now create the topmost one if there is one. entrynew = ( self._dialog_stack[-1] if self._dialog_stack else self._main_stack[-1] if self._main_stack else None ) if entrynew is not None: entrynew.create() def uicleanupcheck(obj: Any, widget: bauiv1.Widget) -> None: """Add a check to ensure a widget-owning object gets cleaned up properly. Category: User Interface Functions 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) assert babase.app.classic is not None babase.app.ui_v1.cleanupchecks.append( UICleanupCheck( obj=weakref.ref(obj), widget=widget, widget_death_time=None ) ) def ui_upkeep() -> None: """Run UI cleanup checks, etc. should be called periodically.""" assert babase.app.classic is not None ui = babase.app.ui_v1 remainingchecks = [] now = babase.apptime() for check in ui.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. See efro.debug module' ' for tools to help debug this.', ) else: remainingchecks.append(check) ui.cleanupchecks = remainingchecks class TextWidgetStringEditAdapter(babase.StringEditAdapter): """A StringEditAdapter subclass for editing our text widgets.""" def __init__(self, text_widget: bauiv1.Widget) -> None: self.widget = text_widget # Ugly hacks to pull values from widgets. Really need to clean # up that api. description: Any = _bauiv1.textwidget(query_description=text_widget) assert isinstance(description, str) initial_text: Any = _bauiv1.textwidget(query=text_widget) assert isinstance(initial_text, str) max_length: Any = _bauiv1.textwidget(query_max_chars=text_widget) assert isinstance(max_length, int) screen_space_center = text_widget.get_screen_space_center() super().__init__( description, initial_text, max_length, screen_space_center ) @override def _do_apply(self, new_text: str) -> None: if self.widget: _bauiv1.textwidget( edit=self.widget, text=new_text, adapter_finished=True ) @override def _do_cancel(self) -> None: if self.widget: _bauiv1.textwidget(edit=self.widget, adapter_finished=True)