Repository: Textualize/textual
Pull request: Extend `@on` decorator to filter matchable attributes
The decorator `@on` now accepts keyword arguments to provide selectors for attributes that the message has whitelisted in `ON_MATCHABLE_ATTRIBUTES`.

This will close #2484.

Problem statement:
Extend decorator `on` to specify non-`control` attributes to match.
By default, the decorator `on` accepts a selector that applies to the attribute `control` of the message.

We want `on` to grow `**kwargs` so that we can specify arbitrary selectors for arbitrary attributes of the messages we handle.

E.g., to handle the message `Tabs.TabActivated`, we may want `@on(Tabs.TabActivated, tab="#first-tab")` to handle the activation of the tab `#first-tab`.

_**EDIT**_: instead of matching arbitrary attributes of the message, we can only try to match attributes that have been whitelisted in the message's classvar `ON_MATCHABLE_ATTRIBUTES`.

Hints from issue discussion:

Commit message: Remove example code.

Patch to review:
diff --git a/CHANGELOG.md b/CHANGELOG.md
index 9b035dd4f6..4680f8ac0b 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,11 @@ 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.ON_MATCHABLE_ATTRIBUTES` https://github.com/Textualize/textual/pull/2498
+  - Added `ON_MATCHABLE_ATTRIBUTES` to `ListView.Highlighted` and `ListView.Selected`
+  - Added `ON_MATCHABLE_ATTRIBUTES` to `RadioSet.Changed`
+  - Added `ON_MATCHABLE_ATTRIBUTES` to `TabbedContent.TabActivated`
+  - Added `ON_MATCHABLE_ATTRIBUTES` to `Tabs.TabActivated`
 
 ### Added
 
diff --git a/docs/guide/events.md b/docs/guide/events.md
index 0629e17e5e..02a04537b4 100644
--- a/docs/guide/events.md
+++ b/docs/guide/events.md
@@ -218,10 +218,14 @@ 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.ON_MATCHABLE_ATTRIBUTES`][textual.message.Message.ON_MATCHABLE_ATTRIBUTES].
+
 ### 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.
diff --git a/src/textual/_on.py b/src/textual/_on.py
index 30689a0522..162459970f 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,68 @@ 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 can take a CSS selector that is applied to the attribute `control`
+    of 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 provide further selectors for the attributes
+    that are listed in [`ON_MATCHABLE_ATTRIBUTES`][textual.message.Message.ON_MATCHABLE_ATTRIBUTES].
+
+    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 not in message_type.ON_MATCHABLE_ATTRIBUTES and not hasattr(
+            message_type, attribute
+        ):
+            raise OnDecoratorError(
+                f"The attribute {attribute!r} can't be matched; have you added it to "
+                + f"{message_type.__name__}.ON_MATCHABLE_ATTRIBUTES?"
+            )
         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 +83,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..d4059d44a0 100644
--- a/src/textual/message.py
+++ b/src/textual/message.py
@@ -32,6 +32,11 @@ class Message:
         "_prevent",
     ]
 
+    ON_MATCHABLE_ATTRIBUTES: ClassVar[set[str]] = set()
+    """Message 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
diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py
index 1466f11619..a35efbaf9f 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,22 @@ 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:
+                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)
+                            try:
+                                if not match(selector, node):
+                                    break
+                            except AttributeError:
+                                raise OnNoWidget(
+                                    f"Attribute {attribute!r} isn't a widget."
+                                ) from None
+                        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..d1296b9c4d 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.
         """
 
+        ON_MATCHABLE_ATTRIBUTES = {"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
@@ -69,6 +72,9 @@ class Selected(Message, bubble=True):
         a parent widget in the DOM.
         """
 
+        ON_MATCHABLE_ATTRIBUTES = {"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
diff --git a/src/textual/widgets/_radio_set.py b/src/textual/widgets/_radio_set.py
index 0366e42fc7..de31744dad 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.
         """
 
+        ON_MATCHABLE_ATTRIBUTES = {"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.
 
diff --git a/src/textual/widgets/_tabbed_content.py b/src/textual/widgets/_tabbed_content.py
index 1242ee2bea..5f1605bd7b 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."""
 
+        ON_MATCHABLE_ATTRIBUTES = {"tab"}
+        """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..3142f92478 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."""
 
+        ON_MATCHABLE_ATTRIBUTES = {"tab"}
+        """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..a1eac41454 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 ON_MATCHABLE_ATTRIBUTES."""
+
+    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"]


Code review comments:
diff_hunk: @@ -32,6 +32,11 @@ class Message:
         "_prevent",
     ]
 
+    ON_MATCHABLE_ATTRIBUTES: ClassVar[set[str]] = set()
path: src/textual/message.py
text: @user1:
I know it was my idea, but the name doesn't work well.

How about `ALLOW_SELECTOR_MATCH` ?

diff_hunk: @@ -13,47 +14,76 @@ 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 can take a CSS selector that is applied to the attribute `control`
+    of 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 provide further selectors for the attributes
+    that are listed in [`ON_MATCHABLE_ATTRIBUTES`][textual.message.Message.ON_MATCHABLE_ATTRIBUTES].
+
+    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 not in message_type.ON_MATCHABLE_ATTRIBUTES and not hasattr(
path: src/textual/_on.py
text: @user1:
Still calling `hasattr`. Won't this break for instance attributes?

@author:
The `hasattr` is a fallback for the `control`. I can remove it if we explicitly whitelist `control` in the classvar, for example. Should I?

@user1:
May as well. Not need for two mechanisms to do the same thing.

diff_hunk: @@ -563,14 +564,22 @@ 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:
+                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)
+                            try:
+                                if not match(selector, node):
path: src/textual/message_pump.py
text: @user1:
This except is too broad. It could catch more than intended.

Could we not check that `node` is a Widget before we attempt to match it?

@author:
We can. I was trying to not have to pay the price of checking every time we try to match because it is highly likely that it _will_ match, but maybe there’s nothing better I can do.

diff_hunk: @@ -563,14 +564,22 @@ 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:
+                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)
+                            try:
+                                if not match(selector, node):
+                                    break
+                            except AttributeError:
+                                raise OnNoWidget(
+                                    f"Attribute {attribute!r} isn't a widget."
path: src/textual/message_pump.py
text: @user1:
This message is accurate, but not helpful. Suggest `"on decorator can't match against {attribute!r} as it is not a widget"`

diff_hunk: @@ -13,47 +14,76 @@ 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 can take a CSS selector that is applied to the attribute `control`
path: src/textual/_on.py
text: @user1:
```suggestion
    The decorator accepts an optional CSS selector that is matched against a widget exposed by a `control` attribute on the message.
```

It's not "applied to" ...