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.

# 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 #: For a request fired from a popup window (see :class:`Popup`), #: the current state of the page the popup is over (its *parent*), #: if that has any; :attr:`state` is then the popup page's own. #: Clients fill this in; page authors never set it directly. parent_state: dict | 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' POPUP_TEXT = 'pt' POPUP = 'pw' 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 # pylint: disable=too-many-return-statements 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.POPUP_TEXT: return PopupText if type_id is t.POPUP: return Popup 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 width, but taller at medium/large ui-scale #: (about a small-tall layout's height); for wide content that #: needs more room than a wide layout's height gives it. The same #: as a wide layout at small ui-scale. WIDE_TALL = 'wt' #: A wide layout's width, but with a height following the screen's #: at medium/large ui-scale (as a small-taller layout's does); for #: lots of scrolling wide content. The same as a wide layout at #: small ui-scale. WIDE_TALLER = 'wtr' #: 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, taking most of the screen (whatever #: that turns out to be). It makes no guarantees about its content #: size, so pages wanting a known size should pick another layout. LARGE = 'l' #: A viewer layout with its column two thirds as wide, leaving more #: of the room to its viewer pane (in a window 10% narrower at #: medium/large ui-scale); for pages with few, simple controls #: beside a big view. VIEWER_NARROW = 'vn' #: 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' @property def minimum_build_number(self) -> int: """The earliest client build that knows this layout (0 for all). Older clients fall back to another layout for one they don't know, so check this before relying on a new one's shape. """ match self: case ( WindowLayout.SMALL | WindowLayout.SMALL_TALL | WindowLayout.SMALL_TALLER | WindowLayout.WIDE | WindowLayout.WIDER | WindowLayout.LARGE | WindowLayout.VIEWER ): return 0 case ( WindowLayout.WIDE_TALL | WindowLayout.WIDE_TALLER | WindowLayout.VIEWER_NARROW ): return 23064 case _: assert_never(self)
[docs] class ScrollEdgeStyle(Enum): """How a doc-ui window's scroll area meets the window around it. Chosen alongside the window's layout (see :attr:`Browse.scroll_edge_style`); by default the layout decides. Either way, content gets the same space and sits in the same place; only how its edges look differs. (At small ui-scale, where windows fill the screen, content always fades toward the toolbars.) """ #: A soft outline around the scroll area (when it has anything to #: scroll). OUTLINE = 'o' #: No outline; content fades into the window backing as it nears #: the scroll area's edges, which reach out toward the window's own #: (and up behind its title). FADE = 'f'
[docs] class ViewerStyle(Enum): """Legacy: how a window's viewer pane looks, for builds 23064 and 23065 (see :attr:`Browse.viewer_style`). Later builds take it from each page instead (see :attr:`Page.viewer_frame_style`). """ #: A framed pane with a backing of its own; its viewer draws within #: the frame. FRAMED = 'f' #: No frame or backing; the viewer draws straight over the window. OVERLAY = 'o'
[docs] class ViewerFrameStyle(Enum): """Whether a doc-ui window's viewer pane (if its page has one) is framed. Chosen by each page (see :attr:`Page.viewer_frame_style`); its background (:attr:`Page.viewer_background_color`) works the same in either. """ #: Rounded, framed and shaded; the viewer and background draw #: within the frame. DEFAULT = 'd' #: No frame or rounding; the viewer and background fill the pane's #: rectangle. NO_FRAME = 'n'
[docs] class ViewerSide(Enum): """Which side of a doc-ui window its viewer pane (if any) sits on. Chosen by each page (see :attr:`Page.viewer_side`); the page's rows get the rest of the window. """ LEFT = 'l' RIGHT = 'r'
[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 #: Whether the current window goes into the back history. Off, the #: new window takes its place instead (scaling in as it scales out): #: back from the new one goes wherever back from the current one #: would have. For moving between peer pages without piling up #: history (next/previous items, tab-like pages, a page that #: shouldn't be returned to). Builds before :data:`TEXT_ROW_BUILD` #: always add it. add_to_history: bool = True #: The new window's layout; WIDE_TALL unless set, as for routes #: (see :meth:`bacommon.docui.routes.DocUIRoute.get_window_layout`). #: On the wire, though, an omitted value means LARGE, as it always #: has (and must keep doing for older clients and servers), so any #: other layout is always written out. A layout a client doesn't #: know shows as WIDE_TALL, a middling shape that's rarely far off; #: builds before 23074 show LARGE. # # TODO(2026-11-23): Once 23074+ is well distributed, drop this # soft_default so the wire's omitted value means WIDE_TALL too # (older clients would then read an omitted layout as LARGE). Same # for bacommon.hostui.Button.layout; update # test_window_layout_defaults_keep_wire_meaning to match. layout: WindowLayout = WindowLayout.WIDE_TALL #: The new window's scroll edge style, or None to let its layout #: decide. Clients predating this ignore it (their layout decides). scroll_edge_style: ScrollEdgeStyle | None = None #: (Legacy) The new window's viewer pane style, read only by builds #: 23064 and 23065 (older ones ignore it and frame their panes). #: From :data:`PAGE_DRIVEN_VIEWER_BUILD` on, each page picks its own #: (:attr:`Page.viewer_frame_style`) and this is ignored. viewer_style: ViewerStyle | None = None #: The new window's backing color (rgb), or None for the usual. Part #: of the window's *frame*: drawn before any page arrives (behind #: its loading spinner) and kept for the window's whole life, so it #: comes with the link rather than a page. Builds before #: :data:`PAGE_LOOK_BUILD` ignore it. background_color: tuple[float, float, float] | None = None #: The color of the new window's close or back button (rgb), or None #: for the usual. It is the window's *cancel* button (what #: back/escape presses). Frame, as for :attr:`background_color`. cancel_button_color: tuple[float, float, float] | None = None
[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 ) #: With :attr:`close_window`, close back to the nearest earlier #: window (of the same doc-ui controller) showing the page at this #: path, rather than just one window back; for leaving a flow #: several pages deep in one step (a purchase confirmation returning #: to the listing it started from). That window then refreshes, with #: :attr:`return_sets` applied to it if given. If no such window is #: found, the client warns and closes just the one window. Clients #: predating this field close just the one window too. Build it with #: :meth:`bacommon.docui.routes.DocUIRoute.close_to`. close_to: str | None = None #: In a popup window (see :class:`Popup`), with #: :attr:`close_window`: once the popup has closed, run this action #: on the page it was over (its *parent*) as if pressed there -- #: with the parent's state, from the button that opened the popup. #: May be a :class:`Browse`, :class:`Replace` or :class:`Local` #: (one without a ``parent_action`` of its own). Ignored outside a #: popup. Build it with :class:`bacommon.docui.routes.PopupParent`. parent_action: Action | None = None #: In a popup window, with :attr:`close_window`: the state type id #: the parent page's state must have for :attr:`parent_action` to #: run (it sets keys of that type). On a mismatch the client warns #: and just closes the popup. parent_state_id: str | None = None #: In a popup window, with :attr:`close_window`: refresh the parent #: page once the popup has closed (with :attr:`return_sets` applied #: to its state if given). Without this (or ``return_sets``), #: closing a popup leaves its parent alone. refresh_parent: bool = False
[docs] @override @classmethod def get_type_id(cls) -> ActionTypeID: return ActionTypeID.LOCAL
[docs] @ioprepped @dataclass class PopupText(Action): """Pop up a bit of text at the button, with an ok button to close it. For informational text (a small '?' button explaining something, say). The popup is sized to fit the text, which wraps to a client-chosen maximum width. A press of its ok button or anywhere outside it closes it. Like a :class:`Menu`, only a press can open one: a button's, or a menu item's (the popup then comes from the menu's button). One arriving anywhere else an action can go (an input row's ``on_change``, a timed action) is ignored. Clients predating this type see an unknown action. """ text: LangStrSpec | int #: Plays a swish. default_sound: bool = True
[docs] @override @classmethod def get_type_id(cls) -> ActionTypeID: return ActionTypeID.POPUP_TEXT
[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 #: Draw with the big font -- bold display lettering with only A-Z #: (upper and lower case share it), 0-9 and a little punctuation; #: anything else draws as '?'. Clients before build #: :data:`BIG_TEXT_BUILD` draw it with the regular font. big: 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 #: Text drawn right after the depiction (a player's "(ready)" after #: their name, say), sized to its height and fitted into the box #: along with it. suffix: LangStrSpec | int | None = None #: A color imposed on the depiction (None for none). Each kind #: decides what it applies to: a name's text, or whatever parts of #: its capsule the capsule's override targets name; never glyphs #: with colors of their own. The suffix draws in it too. color_override: tuple[float, float, float] | None = None #: Multiplies everything the depiction draws, its suffix and #: loading standins included (rgba, alpha being opacity). Unlike #: :attr:`color_override` it replaces nothing, so it dims or tints #: any kind of depiction predictably. Clients before build 23075 #: ignore it. color_multiply: tuple[float, float, float, float] | None = None
[docs] @override @classmethod def get_type_id(cls) -> DecorationTypeID: return DecorationTypeID.DEPICTION
[docs] class ButtonStyle(Enum): """Styles a button can be. :attr:`Button.style` carries an ``enum_fallback`` (``SQUARE``), so a style added here later draws as a square button on builds predating it instead of failing the whole response. """ 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 #: Where selection lands when navigation enters this button's row #: (from the row above or below); the row's first button if none #: has it. One per row. Unlike :attr:`selected` (where selection #: starts on the whole page), this only matters on arriving at the #: row. A scrolling row starts on it and then remembers whichever #: button was last selected in it, as it does anyway; in a grid it #: applies to its own line. Builds before :data:`TEXT_ROW_BUILD` #: ignore it (selection lands on the first button). row_selected: bool = False #: Drawn greyed out and not activatable (a press plays an error #: sound instead of running ``action``). Still selectable, so #: navigation around it is unaffected. Clients predating this field #: show it as a normal button. disabled: 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 #: A depiction drawn as the button's body, in place of its texture #: or style's look, filling the button's box (its size, at its #: scale). One with a shape of its own is fitted inside the box by #: :attr:`depiction_h_align` / :attr:`depiction_v_align`. The label, #: icon and decorations still draw over it, and it brightens, #: pulses and greys out with the button. To show a depiction #: elsewhere on a button, use a :class:`Depiction` decoration. #: Clients predating this field show the button's usual look. depiction: bacommon.depiction.Depiction | None = None depiction_h_align: HAlign = HAlign.CENTER depiction_v_align: VAlign = VAlign.CENTER #: With a :attr:`depiction`, have mouse and touch land on the #: button only where the depiction actually draws (an icon hugging #: one end of a wide button, a short name in a box sized for long #: ones) rather than anywhere in its box. The client grows that area #: to a minimum size so small depictions stay easy to tap; keyboard #: and controller selection are unaffected. Clients predating this #: field take presses anywhere in the box. depiction_hit_area: bool = False #: Custom widget id. Prefixed with the window id; unique within window. widget_id: str | None = None #: Lets client-effects animate this button (see #: ``bacommon.clienteffect.KeyframeAnimation``); give its #: decorations the same id to have them move with it. anim_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' 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' TITLE_ROW = 'tr' SUBTITLE_ROW = 'sr' FOOTNOTE_ROW = 'fr' HEADER_ROW = 'hdr' FOOTER_ROW = 'ftr' 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 # (One case per row type.) # pylint: disable=too-many-branches 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 t.TITLE_ROW: rowtype = TitleRow case t.SUBTITLE_ROW: rowtype = SubtitleRow case t.FOOTNOTE_ROW: rowtype = FootnoteRow case t.HEADER_ROW: rowtype = HeaderRow case t.FOOTER_ROW: rowtype = FooterRow 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] class ButtonRowLayout(Enum): """How a :class:`ButtonRow` handles the row's width. :attr:`ButtonRow.layout` carries an ``enum_fallback`` (``SCROLL``), so a layout added here later scrolls on builds predating it instead of failing the whole response. """ #: Buttons at their own sizes in a strip that scrolls sideways when #: they don't all fit. The strip clips what it holds (vertically #: too, so a button growing as it's pressed can lose its top and #: bottom edges); its scroll bar fades in over the bottom of its #: padding. SCROLL = 's' #: Buttons at their own sizes, placed straight on the page (so #: nothing about them is clipped), aligned in the row like a #: scrolling row's are. If they don't all fit, they all shrink #: together until they do. FIXED = 'x' #: Buttons sharing the row's full width, spanning exactly what a #: control row's contents do (from where control rows' labels start #: to where their controls end), so the row lines up with control #: rows around it. Each button's width is its share of that, in #: proportion to its own ``size`` width (equal sizes for equal #: buttons); everything else about each button, height included, is #: its own. Like ``FIXED``, placed straight on the page. FILL = 'f' #: Buttons in a grid of equal cells, as many columns as fit across #: the width a ``FILL`` row spans (or :attr:`ButtonRow.grid_columns` #: of them), wrapping onto as many lines as it takes. Cells are as #: big as the row's biggest button (or as #: :attr:`ButtonRow.grid_cell_width` / #: :attr:`ButtonRow.grid_cell_height` say); smaller buttons center #: in theirs and bigger ones shrink to fit. Up/down moves within a #: column. Meant for buttons of about the same size: for very mixed #: ones, use another layout. Builds older than this value show the #: row as ``SCROLL``. Like ``FIXED``, placed straight on the page. GRID = 'g'
[docs] @ioprepped @dataclass class ButtonRow(Row): """A row consisting of buttons. ``title``/``subtitle`` are :class:`~bacommon.langstr.LangStrSpec`. How the buttons use the row's width is up to ``layout``. """ buttons: list[Button] #: How the buttons use the row's width. Builds older than this field #: always scroll. layout: ButtonRowLayout = ButtonRowLayout.SCROLL #: Height of an optional band above the row (above its title too) #: holding free-form, non-interactive decorations; 0 for none. Each #: of the three decoration lists is positioned relative to a point #: on the band's vertical midline -- its left edge (inset like row #: titles), its center, and its right edge. Every row type has one. header_height: float = 0.0 #: Scales the header band's height and everything in it. 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 #: The header band's mirror image -- the same thing, below the row #: (below its footnote too). Every row type has one. footer_height: float = 0.0 #: Scales the footer band's height and everything in it. 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 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 #: 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 #: The footnote's color/flatness/shadow; None for the defaults it #: shares with the subtitle. Independent of the subtitle's own #: overrides. footnote_color: tuple[float, float, float, float] | None = None footnote_flatness: float | None = None footnote_shadow: float | None = None #: Spacing between all buttons in the row (between a ``GRID`` row's #: columns, and its lines unless :attr:`grid_line_spacing` is set). button_spacing: float = 15.0 #: How many columns a ``GRID`` row has; None for as many as fit. If #: that many don't fit, the whole grid shrinks until it does. #: Ignored by other layouts. grid_columns: int | None = ( None ) #: Width of a ``GRID`` row's cells (at the scale buttons' sizes #: are given in, padding included); None for its widest button's. #: Ignored by other layouts. grid_cell_width: float | None = None #: Height of a ``GRID`` row's cells; None for its tallest button's. #: See :attr:`grid_cell_width`. grid_cell_height: float | None = None #: Smallest and largest a ``GRID`` row may scale (everything in it #: at once: cells, buttons and gaps) to span its full width, so its #: outer columns line up with a ``FILL`` row's edges (and the page's #: text); 1.0 for both is no scaling. The grid takes whichever #: column count spans the width at the scale nearest 1.0 within #: these bounds: scaling up to fill the room beside the columns #: that fit, or down to fit one more. If none can, it scales up as #: far as :attr:`grid_max_scale` allows and falls short, aligned by #: :attr:`content_align` (so a width that's not quite reachable #: still gets as close as it can, and scale changes smoothly with #: the width rather than jumping as an edge comes within reach). #: Something like 0.9 and 1.1 lines most grids up without looking #: stretched. A grid too wide to fit at all (one column, or #: :attr:`grid_columns` of them) still shrinks to fit, however far #: that takes it. Ignored by other layouts; builds older than these #: fields ignore them. grid_min_scale: float = 1.0 grid_max_scale: float = 1.0 #: Vertical spacing between a ``GRID`` row's lines; None for the #: same as :attr:`button_spacing`, which is the horizontal spacing #: between its columns. Ignored by other layouts. grid_line_spacing: float | None = None #: Space around the buttons; None for the layout's default (see #: :meth:`get_padding`). Left/right are from the column's edges #: (``SCROLL`` and ``FIXED``) or from where control rows' contents #: start/end (``FILL`` and ``GRID``). padding_left: float | None = None padding_right: float | None = None padding_top: float | None = ( None ) padding_bottom: float | None = None #: Extra space above the whole row (outside its header and title). #: May be negative to pull rows together. Every row type has one. spacing_top: float = 0.0 #: Extra space below the whole row (outside its footnote and #: footer). Every row type has one. spacing_bottom: float = 0.0 #: Extra space between the title/subtitle and the row's content #: (no effect without either). Titles normally hug their content; #: pushing one away makes it read as a heading for a group of rows #: rather than a label for this one. Builds older than this field #: ignore it. Every row type has one. spacing_title: float = 0.0 #: Extra space between the title and the subtitle (no effect #: without both). Builds older than this field ignore it. Every row #: type has one. spacing_subtitle: float = ( 0.0 ) #: Whether the title word-wraps to fit the column. When False it #: stays a single line, shrunk as needed to fit -- the choice for #: short labels that read better whole, and for time-varying text #: (countdowns and such), which can't be wrapped. Builds older than #: this field always wrap. Every row type has one, as does #: :class:`Section`. title_wrap: bool = True #: Whether the subtitle word-wraps; see :attr:`title_wrap`. subtitle_wrap: bool = True #: Whether the footnote word-wraps; see :attr:`title_wrap`. footnote_wrap: bool = True #: Extra space between the row's content and its footnote (no #: effect without one); the footnote's counterpart to #: :attr:`spacing_title`. Builds older than this field ignore it. #: Every row type has one. spacing_footnote: float = ( 0.0 ) center_content: bool = False center_title: bool = False #: Horizontal alignment of buttons when they don't fill the row's #: width (a ``SCROLL`` row with more than that simply scrolls; a #: ``FIXED`` one shrinks to fit; ``FILL`` rows always fill it). A #: ``GRID`` row aligns its grid as a whole; a short last line #: starts at the grid's left so columns stay lined up. #: Overrides ``center_content`` when set. Builds older than this #: field ignore it (and thus left-align unless ``center_content`` is #: also set). content_align: HAlign | None = None #: Shifts a ``FIXED`` row's buttons sideways as a group (positive #: is right). They lay out exactly as they otherwise would -- same #: order, spacing and shrink-to-fit -- and then the whole group #: moves this far, but never past the row's edges (it slides back #: in instead). Centered content thus centers on the row's center #: plus this offset: the same point header and footer decorations #: at that x are placed from, which is the way to line a button up #: with band art regardless of the layout's width or insets. #: ``GRID`` rows shift their grid the same way. Ignored by #: ``SCROLL`` and ``FILL`` rows. Builds older than this field #: ignore it. content_offset: float = 0.0 #: Horizontal alignment of the title and subtitle. Overrides #: ``center_title`` when set; same older-build caveat as #: ``content_align``. title_align: HAlign | None = None #: If things disappear when scrolling left/right, turn this up #: (``SCROLL`` only). simple_culling_h: float = ( 100.0 ) #: Whether the row's horizontal scroll bar is drawn (and grabbable #: by the mouse). Off, the row still scrolls every other way (drag, #: wheel, keys, page buttons) and keeps the same layout; only the #: bar goes. Builds older than this field always show it. #: (``SCROLL`` only.) show_scrollbar: bool = True #: Draw bounds of the row and its button columns. debug: bool = False
[docs] def get_padding(self) -> tuple[float, float, float, float]: """Our (left, right, top, bottom) padding, defaults applied. A scrolling row's scroll bar fades in over a strip below its padding, in the row spacing that follows (see :attr:`Page.row_spacing`); no padding is spent on it. (Builds before 23075 put that strip inside a scrolling row's bottom padding, which defaulted to 16 there.) """ left, right, top, bottom = self._default_padding(self.layout) return ( left if self.padding_left is None else self.padding_left, right if self.padding_right is None else self.padding_right, top if self.padding_top is None else self.padding_top, bottom if self.padding_bottom is None else self.padding_bottom, )
@staticmethod def _default_padding( layout: ButtonRowLayout, ) -> tuple[float, float, float, float]: match layout: # (Side defaults put the outermost buttons where text and # every other row's contents start. Builds before 2026-10-21 # used 10, putting them 5 further out.) case ButtonRowLayout.SCROLL | ButtonRowLayout.FIXED: return 15.0, 15.0, 4.0, 4.0 case ButtonRowLayout.FILL | ButtonRowLayout.GRID: return 0.0, 0.0, 4.0, 4.0 case _: assert_never(layout)
[docs] @override @classmethod def get_type_id(cls) -> RowTypeID: return RowTypeID.BUTTON_ROW
[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 #: The label's color/flatness/shadow; None for the page's label #: style (see :attr:`TextStyles.label`), else the usual. Every #: control row has them; builds before :data:`TEXT_ROW_BUILD` #: ignore them. label_color: tuple[float, float, float, float] | None = None label_flatness: float | None = None label_shadow: float | 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 #: The footnote's color/flatness/shadow; None for the defaults it #: shares with the subtitle. Independent of the subtitle's own #: overrides. footnote_color: tuple[float, float, float, float] | None = None footnote_flatness: float | None = None footnote_shadow: float | 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_subtitle`. spacing_subtitle: float = ( 0.0 ) #: See :attr:`ButtonRow.title_wrap`. title_wrap: bool = True #: See :attr:`ButtonRow.subtitle_wrap`. subtitle_wrap: bool = True #: See :attr:`ButtonRow.footnote_wrap`. footnote_wrap: bool = True #: 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 #: See :attr:`CheckboxRow.label_color`. label_color: tuple[float, float, float, float] | None = None #: See :attr:`CheckboxRow.label_color`. label_flatness: float | None = None #: See :attr:`CheckboxRow.label_color`. label_shadow: float | 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 #: The footnote's color/flatness/shadow; None for the defaults it #: shares with the subtitle. Independent of the subtitle's own #: overrides. footnote_color: tuple[float, float, float, float] | None = None footnote_flatness: float | None = None footnote_shadow: float | 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_subtitle`. spacing_subtitle: float = ( 0.0 ) #: See :attr:`ButtonRow.title_wrap`. title_wrap: bool = True #: See :attr:`ButtonRow.subtitle_wrap`. subtitle_wrap: bool = True #: See :attr:`ButtonRow.footnote_wrap`. footnote_wrap: bool = True #: 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 #: See :attr:`CheckboxRow.label_color`. label_color: tuple[float, float, float, float] | None = None #: See :attr:`CheckboxRow.label_color`. label_flatness: float | None = None #: See :attr:`CheckboxRow.label_color`. label_shadow: float | 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 #: The footnote's color/flatness/shadow; None for the defaults it #: shares with the subtitle. Independent of the subtitle's own #: overrides. footnote_color: tuple[float, float, float, float] | None = None footnote_flatness: float | None = None footnote_shadow: float | 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_subtitle`. spacing_subtitle: float = ( 0.0 ) #: See :attr:`ButtonRow.title_wrap`. title_wrap: bool = True #: See :attr:`ButtonRow.subtitle_wrap`. subtitle_wrap: bool = True #: See :attr:`ButtonRow.footnote_wrap`. footnote_wrap: bool = True #: 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 #: See :attr:`CheckboxRow.label_color`. label_color: tuple[float, float, float, float] | None = None #: See :attr:`CheckboxRow.label_color`. label_flatness: float | None = None #: See :attr:`CheckboxRow.label_color`. label_shadow: float | 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 #: The footnote's color/flatness/shadow; None for the defaults it #: shares with the subtitle. Independent of the subtitle's own #: overrides. footnote_color: tuple[float, float, float, float] | None = None footnote_flatness: float | None = None footnote_shadow: float | 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_subtitle`. spacing_subtitle: float = ( 0.0 ) #: See :attr:`ButtonRow.title_wrap`. title_wrap: bool = True #: See :attr:`ButtonRow.subtitle_wrap`. subtitle_wrap: bool = True #: See :attr:`ButtonRow.footnote_wrap`. footnote_wrap: bool = True #: 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 #: See :attr:`CheckboxRow.label_color`. label_color: tuple[float, float, float, float] | None = None #: See :attr:`CheckboxRow.label_color`. label_flatness: float | None = None #: See :attr:`CheckboxRow.label_color`. label_shadow: float | None = None #: 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 #: The footnote's color/flatness/shadow; None for the defaults it #: shares with the subtitle. Independent of the subtitle's own #: overrides. footnote_color: tuple[float, float, float, float] | None = None footnote_flatness: float | None = None footnote_shadow: float | 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_subtitle`. spacing_subtitle: float = ( 0.0 ) #: See :attr:`ButtonRow.title_wrap`. title_wrap: bool = True #: See :attr:`ButtonRow.subtitle_wrap`. subtitle_wrap: bool = True #: See :attr:`ButtonRow.footnote_wrap`. footnote_wrap: bool = True #: 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 #: See :attr:`CheckboxRow.label_color`. label_color: tuple[float, float, float, float] | None = None #: See :attr:`CheckboxRow.label_color`. label_flatness: float | None = None #: See :attr:`CheckboxRow.label_color`. label_shadow: float | None = None #: 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 #: The footnote's color/flatness/shadow; None for the defaults it #: shares with the subtitle. Independent of the subtitle's own #: overrides. footnote_color: tuple[float, float, float, float] | None = None footnote_flatness: float | None = None footnote_shadow: float | 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_subtitle`. spacing_subtitle: float = ( 0.0 ) #: See :attr:`ButtonRow.title_wrap`. title_wrap: bool = True #: See :attr:`ButtonRow.subtitle_wrap`. subtitle_wrap: bool = True #: See :attr:`ButtonRow.footnote_wrap`. footnote_wrap: bool = True #: 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 ButtonControlRow(Row): """A control row whose control is a single button. Label at the left edge of the row; ``button`` at the right, drawn as it would be in a :class:`ButtonRow`. Laid out like the other control rows, so it sits naturally in a column of them, but unlike them it holds no value: the button shows whatever the page draws on it (an icon or preview of a current selection, say) and does whatever its action says (browse to a page for picking a new one, say). The button can be any size; the row grows to fit it, its label stays centered beside it (squished to fit the space the button leaves), and its title and footnote keep clear of it. """ #: The button. Its ``default`` and ``selected`` flags work as they #: do in a :class:`ButtonRow`. button: Button label: LangStrSpec | int | None = None #: See :attr:`CheckboxRow.label_color`. label_color: tuple[float, float, float, float] | None = None #: See :attr:`CheckboxRow.label_color`. label_flatness: float | None = None #: See :attr:`CheckboxRow.label_color`. label_shadow: float | None = None #: The whole row shown dimmed, with its button disabled (as by #: :attr:`Button.disabled`, which on its own leaves the label #: alone). 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 #: The footnote's color/flatness/shadow; None for the defaults it #: shares with the subtitle. Independent of the subtitle's own #: overrides. footnote_color: tuple[float, float, float, float] | None = None footnote_flatness: float | None = None footnote_shadow: float | 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_subtitle`. spacing_subtitle: float = ( 0.0 ) #: See :attr:`ButtonRow.title_wrap`. title_wrap: bool = True #: See :attr:`ButtonRow.subtitle_wrap`. subtitle_wrap: bool = True #: See :attr:`ButtonRow.footnote_wrap`. footnote_wrap: bool = True #: 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.BUTTON_CONTROL_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), kept ``margin`` in from the column's edges (by default the page's side inset), 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, fill and grid rows there too (lining up with the buttons' bounds), and centered things centered on the card. (Builds before 2026-10-21 put the text and those rows 5 units further in.) 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 #: (less :attr:`margin`). max_width: float | None = ( None ) #: How far in from the column's edges the card's sides keep (both #: sides); None for the page's :attr:`Page.h_inset`, which keeps #: them clear of a fading scroll area's side fades (those are that #: wide). 0 runs the card edge to edge, as builds before #: :data:`TEXT_ROW_BUILD` always do. With :attr:`max_width` the card #: is the narrower of the two. A texture's soft margins (outside its #: pins) still spill past the rect, into any fade. margin: float | None = None #: How far in from the card's edges its button rows' buttons sit #: (both sides; a page's sit 33 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
# (Abstract: its subclasses supply get_type_id(), and their fields; # dataclasses don't inherit fields here.)
[docs] class TextRow(Row): # pylint: disable=abstract-method """Base for a row of text standing on its own between rows. See :class:`TitleRow`, :class:`SubtitleRow` and :class:`FootnoteRow` (:data:`AnyTextRow`). They draw exactly like the title, subtitle and footnote a row can carry itself (its ``title``, ``subtitle`` and ``footnote``), and replace those: with ``attach`` on (the default), a title and/or subtitle directly before a row and a footnote directly after it attach to that row and lay out just as its own would. That means they hug it, tuck in beside a control row's control, and come into view whenever it is selected. A row that already has its own title (or subtitle, or footnote) takes no attached one; that text stands alone instead. A text row that doesn't attach (``attach`` off, or nothing there to attach to) stands alone, spaced from both neighbors like a row, and still comes into view with the next selectable row (a footnote, with the previous one). Attaching only works in drawing order -- header, title, subtitle, the row, footnote, footer -- so one out of order stands alone too; the client logs a warning for any with ``attach`` on that couldn't. Builds before :data:`TEXT_ROW_BUILD` skip these rows entirely, so don't rely on them for pages such builds may show. """
[docs] class TitleSize(Enum): """How big a :class:`TitleRow` draws.""" #: A row title's size. NORMAL = 'n' #: A heading for a group of rows, the larger size of a section's #: heading (see :attr:`TitleRow.size`). HEADING = 'h'
[docs] @ioprepped @dataclass class TitleRow(TextRow): """A title on its own row; see :class:`TextRow`.""" text: LangStrSpec | int #: How big it draws. A ``HEADING`` heads the rows after it rather #: than labeling one: it never becomes a row's own title. With #: ``attach`` on, a :class:`HeaderRow` directly before it and a #: :class:`SubtitleRow` directly after join it as one heading, #: which sits close above the next row and comes into view with it #: (as a :class:`Section`'s does). size: TitleSize = TitleSize.NORMAL #: Whether to attach to the row this text sits beside (see above): #: the row after it for a title or subtitle, the one before it for #: a footnote. attach: bool = True #: Color/flatness/shadow; None for the defaults (those of a row's #: own title, subtitle or footnote). color: tuple[float, float, float, float] | None = None flatness: float | None = None shadow: float | None = None #: Whether the text word-wraps; see :attr:`ButtonRow.title_wrap`. wrap: bool = True #: Horizontal alignment; None for that of the row it attaches to #: (left when standing alone). Texts attached to one row share an #: alignment: the first one set among them. align: HAlign | None = None #: Extra space above and below; may be negative. Between texts #: attached to the same row, or between such a text and its row, #: these widen (or narrow) that gap. spacing_top: float = 0.0 spacing_bottom: float = 0.0 #: Draw debug bounds. debug: bool = False
[docs] @override @classmethod def get_type_id(cls) -> RowTypeID: return RowTypeID.TITLE_ROW
[docs] @ioprepped @dataclass class SubtitleRow(TextRow): """A subtitle on its own row; see :class:`TextRow`. Attached, it sits under its row's title, or alone above the row. """ text: LangStrSpec | int #: See :attr:`TitleRow.attach`. attach: bool = True #: See :attr:`TitleRow.color`. color: tuple[float, float, float, float] | None = None #: See :attr:`TitleRow.flatness`. flatness: float | None = None #: See :attr:`TitleRow.shadow`. shadow: float | None = None #: See :attr:`TitleRow.wrap`. wrap: bool = True #: See :attr:`TitleRow.align`. align: HAlign | None = None #: See :attr:`TitleRow.spacing_top`. spacing_top: float = 0.0 #: See :attr:`TitleRow.spacing_bottom`. spacing_bottom: float = 0.0 #: See :attr:`TitleRow.debug`. debug: bool = False
[docs] @override @classmethod def get_type_id(cls) -> RowTypeID: return RowTypeID.SUBTITLE_ROW
[docs] @ioprepped @dataclass class FootnoteRow(TextRow): """A footnote on its own row; see :class:`TextRow`. Attached, it sits under the row before it. """ text: LangStrSpec | int #: See :attr:`TitleRow.attach`. attach: bool = True #: See :attr:`TitleRow.color`. color: tuple[float, float, float, float] | None = None #: See :attr:`TitleRow.flatness`. flatness: float | None = None #: See :attr:`TitleRow.shadow`. shadow: float | None = None #: See :attr:`TitleRow.wrap`. wrap: bool = True #: See :attr:`TitleRow.align`. align: HAlign | None = None #: See :attr:`TitleRow.spacing_top`. spacing_top: float = 0.0 #: See :attr:`TitleRow.spacing_bottom`. spacing_bottom: float = 0.0 #: See :attr:`TitleRow.debug`. debug: bool = False
[docs] @override @classmethod def get_type_id(cls) -> RowTypeID: return RowTypeID.FOOTNOTE_ROW
#: The standalone text rows (see :class:`TextRow`). type AnyTextRow = TitleRow | SubtitleRow | FootnoteRow # (Abstract: its subclasses supply get_type_id(), and their fields; # dataclasses don't inherit fields here.)
[docs] class BandRow(Row): # pylint: disable=abstract-method """Base for a band of decorations standing on its own between rows. See :class:`HeaderRow` and :class:`FooterRow` (:data:`AnyBandRow`). They draw exactly like the header and footer bands a row can carry itself (its ``header_*`` and ``footer_*`` fields) and replace those: with ``attach`` on (the default), a header directly above a row (and above any title and subtitle attached to it) and a footer directly below one (and below any attached footnote) attach to that row and lay out just as its own would. A row that already has a band there takes no attached one; that band stands alone instead. An attached band's ``spacing_bottom`` (header) or ``spacing_top`` (footer), the side facing its row, doesn't apply. A band that doesn't attach (``attach`` off, or nothing there to attach to) stands alone between rows, taking no room of its own beyond its height: its neighbors' spacing decides how far off they sit. It comes into view with the next selectable row (a footer, with the previous one). As with text rows, one with ``attach`` on that couldn't (out of order, say) stands alone and the client logs a warning. Builds before :data:`TEXT_ROW_BUILD` skip these rows entirely, so don't rely on them for pages such builds may show. """
[docs] @ioprepped @dataclass class HeaderRow(BandRow): """A header band on its own row; see :class:`BandRow`.""" #: The band's height (before :attr:`scale`). height: float #: Scales the band's height and everything in it. scale: float = 1.0 #: Decorations positioned relative to points on the band's #: vertical midline: its left edge (inset like row titles), its #: center and its right edge; as for #: :attr:`ButtonRow.header_decorations_left` and friends. decorations_left: list[Decoration] | None = None decorations_center: list[Decoration] | None = None decorations_right: list[Decoration] | None = None #: Whether to attach to the row below (see :class:`BandRow`). attach: bool = True #: Extra space above and below; may be negative. spacing_top: float = 0.0 spacing_bottom: float = 0.0 #: Draw debug bounds. debug: bool = False
[docs] @override @classmethod def get_type_id(cls) -> RowTypeID: return RowTypeID.HEADER_ROW
[docs] @ioprepped @dataclass class FooterRow(BandRow): """A footer band on its own row; see :class:`BandRow`.""" #: See :attr:`HeaderRow.height`. height: float #: See :attr:`HeaderRow.scale`. scale: float = 1.0 #: See :attr:`HeaderRow.decorations_left`. decorations_left: list[Decoration] | None = None #: See :attr:`HeaderRow.decorations_left`. decorations_center: list[Decoration] | None = None #: See :attr:`HeaderRow.decorations_left`. decorations_right: list[Decoration] | None = None #: Whether to attach to the row above (see :class:`BandRow`). attach: bool = True #: See :attr:`HeaderRow.spacing_top`. spacing_top: float = 0.0 #: See :attr:`HeaderRow.spacing_top`. spacing_bottom: float = 0.0 #: See :attr:`HeaderRow.debug`. debug: bool = False
[docs] @override @classmethod def get_type_id(cls) -> RowTypeID: return RowTypeID.FOOTER_ROW
#: The standalone band rows (see :class:`BandRow`). type AnyBandRow = HeaderRow | FooterRow
[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 and note are text and band rows (see :class:`TextRow`) at the start and end of :attr:`rows`: a ``HEADING``-size :class:`TitleRow` (see :attr:`TitleRow.size`), with any :class:`HeaderRow` before it and :class:`SubtitleRow` after it joining it, and a :class:`FootnoteRow` (``attach`` off, so it notes the whole group rather than one row) and :class:`FooterRow` at the end. Neither is selectable: selecting the section's first selectable row scrolls its heading into view too, and its last its note. The section's own heading and note fields (header band, ``title``, ``subtitle``, ``footnote``, footer band, and their looks) are the older way to say the same thing, for builds before :data:`TEXT_ROW_BUILD`, which skip text rows. Newer builds lay them out exactly as those rows placed at the start and end of :attr:`rows` (``spacing_title``, ``spacing_subtitle`` and ``spacing_footnote`` becoming those rows' spacing). They go away with api 9; use the rows for pages only newer builds show. 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.title_wrap`. title_wrap: bool = True #: See :attr:`ButtonRow.subtitle_wrap`. subtitle_wrap: bool = True #: See :attr:`ButtonRow.footnote_wrap`. footnote_wrap: bool = True #: Extra space between the heading and the section's first row (the #: heading's last text's ``spacing_bottom``), between the title and #: subtitle (the subtitle's ``spacing_top``), and between the #: section's last row and its footnote (the footnote's #: ``spacing_top``). Builds before :data:`TEXT_ROW_BUILD` ignore #: these. spacing_title: float = 0.0 spacing_subtitle: float = ( 0.0 ) spacing_footnote: float = ( 0.0 ) #: 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_input_row( row: Row, ) -> TypeIs[AnyInputRow]: """Is this row one of the input-row types (:data:`AnyInputRow`)? Everything concerned with the values rows hold keys off this rather than listing them; it narrows the type in both branches. """ return isinstance(row, _INPUT_ROW_TYPES)
[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 TextStyle: """How a kind of text looks by default on a page. Each value None for the usual. An element's own value (a row's ``title_color``, say) wins over these. """ #: Color (rgba). color: tuple[float, float, float, float] | None = None #: How flat it draws (0 fully shaded, 1 flat). flatness: float | None = None #: Shadow strength. shadow: float | None = None
[docs] @ioprepped @dataclass class TextStyles: """A page's default text looks, by kind; see :class:`TextStyle`. Each None for the usual. Builds before :data:`TEXT_ROW_BUILD` ignore these. """ #: Row titles and :class:`TitleRow` (and section titles, which are #: drawn larger but otherwise alike). title: TextStyle | None = None #: Row subtitles and :class:`SubtitleRow` (and section subtitles). subtitle: TextStyle | None = ( None ) #: Row footnotes and :class:`FootnoteRow` (and section notes). footnote: TextStyle | None = ( None ) #: Control rows' labels. label: TextStyle | None = None #: Button labels (their color and flatness; ``shadow`` doesn't apply #: to buttons). button: TextStyle | None = ( None )
[docs] @ioprepped @dataclass class Spacing: """How much room a page leaves between its rows, by kind. Each value is the room a kind of row wants beside it; between two rows the larger of the two sides' values applies (they don't add up). A row's top edge is its title if it has one (else its subtitle, else the row itself), and its bottom edge its footnote if it has one. Rows' own ``spacing_top`` and ``spacing_bottom`` add on top. Nothing is left above a page's first row or below its last (or inside a card's padding). None for each kind's default, or for :attr:`Page.row_spacing` everywhere when the page sets that. Builds before :data:`TEXT_ROW_BUILD` ignore this and space every row by :attr:`Page.row_spacing`. """ #: Above a row led by a title, and beside a standalone #: :class:`TitleRow` (above an attached ``HEADING`` one only: it #: keeps its own small gap to the row it heads); None for #: :data:`DEFAULT_TITLE_SPACING`. title: float | None = None #: Above a row led by a subtitle (with no title), and beside a #: standalone :class:`SubtitleRow`; None for #: :data:`DEFAULT_SUBTITLE_SPACING`. subtitle: float | None = None #: Below a row ending in a footnote, and beside a standalone #: :class:`FootnoteRow`; None for :data:`DEFAULT_FOOTNOTE_SPACING`. footnote: float | None = None #: Beside a :class:`ButtonRow` (any layout); None for #: :data:`DEFAULT_BUTTON_ROW_SPACING`. A scrolling row's scroll bar #: (when it shows) fades in over the 10 below it, which at the #: default ends right where the next row begins; a page with #: visibly scrolling rows may want a little more. button_row: float | None = ( None ) #: Beside a control row (checkbox, text input, choice, color, #: slider, number, button control); None for #: :data:`DEFAULT_CONTROL_ROW_SPACING`. control_row: float | None = ( None ) #: Between a :class:`Section` and its neighbors (the larger of this #: and what the neighbor wants, as between rows; a section also #: stands a little further apart from what precedes it). Inside, a #: section's heading sits close above its first row and a card's #: padding is all there is at its ends. None for #: :data:`DEFAULT_SECTION_SPACING`. section: float | None = None
[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 a viewer #: layout (:attr:`WindowLayout.VIEWER` or #: :attr:`WindowLayout.VIEWER_NARROW`; 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 there is no pane: the page's rows get the window's #: whole width, so lay them out for that (a confirmation centered in #: the window, say). For an empty pane, send a #: :class:`bacommon.depiction.BlankDepiction` (see #: :func:`blank_viewer`). Builds before #: :data:`PAGE_DRIVEN_VIEWER_BUILD` show a blank pane either way, #: with the rows in their usual column beside it. viewer: bacommon.depiction.Depiction | None = None #: Whether the viewer pane (if any) is framed. Ignored by builds #: before :data:`PAGE_DRIVEN_VIEWER_BUILD` (their panes are framed). viewer_frame_style: ViewerFrameStyle = ViewerFrameStyle.DEFAULT #: What shows behind the viewer pane's content (rgba; any alpha #: lets the window show through), framed or not. None for the frame #: style's usual one: blending into the window when framed, clear #: when not. Viewers able to keep transparency (a mesh viewer) draw #: over it; others (a character's scene) cover it. Ignored by builds #: before :data:`PAGE_DRIVEN_VIEWER_BUILD`. viewer_background_color: tuple[float, float, float, float] | None = None #: Which side the viewer pane (if any) sits on. Ignored by builds #: before :data:`PAGE_DRIVEN_VIEWER_BUILD` (their panes are on the #: left). viewer_side: ViewerSide = ViewerSide.LEFT #: 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 #: Empty space between every pair of rows alike, replacing the #: per-kind defaults of :attr:`spacing` (whose own values still #: win); None for those defaults. A scrolling button row's scroll #: bar fades in over the 12 of it below that row, so less than that #: lets the bar reach the next row. Builds before #: :data:`TEXT_ROW_BUILD` use it (or :data:`DEFAULT_ROW_SPACING`) #: everywhere. (Before :data:`PAGE_LOOK_BUILD` this was always a #: number, 10 by default, and an omitted value still means 10 to #: those builds; they also put the bar's strip inside the row #: instead, so button rows sit the same distance apart on both.) row_spacing: float | None = ( None ) #: Room between rows by kind; None for the defaults (see #: :class:`Spacing`). spacing: Spacing | None = ( None ) #: Default text looks by kind; None for the usual (see #: :class:`TextStyles`). text: TextStyles | None = ( None ) #: How far content sits in from the sides of the page's column (the #: horizontal inset): text, control rows, and button rows' outermost #: buttons (at their default padding). None for #: :data:`DEFAULT_H_INSET`. A fading scroll area's side fades shrink #: to match, so content resting nearer the edge isn't faded. Builds #: before :data:`PAGE_LOOK_BUILD` ignore it. h_inset: float | None = None #: Empty space above the page's first row and below its last (the #: vertical inset); None for :data:`DEFAULT_V_INSET`. A fading #: scroll area's top and bottom fades shrink with it (to none past #: the scroll area's own edge at 0), so content at rest isn't #: faded; scrolled content still fades out beyond that edge. Small #: enough and the top and bottom rows' button glow and selection #: highlight get clipped. #: Clearance for toolbars at small ui-scale is added on top. #: Selecting a row scrolls what it must show at least this far in #: from the scroll area's edges too, so it is never faded either. #: Negative pulls content in toward the edges. Builds before #: :data:`PAGE_LOOK_BUILD` ignore it (and use 27). v_inset: float | None = None #: The color of the page's buttons that have no color of their own #: (rgb), except its default button (see #: :attr:`default_button_color`); None for the usual. Builds before #: :data:`PAGE_LOOK_BUILD` ignore it. button_color: tuple[float, float, float] | None = None #: The color of the page's default button (rgb; see #: :attr:`Button.default`) if it has none of its own; None for the #: usual. Builds before :data:`PAGE_LOOK_BUILD` ignore it. default_button_color: tuple[float, float, float] | None = None #: If things disappear when scrolling up/down, turn this up. simple_culling_v: float = ( 100.0 )
# Retired wire keys; never reuse them here: 'pb', 'pl', 'pt', 'pr' # (page padding, replaced by v_inset and h_inset; older servers # may still send them, and clients ignore them as unknown). #: The first client build whose viewer panes follow their page. A page #: without a viewer gets no pane (and its rows the whole window), and #: :attr:`Page.viewer_frame_style`, :attr:`Page.viewer_background_color`, #: :attr:`Page.viewer_side` and #: :class:`bacommon.depiction.BlankDepiction` are understood. Lay out a #: viewer-less page for the narrow column beside a blank pane for older #: builds. PAGE_DRIVEN_VIEWER_BUILD = 23066 #: The first client build that shows :class:`Popup` actions' popups #: (older ones see an unknown action). Gate any page offering a popup #: on this; see :func:`supports_popups`. POPUP_BUILD = 23073 #: The first client build that draws :attr:`Text.big` text with the big #: font (older ones draw it with the regular font). BIG_TEXT_BUILD = 23073 #: The first client build that understands the page-look fields #: (:attr:`Page.h_inset`, :attr:`Page.v_inset`, #: :attr:`Page.button_color`, :attr:`Page.default_button_color`, and #: the frame colors on :class:`Browse` and :class:`Popup`), gives #: popups a green frame, and #: puts a scrolling button row's scroll-bar strip in the row spacing #: below it. Older builds ignore those fields. PAGE_LOOK_BUILD = 23075 #: The first client build that draws standalone text and band rows #: (:class:`TitleRow`, :class:`SubtitleRow`, :class:`FootnoteRow`, #: :class:`HeaderRow`, :class:`FooterRow`) and spaces rows by #: :attr:`Page.spacing`. Older builds skip those rows entirely and #: space rows evenly by :attr:`Page.row_spacing`. TEXT_ROW_BUILD = 23075 #: Space between a page's rows unless it sets #: :attr:`Page.row_spacing`, for builds before :data:`TEXT_ROW_BUILD` #: (later ones space rows by kind; see :class:`Spacing`). DEFAULT_ROW_SPACING = 22.0 #: Default :attr:`Spacing.title`. DEFAULT_TITLE_SPACING = 22.0 #: Default :attr:`Spacing.subtitle`. DEFAULT_SUBTITLE_SPACING = 10.0 #: Default :attr:`Spacing.footnote`. DEFAULT_FOOTNOTE_SPACING = 10.0 #: Default :attr:`Spacing.button_row`. DEFAULT_BUTTON_ROW_SPACING = 10.0 #: Default :attr:`Spacing.control_row`. DEFAULT_CONTROL_ROW_SPACING = 10.0 #: Default :attr:`Spacing.section`. DEFAULT_SECTION_SPACING = 10.0 #: How far a page's content sits in from its column's sides unless it #: sets :attr:`Page.h_inset`. DEFAULT_H_INSET = 33.0 #: Space above a page's first row and below its last unless it sets #: :attr:`Page.v_inset`. DEFAULT_V_INSET = 27.0 #: A popup's frame color (rgb) unless its :class:`Popup` says #: otherwise, after the classic popups' green. Part of the popup look. POPUP_BACKGROUND_COLOR = (0.45, 0.63, 0.15) #: A :attr:`Page.button_color` that suits #: :data:`POPUP_BACKGROUND_COLOR`; part of the popup look. Popup pages #: wanting the look set this and the other ``POPUP_*`` page values #: themselves. POPUP_BUTTON_COLOR = (0.52, 0.72, 0.22) #: A :attr:`Page.h_inset` tighter than the usual, so a popup of links #: reads as a menu; part of the popup look. POPUP_H_INSET = 20.0 #: A :attr:`Page.row_spacing` tighter than the usual (room for a #: scrolling button row's bar plus 4); part of the popup look. POPUP_ROW_SPACING = 16.0 #: Smallest content size (width, height) a :class:`Popup` may ask for. #: There's no largest: each client shrinks a popup that wouldn't fit on #: its screen (see :attr:`Popup.size`). POPUP_MIN_SIZE = (200.0, 100.0) # What window layouts promise pages. Each layout gives a page a # *content rect* to lay out in, at least this big at every ui-scale on # every screen (windows that fill the screen or follow its size get # more on bigger ones). A page's rows sit within it, in from its sides # by the page's :attr:`Page.h_inset` and from its top and bottom by its # :attr:`Page.v_inset`; so a row filling a small layout's height, say, # can be ``SMALL_CONTENT_HEIGHT - 2 * v_inset`` tall without scrolling. # (The scroll area may reach out past the content rect -- under a title # or toolbars, to screen edges -- but content is never placed there.) #: Content-rect width of :attr:`WindowLayout.SMALL`, #: :attr:`WindowLayout.SMALL_TALL` and :attr:`WindowLayout.SMALL_TALLER` #: pages, and of :attr:`WindowLayout.VIEWER` pages beside their viewer #: pane. Exactly this at every ui-scale. SMALL_CONTENT_WIDTH = 600.0 #: Content-rect height of :attr:`WindowLayout.SMALL` pages (at least; #: more at small ui-scale, where windows fill the screen). Every other #: layout gives at least :data:`WIDE_CONTENT_HEIGHT`. SMALL_CONTENT_HEIGHT = 306.8 #: Content-rect width of :attr:`WindowLayout.VIEWER_NARROW` pages beside #: their viewer pane. Exactly this at every ui-scale. NARROW_CONTENT_WIDTH = 400.0 #: Content-rect width of :attr:`WindowLayout.WIDE`, #: :attr:`WindowLayout.WIDE_TALL` and :attr:`WindowLayout.WIDE_TALLER` #: pages, exactly, at every ui-scale (about 882.76); also the least #: :attr:`WindowLayout.WIDER` and :attr:`WindowLayout.LARGE` give. It is #: the narrowest screen's width at small ui-scale (where windows fill #: the screen), so wide pages can be designed to fill it exactly. WIDE_CONTENT_WIDTH = 1280.0 / 1.45 #: Content-rect height of :attr:`WindowLayout.WIDE` and #: :attr:`WindowLayout.WIDER` pages, exactly, at every ui-scale (about #: 357.55; more at small ui-scale on taller screens); also the least #: every layout but :attr:`WindowLayout.SMALL` gives. It is what the #: shortest screen leaves at small ui-scale, clear of the toolbars. WIDE_CONTENT_HEIGHT = 720.0 / 1.45 + 1.0 - 140.0 #: Content-rect height of :attr:`WindowLayout.SMALL_TALL` and #: :attr:`WindowLayout.WIDE_TALL` pages at medium/large ui-scale, #: exactly; :attr:`WindowLayout.SMALL_TALLER` and #: :attr:`WindowLayout.WIDE_TALLER` give at least this there, more on #: taller screens. (At small ui-scale, where windows fill the screen, #: all of these are the same as their base layouts: at least #: :data:`WIDE_CONTENT_HEIGHT`.) A page taller than this scrolls in a #: tall layout at medium/large ui-scale; one that is often taller is a #: candidate for a taller layout. TALL_CONTENT_HEIGHT = 556.28
[docs] def supports_popups(engine_build_number: int) -> bool: """Whether a client build can show :class:`Popup` popups.""" return engine_build_number >= POPUP_BUILD
[docs] def blank_viewer( engine_build_number: int, ) -> bacommon.depiction.Depiction | None: """An empty viewer pane for a page, for a client build. A :class:`bacommon.depiction.BlankDepiction` (the pane then shows just its background; see :attr:`Page.viewer_background_color`), or None for builds before :data:`PAGE_DRIVEN_VIEWER_BUILD`, which don't know that kind but show no viewer as a blank pane anyway. """ if engine_build_number < PAGE_DRIVEN_VIEWER_BUILD: return None return bacommon.depiction.BlankDepiction()
[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