diff --git a/changelog.md b/changelog.md index 754f3116..72121187 100644 --- a/changelog.md +++ b/changelog.md @@ -96,7 +96,7 @@ Additions: `rio.Component`. - Page rework - Add `rio.Redirect` - - TODO: Automatic page scan + - Still missing automatic page scan - New experimental `rio.FilePickerArea` component ## 0.9.2 diff --git a/rio/observables/containers.py b/rio/observables/containers.py index 61673286..399d377c 100644 --- a/rio/observables/containers.py +++ b/rio/observables/containers.py @@ -1,371 +1,371 @@ -from __future__ import annotations - -import collections.abc -import sys -import typing as t - -import rio - -from .. import global_state - -__all__ = ["List", "Dict", "Set"] - - -T = t.TypeVar("T") - - -def identity(x: T) -> T: - return x - - -class ObservableContainer: - def __init__(self): - self._affected_sessions: set[rio.Session] = set() - - def _mark_as_accessed(self): - if global_state.currently_building_session is None: - return - - global_state.accessed_objects.add(self) - self._affected_sessions.add(global_state.currently_building_session) - - def _mark_as_changed(self) -> None: - for session in self._affected_sessions: - session._changed_objects.add(self) - session._refresh_required_event.set() - - -class List(ObservableContainer, collections.abc.MutableSequence[T]): - """ - A `list`-like object that automatically rebuilds components whenever its - content changes. - - ## Examples - - ```python - class ElementAdder(rio.Component): - list: rio.List[str] - - def build(self): - return rio.Button( - "add an element", - on_press=lambda: self.list.append('foo'), - ) - - class Display(rio.Component): - list: rio.List[str] - - def build(self): - return rio.Text("\n".join(self.list)) - - class ListDemo(rio.Component): - list: rio.List[str] = rio.List() - - def build(self): - return rio.Column( - ElementAdder(self.list), - Display(self.list), - ) - ``` - - Here you can see how the `Display` component automatically updates whenever - the `ElementAdder` component appends a new element to the `List`. This would - be much trickier to do with a regular `list`. - - ## Metadata - - `experimental`: True - """ - - def __init__(self, items: t.Iterable[T] = (), /): - super().__init__() - - self._items = list(items) - - def insert(self, index: int, value: T) -> None: - self._items.insert(index, value) - self._mark_as_changed() - - def append(self, value: T) -> None: - self._items.append(value) - self._mark_as_changed() - - def extend(self, values: t.Iterable[T]) -> None: - self._items.extend(values) - self._mark_as_changed() - - def remove(self, value: T) -> None: - self._items.remove(value) - self._mark_as_changed() - - def clear(self) -> None: - self._items.clear() - self._mark_as_changed() - - def pop(self, index: int | None = None, /) -> T: - self._mark_as_accessed() - - if index is None: - item = self._items.pop() - else: - item = self._items.pop(index) - - self._mark_as_changed() - - return item - - def reverse(self) -> None: - self._items.reverse() - self._mark_as_changed() - - def copy(self) -> List[T]: - self._mark_as_accessed() - return List(self) - - def __delitem__(self, index: int | slice) -> None: - del self._items[index] - self._mark_as_changed() - - def __add__(self, other: t.Iterable[T], /) -> List[T]: - result = List(self) - result += other - return result - - def __iadd__(self, other: t.Iterable[T]) -> List[T]: - self.extend(other) - return self - - def index(self, value: T, start: int = 0, stop: int = sys.maxsize) -> int: - self._mark_as_accessed() - return self._items.index(value, start, stop) - - def count(self, value: T) -> int: - self._mark_as_accessed() - return self._items.count(value) - - @t.overload - def __getitem__(self, index: int) -> T: ... - - @t.overload - def __getitem__(self, index: slice) -> List[T]: ... - - def __getitem__(self, index: int | slice) -> T | List[T]: - self._mark_as_accessed() - - if isinstance(index, slice): - return List(self._items[index]) - else: - return self._items[index] - - def __len__(self) -> int: - self._mark_as_accessed() - return len(self._items) - - def __iter__(self) -> t.Iterator[T]: - # TODO: Technically, no data has been accessed yet. The correct behavior - # would be to return a custom iterator that tracks access in its - # `__next__` method. - self._mark_as_accessed() - return iter(self._items) - - def __contains__(self, value: object) -> bool: - self._mark_as_accessed() - return value in self._items - - # These function signatures are a PITA. Screw the boilerplate, just inherit - # the signature - if t.TYPE_CHECKING: - __setitem__ = collections.abc.MutableSequence.__setitem__ - else: - - def sort(self, *args, **kwargs) -> None: - self._items.sort(*args, **kwargs) - self._mark_as_changed() - - def __setitem__(self, index_or_slice, value) -> None: - self._items[index_or_slice] = value - self._mark_as_changed() - - -K = t.TypeVar("K") -V = t.TypeVar("V") - - -class Dict(ObservableContainer, collections.abc.MutableMapping[K, V]): - """ - A `dict`-like object that automatically rebuilds components whenever its - content changes. - - ## Examples - - ```python - class ElementAdder(rio.Component): - dict: rio.Dict[int, str] - - def _add_element(self): - self.dict[len(self.dict)] = 'foo' - - def build(self): - return rio.Button( - "add an element", - on_press=self._add_element, - ) - - class Display(rio.Component): - dict: rio.Dict[int, str] - - def build(self): - return rio.Text("\n".join(f"{k}: {v}" for k, v in self.dict.items())) - - class DictDemo(rio.Component): - dict: rio.Dict[int, str] = rio.Dict() - - def build(self): - return rio.Column( - ElementAdder(self.dict), - Display(self.dict), - ) - ``` - - Here you can see how the `Display` component automatically updates whenever - the `ElementAdder` component adds a new element to the `Dict`. This would be - much trickier to do with a regular `dict`. - - ## Metadata - - `experimental`: True - """ - - def __init__( - self, - __items: t.Mapping[K, V] | t.Iterable[tuple[K, V]] = (), - /, - **kwargs: V, - ): - super().__init__() - - self._items = dict(__items, **kwargs) - - def __setitem__(self, key: K, value: V, /) -> None: - self._items[key] = value - self._mark_as_changed() - - def __delitem__(self, key: K, /) -> None: - del self._items[key] - self._mark_as_changed() - - def __getitem__(self, key: K, /) -> V: - self._mark_as_accessed() - return self._items[key] - - def __iter__(self) -> t.Iterator[K]: - self._mark_as_accessed() - return iter(self._items) - - def __len__(self) -> int: - self._mark_as_accessed() - return len(self._items) - - def __contains__(self, key: object, /) -> bool: - self._mark_as_accessed() - return key in self._items - - def popitem(self) -> tuple[K, V]: - self._mark_as_accessed() - - item = self._items.popitem() - self._mark_as_changed() - - return item - - # These function signatures are a PITA. Screw the boilerplate, just inherit - # the signature - if not t.TYPE_CHECKING: - - def update(self, *args, **kwargs) -> None: - self._items.update(*args, **kwargs) - self._mark_as_changed() - - def pop(self, *args, **kwargs): - self._mark_as_accessed() - - value = self._items.pop(*args, **kwargs) - self._mark_as_changed() - - return value - - -class Set(ObservableContainer, collections.abc.MutableSet[T]): - """ - A `set`-like object that automatically rebuilds components whenever its - content changes. - - ## Examples - - ```python - class ElementAdder(rio.Component): - set: rio.Set[str] - - def build(self): - return rio.Button( - "add an element", - on_press=lambda: self.set.add(len(self.set)), - ) - - class Display(rio.Component): - set: rio.Set[str] - - def build(self): - return rio.Text("\n".join(self.set)) - - class SetDemo(rio.Component): - set: rio.Set[str] = rio.Set() - - def build(self): - return rio.Column( - ElementAdder(self.set), - Display(self.set), - ) - ``` - - Here you can see how the `Display` component automatically updates whenever - the `ElementAdder` component adds a new element to the `Set`. This would be - much trickier to do with a regular `set`. - - ## Metadata - - `experimental`: True - """ - - def __init__(self, items: t.Iterable[T] = (), /): - super().__init__() - - self._items = set(items) - - def __iter__(self) -> t.Iterator[T]: - self._mark_as_accessed() - return iter(self._items) - - def __len__(self) -> int: - self._mark_as_accessed() - return len(self._items) - - def __contains__(self, value: object) -> bool: - self._mark_as_accessed() - return value in self._items - - def add(self, value: T) -> None: - self._items.add(value) - self._mark_as_changed() - - def update(self, values: t.Iterable[T]) -> None: - self._items.update(values) - self._mark_as_changed() - - def discard(self, value: T) -> None: - self._items.discard(value) - self._mark_as_changed() - - def clear(self) -> None: - self._items.clear() - self._mark_as_changed() +from __future__ import annotations + +import collections.abc +import sys +import typing as t + +import rio + +from .. import global_state + +__all__ = ["List", "Dict", "Set"] + + +T = t.TypeVar("T") + + +def identity(x: T) -> T: + return x + + +class ObservableContainer: + def __init__(self): + self._affected_sessions: set[rio.Session] = set() + + def _mark_as_accessed(self): + if global_state.currently_building_session is None: + return + + global_state.accessed_objects.add(self) + self._affected_sessions.add(global_state.currently_building_session) + + def _mark_as_changed(self) -> None: + for session in self._affected_sessions: + session._changed_objects.add(self) + session._refresh_required_event.set() + + +class List(ObservableContainer, collections.abc.MutableSequence[T]): + """ + A `list`-like object that automatically rebuilds components whenever its + content changes. + + ## Examples + + ```python + class ElementAdder(rio.Component): + list: rio.List[str] + + def build(self): + return rio.Button( + "add an element", + on_press=lambda: self.list.append("foo"), + ) + + class Display(rio.Component): + list: rio.List[str] + + def build(self): + return rio.Text("\\n".join(self.list)) + + class ListDemo(rio.Component): + list: rio.List[str] = rio.List() + + def build(self): + return rio.Column( + ElementAdder(self.list), + Display(self.list), + ) + ``` + + Here you can see how the `Display` component automatically updates whenever + the `ElementAdder` component appends a new element to the `List`. This would + be much trickier to do with a regular `list`. + + ## Metadata + + `experimental`: True + """ + + def __init__(self, items: t.Iterable[T] = (), /): + super().__init__() + + self._items = list(items) + + def insert(self, index: int, value: T) -> None: + self._items.insert(index, value) + self._mark_as_changed() + + def append(self, value: T) -> None: + self._items.append(value) + self._mark_as_changed() + + def extend(self, values: t.Iterable[T]) -> None: + self._items.extend(values) + self._mark_as_changed() + + def remove(self, value: T) -> None: + self._items.remove(value) + self._mark_as_changed() + + def clear(self) -> None: + self._items.clear() + self._mark_as_changed() + + def pop(self, index: int | None = None, /) -> T: + self._mark_as_accessed() + + if index is None: + item = self._items.pop() + else: + item = self._items.pop(index) + + self._mark_as_changed() + + return item + + def reverse(self) -> None: + self._items.reverse() + self._mark_as_changed() + + def copy(self) -> List[T]: + self._mark_as_accessed() + return List(self) + + def __delitem__(self, index: int | slice) -> None: + del self._items[index] + self._mark_as_changed() + + def __add__(self, other: t.Iterable[T], /) -> List[T]: + result = List(self) + result += other + return result + + def __iadd__(self, other: t.Iterable[T]) -> List[T]: + self.extend(other) + return self + + def index(self, value: T, start: int = 0, stop: int = sys.maxsize) -> int: + self._mark_as_accessed() + return self._items.index(value, start, stop) + + def count(self, value: T) -> int: + self._mark_as_accessed() + return self._items.count(value) + + @t.overload + def __getitem__(self, index: int) -> T: ... + + @t.overload + def __getitem__(self, index: slice) -> List[T]: ... + + def __getitem__(self, index: int | slice) -> T | List[T]: + self._mark_as_accessed() + + if isinstance(index, slice): + return List(self._items[index]) + else: + return self._items[index] + + def __len__(self) -> int: + self._mark_as_accessed() + return len(self._items) + + def __iter__(self) -> t.Iterator[T]: + # TODO: Technically, no data has been accessed yet. The correct behavior + # would be to return a custom iterator that tracks access in its + # `__next__` method. + self._mark_as_accessed() + return iter(self._items) + + def __contains__(self, value: object) -> bool: + self._mark_as_accessed() + return value in self._items + + # These function signatures are a PITA. Screw the boilerplate, just inherit + # the signature + if t.TYPE_CHECKING: + __setitem__ = collections.abc.MutableSequence.__setitem__ + else: + + def sort(self, *args, **kwargs) -> None: + self._items.sort(*args, **kwargs) + self._mark_as_changed() + + def __setitem__(self, index_or_slice, value) -> None: + self._items[index_or_slice] = value + self._mark_as_changed() + + +K = t.TypeVar("K") +V = t.TypeVar("V") + + +class Dict(ObservableContainer, collections.abc.MutableMapping[K, V]): + """ + A `dict`-like object that automatically rebuilds components whenever its + content changes. + + ## Examples + + ```python + class ElementAdder(rio.Component): + dict: rio.Dict[int, str] + + def _add_element(self): + self.dict[len(self.dict)] = "foo" + + def build(self): + return rio.Button( + "add an element", + on_press=self._add_element, + ) + + class Display(rio.Component): + dict: rio.Dict[int, str] + + def build(self): + return rio.Text("\\n".join(f"{k}: {v}" for k, v in self.dict.items())) + + class DictDemo(rio.Component): + dict: rio.Dict[int, str] = rio.Dict() + + def build(self): + return rio.Column( + ElementAdder(self.dict), + Display(self.dict), + ) + ``` + + Here you can see how the `Display` component automatically updates whenever + the `ElementAdder` component adds a new element to the `Dict`. This would be + much trickier to do with a regular `dict`. + + ## Metadata + + `experimental`: True + """ + + def __init__( + self, + __items: t.Mapping[K, V] | t.Iterable[tuple[K, V]] = (), + /, + **kwargs: V, + ): + super().__init__() + + self._items = dict(__items, **kwargs) + + def __setitem__(self, key: K, value: V, /) -> None: + self._items[key] = value + self._mark_as_changed() + + def __delitem__(self, key: K, /) -> None: + del self._items[key] + self._mark_as_changed() + + def __getitem__(self, key: K, /) -> V: + self._mark_as_accessed() + return self._items[key] + + def __iter__(self) -> t.Iterator[K]: + self._mark_as_accessed() + return iter(self._items) + + def __len__(self) -> int: + self._mark_as_accessed() + return len(self._items) + + def __contains__(self, key: object, /) -> bool: + self._mark_as_accessed() + return key in self._items + + def popitem(self) -> tuple[K, V]: + self._mark_as_accessed() + + item = self._items.popitem() + self._mark_as_changed() + + return item + + # These function signatures are a PITA. Screw the boilerplate, just inherit + # the signature + if not t.TYPE_CHECKING: + + def update(self, *args, **kwargs) -> None: + self._items.update(*args, **kwargs) + self._mark_as_changed() + + def pop(self, *args, **kwargs): + self._mark_as_accessed() + + value = self._items.pop(*args, **kwargs) + self._mark_as_changed() + + return value + + +class Set(ObservableContainer, collections.abc.MutableSet[T]): + """ + A `set`-like object that automatically rebuilds components whenever its + content changes. + + ## Examples + + ```python + class ElementAdder(rio.Component): + set: rio.Set[str] + + def build(self): + return rio.Button( + "add an element", + on_press=lambda: self.set.add(len(self.set)), + ) + + class Display(rio.Component): + set: rio.Set[str] + + def build(self): + return rio.Text("\\n".join(self.set)) + + class SetDemo(rio.Component): + set: rio.Set[str] = rio.Set() + + def build(self): + return rio.Column( + ElementAdder(self.set), + Display(self.set), + ) + ``` + + Here you can see how the `Display` component automatically updates whenever + the `ElementAdder` component adds a new element to the `Set`. This would be + much trickier to do with a regular `set`. + + ## Metadata + + `experimental`: True + """ + + def __init__(self, items: t.Iterable[T] = (), /): + super().__init__() + + self._items = set(items) + + def __iter__(self) -> t.Iterator[T]: + self._mark_as_accessed() + return iter(self._items) + + def __len__(self) -> int: + self._mark_as_accessed() + return len(self._items) + + def __contains__(self, value: object) -> bool: + self._mark_as_accessed() + return value in self._items + + def add(self, value: T) -> None: + self._items.add(value) + self._mark_as_changed() + + def update(self, values: t.Iterable[T]) -> None: + self._items.update(values) + self._mark_as_changed() + + def discard(self, value: T) -> None: + self._items.discard(value) + self._mark_as_changed() + + def clear(self) -> None: + self._items.clear() + self._mark_as_changed() diff --git a/rio/session.py b/rio/session.py index e1be5ae8..f578a2e7 100644 --- a/rio/session.py +++ b/rio/session.py @@ -1936,6 +1936,16 @@ a.remove(); @property def theme(self) -> theme.Theme: + """ + The theme currently used by this session. + + If you've passed both a light and dark theme to your app, this will be + whichever one is actually being used by the client. You can also assign + a new theme to this property to change the theme for this session. + + Note that changing the theme will only affect this specific session, not + the entire app. + """ return self._theme @theme.setter @@ -3121,6 +3131,7 @@ a.remove(); properties_to_serialize: IdentityDefaultDict[object, set[str]], ): components_to_build = set[rio.Component]() + permanent_component_level_cache: dict[rio.Component, int] = {} while True: # Update the properties_to_serialize @@ -3135,19 +3146,8 @@ a.remove(); self._changed_items.clear() self._refresh_required_event.clear() - # We need to build parents before children, but some components - # haven't had their `_weak_parent_` set yet, so we don't know who - # their parent is. We need to find the topmost components and build - # them. - - # TODO: This is not entirely correct, because during the build - # process, new components can be instantiated or the level of an - # existing component can change. The correct solution would be to - # process one component, then call `_collect_components_to_build()` - # again, and sort again. TODO: I think this *is* correct now that we - # use parents instead of builders - - component_level_cache = {} + # Find the topmost components and build them + component_level_cache = permanent_component_level_cache.copy() components_by_level = collections.defaultdict[ int, list[rio.Component] ](list) @@ -3185,6 +3185,9 @@ a.remove(); yield from components_to_build_in_this_iteration + for component in components_to_build_in_this_iteration: + permanent_component_level_cache[component] = level_to_build + async def _refresh(self) -> None: """ Make sure the session state is up to date. Specifically: @@ -4066,9 +4069,9 @@ def find_components_for_reconciliation( yield old_component, new_component - # Compare the children, but make sure to preserve the topology. - # Can't just use `iter_direct_children` here, since that would - # discard topological information. + # Compare the children, but make sure to preserve the topology. Can't + # just use `iter_direct_children` here, since that would discard + # topological information. # # Also, in this context, "children" means *only* "components stored in # attributes", *not* "tree children". Reconciliation is about component diff --git a/rio/testing/browser_client.py b/rio/testing/browser_client.py index 62556746..74114fea 100644 --- a/rio/testing/browser_client.py +++ b/rio/testing/browser_client.py @@ -177,7 +177,7 @@ class BrowserClient(BaseClient): marker.style.top = `{y}px`; marker.style.transform = 'translate(-50%, -50%)'; document.body.appendChild(marker); - + setTimeout(() => {{ marker.remove(); }}, {sleep} * 1000); diff --git a/tests/test_frontend/test_layouting/test_html_and_body.py b/tests/test_frontend/test_layouting/test_html_and_body.py index bb222849..aad80306 100644 --- a/tests/test_frontend/test_layouting/test_html_and_body.py +++ b/tests/test_frontend/test_layouting/test_html_and_body.py @@ -58,10 +58,10 @@ async def test_3rd_party_elements_dont_affect_layout( ) assert user_content_height == pytest.approx( - await client.get_window_height() + await client.get_window_height(), abs=0.1 ) dev_tools_sidebar_width = await client.get_dev_tools_sidebar_width() assert user_content_width == pytest.approx( - await client.get_window_width() - dev_tools_sidebar_width + await client.get_window_width() - dev_tools_sidebar_width, abs=0.1 ) diff --git a/tsconfig.json b/tsconfig.json index aec426d7..7ee51914 100644 --- a/tsconfig.json +++ b/tsconfig.json @@ -3,7 +3,7 @@ "target": "es2022", "module": "es2022", "lib": ["es2021", "dom", "dom.iterable"], - "strict": false, // TODO: fix and enable strict checking + "strict": true, "moduleResolution": "node", "skipLibCheck": true, "useDefineForClassFields": false