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`
|
- 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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|
||||||
|
|||||||
@@ -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__()
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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.
|
||||||
|
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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"]
|
||||||
|
|||||||
Reference in New Issue
Block a user