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

mutate reactive #4731

Merged
merged 11 commits into from
Jul 15, 2024
Merged
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
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added

- `TextArea.line_number_start` reactive attribute https://github.com/Textualize/textual/pull/4471
- Added `DOMNode.mutate_reactive` https://github.com/Textualize/textual/pull/4731
- Added "quality" parameter to `textual.color.Gradient` https://github.com/Textualize/textual/pull/4739
- Added `textual.color.Gradient.get_rich_color` https://github.com/Textualize/textual/pull/4739
- `Widget.remove_children` now accepts an iterable if widgets in addition to a selector https://github.com/Textualize/textual/issues/4735
Expand Down
21 changes: 21 additions & 0 deletions docs/examples/guide/reactivity/set_reactive03.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from textual.app import App, ComposeResult
from textual.reactive import reactive
from textual.widgets import Input, Label


class MultiGreet(App):
names: reactive[list[str]] = reactive(list, recompose=True) # (1)!

def compose(self) -> ComposeResult:
yield Input(placeholder="Give me a name")
for name in self.names:
yield Label(f"Hello, {name}")

def on_input_submitted(self, event: Input.Changed) -> None:
self.names.append(event.value)
self.mutate_reactive(MultiGreet.names) # (2)!


if __name__ == "__main__":
app = MultiGreet()
app.run()
25 changes: 25 additions & 0 deletions docs/guide/reactivity.md
Original file line number Diff line number Diff line change
Expand Up @@ -383,6 +383,31 @@ The following app contains a fix for this issue:

The line `self.set_reactive(Greeter.greeting, greeting)` sets the `greeting` attribute but doesn't immediately invoke the watcher.

## Mutable reactives

Textual can detect when you set a reactive to a new value, but it can't detect when you _mutate_ a value.
In practice, this means that Textual can detect changes to basic types (int, float, str, etc.), but not if you update a collection, such as a list or dict.

You can still use collections and other mutable objects in reactives, but you will need to call [`mutate_reactive`][textual.dom.DOMNode.mutate_reactive] after making changes for the reactive superpowers to work.

Here's an example, that uses a reactive list:

=== "set_reactive03.py"

```python hl_lines="16"
--8<-- "docs/examples/guide/reactivity/set_reactive03.py"
```

1. Creates a reactive list of strings.
2. Explicitly mutate the reactive list.

=== "Output"

```{.textual path="docs/examples/guide/reactivity/set_reactive03.py" press="W,i,l,l,enter"}
```

Note the call to `mutate_reactive`. Without it, the display would not update when a new name is appended to the list.

