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

Decorator madness #4205

Open
wants to merge 29 commits into
base: main
Choose a base branch
from
Open
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
29 commits
Select commit Hold shift + click to select a range
6dac453
decorators ahoy
willmcgugan Feb 24, 2024
b28d420
restore calculator
willmcgugan Feb 24, 2024
115e6d8
Add validate decorator
willmcgugan Feb 26, 2024
61f3480
Merge branch 'main' into decorator-madness
willmcgugan Feb 26, 2024
36f94b7
typing, remove print
willmcgugan Feb 26, 2024
78b8313
icon emoji
willmcgugan Feb 26, 2024
66074e8
test fix
willmcgugan Feb 26, 2024
facc087
removed import
willmcgugan Feb 26, 2024
adaacb0
snapshot fix
willmcgugan Feb 26, 2024
b3aa08b
command fix
willmcgugan Feb 27, 2024
3685d55
type fixes docs
willmcgugan Feb 27, 2024
70fbf4f
tests and docs
willmcgugan Feb 27, 2024
5c02c0e
supliment test
willmcgugan Feb 27, 2024
f10d6eb
doc fix
willmcgugan Feb 27, 2024
d0ddc73
superfluous
willmcgugan Feb 27, 2024
8963b38
examples
willmcgugan Feb 27, 2024
8bfb48e
changelog
willmcgugan Feb 27, 2024
4091760
word
willmcgugan Feb 27, 2024
0cc9217
exampels in decorators
willmcgugan Feb 27, 2024
444fc82
Merge branch 'main' into decorator-madness
willmcgugan Feb 27, 2024
fdbb80d
Merge branch 'main' into decorator-madness
willmcgugan Feb 27, 2024
2053aec
Update docs/guide/reactivity.md
willmcgugan Feb 28, 2024
de6dc6b
Update src/textual/reactive.py
willmcgugan Feb 28, 2024
871f633
Update src/textual/reactive.py
willmcgugan Feb 28, 2024
bc9654a
Update src/textual/reactive.py
willmcgugan Feb 28, 2024
52a9484
Update src/textual/reactive.py
willmcgugan Feb 28, 2024
2666e66
Update src/textual/reactive.py
willmcgugan Feb 28, 2024
9aca190
remove init
willmcgugan Feb 28, 2024
c96125b
Update src/textual/reactive.py
willmcgugan Feb 28, 2024
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Jump to
Jump to file
Failed to load files.
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

- 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

Expand Down
2 changes: 1 addition & 1 deletion docs/examples/guide/reactivity/computed01.py
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
49 changes: 49 additions & 0 deletions docs/examples/guide/reactivity/computed02.py
Original file line number Diff line number Diff line change
@@ -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()
39 changes: 39 additions & 0 deletions docs/examples/guide/reactivity/validate02.py
Original file line number Diff line number Diff line change
@@ -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()
34 changes: 34 additions & 0 deletions docs/examples/guide/reactivity/watch02.py
Original file line number Diff line number Diff line change
@@ -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()
68 changes: 65 additions & 3 deletions docs/guide/reactivity.md
Original file line number Diff line number Diff line change
Expand Up @@ -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 a decorator.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
In addition to the the naming convention, you can also define a validate method via a decorator.
In addition to 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:

=== "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 IDE to change the name of the reactive attribute, it will also update the decorator.

## Watch methods

Watch methods are another superpower.
Expand All @@ -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"

Expand All @@ -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_.
Expand Down Expand Up @@ -311,15 +354,15 @@ 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.
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
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.
The following example uses a computed attribute. It displays one input for each color component (red, green, and blue). If you enter numbers into these inputs, the background color of another widget changes.
Suggested change
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.
The following example uses a computed attribute. It displays three inputs, one for each color component (red, green, and blue). If you enter numbers into these inputs, the background color of another widget changes.


=== "computed01.py"

```python hl_lines="25-26 28-29"
--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"
Expand All @@ -345,6 +388,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:
Copy link
Contributor

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Suggested change
The following examples replaces the naming convention with an equivalent decorator:
The following example 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.
Expand Down