From 6dac453d8893b4533996441461f6f33aa55ab623 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 24 Feb 2024 15:25:07 +0000 Subject: [PATCH 01/25] decorators ahoy --- examples/calculator.py | 3 +- src/textual/reactive.py | 96 ++++++++++++++++++++++++++++++++++++----- tests/test_reactive.py | 38 ++++++++++++++++ 3 files changed, 125 insertions(+), 12 deletions(-) diff --git a/examples/calculator.py b/examples/calculator.py index 9c8f2f9e76..3d084cfc01 100644 --- a/examples/calculator.py +++ b/examples/calculator.py @@ -47,7 +47,8 @@ def compute_show_ac(self) -> bool: """Compute switch to show AC or C button""" return self.value in ("", "0") and self.numbers == "0" - def watch_show_ac(self, show_ac: bool) -> None: + @show_ac.watch + def _show_ac(self, show_ac: bool) -> None: """Called when show_ac changes.""" self.query_one("#c").display = not show_ac self.query_one("#ac").display = show_ac diff --git a/src/textual/reactive.py b/src/textual/reactive.py index ec6703835f..47a3221523 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -39,6 +39,8 @@ ReactiveType = TypeVar("ReactiveType") ReactableType = TypeVar("ReactableType", bound="DOMNode") +WatchMethodType = TypeVar("WatchMethodType") +ComputeMethodType = TypeVar("ComputeMethodType") class ReactiveError(Exception): @@ -49,6 +51,54 @@ class TooManyComputesError(ReactiveError): """Raised when an attribute has public and private compute methods.""" +class WatchDecorator(Generic[WatchMethodType]): + """Watch decorator.""" + + def __init__( + self, watches: list[tuple[WatchMethodType, bool]] | None = None + ) -> None: + self._watches = watches + + @overload + def __call__(self, *, init: bool = True) -> WatchDecorator[WatchMethodType]: ... + + @overload + def __call__(self, method: WatchMethodType) -> WatchMethodType: ... + + def __call__( + self, method: WatchMethodType | None = None, *, init: bool = True + ) -> WatchMethodType | WatchDecorator[WatchMethodType]: + if method is None: + return self + assert hasattr(method, "__name__") + if not method.__name__.startswith("watch_"): + if self._watches is not None: + self._watches.append((method, init)) + return method + + +class ComputeDecorator(Generic[WatchMethodType]): + """Watch decorator.""" + + def __init__(self, reactive: Reactive | None = None) -> None: + self._reactive = reactive + + @overload + def __call__(self, *, init: bool = True) -> ComputeDecorator[WatchMethodType]: ... + + @overload + def __call__(self, method: WatchMethodType) -> WatchMethodType: ... + + def __call__( + self, method: WatchMethodType | None = None, *, init: bool = True + ) -> WatchMethodType | ComputeDecorator[WatchMethodType]: + if method is None: + return self + if not method.__name__.startswith("compute_"): + self._reactive._compute_method = method + return method + + async def await_watcher(obj: Reactable, awaitable: Awaitable[object]) -> None: """Coroutine to await an awaitable returned from a watcher""" _rich_traceback_omit = True @@ -118,6 +168,8 @@ def __init__( self._always_update = always_update self._run_compute = compute self._owner: Type[MessageTarget] | None = None + self._watches: list[tuple[Callable, bool]] = [] + self._compute_method: Callable | None = None def __rich_repr__(self) -> rich.repr.Result: yield self._default @@ -170,6 +222,8 @@ def _initialize_object(cls, obj: Reactable) -> None: _rich_traceback_omit = True for name, reactive in obj._reactives.items(): reactive._initialize_reactive(obj, name) + for watch_method, init in reactive._watches: + obj.watch(obj, name, watch_method.__get__(obj), init=init) @classmethod def _reset_object(cls, obj: object) -> None: @@ -233,8 +287,14 @@ def __get__( if not hasattr(obj, internal_name): self._initialize_reactive(obj, self.name) - if hasattr(obj, self.compute_name): - value: ReactiveType + value: ReactiveType + if self._compute_method is not None: + old_value = getattr(obj, internal_name) + value = self._compute_method.__get__(obj)() + setattr(obj, internal_name, value) + self._check_watchers(obj, self.name, old_value) + return value + elif hasattr(obj, self.compute_name): old_value = getattr(obj, internal_name) value = getattr(obj, self.compute_name)() setattr(obj, internal_name, value) @@ -326,21 +386,35 @@ def _compute(cls, obj: Reactable) -> None: obj: Reactable object. """ _rich_traceback_guard = True - for compute in obj._reactives.keys(): - try: - compute_method = getattr(obj, f"compute_{compute}") - except AttributeError: + for name, reactive in obj._reactives.items(): + print(name, reactive) + if reactive._compute_method is not None: + compute_method = reactive._compute_method.__get__(obj) + else: try: - compute_method = getattr(obj, f"_compute_{compute}") + compute_method = getattr(obj, f"compute_{name}") except AttributeError: - continue + try: + compute_method = getattr(obj, f"_compute_{name}") + except AttributeError: + continue current_value = getattr( - obj, f"_reactive_{compute}", getattr(obj, f"_default_{compute}", None) + obj, f"_reactive_{name}", getattr(obj, f"_default_{name}", None) ) value = compute_method() - setattr(obj, f"_reactive_{compute}", value) + setattr(obj, f"_reactive_{name}", value) if value != current_value: - cls._check_watchers(obj, compute, current_value) + cls._check_watchers(obj, name, current_value) + + @property + def watch(self) -> WatchDecorator: + """A decorator to make a method a watch method.""" + return WatchDecorator(self._watches) + + @property + def compute(self) -> ComputeDecorator: + """A decorator to make a method a compute method.""" + return ComputeDecorator(self) class reactive(Reactive[ReactiveType]): diff --git a/tests/test_reactive.py b/tests/test_reactive.py index 7bddb3df79..41f2e5e5f1 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -705,3 +705,41 @@ def second_callback() -> None: assert logs == ["first", "second"] app.query_one(SomeWidget).test_var = 73 assert logs == ["first", "second", "first", "second"] + + +async def test_watch_decorator(): + """Test watchers defined via a decorator.""" + + class WatchApp(App): + count = reactive(0, init=False) + + watcher_call_count = 0 + + @count.watch + def _(self, value: int) -> None: + self.watcher_call_count = value + + app = WatchApp() + async with app.run_test(): + app.count += 1 + assert app.watcher_call_count == 1 + app.count += 1 + assert app.watcher_call_count == 2 + app.count -= 1 + assert app.watcher_call_count == 1 + app.count -= 1 + assert app.watcher_call_count == 0 + + +async def test_reactive_compute_decorator_first_time_set(): + class ReactiveComputeFirstTimeSet(App): + number = reactive(1) + double_number = reactive(None) + + @double_number.compute + def _double_number(self): + return self.number * 2 + + app = ReactiveComputeFirstTimeSet() + async with app.run_test(): + assert app.double_number == 2 From b28d4203c53d1a1a9d0df085f1b1d1dff233a571 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Sat, 24 Feb 2024 15:25:22 +0000 Subject: [PATCH 02/25] restore calculator --- examples/calculator.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/examples/calculator.py b/examples/calculator.py index 3d084cfc01..9c8f2f9e76 100644 --- a/examples/calculator.py +++ b/examples/calculator.py @@ -47,8 +47,7 @@ def compute_show_ac(self) -> bool: """Compute switch to show AC or C button""" return self.value in ("", "0") and self.numbers == "0" - @show_ac.watch - def _show_ac(self, show_ac: bool) -> None: + def watch_show_ac(self, show_ac: bool) -> None: """Called when show_ac changes.""" self.query_one("#c").display = not show_ac self.query_one("#ac").display = show_ac From 115e6d89cf6db3e8c5d1d852880de0ab6ab9c6be Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 26 Feb 2024 14:00:53 +0000 Subject: [PATCH 03/25] Add validate decorator --- src/textual/reactive.py | 41 ++++++++++++++++++++++++ tests/test_reactive.py | 71 +++++++++++++++++++++++++++++++++++++++++ 2 files changed, 112 insertions(+) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 47a3221523..d938667918 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -41,6 +41,7 @@ ReactableType = TypeVar("ReactableType", bound="DOMNode") WatchMethodType = TypeVar("WatchMethodType") ComputeMethodType = TypeVar("ComputeMethodType") +ValidateMethodType = TypeVar("ValidateMethodType") class ReactiveError(Exception): @@ -95,10 +96,42 @@ def __call__( if method is None: return self if not method.__name__.startswith("compute_"): + if self._reactive._compute_method is not None: + raise RuntimeError( + "Only a single method may be decorated with compute." + ) self._reactive._compute_method = method return method +class ValidateDecorator(Generic[ValidateMethodType]): + """Watch decorator.""" + + def __init__(self, reactive: Reactive | None = None) -> None: + self._reactive = reactive + + @overload + def __call__( + self, *, init: bool = True + ) -> ValidateDecorator[ValidateMethodType]: ... + + @overload + def __call__(self, method: ValidateMethodType) -> ValidateMethodType: ... + + def __call__( + self, method: ValidateMethodType | None = None, *, init: bool = True + ) -> ValidateMethodType | ValidateDecorator[ValidateMethodType]: + if method is None: + return self + if not method.__name__.startswith("validate_"): + if self._reactive._validate_method is not None: + raise RuntimeError( + "Only a single method may be decorated with validate." + ) + self._reactive._validate_method = method + return method + + async def await_watcher(obj: Reactable, awaitable: Awaitable[object]) -> None: """Coroutine to await an awaitable returned from a watcher""" _rich_traceback_omit = True @@ -170,6 +203,7 @@ def __init__( self._owner: Type[MessageTarget] | None = None self._watches: list[tuple[Callable, bool]] = [] self._compute_method: Callable | None = None + self._validate_method: Callable | None = None def __rich_repr__(self) -> rich.repr.Result: yield self._default @@ -327,6 +361,8 @@ def __set__(self, obj: Reactable, value: ReactiveType) -> None: public_validate_function = getattr(obj, f"validate_{name}", None) if callable(public_validate_function): value = public_validate_function(value) + if self._validate_method: + value = self._validate_method.__get__(self)(value) # If the value has changed, or this is the first time setting the value if current_value != value or self._always_update: # Store the internal value @@ -416,6 +452,11 @@ def compute(self) -> ComputeDecorator: """A decorator to make a method a compute method.""" return ComputeDecorator(self) + @property + def validate(self) -> ValidateDecorator: + """A decorator to make a method a validate method.""" + return ValidateDecorator(self) + class reactive(Reactive[ReactiveType]): """Create a reactive attribute. diff --git a/tests/test_reactive.py b/tests/test_reactive.py index 41f2e5e5f1..2e359aa51f 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -731,6 +731,43 @@ def _(self, value: int) -> None: assert app.watcher_call_count == 0 +async def test_compute_decorator() -> None: + """Check compute decorator""" + + class ComputeApp(App): + count = reactive(0, init=False) + double_count = reactive(0) + + @double_count.compute + def _double_count(self) -> int: + return self.count * 2 + + app = ComputeApp() + async with app.run_test(): + app.count = 1 + assert app.double_count == 2 + app.count = 2 + assert app.double_count == 4 + + +async def test_compute_decorator_error() -> None: + """Two compute decorators should result in an error.""" + + with pytest.raises(RuntimeError): + + class ComputeApp(App): + count = reactive(0, init=False) + double_count = reactive(0) + + @double_count.compute + def _double_count(self) -> int: + return self.count * 2 + + @double_count.compute + def _square_count(self) -> int: + return self.count**2 + + async def test_reactive_compute_decorator_first_time_set(): class ReactiveComputeFirstTimeSet(App): number = reactive(1) @@ -743,3 +780,37 @@ def _double_number(self): app = ReactiveComputeFirstTimeSet() async with app.run_test(): assert app.double_number == 2 + + +async def test_validate_decorator() -> None: + + class ValidateApp(App): + number = reactive(1) + + @number.validate + def max_ten(self, value: int) -> int: + return min(value, 10) + + app = ValidateApp() + async with app.run_test(): + app.number = 2 + assert app.number == 2 + app.number = 10 + assert app.number == 10 + + +async def test_validate_decorator_error() -> None: + """Two validate decorators results in a RuntimeError.""" + + with pytest.raises(RuntimeError): + + class ValidateApp(App): + number = reactive(1) + + @number.validate + def max_ten(self, value: int) -> int: + return min(value, 10) + + @number.validate + def max_twenty(self, value: int) -> int: + return min(value, 20) From 36f94b7b64a4ab4ff6ce1f7fe599bce5990f1797 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 26 Feb 2024 14:06:21 +0000 Subject: [PATCH 04/25] typing, remove print --- src/textual/reactive.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index d938667918..a014fb5ae9 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -95,6 +95,8 @@ def __call__( ) -> WatchMethodType | ComputeDecorator[WatchMethodType]: if method is None: return self + assert self._reactive is not None + assert hasattr(method, "__name__") if not method.__name__.startswith("compute_"): if self._reactive._compute_method is not None: raise RuntimeError( @@ -123,6 +125,8 @@ def __call__( ) -> ValidateMethodType | ValidateDecorator[ValidateMethodType]: if method is None: return self + assert self._reactive is not None + assert hasattr(method, "__name__") if not method.__name__.startswith("validate_"): if self._reactive._validate_method is not None: raise RuntimeError( @@ -423,7 +427,6 @@ def _compute(cls, obj: Reactable) -> None: """ _rich_traceback_guard = True for name, reactive in obj._reactives.items(): - print(name, reactive) if reactive._compute_method is not None: compute_method = reactive._compute_method.__get__(obj) else: From 78b831349aca95d2c7d088abde8ff0693e9e50e3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 26 Feb 2024 14:16:46 +0000 Subject: [PATCH 05/25] icon emoji --- src/textual/command.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/command.py b/src/textual/command.py index 94d3aa07dc..ffacce0c9f 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -390,7 +390,7 @@ class SearchIcon(Static, inherit_css=False): } """ - icon: var[str] = var(Emoji.replace(":magnifying_glass_tilted_right:")) + icon: var[str] = var(Emoji.replace("πŸ”Ž")) """The icon to display.""" def render(self) -> RenderableType: From 66074e85d47f27780b1a536e8561a84242391117 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 26 Feb 2024 14:17:54 +0000 Subject: [PATCH 06/25] test fix --- src/textual/command.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/command.py b/src/textual/command.py index ffacce0c9f..d4604e9916 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -384,6 +384,7 @@ class SearchIcon(Static, inherit_css=False): DEFAULT_CSS = """ SearchIcon { + color: #000; margin-left: 1; margin-top: 1; width: 2; From facc0877f2b6525e1e58d8194370d1d00b2c109d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 26 Feb 2024 14:33:42 +0000 Subject: [PATCH 07/25] removed import --- src/textual/command.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/textual/command.py b/src/textual/command.py index d4604e9916..81b7c24910 100644 --- a/src/textual/command.py +++ b/src/textual/command.py @@ -25,7 +25,6 @@ import rich.repr from rich.align import Align from rich.console import Group, RenderableType -from rich.emoji import Emoji from rich.style import Style from rich.text import Text from typing_extensions import Final, TypeAlias @@ -391,7 +390,7 @@ class SearchIcon(Static, inherit_css=False): } """ - icon: var[str] = var(Emoji.replace("πŸ”Ž")) + icon: var[str] = var("πŸ”Ž") """The icon to display.""" def render(self) -> RenderableType: From adaacb062108a241240a4751c094ab218126159b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Mon, 26 Feb 2024 14:57:35 +0000 Subject: [PATCH 08/25] snapshot fix --- .../__snapshots__/test_snapshots.ambr | 240 +++++++++--------- 1 file changed, 120 insertions(+), 120 deletions(-) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index f31002578f..eed92eab03 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -2992,137 +2992,137 @@ font-weight: 700; } - .terminal-174430999-matrix { + .terminal-454793765-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-174430999-title { + .terminal-454793765-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-174430999-r1 { fill: #a2a2a2 } - .terminal-174430999-r2 { fill: #c5c8c6 } - .terminal-174430999-r3 { fill: #004578 } - .terminal-174430999-r4 { fill: #e2e3e3 } - .terminal-174430999-r5 { fill: #00ff00 } - .terminal-174430999-r6 { fill: #24292f } - .terminal-174430999-r7 { fill: #1e1e1e } - .terminal-174430999-r8 { fill: #fea62b;font-weight: bold } + .terminal-454793765-r1 { fill: #a2a2a2 } + .terminal-454793765-r2 { fill: #c5c8c6 } + .terminal-454793765-r3 { fill: #004578 } + .terminal-454793765-r4 { fill: #e2e3e3 } + .terminal-454793765-r5 { fill: #00ff00 } + .terminal-454793765-r6 { fill: #000000 } + .terminal-454793765-r7 { fill: #1e1e1e } + .terminal-454793765-r8 { fill: #fea62b;font-weight: bold } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - CommandPaletteApp + CommandPaletteApp - + - - - - - β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–” - - πŸ”ŽA - - - This is a test of this code 9 - This is a test of this code 8 - This is a test of this code 7 - This is a test of this code 6 - This is a test of this code 5 - This is a test of this code 4 - This is a test of this code 3 - This is a test of this code 2 - This is a test of this code 1 - This is a test of this code 0 - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - + + + + + β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–” + + πŸ”ŽA + + + This is a test of this code 9 + This is a test of this code 8 + This is a test of this code 7 + This is a test of this code 6 + This is a test of this code 5 + This is a test of this code 4 + This is a test of this code 3 + This is a test of this code 2 + This is a test of this code 1 + This is a test of this code 0 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + @@ -3153,137 +3153,137 @@ font-weight: 700; } - .terminal-3083317776-matrix { + .terminal-929804574-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-3083317776-title { + .terminal-929804574-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-3083317776-r1 { fill: #a2a2a2 } - .terminal-3083317776-r2 { fill: #c5c8c6 } - .terminal-3083317776-r3 { fill: #004578 } - .terminal-3083317776-r4 { fill: #e2e3e3 } - .terminal-3083317776-r5 { fill: #00ff00 } - .terminal-3083317776-r6 { fill: #24292f } - .terminal-3083317776-r7 { fill: #1e1e1e } - .terminal-3083317776-r8 { fill: #777a7e } + .terminal-929804574-r1 { fill: #a2a2a2 } + .terminal-929804574-r2 { fill: #c5c8c6 } + .terminal-929804574-r3 { fill: #004578 } + .terminal-929804574-r4 { fill: #e2e3e3 } + .terminal-929804574-r5 { fill: #00ff00 } + .terminal-929804574-r6 { fill: #000000 } + .terminal-929804574-r7 { fill: #1e1e1e } + .terminal-929804574-r8 { fill: #777a7e } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - CommandPaletteApp + CommandPaletteApp - + - - - - - β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–” - - πŸ”ŽCommand Palette Search... - - - This is a test of this code 0 - This is a test of this code 1 - This is a test of this code 2 - This is a test of this code 3 - This is a test of this code 4 - This is a test of this code 5 - This is a test of this code 6 - This is a test of this code 7 - This is a test of this code 8 - This is a test of this code 9 - ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ - - - - + + + + + β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–”β–” + + πŸ”ŽCommand Palette Search... + + + This is a test of this code 0 + This is a test of this code 1 + This is a test of this code 2 + This is a test of this code 3 + This is a test of this code 4 + This is a test of this code 5 + This is a test of this code 6 + This is a test of this code 7 + This is a test of this code 8 + This is a test of this code 9 + ▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁▁ + + + + From 3685d551bee97534af3d24eb393389fe60fc6b69 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Feb 2024 10:54:32 +0000 Subject: [PATCH 09/25] type fixes docs --- src/textual/reactive.py | 28 ++++++++++++++++++++-------- 1 file changed, 20 insertions(+), 8 deletions(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index a014fb5ae9..59b82f03f0 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -53,7 +53,11 @@ class TooManyComputesError(ReactiveError): class WatchDecorator(Generic[WatchMethodType]): - """Watch decorator.""" + """Watch decorator. + + Decorate a method to make it a watcher. + + """ def __init__( self, watches: list[tuple[WatchMethodType, bool]] | None = None @@ -78,21 +82,25 @@ def __call__( return method -class ComputeDecorator(Generic[WatchMethodType]): - """Watch decorator.""" +class ComputeDecorator(Generic[ComputeMethodType]): + """Watch decorator. + + Decorate a widget method to make it a compute method. + + """ def __init__(self, reactive: Reactive | None = None) -> None: self._reactive = reactive @overload - def __call__(self, *, init: bool = True) -> ComputeDecorator[WatchMethodType]: ... + def __call__(self, *, init: bool = True) -> ComputeDecorator[ComputeMethodType]: ... @overload - def __call__(self, method: WatchMethodType) -> WatchMethodType: ... + def __call__(self, method: ComputeMethodType) -> ComputeMethodType: ... def __call__( - self, method: WatchMethodType | None = None, *, init: bool = True - ) -> WatchMethodType | ComputeDecorator[WatchMethodType]: + self, method: ComputeMethodType | None = None, *, init: bool = True + ) -> ComputeMethodType | ComputeDecorator[ComputeMethodType]: if method is None: return self assert self._reactive is not None @@ -107,7 +115,11 @@ def __call__( class ValidateDecorator(Generic[ValidateMethodType]): - """Watch decorator.""" + """Validate decorator. + + Decorate a Widget method to make it a validator for the attribute/ + + """ def __init__(self, reactive: Reactive | None = None) -> None: self._reactive = reactive From 70fbf4f3c6811ecc18b94d085454b5ebb73b9b4d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Feb 2024 15:06:17 +0000 Subject: [PATCH 10/25] tests and docs --- docs/guide/reactivity.md | 68 ++++++++++++++++++++++++++++++++++++++-- src/textual/reactive.py | 40 ++++++++++++++--------- tests/test_reactive.py | 52 ++++++++++++++++++++++++++++++ 3 files changed, 143 insertions(+), 17 deletions(-) diff --git a/docs/guide/reactivity.md b/docs/guide/reactivity.md index 40eacb6923..14bb23ce9f 100644 --- a/docs/guide/reactivity.md +++ b/docs/guide/reactivity.md @@ -163,6 +163,31 @@ A common use for this is to restrict numbers to a given range. The following exa If you click the buttons in the above example it will show the current count. When `self.count` is modified in the button handler, Textual runs `validate_count` which performs the validation to limit the value of count. +### Validate decorator + +In addition to the the naming convention, you can also define a validate method via decorator. +When in the class scope, reactives have a `validate` attribute which you can use to decorate any method and turn it into a validator. +The following example replaces the naming convention with an equivalent decorator: + +=== "validate02.py" + + ```python hl_lines="12-13" + --8<-- "docs/examples/guide/reactivity/validate02.py" + ``` + + 1. This makes the following method a validator for the `count` reactive. + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/validate02.py"} + ``` + +Note that when you use the decorator approach, the name of the method is not important. +In the example above we use an underscore to indicate the method doesn't need to be referenced outside of Textual's reactivity system. + +A benefit of the decorator is that it is refactor friendly. +If you were to use your IDEA to change the name of the decorator, it will also update the decorator. + ## Watch methods Watch methods are another superpower. @@ -171,7 +196,7 @@ Watch method names begin with `watch_` followed by the name of the attribute, an If the method accepts a single argument, it will be called with the new assigned value. If the method accepts *two* positional arguments, it will be called with both the *old* value and the *new* value. -The following app will display any color you type in to the input. Try it with a valid color in Textual CSS. For example `"darkorchid"` or `"#52de44"`. +The following app will display any color you type into the input. Try it with a valid color in Textual CSS. For example `"darkorchid"` or `"#52de44"`. === "watch01.py" @@ -196,6 +221,24 @@ The following app will display any color you type in to the input. Try it with a The color is parsed in `on_input_submitted` and assigned to `self.color`. Because `color` is reactive, Textual also calls `watch_color` with the old and new values. +### Watch decorator + +Like validate methods, watch methods may also be defined via a decorator. +The following examples replaces the naming convention (i.e. `watch_color`) with the equivalent decorator: + +=== "watch02.py" + + ```python hl_lines="17 18" + --8<-- "docs/examples/guide/reactivity/watch02.py" + ``` + + 1. The decorator defines a watch method for the `color` reactive attribute. + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/watch02.py" press="d,a,r,k,o,r,c,h,i,d"} + ``` + ### When are watch methods called? Textual only calls watch methods if the value of a reactive attribute _changes_. @@ -233,7 +276,7 @@ Compute methods are the final superpower offered by the `reactive` descriptor. T You could be forgiven in thinking this sounds a lot like Python's property decorator. The difference is that Textual will cache the value of compute methods, and update them when any other reactive attribute changes. -The following example uses a computed attribute. It displays three inputs for each color component (red, green, and blue). If you enter numbers in to these inputs, the background color of another widget changes. +The following example uses a computed attribute. It displays three inputs for each color component (red, green, and blue). If you enter numbers into these inputs, the background color of another widget changes. === "computed01.py" @@ -241,7 +284,7 @@ The following example uses a computed attribute. It displays three inputs for ea --8<-- "docs/examples/guide/reactivity/computed01.py" ``` - 1. Combines color components in to a Color object. + 1. Combines color components into a Color object. 2. The watch method is called when the _result_ of `compute_color` changes. === "computed01.tcss" @@ -267,6 +310,25 @@ When the result of `compute_color` changes, Textual will also call `watch_color` It is best to avoid doing anything slow or CPU-intensive in a compute method. Textual calls compute methods on an object when _any_ reactive attribute changes. +### Compute decorator + +Compute methods may also be defined by the `compute` decorator on reactives. +The following examples replaces the naming convention with an equivalent decorator: + +=== "computed02.py" + + ```python hl_lines="25-26 29-30" + --8<-- "docs/examples/guide/reactivity/computed02.py" + ``` + + 1. Defines a compute method for `color`. + 2. Defines a watch method for `color`. + +=== "Output" + + ```{.textual path="docs/examples/guide/reactivity/computed02.py"} + ``` + ## Setting reactives without superpowers You may find yourself in a situation where you want to set a reactive value, but you *don't* want to invoke watchers or the other super powers. diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 59b82f03f0..5f3ae5f09a 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -59,10 +59,8 @@ class WatchDecorator(Generic[WatchMethodType]): """ - def __init__( - self, watches: list[tuple[WatchMethodType, bool]] | None = None - ) -> None: - self._watches = watches + def __init__(self, reactive: Reactive | None = None) -> None: + self._reactive = reactive @overload def __call__(self, *, init: bool = True) -> WatchDecorator[WatchMethodType]: ... @@ -73,12 +71,18 @@ def __call__(self, method: WatchMethodType) -> WatchMethodType: ... def __call__( self, method: WatchMethodType | None = None, *, init: bool = True ) -> WatchMethodType | WatchDecorator[WatchMethodType]: + _rich_traceback_omit = True if method is None: return self + assert self._reactive is not None assert hasattr(method, "__name__") if not method.__name__.startswith("watch_"): - if self._watches is not None: - self._watches.append((method, init)) + if self._reactive._watch_method is not None: + raise RuntimeError( + "Only a single method may be decorator with watch (per-reactive)." + ) + self._reactive._watch_method = method + self._reactive._watch_method_init = init return method @@ -101,6 +105,7 @@ def __call__(self, method: ComputeMethodType) -> ComputeMethodType: ... def __call__( self, method: ComputeMethodType | None = None, *, init: bool = True ) -> ComputeMethodType | ComputeDecorator[ComputeMethodType]: + _rich_traceback_omit = True if method is None: return self assert self._reactive is not None @@ -108,7 +113,7 @@ def __call__( if not method.__name__.startswith("compute_"): if self._reactive._compute_method is not None: raise RuntimeError( - "Only a single method may be decorated with compute." + "Only a single method may be decorated with compute (per-reactive)." ) self._reactive._compute_method = method return method @@ -135,6 +140,7 @@ def __call__(self, method: ValidateMethodType) -> ValidateMethodType: ... def __call__( self, method: ValidateMethodType | None = None, *, init: bool = True ) -> ValidateMethodType | ValidateDecorator[ValidateMethodType]: + _rich_traceback_omit = True if method is None: return self assert self._reactive is not None @@ -142,7 +148,7 @@ def __call__( if not method.__name__.startswith("validate_"): if self._reactive._validate_method is not None: raise RuntimeError( - "Only a single method may be decorated with validate." + "Only a single method may be decorated with validate (per-reactive)." ) self._reactive._validate_method = method return method @@ -218,6 +224,8 @@ def __init__( self._run_compute = compute self._owner: Type[MessageTarget] | None = None self._watches: list[tuple[Callable, bool]] = [] + self._watch_method: Callable | None = None + self._watch_method_init: bool = False self._compute_method: Callable | None = None self._validate_method: Callable | None = None @@ -272,8 +280,13 @@ def _initialize_object(cls, obj: Reactable) -> None: _rich_traceback_omit = True for name, reactive in obj._reactives.items(): reactive._initialize_reactive(obj, name) - for watch_method, init in reactive._watches: - obj.watch(obj, name, watch_method.__get__(obj), init=init) + if reactive._watch_method is not None: + obj.watch( + obj, + name, + reactive._watch_method.__get__(obj), + init=reactive._watch_method_init, + ) @classmethod def _reset_object(cls, obj: object) -> None: @@ -460,7 +473,7 @@ def _compute(cls, obj: Reactable) -> None: @property def watch(self) -> WatchDecorator: """A decorator to make a method a watch method.""" - return WatchDecorator(self._watches) + return WatchDecorator(self) @property def compute(self) -> ComputeDecorator: @@ -545,9 +558,8 @@ def _watch( """ if not hasattr(obj, "__watchers"): setattr(obj, "__watchers", {}) - watchers: dict[str, list[tuple[Reactable, WatchCallbackType]]] = getattr( - obj, "__watchers" - ) + watchers: dict[str, list[tuple[Reactable, WatchCallbackType]]] + watchers = getattr(obj, "__watchers") watcher_list = watchers.setdefault(attribute_name, []) if any(callback == callback_from_list for _, callback_from_list in watcher_list): return diff --git a/tests/test_reactive.py b/tests/test_reactive.py index 2e359aa51f..04e284815f 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -731,6 +731,46 @@ def _(self, value: int) -> None: assert app.watcher_call_count == 0 +async def test_watch_decorator_multiple() -> None: + """Check multiple decorators on the same method.""" + + class WatchApp(App): + foo = reactive(0, init=False) + bar = reactive(0, init=False) + + watcher_call_count = 0 + + @foo.watch + @bar.watch + def _(self, value: int) -> None: + self.watcher_call_count = value + + app = WatchApp() + async with app.run_test(): + assert app.watcher_call_count == 0 + app.foo += 1 + assert app.watcher_call_count == 1 + app.bar = 2 + assert app.watcher_call_count == 2 + + +async def test_watch_decorator_multiple_duplicate() -> None: + """Check error is raised with duplicate watchers.""" + + with pytest.raises(RuntimeError): + + class WatchApp(App): + foo = reactive(0, init=False) + + @foo.watch + def _one(self, value: int) -> None: + self.watcher_call_count = value + + @foo.watch + def _two(self, value: int) -> None: + self.watcher_call_count = value + + async def test_compute_decorator() -> None: """Check compute decorator""" @@ -767,6 +807,18 @@ def _double_count(self) -> int: def _square_count(self) -> int: return self.count**2 + # Check two decorators on one method fails + with pytest.raises(RuntimeError): + + class ComputeApp(App): + count = reactive(0, init=False) + double_count = reactive(0) + + @double_count.compute + @double_count.compute + def _double_count(self) -> int: + return self.count * 2 + async def test_reactive_compute_decorator_first_time_set(): class ReactiveComputeFirstTimeSet(App): From 5c02c0e080925832783ecbe83b9e1a45a28d124c Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Feb 2024 15:10:04 +0000 Subject: [PATCH 11/25] supliment test --- tests/test_reactive.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/tests/test_reactive.py b/tests/test_reactive.py index 04e284815f..7ada437389 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -849,6 +849,8 @@ def max_ten(self, value: int) -> int: assert app.number == 2 app.number = 10 assert app.number == 10 + app.number = 11 + assert app.number == 10 async def test_validate_decorator_error() -> None: From f10d6eb198c8135d8f36bf3b315a2e3659e8efc3 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Feb 2024 15:11:14 +0000 Subject: [PATCH 12/25] doc fix --- docs/examples/guide/reactivity/computed01.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/examples/guide/reactivity/computed01.py b/docs/examples/guide/reactivity/computed01.py index 072d12c312..7e96c9a392 100644 --- a/docs/examples/guide/reactivity/computed01.py +++ b/docs/examples/guide/reactivity/computed01.py @@ -25,7 +25,7 @@ def compose(self) -> ComposeResult: def compute_color(self) -> Color: # (1)! return Color(self.red, self.green, self.blue).clamped - def watch_color(self, color: Color) -> None: # (2) + def watch_color(self, color: Color) -> None: # (2)! self.query_one("#color").styles.background = color def on_input_changed(self, event: Input.Changed) -> None: From d0ddc738d0efe39acc49619d9a4f308c9a14587e Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Feb 2024 15:12:47 +0000 Subject: [PATCH 13/25] superfluous --- src/textual/reactive.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 5f3ae5f09a..f4643c0e09 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -223,7 +223,6 @@ def __init__( self._always_update = always_update self._run_compute = compute self._owner: Type[MessageTarget] | None = None - self._watches: list[tuple[Callable, bool]] = [] self._watch_method: Callable | None = None self._watch_method_init: bool = False self._compute_method: Callable | None = None From 8963b3828c7e4f718f2a47ead478d46b3614efc9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Feb 2024 15:13:28 +0000 Subject: [PATCH 14/25] examples --- docs/examples/guide/reactivity/computed02.py | 49 ++++++++++++++++++++ docs/examples/guide/reactivity/validate02.py | 39 ++++++++++++++++ docs/examples/guide/reactivity/watch02.py | 34 ++++++++++++++ 3 files changed, 122 insertions(+) create mode 100644 docs/examples/guide/reactivity/computed02.py create mode 100644 docs/examples/guide/reactivity/validate02.py create mode 100644 docs/examples/guide/reactivity/watch02.py diff --git a/docs/examples/guide/reactivity/computed02.py b/docs/examples/guide/reactivity/computed02.py new file mode 100644 index 0000000000..fa2ec4de73 --- /dev/null +++ b/docs/examples/guide/reactivity/computed02.py @@ -0,0 +1,49 @@ +from textual.app import App, ComposeResult +from textual.color import Color +from textual.containers import Horizontal +from textual.reactive import reactive +from textual.widgets import Input, Static + + +class ComputedApp(App): + CSS_PATH = "computed01.tcss" + + red = reactive(0) + green = reactive(0) + blue = reactive(0) + color = reactive(Color.parse("transparent")) + + def compose(self) -> ComposeResult: + yield Horizontal( + Input("0", placeholder="Enter red 0-255", id="red"), + Input("0", placeholder="Enter green 0-255", id="green"), + Input("0", placeholder="Enter blue 0-255", id="blue"), + id="color-inputs", + ) + yield Static(id="color") + + @color.compute # (1)! + def _(self) -> Color: + return Color(self.red, self.green, self.blue).clamped + + @color.watch # (2)! + def _(self, color: Color) -> None: + self.query_one("#color").styles.background = color + + def on_input_changed(self, event: Input.Changed) -> None: + try: + component = int(event.value) + except ValueError: + self.bell() + else: + if event.input.id == "red": + self.red = component + elif event.input.id == "green": + self.green = component + else: + self.blue = component + + +if __name__ == "__main__": + app = ComputedApp() + app.run() diff --git a/docs/examples/guide/reactivity/validate02.py b/docs/examples/guide/reactivity/validate02.py new file mode 100644 index 0000000000..c2a54c8ef5 --- /dev/null +++ b/docs/examples/guide/reactivity/validate02.py @@ -0,0 +1,39 @@ +from textual.app import App, ComposeResult +from textual.containers import Horizontal +from textual.reactive import reactive +from textual.widgets import Button, RichLog + + +class ValidateApp(App): + CSS_PATH = "validate01.tcss" + + count = reactive(0) + + @count.validate # (1)! + def _(self, count: int) -> int: + """Validate value.""" + if count < 0: + count = 0 + elif count > 10: + count = 10 + return count + + def compose(self) -> ComposeResult: + yield Horizontal( + Button("+1", id="plus", variant="success"), + Button("-1", id="minus", variant="error"), + id="buttons", + ) + yield RichLog(highlight=True) + + def on_button_pressed(self, event: Button.Pressed) -> None: + if event.button.id == "plus": + self.count += 1 + else: + self.count -= 1 + self.query_one(RichLog).write(f"count = {self.count}") + + +if __name__ == "__main__": + app = ValidateApp() + app.run() diff --git a/docs/examples/guide/reactivity/watch02.py b/docs/examples/guide/reactivity/watch02.py new file mode 100644 index 0000000000..318fe3e008 --- /dev/null +++ b/docs/examples/guide/reactivity/watch02.py @@ -0,0 +1,34 @@ +from textual.app import App, ComposeResult +from textual.color import Color, ColorParseError +from textual.containers import Grid +from textual.reactive import reactive +from textual.widgets import Input, Static + + +class WatchApp(App): + CSS_PATH = "watch01.tcss" + + color = reactive(Color.parse("transparent")) + + def compose(self) -> ComposeResult: + yield Input(placeholder="Enter a color") + yield Grid(Static(id="old"), Static(id="new"), id="colors") + + @color.watch # (1)! + def _(self, old_color: Color, new_color: Color) -> None: + self.query_one("#old").styles.background = old_color + self.query_one("#new").styles.background = new_color + + def on_input_submitted(self, event: Input.Submitted) -> None: + try: + input_color = Color.parse(event.value) + except ColorParseError: + pass + else: + self.query_one(Input).value = "" + self.color = input_color + + +if __name__ == "__main__": + app = WatchApp() + app.run() From 8bfb48e0e794872902a3cee41121badb145f80f9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Feb 2024 15:14:17 +0000 Subject: [PATCH 15/25] changelog --- CHANGELOG.md | 1 + 1 file changed, 1 insertion(+) diff --git a/CHANGELOG.md b/CHANGELOG.md index 7928c4dcb5..16714fe731 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Mapping of ANSI colors to hex codes configurable via `App.ansi_theme_dark` and `App.ansi_theme_light` https://github.com/Textualize/textual/pull/4192 +- Added reactive decorators: `validate`, `watch`, and `compute` https://github.com/Textualize/textual/pull/4205 ### Fixed From 40917600d3d60911dd664c373675351dd5597cdc Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Feb 2024 15:15:27 +0000 Subject: [PATCH 16/25] word --- docs/guide/reactivity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/reactivity.md b/docs/guide/reactivity.md index 14bb23ce9f..61fd086b92 100644 --- a/docs/guide/reactivity.md +++ b/docs/guide/reactivity.md @@ -165,7 +165,7 @@ If you click the buttons in the above example it will show the current count. Wh ### Validate decorator -In addition to the the naming convention, you can also define a validate method via decorator. +In addition to the the naming convention, you can also define a validate method via a decorator. When in the class scope, reactives have a `validate` attribute which you can use to decorate any method and turn it into a validator. The following example replaces the naming convention with an equivalent decorator: From 0cc92171f86b9041fc7b732404f82ebad4be627a Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Tue, 27 Feb 2024 15:21:40 +0000 Subject: [PATCH 17/25] exampels in decorators --- src/textual/reactive.py | 43 ++++++++++++++++++++++++++++++++++++++--- 1 file changed, 40 insertions(+), 3 deletions(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index f4643c0e09..05aba87ed2 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -471,17 +471,54 @@ def _compute(cls, obj: Reactable) -> None: @property def watch(self) -> WatchDecorator: - """A decorator to make a method a watch method.""" + """A decorator to make a method a watch method. + + Example: + ```python + class MyWidget(Widget): + count = reactive(0) + @count.watch + def _count_changed(self, count:int) -> None: + # Called when count changes + ... + ``` + + """ return WatchDecorator(self) @property def compute(self) -> ComputeDecorator: - """A decorator to make a method a compute method.""" + """A decorator to make a method a compute method. + + Example: + ```python + class MyWidget(Widget): + count = reactive(0) + double = reactive(0) + + @double.compute + def _compute_double(self) -> int: + # Return double of count + return self.count * 2 + ``` + """ return ComputeDecorator(self) @property def validate(self) -> ValidateDecorator: - """A decorator to make a method a validate method.""" + """A decorator to make a method a validate method. + + Example: + ```python + class MyWidget(Widget): + count = reactive(0) + + @count.validate + def _positive(self, value:int) -> int: + # Don't allow count to go below zero + return max(0, value) + ``` + """ return ValidateDecorator(self) From 2053aec96fda91334c2456f810ef69edecc0bc2b Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Feb 2024 08:22:29 +0000 Subject: [PATCH 18/25] Update docs/guide/reactivity.md MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo GirΓ£o SerrΓ£o <5621605+rodrigogiraoserrao@users.noreply.github.com> --- docs/guide/reactivity.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/guide/reactivity.md b/docs/guide/reactivity.md index bf1acd1b1a..99791dc0f9 100644 --- a/docs/guide/reactivity.md +++ b/docs/guide/reactivity.md @@ -186,7 +186,7 @@ Note that when you use the decorator approach, the name of the method is not imp In the example above we use an underscore to indicate the method doesn't need to be referenced outside of Textual's reactivity system. A benefit of the decorator is that it is refactor friendly. -If you were to use your IDEA to change the name of the decorator, it will also update the decorator. +If you were to use your IDE to change the name of the reactive attribute, it will also update the decorator. ## Watch methods From de6dc6b8dbf55be897467ad5d8140daf639eb047 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Feb 2024 08:22:35 +0000 Subject: [PATCH 19/25] Update src/textual/reactive.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo GirΓ£o SerrΓ£o <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/reactive.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 3d6ad5a24d..6f4c0c7d6e 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -80,7 +80,7 @@ def __call__( if not method.__name__.startswith("watch_"): if self._reactive._watch_method is not None: raise RuntimeError( - "Only a single method may be decorator with watch (per-reactive)." + "Only a single method may be decorated with watch (per-reactive)." ) self._reactive._watch_method = method self._reactive._watch_method_init = init From 871f6336428d0d160d6438fff387747029c69fd8 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Feb 2024 08:22:45 +0000 Subject: [PATCH 20/25] Update src/textual/reactive.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo GirΓ£o SerrΓ£o <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/reactive.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 6f4c0c7d6e..e501d4593b 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -57,7 +57,6 @@ class WatchDecorator(Generic[WatchMethodType]): """Watch decorator. Decorate a method to make it a watcher. - """ def __init__(self, reactive: Reactive | None = None) -> None: From bc9654aa64b3a06bc4772223d0c84102dcbce886 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Feb 2024 08:22:51 +0000 Subject: [PATCH 21/25] Update src/textual/reactive.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo GirΓ£o SerrΓ£o <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/reactive.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index e501d4593b..d36c1350a3 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -90,7 +90,6 @@ class ComputeDecorator(Generic[ComputeMethodType]): """Watch decorator. Decorate a widget method to make it a compute method. - """ def __init__(self, reactive: Reactive | None = None) -> None: From 52a9484250f1369519c39971365ec2159dc42dff Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Feb 2024 08:22:56 +0000 Subject: [PATCH 22/25] Update src/textual/reactive.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo GirΓ£o SerrΓ£o <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/reactive.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index d36c1350a3..36ff17f22f 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -87,7 +87,7 @@ def __call__( class ComputeDecorator(Generic[ComputeMethodType]): - """Watch decorator. + """Compute decorator. Decorate a widget method to make it a compute method. """ From 2666e66f6890c28d3ac383579ed285c9034016a9 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Feb 2024 08:23:09 +0000 Subject: [PATCH 23/25] Update src/textual/reactive.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo GirΓ£o SerrΓ£o <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/reactive.py | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index 36ff17f22f..fcf2c8b322 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -121,8 +121,7 @@ def __call__( class ValidateDecorator(Generic[ValidateMethodType]): """Validate decorator. - Decorate a Widget method to make it a validator for the attribute/ - + Decorate a Widget method to make it a validator for the attribute. """ def __init__(self, reactive: Reactive | None = None) -> None: From 9aca1900eb3d8004a6eb01c8abe1e2c9f82e9bd5 Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Feb 2024 08:52:46 +0000 Subject: [PATCH 24/25] remove init --- src/textual/reactive.py | 18 +++---- tests/test_reactive.py | 107 ++++++++++++++++++++++++++++++++++++++-- 2 files changed, 110 insertions(+), 15 deletions(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index fcf2c8b322..d38b211f06 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -63,13 +63,13 @@ def __init__(self, reactive: Reactive | None = None) -> None: self._reactive = reactive @overload - def __call__(self, *, init: bool = True) -> WatchDecorator[WatchMethodType]: ... + def __call__(self) -> WatchDecorator[WatchMethodType]: ... @overload def __call__(self, method: WatchMethodType) -> WatchMethodType: ... def __call__( - self, method: WatchMethodType | None = None, *, init: bool = True + self, method: WatchMethodType | None = None ) -> WatchMethodType | WatchDecorator[WatchMethodType]: _rich_traceback_omit = True if method is None: @@ -82,7 +82,6 @@ def __call__( "Only a single method may be decorated with watch (per-reactive)." ) self._reactive._watch_method = method - self._reactive._watch_method_init = init return method @@ -96,13 +95,13 @@ def __init__(self, reactive: Reactive | None = None) -> None: self._reactive = reactive @overload - def __call__(self, *, init: bool = True) -> ComputeDecorator[ComputeMethodType]: ... + def __call__(self) -> ComputeDecorator[ComputeMethodType]: ... @overload def __call__(self, method: ComputeMethodType) -> ComputeMethodType: ... def __call__( - self, method: ComputeMethodType | None = None, *, init: bool = True + self, method: ComputeMethodType | None = None ) -> ComputeMethodType | ComputeDecorator[ComputeMethodType]: _rich_traceback_omit = True if method is None: @@ -128,15 +127,13 @@ def __init__(self, reactive: Reactive | None = None) -> None: self._reactive = reactive @overload - def __call__( - self, *, init: bool = True - ) -> ValidateDecorator[ValidateMethodType]: ... + def __call__(self) -> ValidateDecorator[ValidateMethodType]: ... @overload def __call__(self, method: ValidateMethodType) -> ValidateMethodType: ... def __call__( - self, method: ValidateMethodType | None = None, *, init: bool = True + self, method: ValidateMethodType | None = None ) -> ValidateMethodType | ValidateDecorator[ValidateMethodType]: _rich_traceback_omit = True if method is None: @@ -231,7 +228,6 @@ def __init__( self._recompose = recompose self._owner: Type[MessageTarget] | None = None self._watch_method: Callable | None = None - self._watch_method_init: bool = False self._compute_method: Callable | None = None self._validate_method: Callable | None = None @@ -292,7 +288,7 @@ def _initialize_object(cls, obj: Reactable) -> None: obj, name, reactive._watch_method.__get__(obj), - init=reactive._watch_method_init, + init=reactive._init, ) @classmethod diff --git a/tests/test_reactive.py b/tests/test_reactive.py index f08b3674b0..6aceb78ee0 100644 --- a/tests/test_reactive.py +++ b/tests/test_reactive.py @@ -719,18 +719,79 @@ class WatchApp(App): @count.watch def _(self, value: int) -> None: - self.watcher_call_count = value + self.watcher_call_count += 1 app = WatchApp() async with app.run_test(): app.count += 1 assert app.watcher_call_count == 1 + assert app.count == 1 app.count += 1 assert app.watcher_call_count == 2 + assert app.count == 2 + app.count -= 1 + assert app.watcher_call_count == 3 + assert app.count == 1 app.count -= 1 + assert app.watcher_call_count == 4 + assert app.count == 0 + + +async def test_watch_decorator_call(): + """Test watchers defined via a decorator, that is called.""" + + class WatchApp(App): + count = reactive(0, init=False) + + watcher_call_count = 0 + + @count.watch() + def _(self, value: int) -> None: + self.watcher_call_count += 1 + + app = WatchApp() + async with app.run_test(): + app.count += 1 assert app.watcher_call_count == 1 + assert app.count == 1 + app.count += 1 + assert app.watcher_call_count == 2 + assert app.count == 2 app.count -= 1 - assert app.watcher_call_count == 0 + assert app.watcher_call_count == 3 + assert app.count == 1 + app.count -= 1 + assert app.watcher_call_count == 4 + assert app.count == 0 + + +async def test_watch_decorator_init_true(): + """Test watchers defined via a decorator, that is called.""" + + class WatchApp(App): + count = reactive(0, init=True) + + watcher_call_count = 0 + + @count.watch + def _(self, value: int) -> None: + self.watcher_call_count += 1 + + app = WatchApp() + async with app.run_test(): + assert app.watcher_call_count == 1 + app.count += 1 + assert app.watcher_call_count == 2 + assert app.count == 1 + app.count += 1 + assert app.watcher_call_count == 3 + assert app.count == 2 + app.count -= 1 + assert app.watcher_call_count == 4 + assert app.count == 1 + app.count -= 1 + assert app.watcher_call_count == 5 + assert app.count == 0 async def test_watch_decorator_multiple() -> None: @@ -743,7 +804,7 @@ class WatchApp(App): watcher_call_count = 0 @foo.watch - @bar.watch + @bar.watch() def _(self, value: int) -> None: self.watcher_call_count = value @@ -792,6 +853,25 @@ def _double_count(self) -> int: assert app.double_count == 4 +async def test_compute_decorator_call() -> None: + """Check compute decorator when called""" + + class ComputeApp(App): + count = reactive(0, init=False) + double_count = reactive(0) + + @double_count.compute() + def _double_count(self) -> int: + return self.count * 2 + + app = ComputeApp() + async with app.run_test(): + app.count = 1 + assert app.double_count == 2 + app.count = 2 + assert app.double_count == 4 + + async def test_compute_decorator_error() -> None: """Two compute decorators should result in an error.""" @@ -855,6 +935,25 @@ def max_ten(self, value: int) -> int: assert app.number == 10 +async def test_validate_decorator_called() -> None: + + class ValidateApp(App): + number = reactive(1) + + @number.validate() + def max_ten(self, value: int) -> int: + return min(value, 10) + + app = ValidateApp() + async with app.run_test(): + app.number = 2 + assert app.number == 2 + app.number = 10 + assert app.number == 10 + app.number = 11 + assert app.number == 10 + + async def test_validate_decorator_error() -> None: """Two validate decorators results in a RuntimeError.""" @@ -871,6 +970,7 @@ def max_ten(self, value: int) -> int: def max_twenty(self, value: int) -> int: return min(value, 20) + async def test_message_sender_from_reactive() -> None: """Test that the sender of a message comes from the reacting widget.""" @@ -906,4 +1006,3 @@ def compose(self) -> ComposeResult: pilot.app.query_one(TestWidget).make_reaction() await pilot.pause() assert message_senders == [pilot.app.query_one(TestWidget)] - From c96125b16f8795558bd9acf6419a160213cfba6d Mon Sep 17 00:00:00 2001 From: Will McGugan Date: Wed, 28 Feb 2024 08:58:49 +0000 Subject: [PATCH 25/25] Update src/textual/reactive.py MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo GirΓ£o SerrΓ£o <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/reactive.py | 1 - 1 file changed, 1 deletion(-) diff --git a/src/textual/reactive.py b/src/textual/reactive.py index d38b211f06..4030bb4c5f 100644 --- a/src/textual/reactive.py +++ b/src/textual/reactive.py @@ -490,7 +490,6 @@ def _count_changed(self, count:int) -> None: # Called when count changes ... ``` - """ return WatchDecorator(self)