Skip to content

Commit

Permalink
Merge branch 'main' into toggle-style-order
Browse files Browse the repository at this point in the history
  • Loading branch information
willmcgugan authored Jul 15, 2024
2 parents 54a023c + 227828d commit 2a35854
Show file tree
Hide file tree
Showing 11 changed files with 337 additions and 18 deletions.
2 changes: 2 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
### Added

- `TextArea.line_number_start` reactive attribute https://github.com/Textualize/textual/pull/4471
- Added `DOMNode.mutate_reactive` https://github.com/Textualize/textual/pull/4731
- Added "quality" parameter to `textual.color.Gradient` https://github.com/Textualize/textual/pull/4739
- Added `textual.color.Gradient.get_rich_color` https://github.com/Textualize/textual/pull/4739
- `Widget.remove_children` now accepts an iterable if widgets in addition to a selector https://github.com/Textualize/textual/issues/4735
Expand All @@ -21,6 +22,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Fixed scroll_visible with margin https://github.com/Textualize/textual/pull/4719
- Fixed programmatically disabling button stuck in hover state https://github.com/Textualize/textual/pull/4724
- Fixed `Tree` and `DirectoryTree` horizontal scrolling off-by-2 https://github.com/Textualize/textual/pull/4744
- Fixed text-opacity in component styles https://github.com/Textualize/textual/pull/4747

### Changed

Expand Down
21 changes: 21 additions & 0 deletions docs/examples/guide/reactivity/set_reactive03.py
Original file line number Diff line number Diff line change
@@ -0,0 +1,21 @@
from textual.app import App, ComposeResult
from textual.reactive import reactive
from textual.widgets import Input, Label


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

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

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


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

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

## Mutable reactives

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

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

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

=== "set_reactive03.py"

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

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

=== "Output"

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

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

## Data binding

Reactive attributes from one widget may be *bound* (connected) to another widget, so that changes to a single reactive will automatically update another widget (potentially more than one).
Expand Down
25 changes: 24 additions & 1 deletion src/textual/dom.py
Original file line number Diff line number Diff line change
Expand Up @@ -221,7 +221,7 @@ def set_reactive(
```
Args:
name: Name of reactive attribute.
reactive: A reactive property (use the class scope syntax, i.e. `MyClass.my_reactive`).
value: New value of reactive.
Raises:
Expand All @@ -237,6 +237,29 @@ def set_reactive(
)
setattr(self, f"_reactive_{reactive.name}", value)

def mutate_reactive(self, reactive: Reactive[ReactiveType]) -> None:
"""Force an update to a mutable reactive.
Example:
```python
self.reactive_name_list.append("Jessica")
self.mutate_reactive(MyClass.reactive_name_list)
```
Textual will automatically detect when a reactive is set to a new value, but it is unable
to detect if a value is _mutated_ (such as updating a list, dict, or attribute of an object).
If you do wish to use a collection or other mutable object in a reactive, then you can call
this method after your reactive is updated. This will ensure that all the reactive _superpowers_
work.
Args:
reactive: A reactive property (use the class scope syntax, i.e. `MyClass.my_reactive`).
"""

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

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

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

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

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

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

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

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

self._set(obj, value)

@classmethod
def _check_watchers(cls, obj: Reactable, name: str, old_value: Any) -> None:
"""Check watchers, and call watch methods / computes
Expand Down
35 changes: 24 additions & 11 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -59,6 +59,7 @@
from .await_remove import AwaitRemove
from .box_model import BoxModel
from .cache import FIFOCache
from .color import Color
from .css.match import match
from .css.parse import parse_selectors
from .css.query import NoMatches, WrongType
Expand Down Expand Up @@ -107,6 +108,8 @@


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


class AwaitMount:
Expand Down Expand Up @@ -803,6 +806,14 @@ def get_component_rich_style(self, *names: str, partial: bool = False) -> Style:
if names not in self._rich_style_cache:
component_styles = self.get_component_styles(*names)
style = component_styles.rich_style
text_opacity = component_styles.text_opacity
if text_opacity < 1 and style.bgcolor is not None:
style += Style.from_color(
(
Color.from_rich_color(style.bgcolor)
+ component_styles.color.multiply_alpha(text_opacity)
).rich_color
)
partial_style = component_styles.partial_rich_style
self._rich_style_cache[names] = (style, partial_style)

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

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

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

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

Expand Down
Loading

0 comments on commit 2a35854

Please sign in to comment.