Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

recompose #4650

Draft
wants to merge 7 commits into
base: main
Choose a base branch
from
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 5 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -7,6 +7,11 @@ 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

### Fixed

- Fixed erroneous mouse 'ButtonDown' reporting for mouse movement when any-event mode is enabled in xterm. https://github.com/Textualize/textual/pull/3647
Expand Down
75 changes: 75 additions & 0 deletions src/textual/_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -76,3 +76,78 @@ def compose(node: App | Widget) -> list[Widget]:
app._compose_stacks.pop()
app._composed.pop()
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]]:
"""Recompose a node (nodes with a matching nodes will have their state copied).

Args:
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.
"""
children: list[Widget] = list(
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)

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))
is not None
):
new_children.append(existing_child)
remove_children.discard(existing_child)
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)
20 changes: 20 additions & 0 deletions src/textual/_node_list.py
Original file line number Diff line number Diff line change
Expand Up @@ -119,6 +119,26 @@ 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)
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:
raise DuplicateIds(
Expand Down
16 changes: 12 additions & 4 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -2627,12 +2628,17 @@ async def recompose(self) -> None:

Recomposing will remove children and call `self.compose` again to remount.
"""

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))
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

Expand Down Expand Up @@ -2722,7 +2728,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)
Expand Down
15 changes: 15 additions & 0 deletions src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -284,6 +284,21 @@ 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, 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.

Expand Down
9 changes: 9 additions & 0 deletions src/textual/reactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand All @@ -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
Expand All @@ -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]:
Expand Down Expand Up @@ -379,6 +382,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__(
Expand All @@ -391,6 +395,7 @@ def __init__(
always_update: bool = False,
recompose: bool = False,
bindings: bool = False,
state: bool = True,
) -> None:
super().__init__(
default,
Expand All @@ -400,6 +405,7 @@ def __init__(
always_update=always_update,
recompose=recompose,
bindings=bindings,
state=state,
)


Expand All @@ -411,6 +417,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__(
Expand All @@ -419,6 +426,7 @@ def __init__(
init: bool = True,
always_update: bool = False,
bindings: bool = False,
state: bool = True,
) -> None:
super().__init__(
default,
Expand All @@ -427,6 +435,7 @@ def __init__(
init=init,
always_update=always_update,
bindings=bindings,
state=state,
)


Expand Down
7 changes: 5 additions & 2 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -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
Expand Down Expand Up @@ -1138,9 +1139,11 @@ 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)
if remove_children:
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)

def _post_register(self, app: App) -> None:
"""Called when the instance is registered.
Expand Down
6 changes: 5 additions & 1 deletion src/textual/widgets/_footer.py
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand Down Expand Up @@ -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")
1 change: 1 addition & 0 deletions src/textual/widgets/_placeholder.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
46 changes: 46 additions & 0 deletions tests/test_recompose.py
Original file line number Diff line number Diff line change
@@ -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"
Loading