diff --git a/CHANGELOG.md b/CHANGELOG.md index fbb4b8a577..3834b81711 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/examples/guide/reactivity/set_reactive03.py b/docs/examples/guide/reactivity/set_reactive03.py new file mode 100644 index 0000000000..80d3a45bd9 --- /dev/null +++ b/docs/examples/guide/reactivity/set_reactive03.py @@ -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() diff --git a/docs/guide/reactivity.md b/docs/guide/reactivity.md index 0d191ad832..ff7a1cebdf 100644 --- a/docs/guide/reactivity.md +++ b/docs/guide/reactivity.md @@ -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). diff --git a/src/textual/dom.py b/src/textual/dom.py index 0bb74876c2..bc75cadd86 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -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: @@ -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], diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 77593d683c..bce43092c9 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -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.""" diff --git a/src/textual/reactive.py b/src/textual/reactive.py index fb0224dad0..2507b14ab5 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -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"): @@ -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) @@ -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 diff --git a/src/textual/widget.py b/src/textual/widget.py index dfac89349c..b851b4b460 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -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: @@ -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: @@ -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 ) diff --git a/tests/test_reactive.py b/tests/test_reactive.py index 8a22d70a13..ad2da5724d 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -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() @@ -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"]]