Skip to content

Commit c0173f7

Browse files
authored
Merge pull request #4828 from Textualize/mutate-bind
mutate via data bind
2 parents 13ec4c3 + 3cdc653 commit c0173f7

File tree

4 files changed

+62
-3
lines changed

4 files changed

+62
-3
lines changed

docs/guide/reactivity.md

Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -410,7 +410,8 @@ Note the call to `mutate_reactive`. Without it, the display would not update whe
410410

411411
## Data binding
412412

413-
Reactive attributes from one widget may be *bound* (connected) to another widget, so that changes to a single reactive will automatically update another widget (potentially more than one).
413+
Reactive attributes may be *bound* (connected) to attributes on child widgets, so that changes to the parent are automatically reflected in the children.
414+
This can simplify working with compound widgets where the value of an attribute might be used in multiple places.
414415

415416
To bind reactive attributes, call [data_bind][textual.dom.DOMNode.data_bind] on a widget.
416417
This method accepts reactives (as class attributes) in positional arguments or keyword arguments.

src/textual/dom.py

Lines changed: 8 additions & 2 deletions
Original file line numberDiff line numberDiff line change
@@ -41,7 +41,7 @@
4141
from .css.styles import RenderStyles, Styles
4242
from .css.tokenize import IDENTIFIER
4343
from .message_pump import MessagePump
44-
from .reactive import Reactive, ReactiveError, _watch
44+
from .reactive import Reactive, ReactiveError, _Mutated, _watch
4545
from .timer import Timer
4646
from .walk import walk_breadth_first, walk_depth_first
4747
from .worker_manager import WorkerManager
@@ -253,6 +253,10 @@ def mutate_reactive(self, reactive: Reactive[ReactiveType]) -> None:
253253
this method after your reactive is updated. This will ensure that all the reactive _superpowers_
254254
work.
255255
256+
!!! note
257+
258+
This method will cause watchers to be called, even if the value hasn't changed.
259+
256260
Args:
257261
reactive: A reactive property (use the class scope syntax, i.e. `MyClass.my_reactive`).
258262
"""
@@ -279,6 +283,7 @@ def compose(self) -> ComposeResult:
279283
yield WorldClock("Asia/Tokyo").data_bind(WorldClockApp.time)
280284
```
281285
286+
282287
Raises:
283288
ReactiveError: If the data wasn't bound.
284289
@@ -334,7 +339,8 @@ def setter(value: object) -> None:
334339
"""Set bound data."""
335340
_rich_traceback_omit = True
336341
Reactive._initialize_object(self)
337-
setattr(self, variable_name, value)
342+
# Wrap the value in `_Mutated` so the setter knows to invoke watchers etc.
343+
setattr(self, variable_name, _Mutated(value))
338344

339345
return setter
340346

src/textual/reactive.py

Lines changed: 11 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -42,6 +42,13 @@
4242
ReactableType = TypeVar("ReactableType", bound="DOMNode")
4343

4444

45+
class _Mutated:
46+
"""A wrapper to indicate a value was mutated."""
47+
48+
def __init__(self, value: Any) -> None:
49+
self.value = value
50+
51+
4552
class ReactiveError(Exception):
4653
"""Base class for reactive errors."""
4754

@@ -273,6 +280,10 @@ def _set(self, obj: Reactable, value: ReactiveType, always: bool = False) -> Non
273280
f"Node is missing data; Check you are calling super().__init__(...) in the {obj.__class__.__name__}() constructor, before setting reactives."
274281
)
275282

283+
if isinstance(value, _Mutated):
284+
value = value.value
285+
always = True
286+
276287
self._initialize_reactive(obj, self.name)
277288

278289
if hasattr(obj, self.compute_name):

tests/test_reactive.py

Lines changed: 41 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -783,3 +783,44 @@ def compose(self) -> ComposeResult:
783783
widget.mutate_reactive(TestWidget.names)
784784
# Watcher should be invoked
785785
assert watched_names == [[], ["Paul"], ["Jessica"]]
786+
787+
788+
async def test_mutate_reactive_data_bind() -> None:
789+
"""https://github.com/Textualize/textual/issues/4825"""
790+
791+
# Record mutations to TestWidget.messages
792+
widget_messages: list[list[str]] = []
793+
794+
class TestWidget(Widget):
795+
messages: reactive[list[str]] = reactive(list, init=False)
796+
797+
def watch_messages(self, names: list[str]) -> None:
798+
widget_messages.append(names.copy())
799+
800+
class TestApp(App):
801+
messages: reactive[list[str]] = reactive(list, init=False)
802+
803+
def compose(self) -> ComposeResult:
804+
yield TestWidget().data_bind(TestApp.messages)
805+
806+
app = TestApp()
807+
async with app.run_test():
808+
test_widget = app.query_one(TestWidget)
809+
assert widget_messages == [[]]
810+
assert test_widget.messages == []
811+
812+
# Should be the same instance
813+
assert app.messages is test_widget.messages
814+
815+
# Mutate app
816+
app.messages.append("foo")
817+
# Mutations aren't detected
818+
assert widget_messages == [[]]
819+
assert app.messages == ["foo"]
820+
assert test_widget.messages == ["foo"]
821+
# Explicitly mutate app reactive
822+
app.mutate_reactive(TestApp.messages)
823+
# Mutating app, will also invoke watchers on any data binds
824+
assert widget_messages == [[], ["foo"]]
825+
assert app.messages == ["foo"]
826+
assert test_widget.messages == ["foo"]

0 commit comments

Comments
 (0)