From dbd277e899990271da6c7ea6af4a705b5ee678de Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 14 Jun 2024 17:39:05 +0100 Subject: [PATCH 1/6] recompose --- src/textual/_compose.py | 28 ++++++++++++++++++++++++++++ src/textual/app.py | 10 +++++++--- src/textual/dom.py | 13 +++++++++++++ src/textual/widget.py | 10 ++++++++-- 4 files changed, 56 insertions(+), 5 deletions(-) diff --git a/src/textual/_compose.py b/src/textual/_compose.py index 482b27fb6a..1d18b9e67d 100644 --- a/src/textual/_compose.py +++ b/src/textual/_compose.py @@ -76,3 +76,31 @@ def compose(node: App | Widget) -> list[Widget]: app._compose_stacks.pop() app._composed.pop() return nodes + + +def recompose(node: App | Widget) -> tuple[list[Widget], set[Widget]]: + """Recompose a node (nodes with a matching nodes will have their state copied). + + Args: + Node to be recomposed. + + Returns: + A list of new nodes, and a list of nodes to be removed. + """ + children: list[Widget] = list( + child for child in node.children if not child.has_class("-textual-system") + ) + children_by_id = {child.id: child for child in children if child.id is not None} + new_children: list[Widget] = [] + remove_children: set[Widget] = set(children) + for node in compose(node): + if node.id is None: + new_children.append(node) + else: + if (existing_child := children_by_id.pop(node.id, None)) is not None: + new_children.append(existing_child) + remove_children.discard(existing_child) + existing_child.copy_state(node) + else: + new_children.append(node) + return (new_children, remove_children) diff --git a/src/textual/app.py b/src/textual/app.py index a9237dc6ec..fa4118194d 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -70,6 +70,7 @@ from ._ansi_theme import ALABASTER, MONOKAI from ._callback import invoke from ._compose import compose +from ._compose import recompose as recompose_node from ._compositor import CompositorUpdate from ._context import active_app, active_message_pump from ._context import message_hook as message_hook_context_var @@ -2620,9 +2621,12 @@ async def recompose(self) -> None: if self._exit: return try: - async with self.screen.batch(): - await self.screen.query("*").exclude(".-textual-system").remove() - await self.screen.mount_all(compose(self)) + new_children, remove_children = recompose_node(self.screen) + print("new", new_children) + print("remove", remove_children) + await self.app._remove_nodes(list(remove_children), self.screen) + if self.screen.is_attached: + await self.screen.mount_all(new_children) except ScreenStackError: pass diff --git a/src/textual/dom.py b/src/textual/dom.py index c8243d1edd..c3f7e4c3f7 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -284,6 +284,19 @@ def compose(self) -> ComposeResult: self.call_later(self._initialize_data_bind) return self + def copy_state(self, node: DOMNode) -> None: + """Copy the state from another node. + + The default implementation will copy over reactives with the same name, + but this method may be override for different strategies. + + Args: + node: A node to copy state from. + """ + for key in node._reactives: + if key in self._reactives: + setattr(self, key, getattr(node, key)) + def _initialize_data_bind(self) -> None: """initialize a data binding. diff --git a/src/textual/widget.py b/src/textual/widget.py index 5c701695c3..8c2c0c1f5d 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -48,6 +48,7 @@ from ._animator import DEFAULT_EASING, Animatable, BoundAnimator, EasingFunction from ._arrange import DockArrangeResult, arrange from ._compose import compose +from ._compose import recompose as recompose_node from ._context import NoActiveAppError, active_app from ._easing import DEFAULT_SCROLL_EASING from ._layout import Layout @@ -1130,9 +1131,14 @@ async def recompose(self) -> None: if not self.is_attached: return async with self.batch(): - await self.query("*").exclude(".-textual-system").remove() + new_children, remove_children = recompose_node(self) + await self.app._remove_nodes(list(remove_children), self) if self.is_attached: - await self.mount_all(compose(self)) + await self.mount_all(new_children) + + # await self.query("*").exclude(".-textual-system").remove() + # if self.is_attached: + # await self.mount_all(compose(self)) def _post_register(self, app: App) -> None: """Called when the instance is registered. From 38ace6f62e3319e3c1a6a8954dd2eee3a6e2f9b3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 14 Jun 2024 17:57:07 +0100 Subject: [PATCH 2/6] debug --- src/textual/_compose.py | 23 +++++++++++++---------- src/textual/app.py | 3 +++ src/textual/widget.py | 2 ++ 3 files changed, 18 insertions(+), 10 deletions(-) diff --git a/src/textual/_compose.py b/src/textual/_compose.py index 1d18b9e67d..96b23e4e58 100644 --- a/src/textual/_compose.py +++ b/src/textual/_compose.py @@ -88,19 +88,22 @@ def recompose(node: App | Widget) -> tuple[list[Widget], set[Widget]]: A list of new nodes, and a list of nodes to be removed. """ children: list[Widget] = list( - child for child in node.children if not child.has_class("-textual-system") + child for child in node._nodes if not child.has_class("-textual-system") ) children_by_id = {child.id: child for child in children if child.id is not None} new_children: list[Widget] = [] remove_children: set[Widget] = set(children) - for node in compose(node): - if node.id is None: - new_children.append(node) + print("?", node) + for compose_node in compose(node): + print("!!", compose_node) + if ( + compose_node.id is not None + and (existing_child := children_by_id.pop(compose_node.id, None)) + is not None + ): + new_children.append(existing_child) + remove_children.discard(existing_child) + existing_child.copy_state(compose_node) else: - if (existing_child := children_by_id.pop(node.id, None)) is not None: - new_children.append(existing_child) - remove_children.discard(existing_child) - existing_child.copy_state(node) - else: - new_children.append(node) + new_children.append(compose_node) return (new_children, remove_children) diff --git a/src/textual/app.py b/src/textual/app.py index fa4118194d..c0863a729f 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2618,8 +2618,11 @@ async def recompose(self) -> None: Recomposing will remove children and call `self.compose` again to remount. """ + if self._exit: return + await self.screen.recompose() + return try: new_children, remove_children = recompose_node(self.screen) print("new", new_children) diff --git a/src/textual/widget.py b/src/textual/widget.py index 8c2c0c1f5d..1695273a9b 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1132,6 +1132,8 @@ async def recompose(self) -> None: return async with self.batch(): new_children, remove_children = recompose_node(self) + print("widget new", new_children) + print("widget remove", remove_children) await self.app._remove_nodes(list(remove_children), self) if self.is_attached: await self.mount_all(new_children) From e92507aba8988b1c434b2f73e16e5af1579b30e2 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 14 Jun 2024 18:21:16 +0100 Subject: [PATCH 3/6] app recompose --- src/textual/_compose.py | 11 ++++++----- src/textual/app.py | 15 +++++++-------- src/textual/widget.py | 9 ++------- 3 files changed, 15 insertions(+), 20 deletions(-) diff --git a/src/textual/_compose.py b/src/textual/_compose.py index 96b23e4e58..e8d9869d0c 100644 --- a/src/textual/_compose.py +++ b/src/textual/_compose.py @@ -78,11 +78,14 @@ def compose(node: App | Widget) -> list[Widget]: return nodes -def recompose(node: App | Widget) -> tuple[list[Widget], set[Widget]]: +def recompose( + node: App | Widget, compose_node: App | Widget | None = None +) -> tuple[list[Widget], set[Widget]]: """Recompose a node (nodes with a matching nodes will have their state copied). Args: - Node to be recomposed. + node: Node to be recomposed. + compose_node: Node where new nodes are composed, or `None` for the same node. Returns: A list of new nodes, and a list of nodes to be removed. @@ -93,9 +96,7 @@ def recompose(node: App | Widget) -> tuple[list[Widget], set[Widget]]: children_by_id = {child.id: child for child in children if child.id is not None} new_children: list[Widget] = [] remove_children: set[Widget] = set(children) - print("?", node) - for compose_node in compose(node): - print("!!", compose_node) + for compose_node in compose(node if compose_node is None else compose_node): if ( compose_node.id is not None and (existing_child := children_by_id.pop(compose_node.id, None)) diff --git a/src/textual/app.py b/src/textual/app.py index c0863a729f..be8ef47ec3 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2621,15 +2621,14 @@ async def recompose(self) -> None: if self._exit: return - await self.screen.recompose() - return + try: - new_children, remove_children = recompose_node(self.screen) - print("new", new_children) - print("remove", remove_children) - await self.app._remove_nodes(list(remove_children), self.screen) - if self.screen.is_attached: - await self.screen.mount_all(new_children) + screen = self.screen + new_children, remove_children = recompose_node(screen, self) + async with screen.batch(): + await self.app._remove_nodes(list(remove_children), self.screen) + if self.screen.is_attached: + await self.screen.mount_all(new_children) except ScreenStackError: pass diff --git a/src/textual/widget.py b/src/textual/widget.py index 1695273a9b..210d06634a 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -1132,16 +1132,11 @@ async def recompose(self) -> None: return async with self.batch(): new_children, remove_children = recompose_node(self) - print("widget new", new_children) - print("widget remove", remove_children) - await self.app._remove_nodes(list(remove_children), self) + if remove_children: + await self.app._remove_nodes(list(remove_children), self) if self.is_attached: await self.mount_all(new_children) - # await self.query("*").exclude(".-textual-system").remove() - # if self.is_attached: - # await self.mount_all(compose(self)) - def _post_register(self, app: App) -> None: """Called when the instance is registered. From a6e70c67d8891cfe8d58e426eca741fc88db4cb5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 14 Jun 2024 19:00:56 +0100 Subject: [PATCH 4/6] move to end --- CHANGELOG.md | 7 +++++++ src/textual/_node_list.py | 10 ++++++++++ src/textual/app.py | 4 +++- src/textual/dom.py | 8 +++++--- src/textual/reactive.py | 9 +++++++++ 5 files changed, 34 insertions(+), 4 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7a4854f20f..6003180822 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -5,6 +5,13 @@ All notable changes to this project will be documented in this file. The format is based on [Keep a Changelog](http://keepachangelog.com/) and this project adheres to [Semantic Versioning](http://semver.org/). +## Unreleased + +### Changed + +- Recompose will now keep widgets with matching IDs (with the same parent) +- Added `state` to Reactive + ## [0.68.0] - 2024-06-14 ### Added diff --git a/src/textual/_node_list.py b/src/textual/_node_list.py index ac9b425f32..6aff990e3d 100644 --- a/src/textual/_node_list.py +++ b/src/textual/_node_list.py @@ -119,6 +119,16 @@ def _insert(self, index: int, widget: Widget) -> None: self._nodes_by_id[widget_id] = widget self._updates += 1 + def _move_to_end(self, widget: Widget) -> None: + """Move a widget to the end. + + Args: + widget: Widget to move (should be in the list). + """ + if widget in self._nodes_set: + self._nodes.remove(widget) + self._nodes.append(widget) + def _ensure_unique_id(self, widget_id: str) -> None: if widget_id in self._nodes_by_id: raise DuplicateIds( diff --git a/src/textual/app.py b/src/textual/app.py index be8ef47ec3..7a09b6a44c 100644 --- a/src/textual/app.py +++ b/src/textual/app.py @@ -2718,7 +2718,9 @@ def _register( for widget in widget_list: if not isinstance(widget, Widget): raise AppError(f"Can't register {widget!r}; expected a Widget instance") - if widget not in self._registry: + if widget in self._registry: + parent._nodes._move_to_end(widget) + else: self._register_child(parent, widget, before, after) if widget._nodes: self._register(widget, *widget._nodes, cache=cache) diff --git a/src/textual/dom.py b/src/textual/dom.py index c3f7e4c3f7..61e2523ce9 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -293,9 +293,11 @@ def copy_state(self, node: DOMNode) -> None: Args: node: A node to copy state from. """ - for key in node._reactives: - if key in self._reactives: - setattr(self, key, getattr(node, key)) + for key, reactive in node._reactives.items(): + if reactive.state: + if key in self._reactives: + if hasattr(node, reactive.internal_name): + setattr(self, key, getattr(node, key)) def _initialize_data_bind(self) -> None: """initialize a data binding. diff --git a/src/textual/reactive.py b/src/textual/reactive.py index c9b153a743..d9de57a5c6 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -122,6 +122,7 @@ def __init__( compute: bool = True, recompose: bool = False, bindings: bool = False, + state: bool = True, ) -> None: self._default = default self._layout = layout @@ -132,6 +133,7 @@ def __init__( self._recompose = recompose self._bindings = bindings self._owner: Type[MessageTarget] | None = None + self.state = state def __rich_repr__(self) -> rich.repr.Result: yield self._default @@ -141,6 +143,7 @@ def __rich_repr__(self) -> rich.repr.Result: yield "always_update", self._always_update yield "compute", self._run_compute yield "recompose", self._recompose + yield "state", self.state @property def owner(self) -> Type[MessageTarget]: @@ -375,6 +378,7 @@ class reactive(Reactive[ReactiveType]): init: Call watchers on initialize (post mount). always_update: Call watchers even when the new value equals the old value. bindings: Refresh bindings when the reactive changes. + state: Include this value in `copy_state`. """ def __init__( @@ -387,6 +391,7 @@ def __init__( always_update: bool = False, recompose: bool = False, bindings: bool = False, + state: bool = True, ) -> None: super().__init__( default, @@ -396,6 +401,7 @@ def __init__( always_update=always_update, recompose=recompose, bindings=bindings, + state=state, ) @@ -407,6 +413,7 @@ class var(Reactive[ReactiveType]): init: Call watchers on initialize (post mount). always_update: Call watchers even when the new value equals the old value. bindings: Refresh bindings when the reactive changes. + state: Include this value in `copy_state`. """ def __init__( @@ -415,6 +422,7 @@ def __init__( init: bool = True, always_update: bool = False, bindings: bool = False, + state: bool = True, ) -> None: super().__init__( default, @@ -423,6 +431,7 @@ def __init__( init=init, always_update=always_update, bindings=bindings, + state=state, ) From 24080530ec940bf7899d95293eb09d681f4ce994 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sun, 16 Jun 2024 07:48:26 +0100 Subject: [PATCH 5/6] tests --- tests/test_recompose.py | 46 +++++++++++++++++++++++++++++++++++++++++ 1 file changed, 46 insertions(+) create mode 100644 tests/test_recompose.py diff --git a/tests/test_recompose.py b/tests/test_recompose.py new file mode 100644 index 0000000000..40ee406713 --- /dev/null +++ b/tests/test_recompose.py @@ -0,0 +1,46 @@ +from textual.app import App, ComposeResult +from textual.containers import Vertical +from textual.widgets import Input, Label + + +async def test_recompose_preserve_id(): + """Check recomposing leaves widgets with matching ID.""" + + class MyVertical(Vertical): + def compose(self) -> ComposeResult: + yield Label("foo", classes="foo", name="foo") + yield Input(id="bar", name="bar") + yield Label("baz", classes="baz", name="baz") + + class RecomposeApp(App): + def compose(self) -> ComposeResult: + yield MyVertical() + + app = RecomposeApp() + async with app.run_test() as pilot: + foo = app.query_one(".foo") + bar = app.query_one("#bar") + baz = app.query_one(".baz") + assert [node.name for node in app.query("MyVertical > *")] == [ + "foo", + "bar", + "baz", + ] + + bar.focus() + await pilot.press("h", "i") + await app.query_one(MyVertical).recompose() + + # Order shouldn't change + assert [node.name for node in app.query("MyVertical > *")] == [ + "foo", + "bar", + "baz", + ] + + # Check that only the ID is the same instance + assert app.query_one(".foo") is not foo # New instance + assert app.query_one("#bar") is bar # Matching id, so old instance + assert app.query_one(".baz") is not baz # new instance + + assert app.query_one("#bar", Input).value == "hi" From dd3ff8492e8b8264cd905fa2d36e9dfc5eb37195 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 17 Jun 2024 21:22:43 +0100 Subject: [PATCH 6/6] wip --- src/textual/_compose.py | 43 +++++++++++++++++++++++++++++ src/textual/_node_list.py | 10 +++++++ src/textual/widgets/_footer.py | 6 +++- src/textual/widgets/_placeholder.py | 1 + 4 files changed, 59 insertions(+), 1 deletion(-) diff --git a/src/textual/_compose.py b/src/textual/_compose.py index e8d9869d0c..50e26b3bea 100644 --- a/src/textual/_compose.py +++ b/src/textual/_compose.py @@ -78,6 +78,26 @@ def compose(node: App | Widget) -> list[Widget]: return nodes +def _pending_children( + node: Widget, children_by_id: dict[str, Widget] +) -> dict[str, Widget]: + def recurse_children(widget: Widget): + children: list[Widget] = list( + child + for child in widget._pending_children + if not child.has_class("-textual-system") + ) + + if widget.id is not None and widget.id not in children_by_id: + children_by_id[widget.id] = widget + for child in children: + recurse_children(child) + + recurse_children(node) + + return children_by_id + + def recompose( node: App | Widget, compose_node: App | Widget | None = None ) -> tuple[list[Widget], set[Widget]]: @@ -96,7 +116,25 @@ def recompose( children_by_id = {child.id: child for child in children if child.id is not None} new_children: list[Widget] = [] remove_children: set[Widget] = set(children) + + composed = compose(node if compose_node is None else compose_node) + for compose_node in compose(node if compose_node is None else compose_node): + _pending_children(compose_node, children_by_id) + + def recurse_pending(node: Widget) -> None: + print("recurse", node, children_by_id) + for child in list(node._nodes): + if child.id is not None and child.id in children_by_id: + print(1) + existing_child = children_by_id.pop(child.id) + if node._nodes._replace(child, existing_child): + remove_children.add(child) + else: + print(2) + recurse_pending(child) + + for compose_node in composed: if ( compose_node.id is not None and (existing_child := children_by_id.pop(compose_node.id, None)) @@ -107,4 +145,9 @@ def recompose( existing_child.copy_state(compose_node) else: new_children.append(compose_node) + recurse_pending(node) + + node.log(children_by_id) + print(new_children, remove_children) + return (new_children, remove_children) diff --git a/src/textual/_node_list.py b/src/textual/_node_list.py index f448a6f493..7e8f75c3cd 100644 --- a/src/textual/_node_list.py +++ b/src/textual/_node_list.py @@ -128,6 +128,16 @@ def _move_to_end(self, widget: Widget) -> None: if widget in self._nodes_set: self._nodes.remove(widget) self._nodes.append(widget) + self._updates += 1 + + def _replace(self, widget: Widget, new_widget: Widget) -> bool: + try: + index = self._nodes.index(widget) + except ValueError: + return False + else: + self._nodes[index] = new_widget + return True def _ensure_unique_id(self, widget_id: str) -> None: if widget_id in self._nodes_by_id: diff --git a/src/textual/widgets/_footer.py b/src/textual/widgets/_footer.py index aa39e3719e..2632c84bc2 100644 --- a/src/textual/widgets/_footer.py +++ b/src/textual/widgets/_footer.py @@ -9,6 +9,7 @@ from ..app import ComposeResult from ..binding import Binding from ..containers import ScrollableContainer +from ..dom import NoScreen from ..reactive import reactive from ..widget import Widget @@ -173,7 +174,10 @@ async def bindings_changed(screen: Screen) -> None: self.screen.bindings_updated_signal.subscribe(self, bindings_changed) def on_unmount(self) -> None: - self.screen.bindings_updated_signal.unsubscribe(self) + try: + self.screen.bindings_updated_signal.unsubscribe(self) + except NoScreen: + pass def watch_compact(self, compact: bool) -> None: self.set_class(compact, "-compact") diff --git a/src/textual/widgets/_placeholder.py b/src/textual/widgets/_placeholder.py index 3b210db921..0208d18994 100644 --- a/src/textual/widgets/_placeholder.py +++ b/src/textual/widgets/_placeholder.py @@ -143,6 +143,7 @@ def render(self) -> RenderResult: Returns: The value to render. """ + return str(id(self)) return self._renderables[self.variant] def cycle_variant(self) -> Self: