Source code for bacommon.docui.v2

# Released under the MIT License. See LICENSE for details.
#
"""Version 2 doc-ui types -- language-agnostic (l-string) text.

Where v1 carries pre-localized raw ``str`` text (optionally a JSON-encoded
legacy ``babase.Lstr`` via ``*_is_lstr`` flags) and expects the *server* to
localize, v2 text is always a language-agnostic
:class:`~bacommon.langstr.LangStrSpec`. The server ships one response to every
client regardless of language; the client resolves the referenced
asset-packages in its own locale and decodes the strings at render time.

See ``docs/initiatives/docui-v2-lstrings.md`` (ballistica-internal). This is
the milestone-1 slice: a minimal but real subset of the v1 element set, with
text typed as ``LangStrSpec`` (the name-based form -- subs are flat for now).
Non-text fields mirror v1's names/keys so client render code can stay close
to ``v1prep``.
"""

from __future__ import annotations  # Docs-generation hack.

from enum import Enum
from dataclasses import dataclass, field
from typing import Annotated, override, assert_never

from efro.dataclassio import ioprepped, IOAttrs, IOMultiType

import bacommon.clienteffect as clfx
import bacommon.legacydisplayitem as lditm
from bacommon.langstr import LangStrSpec
from bacommon.assetspec import TextureSpec, MeshSpec
from bacommon.docui._docui import (
    DocUIRequest,
    DocUIRequestTypeID,
    DocUIResponse,
    DocUIResponseTypeID,
)


