Merge branch 'main' into cygnus-x-1

This commit is contained in:
Dave Pearson
2023-05-09 13:38:16 +01:00
16 changed files with 256 additions and 35 deletions

View File

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

View 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).

View File

@@ -6,6 +6,3 @@ Select {
width: 60;
margin: 2;
}
Input {
width: 60;
}

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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