Source code for bacommon.decui.v1

# Released under the MIT License. See LICENSE for details.
#
"""Full UIs defined in the cloud - similar to a basic form of html"""

from __future__ import annotations

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

from efro.dataclassio import ioprepped, IOAttrs, IOMultiType

import bacommon.displayitem as ditm
import bacommon.clienteffect as clfx
from bacommon.decui._decui import (
    DecUIRequest,
    DecUIRequestTypeID,
    DecUIResponse,
    DecUIResponseTypeID,
)


[docs] class RequestMethod(Enum): """Typeof of requests that can be made to dec-ui servers.""" #: An unknown request method. This can appear if a newer client is #: requesting some method from an older server that is not known to #: the server. UNKNOWN = 'u' #: Fetch some resource. This can be retried and its results can #: optionally be cached for some amount of time. GET = 'g' #: Change some resource. This cannot be implicitly retried (at least #: without deduplication), nor can it be cached. POST = 'p'
[docs] @ioprepped @dataclass class Request(DecUIRequest): """Full request to dec-ui.""" path: str method: RequestMethod = RequestMethod.GET args: dict = field( default_factory=dict )
[docs] @override @classmethod def get_type_id(cls) -> DecUIRequestTypeID: return DecUIRequestTypeID.V1
[docs] class ActionTypeID(Enum): """Type ID for each of our subclasses.""" BROWSE = 'b' REPLACE = 'r' LOCAL = 'l' UNKNOWN = 'u'
[docs] class Action(IOMultiType[ActionTypeID]): """Top level class for our multitype."""
[docs] @override @classmethod def get_type_id(cls) -> ActionTypeID: # Require child classes to supply this themselves. If we did a # full type registry/lookup here it would require us to import # everything and would prevent lazy loading. raise NotImplementedError()
[docs] @override @classmethod def get_type(cls, type_id: ActionTypeID) -> type[Action]: """Return the subclass for each of our type-ids.""" # pylint: disable=cyclic-import t = ActionTypeID if type_id is t.BROWSE: return Browse if type_id is t.REPLACE: return Replace if type_id is t.LOCAL: return Local if type_id is t.UNKNOWN: return UnknownAction # Important to make sure we provide all types. 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: # If we encounter some future type we don't know anything about, # drop in a placeholder. return UnknownAction()
[docs] @ioprepped @dataclass class UnknownAction(Action): """Action type we don't recognize."""
[docs] @override @classmethod def get_type_id(cls) -> ActionTypeID: return ActionTypeID.UNKNOWN
[docs] @ioprepped @dataclass class Browse(Action): """Browse to a new page in a new window.""" request: Request #: Plays a swish. default_sound: bool = True #: Client-effects to run immediately when the button is pressed. #: #: :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.decui.DecUIController.local_action()`. immediate_local_action: str | None = None immediate_local_action_args: dict | None = None
[docs] @override @classmethod def get_type_id(cls) -> ActionTypeID: return ActionTypeID.BROWSE
[docs] @ioprepped @dataclass class Replace(Action): """Replace current page with a new one. Should be used to effectively 'modify' existing UIs by replacing them with something slightly different. Things like scroll position and selection will be carried across to the new layout when possible to make for a seamless transition. """ request: Request #: Plays a click if triggered by a button press. default_sound: bool = True #: Client-effects to run immediately when the button is pressed. #: #: :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.decui.DecUIController.local_action()`. immediate_local_action: str | None = None immediate_local_action_args: dict | None = None
[docs] @override @classmethod def get_type_id(cls) -> ActionTypeID: return ActionTypeID.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 or a click if triggered by a #: button press. default_sound: bool = True #: Client-effects to run immediately when the button is pressed. #: #: :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.decui.DecUIController.local_action()`. immediate_local_action: str | None = None immediate_local_action_args: dict | None = None
[docs] @override @classmethod def get_type_id(cls) -> ActionTypeID: return ActionTypeID.LOCAL
[docs] class HAlign(Enum): """Horizontal alignment.""" LEFT = 'l' CENTER = 'c' RIGHT = 'r'
[docs] class VAlign(Enum): """Vertical alignment.""" TOP = 't' CENTER = 'c' BOTTOM = 'b'
[docs] class DecorationTypeID(Enum): """Type ID for each of our subclasses.""" UNKNOWN = 'u' TEXT = 't' IMAGE = 'i' DISPLAY_ITEM = 'd'
[docs] class Decoration(IOMultiType[DecorationTypeID]): """Top level class for our multitype."""
[docs] @override @classmethod def get_type_id(cls) -> DecorationTypeID: # Require child classes to supply this themselves. If we did a # full type registry/lookup here it would require us to import # everything and would prevent lazy loading. raise NotImplementedError()
[docs] @override @classmethod def get_type(cls, type_id: DecorationTypeID) -> type[Decoration]: # pylint: disable=cyclic-import t = DecorationTypeID if type_id is t.UNKNOWN: return UnknownDecoration if type_id is t.TEXT: return Text if type_id is t.IMAGE: return Image if type_id is t.DISPLAY_ITEM: return DisplayItem # Important to make sure we provide all types. assert_never(type_id)
[docs] @override @classmethod def get_unknown_type_fallback(cls) -> Decoration: # If we encounter some future type we don't know anything about, # drop in a placeholder. return UnknownDecoration()
[docs] @override @classmethod def get_type_id_storage_name(cls) -> str: return '_t'
[docs] @ioprepped @dataclass class UnknownDecoration(Decoration): """An unknown decoration. In practice these should never show up since the master-server generates these on the fly for the client and so should not send clients one they can't digest. """
[docs] @override @classmethod def get_type_id(cls) -> DecorationTypeID: return DecorationTypeID.UNKNOWN
[docs] @ioprepped @dataclass class Text(Decoration): """Text decoration.""" #: Note that dec-ui accepts only raw :class:`str` values for text; #: use :meth:`babase.Lstr.evaluate()` or whatnot for multi-language #: support. text: str position: tuple[float, float] #: Note that this effectively is 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 is_lstr: bool = False highlight: bool = True depth_range: tuple[float, float] | None = None #: Show max-width/height bounds; useful during development. debug: bool = False
[docs] @override @classmethod def get_type_id(cls) -> DecorationTypeID: return DecorationTypeID.TEXT
[docs] @ioprepped @dataclass class Image(Decoration): """Image decoration.""" texture: str 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: str | None = ( None ) tint_color: tuple[float, float, float] | None = None tint2_color: tuple[float, float, float] | None = None mask_texture: str | None = ( None ) mesh_opaque: str | None = ( None ) mesh_transparent: str | None = None highlight: bool = True depth_range: tuple[float, float] | None = None
[docs] @override @classmethod def get_type_id(cls) -> DecorationTypeID: return DecorationTypeID.IMAGE
[docs] class DisplayItemStyle(Enum): """Styles a display-item can be drawn in.""" FULL = 'f' COMPACT = 'c'
[docs] @ioprepped @dataclass class DisplayItem(Decoration): """DisplayItem decoration.""" wrapper: ditm.Wrapper position: tuple[float, float] size: tuple[float, float] style: DisplayItemStyle = ( DisplayItemStyle.FULL ) text_color: tuple[float, float, float] | None = None highlight: bool = True depth_range: tuple[float, float] | None = None debug: bool = False
[docs] @override @classmethod def get_type_id(cls) -> DecorationTypeID: return DecorationTypeID.DISPLAY_ITEM
[docs] class ButtonStyle(Enum): """Styles a button can be.""" SQUARE = 'q' TAB = 't' SMALL = 's' MEDIUM = 'm' LARGE = 'l' LARGER = 'xl'
[docs] @ioprepped @dataclass class Button: """A button in our cloud ui. Note that size, padding, and all decorations are scaled consistently with 'scale'. """ #: Note that dec-ui accepts only raw :class:`str` values for text; #: use :meth:`babase.Lstr.evaluate()` or whatnot for multi-language #: support. label: str | 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_flatness: float | None = None label_scale: float | None = ( None ) label_is_lstr: bool = False texture: str | None = None scale: float = 1.0 padding_left: float = 0.0 padding_top: float = 0.0 padding_right: float = 0.0 padding_bottom: float = 0.0 decorations: list[Decoration] | None = None style: ButtonStyle = ( ButtonStyle.SQUARE ) default: bool = False selected: bool = False icon: str | None = None icon_scale: float | None = ( None ) icon_color: tuple[float, float, float, float] | None = None depth_range: tuple[float, float] | None = None #: Draw bounds of the button. debug: bool = False
[docs] class RowTypeID(Enum): """Type ID for each of our subclasses.""" BUTTON_ROW = 'b' UNKNOWN = 'u'
[docs] class Row(IOMultiType[RowTypeID]): """Top level class for our multitype."""
[docs] @override @classmethod def get_type_id(cls) -> RowTypeID: # Require child classes to supply this themselves. If we did a # full type registry/lookup here it would require us to import # everything and would prevent lazy loading. raise NotImplementedError()
[docs] @override @classmethod def get_type(cls, type_id: RowTypeID) -> type[Row]: """Return the subclass for each of our type-ids.""" # pylint: disable=cyclic-import t = RowTypeID if type_id is t.UNKNOWN: return UnknownRow if type_id is t.BUTTON_ROW: return ButtonRow # Important to make sure we provide all types. assert_never(type_id)
[docs] @override @classmethod def get_unknown_type_fallback(cls) -> Row: # If we encounter some future type we don't know anything about, # drop in a placeholder. return UnknownRow()
[docs] @override @classmethod def get_type_id_storage_name(cls) -> str: return '_t'
[docs] @ioprepped @dataclass class UnknownRow(Row): """A row type we don't have."""
[docs] @override @classmethod def get_type_id(cls) -> RowTypeID: return RowTypeID.UNKNOWN
[docs] @ioprepped @dataclass class ButtonRow(Row): """A row consisting of buttons.""" buttons: list[Button] header_height: float = 0.0 header_scale: float = 1.0 header_decorations_left: list[Decoration] | None = None header_decorations_center: list[Decoration] | None = None header_decorations_right: list[Decoration] | None = None #: Note that dec-ui accepts only raw :class:`str` values for text; #: use :meth:`babase.Lstr.evaluate()` or whatnot for multi-language #: support. title: str | None = None title_color: tuple[float, float, float, float] | None = None title_flatness: float | None = None title_shadow: float | None = None title_is_lstr: bool = False subtitle: str | None = None subtitle_color: tuple[float, float, float, float] | None = None subtitle_flatness: float | None = None subtitle_shadow: float | None = None subtitle_is_lstr: bool = ( False ) button_spacing: float = 5.0 padding_left: float = 10.0 padding_right: float = 10.0 padding_top: float = 10.0 padding_bottom: float = 10.0 center_content: bool = False center_title: bool = False #: If things disappear when scrolling left/right, turn this up. simple_culling_h: float = ( 100.0 ) #: Draw bounds of the overall row and individual button columns #: (including padding). The UI will scroll to keep these areas #: visible in their entirety when changing selection via directional #: controls, so try to make sure all decorations for a button are #: within these bounds. debug: bool = False
[docs] @override @classmethod def get_type_id(cls) -> RowTypeID: return RowTypeID.BUTTON_ROW
[docs] @ioprepped @dataclass class Page: """Dec-UI page version 1.""" #: Note that dec-ui accepts only raw :class:`str` values for text; #: use :meth:`babase.Lstr.evaluate()` or whatnot for multi-language #: support. title: str rows: list[Row] #: If True, content smaller than the available height will be #: centered vertically. This can look natural for certain types of #: content such as confirmation dialogs. center_vertically: bool = ( False ) row_spacing: float = 10.0 #: If things disappear when scrolling up and down, turn this up. simple_culling_v: float = ( 100.0 ) #: Whether the title is a json dict representing an Lstr. Generally #: dec-ui translation should be handled server-side, but this can #: allow client-side translation. title_is_lstr: bool = False padding_bottom: float = 0.0 padding_left: float = 0.0 padding_top: float = 0.0 padding_right: float = 0.0
[docs] class StatusCode(Enum): """The overall result of a request.""" SUCCESS = 0 UNKNOWN_ERROR = 1
[docs] @ioprepped @dataclass class Response(DecUIResponse): """Full decui response.""" page: Page status: StatusCode = ( StatusCode.SUCCESS ) #: Effects to run on the client when this response is initially #: received. Note that these effects will not re-run if the page is #: automatically refreshed later (due to window resizing, back #: navigation, etc). #: #: :meta private: client_effects: list[clfx.Effect] = field(default_factory=list) #: Local action to run after this response is initially received. #: Will be handled by #: :meth:`bauiv1lib.decui.DecUIController.local_action()`. Note #: that these actions will not re-run if the page is automatically #: refreshed later (due to window resizing, back navigation, etc). 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 #: dec-ui window. timed_action: Action | None = None timed_action_delay: float = 0.0 #: If provided, error on builds older than this (can be used to gate #: functionality without bumping entire decui version). minimum_engine_build: int | None = None #: The client maintains some persistent state (such as widget #: selection) for all pages viewed. The default index for these #: states is the path of the request. If a server returns a #: significant variety of responses for a single path, however, #: (based on args, etc) then it may make sense for the server to #: provide explicit state ids for those different variations. shared_state_id: str | None = None
[docs] @override @classmethod def get_type_id(cls) -> DecUIResponseTypeID: return DecUIResponseTypeID.V1
# Docs-generation hack; import some stuff that we likely only forward-declared # in our actual source code so that docs tools can find it. from typing import (Coroutine, Any, Literal, Callable, Generator, Awaitable, Sequence, Self) import asyncio from concurrent.futures import Future from pathlib import Path from enum import Enum