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` - 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 - 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 - 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 ### 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.is_root` https://github.com/Textualize/textual/pull/2510
- Added `TreeNode.remove_children` 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 `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 - Markdown document sub-widgets now reference the container document
- Table of contents of a markdown document now references the document - Table of contents of a markdown document now references the document
- Added the `control` property to messages - 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 !!! 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. 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 ### 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. 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) 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: 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 ```python

View File

@@ -2,6 +2,7 @@ from __future__ import annotations
from typing import Callable, TypeVar from typing import Callable, TypeVar
from .css.model import SelectorSet
from .css.parse import parse_selectors from .css.parse import parse_selectors
from .css.tokenizer import TokenError from .css.tokenizer import TokenError
from .message import Message from .message import Message
@@ -13,39 +14,71 @@ class OnDecoratorError(Exception):
"""Errors related to the `on` decorator. """Errors related to the `on` decorator.
Typically raised at import time as an early warning system. 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( def on(
message_type: type[Message], selector: str | None = None message_type: type[Message], selector: str | None = None, **kwargs: str
) -> Callable[[DecoratedType], DecoratedType]: ) -> 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: Example:
```python ```python
# Handle the press of buttons with ID "#quit".
@on(Button.Pressed, "#quit") @on(Button.Pressed, "#quit")
def quit_button(self) -> None: def quit_button(self) -> None:
self.app.quit() 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: Args:
message_type: The message type (i.e. the class). 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` 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. 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"): selectors: dict[str, str] = {}
raise OnDecoratorError(
"The 'selector' argument requires a message class with a 'control' attribute (such as events from controls)."
)
if selector is not None: if selector is not None:
try: selectors["control"] = selector
parse_selectors(selector) if kwargs:
except TokenError as error: 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(
"The message class must have a 'control' to match with the on decorator"
)
elif attribute not in message_type.ALLOW_SELECTOR_MATCH:
raise OnDecoratorError( raise OnDecoratorError(
f"Unable to parse selector {selector!r}; check for syntax errors" 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 ) from None
def decorator(method: DecoratedType) -> DecoratedType: def decorator(method: DecoratedType) -> DecoratedType:
@@ -53,7 +86,7 @@ def on(
if not hasattr(method, "_textual_on"): if not hasattr(method, "_textual_on"):
setattr(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 return method

View File

@@ -16,6 +16,7 @@ from .case import camel_to_snake
if TYPE_CHECKING: if TYPE_CHECKING:
from .message_pump import MessagePump from .message_pump import MessagePump
from .widget import Widget
@rich.repr.auto @rich.repr.auto
@@ -32,10 +33,16 @@ class Message:
"_prevent", "_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 bubble: ClassVar[bool] = True # Message will bubble to parent
verbose: ClassVar[bool] = False # Message is verbose verbose: ClassVar[bool] = False # Message is verbose
no_dispatch: ClassVar[bool] = False # Message may not be handled by client code no_dispatch: ClassVar[bool] = False # Message may not be handled by client code
namespace: ClassVar[str] = "" # Namespace to disambiguate messages namespace: ClassVar[str] = "" # Namespace to disambiguate messages
control: Widget | None = None
def __init__(self) -> None: def __init__(self) -> None:
self.__post_init__() self.__post_init__()

View File

@@ -22,11 +22,11 @@ from ._context import (
active_message_pump, active_message_pump,
prevent_message_types_stack, prevent_message_types_stack,
) )
from ._on import OnNoWidget
from ._time import time from ._time import time
from ._types import CallbackType from ._types import CallbackType
from .case import camel_to_snake from .case import camel_to_snake
from .css.match import match from .css.match import match
from .css.parse import parse_selectors
from .errors import DuplicateKeyHandlers from .errors import DuplicateKeyHandlers
from .events import Event from .events import Event
from .message import Message from .message import Message
@@ -35,6 +35,7 @@ from .timer import Timer, TimerCallback
if TYPE_CHECKING: if TYPE_CHECKING:
from .app import App from .app import App
from .css.model import SelectorSet
class CallbackError(Exception): class CallbackError(Exception):
@@ -60,15 +61,15 @@ class _MessagePumpMeta(type):
namespace = camel_to_snake(name) namespace = camel_to_snake(name)
isclass = inspect.isclass isclass = inspect.isclass
handlers: dict[ 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.get("_decorated_handlers", {})
class_dict["_decorated_handlers"] = handlers class_dict["_decorated_handlers"] = handlers
for value in class_dict.values(): for value in class_dict.values():
if callable(value) and hasattr(value, "_textual_on"): if callable(value) and hasattr(value, "_textual_on"):
for message_type, selector in getattr(value, "_textual_on"): for message_type, selectors in getattr(value, "_textual_on"):
handlers.setdefault(message_type, []).append((value, selector)) handlers.setdefault(message_type, []).append((value, selectors))
if isclass(value) and issubclass(value, Message): if isclass(value) and issubclass(value, Message):
if "namespace" not in value.__dict__: if "namespace" not in value.__dict__:
value.namespace = namespace value.namespace = namespace
@@ -563,14 +564,23 @@ class MessagePump(metaclass=_MessagePumpMeta):
decorated_handlers = cls.__dict__.get("_decorated_handlers") decorated_handlers = cls.__dict__.get("_decorated_handlers")
if decorated_handlers is not None: if decorated_handlers is not None:
handlers = decorated_handlers.get(type(message), []) handlers = decorated_handlers.get(type(message), [])
for method, selector in handlers: from .widget import Widget
if selector is None:
for method, selectors in handlers:
if not selectors:
yield cls, method.__get__(self, cls) yield cls, method.__get__(self, cls)
else: else:
selector_sets = parse_selectors(selector) if not message._sender:
if message._sender is not None and match( continue
selector_sets, message.control 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) yield cls, method.__get__(self, cls)
# Fall back to the naming convention # 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. 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: def __init__(self, list_view: ListView, item: ListItem | None) -> None:
super().__init__() super().__init__()
self.list_view: ListView = list_view 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. 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: def __init__(self, list_view: ListView, item: ListItem) -> None:
super().__init__() super().__init__()
self.list_view: ListView = list_view 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. 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: def __init__(self, radio_set: RadioSet, pressed: RadioButton) -> None:
"""Initialise the message. """Initialise the message.

View File

@@ -88,6 +88,9 @@ class TabbedContent(Widget):
class TabActivated(Message): class TabActivated(Message):
"""Posted when the active tab changes.""" """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: def __init__(self, tabbed_content: TabbedContent, tab: Tab) -> None:
"""Initialize message. """Initialize message.

View File

@@ -178,6 +178,9 @@ class Tabs(Widget, can_focus=True):
class TabActivated(Message): class TabActivated(Message):
"""Sent when a new tab is activated.""" """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 tabs: Tabs
"""The tabs widget containing the tab.""" """The tabs widget containing the tab."""
tab: Tab tab: Tab

View File

@@ -5,7 +5,7 @@ from textual._on import OnDecoratorError
from textual.app import App, ComposeResult from textual.app import App, ComposeResult
from textual.message import Message from textual.message import Message
from textual.widget import Widget from textual.widget import Widget
from textual.widgets import Button from textual.widgets import Button, TabbedContent, TabPane
async def test_on_button_pressed() -> None: async def test_on_button_pressed() -> None:
@@ -102,3 +102,44 @@ def test_on_no_control() -> None:
@on(CustomMessage, "#foo") @on(CustomMessage, "#foo")
def foo(): def foo():
pass 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"]