Files
rio/rio/components/component.py
2024-06-18 21:33:29 +02:00

620 lines
23 KiB
Python

from __future__ import annotations
import abc
import io
from abc import abstractmethod
from collections.abc import Callable, Iterable
from dataclasses import KW_ONLY
from pathlib import Path
from typing import * # type: ignore
from typing_extensions import Self
from uniserde import Jsonable, JsonDoc
import rio
from .. import inspection, utils
from ..component_meta import ComponentMeta
from ..dataclass import internal_field
from ..state_properties import AttributeBindingMaker
__all__ = ["Component"]
T = TypeVar("T")
# Using `metaclass=ComponentMeta` makes this an abstract class, but since
# pyright is too stupid to understand that, we have to additionally inherit from
# `abc.ABC`
class Component(abc.ABC, metaclass=ComponentMeta):
"""
Base class for all Rio components.
Components are the building blocks of all Rio apps. Rio already ships with
many useful components out of the box, but you can also subclass
`rio.Component` to create your own.
Components all follow the same basic structure.
- Class Header
- Attribute (with type annotations!)
- custom functions and event handlers
- `build` method
Here's a basic example
```python
class HelloComponent(rio.Component):
# List all of the components attributes here
name: str
# Define the build function. It is called when the component is created
# or any of its attributes have changed
def build(self) -> rio.Component:
return rio.Text(f"Hello, {self.name}!")
```
Notice that there is no `__init__` method. That's because all Rio components
are automatically dataclasses. This means that you can just list the
attributes of your component as class variables, and Rio will automatically
create a constructor for you.
In fact, **never write an `__init__` method for a Rio component** unless you
know what you're doing. If you do need custom code to run during
construction, **use the `__post_init__` method** instead. Here's another
example, with a custom `__post_init__` method:
```python
class HelloComponent(rio.Component):
name: str
greeting: str = ""
# In order to run custom code during initialization, create a
# `__post_init__` method. This method is called after all internal
# setup is done, so you are free to access your finished component.
def __post_init__(self) -> None:
# If the caller hasn't provided a greeting, we'll make one up
# based on the connected user's language
if self.greeting:
return
if self.session.preferred_languages[0].startswith("de"):
self.greeting = "Hallo"
elif self.session.preferred_languages[0].startswith("es"):
self.greeting = "Hola"
elif self.session.preferred_languages[0].startswith("fr"):
self.greeting = "Bonjour"
else:
self.greeting = "Hello"
def build(self) -> rio.Component:
return rio.Text(f"{self.greeting}, {self.name}!")
```
This example initializes allows the user to provide a custom greeting, but
if they don't, it will automatically choose a greeting based on the user's
language. This needs custom code to run during initialization, so we use
`__post_init__`.
## Attributes
`key`: A unique identifier for this component. If two components with the
same key are present during reconciliation they will be considered
the same component and their state will be preserved. If no key is
specified, reconciliation falls back to a less precise method, by
comparing the location of the component in the component tree.
`margin`: The margin around this component. This is a shorthand for
setting `margin_left`, `margin_top`, `margin_right` and `margin_bottom`
to the same value. If multiple conflicting margins are specified the
most specific one wins. If for example `margin` and `margin_left` are
both specified, `margin_left` is used for the left side, while the other
sides use `margin`. Sizes are measured in "font heights", so a margin of
1 is the height of a single line of text.
`margin_x`: The horizontal margin around this component. This is a
shorthand for setting `margin_left` and `margin_right` to the same
value. If multiple conflicting margins are specified the most
specific one wins. If for example `margin_x` and `margin_left` are
both specified, `margin_left` is used for the left side, while the
other side uses `margin_x`. Sizes are measured in "font heights", so a
margin of 1 is the height of a single line of text.
`margin_y`: The vertical margin around this component. This is a shorthand
for setting `margin_top` and `margin_bottom` to the same value. If
multiple conflicting margins are specified the most specific one
wins. If for example `margin_y` and `margin_top` are both specified,
`margin_top` is used for the top side, while the other side uses
`margin_y`. Sizes are measured in "font heights", so a margin of 1 is
the height of a single line of text.
`margin_left`: The left margin around this component. If multiple
conflicting margins are specified this one will be used, since it's
the most specific. If for example `margin_left` and `margin` are
both specified, `margin_left` is used for the left side, while the
other sides use `margin`. Sizes are measured in "font heights", so a
margin of 1 is the height of a single line of text.
`margin_top`: The top margin around this component. If multiple
conflicting margins are specified this one will be used, since it's
the most specific. If for example `margin_top` and `margin` are both
specified, `margin_top` is used for the top side, while the other
sides use `margin`. Sizes are measured in "font heights", so a margin
of 1 is the height of a single line of text.
`margin_right`: The right margin around this component. If multiple
conflicting margins are specified this one will be used, since it's
the most specific. If for example `margin_right` and `margin` are
both specified, `margin_right` is used for the right side, while the
other sides use `margin`. Sizes are measured in "font heights", so a
margin of 1 is the height of a single line of text.
`margin_bottom`: The bottom margin around this component. If multiple
conflicting margins are specified this one will be used, since it's
the most specific. If for example `margin_bottom` and `margin` are
both specified, `margin_bottom` is used for the bottom side, while
the other sides use `margin`. Sizes are measured in "font heights", so
a margin of 1 is the height of a single line of text.
`width`: How much horizontal space this component should request during
layouting. This can be either a number, or one of the special
values:
- If `"natural"`, the component will request the minimum amount it
requires to fit on the screen. For example a `Text` will request
however much space the characters of that text require. A `Row`
would request the sum of the widths of its children.
- If `"grow"`, the component will request all the remaining space in its parent.
- Please note that the space a `Component` receives during layouting
may not match the request. As a general rule, for example, containers
try to pass on all available space to children. If you really want a
`Component` to only take up as much space as requested, consider
specifying an alignment.
Sizes are measured in "font heights", so a width of 1 is the same as
the height of a single line of text.
`height`: How much vertical space this component should request during
layouting. This can be either a number, or one of the special values:
- If `"natural"`, the component will request the minimum amount it
requires to fit on the screen. For example a `Text` will request
however much space the characters of that text require. A `Row`
would request the height of its tallest child.
- If `"grow"`, the component will request all the remaining space in its
parent.
- Please note that the space a `Component` receives during layouting
may not match the request. As a general rule for example, containers
try to pass on all available space to children. If you really want a
`Component` to only take up as much space as requested, consider
specifying an alignment.
Sizes are measured in "font heights", so a height of 1 is the same as
the height of a single line of text.
`align_x`: How this component should be aligned horizontally, if it
receives more space than it requested. This can be a number between
0 and 1, where 0 means left-aligned, 0.5 means centered, and 1 means
right-aligned.
`align_y`: How this component should be aligned vertically, if it receives
more space than it requested. This can be a number between 0 and 1,
where 0 means top-aligned, 0.5 means centered, and 1 means
bottom-aligned.
"""
_: KW_ONLY
key: str | int | None = internal_field(default=None, init=True)
width: float | Literal["natural", "grow"] = "natural"
height: float | Literal["natural", "grow"] = "natural"
align_x: float | None = None
align_y: float | None = None
scroll_x: Literal["never", "auto", "always"] = "never"
scroll_y: Literal["never", "auto", "always"] = "never"
margin_left: float | None = None
margin_top: float | None = None
margin_right: float | None = None
margin_bottom: float | None = None
margin_x: float | None = None
margin_y: float | None = None
margin: float | None = None
_id: int = internal_field(init=False)
# Weak reference to the component's builder. Used to check if the component
# is still part of the component tree.
#
# Dataclasses like to turn this function into a method. Make sure it works
# both with and without `self`.
_weak_builder_: Callable[[], Component | None] = internal_field(
default=lambda *args: None,
init=False,
)
# Weak reference to the component's creator
_weak_creator_: Callable[[], Component | None] = internal_field(
init=False,
)
# Each time a component is built the build generation in that component's
# COMPONENT DATA is incremented. If this value no longer matches the value
# in its builder's COMPONENT DATA, the component is dead.
_build_generation_: int = internal_field(default=-1, init=False)
_session_: rio.Session = internal_field(init=False)
# Remember which properties were explicitly set in the constructor
_properties_set_by_creator_: set[str] = internal_field(
init=False, default_factory=set
)
# Remember which properties had new values assigned after the component's
# construction
_properties_assigned_after_creation_: set[str] = internal_field(init=False)
# Whether the `on_populate` event has already been triggered for this
# component
_on_populate_triggered_: bool = internal_field(default=False, init=False)
# Whether this instance is internal to Rio, e.g. because the spawning
# component is a high-level component defined in Rio.
#
# In debug mode, this field is initialized by monkeypatches. When running in
# release mode this value isn't set at all, and the default set below is
# always used.
_rio_internal_: bool = internal_field(init=False, default=False)
# The stackframe which has created this component. Used by the dev tools.
# Only initialized if in debugging mode.
_creator_stackframe_: tuple[Path, int] = internal_field(init=False)
# Whether this component's `__init__` has already been called. Used to
# verify that the `__init__` doesn't try to read any state properties.
_init_called_: bool = internal_field(init=False, default=False)
# Hide this function from type checkers so they don't think that we accept
# arbitrary args
if not TYPE_CHECKING:
# Make sure users don't inherit from rio components. Inheriting from
# their own components is fine, though.
def __init_subclass__(cls, *args, **kwargs):
super().__init_subclass__(*args, **kwargs)
if cls.__module__.startswith("rio."):
return
for base_cls in cls.__bases__:
if (
base_cls is not __class__
and issubclass(base_cls, __class__)
and base_cls.__module__.startswith("rio.")
):
raise Exception(
"Inheriting from builtin rio components is not allowed"
)
@property
def session(self) -> rio.Session:
"""
Return the session this component is part of.
"""
return self._session_
# There isn't really a good type annotation for this... Self is the closest
# thing
def bind(self) -> Self:
return AttributeBindingMaker(self) # type: ignore
def _custom_serialize(self) -> JsonDoc:
"""
Return any additional properties to be serialized, which cannot be
deduced automatically from the type annotations.
"""
return {}
@abstractmethod
def build(self) -> rio.Component:
"""
Return a component tree which represents the UI of this component.
Most components define their appearance and behavior by combining other,
more basic components. This function's purpose is to do exactly that. It
returns another component (typically a container) which will be
displayed on the screen.
The `build` function should be pure, meaning that it does not modify the
component's state and returns the same result each time it's invoked.
"""
raise NotImplementedError() # pragma: no cover
def _iter_direct_children(self) -> Iterable[Component]:
for name in inspection.get_child_component_containing_attribute_names(
type(self)
):
try:
value = getattr(self, name)
except AttributeError:
continue
if isinstance(value, Component):
yield value
if isinstance(value, list):
value = cast(list[object], value)
for item in value:
if isinstance(item, Component):
yield item
def _iter_direct_and_indirect_child_containing_attributes(
self,
*,
include_self: bool,
recurse_into_high_level_components: bool,
) -> Iterable[Component]:
from . import fundamental_component # Avoid circular import problem
# Special case the component itself to handle `include_self`
if include_self:
yield self
if not recurse_into_high_level_components and not isinstance(
self, fundamental_component.FundamentalComponent
):
return
# Iteratively yield all children
to_do = list(self._iter_direct_children())
while to_do:
cur = to_do.pop()
yield cur
if recurse_into_high_level_components or isinstance(
cur, fundamental_component.FundamentalComponent
):
to_do.extend(cur._iter_direct_children())
def _iter_component_tree(
self, *, include_root: bool = True
) -> Iterable[Component]:
"""
Iterate over all components in the component tree, with this component as the root.
"""
from . import fundamental_component # Avoid circular import problem
if include_root:
yield self
if isinstance(self, fundamental_component.FundamentalComponent):
for child in self._iter_direct_children():
yield from child._iter_component_tree()
else:
build_result = self.session._weak_component_data_by_component[
self
].build_result
yield from build_result._iter_component_tree()
async def _on_message(self, msg: Jsonable, /) -> None:
raise RuntimeError(
f"{type(self).__name__} received unexpected message `{msg}`"
)
def _is_in_component_tree(self, cache: dict[rio.Component, bool]) -> bool:
"""
Returns whether this component is directly or indirectly connected to the
component tree of a session.
This operation is fast, but has to walk up the component tree to make sure
the component's parent is also connected. Thus, when checking multiple
components it can easily happen that the same components are checked over and
over, resulting on O(n log n) runtime. To avoid this, pass a cache
dictionary to this function, which will be used to memoize the result.
Be careful not to reuse the cache if the component hierarchy might have
changed (for example after an async yield).
"""
# Already cached?
try:
return cache[self]
except KeyError:
pass
# Root component?
if self is self.session._root_component:
result = True
# Has the builder has been garbage collected?
else:
builder = self._weak_builder_()
if builder is None:
result = False
# Has the builder since created new build output, and this component
# isn't part of it anymore?
else:
parent_data = self.session._weak_component_data_by_component[
builder
]
result = (
parent_data.build_generation == self._build_generation_
and builder._is_in_component_tree(cache)
)
# Cache the result and return
cache[self] = result
return result
@overload
async def call_event_handler(
self,
handler: rio.EventHandler[[]],
) -> None: ... # pragma: no cover
@overload
async def call_event_handler(
self,
handler: rio.EventHandler[[T]],
event_data: T,
/,
) -> None: ... # pragma: no cover
async def call_event_handler(
self,
handler: rio.EventHandler[...],
*event_data: object,
) -> None:
"""
Calls an even handler, awaiting it if necessary.
Call an event handler, if one is present. Await it if necessary. Log and
discard any exceptions. If `event_data` is present, it will be passed to
the event handler.
## Parameters
`handler`: The event handler (function) to call.
`event_data`: Arguments to pass to the event handler.
"""
await self.session._call_event_handler(
handler, *event_data, refresh=False
)
async def force_refresh(self) -> None:
"""
Force a rebuild of this component.
Most of the time components update automatically when their state
changes. However, some state mutations are invisible to `Rio`: For
example, appending items to a list modifies the list, but since no list
instance was actually assigned to th component, `Rio` will be unaware of
this change.
In these cases, you can force a rebuild of the component by calling
`force_refresh`. This will trigger a rebuild of the component and
display the updated version on the screen.
Another common use case is if you wish to update an component while an
event handler is still running. `Rio` will automatically detect changes
after event handlers return, but if you are performing a long-running
operation, you may wish to update the component while the event handler
is still running. This allows you to e.g. update a progress bar while
the operation is still running.
"""
self.session._register_dirty_component(
self,
include_children_recursively=False,
)
await self.session._refresh()
def _get_debug_details(self) -> dict[str, Any]:
"""
Used by Rio's dev tools to decide which properties to display to a user,
when they select a component.
"""
result = {}
for prop in self._state_properties_:
# Consider properties starting with an underscore internal
if prop.startswith("_"):
continue
# Keep it
result[prop] = getattr(self, prop)
return result
def __repr__(self) -> str:
result = f"<{type(self).__name__} id:{self._id}"
child_strings: list[str] = []
for child in self._iter_direct_children():
child_strings.append(f" {type(child).__name__}:{child._id}")
if child_strings:
result += " -" + "".join(child_strings)
return result + ">"
def _repr_tree_worker(self, file: IO[str], indent: str) -> None:
file.write(indent)
file.write(repr(self))
for child in self._iter_direct_children():
file.write("\n")
child._repr_tree_worker(file, indent + " ")
def _repr_tree(self) -> str:
file = io.StringIO()
self._repr_tree_worker(file, "")
return file.getvalue()
@property
def _effective_margin_left(self) -> float:
"""
Calculates the actual left margin of a component, taking into account
the values of `margin`, `margin_x` and `margin_left`.
"""
return utils.first_non_null(
self.margin_left,
self.margin_x,
self.margin,
0,
)
@property
def _effective_margin_top(self) -> float:
"""
Calculates the actual top margin of a component, taking into account
the values of `margin`, `margin_y` and `margin_top`.
"""
return utils.first_non_null(
self.margin_top,
self.margin_y,
self.margin,
0,
)
@property
def _effective_margin_right(self) -> float:
"""
Calculates the actual right margin of a component, taking into account
the values of `margin`, `margin_right` and `margin_x`.
"""
return utils.first_non_null(
self.margin_right,
self.margin_x,
self.margin,
0,
)
@property
def _effective_margin_bottom(self) -> float:
"""
Calculates the actual bottom margin of a component, taking into account
the values of `margin`, `margin_y` and `margin_bottom`.
"""
return utils.first_non_null(
self.margin_bottom,
self.margin_y,
self.margin,
0,
)