# 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.
# This is the doc-ui v2 wire format in its entirety, and it being one
# module is a feature: it is the spec that anyone producing or
# consuming doc-ui reads. Its types also refer to each other in both
# directions (rows hold actions; the row multitype hands back its row
# classes), so splitting it means either a module-level import cycle or
# scattering the public names across modules. So allow it to run long.
# pylint: disable=too-many-lines
from enum import Enum
from dataclasses import dataclass, field
from typing import TYPE_CHECKING, Annotated, override, assert_never
from efro.dataclassio import ioprepped, IOAttrs, IOMultiType
import bacommon.clienteffect as clfx
import bacommon.depiction
from bacommon.langstr import LangStrSpec
from bacommon.assetspec import TextureSpec, MeshSpec
from bacommon.assetpackage import ApverNum
from bacommon.docui._docui import (
DocUIRequest,
DocUIRequestTypeID,
DocUIResponse,
DocUIResponseTypeID,
)
if TYPE_CHECKING:
from typing import Iterator, TypeIs
[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
)
#: Current state of the page this request was fired from (see
#: :attr:`Page.state`), if it had any. Clients fill this in; page
#: authors never set it directly.
state: dict | None = None
#: State key of the input whose value change fired this request,
#: if that is what fired it.
trigger: str | None = None
[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'
MENU = 'm'
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.MENU:
return Menu
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]
class WindowLayout(Enum):
"""The overall shape of a doc-ui window.
Chosen by whatever opens the window (see :attr:`Browse.layout`),
since the window exists before its page arrives; pages replacing
each other within a window share its layout. The client owns each
layout's actual geometry per ui-scale.
"""
#: A narrower, shorter window for simple list-style pages (settings
#: and the like). At small ui-scale, where windows fill the screen,
#: content is instead laid out in a centered column of limited
#: width.
SMALL = 's'
#: A small layout's width, with a height between a small and a
#: small-taller layout's. The same as a small layout at small
#: ui-scale.
SMALL_TALL = 'st'
#: A small layout's width, but taller; for long list-style pages
#: (options pages and the like). The same as a small layout at
#: small ui-scale.
SMALL_TALLER = 'str'
#: A squat window for horizontally laid out content designed to
#: fill the screen elegantly (a row of big buttons, say). Its pages
#: get the same width and height at every ui-scale -- small
#: ui-scale's on the narrowest screen -- so content sized to fit
#: never scrolls and leaves little empty backing. Fills the screen
#: at small ui-scale (full width, unlike a small layout).
WIDE = 'w'
#: A wide layout's height, but wider at medium/large ui-scale; for
#: content expected to be wider than the screen (a long row of
#: items, say), where showing as much as possible means less
#: scrolling. The same as a wide layout at small ui-scale.
WIDER = 'wr'
#: The standard full-size window.
LARGE = 'l'
#: A small layout's column at the right of a full-size window, the
#: rest to its left reserved for a viewer pane (a live character
#: view, say).
VIEWER = 'v'
[docs]
@ioprepped
@dataclass
class Browse(Action):
"""Browse to a new page in a new window."""
request: Request
#: Plays a swish.
default_sound: bool = True
#: Values to assign into the page's state before the request goes
#: out (see :attr:`Page.state`).
sets: dict | None = None
#: A complete state to send *instead of* the page's own; for
#: handing state to a different page.
state: dict | None = None
#: The new window's layout. Note that the wire default is LARGE
#: (an omitted value has always meant LARGE, and must keep doing
#: so for older clients and servers), but routes' own default is
#: WIDE (see :meth:`bacommon.docui.routes.DocUIRoute.browse`).
layout: WindowLayout = WindowLayout.LARGE
[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
#: Values to assign into the page's state before the request goes
#: out (see :attr:`Page.state`).
sets: dict | None = None
#: A complete state to send *instead of* the page's own; for
#: handing state to a different page.
state: dict | None = None
[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.
#: Use the v2 effect forms (language-string text, asset-package
#: sounds); the response's package manifest covers what they
#: reference.
#:
#: :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
#: Values to assign into the page's state (see :attr:`Page.state`).
sets: dict | None = None
#: With :attr:`close_window`, values to assign into the state of the
#: page being returned to, which then refreshes with them; how a
#: picker opened in a window of its own hands back what was picked.
#: Carries the state's type id (``_t``), and is applied only if the
#: returned-to page's state is of that type. Build it with
#: :meth:`bacommon.docui.routes.DocUIState.assign_on_return`.
return_sets: dict | None = (
None
)
[docs]
@override
@classmethod
def get_type_id(cls) -> ActionTypeID:
return ActionTypeID.LOCAL
[docs]
class HAlign(Enum):
"""Horizontal alignment.
Fields of this type carry an ``enum_fallback`` (each field's own
default; ``LEFT`` for the optional row alignments), so a value
added here later decodes as that on builds predating it instead
of failing the whole response.
"""
LEFT = 'l'
CENTER = 'c'
RIGHT = 'r'
[docs]
class VAlign(Enum):
"""Vertical alignment.
Fields of this type carry an ``enum_fallback`` (``CENTER``), so a
value added here later decodes as that on builds predating it
instead of failing the whole response.
"""
TOP = 't'
CENTER = 'c'
BOTTOM = 'b'
[docs]
class DecorationTypeID(Enum):
"""Type ID for each of our subclasses."""
UNKNOWN = 'u'
TEXT = 't'
IMAGE = 'i'
DEPICTION = 'd'
[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.DEPICTION:
return Depiction
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 TextImage:
"""An image fixed to one end of a :class:`Text` decoration.
For things like a price: a count with its currency icon beside it,
measured, fitted, and aligned as one unit. The producer cannot do
that itself because it cannot know the text's rendered width; the
client measures it while laying out the text.
Sizes and offsets are in *text units* -- they are multiplied by the
text's effective scale -- so the image grows and shrinks with its
text. The image's layout box (its size less its :attr:`insets`)
sits butted against its end of the text and vertically centered on
the line; spacing from the text therefore comes from the insets,
not from anything the producer adds.
Meant for single-line text; on multi-line text the images center
against the whole block.
"""
#: The image's texture. An ``int`` is the indexed form; see
#: :attr:`Image.texture`.
texture: TextureSpec | int
#: Size in text units.
size: tuple[float, float]
#: A purely visual shift in text units, positive being right/up. It
#: moves where the image draws and nothing else, like CSS relative
#: positioning; measuring, fitting, and alignment ignore it. For
#: small optical corrections -- spacing belongs in :attr:`insets`.
offset: tuple[float, float] = (
0.0,
0.0,
)
#: Color and opacity. Deliberately separate from the text's color:
#: a coin should not turn green because its count is.
color: tuple[float, float, float, float] | None = None
#: The image's layout box, as fractions of its size trimmed from
#: each edge, in (left, bottom, right, top) order. Layout uses only
#: this box -- for butting against the text, centering on the line,
#: and measuring the unit -- while the full image still draws.
#: Typically trims most of the art's transparent padding, leaving in
#: whatever spacing the art wants beside text -- the way a font
#: glyph carries its own side bearings. A negative inset extends the
#: box past the image instead (like a CSS margin), for art with less
#: padding than the spacing it wants. Fractions rather than units
#: because this is a fact of the art, true at any size.
insets: tuple[float, float, float, float] = (0.0, 0.0, 0.0, 0.0)
[docs]
@ioprepped
@dataclass
class Text(Decoration):
"""Text decoration.
``text`` is a language-agnostic :class:`~bacommon.langstr.LangStrSpec`.
With :attr:`image_left` or :attr:`image_right` set, the text and its
images are measured, shrunk to fit :attr:`size` (never grown), and
aligned as a single unit.
"""
#: 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
#: An image fixed to the left end of the text.
image_left: TextImage | None = None
#: An image fixed to the right end of the text.
image_right: TextImage | None = None
#: Lets client-effects animate this decoration (see
#: ``bacommon.clienteffect.KeyframeAnimation``). Everything in
#: a page sharing an id animates together.
anim_id: str | None = None
[docs]
@override
@classmethod
def get_type_id(cls) -> DecorationTypeID:
return DecorationTypeID.TEXT
[docs]
@ioprepped
@dataclass
class ImageNinePatch:
"""Draws an :class:`Image` as a 9-patch filling its box exactly.
The texture splits into corners, edges and a middle; corners keep
their drawn size, edges and middle fill what's between. Tint and
mask textures share the texture's layout.
"""
#: Where the texture splits, as fractions of its width/height from
#: the left, bottom, right and top. 0.5 on each side of an axis
#: makes that axis's middle a single texel line.
insets: tuple[float, float, float, float]
#: How big those edges draw, in the image's own units (as its
#: ``size``), same order. A pair too big for the box shrinks to fit.
borders: tuple[float, float, float, float]
#: Repeat the horizontal (``tile_h``) or vertical (``tile_v``) middle
#: at the corners' scale -- fitted to a whole number of copies --
#: rather than stretching it. Its art must tile seamlessly.
tile_h: bool = False
tile_v: bool = False
[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
#: Tint through the tint texture's blue channel (as
#: :attr:`tint_color` is red and :attr:`tint2_color` green). Clients
#: before this field ignore it.
tint3_color: tuple[float, float, float] | None = None
#: Draw as a 9-patch (see :class:`ImageNinePatch`). Clients before
#: this field ignore it and stretch the whole texture over the box.
nine_patch: ImageNinePatch | 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
#: Lets client-effects animate this decoration (see
#: ``bacommon.clienteffect.KeyframeAnimation``). Everything in
#: a page sharing an id animates together.
anim_id: str | None = None
[docs]
@override
@classmethod
def get_type_id(cls) -> DecorationTypeID:
return DecorationTypeID.IMAGE
[docs]
@ioprepped
@dataclass
class Depiction(Decoration):
"""A :class:`bacommon.depiction.Depiction`, drawn in a box.
``position`` is the box's center and ``size`` its size. A depiction
with a shape of its own (a square icon, an image's aspect) is fitted
inside the box and placed by ``h_align``/``v_align``; one without
(a name) fills the box.
Depictions are self-sufficient: whatever packages one references
contribute **nothing** to the page's own :attr:`Response.packages`.
Art that isn't local yet shows as a standin, never a blocked page.
"""
depiction: bacommon.depiction.Depiction
position: tuple[float, float]
size: tuple[float, float]
h_align: HAlign = HAlign.CENTER
v_align: VAlign = VAlign.CENTER
#: Whether to follow the button this decorates -- brightening as
#: it's hovered, pressed, or selected, and drawing faded and greyed
#: while it's disabled.
highlight: bool = True
depth_range: tuple[float, float] | None = None
debug: bool = False
#: Lets client-effects animate this decoration (see
#: ``bacommon.clienteffect.KeyframeAnimation``). Everything in
#: a page sharing an id animates together.
anim_id: str | None = None
[docs]
@override
@classmethod
def get_type_id(cls) -> DecorationTypeID:
return DecorationTypeID.DEPICTION
[docs]
class RowTypeID(Enum):
"""Type ID for each of our subclasses."""
BUTTON_ROW = 'b'
CHECKBOX_ROW = 'c'
TEXT_INPUT_ROW = 't'
CHOICE_ROW = 'h'
COLOR_ROW = 'k'
SLIDER_ROW = 's'
NUMBER_ROW = 'n'
BUTTON_CONTROL_ROW = 'bc'
SECTION = 'sc'
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
rowtype: type[Row]
match type_id:
case t.UNKNOWN:
rowtype = UnknownRow
case t.BUTTON_ROW:
rowtype = ButtonRow
case t.CHECKBOX_ROW:
rowtype = CheckboxRow
case t.TEXT_INPUT_ROW:
rowtype = TextInputRow
case t.CHOICE_ROW:
rowtype = ChoiceRow
case t.COLOR_ROW:
rowtype = ColorRow
case t.SLIDER_ROW:
rowtype = SliderRow
case t.NUMBER_ROW:
rowtype = NumberRow
case t.BUTTON_CONTROL_ROW:
rowtype = ButtonControlRow
case t.SECTION:
rowtype = Section
case _:
assert_never(type_id)
return rowtype
[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 CheckboxRow(Row):
"""A row consisting of a single checkbox.
Label at the left edge of the row; box at the right. Its value
lives in the page's state (see :attr:`Page.state`) under ``name``,
which must not start with an underscore and must hold a bool.
"""
#: Key in the page's state holding our value.
name: str
label: LangStrSpec | int | None = None
#: Fired when the value changes, after the new value has been
#: written to the page's state. Without one, a changed value simply
#: goes out with whatever request the page fires next. Actions
#: opening new windows are not allowed here.
on_change: Action | None = (
None
)
#: Shown dimmed and not toggleable (a press plays an error sound).
#: Still selectable, so navigation around it is unaffected. Clients
#: predating this field show it as a normal row.
disabled: bool = False
#: Section title shown above the row (with an optional subtitle),
#: styled and placed exactly as a :class:`ButtonRow`'s.
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
title_align: HAlign | None = None
#: Small explanatory text drawn below the row's content, aligned
#: like its title; the row grows to make room for it.
footnote: LangStrSpec | int | None = None
#: Extra inset for the label, which otherwise starts where row
#: titles do.
padding_left: float = 0.0
#: Extra inset for the box, which otherwise ends where the last
#: button of a right-aligned row does.
padding_right: float = 0.0
padding_top: float = 4.0
padding_bottom: float = 4.0
#: See :attr:`ButtonRow.header_height`.
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
#: See :attr:`ButtonRow.footer_height`.
footer_height: float = 0.0
footer_scale: float = 1.0
footer_decorations_left: list[Decoration] | None = None
footer_decorations_center: list[Decoration] | None = None
footer_decorations_right: list[Decoration] | None = None
#: See :attr:`ButtonRow.spacing_top`.
spacing_top: float = 0.0
#: See :attr:`ButtonRow.spacing_bottom`.
spacing_bottom: float = 0.0
#: See :attr:`ButtonRow.spacing_title`.
spacing_title: float = 0.0
#: See :attr:`ButtonRow.spacing_footnote`.
spacing_footnote: float = (
0.0
)
#: Draw bounds of the row.
debug: bool = False
[docs]
@override
@classmethod
def get_type_id(cls) -> RowTypeID:
return RowTypeID.CHECKBOX_ROW
[docs]
@ioprepped
@dataclass
class TextInputRow(Row):
"""A row consisting of a single editable line of text.
Label at the left edge of the row, taking up to half of its width
(and squished to fit if it wants more); the editable text gets the
rest. Its value lives in the page's state (see :attr:`Page.state`)
under ``name``, which must not start with an underscore and must
hold a str.
"""
#: Key in the page's state holding our value.
name: str
label: LangStrSpec | int | None = None
#: What is being edited, for places that ask for text by way of a
#: separate dialog (on-screen keyboards and such). The label gets
#: used when this is not provided.
description: LangStrSpec | int | None = None
max_chars: int = 64
#: Fired when an edit is applied (a text-entry dialog closing with
#: a value, inline editing ending, the clear button; NOT per
#: character) with a changed value, after that value has been
#: written to the page's state. Without one, a changed value simply
#: goes out with whatever request the page fires next. Actions
#: opening new windows are not allowed here.
on_change: Action | None = (
None
)
#: Fired when the user submits the field (return/enter while
#: editing inline, or a text-entry dialog's submit), after the value
#: has been applied to the page's state. For forms where typing and
#: hitting return should do what the page's main button does. Any
#: action is allowed.
on_submit: Action | None = (
None
)
#: Shown dimmed and not editable. Still selectable, so navigation
#: around it is unaffected. Clients predating this field show it as
#: a normal editable row.
disabled: bool = False
#: Section title shown above the row (with an optional subtitle),
#: styled and placed exactly as a :class:`ButtonRow`'s.
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
title_align: HAlign | None = None
#: Small explanatory text drawn below the row's content, aligned
#: like its title; the row grows to make room for it.
footnote: LangStrSpec | int | None = None
#: Extra inset for the label, which otherwise starts where row
#: titles do.
padding_left: float = 0.0
#: Extra inset for the text box, which otherwise ends where the
#: last button of a right-aligned row does.
padding_right: float = 0.0
padding_top: float = 4.0
padding_bottom: float = 4.0
#: See :attr:`ButtonRow.header_height`.
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
#: See :attr:`ButtonRow.footer_height`.
footer_height: float = 0.0
footer_scale: float = 1.0
footer_decorations_left: list[Decoration] | None = None
footer_decorations_center: list[Decoration] | None = None
footer_decorations_right: list[Decoration] | None = None
#: See :attr:`ButtonRow.spacing_top`.
spacing_top: float = 0.0
#: See :attr:`ButtonRow.spacing_bottom`.
spacing_bottom: float = 0.0
#: See :attr:`ButtonRow.spacing_title`.
spacing_title: float = 0.0
#: See :attr:`ButtonRow.spacing_footnote`.
spacing_footnote: float = (
0.0
)
#: Draw bounds of the row.
debug: bool = False
[docs]
@override
@classmethod
def get_type_id(cls) -> RowTypeID:
return RowTypeID.TEXT_INPUT_ROW
[docs]
@ioprepped
@dataclass
class Choice:
"""One option in a :class:`ChoiceRow`."""
#: The value stored in the page's state when this is chosen. None
#: is a legitimate choice value (an optional's 'nothing' option) --
#: it lands in state as a null, distinct from the key being absent.
value: str | None
label: LangStrSpec | int
#: Shown in the menu but greyed out and not pickable (an option this
#: device can't use, say). Clients predating this field show it as
#: a normal choice.
disabled: bool = False
[docs]
@ioprepped
@dataclass
class ChoiceRow(Row):
"""A row consisting of a single pick-one-of-these control.
Label at the left edge of the row; a popup-menu button showing the
current choice at the right. Its value lives in the page's state
(see :attr:`Page.state`) under ``name``, which must not start with
an underscore and must hold the ``value`` of one of ``choices``.
"""
#: Key in the page's state holding our value.
name: str
choices: list[Choice]
label: LangStrSpec | int | None = None
#: Fired when a choice is picked, after the new value has been
#: written to the page's state. Without one, a changed value simply
#: goes out with whatever request the page fires next. Actions
#: opening new windows are not allowed here.
on_change: Action | None = (
None
)
#: The whole row shown dimmed with its menu unopenable (a press plays
#: an error sound instead). Still selectable, so navigation around it
#: is unaffected. Distinct from :attr:`Choice.disabled`, which greys
#: individual options in a working menu. Clients predating this field
#: show it as a normal row.
disabled: bool = False
#: Section title shown above the row (with an optional subtitle),
#: styled and placed exactly as a :class:`ButtonRow`'s.
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
title_align: HAlign | None = None
#: Small explanatory text drawn below the row's content, aligned
#: like its title; the row grows to make room for it.
footnote: LangStrSpec | int | None = None
#: Extra inset for the label, which otherwise starts where row
#: titles do.
padding_left: float = 0.0
#: Extra inset for the button, which otherwise ends where the last
#: button of a right-aligned row does.
padding_right: float = 0.0
padding_top: float = 4.0
padding_bottom: float = 4.0
#: See :attr:`ButtonRow.header_height`.
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
#: See :attr:`ButtonRow.footer_height`.
footer_height: float = 0.0
footer_scale: float = 1.0
footer_decorations_left: list[Decoration] | None = None
footer_decorations_center: list[Decoration] | None = None
footer_decorations_right: list[Decoration] | None = None
#: See :attr:`ButtonRow.spacing_top`.
spacing_top: float = 0.0
#: See :attr:`ButtonRow.spacing_bottom`.
spacing_bottom: float = 0.0
#: See :attr:`ButtonRow.spacing_title`.
spacing_title: float = 0.0
#: See :attr:`ButtonRow.spacing_footnote`.
spacing_footnote: float = (
0.0
)
#: Draw bounds of the row.
debug: bool = False
[docs]
@override
@classmethod
def get_type_id(cls) -> RowTypeID:
return RowTypeID.CHOICE_ROW
[docs]
@ioprepped
@dataclass
class ColorRow(Row):
"""A row consisting of a single color-picking control.
Label at the left edge of the row; a swatch button showing the
current color at the right, which opens a color picker. Its value
lives in the page's state (see :attr:`Page.state`) under ``name``,
which must not start with an underscore and must hold an
``[r, g, b]`` list of floats in the 0-1 range.
"""
#: Key in the page's state holding our value.
name: str
label: LangStrSpec | int | None = None
#: Fired when the picker closes with a changed color, after the new
#: value has been written to the page's state. (Not on every
#: intermediate change; the exact-color picker streams those.)
#: Without one, a changed value simply goes out with whatever
#: request the page fires next. Actions opening new windows are not
#: allowed here.
on_change: Action | None = (
None
)
#: Shown dimmed (the swatch greyed like any disabled button) with its
#: picker unopenable (a press plays an error sound). Still
#: selectable, so navigation around it is unaffected. Clients
#: predating this field show it as a normal row.
disabled: bool = False
#: Section title shown above the row (with an optional subtitle),
#: styled and placed exactly as a :class:`ButtonRow`'s.
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
title_align: HAlign | None = None
#: Small explanatory text drawn below the row's content, aligned
#: like its title; the row grows to make room for it.
footnote: LangStrSpec | int | None = None
#: Extra inset for the label, which otherwise starts where row
#: titles do.
padding_left: float = 0.0
#: Extra inset for the swatch, which otherwise ends where the last
#: button of a right-aligned row does.
padding_right: float = 0.0
padding_top: float = 4.0
padding_bottom: float = 4.0
#: See :attr:`ButtonRow.header_height`.
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
#: See :attr:`ButtonRow.footer_height`.
footer_height: float = 0.0
footer_scale: float = 1.0
footer_decorations_left: list[Decoration] | None = None
footer_decorations_center: list[Decoration] | None = None
footer_decorations_right: list[Decoration] | None = None
#: See :attr:`ButtonRow.spacing_top`.
spacing_top: float = 0.0
#: See :attr:`ButtonRow.spacing_bottom`.
spacing_bottom: float = 0.0
#: See :attr:`ButtonRow.spacing_title`.
spacing_title: float = 0.0
#: See :attr:`ButtonRow.spacing_footnote`.
spacing_footnote: float = (
0.0
)
#: Draw bounds of the row.
debug: bool = False
[docs]
@override
@classmethod
def get_type_id(cls) -> RowTypeID:
return RowTypeID.COLOR_ROW
[docs]
@ioprepped
@dataclass
class SliderRow(Row):
"""A row consisting of a single slider control.
Label at the left (right-aligned toward the control), the current
value as text, then the slider itself at the row's right. Its value
lives in the page's state (see :attr:`Page.state`) under ``name``,
which must not start with an underscore and must hold a float
between ``min_value`` and ``max_value``.
"""
#: Key in the page's state holding our value.
name: str
min_value: float
max_value: float
increment: float
label: LangStrSpec | int | None = None
#: Show the value as a whole percentage (``0.5`` -> ``50%``) rather
#: than to ``decimals`` places.
as_percent: bool = False
decimals: int = 2
#: Fired when a value is settled on -- the nub released after a
#: drag, or a run of key/controller steps going quiet (half a
#: second after the last step, or at once on deselection) -- after
#: the value has been written to the page's state. Without one, a changed
#: value simply goes out with whatever request the page fires next.
#: Actions opening new windows are not allowed here.
on_change: Action | None = (
None
)
#: Fired *while* the nub is being dragged (key/controller steps
#: count as dragging), at most every
#: ``drag_interval`` seconds and not before ``drag_delay`` into the
#: drag, with the page's state already holding the value so far;
#: also once for the settled value if the drag's last apply missed
#: it. Local only (typed so): whatever needs to track the value
#: live -- a volume being set -- happens client side, with no
#: request until the value settles.
on_drag: Local | None = None
drag_interval: float = 0.25
drag_delay: float = 0.0
#: Shown dimmed and not adjustable. Still selectable, so navigation
#: around it is unaffected. Clients predating this field show it as
#: a normal adjustable row.
disabled: bool = False
#: Section title shown above the row (with an optional subtitle),
#: styled and placed exactly as a :class:`ButtonRow`'s.
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
title_align: HAlign | None = None
#: Small explanatory text drawn below the row's content, aligned
#: like its title; the row grows to make room for it.
footnote: LangStrSpec | int | None = None
#: Extra inset for the label, which otherwise starts where row
#: titles do.
padding_left: float = 0.0
#: Extra inset for the slider, which otherwise ends where the last
#: button of a right-aligned row does.
padding_right: float = 0.0
padding_top: float = 4.0
padding_bottom: float = 4.0
#: See :attr:`ButtonRow.header_height`.
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
#: See :attr:`ButtonRow.footer_height`.
footer_height: float = 0.0
footer_scale: float = 1.0
footer_decorations_left: list[Decoration] | None = None
footer_decorations_center: list[Decoration] | None = None
footer_decorations_right: list[Decoration] | None = None
#: See :attr:`ButtonRow.spacing_top`.
spacing_top: float = 0.0
#: See :attr:`ButtonRow.spacing_bottom`.
spacing_bottom: float = 0.0
#: See :attr:`ButtonRow.spacing_title`.
spacing_title: float = 0.0
#: See :attr:`ButtonRow.spacing_footnote`.
spacing_footnote: float = (
0.0
)
#: Draw bounds of the row.
debug: bool = False
[docs]
@override
@classmethod
def get_type_id(cls) -> RowTypeID:
return RowTypeID.SLIDER_ROW
[docs]
@ioprepped
@dataclass
class NumberRow(Row):
"""A row editing a number with a '-' and a '+' button.
Label at the left, then at the row's right the current value as
text followed by the two buttons. Each press steps the value by
``increment`` within ``min_value``/``max_value`` (holding a button
repeats). Its value lives in the page's state (see
:attr:`Page.state`) under ``name``, which must not start with an
underscore and must hold a float between ``min_value`` and
``max_value``.
For a value with many steps, :class:`SliderRow` is usually the
better fit; this suits short ranges where each step is a distinct
setting.
"""
#: Key in the page's state holding our value.
name: str
min_value: float
max_value: float
increment: float
label: LangStrSpec | int | None = None
#: Show the value as a whole percentage (``0.5`` -> ``50%``) rather
#: than to ``decimals`` places.
as_percent: bool = False
decimals: int = 0
#: Fired on each press that changes the value, after it has been
#: written to the page's state. Without one, a changed value simply
#: goes out with whatever request the page fires next. Actions
#: opening new windows are not allowed here.
on_change: Action | None = (
None
)
#: Shown dimmed and not adjustable (both buttons disabled). Still
#: selectable, so navigation around it is unaffected. Clients
#: predating this field show it as a normal adjustable row.
disabled: bool = False
#: Section title shown above the row (with an optional subtitle),
#: styled and placed exactly as a :class:`ButtonRow`'s.
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
title_align: HAlign | None = None
#: Small explanatory text drawn below the row's content, aligned
#: like its title; the row grows to make room for it.
footnote: LangStrSpec | int | None = None
#: Extra inset for the label, which otherwise starts where row
#: titles do.
padding_left: float = 0.0
#: Extra inset for the '+' button, which otherwise ends where the
#: last button of a right-aligned row does.
padding_right: float = 0.0
padding_top: float = 4.0
padding_bottom: float = 4.0
#: See :attr:`ButtonRow.header_height`.
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
#: See :attr:`ButtonRow.footer_height`.
footer_height: float = 0.0
footer_scale: float = 1.0
footer_decorations_left: list[Decoration] | None = None
footer_decorations_center: list[Decoration] | None = None
footer_decorations_right: list[Decoration] | None = None
#: See :attr:`ButtonRow.spacing_top`.
spacing_top: float = 0.0
#: See :attr:`ButtonRow.spacing_bottom`.
spacing_bottom: float = 0.0
#: See :attr:`ButtonRow.spacing_title`.
spacing_title: float = 0.0
#: See :attr:`ButtonRow.spacing_footnote`.
spacing_footnote: float = (
0.0
)
#: Draw bounds of the row.
debug: bool = False
[docs]
@override
@classmethod
def get_type_id(cls) -> RowTypeID:
return RowTypeID.NUMBER_ROW
[docs]
@ioprepped
@dataclass
class SectionBacking:
"""A backing for a :class:`Section`, and the layout that goes with it.
A backed section is a card: a rect of ``max_width`` at most (else
the column's full width), centered in the column, from the top of
the section's heading (else its first row) to the bottom of its
note (else its last row) plus ``padding_top``/``padding_bottom``.
Its button rows clip at the rect's left and right edges, and its
content lays out inside it as a page's does in its column: button
rows' buttons ``content_inset`` in from those edges, text (heading
and note text, row titles, labels) and control and fill rows a few
units further in to line up with buttons' visible edges, and
centered things centered on the card. All of that holds whatever
width the section gets.
The rect is drawn as ``texture`` (tinted by ``color``) or, with no
texture, a flat ``color`` fill; an alpha of 0 draws nothing, which
leaves just the layout.
A texture's visible shape usually sits inside soft or shadowed
margins; the pins say where. ``h_pin`` (left, right) is how far
in from each side of the image, as a fraction of its width (0 to
0.5), the shape's edge falls: the image is stretched so those
points land on the rect's edges. ``v_pin`` (top, bottom) does the
same vertically. Pins are a property of the texture alone, so one
calibration holds at every width. They don't apply to flat fills.
"""
texture: TextureSpec | int | None = None
color: tuple[float, float, float, float] = (1.0, 1.0, 1.0, 1.0)
h_pin: tuple[float, float] = (0.0, 0.0)
v_pin: tuple[float, float] = (0.0, 0.0)
#: Widest the card gets; None to take the column's full width.
max_width: float | None = (
None
)
#: How far in from the card's edges its button rows' buttons sit
#: (both sides; a page's sit 28 in from its column's).
content_inset: float = 0.0
#: Room inside the card above its first content and below its last.
padding_top: float = 0.0
padding_bottom: float = 0.0
[docs]
@ioprepped
@dataclass
class Section(Row):
"""A group of rows, with an optional heading, note and backing.
Its rows lay out exactly as they would directly in the page; the
section adds around them a heading (header band, title, subtitle)
above, a note (footnote, footer band) below, and optionally a
backing (see :class:`SectionBacking`), which turns it into a card
with a layout of its own. With none of those it just stands its
rows a little apart from what comes before and after, so the
grouping reads at a glance.
The heading is drawn like a row's own titles but a bit larger, so
it reads as heading the group rather than labeling one row; the
note likewise. Neither is selectable: selecting the section's first
selectable row scrolls its heading into view too, and its last its
note.
Sections don't nest; a section among a section's rows is ignored
(the client logs an error). Builds older than this row type skip a
section entirely, rows included.
``title``/``subtitle``/``footnote`` are
:class:`~bacommon.langstr.LangStrSpec`.
"""
#: The rows in the section.
rows: list[Row]
#: Make the section a card (see :class:`SectionBacking`).
backing: SectionBacking | None = None
#: See :attr:`ButtonRow.header_height`.
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
#: See :attr:`ButtonRow.title_align`; the footnote follows it too.
#: Left by default.
title_align: HAlign | None = None
#: The note under the section's rows (drawn like a subtitle).
footnote: LangStrSpec | int | None = None
#: The footnote's color/flatness/shadow (drawn like a subtitle).
footnote_color: tuple[float, float, float, float] | None = None
footnote_flatness: float | None = None
footnote_shadow: float | None = None
#: See :attr:`ButtonRow.footer_height`.
footer_height: float = 0.0
footer_scale: float = 1.0
footer_decorations_left: list[Decoration] | None = None
footer_decorations_center: list[Decoration] | None = None
footer_decorations_right: list[Decoration] | None = None
#: Extra space above the section (outside its header band) and
#: below it (outside its footer band), on top of the standing room
#: it keeps from its neighbors; either may be negative. See
#: :attr:`ButtonRow.spacing_top`.
spacing_top: float = 0.0
spacing_bottom: float = 0.0
#: Outline the section's heading and note (as rows' debug does).
#: Its rows have their own debug flags.
debug: bool = False
[docs]
@override
@classmethod
def get_type_id(cls) -> RowTypeID:
return RowTypeID.SECTION
[docs]
def all_rows(rows: list[Row]) -> Iterator[Row]:
"""Yield rows depth-first: each row, and a section's rows after it.
For code that visits every row wherever it sits (string and action
walkers, validation). Sections don't nest, but this simply recurses
if one does.
"""
for row in rows:
yield row
if isinstance(row, Section):
yield from all_rows(row.rows)
#: Union of the input-row types: the control rows holding a value,
#: bound to a page-state key (see :attr:`Page.state`).
type AnyInputRow = (
CheckboxRow | TextInputRow | ChoiceRow | ColorRow | SliderRow | NumberRow
)
#: Union of the control-row types: rows holding a single control,
#: label at the left and control at the right. That is the input rows
#: plus :class:`ButtonControlRow`, which holds no value.
type AnyControlRow = AnyInputRow | ButtonControlRow
_INPUT_ROW_TYPES = (
CheckboxRow,
TextInputRow,
ChoiceRow,
ColorRow,
SliderRow,
NumberRow,
)
_CONTROL_ROW_TYPES = _INPUT_ROW_TYPES + (ButtonControlRow,)
[docs]
def is_control_row(
row: Row,
) -> TypeIs[AnyControlRow]:
"""Is this row one of the control-row types (:data:`AnyControlRow`)?
Everything that lays control rows out alike keys off this rather
than listing them; it narrows the type in both branches.
"""
return isinstance(row, _CONTROL_ROW_TYPES)
[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]
#: Values belonging to the page as a whole -- what input rows show
#: and edit, plus anything else the page wants handed back. The
#: client sends the current values along with every request fired
#: from the page (see :attr:`Request.state`), and whatever page
#: comes back replaces them with its own.
#:
#: A flat dict of json values. Keys starting with an underscore
#: are reserved; ``_t`` optionally names the type of state this is
#: so that state reaching a page expecting some other type can be
#: recognized as such and ignored.
state: dict | None = None
#: What the window's viewer pane shows, for windows at the
#: :attr:`WindowLayout.VIEWER` layout (ignored elsewhere): usually a
#: :class:`bacommon.depiction.CharacterViewerDepiction`. The client
#: keeps what the pane shows running across pages, so a page re-sent
#: with a changed depiction (an editor's draft, say) updates the
#: picture in place. Without one the pane is left blank.
viewer: bacommon.depiction.Depiction | None = None
#: Center content vertically when it's smaller than the available height.
center_vertically: bool = (
False
)
#: Whether the page's vertical scroll bar is drawn (and grabbable by
#: the mouse). Off, the page still scrolls every other way (drag,
#: wheel, keys) and keeps the same layout; only the bar goes. Builds
#: older than this field always show it.
show_scrollbar: bool = True
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
#: The client is too old for this; it needs an update. Set by a
#: server declining a request on those grounds, so clients can show
#: their standard update prompt (and composite pages can say so in
#: their own words) without reading the page's text.
#: :attr:`Response.minimum_engine_build` may name the build when
#: the server knows it.
NEED_UPDATE_ERROR = 4
[docs]
@ioprepped
@dataclass
class Response(DocUIResponse):
"""Full docui response (v2)."""
page: Page
#: (A status this build doesn't know decodes as
#: :attr:`ResponseStatus.UNKNOWN_ERROR`, so statuses can be added
#: without older clients failing to decode the response. Builds
#: before 23021 lack this fallback: a server must not send them
#: :attr:`ResponseStatus.NEED_UPDATE_ERROR` or anything newer.)
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[ApverNum] = (
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). Use the v2
#: effect forms (language-string text, asset-package sounds); the
#: response's package manifest covers what they reference.
#:
#: :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, TypeIs)
import asyncio
from concurrent.futures import Future
from pathlib import Path
from enum import Enum