## 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).
Expand Down
25 changes: 24 additions & 1 deletion src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ def set_reactive(
```

Args:
name: Name of reactive attribute.
reactive: A reactive property (use the class scope syntax, i.e. `MyClass.my_reactive`).
value: New value of reactive.

Raises:
Expand All @@ -237,6 +237,29 @@ def set_reactive(
)
setattr(self, f"_reactive_{reactive.name}", value)

def mutate_reactive(self, reactive: Reactive[ReactiveType]) -> None:
"""Force an update to a mutable reactive.

Example:
```python
self.reactive_name_list.append("Jessica")
self.mutate_reactive(MyClass.reactive_name_list)
```

Textual will automatically detect when a reactive is set to a new value, but it is unable
to detect if a value is _mutated_ (such as updating a list, dict, or attribute of an object).
If you do wish to use a collection or other mutable object in a reactive, then you can call
this method after your reactive is updated. This will ensure that all the reactive _superpowers_
work.

Args:
reactive: A reactive property (use the class scope syntax, i.e. `MyClass.my_reactive`).
"""

internal_name = f"_reactive_{reactive.name}"
value = getattr(self, internal_name)
reactive._set(self, value, always=True)

def data_bind(
self,
*reactives: Reactive[Any],
Expand Down
3 changes: 1 addition & 2 deletions src/textual/message_pump.py
Original file line number Diff line number Diff line change
Expand Up @@ -297,8 +297,7 @@ def check_message_enabled(self, message: Message) -> bool:
`True` if the message will be sent, or `False` if it is disabled.
"""

enabled = type(message) not in self._disabled_messages
return enabled
return type(message) not in self._disabled_messages

def disable_messages(self, *messages: type[Message]) -> None:
"""Disable message types from being processed."""
Expand Down
9 changes: 7 additions & 2 deletions src/textual/reactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -262,7 +262,7 @@ def __get__(
else:
return getattr(obj, internal_name)

def __set__(self, obj: Reactable, value: ReactiveType) -> None:
def _set(self, obj: Reactable, value: ReactiveType, always: bool = False) -> None:
_rich_traceback_omit = True

if not hasattr(obj, "_id"):
Expand All @@ -287,7 +287,7 @@ def __set__(self, obj: Reactable, value: ReactiveType) -> None:
if callable(public_validate_function):
value = public_validate_function(value)
# If the value has changed, or this is the first time setting the value
if current_value != value or self._always_update:
if always or self._always_update or current_value != value:
# Store the internal value
setattr(obj, self.internal_name, value)

Expand All @@ -308,6 +308,11 @@ def __set__(self, obj: Reactable, value: ReactiveType) -> None:
recompose=self._recompose,
)

def __set__(self, obj: Reactable, value: ReactiveType) -> None:
_rich_traceback_omit = True

self._set(obj, value)

@classmethod
def _check_watchers(cls, obj: Reactable, name: str, old_value: Any) -> None:
"""Check watchers, and call watch methods / computes
Expand Down
26 changes: 15 additions & 11 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -107,6 +107,8 @@


_NULL_STYLE = Style()
_MOUSE_EVENTS_DISALLOW_IF_DISABLED = (events.MouseEvent, events.Enter, events.Leave)
_MOUSE_EVENTS_ALLOW_IF_DISABLED = (events.MouseScrollDown, events.MouseScrollUp)


class AwaitMount:
Expand Down Expand Up @@ -1782,11 +1784,13 @@ def virtual_region_with_margin(self) -> Region:
@property
def _self_or_ancestors_disabled(self) -> bool:
"""Is this widget or any of its ancestors disabled?"""
return any(
node.disabled
for node in self.ancestors_with_self
if isinstance(node, Widget)
)

node: Widget | None = self
while isinstance(node, Widget) and not node.is_dom_root:
if node.disabled:
return True
node = node._parent # type:ignore[assignment]
return False

@property
def focusable(self) -> bool:
Expand Down Expand Up @@ -3725,20 +3729,20 @@ def check_message_enabled(self, message: Message) -> bool:
`True` if the message will be sent, or `False` if it is disabled.
"""
# Do the normal checking and get out if that fails.
if not super().check_message_enabled(message):
return False
message_type = type(message)
if self._is_prevented(message_type):
if not super().check_message_enabled(message) or self._is_prevented(
type(message)
):
return False

# Mouse scroll events should always go through, this allows mouse
# wheel scrolling to pass through disabled widgets.
if isinstance(message, (events.MouseScrollDown, events.MouseScrollUp)):
if isinstance(message, _MOUSE_EVENTS_ALLOW_IF_DISABLED):
return True
# Otherwise, if this is any other mouse event, the widget receiving
# the event must not be disabled at this moment.
return (
not self._self_or_ancestors_disabled
if isinstance(message, (events.MouseEvent, events.Enter, events.Leave))
if isinstance(message, _MOUSE_EVENTS_DISALLOW_IF_DISABLED)
else True
)

Expand Down
41 changes: 40 additions & 1 deletion tests/test_reactive.py
Original file line number Diff line number Diff line change
Expand Up @@ -735,7 +735,6 @@ def on_test_widget_test_message(self, event: TestWidget.TestMessage) -> None:
message_senders.append(event._sender)

class TestApp(App[None]):

def compose(self) -> ComposeResult:
yield TestContainer()

Expand All @@ -744,3 +743,43 @@ def compose(self) -> ComposeResult:
pilot.app.query_one(TestWidget).make_reaction()
await pilot.pause()
assert message_senders == [pilot.app.query_one(TestWidget)]


async def test_mutate_reactive() -> None:
"""Test explicitly mutating reactives"""

watched_names: list[list[str]] = []

class TestWidget(Widget):
names: reactive[list[str]] = reactive(list)

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

class TestApp(App):
def compose(self) -> ComposeResult:
yield TestWidget()

app = TestApp()
async with app.run_test():
widget = app.query_one(TestWidget)
# watch method called on startup
assert watched_names == [[]]

# Mutate the list
widget.names.append("Paul")
# No changes expected
assert watched_names == [[]]
# Explicitly mutate the reactive
widget.mutate_reactive(TestWidget.names)
# Watcher will be invoked
assert watched_names == [[], ["Paul"]]
# Make further modifications
widget.names.append("Jessica")
widget.names.remove("Paul")
# No change expected
assert watched_names == [[], ["Paul"]]
# Explicit mutation
widget.mutate_reactive(TestWidget.names)
# Watcher should be invoked
assert watched_names == [[], ["Paul"], ["Jessica"]]
Loading