Source code for bacommon.docui.routes._popup
# Released under the MIT License. See LICENSE for details.
#
"""Type-safe doc-ui popup windows and the parents they act on."""
from __future__ import annotations # Docs-generation hack.
from dataclasses import dataclass
from typing import TYPE_CHECKING, override, get_args, get_origin
import bacommon.docui.v2 as dui2
from bacommon.docui.walk import page_actions
from bacommon.docui.routes._base import DocUIRoute
from bacommon.docui.routes._state import (
DocUIState,
STATE_TYPE_KEY,
merge_assigns,
validate_page_state,
)
if TYPE_CHECKING:
from typing import Any, Sequence
import bacommon.docui.v2
from bacommon.docui.routes._state import DocUIStateAssign
# Parent state types of popup routes, by the class declaring them (see
# PopupRoute.__init_subclass__). Kept out here rather than as a class
# attribute because dataclassio would try to prep an annotated one as
# part of our dataclass children (and mypy wants it annotated).
_g_parent_state_types: dict[type, type[DocUIState]] = {}
# (A mixin beside a family class, which supplies get_route_types().)
# pylint: disable-next=abstract-method
[docs]
class PopupRoute[S: DocUIState](DocUIRoute):
"""A route whose page is shown in a popup over a parent page.
Mixed in next to a domain's family class, naming the state type of
the page(s) the popup opens over:
``class SortMenu(StoreRoute, PopupRoute[ListingState], path=...)``.
That type is what the popup may act on: its
:meth:`parent` builds actions applying to the parent (setting keys
of ``S``), and :meth:`get_parent_state` reads the parent's current
state. Pages opening the popup must hold state of type ``S``; that
and the rules for what a popup page may contain are checked when
pages are validated (see :func:`validate_route_page`).
Open one with :meth:`popup`.
"""
@override
def __init_subclass__(cls, **kwargs: Any) -> None:
super().__init_subclass__(**kwargs)
# Pick up S from a 'PopupRoute[Foo]' on our own class line;
# subclasses of that class simply inherit it.
for base in cls.__dict__.get('__orig_bases__', ()):
if get_origin(base) is PopupRoute:
(arg,) = get_args(base)
if isinstance(arg, type) and issubclass(arg, DocUIState):
_g_parent_state_types[cls] = arg
[docs]
@classmethod
def get_parent_state_type(cls) -> type[S]:
"""Return the state type of the page this popup opens over."""
statetype = next(
(
_g_parent_state_types[c]
for c in cls.__mro__
if c in _g_parent_state_types
),
None,
)
if statetype is None:
raise TypeError(
f'{cls.__name__} declares no parent state type'
f' (PopupRoute[SomeState] on its class line).'
)
# (Set from our own type arg; see __init_subclass__.)
return statetype # type: ignore[return-value]
[docs]
@classmethod
def parent(cls) -> PopupParent[S]:
"""Return a handle for building actions acting on our parent."""
return PopupParent(cls.get_parent_state_type())
[docs]
@classmethod
def get_popup_size(cls) -> tuple[float, float]:
"""Content-area size (width, height) our popups open at.
In window units (see :attr:`bacommon.docui.v2.Popup.size`), at
least :data:`~bacommon.docui.v2.POPUP_MIN_SIZE`; clients shrink
whatever doesn't fit their screen. Override to suit the page;
:meth:`~bacommon.docui.routes.PopupRoute.popup` can also be told
otherwise per link.
"""
return (500.0, 300.0)
[docs]
def popup(
self,
*,
default_sound: bool = True,
sets: Sequence[DocUIStateAssign[S]] | None = None,
size: tuple[float, float] | None = None,
show_title: bool = False,
show_close_button: bool = False,
dim_background: bool = True,
scroll_edge_style: bacommon.docui.v2.ScrollEdgeStyle = (
dui2.ScrollEdgeStyle.FADE
),
background_color: tuple[float, float, float] | None = None,
cancel_button_color: tuple[float, float, float] | None = None,
) -> bacommon.docui.v2.Popup:
"""Return an action opening this route's page in a popup.
The pressing page (whose state must be of our parent state
type) is the popup's parent; any ``sets`` are applied to its
state first, and that state goes along with the request (and
every later one fired from the popup). The popup's content
area is ``size``, or :meth:`get_popup_size` if not given; the
rest is as for :class:`bacommon.docui.v2.Popup`.
Only clients from :data:`bacommon.docui.v2.POPUP_BUILD` on can
show popups; gate links to them on
:func:`bacommon.docui.v2.supports_popups`.
"""
if size is None:
size = self.get_popup_size()
lo = dui2.POPUP_MIN_SIZE
if not (lo[0] <= size[0] and lo[1] <= size[1]):
raise ValueError(
f'Popup size {size} is smaller than {lo}'
f' ({type(self).__name__}).'
)
return dui2.Popup(
self.request(),
default_sound=default_sound,
sets=merge_assigns(sets),
parent_state_id=self.get_parent_state_type().get_state_id(),
size=size,
show_title=show_title,
show_close_button=show_close_button,
dim_background=dim_background,
scroll_edge_style=scroll_edge_style,
background_color=background_color,
cancel_button_color=cancel_button_color,
)
[docs]
def get_parent_state(self) -> S | None:
"""Return the parent page's state as it arrived with our request.
As with :meth:`~bacommon.docui.routes.DocUIRoute.get_state`,
this is always optional input: None when absent, of another
type, or undecodable.
"""
src = self.get_source_request()
return self.get_parent_state_type().decode(
None if src is None else src.parent_state
)
[docs]
@dataclass(frozen=True)
class PopupParent[S: DocUIState]:
"""Builds a popup's actions that act on the page it is over.
Get one from :meth:`PopupRoute.parent`. Every action here closes
the popup; the parent then does what the action says, as though
pressed there. A link that would read ``Foo().replace()`` on the
parent page reads ``parent.replace(Foo())`` in the popup.
"""
#: State type of the parent page.
state_type: type[S]
[docs]
def dismiss(self, *, default_sound: bool = True) -> bacommon.docui.v2.Local:
"""Close the popup, leaving the parent alone."""
return dui2.Local(close_window=True, default_sound=default_sound)
[docs]
def refresh(
self, *assigns: DocUIStateAssign[S], default_sound: bool = True
) -> bacommon.docui.v2.Local:
"""Close the popup and refresh the parent.
Any ``assigns`` are applied to the parent's state first.
"""
values = merge_assigns(assigns) or {}
values[STATE_TYPE_KEY] = self.state_type.get_state_id()
return dui2.Local(
close_window=True,
refresh_parent=True,
return_sets=values,
default_sound=default_sound,
)
[docs]
def replace(
self,
route: DocUIRoute,
*,
sets: Sequence[DocUIStateAssign[S]] | None = None,
state: DocUIState | None = None,
default_sound: bool = True,
) -> bacommon.docui.v2.Local:
"""Close the popup; the parent replaces its page with a route.
``sets`` and ``state`` are as for
:meth:`~bacommon.docui.routes.DocUIRoute.replace`, with
``sets`` applying to the parent's state.
"""
return self.run(
route.replace(default_sound=False, sets=sets, state=state),
default_sound=default_sound,
)
[docs]
def browse(
self,
route: DocUIRoute,
*,
sets: Sequence[DocUIStateAssign[S]] | None = None,
state: DocUIState | None = None,
layout: bacommon.docui.v2.WindowLayout | None = None,
default_sound: bool = True,
) -> bacommon.docui.v2.Local:
"""Close the popup; the parent browses to a route.
As for :meth:`~bacommon.docui.routes.DocUIRoute.browse`, with
``sets`` applying to the parent's state.
"""
return self.run(
route.browse(
default_sound=False, sets=sets, state=state, layout=layout
),
default_sound=default_sound,
)
[docs]
def close(self, *, default_sound: bool = True) -> bacommon.docui.v2.Local:
"""Close the popup and the parent's window too."""
return self.run(
dui2.Local(close_window=True, default_sound=False),
default_sound=default_sound,
)
[docs]
def close_to(
self,
route_type: type[DocUIRoute],
*assigns: DocUIStateAssign[DocUIState],
default_sound: bool = True,
) -> bacommon.docui.v2.Local:
"""Close the popup, then close the parent back to a route.
As :meth:`~bacommon.docui.routes.DocUIRoute.close_to` would if
pressed on the parent (``assigns`` go to the page closed back
to, so may be of any one state type).
"""
return self.run(
route_type.close_to(*assigns, default_sound=False),
default_sound=default_sound,
)
[docs]
def run(
self, action: bacommon.docui.v2.Action, *, default_sound: bool = True
) -> bacommon.docui.v2.Local:
"""Close the popup; the parent runs an arbitrary action.
The escape hatch for anything the other methods don't cover (a
local-action's :meth:`local()
<bacommon.docui.routes.DocUILocalActionBase.local>`, say). The
action must be a browse, replace or local (not one acting on
a parent itself); its ``sets`` apply to the parent's state.
"""
_check_parent_action(action)
return dui2.Local(
close_window=True,
parent_action=action,
parent_state_id=self.state_type.get_state_id(),
default_sound=default_sound,
)
def _check_parent_action(action: dui2.Action) -> None:
if not isinstance(action, (dui2.Browse, dui2.Replace, dui2.Local)):
raise ValueError(
f'A popup parent action must be a browse, replace or local;'
f' got {type(action).__name__}.'
)
if isinstance(action, dui2.Local) and (
action.parent_action is not None or action.refresh_parent
):
raise ValueError('A popup parent action cannot act on a parent.')
def _check_popup_parent(
action: dui2.Popup, parent_id: str | None, what: str
) -> None:
if action.parent_state_id is not None and (
action.parent_state_id != parent_id
):
raise ValueError(
f'Page opens a popup expecting a parent with state'
f' {action.parent_state_id!r}, but its parent would be'
f' {what}, with state {parent_id!r}.'
)
[docs]
def validate_popup_page(
page: bacommon.docui.v2.Page, parent_state_type: type[DocUIState]
) -> None:
"""Make sure a page shown in a popup keeps to the popup rules.
No :class:`~bacommon.docui.v2.Browse` (the parent browses
instead), parent actions of the allowed kinds only, and everything
aimed at the parent (``sets`` of its actions, values handed back)
belonging to the parent's state type.
"""
parent_id = parent_state_type.get_state_id()
parent_keys = parent_state_type.get_keys()
for act in page_actions(page, parent_actions=False):
if isinstance(act, dui2.Popup):
# Replaces this popup over the same parent.
_check_popup_parent(act, parent_id, 'a popup')
for key in act.sets or {}:
if key not in parent_keys:
raise ValueError(
f'Popup page opens a popup setting key {key!r},'
f' which is not part of'
f' {parent_state_type.__name__}.'
)
continue
if isinstance(act, dui2.Browse):
raise ValueError(
'Popup pages cannot browse; browse from the parent'
' (PopupParent.browse()).'
)
if not isinstance(act, dui2.Local):
continue
if act.return_sets and act.close_window:
if act.return_sets.get(STATE_TYPE_KEY) != parent_id:
raise ValueError(
f'Popup page hands values back to a parent of state'
f' {act.return_sets.get(STATE_TYPE_KEY)!r}, but its'
f' parent is {parent_id!r}.'
)
if act.parent_action is None:
continue
_check_parent_action(act.parent_action)
if act.parent_state_id != parent_id:
raise ValueError(
f'Popup page parent action expects state'
f' {act.parent_state_id!r}, but its parent is'
f' {parent_id!r}.'
)
assert isinstance(
act.parent_action, (dui2.Browse, dui2.Replace, dui2.Local)
)
for key in act.parent_action.sets or {}:
if key not in parent_keys:
raise ValueError(
f'Popup page parent action sets key {key!r}, which'
f' is not part of {parent_state_type.__name__}.'
)
[docs]
def validate_route_page(
route: DocUIRoute, page: bacommon.docui.v2.Page
) -> None:
"""Run every validation that applies to a route's page.
:func:`~bacommon.docui.routes.validate_page_state`, plus
:func:`validate_popup_page` for a popup route. Typed controllers
and request handlers call this on every page they produce, so a
misbuilt page fails on its first render rather than in the field.
"""
if isinstance(route, PopupRoute):
validate_page_state(page, in_popup=True)
validate_popup_page(page, route.get_parent_state_type())
return
validate_page_state(page)
# Popups opened from here must expect a parent of our state type.
page_id = None if page.state is None else page.state.get(STATE_TYPE_KEY)
for act in page_actions(page, parent_actions=False):
if isinstance(act, dui2.Popup):
_check_popup_parent(act, page_id, 'the page')
# 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