Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

Extend @on decorator to filter matchable attributes #2498

Merged
merged 27 commits into from
May 8, 2023
Merged
Show file tree
Hide file tree
Changes from 25 commits
Commits
Show all changes
27 commits
Select commit Hold shift + click to select a range
4a57e14
Add tests for #2484.
rodrigogiraoserrao May 5, 2023
83b0ae7
Implement @on extension.
rodrigogiraoserrao May 5, 2023
2898b1d
Changelog.
rodrigogiraoserrao May 5, 2023
e77b366
Add missing @on test.
rodrigogiraoserrao May 8, 2023
e097d64
Merge branch 'main' into on-kwargs
rodrigogiraoserrao May 8, 2023
8febe0f
Remove debug prints.
rodrigogiraoserrao May 8, 2023
0950e51
Document changes.
rodrigogiraoserrao May 8, 2023
6434149
Merge branch 'main' into on-kwargs
rodrigogiraoserrao May 8, 2023
938fa3b
Merge branch 'main' into on-kwargs
rodrigogiraoserrao May 8, 2023
51139c4
Update tests.
rodrigogiraoserrao May 8, 2023
bd34558
Cache parsed selectors.
rodrigogiraoserrao May 8, 2023
4f6ca0e
Streamline exit condition.
rodrigogiraoserrao May 8, 2023
3adf63c
Fix typing.
rodrigogiraoserrao May 8, 2023
f1d6652
More succint wording.
rodrigogiraoserrao May 8, 2023
0abb99f
Document 'on' kwargs.
rodrigogiraoserrao May 8, 2023
64f1253
Update src/textual/_on.py
rodrigogiraoserrao May 8, 2023
6b08b3c
Update docs/guide/events.md
rodrigogiraoserrao May 8, 2023
a7cc55e
Change 'on' API.
rodrigogiraoserrao May 8, 2023
813a91a
Remove example code.
rodrigogiraoserrao May 8, 2023
36ee21f
Address feedback.
rodrigogiraoserrao May 8, 2023
63f2853
Update src/textual/_on.py
rodrigogiraoserrao May 8, 2023
3842911
Address review feedback.
rodrigogiraoserrao May 8, 2023
054b1c1
Merge branch 'main' into on-kwargs
rodrigogiraoserrao May 8, 2023
5120f9e
Fix #2499.
rodrigogiraoserrao May 8, 2023
aa911e9
don't require control to be manually specified
willmcgugan May 8, 2023
64f7fba
update docstring
willmcgugan May 8, 2023
84ca784
deleted words
willmcgugan May 8, 2023
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
3 changes: 3 additions & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -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

Expand All @@ -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
Expand Down
22 changes: 21 additions & 1 deletion docs/guide/events.md
Copy link
Contributor Author

@rodrigogiraoserrao rodrigogiraoserrao May 8, 2023

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

@willmcgugan still trying to strike a balance between my personal verbose style and the intended style for the docs.
Let me know how this looks.

Original file line number Diff line number Diff line change
Expand Up @@ -218,10 +218,22 @@ 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 it is the tab with id `#home` that is activated:

```py
@on(TabbedContent.TabActivated, tab="#home")
def two(self) -> None:
log.append("Switching back to the 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.
Expand All @@ -231,6 +243,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
Expand Down
57 changes: 45 additions & 12 deletions src/textual/_on.py
Original file line number Diff line number Diff line change
Expand Up @@ -2,6 +2,7 @@

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
Expand All @@ -13,47 +14,79 @@ 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:
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(
"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:
parse_selectors(selector)
except TokenError as error:
parsed_selectors[attribute] = parse_selectors(css_selector)
except TokenError:
raise OnDecoratorError(
f"Unable to parse selector {selector!r}; check for syntax errors"
f"Unable to parse selector {css_selector!r} for {attribute}; check for syntax errors"
) from None

def decorator(method: DecoratedType) -> DecoratedType:
"""Store message and selector in function attribute, return callable unaltered."""

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

Expand Down
7 changes: 7 additions & 0 deletions src/textual/message.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,7 @@

if TYPE_CHECKING:
from .message_pump import MessagePump
from .widget import Widget


@rich.repr.auto
Expand All @@ -32,10 +33,16 @@ class Message:
"_prevent",
]

ALLOW_SELECTOR_MATCH: ClassVar[set[str]] = {"control"}
"""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__()
Expand Down
30 changes: 20 additions & 10 deletions src/textual/message_pump.py
Original file line number Diff line number Diff line change
Expand Up @@ -22,11 +22,11 @@
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
Expand All @@ -35,6 +35,7 @@

if TYPE_CHECKING:
from .app import App
from .css.model import SelectorSet


class CallbackError(Exception):
Expand All @@ -60,15 +61,15 @@ def __new__(
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
Expand Down Expand Up @@ -563,14 +564,23 @@ def _get_dispatch_methods(
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
Expand Down
6 changes: 6 additions & 0 deletions src/textual/widgets/_list_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -46,6 +46,9 @@ class Highlighted(Message, bubble=True):
or in a parent widget in the DOM.
"""

ALLOW_SELECTOR_MATCH = {"control", "item"}
"""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
Expand All @@ -69,6 +72,9 @@ class Selected(Message, bubble=True):
a parent widget in the DOM.
"""

ALLOW_SELECTOR_MATCH = {"control", "item"}
"""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
Expand Down
3 changes: 3 additions & 0 deletions src/textual/widgets/_radio_set.py
Original file line number Diff line number Diff line change
Expand Up @@ -78,6 +78,9 @@ class Changed(Message, bubble=True):
This message can be handled using an `on_radio_set_changed` method.
"""

ALLOW_SELECTOR_MATCH = {"control", "pressed"}
"""Message attributes that can be used with the [`on` decorator][textual.on]."""

def __init__(self, radio_set: RadioSet, pressed: RadioButton) -> None:
"""Initialise the message.

Expand Down
3 changes: 3 additions & 0 deletions src/textual/widgets/_tabbed_content.py
Original file line number Diff line number Diff line change
Expand Up @@ -88,6 +88,9 @@ class TabbedContent(Widget):
class TabActivated(Message):
"""Posted when the active tab changes."""

ALLOW_SELECTOR_MATCH = {"control", "tab"}
"""Message attributes that can be used with the [`on` decorator][textual.on]."""

def __init__(self, tabbed_content: TabbedContent, tab: Tab) -> None:
"""Initialize message.

Expand Down
3 changes: 3 additions & 0 deletions src/textual/widgets/_tabs.py
Original file line number Diff line number Diff line change
Expand Up @@ -178,6 +178,9 @@ class Tabs(Widget, can_focus=True):
class TabActivated(Message):
"""Sent when a new tab is activated."""

ALLOW_SELECTOR_MATCH = {"control", "tab"}
"""Message attributes that can be used with the [`on` decorator][textual.on]."""

tabs: Tabs
"""The tabs widget containing the tab."""
tab: Tab
Expand Down
43 changes: 42 additions & 1 deletion tests/test_on.py
Original file line number Diff line number Diff line change
Expand Up @@ -5,7 +5,7 @@
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:
Expand Down Expand Up @@ -102,3 +102,44 @@ class CustomMessage(Message):
@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"]