Source code for bauiv1._window

# Released under the MIT License. See LICENSE for details.
#
"""Window related UI bits."""

from __future__ import annotations

import logging
import warnings
from typing import TYPE_CHECKING, override

import babase

import _bauiv1

if TYPE_CHECKING:
    from typing import Any, Type, Literal, Callable

    import bauiv1


[docs] class Window: """A basic window. Essentially wraps a ContainerWidget with some higher level functionality. """ def __init__( self, root_widget: bauiv1.Widget, cleanupcheck: bool = True, prevent_main_window_auto_recreate: bool = True, ): self._root_widget = root_widget # By default, the presence of any generic windows prevents the # app from running its fancy main-window-auto-recreate mechanism # on screen-resizes and whatnot. This avoids things like # temporary popup windows getting stuck under auto-re-created # main-windows. self._window_main_window_auto_recreate_suppress = ( MainWindowAutoRecreateSuppress() if prevent_main_window_auto_recreate else None ) # Generally we complain if we outlive our root widget. if cleanupcheck: babase.app.ui_v1.add_ui_cleanup_check(self, root_widget)
[docs] def get_root_widget(self) -> bauiv1.Widget: """Return the root widget.""" return self._root_widget
[docs] class MainWindow(Window): """A special type of window that can be set as 'main'. The UI system has at most one main window at any given time. MainWindows support high level functionality such as saving and restoring states, allowing them to be automatically recreated when navigating back from other locations or when something like ui-scale changes. """ def __init__( self, root_widget: bauiv1.Widget, *, transition: str | None, origin_widget: bauiv1.Widget | None, cleanupcheck: bool = True, refresh_on_screen_size_changes: bool = False, ): """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. """ self.main_window_id_prefix = babase.app.ui_v1.new_id_prefix( type(self).__name__.lower() ) # 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 that size tailor themselves to exact screen dimensions # can pass True for this. Generally this only applies to small # ui scale and at larger scales windows simply fit in the # virtual safe area. self.refreshes_on_screen_size_changes = refresh_on_screen_size_changes # 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=cleanupcheck, prevent_main_window_auto_recreate=False, ) 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_save_shared_state(self) -> None: """Save shared state (such as widget selection). This is automatically called just before main-windows are destroyed, but the user may opt to call it at other times such as before refreshing a UI (so that selection can be restored after the refresh, etc.) State contained here is intended to operate on already-constructed UI; state that influences which UI is contructed should go through other mechanisms. """ # pylint: disable=assignment-from-none key = self.get_main_window_shared_state_id() assert isinstance(key, str | None) keyfin = type(self) if key is None else key shared_state: dict = {} # Save selection if desired. if self._get_main_window_should_preserve_selection(): sel = _bauiv1.get_selected_widget() if sel is None: selfin = None else: if sel.allow_preserve_selection: selfin = sel.id if selfin is not None: pre = f'{self.main_window_id_prefix}|' if selfin.startswith(pre): selfin = f'$(WIN)|{selfin.removeprefix(pre)}' babase.uilog.debug( "Saving ui selection from '%s': '%s'.", self.main_window_id_prefix, selfin, ) else: # if not sel.allow_preserve_selection: babase.uilog.warning( 'main_window_should_preserve_selection()' ' returned True for %s but no id was assigned' ' to the currently selected widget %s. All' ' selectable widgets must be assigned unique' ' ids for selection-preserving to work' ' properly.', self, sel, ) else: selfin = None babase.uilog.debug( "Not saving ui selection from '%s';" ' selected widget disallows it (%s).', self.main_window_id_prefix, sel, ) shared_state['selection'] = selfin # Allow win to save any custom state. (Do this after selection # save so user can manipulate save output if they want). try: self.main_window_do_save_shared_state(shared_state) except Exception: logging.exception( 'Error in main_window_do_save_shared_state() for %s.', self ) assert isinstance(shared_state, dict) babase.uilog.debug( "Saving shared state from '%s' using key %r.", self.main_window_id_prefix, keyfin, ) babase.app.ui_v1.main_window_shared_states[keyfin] = shared_state
[docs] def main_window_restore_shared_state(self) -> None: """Restore shared state (such as widget selection), if any. This is automatically called just after main-windows are created, but the user may opt to call it at other times such as after explicitly refreshing some UI. State contained here is intended to operate on already-constructed UI; state that influences which UI is contructed should go through other mechanisms. """ # pylint: disable=assignment-from-none key = self.get_main_window_shared_state_id() assert isinstance(key, str | None) keyfin = type(self) if key is None else key babase.uilog.debug( "Restoring shared state to '%s' using key %r.", self.main_window_id_prefix, keyfin, ) shared_state = babase.app.ui_v1.main_window_shared_states.get(keyfin) if shared_state is None: shared_state = {} assert isinstance(shared_state, dict) # Allow win to restore any custom state. (Do this before # selection restore so user can manipulate input if they want). try: self.main_window_do_restore_shared_state(shared_state) except Exception: logging.exception( 'Error in main_window_do_restore_shared_state() for %s.', self ) # Restore selection if desired. if self._get_main_window_should_preserve_selection(): sel = shared_state.get('selection') if isinstance(sel, str): babase.uilog.debug( "Restoring ui selection to '%s': '%s'.", self.main_window_id_prefix, sel, ) pre = '$(WIN)|' if sel.startswith(pre): sel = ( f'{self.main_window_id_prefix}|{sel.removeprefix(pre)}' ) widget = _bauiv1.widget_by_id(sel) if widget is not None: if widget.selectable: widget.global_select() widget.scroll_into_view() else: babase.uilog.debug( "Unable to restore selection '%s';" ' widget is not selectable.', sel, ) else: # We expect this to happen sometimes (windows may come # up with different UIs visible/etc.). Let's note it but # subtly. babase.uilog.debug( "Unable to restore selection '%s'; widget not found.", sel, )
[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 # Save selection, etc. self.main_window_save_shared_state() # Give the user a chance to do whatever. try: self.on_main_window_close() except Exception: logging.exception('Error in on_main_window_close() for %s.', self) # Transition ourself out. # 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? This is called internally by methods such as :meth:`main_window_replace()` and :meth:`main_window_back()` so generally you do not need to call it directly when using those. However you may still opt to check this if doing other actions besides main-window navigation (such as displaying pop-ups). """ # 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 uiv1 = babase.app.ui_v1 # Get ourself transitioning out. self.main_window_close() # Get the 'back' window coming in. if not self.main_window_is_top_level: back_state = self.main_window_back_state if back_state is None: raise RuntimeError( f'Main window {self} provides no back-state.' ) # 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 # When leaving an auxiliary window, scale the destination # window in instead of sliding to convey that its more of a # 'swapping out' than a 'back' action. backwin = back_state.create_window( transition=( 'in_scale' if self.main_window_is_auxiliary else 'in_left' ) ) uiv1.set_main_window( backwin, from_window=self, is_back=True, back_state=back_state, suppress_warning=True, )
[docs] def main_window_replace( self, new_window: MainWindow | Callable[[], MainWindow], back_state: MainWindowState | None = None, is_auxiliary: bool = False, ) -> MainWindow | None: """Replace ourself with a new MainWindow. Returns the new MainWindow. Will no-op and return None if we are not allowed to replace the MainWindow. """ ui = babase.app.ui_v1 # If they didn't provide an explicit back-state, calc one to # recreate this window. if back_state is None: back_state = ui.save_current_main_window_state() # Save selection, etc. self.main_window_save_shared_state() if not isinstance(new_window, MainWindow): # If we're not in control, we're not allowed to change things. if not self.main_window_has_control(): babase.uilog.debug( 'main_window_replace:' ' no-op due to main_window_has_control() returning False.', stack_info=True, ) return None new_window = new_window() else: # We originally were passed MainWindows directly, but want # to phase this out, as it prevents our automatic selection # save/restore from working (we need to save the old # selection *before* the replacement window is created since # the creation itself will change the selection). warnings.warn( 'Passing MainWindow objects to main_window_replace() is' ' deprecated and will be removed when api 9 support ends.' ' You should instead pass calls to generate MainWindow objects.' ' So `main_window_replace(MyWin(some_arg))` would become' ' `main_win_replace(lambda: MyWin(some_arg))`.', DeprecationWarning, stacklevel=2, ) # In this old path, 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().' ) # Give user a chance to do whatever. try: self.on_main_window_close() except Exception: logging.exception('Error in on_main_window_close() for %s.', self) # For auxiliary windows, use scale to give a feel that we're # switching over to a totally separate 'side quest' ui. For # regular back/forward relationships, shove the old out the left # to give the feel that we're adding to a nav stack. if is_auxiliary: transition = 'out_scale' else: transition = 'out_left' # Transition ourself out. _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, ) return new_window
[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 specific window. Used to gracefully return to a window from another window or ui system. """ raise NotImplementedError()
[docs] def main_window_should_preserve_selection(self) -> bool | None: """Whether this window should auto-save/restore selection. If enabled, selection will be stored in the window's shared state. See :meth:`~bauiv1.MainWindow.get_main_window_shared_state_id()` for more info about main-window shared-state. The default value of None results in a warning to explicitly override this (as the implicit default will change from False to True after api 9 support ends). """ return None
[docs] def get_main_window_shared_state_id(self) -> str | None: """Provide a custom id for window shared state. Unlike :class:`~bauiv1.MainWindowState`, which is used to save and restore a single main-window instance, shared-state is intended to hold values that can apply to multiple instances of a window. By default, shared state uses the window class as an index (so is shared by all windows of the same class), but this method can be overridden to provide more distinct states. For example, a store-page main-window class might want to keep distinct states for different sub-pages it can display instead of having a single state for the whole class. Note that shared state only persists for the current run of the app. """ return None
[docs] def main_window_do_save_shared_state(self, state: dict) -> None: """Save state into the provided shared state dict. Can be overridden by subclasses to save custom data. """
[docs] def main_window_do_restore_shared_state(self, state: dict) -> None: """Restore state from the provided shared state dict. Can be overridden by subclasses to restore custom data. """
def _get_main_window_should_preserve_selection(self) -> bool: # pylint: disable=assignment-from-none val = self.main_window_should_preserve_selection() if val is None: warnings.warn( f'{type(self)} should override' f' main_window_should_preserve_selection()' ' to return True or False.' f' The current default is False (for backward compatibility)' f' but it will change to True when api 9 support ends.', FutureWarning, stacklevel=2, ) val = False return val
[docs] class MainWindowState: """Persistent state for a specific MainWindow. 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
[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()
[docs] class BasicMainWindowState(MainWindowState): """A basic MainWindowState. Holds some call to recreate a window and optionally a selection to restore. """ 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: win = self.create_call(transition, origin_widget) return win
[docs] class MainWindowAutoRecreateSuppress: """Suppresses main-window auto-recreate while in existence. Can be instantiated and held by windows or processes within windows for the purpose of preventing the main-window auto-recreate mechanism from firing. This mechanism normally fires when the screen is resized or the ui-scale is changed, allowing main-windows to be recreated to adapt to the new configuration. """ def __init__(self) -> None: babase.app.ui_v1.window_auto_recreate_suppress_count += 1 def __del__(self) -> None: babase.app.ui_v1.window_auto_recreate_suppress_count -= 1
# 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