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/)
|
||||
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
|
||||
|
||||
@@ -20,6 +26,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- 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
|
||||
- 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
|
||||
|
||||
@@ -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.remove_children` 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
|
||||
- Table of contents of a markdown document now references the document
|
||||
- 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
|
||||
- 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.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
|
||||
|
||||
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;
|
||||
margin: 2;
|
||||
}
|
||||
Input {
|
||||
width: 60;
|
||||
}
|
||||
|
||||
@@ -218,10 +218,23 @@ Messages from builtin controls will have this attribute, but you may need to add
|
||||
|
||||
!!! 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.
|
||||
|
||||
#### 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
|
||||
|
||||
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)
|
||||
```
|
||||
|
||||
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:
|
||||
|
||||
```python
|
||||
|
||||
@@ -45,7 +45,7 @@ Widgets are key to making user-friendly interfaces. The builtin widgets should c
|
||||
- [x] DataTable
|
||||
* [x] Cell select
|
||||
* [x] Row / Column select
|
||||
* [ ] API to update cells / rows
|
||||
* [x] API to update cells / rows
|
||||
* [ ] Lazy loading API
|
||||
- [ ] Date picker
|
||||
- [ ] Drop-down menus
|
||||
@@ -61,6 +61,7 @@ Widgets are key to making user-friendly interfaces. The builtin widgets should c
|
||||
* [ ] Validation
|
||||
* [ ] Error / warning states
|
||||
* [ ] Template types: IP address, physical units (weight, volume), currency, credit card etc
|
||||
- [X] Select control (pull-down)
|
||||
- [X] Markdown viewer
|
||||
* [ ] Collapsible sections
|
||||
* [ ] Custom widgets
|
||||
|
||||
@@ -54,7 +54,7 @@ class CodeBrowser(App):
|
||||
code_view = self.query_one("#code", Static)
|
||||
try:
|
||||
syntax = Syntax.from_path(
|
||||
event.path,
|
||||
str(event.path),
|
||||
line_numbers=True,
|
||||
word_wrap=False,
|
||||
indent_guides=True,
|
||||
@@ -66,7 +66,7 @@ class CodeBrowser(App):
|
||||
else:
|
||||
code_view.update(syntax)
|
||||
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:
|
||||
"""Called in response to key binding."""
|
||||
|
||||
@@ -1,6 +1,6 @@
|
||||
[tool.poetry]
|
||||
name = "textual"
|
||||
version = "0.23.0"
|
||||
version = "0.24.1"
|
||||
homepage = "https://github.com/Textualize/textual"
|
||||
description = "Modern Text User Interface framework"
|
||||
authors = ["Will McGugan <will@textualize.io>"]
|
||||
|
||||
@@ -2,6 +2,7 @@ from __future__ import annotations
|
||||
|
||||
from typing import Callable, TypeVar
|
||||
|
||||
from .css.model import SelectorSet
|
||||
from .css.parse import parse_selectors
|
||||
from .css.tokenizer import TokenError
|
||||
from .message import Message
|
||||
@@ -13,39 +14,71 @@ class OnDecoratorError(Exception):
|
||||
"""Errors related to the `on` decorator.
|
||||
|
||||
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(
|
||||
message_type: type[Message], selector: str | None = None
|
||||
message_type: type[Message], selector: str | None = None, **kwargs: str
|
||||
) -> 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:
|
||||
```python
|
||||
# Handle the press of buttons with ID "#quit".
|
||||
@on(Button.Pressed, "#quit")
|
||||
def quit_button(self) -> None:
|
||||
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:
|
||||
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.
|
||||
**kwargs: Additional selectors for other attributes 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)."
|
||||
)
|
||||
|
||||
selectors: dict[str, str] = {}
|
||||
if selector is not None:
|
||||
try:
|
||||
parse_selectors(selector)
|
||||
except TokenError as error:
|
||||
selectors["control"] = selector
|
||||
if kwargs:
|
||||
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(
|
||||
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
|
||||
|
||||
def decorator(method: DecoratedType) -> DecoratedType:
|
||||
@@ -53,7 +86,7 @@ def on(
|
||||
|
||||
if not hasattr(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
|
||||
|
||||
|
||||
@@ -16,6 +16,7 @@ from .case import camel_to_snake
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .message_pump import MessagePump
|
||||
from .widget import Widget
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
@@ -32,10 +33,16 @@ class Message:
|
||||
"_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
|
||||
verbose: ClassVar[bool] = False # Message is verbose
|
||||
no_dispatch: ClassVar[bool] = False # Message may not be handled by client code
|
||||
namespace: ClassVar[str] = "" # Namespace to disambiguate messages
|
||||
control: Widget | None = None
|
||||
|
||||
def __init__(self) -> None:
|
||||
self.__post_init__()
|
||||
|
||||
@@ -22,11 +22,11 @@ from ._context import (
|
||||
active_message_pump,
|
||||
prevent_message_types_stack,
|
||||
)
|
||||
from ._on import OnNoWidget
|
||||
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
|
||||
@@ -35,6 +35,7 @@ from .timer import Timer, TimerCallback
|
||||
|
||||
if TYPE_CHECKING:
|
||||
from .app import App
|
||||
from .css.model import SelectorSet
|
||||
|
||||
|
||||
class CallbackError(Exception):
|
||||
@@ -60,15 +61,15 @@ class _MessagePumpMeta(type):
|
||||
namespace = camel_to_snake(name)
|
||||
isclass = inspect.isclass
|
||||
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["_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))
|
||||
for message_type, selectors in getattr(value, "_textual_on"):
|
||||
handlers.setdefault(message_type, []).append((value, selectors))
|
||||
if isclass(value) and issubclass(value, Message):
|
||||
if "namespace" not in value.__dict__:
|
||||
value.namespace = namespace
|
||||
@@ -563,14 +564,23 @@ class MessagePump(metaclass=_MessagePumpMeta):
|
||||
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:
|
||||
from .widget import Widget
|
||||
|
||||
for method, selectors in handlers:
|
||||
if not selectors:
|
||||
yield cls, method.__get__(self, cls)
|
||||
else:
|
||||
selector_sets = parse_selectors(selector)
|
||||
if message._sender is not None and match(
|
||||
selector_sets, message.control
|
||||
):
|
||||
if not message._sender:
|
||||
continue
|
||||
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)
|
||||
|
||||
# Fall back to the naming convention
|
||||
|
||||
@@ -261,7 +261,7 @@ class DirectoryTree(Tree[DirEntry]):
|
||||
if not dir_entry.loaded:
|
||||
self._load_directory(event.node)
|
||||
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:
|
||||
event.stop()
|
||||
@@ -269,4 +269,4 @@ class DirectoryTree(Tree[DirEntry]):
|
||||
if dir_entry is None:
|
||||
return
|
||||
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.
|
||||
"""
|
||||
|
||||
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:
|
||||
super().__init__()
|
||||
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.
|
||||
"""
|
||||
|
||||
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:
|
||||
super().__init__()
|
||||
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.
|
||||
"""
|
||||
|
||||
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:
|
||||
"""Initialise the message.
|
||||
|
||||
|
||||
@@ -88,6 +88,9 @@ class TabbedContent(Widget):
|
||||
class TabActivated(Message):
|
||||
"""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:
|
||||
"""Initialize message.
|
||||
|
||||
|
||||
@@ -178,6 +178,9 @@ class Tabs(Widget, can_focus=True):
|
||||
class TabActivated(Message):
|
||||
"""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
|
||||
"""The tabs widget containing the tab."""
|
||||
tab: Tab
|
||||
|
||||
@@ -5,7 +5,7 @@ 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
|
||||
from textual.widgets import Button, TabbedContent, TabPane
|
||||
|
||||
|
||||
async def test_on_button_pressed() -> None:
|
||||
@@ -102,3 +102,44 @@ def test_on_no_control() -> None:
|
||||
@on(CustomMessage, "#foo")
|
||||
def foo():
|
||||
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