mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
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:
committed by
GitHub
parent
dd7e768887
commit
855c90d4f0
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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__()
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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"]
|
||||
|
||||
Reference in New Issue
Block a user