diff --git a/CHANGELOG.md b/CHANGELOG.md index 3c512aa7c..d5c705976 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/). ### Added - Watch methods can now optionally be private https://github.com/Textualize/textual/issues/2382 +- Added textual.on decorator https://github.com/Textualize/textual/issues/2398 ## [0.22.3] - 2023-04-29 diff --git a/docs/api/on.md b/docs/api/on.md new file mode 100644 index 000000000..8d59ae19f --- /dev/null +++ b/docs/api/on.md @@ -0,0 +1,3 @@ +# On + +::: textual.on diff --git a/docs/examples/events/custom01.py b/docs/examples/events/custom01.py index e632babd6..906c1e92a 100644 --- a/docs/examples/events/custom01.py +++ b/docs/examples/events/custom01.py @@ -1,6 +1,6 @@ from textual.app import App, ComposeResult from textual.color import Color -from textual.message import Message, MessageTarget +from textual.message import Message from textual.widgets import Static diff --git a/docs/examples/events/on_decorator.css b/docs/examples/events/on_decorator.css new file mode 100644 index 000000000..f560080df --- /dev/null +++ b/docs/examples/events/on_decorator.css @@ -0,0 +1,8 @@ +Screen { + align: center middle; + layout: horizontal; +} + +Button { + margin: 2 4; +} diff --git a/docs/examples/events/on_decorator01.py b/docs/examples/events/on_decorator01.py new file mode 100644 index 000000000..3b97f7f0d --- /dev/null +++ b/docs/examples/events/on_decorator01.py @@ -0,0 +1,27 @@ +from textual import on +from textual.app import App, ComposeResult +from textual.widgets import Button + + +class OnDecoratorApp(App): + CSS_PATH = "on_decorator.css" + + def compose(self) -> ComposeResult: + """Three buttons.""" + yield Button("Bell", id="bell") + yield Button("Toggle dark", classes="toggle dark") + yield Button("Quit", id="quit") + + def on_button_pressed(self, event: Button.Pressed) -> None: + """Handle all button pressed events.""" + if event.button.id == "bell": + self.bell() + elif event.button.has_class("toggle", "dark"): + self.dark = not self.dark + elif event.button.id == "quit": + self.exit() + + +if __name__ == "__main__": + app = OnDecoratorApp() + app.run() diff --git a/docs/examples/events/on_decorator02.py b/docs/examples/events/on_decorator02.py new file mode 100644 index 000000000..87546841a --- /dev/null +++ b/docs/examples/events/on_decorator02.py @@ -0,0 +1,33 @@ +from textual import on +from textual.app import App, ComposeResult +from textual.widgets import Button + + +class OnDecoratorApp(App): + CSS_PATH = "on_decorator.css" + + def compose(self) -> ComposeResult: + """Three buttons.""" + yield Button("Bell", id="bell") + yield Button("Toggle dark", classes="toggle dark") + yield Button("Quit", id="quit") + + @on(Button.Pressed, "#bell") # (1)! + def play_bell(self): + """Called when the bell button is pressed.""" + self.bell() + + @on(Button.Pressed, ".toggle.dark") # (2)! + def toggle_dark(self): + """Called when the 'toggle dark' button is pressed.""" + self.dark = not self.dark + + @on(Button.Pressed, "#quit") # (3)! + def quit(self): + """Called when the quit button is pressed.""" + self.exit() + + +if __name__ == "__main__": + app = OnDecoratorApp() + app.run() diff --git a/docs/guide/events.md b/docs/guide/events.md index 8c8c7874a..1e6136c8f 100644 --- a/docs/guide/events.md +++ b/docs/guide/events.md @@ -155,9 +155,74 @@ Textual uses the following scheme to map messages classes on to a Python method. --8<-- "docs/images/events/naming.excalidraw.svg" +### On decorator + +In addition to the naming convention, message handlers may be created with the [`on`][textual.on] decorator, which turns a method into a handler for the given message or event. + +For instance, the two methods declared below are equivalent: + +```python +@on(Button.Pressed) +def handle_button_pressed(self): + ... + +def on_button_pressed(self): + ... +``` + +While this allows you to name your method handlers anything you want, the main advantage of the decorator approach over the naming convention is that you can specify *which* widget(s) you want to handle messages for. + +Let's first explore where this can be useful. +In the following example we have three buttons, each of which does something different; one plays the bell, one toggles dark mode, and the other quits the app. + +=== "on_decorator01.py" + + ```python title="on_decorator01.py" + --8<-- "docs/examples/events/on_decorator01.py" + ``` + +=== "Output" + + ```{.textual path="docs/examples/events/on_decorator01.py"} + ``` + +Note how the message handler has a chained `if` statement to match the action to the button. +While this works just fine, it can be a little hard to follow when the number of buttons grows. + +The `on` decorator takes a [CSS selector](./CSS.md#selectors) in addition to the event type which will be used to select which controls the handler should work with. +We can use this to write a handler per control rather than manage them all in a single handler. + +The following example uses the decorator approach to write individual message handlers for each of the three buttons: + +=== "on_decorator02.py" + + ```python title="on_decorator02.py" + --8<-- "docs/examples/events/on_decorator02.py" + ``` + + 1. Matches the button with an id of "bell" (note the `#` to match the id) + 2. Matches the button with class names "toggle" *and* "dark" + 3. Matches the button with an id of "quit" + +=== "Output" + + ```{.textual path="docs/examples/events/on_decorator02.py"} + ``` + +While there are a few more lines of code, it is clearer what will happen when you click any given button. + +Note that the decorator requires that the message class has a `control` attribute which should be the widget associated with the message. +Messages from builtin controls will have this attribute, but you may need to add `control` to any [custom messages](#custom-messages) you write. + +!!! note + + If multiple decorated handlers match the `control`, then they will *all* be called in the order they are defined. + + The naming convention handler will be called *after* any decorated handlers. + ### 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. ```python def on_color_button_selected(self, message: ColorButton.Selected) -> None: diff --git a/examples/calculator.py b/examples/calculator.py index f494185ab..2720e84d4 100644 --- a/examples/calculator.py +++ b/examples/calculator.py @@ -1,6 +1,14 @@ +""" + +An implementation of a classic calculator, with a layout inspired by macOS calculator. + + +""" + + from decimal import Decimal -from textual import events +from textual import events, on from textual.app import App, ComposeResult from textual.containers import Container from textual.css.query import NoMatches @@ -34,7 +42,6 @@ class CalculatorApp(App): def watch_numbers(self, value: str) -> None: """Called when numbers is updated.""" - # Update the Numbers widget self.query_one("#numbers", Static).update(value) def compute_show_ac(self) -> bool: @@ -55,19 +62,19 @@ class CalculatorApp(App): yield Button("+/-", id="plus-minus", variant="primary") yield Button("%", id="percent", variant="primary") yield Button("÷", id="divide", variant="warning") - yield Button("7", id="number-7") - yield Button("8", id="number-8") - yield Button("9", id="number-9") + yield Button("7", id="number-7", classes="number") + yield Button("8", id="number-8", classes="number") + yield Button("9", id="number-9", classes="number") yield Button("×", id="multiply", variant="warning") - yield Button("4", id="number-4") - yield Button("5", id="number-5") - yield Button("6", id="number-6") + yield Button("4", id="number-4", classes="number") + yield Button("5", id="number-5", classes="number") + yield Button("6", id="number-6", classes="number") yield Button("-", id="minus", variant="warning") - yield Button("1", id="number-1") - yield Button("2", id="number-2") - yield Button("3", id="number-3") + yield Button("1", id="number-1", classes="number") + yield Button("2", id="number-2", classes="number") + yield Button("3", id="number-3", classes="number") yield Button("+", id="plus", variant="warning") - yield Button("0", id="number-0") + yield Button("0", id="number-0", classes="number") yield Button(".", id="point") yield Button("=", id="equals", variant="warning") @@ -75,6 +82,8 @@ class CalculatorApp(App): """Called when the user presses a key.""" def press(button_id: str) -> None: + """Press a button, should it exist.""" + try: self.query_one(f"#{button_id}", Button).press() except NoMatches: @@ -91,54 +100,73 @@ class CalculatorApp(App): if button_id is not None: press(self.NAME_MAP.get(key, key)) - def on_button_pressed(self, event: Button.Pressed) -> None: - """Called when a button is pressed.""" + @on(Button.Pressed, ".number") + def number_pressed(self, event: Button.Pressed) -> None: + """Pressed a number.""" + assert event.button.id is not None + number = event.button.id.partition("-")[-1] + self.numbers = self.value = self.value.lstrip("0") + number - button_id = event.button.id - assert button_id is not None + @on(Button.Pressed, "#plus-minus") + def plus_minus_pressed(self) -> None: + """Pressed + / -""" + self.numbers = self.value = str(Decimal(self.value or "0") * -1) - def do_math() -> None: - """Does the math: LEFT OPERATOR RIGHT""" - try: - if self.operator == "plus": - self.left += self.right - elif self.operator == "minus": - self.left -= self.right - elif self.operator == "divide": - self.left /= self.right - elif self.operator == "multiply": - self.left *= self.right - self.numbers = str(self.left) - self.value = "" - except Exception: - self.numbers = "Error" + @on(Button.Pressed, "#percent") + def percent_pressed(self) -> None: + """Pressed %""" + self.numbers = self.value = str(Decimal(self.value or "0") / Decimal(100)) - if button_id.startswith("number-"): - number = button_id.partition("-")[-1] - self.numbers = self.value = self.value.lstrip("0") + number - elif button_id == "plus-minus": - self.numbers = self.value = str(Decimal(self.value or "0") * -1) - elif button_id == "percent": - self.numbers = self.value = str(Decimal(self.value or "0") / Decimal(100)) - elif button_id == "point": - if "." not in self.value: - self.numbers = self.value = (self.value or "0") + "." - elif button_id == "ac": + @on(Button.Pressed, "#point") + def pressed_point(self) -> None: + """Pressed .""" + if "." not in self.value: + self.numbers = self.value = (self.value or "0") + "." + + @on(Button.Pressed, "#ac") + def pressed_ac(self) -> None: + """Pressed AC""" + self.value = "" + self.left = self.right = Decimal(0) + self.operator = "plus" + self.numbers = "0" + + @on(Button.Pressed, "#c") + def pressed_c(self) -> None: + """Pressed C""" + self.value = "" + self.numbers = "0" + + def _do_math(self) -> None: + """Does the math: LEFT OPERATOR RIGHT""" + try: + if self.operator == "plus": + self.left += self.right + elif self.operator == "minus": + self.left -= self.right + elif self.operator == "divide": + self.left /= self.right + elif self.operator == "multiply": + self.left *= self.right + self.numbers = str(self.left) self.value = "" - self.left = self.right = Decimal(0) - self.operator = "plus" - self.numbers = "0" - elif button_id == "c": - self.value = "" - self.numbers = "0" - elif button_id in ("plus", "minus", "divide", "multiply"): - self.right = Decimal(self.value or "0") - do_math() - self.operator = button_id - elif button_id == "equals": - if self.value: - self.right = Decimal(self.value) - do_math() + except Exception: + self.numbers = "Error" + + @on(Button.Pressed, "#plus,#minus,#divide,#multiply") + def pressed_op(self, event: Button.Pressed) -> None: + """Pressed one of the arithmetic operations.""" + self.right = Decimal(self.value or "0") + self._do_math() + assert event.button.id is not None + self.operator = event.button.id + + @on(Button.Pressed, "#equals") + def pressed_equals(self) -> None: + """Pressed =""" + if self.value: + self.right = Decimal(self.value) + self._do_math() if __name__ == "__main__": diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index 3a89dd15f..4a0fc2cc4 100644 --- a/mkdocs-nav.yml +++ b/mkdocs-nav.yml @@ -173,6 +173,7 @@ nav: - "api/map_geometry.md" - "api/message_pump.md" - "api/message.md" + - "api/on.md" - "api/pilot.md" - "api/query.md" - "api/reactive.md" diff --git a/src/textual/__init__.py b/src/textual/__init__.py index c009ca3ab..fbf43f55f 100644 --- a/src/textual/__init__.py +++ b/src/textual/__init__.py @@ -9,12 +9,19 @@ from rich.console import RenderableType from . import constants from ._context import active_app from ._log import LogGroup, LogVerbosity +from ._on import on from ._work_decorator import work as work if TYPE_CHECKING: from typing_extensions import TypeAlias -__all__ = ["log", "panic", "__version__", "work"] # type: ignore +__all__ = [ + "__version__", # type: ignore + "log", + "on", + "panic", + "work", +] LogCallable: TypeAlias = "Callable" diff --git a/src/textual/_on.py b/src/textual/_on.py new file mode 100644 index 000000000..30689a052 --- /dev/null +++ b/src/textual/_on.py @@ -0,0 +1,60 @@ +from __future__ import annotations + +from typing import Callable, TypeVar + +from .css.parse import parse_selectors +from .css.tokenizer import TokenError +from .message import Message + +DecoratedType = TypeVar("DecoratedType") + + +class OnDecoratorError(Exception): + """Errors related to the `on` decorator. + + Typically raised at import time as an early warning system. + + """ + + +def on( + message_type: type[Message], selector: str | None = None +) -> Callable[[DecoratedType], DecoratedType]: + """Decorator to declare method is a message handler. + + Example: + ```python + @on(Button.Pressed, "#quit") + def quit_button(self) -> None: + self.app.quit() + ``` + + 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. + """ + + 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)." + ) + + if selector is not None: + try: + parse_selectors(selector) + except TokenError as error: + raise OnDecoratorError( + f"Unable to parse selector {selector!r}; 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)) + + return method + + return decorator diff --git a/src/textual/dom.py b/src/textual/dom.py index 475ed1c55..3f549b059 100644 --- a/src/textual/dom.py +++ b/src/textual/dom.py @@ -12,6 +12,7 @@ from functools import lru_cache from inspect import getfile from typing import ( TYPE_CHECKING, + Callable, ClassVar, Iterable, Sequence, @@ -49,6 +50,7 @@ if TYPE_CHECKING: from rich.console import RenderableType from .app import App from .css.query import DOMQuery, QueryType + from .message import Message from .screen import Screen from .widget import Widget from .worker import Worker, WorkType, ResultType @@ -147,6 +149,8 @@ class DOMNode(MessagePump): _reactives: ClassVar[dict[str, Reactive]] + _decorated_handlers: dict[type[Message], list[tuple[Callable, str | None]]] + def __init__( self, *, diff --git a/src/textual/message_pump.py b/src/textual/message_pump.py index 101446c37..18578606a 100644 --- a/src/textual/message_pump.py +++ b/src/textual/message_pump.py @@ -25,6 +25,8 @@ from ._context import ( 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 @@ -57,7 +59,16 @@ class _MessagePumpMeta(type): ): namespace = camel_to_snake(name) isclass = inspect.isclass + handlers: dict[ + type[Message], list[tuple[Callable, str | None]] + ] = 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)) if isclass(value) and issubclass(value, Message): if "namespace" not in value.__dict__: value.namespace = namespace @@ -545,12 +556,29 @@ class MessagePump(metaclass=_MessagePumpMeta): method_name: Handler method name. message: Message object. """ - private_method = f"_{method_name}" for cls in self.__class__.__mro__: if message._no_default_action: break - method = cls.__dict__.get(private_method) or cls.__dict__.get(method_name) - if method is not None: + # Try decorated handlers first + 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: + yield cls, method.__get__(self, cls) + else: + selector_sets = parse_selectors(selector) + if message._sender is not None and match( + selector_sets, message.control + ): + yield cls, method.__get__(self, cls) + + # Fall back to the naming convention + # But avoid calling the handler if it was decorated + method = cls.__dict__.get(f"_{method_name}") or cls.__dict__.get( + method_name + ) + if method is not None and not getattr(method, "_textual_on", None): yield cls, method.__get__(self, cls) async def on_event(self, event: events.Event) -> None: diff --git a/src/textual/widget.py b/src/textual/widget.py index 2eda54b85..841593298 100644 --- a/src/textual/widget.py +++ b/src/textual/widget.py @@ -2927,6 +2927,7 @@ class Widget(DOMNode): self.log.warning(self, f"IS NOT RUNNING, {message!r} not sent") except NoActiveAppError: pass + return super().post_message(message) async def _on_idle(self, event: events.Idle) -> None: diff --git a/tests/test_on.py b/tests/test_on.py new file mode 100644 index 000000000..411e54217 --- /dev/null +++ b/tests/test_on.py @@ -0,0 +1,104 @@ +import pytest + +from textual import on +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 + + +async def test_on_button_pressed() -> None: + """Test handlers with @on decorator.""" + + pressed: list[str] = [] + + class ButtonApp(App): + def compose(self) -> ComposeResult: + yield Button("OK", id="ok") + yield Button("Cancel", classes="exit cancel") + yield Button("Quit", classes="exit quit") + + @on(Button.Pressed, "#ok") + def ok(self): + pressed.append("ok") + + @on(Button.Pressed, ".exit") + def exit(self): + pressed.append("exit") + + @on(Button.Pressed, ".exit.quit") + def _(self): + pressed.append("quit") + + def on_button_pressed(self): + pressed.append("default") + + app = ButtonApp() + async with app.run_test() as pilot: + await pilot.press("tab", "enter", "tab", "enter", "tab", "enter") + await pilot.pause() + + assert pressed == [ + "ok", # Matched ok first + "default", # on_button_pressed matched everything + "exit", # Cancel button, matches exit + "default", # on_button_pressed matched everything + "exit", # Quit button pressed, matched exit and _ + "quit", # Matched previous button + "default", # on_button_pressed matched everything + ] + + +async def test_on_inheritance() -> None: + """Test on decorator and inheritance.""" + pressed: list[str] = [] + + class MyWidget(Widget): + def compose(self) -> ComposeResult: + yield Button("OK", id="ok") + + # Also called + @on(Button.Pressed, "#ok") + def ok(self): + pressed.append("MyWidget.ok base") + + class DerivedWidget(MyWidget): + # Should be called first + @on(Button.Pressed, "#ok") + def ok(self): + pressed.append("MyWidget.ok derived") + + class ButtonApp(App): + def compose(self) -> ComposeResult: + yield DerivedWidget() + + app = ButtonApp() + async with app.run_test() as pilot: + await pilot.press("tab", "enter") + + expected = ["MyWidget.ok derived", "MyWidget.ok base"] + assert pressed == expected + + +def test_on_bad_selector() -> None: + """Check bad selectors raise an error.""" + + with pytest.raises(OnDecoratorError): + + @on(Button.Pressed, "@") + def foo(): + pass + + +def test_on_no_control() -> None: + """Check messages with no 'control' attribute raise an error.""" + + class CustomMessage(Message): + pass + + with pytest.raises(OnDecoratorError): + + @on(CustomMessage, "#foo") + def foo(): + pass