Skip to content

Commit

Permalink
Merge pull request #4828 from Textualize/mutate-bind
Browse files Browse the repository at this point in the history
mutate via data bind
  • Loading branch information
willmcgugan authored Aug 1, 2024
2 parents 13ec4c3 + 3cdc653 commit c0173f7
Show file tree
Hide file tree
Showing 4 changed files with 62 additions and 3 deletions.
3 changes: 2 additions & 1 deletion docs/guide/reactivity.md
Original file line number Diff line number Diff line change
Expand Up @@ -410,7 +410,8 @@ Note the call to `mutate_reactive`. Without it, the display would not update whe

## Data binding

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).
Reactive attributes may be *bound* (connected) to attributes on child widgets, so that changes to the parent are automatically reflected in the children.
This can simplify working with compound widgets where the value of an attribute might be used in multiple places.

To bind reactive attributes, call [data_bind][textual.dom.DOMNode.data_bind] on a widget.
This method accepts reactives (as class attributes) in positional arguments or keyword arguments.
Expand Down
10 changes: 8 additions & 2 deletions src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -41,7 +41,7 @@
from .css.styles import RenderStyles, Styles
from .css.tokenize import IDENTIFIER
from .message_pump import MessagePump
from .reactive import Reactive, ReactiveError, _watch
from .reactive import Reactive, ReactiveError, _Mutated, _watch
from .timer import Timer
from .walk import walk_breadth_first, walk_depth_first
from .worker_manager import WorkerManager
Expand Down Expand Up @@ -253,6 +253,10 @@ def mutate_reactive(self, reactive: Reactive[ReactiveType]) -> None:
this method after your reactive is updated. This will ensure that all the reactive _superpowers_
work.
!!! note
This method will cause watchers to be called, even if the value hasn't changed.
Args:
reactive: A reactive property (use the class scope syntax, i.e. `MyClass.my_reactive`).
"""
Expand All @@ -279,6 +283,7 @@ def compose(self) -> ComposeResult:
yield WorldClock("Asia/Tokyo").data_bind(WorldClockApp.time)
```
Raises:
ReactiveError: If the data wasn't bound.
Expand Down Expand Up @@ -334,7 +339,8 @@ def setter(value: object) -> None:
"""Set bound data."""
_rich_traceback_omit = True
Reactive._initialize_object(self)
setattr(self, variable_name, value)
# Wrap the value in `_Mutated` so the setter knows to invoke watchers etc.
setattr(self, variable_name, _Mutated(value))

return setter

Expand Down
11 changes: 11 additions & 0 deletions src/textual/reactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -42,6 +42,13 @@
ReactableType = TypeVar("ReactableType", bound="DOMNode")


class _Mutated:
"""A wrapper to indicate a value was mutated."""

def __init__(self, value: Any) -> None:
self.value = value


class ReactiveError(Exception):
"""Base class for reactive errors."""

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

if isinstance(value, _Mutated):
value = value.value
always = True

self._initialize_reactive(obj, self.name)

if hasattr(obj, self.compute_name):
Expand Down
41 changes: 41 additions & 0 deletions tests/test_reactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -783,3 +783,44 @@ def compose(self) -> ComposeResult:
widget.mutate_reactive(TestWidget.names)
# Watcher should be invoked
assert watched_names == [[], ["Paul"], ["Jessica"]]


async def test_mutate_reactive_data_bind() -> None:
"""https://github.com/Textualize/textual/issues/4825"""

# Record mutations to TestWidget.messages
widget_messages: list[list[str]] = []

class TestWidget(Widget):
messages: reactive[list[str]] = reactive(list, init=False)

def watch_messages(self, names: list[str]) -> None:
widget_messages.append(names.copy())

class TestApp(App):
messages: reactive[list[str]] = reactive(list, init=False)

def compose(self) -> ComposeResult:
yield TestWidget().data_bind(TestApp.messages)

app = TestApp()
async with app.run_test():
test_widget = app.query_one(TestWidget)
assert widget_messages == [[]]
assert test_widget.messages == []

# Should be the same instance
assert app.messages is test_widget.messages

# Mutate app
app.messages.append("foo")
# Mutations aren't detected
assert widget_messages == [[]]
assert app.messages == ["foo"]
assert test_widget.messages == ["foo"]
# Explicitly mutate app reactive
app.mutate_reactive(TestApp.messages)
# Mutating app, will also invoke watchers on any data binds
assert widget_messages == [[], ["foo"]]
assert app.messages == ["foo"]
assert test_widget.messages == ["foo"]

0 comments on commit c0173f7

Please sign in to comment.