On decorator (#2453)

* Add on decorator

* decorator code

* docs for on decorator

* Examples

* test errors

* simplify listing

* words

* changelog

* Update docs/guide/events.md

Co-authored-by: Dave Pearson <davep@davep.org>

* Update docs/guide/events.md

Co-authored-by: Dave Pearson <davep@davep.org>

* Update docs/examples/events/on_decorator.css

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* Update docs/guide/events.md

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* rewording

* comment

* clarification

* Added note

---------

Co-authored-by: Dave Pearson <davep@davep.org>
Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>
This commit is contained in:
Will McGugan
2023-05-02 16:17:40 +01:00
committed by GitHub
parent 914e50a70f
commit 91a9d570a4
15 changed files with 432 additions and 62 deletions

View File

@@ -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

3
docs/api/on.md Normal file
View File

@@ -0,0 +1,3 @@
# On
::: textual.on

View File

@@ -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

View File

@@ -0,0 +1,8 @@
Screen {
align: center middle;
layout: horizontal;
}
Button {
margin: 2 4;
}

View File

@@ -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()

View File

@@ -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()

View File

@@ -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"
</div>
### 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:

View File

@@ -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,13 +100,44 @@ 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:
@on(Button.Pressed, "#percent")
def percent_pressed(self) -> None:
"""Pressed %"""
self.numbers = self.value = str(Decimal(self.value or "0") / Decimal(100))
@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":
@@ -113,32 +153,20 @@ class CalculatorApp(App):
except Exception:
self.numbers = "Error"
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":
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"):
@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")
do_math()
self.operator = button_id
elif button_id == "equals":
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)
do_math()
self._do_math()
if __name__ == "__main__":

View File

@@ -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"

View File

@@ -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"

60
src/textual/_on.py Normal file
View File

@@ -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

View File

@@ -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,
*,

View File

@@ -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:

View File

@@ -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:

104
tests/test_on.py Normal file
View File

@@ -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