mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
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:
@@ -26,6 +26,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- Watch methods can now optionally be private https://github.com/Textualize/textual/issues/2382
|
- 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
|
## [0.22.3] - 2023-04-29
|
||||||
|
|
||||||
|
|||||||
3
docs/api/on.md
Normal file
3
docs/api/on.md
Normal file
@@ -0,0 +1,3 @@
|
|||||||
|
# On
|
||||||
|
|
||||||
|
::: textual.on
|
||||||
@@ -1,6 +1,6 @@
|
|||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.color import Color
|
from textual.color import Color
|
||||||
from textual.message import Message, MessageTarget
|
from textual.message import Message
|
||||||
from textual.widgets import Static
|
from textual.widgets import Static
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
8
docs/examples/events/on_decorator.css
Normal file
8
docs/examples/events/on_decorator.css
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
Screen {
|
||||||
|
align: center middle;
|
||||||
|
layout: horizontal;
|
||||||
|
}
|
||||||
|
|
||||||
|
Button {
|
||||||
|
margin: 2 4;
|
||||||
|
}
|
||||||
27
docs/examples/events/on_decorator01.py
Normal file
27
docs/examples/events/on_decorator01.py
Normal 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()
|
||||||
33
docs/examples/events/on_decorator02.py
Normal file
33
docs/examples/events/on_decorator02.py
Normal 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()
|
||||||
@@ -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"
|
--8<-- "docs/images/events/naming.excalidraw.svg"
|
||||||
</div>
|
</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
|
### 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
|
```python
|
||||||
def on_color_button_selected(self, message: ColorButton.Selected) -> None:
|
def on_color_button_selected(self, message: ColorButton.Selected) -> None:
|
||||||
|
|||||||
@@ -1,6 +1,14 @@
|
|||||||
|
"""
|
||||||
|
|
||||||
|
An implementation of a classic calculator, with a layout inspired by macOS calculator.
|
||||||
|
|
||||||
|
|
||||||
|
"""
|
||||||
|
|
||||||
|
|
||||||
from decimal import Decimal
|
from decimal import Decimal
|
||||||
|
|
||||||
from textual import events
|
from textual import events, on
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.containers import Container
|
from textual.containers import Container
|
||||||
from textual.css.query import NoMatches
|
from textual.css.query import NoMatches
|
||||||
@@ -34,7 +42,6 @@ class CalculatorApp(App):
|
|||||||
|
|
||||||
def watch_numbers(self, value: str) -> None:
|
def watch_numbers(self, value: str) -> None:
|
||||||
"""Called when numbers is updated."""
|
"""Called when numbers is updated."""
|
||||||
# Update the Numbers widget
|
|
||||||
self.query_one("#numbers", Static).update(value)
|
self.query_one("#numbers", Static).update(value)
|
||||||
|
|
||||||
def compute_show_ac(self) -> bool:
|
def compute_show_ac(self) -> bool:
|
||||||
@@ -55,19 +62,19 @@ class CalculatorApp(App):
|
|||||||
yield Button("+/-", id="plus-minus", variant="primary")
|
yield Button("+/-", id="plus-minus", variant="primary")
|
||||||
yield Button("%", id="percent", variant="primary")
|
yield Button("%", id="percent", variant="primary")
|
||||||
yield Button("÷", id="divide", variant="warning")
|
yield Button("÷", id="divide", variant="warning")
|
||||||
yield Button("7", id="number-7")
|
yield Button("7", id="number-7", classes="number")
|
||||||
yield Button("8", id="number-8")
|
yield Button("8", id="number-8", classes="number")
|
||||||
yield Button("9", id="number-9")
|
yield Button("9", id="number-9", classes="number")
|
||||||
yield Button("×", id="multiply", variant="warning")
|
yield Button("×", id="multiply", variant="warning")
|
||||||
yield Button("4", id="number-4")
|
yield Button("4", id="number-4", classes="number")
|
||||||
yield Button("5", id="number-5")
|
yield Button("5", id="number-5", classes="number")
|
||||||
yield Button("6", id="number-6")
|
yield Button("6", id="number-6", classes="number")
|
||||||
yield Button("-", id="minus", variant="warning")
|
yield Button("-", id="minus", variant="warning")
|
||||||
yield Button("1", id="number-1")
|
yield Button("1", id="number-1", classes="number")
|
||||||
yield Button("2", id="number-2")
|
yield Button("2", id="number-2", classes="number")
|
||||||
yield Button("3", id="number-3")
|
yield Button("3", id="number-3", classes="number")
|
||||||
yield Button("+", id="plus", variant="warning")
|
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="point")
|
||||||
yield Button("=", id="equals", variant="warning")
|
yield Button("=", id="equals", variant="warning")
|
||||||
|
|
||||||
@@ -75,6 +82,8 @@ class CalculatorApp(App):
|
|||||||
"""Called when the user presses a key."""
|
"""Called when the user presses a key."""
|
||||||
|
|
||||||
def press(button_id: str) -> None:
|
def press(button_id: str) -> None:
|
||||||
|
"""Press a button, should it exist."""
|
||||||
|
|
||||||
try:
|
try:
|
||||||
self.query_one(f"#{button_id}", Button).press()
|
self.query_one(f"#{button_id}", Button).press()
|
||||||
except NoMatches:
|
except NoMatches:
|
||||||
@@ -91,54 +100,73 @@ class CalculatorApp(App):
|
|||||||
if button_id is not None:
|
if button_id is not None:
|
||||||
press(self.NAME_MAP.get(key, key))
|
press(self.NAME_MAP.get(key, key))
|
||||||
|
|
||||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
@on(Button.Pressed, ".number")
|
||||||
"""Called when a button is pressed."""
|
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
|
@on(Button.Pressed, "#plus-minus")
|
||||||
assert button_id is not None
|
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")
|
||||||
"""Does the math: LEFT OPERATOR RIGHT"""
|
def percent_pressed(self) -> None:
|
||||||
try:
|
"""Pressed %"""
|
||||||
if self.operator == "plus":
|
self.numbers = self.value = str(Decimal(self.value or "0") / Decimal(100))
|
||||||
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"
|
|
||||||
|
|
||||||
if button_id.startswith("number-"):
|
@on(Button.Pressed, "#point")
|
||||||
number = button_id.partition("-")[-1]
|
def pressed_point(self) -> None:
|
||||||
self.numbers = self.value = self.value.lstrip("0") + number
|
"""Pressed ."""
|
||||||
elif button_id == "plus-minus":
|
if "." not in self.value:
|
||||||
self.numbers = self.value = str(Decimal(self.value or "0") * -1)
|
self.numbers = self.value = (self.value or "0") + "."
|
||||||
elif button_id == "percent":
|
|
||||||
self.numbers = self.value = str(Decimal(self.value or "0") / Decimal(100))
|
@on(Button.Pressed, "#ac")
|
||||||
elif button_id == "point":
|
def pressed_ac(self) -> None:
|
||||||
if "." not in self.value:
|
"""Pressed AC"""
|
||||||
self.numbers = self.value = (self.value or "0") + "."
|
self.value = ""
|
||||||
elif button_id == "ac":
|
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.value = ""
|
||||||
self.left = self.right = Decimal(0)
|
except Exception:
|
||||||
self.operator = "plus"
|
self.numbers = "Error"
|
||||||
self.numbers = "0"
|
|
||||||
elif button_id == "c":
|
@on(Button.Pressed, "#plus,#minus,#divide,#multiply")
|
||||||
self.value = ""
|
def pressed_op(self, event: Button.Pressed) -> None:
|
||||||
self.numbers = "0"
|
"""Pressed one of the arithmetic operations."""
|
||||||
elif button_id in ("plus", "minus", "divide", "multiply"):
|
self.right = Decimal(self.value or "0")
|
||||||
self.right = Decimal(self.value or "0")
|
self._do_math()
|
||||||
do_math()
|
assert event.button.id is not None
|
||||||
self.operator = button_id
|
self.operator = event.button.id
|
||||||
elif button_id == "equals":
|
|
||||||
if self.value:
|
@on(Button.Pressed, "#equals")
|
||||||
self.right = Decimal(self.value)
|
def pressed_equals(self) -> None:
|
||||||
do_math()
|
"""Pressed ="""
|
||||||
|
if self.value:
|
||||||
|
self.right = Decimal(self.value)
|
||||||
|
self._do_math()
|
||||||
|
|
||||||
|
|
||||||
if __name__ == "__main__":
|
if __name__ == "__main__":
|
||||||
|
|||||||
@@ -173,6 +173,7 @@ nav:
|
|||||||
- "api/map_geometry.md"
|
- "api/map_geometry.md"
|
||||||
- "api/message_pump.md"
|
- "api/message_pump.md"
|
||||||
- "api/message.md"
|
- "api/message.md"
|
||||||
|
- "api/on.md"
|
||||||
- "api/pilot.md"
|
- "api/pilot.md"
|
||||||
- "api/query.md"
|
- "api/query.md"
|
||||||
- "api/reactive.md"
|
- "api/reactive.md"
|
||||||
|
|||||||
@@ -9,12 +9,19 @@ from rich.console import RenderableType
|
|||||||
from . import constants
|
from . import constants
|
||||||
from ._context import active_app
|
from ._context import active_app
|
||||||
from ._log import LogGroup, LogVerbosity
|
from ._log import LogGroup, LogVerbosity
|
||||||
|
from ._on import on
|
||||||
from ._work_decorator import work as work
|
from ._work_decorator import work as work
|
||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from typing_extensions import TypeAlias
|
from typing_extensions import TypeAlias
|
||||||
|
|
||||||
__all__ = ["log", "panic", "__version__", "work"] # type: ignore
|
__all__ = [
|
||||||
|
"__version__", # type: ignore
|
||||||
|
"log",
|
||||||
|
"on",
|
||||||
|
"panic",
|
||||||
|
"work",
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
LogCallable: TypeAlias = "Callable"
|
LogCallable: TypeAlias = "Callable"
|
||||||
|
|||||||
60
src/textual/_on.py
Normal file
60
src/textual/_on.py
Normal 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
|
||||||
@@ -12,6 +12,7 @@ from functools import lru_cache
|
|||||||
from inspect import getfile
|
from inspect import getfile
|
||||||
from typing import (
|
from typing import (
|
||||||
TYPE_CHECKING,
|
TYPE_CHECKING,
|
||||||
|
Callable,
|
||||||
ClassVar,
|
ClassVar,
|
||||||
Iterable,
|
Iterable,
|
||||||
Sequence,
|
Sequence,
|
||||||
@@ -49,6 +50,7 @@ if TYPE_CHECKING:
|
|||||||
from rich.console import RenderableType
|
from rich.console import RenderableType
|
||||||
from .app import App
|
from .app import App
|
||||||
from .css.query import DOMQuery, QueryType
|
from .css.query import DOMQuery, QueryType
|
||||||
|
from .message import Message
|
||||||
from .screen import Screen
|
from .screen import Screen
|
||||||
from .widget import Widget
|
from .widget import Widget
|
||||||
from .worker import Worker, WorkType, ResultType
|
from .worker import Worker, WorkType, ResultType
|
||||||
@@ -147,6 +149,8 @@ class DOMNode(MessagePump):
|
|||||||
|
|
||||||
_reactives: ClassVar[dict[str, Reactive]]
|
_reactives: ClassVar[dict[str, Reactive]]
|
||||||
|
|
||||||
|
_decorated_handlers: dict[type[Message], list[tuple[Callable, str | None]]]
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
*,
|
*,
|
||||||
|
|||||||
@@ -25,6 +25,8 @@ from ._context import (
|
|||||||
from ._time import time
|
from ._time import time
|
||||||
from ._types import CallbackType
|
from ._types import CallbackType
|
||||||
from .case import camel_to_snake
|
from .case import camel_to_snake
|
||||||
|
from .css.match import match
|
||||||
|
from .css.parse import parse_selectors
|
||||||
from .errors import DuplicateKeyHandlers
|
from .errors import DuplicateKeyHandlers
|
||||||
from .events import Event
|
from .events import Event
|
||||||
from .message import Message
|
from .message import Message
|
||||||
@@ -57,7 +59,16 @@ class _MessagePumpMeta(type):
|
|||||||
):
|
):
|
||||||
namespace = camel_to_snake(name)
|
namespace = camel_to_snake(name)
|
||||||
isclass = inspect.isclass
|
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():
|
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 isclass(value) and issubclass(value, Message):
|
||||||
if "namespace" not in value.__dict__:
|
if "namespace" not in value.__dict__:
|
||||||
value.namespace = namespace
|
value.namespace = namespace
|
||||||
@@ -545,12 +556,29 @@ class MessagePump(metaclass=_MessagePumpMeta):
|
|||||||
method_name: Handler method name.
|
method_name: Handler method name.
|
||||||
message: Message object.
|
message: Message object.
|
||||||
"""
|
"""
|
||||||
private_method = f"_{method_name}"
|
|
||||||
for cls in self.__class__.__mro__:
|
for cls in self.__class__.__mro__:
|
||||||
if message._no_default_action:
|
if message._no_default_action:
|
||||||
break
|
break
|
||||||
method = cls.__dict__.get(private_method) or cls.__dict__.get(method_name)
|
# Try decorated handlers first
|
||||||
if method is not None:
|
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)
|
yield cls, method.__get__(self, cls)
|
||||||
|
|
||||||
async def on_event(self, event: events.Event) -> None:
|
async def on_event(self, event: events.Event) -> None:
|
||||||
|
|||||||
@@ -2927,6 +2927,7 @@ class Widget(DOMNode):
|
|||||||
self.log.warning(self, f"IS NOT RUNNING, {message!r} not sent")
|
self.log.warning(self, f"IS NOT RUNNING, {message!r} not sent")
|
||||||
except NoActiveAppError:
|
except NoActiveAppError:
|
||||||
pass
|
pass
|
||||||
|
|
||||||
return super().post_message(message)
|
return super().post_message(message)
|
||||||
|
|
||||||
async def _on_idle(self, event: events.Idle) -> None:
|
async def _on_idle(self, event: events.Idle) -> None:
|
||||||
|
|||||||
104
tests/test_on.py
Normal file
104
tests/test_on.py
Normal 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
|
||||||
Reference in New Issue
Block a user