# Released under the MIT License. See LICENSE for details.
#
"""Provide top level UI related functionality."""
from __future__ import annotations
import os
import weakref
import logging
from dataclasses import dataclass
from typing import TYPE_CHECKING, override
import babase
import _bauiv1
if TYPE_CHECKING:
from typing import Any, Type, Literal, Callable
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
Essentially wraps a ContainerWidget with some higher level
functionality.
"""
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)
class MainWindow(Window):
"""A special window that can be used as a main window."""
def __init__(
self,
root_widget: bauiv1.Widget,
transition: str | None,
origin_widget: bauiv1.Widget | None,
cleanupcheck: bool = True,
):
"""Create a MainWindow given a root widget and transition info.
Automatically handles in and out transitions on the provided widget,
so there is no need to set transitions when creating it.
"""
# A back-state supplied by the ui system.
self.main_window_back_state: MainWindowState | None = None
self.main_window_is_top_level: bool = False
# Windows can be flagged as auxiliary when not related to the
# main UI task at hand. UI code may choose to handle auxiliary
# windows in special ways, such as by implicitly replacing
# existing auxiliary windows with new ones instead of keeping
# old ones as back targets.
self.main_window_is_auxiliary: bool = False
self._main_window_transition = transition
self._main_window_origin_widget = origin_widget
super().__init__(root_widget, cleanupcheck)
scale_origin: tuple[float, float] | None
if origin_widget is not None:
self._main_window_transition_out = 'out_scale'
scale_origin = origin_widget.get_screen_space_center()
transition = 'in_scale'
else:
self._main_window_transition_out = 'out_right'
scale_origin = None
_bauiv1.containerwidget(
edit=root_widget,
transition=transition,
scale_origin_stack_offset=scale_origin,
)
[docs]
def main_window_close(self, transition: str | None = None) -> None:
"""Get window transitioning out if still alive."""
# no-op if our underlying widget is dead or on its way out.
if not self._root_widget or self._root_widget.transitioning_out:
return
# Transition ourself out.
try:
self.on_main_window_close()
except Exception:
logging.exception('Error in on_main_window_close() for %s.', self)
# Note: normally transition of None means instant, but we use
# that to mean 'do the default' so we support a special
# 'instant' string..
if transition == 'instant':
self._root_widget.delete()
else:
_bauiv1.containerwidget(
edit=self._root_widget,
transition=(
self._main_window_transition_out
if transition is None
else transition
),
)
[docs]
def main_window_has_control(self) -> bool:
"""Is this MainWindow allowed to change the global main window?
It is a good idea to make sure this is True before calling
main_window_replace(). This prevents fluke UI breakage such as
multiple simultaneous events causing a MainWindow to spawn
multiple replacements for itself.
"""
# We are allowed to change main windows if we are the current one
# AND our underlying widget is still alive and not transitioning out.
return (
babase.app.ui_v1.get_main_window() is self
and bool(self._root_widget)
and not self._root_widget.transitioning_out
)
[docs]
def main_window_back(self) -> None:
"""Move back in the main window stack.
Is a no-op if the main window does not have control;
no need to check main_window_has_control() first.
"""
# Users should always check main_window_has_control() before
# calling us. Error if it seems they did not.
if not self.main_window_has_control():
return
if not self.main_window_is_top_level:
# Get the 'back' window coming in.
babase.app.ui_v1.auto_set_back_window(self)
self.main_window_close()
[docs]
def main_window_replace(
self,
new_window: MainWindow,
back_state: MainWindowState | None = None,
is_auxiliary: bool = False,
) -> None:
"""Replace ourself with a new MainWindow."""
# Users should always check main_window_has_control() *before*
# creating new MainWindows and passing them in here. Kill the
# passed window and Error if it seems they did not.
if not self.main_window_has_control():
new_window.get_root_widget().delete()
raise RuntimeError(
f'main_window_replace() called on a not-in-control window'
f' ({self}); always check main_window_has_control() before'
f' calling main_window_replace().'
)
# Just shove the old out the left to give the feel that we're
# adding to the nav stack.
transition = 'out_left'
# Transition ourself out.
try:
self.on_main_window_close()
except Exception:
logging.exception('Error in on_main_window_close() for %s.', self)
_bauiv1.containerwidget(edit=self._root_widget, transition=transition)
babase.app.ui_v1.set_main_window(
new_window,
from_window=self,
back_state=back_state,
is_auxiliary=is_auxiliary,
suppress_warning=True,
)
[docs]
def on_main_window_close(self) -> None:
"""Called before transitioning out a main window.
A good opportunity to save window state/etc.
"""
[docs]
def get_main_window_state(self) -> MainWindowState:
"""Return a WindowState to recreate this window, if supported."""
raise NotImplementedError()
class MainWindowState:
"""Persistent state for a specific main-window and its ancestors.
This allows MainWindows to be automatically recreated for back-button
purposes, when switching app-modes, etc.
"""
def __init__(self) -> None:
# The window that back/cancel navigation should take us to.
self.parent: MainWindowState | None = None
self.is_top_level: bool | None = None
self.is_auxiliary: bool | None = None
self.window_type: type[MainWindow] | None = None
self.selection: str | None = None
[docs]
def create_window(
self,
transition: Literal['in_right', 'in_left', 'in_scale'] | None = None,
origin_widget: bauiv1.Widget | None = None,
) -> MainWindow:
"""Create a window based on this state.
WindowState child classes should override this to recreate their
particular type of window.
"""
raise NotImplementedError()
class BasicMainWindowState(MainWindowState):
"""A basic MainWindowState holding a lambda to recreate a MainWindow."""
def __init__(
self,
create_call: Callable[
[
Literal['in_right', 'in_left', 'in_scale'] | None,
bauiv1.Widget | None,
],
bauiv1.MainWindow,
],
) -> None:
super().__init__()
self.create_call = create_call
[docs]
@override
def create_window(
self,
transition: Literal['in_right', 'in_left', 'in_scale'] | None = None,
origin_widget: bauiv1.Widget | None = None,
) -> bauiv1.MainWindow:
return self.create_call(transition, origin_widget)
@dataclass
class UICleanupCheck:
"""Holds info about a uicleanupcheck target."""
obj: weakref.ref
widget: bauiv1.Widget
widget_death_time: float | None
def uicleanupcheck(obj: Any, widget: bauiv1.Widget) -> None:
"""Checks 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. 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)
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)