Extend @on decorator to filter matchable attributes (#2498)

* Add tests for #2484.

* Implement @on extension.

[skip ci]
Related issues: #2484.

* Changelog.

* Add missing @on test.

* Remove debug prints.

* Document changes.

* Update tests.

Test now fully works, as of #2490.

* Cache parsed selectors.

* Streamline exit condition.

* Fix typing.

* More succint wording.

* Document 'on' kwargs.

* Update src/textual/_on.py

Co-authored-by: Will McGugan <willmcgugan@gmail.com>

* Update docs/guide/events.md

Co-authored-by: Will McGugan <willmcgugan@gmail.com>

* Change 'on' API.

* Remove example code.

* Address feedback.

* Update src/textual/_on.py

Co-authored-by: Will McGugan <willmcgugan@gmail.com>

* Address review feedback.

* Fix #2499.

* don't require control to be manually specified

* update docstring

* deleted words

---------

Co-authored-by: Will McGugan <willmcgugan@gmail.com>
This commit is contained in:
Rodrigo Girão Serrão
2023-05-08 17:30:07 +01:00
committed by GitHub
parent dd7e768887
commit 855c90d4f0
10 changed files with 155 additions and 25 deletions

View File

@@ -20,6 +20,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added `always_update` as an optional argument for `reactive.var`
- Made Binding description default to empty string, which is equivalent to show=False https://github.com/Textualize/textual/pull/2501
- Modified Message to allow it to be used as a dataclass https://github.com/Textualize/textual/pull/2501
- Decorator `@on` accepts arbitrary `**kwargs` to apply selectors to attributes of the message https://github.com/Textualize/textual/pull/2498
### Added
@@ -34,6 +35,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added `TreeNode.is_root` https://github.com/Textualize/textual/pull/2510
- Added `TreeNode.remove_children` https://github.com/Textualize/textual/pull/2510
- Added `TreeNode.remove` https://github.com/Textualize/textual/pull/2510
- Added classvar `Message.ALLOW_SELECTOR_MATCH` https://github.com/Textualize/textual/pull/2498
- Added `ALLOW_SELECTOR_MATCH` to all built-in messages associated with widgets https://github.com/Textualize/textual/pull/2498
- Markdown document sub-widgets now reference the container document
- Table of contents of a markdown document now references the document
- Added the `control` property to messages

View File

@@ -218,10 +218,23 @@ Messages from builtin controls will have this attribute, but you may need to add
!!! note
If multiple decorated handlers match the `control`, then they will *all* be called in the order they are defined.
If multiple decorated handlers match the message, then they will *all* be called in the order they are defined.
The naming convention handler will be called *after* any decorated handlers.
#### Applying CSS selectors to arbitrary attributes
The `on` decorator also accepts selectors as keyword arguments that may be used to match other attributes in a Message, provided those attributes are in [`Message.ALLOW_SELECTOR_MATCH`][textual.message.Message.ALLOW_SELECTOR_MATCH].
The snippet below shows how to match the message [`TabbedContent.TabActivated`][textual.widgets.TabbedContent.TabActivated] only when the tab with id `home` was activated:
```py
@on(TabbedContent.TabActivated, tab="#home")
def home_tab(self) -> None:
self.log("Switched back to home tab.")
...
```
### Handler arguments
Message handler methods can be written with or without a positional argument. If you add a positional argument, Textual will call the handler with the event object. The following handler (taken from `custom01.py` above) contains a `message` parameter. The body of the code makes use of the message to set a preset color.
@@ -231,6 +244,14 @@ Message handler methods can be written with or without a positional argument. If
self.screen.styles.animate("background", message.color, duration=0.5)
```
A similar handler can be written using the decorator `on`:
```python
@on(ColorButton.Selected)
def animate_background_color(self, message: ColorButton.Selected) -> None:
self.screen.styles.animate("background", message.color, duration=0.5)
```
If the body of your handler doesn't require any information in the message you can omit it from the method signature. If we just want to play a bell noise when the button is clicked, we could write our handler like this:
```python

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from typing import Callable, TypeVar
from .css.model import SelectorSet
from .css.parse import parse_selectors
from .css.tokenizer import TokenError
from .message import Message
@@ -13,39 +14,71 @@ class OnDecoratorError(Exception):
"""Errors related to the `on` decorator.
Typically raised at import time as an early warning system.
"""
class OnNoWidget(Exception):
"""A selector was applied to an attribute that isn't a widget."""
def on(
message_type: type[Message], selector: str | None = None
message_type: type[Message], selector: str | None = None, **kwargs: str
) -> Callable[[DecoratedType], DecoratedType]:
"""Decorator to declare method is a message handler.
"""Decorator to declare that the method is a message handler.
The decorator accepts an optional CSS selector that will be matched against a widget exposed by
a `control` attribute on the message.
Example:
```python
# Handle the press of buttons with ID "#quit".
@on(Button.Pressed, "#quit")
def quit_button(self) -> None:
self.app.quit()
```
Keyword arguments can be used to match additional selectors for attributes
listed in [`ALLOW_SELECTOR_MATCH`][textual.message.Message.ALLOW_SELECTOR_MATCH].
Example:
```python
# Handle the activation of the tab "#home" within the `TabbedContent` "#tabs".
@on(TabbedContent.TabActivated, "#tabs", tab="#home")
def switch_to_home(self) -> None:
self.log("Switching back to the home tab.")
...
```
Args:
message_type: The message type (i.e. the class).
selector: An optional [selector](/guide/CSS#selectors). If supplied, the handler will only be called if `selector`
matches the widget from the `control` attribute of the message.
**kwargs: Additional selectors for other attributes of the message.
"""
if selector is not None and not hasattr(message_type, "control"):
raise OnDecoratorError(
"The 'selector' argument requires a message class with a 'control' attribute (such as events from controls)."
)
selectors: dict[str, str] = {}
if selector is not None:
try:
parse_selectors(selector)
except TokenError as error:
selectors["control"] = selector
if kwargs:
selectors.update(kwargs)
parsed_selectors: dict[str, tuple[SelectorSet, ...]] = {}
for attribute, css_selector in selectors.items():
if attribute == "control":
if message_type.control is None:
raise OnDecoratorError(
f"Unable to parse selector {selector!r}; check for syntax errors"
"The message class must have a 'control' to match with the on decorator"
)
elif attribute not in message_type.ALLOW_SELECTOR_MATCH:
raise OnDecoratorError(
f"The attribute {attribute!r} can't be matched; have you added it to "
+ f"{message_type.__name__}.ALLOW_SELECTOR_MATCH?"
)
try:
parsed_selectors[attribute] = parse_selectors(css_selector)
except TokenError:
raise OnDecoratorError(
f"Unable to parse selector {css_selector!r} for {attribute}; check for syntax errors"
) from None
def decorator(method: DecoratedType) -> DecoratedType:
@@ -53,7 +86,7 @@ def on(
if not hasattr(method, "_textual_on"):
setattr(method, "_textual_on", [])
getattr(method, "_textual_on").append((message_type, selector))
getattr(method, "_textual_on").append((message_type, parsed_selectors))
return method

View File

@@ -16,6 +16,7 @@ from .case import camel_to_snake
if TYPE_CHECKING:
from .message_pump import MessagePump
from .widget import Widget
@rich.repr.auto
@@ -32,10 +33,16 @@ class Message:
"_prevent",
]
ALLOW_SELECTOR_MATCH: ClassVar[set[str]] = set()
"""Additional attributes that can be used with the [`on` decorator][textual.on].
These attributes must be widgets.
"""
bubble: ClassVar[bool] = True # Message will bubble to parent
verbose: ClassVar[bool] = False # Message is verbose
no_dispatch: ClassVar[bool] = False # Message may not be handled by client code
namespace: ClassVar[str] = "" # Namespace to disambiguate messages
control: Widget | None = None
def __init__(self) -> None:
self.__post_init__()

View File

@@ -22,11 +22,11 @@ from ._context import (
active_message_pump,
prevent_message_types_stack,
)
from ._on import OnNoWidget
from ._time import time
from ._types import CallbackType
from .case import camel_to_snake
from .css.match import match
from .css.parse import parse_selectors
from .errors import DuplicateKeyHandlers
from .events import Event
from .message import Message
@@ -35,6 +35,7 @@ from .timer import Timer, TimerCallback
if TYPE_CHECKING:
from .app import App
from .css.model import SelectorSet
class CallbackError(Exception):
@@ -60,15 +61,15 @@ class _MessagePumpMeta(type):
namespace = camel_to_snake(name)
isclass = inspect.isclass
handlers: dict[
type[Message], list[tuple[Callable, str | None]]
type[Message], list[tuple[Callable, dict[str, tuple[SelectorSet, ...]]]]
] = class_dict.get("_decorated_handlers", {})
class_dict["_decorated_handlers"] = handlers
for value in class_dict.values():
if callable(value) and hasattr(value, "_textual_on"):
for message_type, selector in getattr(value, "_textual_on"):
handlers.setdefault(message_type, []).append((value, selector))
for message_type, selectors in getattr(value, "_textual_on"):
handlers.setdefault(message_type, []).append((value, selectors))
if isclass(value) and issubclass(value, Message):
if "namespace" not in value.__dict__:
value.namespace = namespace
@@ -563,14 +564,23 @@ class MessagePump(metaclass=_MessagePumpMeta):
decorated_handlers = cls.__dict__.get("_decorated_handlers")
if decorated_handlers is not None:
handlers = decorated_handlers.get(type(message), [])
for method, selector in handlers:
if selector is None:
from .widget import Widget
for method, selectors in handlers:
if not selectors:
yield cls, method.__get__(self, cls)
else:
selector_sets = parse_selectors(selector)
if message._sender is not None and match(
selector_sets, message.control
):
if not message._sender:
continue
for attribute, selector in selectors.items():
node = getattr(message, attribute)
if not isinstance(node, Widget):
raise OnNoWidget(
f"on decorator can't match against {attribute!r} as it is not a widget."
)
if not match(selector, node):
break
else:
yield cls, method.__get__(self, cls)
# Fall back to the naming convention

View File

@@ -46,6 +46,9 @@ class ListView(VerticalScroll, can_focus=True, can_focus_children=False):
or in a parent widget in the DOM.
"""
ALLOW_SELECTOR_MATCH = {"item"}
"""Additional message attributes that can be used with the [`on` decorator][textual.on]."""
def __init__(self, list_view: ListView, item: ListItem | None) -> None:
super().__init__()
self.list_view: ListView = list_view
@@ -69,6 +72,9 @@ class ListView(VerticalScroll, can_focus=True, can_focus_children=False):
a parent widget in the DOM.
"""
ALLOW_SELECTOR_MATCH = {"item"}
"""Additional message attributes that can be used with the [`on` decorator][textual.on]."""
def __init__(self, list_view: ListView, item: ListItem) -> None:
super().__init__()
self.list_view: ListView = list_view

View File

@@ -78,6 +78,9 @@ class RadioSet(Container, can_focus=True, can_focus_children=False):
This message can be handled using an `on_radio_set_changed` method.
"""
ALLOW_SELECTOR_MATCH = {"pressed"}
"""Additional message attributes that can be used with the [`on` decorator][textual.on]."""
def __init__(self, radio_set: RadioSet, pressed: RadioButton) -> None:
"""Initialise the message.

View File

@@ -88,6 +88,9 @@ class TabbedContent(Widget):
class TabActivated(Message):
"""Posted when the active tab changes."""
ALLOW_SELECTOR_MATCH = {"tab"}
"""Additional message attributes that can be used with the [`on` decorator][textual.on]."""
def __init__(self, tabbed_content: TabbedContent, tab: Tab) -> None:
"""Initialize message.

View File

@@ -178,6 +178,9 @@ class Tabs(Widget, can_focus=True):
class TabActivated(Message):
"""Sent when a new tab is activated."""
ALLOW_SELECTOR_MATCH = {"tab"}
"""Additional message attributes that can be used with the [`on` decorator][textual.on]."""
tabs: Tabs
"""The tabs widget containing the tab."""
tab: Tab

View File

@@ -5,7 +5,7 @@ from textual._on import OnDecoratorError
from textual.app import App, ComposeResult
from textual.message import Message
from textual.widget import Widget
from textual.widgets import Button
from textual.widgets import Button, TabbedContent, TabPane
async def test_on_button_pressed() -> None:
@@ -102,3 +102,44 @@ def test_on_no_control() -> None:
@on(CustomMessage, "#foo")
def foo():
pass
def test_on_attribute_not_listed() -> None:
"""Check `on` checks if the attribute is in ALLOW_SELECTOR_MATCH."""
class CustomMessage(Message):
pass
with pytest.raises(OnDecoratorError):
@on(CustomMessage, foo="bar")
def foo():
pass
async def test_on_arbitrary_attributes() -> None:
log: list[str] = []
class OnArbitraryAttributesApp(App[None]):
def compose(self) -> ComposeResult:
with TabbedContent():
yield TabPane("One", id="one")
yield TabPane("Two", id="two")
yield TabPane("Three", id="three")
def on_mount(self) -> None:
self.query_one(TabbedContent).add_class("tabs")
@on(TabbedContent.TabActivated, tab="#one")
def one(self) -> None:
log.append("one")
@on(TabbedContent.TabActivated, ".tabs", tab="#two")
def two(self) -> None:
log.append("two")
app = OnArbitraryAttributesApp()
async with app.run_test() as pilot:
await pilot.press("tab", "right", "right")
assert log == ["one", "two"]