[docs] class RequestMethod(Enum): """Type of requests that can be made to doc-ui servers.""" #: An unknown request method (newer client -> older server). UNKNOWN = 'u' #: Fetch some resource. Retriable; results optionally cacheable. GET = 'g' #: Change some resource. Not implicitly retriable, not cacheable. POST = 'p'
[docs] @ioprepped @dataclass class Request(DocUIRequest): """Full request to doc-ui (v2).""" path: str method: RequestMethod = RequestMethod.GET args: dict = field( default_factory=dict )
[docs] @override @classmethod def get_type_id(cls) -> DocUIRequestTypeID: return DocUIRequestTypeID.V2
[docs] class ActionTypeID(Enum): """Type ID for each of our subclasses.""" BROWSE = 'b' REPLACE = 'r' LOCAL = 'l' UNKNOWN = 'u'
[docs] class Action(IOMultiType[ActionTypeID]): """Something that happens when a button is pressed."""
[docs] @override @classmethod def get_type_id(cls) -> ActionTypeID: raise NotImplementedError()
[docs] @override @classmethod def get_type(cls, type_id: ActionTypeID) -> type[Action]: # pylint: disable=cyclic-import t = ActionTypeID if type_id is t.BROWSE: return Browse if type_id is t.REPLACE: return Replace if type_id is t.LOCAL: return Local if type_id is t.UNKNOWN: return UnknownAction assert_never(type_id)
[docs] @override @classmethod def get_type_id_storage_name(cls) -> str: return '_t'
[docs] @override @classmethod def get_unknown_type_fallback(cls) -> Action: return UnknownAction()
[docs] @ioprepped @dataclass class UnknownAction(Action): """Action type we don't recognize."""
[docs] @override @classmethod def get_type_id(cls) -> ActionTypeID: return ActionTypeID.UNKNOWN
[docs] @ioprepped @dataclass class Browse(Action): """Browse to a new page in a new window.""" request: Request #: Plays a swish. default_sound: bool = True
[docs] @override @classmethod def get_type_id(cls) -> ActionTypeID: return ActionTypeID.BROWSE
[docs] @ioprepped @dataclass class Replace(Action): """Replace the current page with a new one (seamless transition).""" request: Request #: Plays a click if triggered by a button press. default_sound: bool = True
[docs] @override @classmethod def get_type_id(cls) -> ActionTypeID: return ActionTypeID.REPLACE
[docs] @ioprepped @dataclass class Local(Action): """Perform only local actions; no new requests or page changes.""" close_window: bool = False #: Plays a swish if closing the window, else a click. default_sound: bool = True #: Client-effects to run immediately when the button is pressed. #: Note that effect payloads are not yet v2-native — text in them is #: raw/legacy-lstr, pending clienteffect gaining a resolve-context #: concept (see the SoundSpec followup in docs/followups.md). #: #: :meta private: immediate_client_effects: list[clfx.Effect] = field(default_factory=list) #: Local action to run immediately when the button is pressed. Will #: be handled by #: :meth:`bauiv1lib.docui.DocUIController.local_action()`. immediate_local_action: str | None = None immediate_local_action_args: dict | None = None
[docs] @override @classmethod def get_type_id(cls) -> ActionTypeID: return ActionTypeID.LOCAL
[docs] class HAlign(Enum): """Horizontal alignment.""" LEFT = 'l' CENTER = 'c' RIGHT = 'r'
[docs] class VAlign(Enum): """Vertical alignment.""" TOP = 't' CENTER = 'c' BOTTOM = 'b'
[docs] class DecorationTypeID(Enum): """Type ID for each of our subclasses.""" UNKNOWN = 'u' TEXT = 't' IMAGE = 'i' DISPLAY_ITEM = 'd' FRAME = 'f'
[docs] class Decoration(IOMultiType[DecorationTypeID]): """Top level class for our decoration multitype."""
[docs] @override @classmethod def get_type_id(cls) -> DecorationTypeID: raise NotImplementedError()
[docs] @override @classmethod def get_type(cls, type_id: DecorationTypeID) -> type[Decoration]: # pylint: disable=cyclic-import t = DecorationTypeID if type_id is t.UNKNOWN: return UnknownDecoration if type_id is t.TEXT: return Text if type_id is t.IMAGE: return Image if type_id is t.DISPLAY_ITEM: return DisplayItem if type_id is t.FRAME: return Frame assert_never(type_id)
[docs] @override @classmethod def get_unknown_type_fallback(cls) -> Decoration: return UnknownDecoration()
[docs] @override @classmethod def get_type_id_storage_name(cls) -> str: return '_t'
[docs] @ioprepped @dataclass class UnknownDecoration(Decoration): """An unknown decoration (should never reach a client in practice)."""
[docs] @override @classmethod def get_type_id(cls) -> DecorationTypeID: return DecorationTypeID.UNKNOWN
[docs] @ioprepped @dataclass class Text(Decoration): """Text decoration. ``text`` is a language-agnostic :class:`~bacommon.langstr.LangStrSpec`. """ #: The text. An ``int`` is the indexed form -- a flat index #: into the string domain of :attr:`Response.packages` (see #: ``bacommon.langstr._flatindex``); the client unfolds it #: into the two-integer form the native decoder consumes while #: resolving. Strings carrying substitutions never fold. text: LangStrSpec | int position: tuple[float, float] #: Effectively max-width and max-height. size: tuple[float, float] scale: float = 1.0 h_align: HAlign = ( HAlign.CENTER ) v_align: VAlign = ( VAlign.CENTER ) color: tuple[float, float, float, float] | None = None flatness: float | None = None shadow: float | None = None highlight: bool = True depth_range: tuple[float, float] | None = None #: Show max-width/height bounds; useful during development. debug: bool = False
[docs] @override @classmethod def get_type_id(cls) -> DecorationTypeID: return DecorationTypeID.TEXT
[docs] @ioprepped @dataclass class Image(Decoration): """Image decoration. Textures/meshes are language-independent refs. Unlike text, image assets need no per-locale decode; each ref (:class:`~bacommon.assetspec.TextureSpec` / :class:`~bacommon.assetspec.MeshSpec`) is resolved by the client and rendered directly. """ #: The image's texture. An ``int`` is the indexed form -- a flat #: index into the textures domain of :attr:`Response.packages` (see #: ``bacommon.assetspec._index``); the client swaps it for a #: :class:`~bacommon.assetspec.TextureSpec` while resolving, so #: everything downstream of resolve sees only specs. Old clients are #: served the spec form. texture: TextureSpec | int position: tuple[float, float] size: tuple[float, float] color: tuple[float, float, float, float] | None = None h_align: HAlign = ( HAlign.CENTER ) v_align: VAlign = ( VAlign.CENTER ) tint_texture: TextureSpec | int | None = None tint_color: tuple[float, float, float] | None = None tint2_color: tuple[float, float, float] | None = None mask_texture: TextureSpec | int | None = None mesh_opaque: MeshSpec | int | None = None mesh_transparent: MeshSpec | int | None = None highlight: bool = True depth_range: tuple[float, float] | None = None #: Show this image's bounds; useful during development. Worth #: having separately from the art because a texture with a #: transparent margin gives no clue where its box really is. debug: bool = False
[docs] @override @classmethod def get_type_id(cls) -> DecorationTypeID: return DecorationTypeID.IMAGE
[docs] @ioprepped @dataclass class Frame(Decoration): """A self-contained grouping of non-interactive decorations. A frame lets a producer describe *how something looks* — an item, a badge, a composed graphic — instead of naming a thing the client must already know how to draw. Its children are positioned relative to the frame's own origin and are transformed as a group, so the same frame can be placed anywhere at any size. Frames are decorations themselves, so they embed in doc-ui pages like any other; they can also be drawn straight into a plain container widget. They are deliberately non-interactive for now. By default a frame imposes no bounds -- it knows only where its center is and how big to draw. Give it a :attr:`size` and it instead fits its children into that box; see there. """ #: Child decorations, positioned relative to this frame's origin. #: Nested frames are allowed -- except under :attr:`size`. decorations: list[Decoration] position: tuple[float, float] scale: float = 1.0 highlight: bool = True #: Fit the children into this box -- measure their combined #: extent, center that on the frame's position, and scale it #: down (never up) if it would not otherwise fit. #: #: This exists so a producer can compose things whose size it #: cannot know. Centering a count beside its currency icon needs #: the count's rendered width, which only the client can measure -- #: so the producer says "these two, together, in this box" and the #: client works out the rest at prep time, where it is already #: measuring text. #: #: **Children must be text and images only.** Their combined #: extent has to be computable before anything is drawn, which #: rules out nested frames and display-items. A frame that breaks #: this draws unfitted rather than silently mis-centering. size: tuple[float, float] | None = None #: Where the fitted content sits in :attr:`size`. Only matters when #: the content is smaller than the box, since content that had to #: shrink already fills it on the binding axis. h_align: HAlign = ( HAlign.CENTER ) v_align: VAlign = ( VAlign.CENTER ) #: Draw this frame's bounds; useful during development. Shows the #: :attr:`size` box and, inside it, the extent the children #: actually occupy -- so a composition that does not sit where it #: was meant to is visible rather than inferred. debug: bool = False
[docs] @override @classmethod def get_type_id(cls) -> DecorationTypeID: return DecorationTypeID.FRAME
[docs] class DisplayItemStyle(Enum): """Styles a display-item can be drawn in (mirrors v1).""" #: Fully conveys what the item is. Draws in a 4x3 box and works #: best with large-ish displays. FULL = 'f' #: Fully conveys the item, condensed into a 2x1 box for small sizes. COMPACT = 'c' #: Graphics-only representation in a 1x1 box, for use alongside a #: textual description. ICON = 'i'
[docs] @ioprepped @dataclass class DisplayItem(Decoration): """DisplayItem decoration. The wrapped :class:`~bacommon.legacydisplayitem.Wrapper` already localizes its own text client-side, so it carries over from v1 unchanged. """ wrapper: lditm.Wrapper position: tuple[float, float] size: tuple[float, float] style: DisplayItemStyle = ( DisplayItemStyle.FULL ) text_color: tuple[float, float, float] | None = None highlight: bool = True depth_range: tuple[float, float] | None = None debug: bool = False
[docs] @override @classmethod def get_type_id(cls) -> DecorationTypeID: return DecorationTypeID.DISPLAY_ITEM
[docs] class ButtonStyle(Enum): """Styles a button can be.""" SQUARE = 'q' TAB = 't' SMALL = 's' MEDIUM = 'm' LARGE = 'l' LARGER = 'xl' BACK = 'b' BACK_SMALL = 'bs' SQUARE_WIDE = 'w'
[docs] @ioprepped @dataclass class Button: """A button in our doc-ui. ``label`` is a language-agnostic :class:`~bacommon.langstr.LangStrSpec`. Size, padding, and all decorations scale consistently with ``scale``. """ label: LangStrSpec | int | None = None action: Action | None = None size: tuple[float, float] | None = None color: tuple[float, float, float, float] | None = None label_color: tuple[float, float, float, float] | None = None label_scale: float | None = ( None ) label_flatness: float | None = None texture: TextureSpec | int | None = None scale: float = 1.0 padding_left: float = 0.0 padding_top: float = 0.0 padding_right: float = 0.0 padding_bottom: float = 0.0 decorations: list[Decoration] | None = None style: ButtonStyle = ( ButtonStyle.SQUARE ) default: bool = False selected: bool = False icon: TextureSpec | int | None = None icon_scale: float | None = ( None ) icon_color: tuple[float, float, float, float] | None = None depth_range: tuple[float, float] | None = None #: Custom widget id. Prefixed with the window id; unique within window. widget_id: str | None = None #: Draw bounds of the button. debug: bool = False
[docs] class RowTypeID(Enum): """Type ID for each of our subclasses.""" BUTTON_ROW = 'b' UNKNOWN = 'u'
[docs] class Row(IOMultiType[RowTypeID]): """Top level class for our row multitype."""
[docs] @override @classmethod def get_type_id(cls) -> RowTypeID: raise NotImplementedError()
[docs] @override @classmethod def get_type(cls, type_id: RowTypeID) -> type[Row]: # pylint: disable=cyclic-import t = RowTypeID if type_id is t.UNKNOWN: return UnknownRow if type_id is t.BUTTON_ROW: return ButtonRow assert_never(type_id)
[docs] @override @classmethod def get_unknown_type_fallback(cls) -> Row: return UnknownRow()
[docs] @override @classmethod def get_type_id_storage_name(cls) -> str: return '_t'
[docs] @ioprepped @dataclass class UnknownRow(Row): """A row type we don't have."""
[docs] @override @classmethod def get_type_id(cls) -> RowTypeID: return RowTypeID.UNKNOWN
[docs] @ioprepped @dataclass class ButtonRow(Row): """A row consisting of buttons. ``title``/``subtitle`` are :class:`~bacommon.langstr.LangStrSpec`. """ buttons: list[Button] header_height: float = 0.0 header_scale: float = 1.0 header_decorations_left: list[Decoration] | None = None header_decorations_center: list[Decoration] | None = None header_decorations_right: list[Decoration] | None = None title: LangStrSpec | int | None = None title_color: tuple[float, float, float, float] | None = None title_flatness: float | None = None title_shadow: float | None = None subtitle: LangStrSpec | int | None = None subtitle_color: tuple[float, float, float, float] | None = None subtitle_flatness: float | None = None subtitle_shadow: float | None = None #: Spacing between all buttons in the row. button_spacing: float = 15.0 padding_left: float = 10.0 padding_right: float = 10.0 padding_top: float = 10.0 padding_bottom: float = 10.0 #: Extra space above the row's horizontally-scrollable area. spacing_top: float = 0.0 #: Extra space below the row's horizontally-scrollable area. spacing_bottom: float = 0.0 center_content: bool = False center_title: bool = False #: If things disappear when scrolling left/right, turn this up. simple_culling_h: float = ( 100.0 ) #: Draw bounds of the row and its button columns. debug: bool = False
[docs] @override @classmethod def get_type_id(cls) -> RowTypeID: return RowTypeID.BUTTON_ROW
[docs] @ioprepped @dataclass class Page: """Doc-UI page version 2. ``title`` is a language-agnostic :class:`~bacommon.langstr.LangStrSpec`. """ title: LangStrSpec | int rows: list[Row] #: Center content vertically when it's smaller than the available height. center_vertically: bool = ( False ) row_spacing: float = 10.0 #: If things disappear when scrolling up/down, turn this up. simple_culling_v: float = ( 100.0 ) padding_bottom: float = 0.0 padding_left: float = 0.0 padding_top: float = 0.0 padding_right: float = 0.0
[docs] class ResponseStatus(Enum): """The overall result of a request.""" SUCCESS = 0 #: Something went wrong. That's all we know. UNKNOWN_ERROR = 1 #: Something went wrong talking to the server. A 'Retry' may be apt. COMMUNICATION_ERROR = 2 #: This requires the user to be signed in, and they aint. NOT_SIGNED_IN_ERROR = 3
[docs] @ioprepped @dataclass class Response(DocUIResponse): """Full docui response (v2).""" page: Page status: ResponseStatus = ( ResponseStatus.SUCCESS ) #: The engine build this response was built for, as a sanity check: #: responses can be tailored per-build (client-effect forms etc.), #: so a consumer seeing a mismatch with its own build should treat #: the response as stale (e.g. toss cached data) rather than use it. for_build: int | None = None #: Asset-package-versions the response's integer-indexed #: language-strings resolve against (position = package index). #: Present only on wire responses finalized to the indexed form by #: the server; its presence declares the page + contained client #: effects fully indexed (consumers may flag resource-form leaks), #: and it doubles as the client's resolve/pre-warm manifest. #: Locally-authored responses never carry it (indexing is a wire #: compression; local pages stay in the authored resource form). packages: list[str] = field( default_factory=list ) #: Digest of the exact asset-index domain the producer indexed #: against, from #: :meth:`~bacommon.assetspec.AssetIndexContext.domain_digest`. The #: two ends build that domain from different sources, and a #: disagreement is invisible on its own -- an index that is wrong #: but still in range simply names a different asset, so the page #: renders with the wrong art and nothing logs. A consumer whose #: own digest differs must refuse to de-index rather than trust it; #: leaving the integers in place makes the failure loud and names #: the packages involved. Set only when asset refs were indexed. asset_index_digest: str | None = None #: The same guard for folded language-string references, from #: :meth:`~bacommon.langstr.LangStrFlatIndexContext.domain_digest`. #: Separate from the asset digest so a mismatch says which of the #: two domains drifted. Set only when string refs were folded. langstr_index_digest: str | None = None #: Effects to run on the client when this response is initially #: received (not re-run on automatic page refreshes). Note that #: effect payloads are not yet v2-native — text in them is #: raw/legacy-lstr, pending clienteffect gaining a resolve-context #: concept (see the SoundSpec followup in docs/followups.md). #: #: :meta private: client_effects: list[clfx.Effect] = field(default_factory=list) #: Local action to run after this response is initially received #: (not re-run on automatic page refreshes). Will be handled by #: :meth:`bauiv1lib.docui.DocUIController.local_action()`. local_action: str | None = ( None ) local_action_args: dict | None = None #: New overall action to have the client schedule after this #: response is received. Useful for redirecting to other pages or #: closing the doc-ui window. timed_action: Action | None = None timed_action_delay: float = 0.0 #: If provided, error on builds older than this. minimum_engine_build: int | None = None #: Explicit shared-state id (defaults to the request path client-side). shared_state_id: str | None = None
[docs] @override @classmethod def get_type_id(cls) -> DocUIResponseTypeID: return DocUIResponseTypeID.V2
# 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