From 92aca16b4c7ee496a915ab6f786ba5fbac29d61c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 11 Jul 2024 19:27:50 +0100 Subject: [PATCH 1/9] mutte --- src/textual/dom.py | 13 +++++++++++++ src/textual/reactive.py | 9 +++++++-- 2 files changed, 20 insertions(+), 2 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 0bb74876c2..42a8015438 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -237,6 +237,19 @@ 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. + + This will invoke any associated watchers. + + Args: + reactive (_type_): _description_ + """ + + internal_name = f"_reactive_{reactive.name}" + value = getattr(self, internal_name) + reactive._set(self, value) + def data_bind( self, *reactives: Reactive[Any], diff --git a/src/textual/reactive.py b/src/textual/reactive.py index fb0224dad0..8bbbc979e6 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 current_value != value or self._always_update: # 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 From 94426f7abc4203e329b62a6f4abdd75f3c347e16 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 11 Jul 2024 19:30:29 +0100 Subject: [PATCH 2/9] words --- src/textual/dom.py | 16 +++++++++++++--- 1 file changed, 13 insertions(+), 3 deletions(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 42a8015438..5d94a7f1b3 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: @@ -240,10 +240,20 @@ def set_reactive( def mutate_reactive(self, reactive: Reactive[ReactiveType]) -> None: """Force an update to a mutable reactive. - This will invoke any associated watchers. + 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). + 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 (_type_): _description_ + reactive: A reactive property (use the class scope syntax, i.e. `MyClass.my_reactive`). """ internal_name = f"_reactive_{reactive.name}" From d02b15bcb2e906946fdfef3282ae15d09f7de701 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 11 Jul 2024 19:32:31 +0100 Subject: [PATCH 3/9] words --- src/textual/dom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 5d94a7f1b3..6802ea0e6f 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -247,7 +247,7 @@ def mutate_reactive(self, reactive: Reactive[ReactiveType]) -> None: ``` 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). + 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. From 71f7277885acb886fd8e92364247ace52f51cf9e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Thu, 11 Jul 2024 19:33:12 +0100 Subject: [PATCH 4/9] lways --- src/textual/dom.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/dom.py b/src/textual/dom.py index 6802ea0e6f..bc75cadd86 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -258,7 +258,7 @@ def mutate_reactive(self, reactive: Reactive[ReactiveType]) -> None: internal_name = f"_reactive_{reactive.name}" value = getattr(self, internal_name) - reactive._set(self, value) + reactive._set(self, value, always=True) def data_bind( self, From 08c4aad92dc65ea9a4b6e0ab3bd5e726600520a3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 12 Jul 2024 15:23:29 +0100 Subject: [PATCH 5/9] added test --- CHANGELOG.md | 1 + src/textual/reactive.py | 2 +- tests/test_reactive.py | 41 ++++++++++++++++++++++++++++++++++++++++- 3 files changed, 42 insertions(+), 2 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index ed46c3a9f2..37c99a1851 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` ### Fixed diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 8bbbc979e6..2507b14ab5 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -287,7 +287,7 @@ def _set(self, obj: Reactable, value: ReactiveType, always: bool = False) -> Non 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 always or 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) 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"]] From 12aa4ed7229903279c7fa576a6741477e8304b06 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 12 Jul 2024 15:51:01 +0100 Subject: [PATCH 6/9] micro oprimizations --- src/textual/message_pump.py | 3 +-- src/textual/widget.py | 26 +++++++++++++++----------- 2 files changed, 16 insertions(+), 13 deletions(-) diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index f5b312b0f6..42bbe7df3f 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -298,8 +298,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/widget.py b/src/textual/widget.py index fcdbc8aa60..5c00c8099a 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: @@ -3714,20 +3718,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 ) From 78b2a6670a704b4151b87a4dbaf1c1708dd28c4a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 12 Jul 2024 16:09:05 +0100 Subject: [PATCH 7/9] docs --- CHANGELOG.md | 2 +- docs/guide/reactivity.md | 7 +++++++ 2 files changed, 8 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 37c99a1851..19387fcba2 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,7 +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` +- Added `DOMNode.mutate_reactive` https://github.com/Textualize/textual/pull/4731 ### Fixed diff --git a/docs/guide/reactivity.md b/docs/guide/reactivity.md index 0d191ad832..74a9be3e68 100644 --- a/docs/guide/reactivity.md +++ b/docs/guide/reactivity.md @@ -383,6 +383,13 @@ 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. + ## 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). From 4c11d09315f6c7c6494103d6717474a8ff8408e9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 12 Jul 2024 16:23:18 +0100 Subject: [PATCH 8/9] docs --- docs/guide/reactivity.md | 18 ++++++++++++++++++ 1 file changed, 18 insertions(+) diff --git a/docs/guide/reactivity.md b/docs/guide/reactivity.md index 74a9be3e68..ff7a1cebdf 100644 --- a/docs/guide/reactivity.md +++ b/docs/guide/reactivity.md @@ -390,6 +390,24 @@ In practice, this means that Textual can detect changes to basic types (int, flo 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). From ac860e93d42f6e647850e3e7055a0196670c8af3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Fri, 12 Jul 2024 16:23:46 +0100 Subject: [PATCH 9/9] example --- .../guide/reactivity/set_reactive03.py | 21 +++++++++++++++++++ 1 file changed, 21 insertions(+) create mode 100644 docs/examples/guide/reactivity/set_reactive03.py 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()