# Released under the MIT License. See LICENSE for details.
#
"""Provides AppMode functionality."""
from __future__ import annotations # Docs-generation hack.
from enum import Enum
from typing import TYPE_CHECKING
from dataclasses import dataclass
if TYPE_CHECKING:
from collections.abc import Callable
from babase import AppIntent, DevConsoleButtonDef, DevConsoleToggleDef
[docs]
class ControlPermission(Enum):
"""An app-mode's answer to a request to control the app."""
#: The user (or policy) allows it.
ALLOW = 'allow'
#: The user (or policy) refuses it. This is sticky -- the
#: requester is turned away for a while without asking again,
#: so a repeated request can't wear someone down.
DENY = 'deny'
#: No answer is possible *right now* -- this mode has no way to
#: ask. Distinct from DENY on purpose, because a request
#: arriving during bring-up should be held and put to the user
#: once a mode that can ask becomes active, not refused.
CANNOT_ASK = 'cannot_ask'
[docs]
@dataclass
class ControlPermissionRequest:
"""Someone asking for permission to control this app."""
#: Display name of whoever is asking -- an account tag, vouched
#: for by the master server, so it can be shown as fact rather
#: than as a claim. ``None`` when it couldn't be established.
#:
#: Classic deliberately does not show this: today's only caller
#: (the cloud console) can reach a device only by owning it, so
#: the tag is always the viewer's own and naming it says nothing.
#: Kept because that is a property of the current caller, not of
#: this request -- anything reachable by someone else would need
#: it back.
requester_name: str | None = None
#: Opaque stable id for the requester, the same across their
#: sessions. A remembered grant hangs off this. ``None`` means
#: this requester can't be recognized again, so any allowance
#: must apply to this request alone.
requester_key: str | None = None
[docs]
class AppModeConfig:
"""Base class for app-mode configuration objects.
An app-mode defines how it should be set up by returning one of
these from :meth:`AppMode.new_app_mode_config`. The framework
builds a fresh one for each activation, offers it to plugins
(:meth:`~babase.Plugin.on_app_mode_config`), and then hands it to
the mode's :meth:`AppMode.on_activate`, where the mode configures
itself and any app-subsystems it uses from it.
Each mode defines its own config subclass, so the config *type*
identifies the mode to anyone amending it. By convention a config
only *describes* -- building or mutating one changes nothing
live; values take effect when the mode activates and reads them.
That convention is what makes configs safe for mode subclasses
and plugins to amend.
Configs are deliberately owned by their mode rather than being
one framework-wide object with a part per subsystem: the mode
that builds a config is also the one that consumes it, so the
framework never needs to know what any particular config holds
and nothing has to be registered to apply one.
When writing a config type, make any value that has no sensible
default a required constructor argument of whatever object holds
it, and leave everything a plain mutable attribute afterward.
That way a type checker catches a mode that forgets a required
value, while subclasses and plugins can still reassign anything.
Prefer holding cheap descriptions (asset handles rather than
loaded assets, for example) so that building a config stays free
of side effects and replaced defaults are never loaded.
"""
[docs]
class AppMode:
"""A low level mode the app can be in.
App-modes fundamentally change app behavior related to input
handling, networking, graphics, and more. In a way, different
app-modes can almost be considered different apps.
"""
[docs]
@classmethod
def can_handle_intent(cls, intent: AppIntent) -> bool:
"""Override this to define indent handling for an app-mode."""
raise NotImplementedError('AppMode subclasses must override this.')
[docs]
def handle_intent(self, intent: AppIntent) -> None:
"""Handle an intent."""
raise NotImplementedError('AppMode subclasses must override this.')
[docs]
def on_control_permission_request(
self,
request: ControlPermissionRequest,
on_result: Callable[[ControlPermission], None],
) -> None:
"""Ask the user whether something may control the app.
'Control' is deliberately broad: running commands, reading
this app's log, and whatever that grows into. Answer via
``on_result``, which may be called later (from a dialog) or
immediately (from policy).
The default answers ``CANNOT_ASK``, which is the honest
answer for a mode with no way to prompt -- the request is
then held and re-put once a mode that can ask activates.
An app-mode with a UI should override this; one running
without a user present (a dedicated server) should answer
``ALLOW``, since nobody is there to ask and the operator
already owns the account.
"""
del request # Unused.
on_result(ControlPermission.CANNOT_ASK)
[docs]
def new_app_mode_config(self) -> AppModeConfig:
"""Create a fresh config describing how this mode should run.
Called by the framework each time this mode is about to become
the active one. Override to return your mode's own
:class:`~babase.AppModeConfig` subclass, built with the mode's
defaults; the framework then offers it to plugins and passes
the final result to :meth:`on_activate`.
A fresh instance is built per activation, so nothing can
persist between modes (or activations) by construction.
"""
return AppModeConfig()
[docs]
def on_activate(self, config: AppModeConfig) -> None:
"""Called when the mode is becoming the active one for the app.
``config`` is the object created by
:meth:`new_app_mode_config` for this activation, possibly
amended by plugins. Modes that define their own config type
should begin with ``assert isinstance(config, TheirConfig)``
to recover the concrete type, then set themselves (and any
app-subsystems they use) up from it.
"""
[docs]
def on_deactivate(self) -> None:
"""Called when the mode stops being the active one for the app.
On platforms where the app is explicitly exited (such as desktop
PC) this will also be called at app shutdown.
To best cover both mobile and desktop style platforms, actions
such as saving state should generally happen in response to both
:meth:`on_deactivate()` and :meth:`on_app_active_changed()`
(when active is False).
"""
[docs]
def on_main_ui_dismissed(self) -> None:
"""Called when the main-window goes away, leaving none.
However that happens: backing out of a top-level window, a
menu press, a resume button and so on. (Navigating between main
windows doesn't count.) The default does nothing.
"""
[docs]
def on_app_active_changed(self) -> None:
"""Called when the app's active state changes while in this app-mode.
This corresponds to the app's :attr:`~babase.App.active` attr.
App-active state becomes false when the app is hidden,
minimized, backgrounded, etc. The app-mode may want to take
action such as pausing a running game or saving state when this
occurs.
On platforms such as mobile where apps get suspended and later
silently terminated by the OS, this is likely to be the last
reliable place to save state/etc.
To best cover both mobile and desktop style platforms, actions
such as saving state should generally happen in response to both
:meth:`on_deactivate()` and :meth:`on_app_active_changed()`
(when active is False).
"""
def on_purchase_process_begin(
self, item_id: str, user_initiated: bool
) -> None:
"""Called when in-app-purchase processing is beginning.
This call happens after a purchase has been completed locally
but before its receipt/info is sent to the master-server to
apply to the account.
:meta private:
"""
# pylint: disable=cyclic-import
import babase
from babase import _builtinassets
del item_id # Unused.
# Show nothing for stuff not directly kicked off by the user.
if not user_initiated:
return
babase.screenmessage(
_builtinassets.strings.account.updating_account,
color=(0, 1, 0),
)
# Ick; we can be called early in the bootstrapping process
# before we're allowed to load assets. Guard against that.
if babase.asset_loads_allowed():
_builtinassets.audio.click01.get().play()
def on_purchase_process_end(
self, item_id: str, user_initiated: bool, applied: bool
) -> None:
"""Called when in-app-purchase processing completes.
Each call to :meth:`on_purchase_process_begin()` will be
followed up by a call to this method. If the purchase was found
to be valid and was applied to the account, applied will be
True. In the case of redundant or invalid purchases or
communication failures it will be False.
:meta private:
"""
# pylint: disable=cyclic-import
import babase
# Ignore this; we want to announce newly applied stuff even if
# it was from a different launch or client or whatever.
del user_initiated
# If the purchase wasn't applied, do nothing. This likely means it
# was redundant or something else harmless.
if not applied:
return
# By default just announce the item id we got. Real app-modes
# probably want to do something more specific based on item-id.
from babase import _builtinassets, _commonassets
from babase._asset_packages import construct_assets_complete
babase.screenmessage(
_builtinassets.strings.account.you_got_item(item=item_id),
color=(0, 1, 0),
)
if construct_assets_complete():
_commonassets.audio.cash_register.get().play()
[docs]
def get_dev_console_ui_debug_draw_toggles(
self,
) -> list[DevConsoleToggleDef]:
"""Define debug-drawing toggles to show up in the UI dev console.
These join the tab's own in its 'Debug Drawing' row (in the
console's expanded view). For ui that the tab itself can't know
about, such as a ui package's layout guides.
"""
return []
# 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, TypeIs)
import asyncio
from concurrent.futures import Future
from pathlib import Path
from enum import Enum