diff --git a/CHANGELOG.md b/CHANGELOG.md index fc8b457d85..b776f49513 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -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 diff --git a/docs/guide/events.md b/docs/guide/events.md index 0629e17e5e..ea28dce210 100644 --- a/docs/guide/events.md +++ b/docs/guide/events.md @@ -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 diff --git a/src/textual/_on.py b/src/textual/_on.py index 30689a0522..0ff0a6f593 100644 --- a/src/textual/_on.py +++ b/src/textual/_on.py @@ -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 @@ -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: + 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: @@ -53,7 +86,7 @@ def decorator(method: DecoratedType) -> DecoratedType: 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 diff --git a/src/textual/message.py b/src/textual/message.py index cdb731ad13..d2275bf367 100644 --- a/src/textual/message.py +++ b/src/textual/message.py @@ -16,6 +16,7 @@ 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__() diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 1466f11619..7439919ce3 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -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 @@ -35,6 +35,7 @@ if TYPE_CHECKING: from .app import App + from .css.model import SelectorSet class CallbackError(Exception): @@ -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 @@ -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 diff --git a/src/textual/widgets/_list_view.py b/src/textual/widgets/_list_view.py index 7c5145e969..f9f423b53a 100644 --- a/src/textual/widgets/_list_view.py +++ b/src/textual/widgets/_list_view.py @@ -46,6 +46,9 @@ class Highlighted(Message, bubble=True): 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 Selected(Message, bubble=True): 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 diff --git a/src/textual/widgets/_radio_set.py b/src/textual/widgets/_radio_set.py index 0366e42fc7..b3c98aec06 100644 --- a/src/textual/widgets/_radio_set.py +++ b/src/textual/widgets/_radio_set.py @@ -78,6 +78,9 @@ class Changed(Message, bubble=True): 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. diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py index 1242ee2bea..689ce8e800 100644 --- a/src/textual/widgets/_tabbed_content.py +++ b/src/textual/widgets/_tabbed_content.py @@ -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. diff --git a/src/textual/widgets/_tabs.py b/src/textual/widgets/_tabs.py index 7394d106cd..ffeb3fe5bf 100644 --- a/src/textual/widgets/_tabs.py +++ b/src/textual/widgets/_tabs.py @@ -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 diff --git a/tests/test_on.py b/tests/test_on.py index 411e54217a..7812cd616e 100644 --- a/tests/test_on.py +++ b/tests/test_on.py @@ -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: @@ -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"]