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
|
||||
|
||||
- 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
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.color import Color
|
||||
from textual.message import Message, MessageTarget
|
||||
from textual.message import Message
|
||||
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"
|
||||
</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:
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
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 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,
|
||||
*,
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
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