mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into cygnus-x-1
This commit is contained in:
13
CHANGELOG.md
13
CHANGELOG.md
@@ -5,7 +5,13 @@ All notable changes to this project will be documented in this file.
|
|||||||
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
The format is based on [Keep a Changelog](http://keepachangelog.com/)
|
||||||
and this project adheres to [Semantic Versioning](http://semver.org/).
|
and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||||
|
|
||||||
## Unreleased
|
## [0.24.1] - 2023-05-08
|
||||||
|
|
||||||
|
### Fixed
|
||||||
|
|
||||||
|
- Fix TypeError in code browser
|
||||||
|
|
||||||
|
## [0.24.0] - 2023-05-08
|
||||||
|
|
||||||
### Fixed
|
### Fixed
|
||||||
|
|
||||||
@@ -20,6 +26,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
- Added `always_update` as an optional argument for `reactive.var`
|
- Added `always_update` as an optional argument for `reactive.var`
|
||||||
- Made Binding description default to empty string, which is equivalent to show=False https://github.com/Textualize/textual/pull/2501
|
- Made Binding description default to empty string, which is equivalent to show=False https://github.com/Textualize/textual/pull/2501
|
||||||
- Modified Message to allow it to be used as a dataclass https://github.com/Textualize/textual/pull/2501
|
- Modified Message to allow it to be used as a dataclass https://github.com/Textualize/textual/pull/2501
|
||||||
|
- Decorator `@on` accepts arbitrary `**kwargs` to apply selectors to attributes of the message https://github.com/Textualize/textual/pull/2498
|
||||||
|
|
||||||
### Added
|
### Added
|
||||||
|
|
||||||
@@ -34,6 +41,8 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
- Added `TreeNode.is_root` https://github.com/Textualize/textual/pull/2510
|
- Added `TreeNode.is_root` https://github.com/Textualize/textual/pull/2510
|
||||||
- Added `TreeNode.remove_children` https://github.com/Textualize/textual/pull/2510
|
- Added `TreeNode.remove_children` https://github.com/Textualize/textual/pull/2510
|
||||||
- Added `TreeNode.remove` https://github.com/Textualize/textual/pull/2510
|
- Added `TreeNode.remove` https://github.com/Textualize/textual/pull/2510
|
||||||
|
- Added classvar `Message.ALLOW_SELECTOR_MATCH` https://github.com/Textualize/textual/pull/2498
|
||||||
|
- Added `ALLOW_SELECTOR_MATCH` to all built-in messages associated with widgets https://github.com/Textualize/textual/pull/2498
|
||||||
- Markdown document sub-widgets now reference the container document
|
- Markdown document sub-widgets now reference the container document
|
||||||
- Table of contents of a markdown document now references the document
|
- Table of contents of a markdown document now references the document
|
||||||
- Added the `control` property to messages
|
- Added the `control` property to messages
|
||||||
@@ -925,6 +934,8 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
|
|||||||
- New handler system for messages that doesn't require inheritance
|
- New handler system for messages that doesn't require inheritance
|
||||||
- Improved traceback handling
|
- Improved traceback handling
|
||||||
|
|
||||||
|
[0.24.1]: https://github.com/Textualize/textual/compare/v0.24.0...v0.24.1
|
||||||
|
[0.24.0]: https://github.com/Textualize/textual/compare/v0.23.0...v0.24.0
|
||||||
[0.23.0]: https://github.com/Textualize/textual/compare/v0.22.3...v0.23.0
|
[0.23.0]: https://github.com/Textualize/textual/compare/v0.22.3...v0.23.0
|
||||||
[0.22.3]: https://github.com/Textualize/textual/compare/v0.22.2...v0.22.3
|
[0.22.3]: https://github.com/Textualize/textual/compare/v0.22.2...v0.22.3
|
||||||
[0.22.2]: https://github.com/Textualize/textual/compare/v0.22.1...v0.22.2
|
[0.22.2]: https://github.com/Textualize/textual/compare/v0.22.1...v0.22.2
|
||||||
|
|||||||
85
docs/blog/posts/release2-24-0.md
Normal file
85
docs/blog/posts/release2-24-0.md
Normal file
@@ -0,0 +1,85 @@
|
|||||||
|
---
|
||||||
|
draft: false
|
||||||
|
date: 2023-05-08
|
||||||
|
categories:
|
||||||
|
- Release
|
||||||
|
title: "Textual 0.24.0 adds a Select control"
|
||||||
|
authors:
|
||||||
|
- willmcgugan
|
||||||
|
---
|
||||||
|
|
||||||
|
# Textual 0.24.0 adds a Select control
|
||||||
|
|
||||||
|
Coming just 5 days after the last release, we have version 0.24.0 which we are crowning the King of Textual releases.
|
||||||
|
At least until it is deposed by version 0.25.0.
|
||||||
|
|
||||||
|
<!-- more -->
|
||||||
|
|
||||||
|
The highlight of this release is the new [Select](/widget_gallery/#select) widget: a very familiar control from the web and desktop worlds.
|
||||||
|
Here's a screenshot and code:
|
||||||
|
|
||||||
|
=== "Output (expanded)"
|
||||||
|
|
||||||
|
```{.textual path="docs/examples/widgets/select_widget.py" press="tab,enter,down,down"}
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "select_widget.py"
|
||||||
|
|
||||||
|
```python
|
||||||
|
--8<-- "docs/examples/widgets/select_widget.py"
|
||||||
|
```
|
||||||
|
|
||||||
|
=== "select.css"
|
||||||
|
|
||||||
|
```sass
|
||||||
|
--8<-- "docs/examples/widgets/select.css"
|
||||||
|
```
|
||||||
|
|
||||||
|
## New styles
|
||||||
|
|
||||||
|
This one required new functionality in Textual itself.
|
||||||
|
The "pull-down" overlay with options presented a difficulty with the previous API.
|
||||||
|
The overlay needed to appear over any content below it.
|
||||||
|
This is possible (using [layers](https://textual.textualize.io/styles/layers/)), but there was no simple way of positioning it directly under the parent widget.
|
||||||
|
|
||||||
|
We solved this with a new "overlay" concept, which can considered a special layer for user interactions like this Select, but also pop-up menus, tooltips, etc.
|
||||||
|
Widgets styled to use the overlay appear in their natural place in the "document", but on top of everything else.
|
||||||
|
|
||||||
|
A second problem we tackled was ensuring that an overlay widget was never clipped.
|
||||||
|
This was also solved with a new rule called "constrain".
|
||||||
|
Applying `constrain` to a widget will keep the widget within the bounds of the screen.
|
||||||
|
In the case of `Select`, if you expand the options while at the bottom of the screen, then the overlay will be moved up so that you can see all the options.
|
||||||
|
|
||||||
|
These new rules are currently undocumented as they are still subject to change, but you can see them in the [Select](https://github.com/Textualize/textual/blob/main/src/textual/widgets/_select.py#L179) source if you are interested.
|
||||||
|
|
||||||
|
In a future release these will be finalized and you can confidently use them in your own projects.
|
||||||
|
|
||||||
|
## Fixes for the @on decorator
|
||||||
|
|
||||||
|
The new `@on` decorator is proving popular.
|
||||||
|
To recap, it is a more declarative and finely grained way of dispatching messages.
|
||||||
|
Here's a snippet from the [calculator](https://github.com/Textualize/textual/blob/main/examples/calculator.py) example which uses `@on`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@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
|
||||||
|
```
|
||||||
|
|
||||||
|
The decorator arranges for the method to be called when any of the four math operation buttons are pressed.
|
||||||
|
|
||||||
|
In 0.24.0 we've fixed some missing attributes which prevented the decorator from working with some messages.
|
||||||
|
We've also extended the decorator to use keywords arguments, so it will match attributes other than `control`.
|
||||||
|
|
||||||
|
## Other fixes
|
||||||
|
|
||||||
|
There is a surprising number of fixes in this release for just 5 days. See [CHANGELOG.md](https://github.com/Textualize/textual/blob/main/CHANGELOG.md) for details.
|
||||||
|
|
||||||
|
|
||||||
|
## Join us
|
||||||
|
|
||||||
|
If you want to talk about this update or anything else Textual related, join us on our [Discord server](https://discord.gg/Enf6Z3qhVr).
|
||||||
@@ -6,6 +6,3 @@ Select {
|
|||||||
width: 60;
|
width: 60;
|
||||||
margin: 2;
|
margin: 2;
|
||||||
}
|
}
|
||||||
Input {
|
|
||||||
width: 60;
|
|
||||||
}
|
|
||||||
|
|||||||
@@ -218,10 +218,23 @@ Messages from builtin controls will have this attribute, but you may need to add
|
|||||||
|
|
||||||
!!! note
|
!!! note
|
||||||
|
|
||||||
If multiple decorated handlers match the `control`, then they will *all* be called in the order they are defined.
|
If multiple decorated handlers match the message, then they will *all* be called in the order they are defined.
|
||||||
|
|
||||||
The naming convention handler will be called *after* any decorated handlers.
|
The naming convention handler will be called *after* any decorated handlers.
|
||||||
|
|
||||||
|
#### Applying CSS selectors to arbitrary attributes
|
||||||
|
|
||||||
|
The `on` decorator also accepts selectors as keyword arguments that may be used to match other attributes in a Message, provided those attributes are in [`Message.ALLOW_SELECTOR_MATCH`][textual.message.Message.ALLOW_SELECTOR_MATCH].
|
||||||
|
|
||||||
|
The snippet below shows how to match the message [`TabbedContent.TabActivated`][textual.widgets.TabbedContent.TabActivated] only when the tab with id `home` was activated:
|
||||||
|
|
||||||
|
```py
|
||||||
|
@on(TabbedContent.TabActivated, tab="#home")
|
||||||
|
def home_tab(self) -> None:
|
||||||
|
self.log("Switched back to home tab.")
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
### 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.
|
||||||
@@ -231,6 +244,14 @@ Message handler methods can be written with or without a positional argument. If
|
|||||||
self.screen.styles.animate("background", message.color, duration=0.5)
|
self.screen.styles.animate("background", message.color, duration=0.5)
|
||||||
```
|
```
|
||||||
|
|
||||||
|
A similar handler can be written using the decorator `on`:
|
||||||
|
|
||||||
|
```python
|
||||||
|
@on(ColorButton.Selected)
|
||||||
|
def animate_background_color(self, message: ColorButton.Selected) -> None:
|
||||||
|
self.screen.styles.animate("background", message.color, duration=0.5)
|
||||||
|
```
|
||||||
|
|
||||||
If the body of your handler doesn't require any information in the message you can omit it from the method signature. If we just want to play a bell noise when the button is clicked, we could write our handler like this:
|
If the body of your handler doesn't require any information in the message you can omit it from the method signature. If we just want to play a bell noise when the button is clicked, we could write our handler like this:
|
||||||
|
|
||||||
```python
|
```python
|
||||||
|
|||||||
@@ -45,7 +45,7 @@ Widgets are key to making user-friendly interfaces. The builtin widgets should c
|
|||||||
- [x] DataTable
|
- [x] DataTable
|
||||||
* [x] Cell select
|
* [x] Cell select
|
||||||
* [x] Row / Column select
|
* [x] Row / Column select
|
||||||
* [ ] API to update cells / rows
|
* [x] API to update cells / rows
|
||||||
* [ ] Lazy loading API
|
* [ ] Lazy loading API
|
||||||
- [ ] Date picker
|
- [ ] Date picker
|
||||||
- [ ] Drop-down menus
|
- [ ] Drop-down menus
|
||||||
@@ -61,6 +61,7 @@ Widgets are key to making user-friendly interfaces. The builtin widgets should c
|
|||||||
* [ ] Validation
|
* [ ] Validation
|
||||||
* [ ] Error / warning states
|
* [ ] Error / warning states
|
||||||
* [ ] Template types: IP address, physical units (weight, volume), currency, credit card etc
|
* [ ] Template types: IP address, physical units (weight, volume), currency, credit card etc
|
||||||
|
- [X] Select control (pull-down)
|
||||||
- [X] Markdown viewer
|
- [X] Markdown viewer
|
||||||
* [ ] Collapsible sections
|
* [ ] Collapsible sections
|
||||||
* [ ] Custom widgets
|
* [ ] Custom widgets
|
||||||
|
|||||||
@@ -54,7 +54,7 @@ class CodeBrowser(App):
|
|||||||
code_view = self.query_one("#code", Static)
|
code_view = self.query_one("#code", Static)
|
||||||
try:
|
try:
|
||||||
syntax = Syntax.from_path(
|
syntax = Syntax.from_path(
|
||||||
event.path,
|
str(event.path),
|
||||||
line_numbers=True,
|
line_numbers=True,
|
||||||
word_wrap=False,
|
word_wrap=False,
|
||||||
indent_guides=True,
|
indent_guides=True,
|
||||||
@@ -66,7 +66,7 @@ class CodeBrowser(App):
|
|||||||
else:
|
else:
|
||||||
code_view.update(syntax)
|
code_view.update(syntax)
|
||||||
self.query_one("#code-view").scroll_home(animate=False)
|
self.query_one("#code-view").scroll_home(animate=False)
|
||||||
self.sub_title = event.path
|
self.sub_title = str(event.path)
|
||||||
|
|
||||||
def action_toggle_files(self) -> None:
|
def action_toggle_files(self) -> None:
|
||||||
"""Called in response to key binding."""
|
"""Called in response to key binding."""
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
[tool.poetry]
|
[tool.poetry]
|
||||||
name = "textual"
|
name = "textual"
|
||||||
version = "0.23.0"
|
version = "0.24.1"
|
||||||
homepage = "https://github.com/Textualize/textual"
|
homepage = "https://github.com/Textualize/textual"
|
||||||
description = "Modern Text User Interface framework"
|
description = "Modern Text User Interface framework"
|
||||||
authors = ["Will McGugan <will@textualize.io>"]
|
authors = ["Will McGugan <will@textualize.io>"]
|
||||||
|
|||||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
|||||||
|
|
||||||
from typing import Callable, TypeVar
|
from typing import Callable, TypeVar
|
||||||
|
|
||||||
|
from .css.model import SelectorSet
|
||||||
from .css.parse import parse_selectors
|
from .css.parse import parse_selectors
|
||||||
from .css.tokenizer import TokenError
|
from .css.tokenizer import TokenError
|
||||||
from .message import Message
|
from .message import Message
|
||||||
@@ -13,39 +14,71 @@ class OnDecoratorError(Exception):
|
|||||||
"""Errors related to the `on` decorator.
|
"""Errors related to the `on` decorator.
|
||||||
|
|
||||||
Typically raised at import time as an early warning system.
|
Typically raised at import time as an early warning system.
|
||||||
|
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
|
||||||
|
class OnNoWidget(Exception):
|
||||||
|
"""A selector was applied to an attribute that isn't a widget."""
|
||||||
|
|
||||||
|
|
||||||
def on(
|
def on(
|
||||||
message_type: type[Message], selector: str | None = None
|
message_type: type[Message], selector: str | None = None, **kwargs: str
|
||||||
) -> Callable[[DecoratedType], DecoratedType]:
|
) -> Callable[[DecoratedType], DecoratedType]:
|
||||||
"""Decorator to declare method is a message handler.
|
"""Decorator to declare that the method is a message handler.
|
||||||
|
|
||||||
|
The decorator accepts an optional CSS selector that will be matched against a widget exposed by
|
||||||
|
a `control` attribute on the message.
|
||||||
|
|
||||||
Example:
|
Example:
|
||||||
```python
|
```python
|
||||||
|
# Handle the press of buttons with ID "#quit".
|
||||||
@on(Button.Pressed, "#quit")
|
@on(Button.Pressed, "#quit")
|
||||||
def quit_button(self) -> None:
|
def quit_button(self) -> None:
|
||||||
self.app.quit()
|
self.app.quit()
|
||||||
```
|
```
|
||||||
|
|
||||||
|
Keyword arguments can be used to match additional selectors for attributes
|
||||||
|
listed in [`ALLOW_SELECTOR_MATCH`][textual.message.Message.ALLOW_SELECTOR_MATCH].
|
||||||
|
|
||||||
|
Example:
|
||||||
|
```python
|
||||||
|
# Handle the activation of the tab "#home" within the `TabbedContent` "#tabs".
|
||||||
|
@on(TabbedContent.TabActivated, "#tabs", tab="#home")
|
||||||
|
def switch_to_home(self) -> None:
|
||||||
|
self.log("Switching back to the home tab.")
|
||||||
|
...
|
||||||
|
```
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
message_type: The message type (i.e. the class).
|
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`
|
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.
|
matches the widget from the `control` attribute of the message.
|
||||||
|
**kwargs: Additional selectors for other attributes of the message.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
if selector is not None and not hasattr(message_type, "control"):
|
selectors: dict[str, str] = {}
|
||||||
raise OnDecoratorError(
|
|
||||||
"The 'selector' argument requires a message class with a 'control' attribute (such as events from controls)."
|
|
||||||
)
|
|
||||||
|
|
||||||
if selector is not None:
|
if selector is not None:
|
||||||
try:
|
selectors["control"] = selector
|
||||||
parse_selectors(selector)
|
if kwargs:
|
||||||
except TokenError as error:
|
selectors.update(kwargs)
|
||||||
|
|
||||||
|
parsed_selectors: dict[str, tuple[SelectorSet, ...]] = {}
|
||||||
|
for attribute, css_selector in selectors.items():
|
||||||
|
if attribute == "control":
|
||||||
|
if message_type.control is None:
|
||||||
|
raise OnDecoratorError(
|
||||||
|
"The message class must have a 'control' to match with the on decorator"
|
||||||
|
)
|
||||||
|
elif attribute not in message_type.ALLOW_SELECTOR_MATCH:
|
||||||
raise OnDecoratorError(
|
raise OnDecoratorError(
|
||||||
f"Unable to parse selector {selector!r}; check for syntax errors"
|
f"The attribute {attribute!r} can't be matched; have you added it to "
|
||||||
|
+ f"{message_type.__name__}.ALLOW_SELECTOR_MATCH?"
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
parsed_selectors[attribute] = parse_selectors(css_selector)
|
||||||
|
except TokenError:
|
||||||
|
raise OnDecoratorError(
|
||||||
|
f"Unable to parse selector {css_selector!r} for {attribute}; check for syntax errors"
|
||||||
) from None
|
) from None
|
||||||
|
|
||||||
def decorator(method: DecoratedType) -> DecoratedType:
|
def decorator(method: DecoratedType) -> DecoratedType:
|
||||||
@@ -53,7 +86,7 @@ def on(
|
|||||||
|
|
||||||
if not hasattr(method, "_textual_on"):
|
if not hasattr(method, "_textual_on"):
|
||||||
setattr(method, "_textual_on", [])
|
setattr(method, "_textual_on", [])
|
||||||
getattr(method, "_textual_on").append((message_type, selector))
|
getattr(method, "_textual_on").append((message_type, parsed_selectors))
|
||||||
|
|
||||||
return method
|
return method
|
||||||
|
|
||||||
|
|||||||
@@ -16,6 +16,7 @@ from .case import camel_to_snake
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .message_pump import MessagePump
|
from .message_pump import MessagePump
|
||||||
|
from .widget import Widget
|
||||||
|
|
||||||
|
|
||||||
@rich.repr.auto
|
@rich.repr.auto
|
||||||
@@ -32,10 +33,16 @@ class Message:
|
|||||||
"_prevent",
|
"_prevent",
|
||||||
]
|
]
|
||||||
|
|
||||||
|
ALLOW_SELECTOR_MATCH: ClassVar[set[str]] = set()
|
||||||
|
"""Additional attributes that can be used with the [`on` decorator][textual.on].
|
||||||
|
|
||||||
|
These attributes must be widgets.
|
||||||
|
"""
|
||||||
bubble: ClassVar[bool] = True # Message will bubble to parent
|
bubble: ClassVar[bool] = True # Message will bubble to parent
|
||||||
verbose: ClassVar[bool] = False # Message is verbose
|
verbose: ClassVar[bool] = False # Message is verbose
|
||||||
no_dispatch: ClassVar[bool] = False # Message may not be handled by client code
|
no_dispatch: ClassVar[bool] = False # Message may not be handled by client code
|
||||||
namespace: ClassVar[str] = "" # Namespace to disambiguate messages
|
namespace: ClassVar[str] = "" # Namespace to disambiguate messages
|
||||||
|
control: Widget | None = None
|
||||||
|
|
||||||
def __init__(self) -> None:
|
def __init__(self) -> None:
|
||||||
self.__post_init__()
|
self.__post_init__()
|
||||||
|
|||||||
@@ -22,11 +22,11 @@ from ._context import (
|
|||||||
active_message_pump,
|
active_message_pump,
|
||||||
prevent_message_types_stack,
|
prevent_message_types_stack,
|
||||||
)
|
)
|
||||||
|
from ._on import OnNoWidget
|
||||||
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.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
|
||||||
@@ -35,6 +35,7 @@ from .timer import Timer, TimerCallback
|
|||||||
|
|
||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from .app import App
|
from .app import App
|
||||||
|
from .css.model import SelectorSet
|
||||||
|
|
||||||
|
|
||||||
class CallbackError(Exception):
|
class CallbackError(Exception):
|
||||||
@@ -60,15 +61,15 @@ class _MessagePumpMeta(type):
|
|||||||
namespace = camel_to_snake(name)
|
namespace = camel_to_snake(name)
|
||||||
isclass = inspect.isclass
|
isclass = inspect.isclass
|
||||||
handlers: dict[
|
handlers: dict[
|
||||||
type[Message], list[tuple[Callable, str | None]]
|
type[Message], list[tuple[Callable, dict[str, tuple[SelectorSet, ...]]]]
|
||||||
] = class_dict.get("_decorated_handlers", {})
|
] = class_dict.get("_decorated_handlers", {})
|
||||||
|
|
||||||
class_dict["_decorated_handlers"] = 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"):
|
if callable(value) and hasattr(value, "_textual_on"):
|
||||||
for message_type, selector in getattr(value, "_textual_on"):
|
for message_type, selectors in getattr(value, "_textual_on"):
|
||||||
handlers.setdefault(message_type, []).append((value, selector))
|
handlers.setdefault(message_type, []).append((value, selectors))
|
||||||
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
|
||||||
@@ -563,14 +564,23 @@ class MessagePump(metaclass=_MessagePumpMeta):
|
|||||||
decorated_handlers = cls.__dict__.get("_decorated_handlers")
|
decorated_handlers = cls.__dict__.get("_decorated_handlers")
|
||||||
if decorated_handlers is not None:
|
if decorated_handlers is not None:
|
||||||
handlers = decorated_handlers.get(type(message), [])
|
handlers = decorated_handlers.get(type(message), [])
|
||||||
for method, selector in handlers:
|
from .widget import Widget
|
||||||
if selector is None:
|
|
||||||
|
for method, selectors in handlers:
|
||||||
|
if not selectors:
|
||||||
yield cls, method.__get__(self, cls)
|
yield cls, method.__get__(self, cls)
|
||||||
else:
|
else:
|
||||||
selector_sets = parse_selectors(selector)
|
if not message._sender:
|
||||||
if message._sender is not None and match(
|
continue
|
||||||
selector_sets, message.control
|
for attribute, selector in selectors.items():
|
||||||
):
|
node = getattr(message, attribute)
|
||||||
|
if not isinstance(node, Widget):
|
||||||
|
raise OnNoWidget(
|
||||||
|
f"on decorator can't match against {attribute!r} as it is not a widget."
|
||||||
|
)
|
||||||
|
if not match(selector, node):
|
||||||
|
break
|
||||||
|
else:
|
||||||
yield cls, method.__get__(self, cls)
|
yield cls, method.__get__(self, cls)
|
||||||
|
|
||||||
# Fall back to the naming convention
|
# Fall back to the naming convention
|
||||||
|
|||||||
@@ -261,7 +261,7 @@ class DirectoryTree(Tree[DirEntry]):
|
|||||||
if not dir_entry.loaded:
|
if not dir_entry.loaded:
|
||||||
self._load_directory(event.node)
|
self._load_directory(event.node)
|
||||||
else:
|
else:
|
||||||
self.post_message(self.FileSelected(event.node, dir_entry.path))
|
self.post_message(self.FileSelected(self, event.node, dir_entry.path))
|
||||||
|
|
||||||
def _on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
|
def _on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
|
||||||
event.stop()
|
event.stop()
|
||||||
@@ -269,4 +269,4 @@ class DirectoryTree(Tree[DirEntry]):
|
|||||||
if dir_entry is None:
|
if dir_entry is None:
|
||||||
return
|
return
|
||||||
if not dir_entry.path.is_dir():
|
if not dir_entry.path.is_dir():
|
||||||
self.post_message(self.FileSelected(event.node, dir_entry.path))
|
self.post_message(self.FileSelected(self, event.node, dir_entry.path))
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ class ListView(VerticalScroll, can_focus=True, can_focus_children=False):
|
|||||||
or in a parent widget in the DOM.
|
or in a parent widget in the DOM.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
ALLOW_SELECTOR_MATCH = {"item"}
|
||||||
|
"""Additional message attributes that can be used with the [`on` decorator][textual.on]."""
|
||||||
|
|
||||||
def __init__(self, list_view: ListView, item: ListItem | None) -> None:
|
def __init__(self, list_view: ListView, item: ListItem | None) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.list_view: ListView = list_view
|
self.list_view: ListView = list_view
|
||||||
@@ -69,6 +72,9 @@ class ListView(VerticalScroll, can_focus=True, can_focus_children=False):
|
|||||||
a parent widget in the DOM.
|
a parent widget in the DOM.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
ALLOW_SELECTOR_MATCH = {"item"}
|
||||||
|
"""Additional message attributes that can be used with the [`on` decorator][textual.on]."""
|
||||||
|
|
||||||
def __init__(self, list_view: ListView, item: ListItem) -> None:
|
def __init__(self, list_view: ListView, item: ListItem) -> None:
|
||||||
super().__init__()
|
super().__init__()
|
||||||
self.list_view: ListView = list_view
|
self.list_view: ListView = list_view
|
||||||
|
|||||||
@@ -78,6 +78,9 @@ class RadioSet(Container, can_focus=True, can_focus_children=False):
|
|||||||
This message can be handled using an `on_radio_set_changed` method.
|
This message can be handled using an `on_radio_set_changed` method.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
|
ALLOW_SELECTOR_MATCH = {"pressed"}
|
||||||
|
"""Additional message attributes that can be used with the [`on` decorator][textual.on]."""
|
||||||
|
|
||||||
def __init__(self, radio_set: RadioSet, pressed: RadioButton) -> None:
|
def __init__(self, radio_set: RadioSet, pressed: RadioButton) -> None:
|
||||||
"""Initialise the message.
|
"""Initialise the message.
|
||||||
|
|
||||||
|
|||||||
@@ -88,6 +88,9 @@ class TabbedContent(Widget):
|
|||||||
class TabActivated(Message):
|
class TabActivated(Message):
|
||||||
"""Posted when the active tab changes."""
|
"""Posted when the active tab changes."""
|
||||||
|
|
||||||
|
ALLOW_SELECTOR_MATCH = {"tab"}
|
||||||
|
"""Additional message attributes that can be used with the [`on` decorator][textual.on]."""
|
||||||
|
|
||||||
def __init__(self, tabbed_content: TabbedContent, tab: Tab) -> None:
|
def __init__(self, tabbed_content: TabbedContent, tab: Tab) -> None:
|
||||||
"""Initialize message.
|
"""Initialize message.
|
||||||
|
|
||||||
|
|||||||
@@ -178,6 +178,9 @@ class Tabs(Widget, can_focus=True):
|
|||||||
class TabActivated(Message):
|
class TabActivated(Message):
|
||||||
"""Sent when a new tab is activated."""
|
"""Sent when a new tab is activated."""
|
||||||
|
|
||||||
|
ALLOW_SELECTOR_MATCH = {"tab"}
|
||||||
|
"""Additional message attributes that can be used with the [`on` decorator][textual.on]."""
|
||||||
|
|
||||||
tabs: Tabs
|
tabs: Tabs
|
||||||
"""The tabs widget containing the tab."""
|
"""The tabs widget containing the tab."""
|
||||||
tab: Tab
|
tab: Tab
|
||||||
|
|||||||
@@ -5,7 +5,7 @@ from textual._on import OnDecoratorError
|
|||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.message import Message
|
from textual.message import Message
|
||||||
from textual.widget import Widget
|
from textual.widget import Widget
|
||||||
from textual.widgets import Button
|
from textual.widgets import Button, TabbedContent, TabPane
|
||||||
|
|
||||||
|
|
||||||
async def test_on_button_pressed() -> None:
|
async def test_on_button_pressed() -> None:
|
||||||
@@ -102,3 +102,44 @@ def test_on_no_control() -> None:
|
|||||||
@on(CustomMessage, "#foo")
|
@on(CustomMessage, "#foo")
|
||||||
def foo():
|
def foo():
|
||||||
pass
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def test_on_attribute_not_listed() -> None:
|
||||||
|
"""Check `on` checks if the attribute is in ALLOW_SELECTOR_MATCH."""
|
||||||
|
|
||||||
|
class CustomMessage(Message):
|
||||||
|
pass
|
||||||
|
|
||||||
|
with pytest.raises(OnDecoratorError):
|
||||||
|
|
||||||
|
@on(CustomMessage, foo="bar")
|
||||||
|
def foo():
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
async def test_on_arbitrary_attributes() -> None:
|
||||||
|
log: list[str] = []
|
||||||
|
|
||||||
|
class OnArbitraryAttributesApp(App[None]):
|
||||||
|
def compose(self) -> ComposeResult:
|
||||||
|
with TabbedContent():
|
||||||
|
yield TabPane("One", id="one")
|
||||||
|
yield TabPane("Two", id="two")
|
||||||
|
yield TabPane("Three", id="three")
|
||||||
|
|
||||||
|
def on_mount(self) -> None:
|
||||||
|
self.query_one(TabbedContent).add_class("tabs")
|
||||||
|
|
||||||
|
@on(TabbedContent.TabActivated, tab="#one")
|
||||||
|
def one(self) -> None:
|
||||||
|
log.append("one")
|
||||||
|
|
||||||
|
@on(TabbedContent.TabActivated, ".tabs", tab="#two")
|
||||||
|
def two(self) -> None:
|
||||||
|
log.append("two")
|
||||||
|
|
||||||
|
app = OnArbitraryAttributesApp()
|
||||||
|
async with app.run_test() as pilot:
|
||||||
|
await pilot.press("tab", "right", "right")
|
||||||
|
|
||||||
|
assert log == ["one", "two"]
|
||||||
|
|||||||
Reference in New Issue
Block a user