Merge branch 'main' into directory-tree-redux

This commit is contained in:
Dave Pearson
2023-05-03 10:06:30 +01:00
35 changed files with 560 additions and 178 deletions

View File

@@ -1,6 +1,14 @@
name: Test Textual module
on: [pull_request]
on:
pull_request:
paths:
- '**.py'
- '**.pyi'
- '**.css'
- '**.ambr'
- '**.lock'
- 'Makefile'
jobs:
build:

View File

@@ -24,6 +24,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Unknown psuedo-selectors will now raise a tokenizer error (previously they were silently ignored) https://github.com/Textualize/textual/pull/2445
- Breaking change: `DirectoryTree.FileSelected.path` is now always a `Path` https://github.com/Textualize/textual/issues/2448
- Breaking change: `Directorytree.load_directory` renamed to `Directorytree._load_directory` https://github.com/Textualize/textual/issues/2448
- Unknown pseudo-selectors will now raise a tokenizer error (previously they were silently ignored) https://github.com/Textualize/textual/pull/2445
### Added
@@ -31,6 +32,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added `DirectoryTree.path` reactive attribute https://github.com/Textualize/textual/issues/2448
- Added `DirectoryTree.FileSelected.node`
- Added `DirectoryTree.reload` https://github.com/Textualize/textual/issues/2448
- Added textual.on decorator https://github.com/Textualize/textual/issues/2398
## [0.22.3] - 2023-04-29

View File

@@ -1,67 +0,0 @@
{{ log.debug("Rendering " + attribute.path) }}
<div class="doc doc-object doc-attribute">
{% with html_id = attribute.path %}
{% if root %}
{% set show_full_path = config.show_root_full_path %}
{% set root_members = True %}
{% elif root_members %}
{% set show_full_path = config.show_root_members_full_path or config.show_object_full_path %}
{% set root_members = False %}
{% else %}
{% set show_full_path = config.show_object_full_path %}
{% endif %}
{% if not root or config.show_root_heading %}
{% filter heading(heading_level,
role="data" if attribute.parent.kind.value == "module" else "attr",
id=html_id,
class="doc doc-heading",
toc_label=attribute.name) %}
{% if config.separate_signature %}
<span class="doc doc-object-name doc-attribute-name">{% if show_full_path %}{{ attribute.path }}{% else %}{{ attribute.name }}{% endif %}</span>
{% else %}
{% filter highlight(language="python", inline=True) %}
{% if show_full_path %}{{ attribute.path }}{% else %}{{ attribute.name }}{% endif %}
{% if attribute.annotation %}: {{ attribute.annotation }}{% endif %}
{% endfilter %}
{% endif %}
{% with labels = attribute.labels %}
{% include "labels.html" with context %}
{% endwith %}
{% endfilter %}
{% if config.separate_signature %}
{% filter highlight(language="python", inline=False) %}
{% filter format_code(config.line_length) %}
{% if show_full_path %}{{ attribute.path }}{% else %}{{ attribute.name }}{% endif %}
{% if attribute.annotation %}: {{ attribute.annotation|safe }}{% endif %}
{% endfilter %}
{% endfilter %}
{% endif %}
{% else %}
{% if config.show_root_toc_entry %}
{% filter heading(heading_level,
role="data" if attribute.parent.kind.value == "module" else "attr",
id=html_id,
toc_label=attribute.path if config.show_root_full_path else attribute.name,
hidden=True) %}
{% endfilter %}
{% endif %}
{% set heading_level = heading_level - 1 %}
{% endif %}
<div class="doc doc-contents {% if root %}first{% endif %}">
{% with docstring_sections = attribute.docstring.parsed %}
{% include "docstring.html" with context %}
{% endwith %}
</div>
{% endwith %}
</div>

1
docs/api/errors.md Normal file
View File

@@ -0,0 +1 @@
::: textual.errors

1
docs/api/filter.md Normal file
View File

@@ -0,0 +1 @@
::: textual.filter

3
docs/api/on.md Normal file
View File

@@ -0,0 +1,3 @@
# On
::: textual.on

1
docs/api/scrollbar.md Normal file
View File

@@ -0,0 +1 @@
::: textual.scrollbar

1
docs/api/types.md Normal file
View File

@@ -0,0 +1 @@
::: textual.types

View File

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

View File

@@ -0,0 +1,8 @@
Screen {
align: center middle;
layout: horizontal;
}
Button {
margin: 2 4;
}

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

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

View File

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

View File

@@ -68,7 +68,7 @@ Widgets are key to making user-friendly interfaces. The builtin widgets should c
* [ ] bar chart
* [ ] line chart
* [ ] Candlestick chars
- [ ] Progress bars
- [X] Progress bars
* [ ] Style variants (solid, thin etc)
- [X] Radio boxes
- [ ] Spark-lines

View File

@@ -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__":

View File

@@ -164,6 +164,8 @@ nav:
- "api/coordinate.md"
- "api/dom_node.md"
- "api/events.md"
- "api/errors.md"
- "api/filter.md"
- "api/geometry.md"
- "api/index.md"
- "api/logger.md"
@@ -171,13 +173,16 @@ nav:
- "api/map_geometry.md"
- "api/message_pump.md"
- "api/message.md"
- "api/on.md"
- "api/pilot.md"
- "api/query.md"
- "api/reactive.md"
- "api/screen.md"
- "api/scrollbar.md"
- "api/scroll_view.md"
- "api/strip.md"
- "api/timer.md"
- "api/types.md"
- "api/walk.md"
- "api/widget.md"
- "api/work.md"

View File

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

View File

@@ -21,6 +21,10 @@ if TYPE_CHECKING:
"""Animation keys are the id of the object and the attribute being animated."""
EasingFunction = Callable[[float], float]
"""Signature for a function that parametrises animation speed.
An easing function must map the interval [0, 1] into the interval [0, 1].
"""
class AnimationError(Exception):
@@ -32,6 +36,12 @@ ReturnType = TypeVar("ReturnType")
@runtime_checkable
class Animatable(Protocol):
"""Protocol for objects that can have their intrinsic values animated.
For example, the transition between two colors can be animated
because the class [`Color`][textual.color.Color.blend] satisfies this protocol.
"""
def blend(
self: ReturnType, destination: ReturnType, factor: float
) -> ReturnType: # pragma: no cover

View File

@@ -11,7 +11,7 @@ if TYPE_CHECKING:
class NoActiveAppError(RuntimeError):
pass
"""Runtime error raised if we try to retrieve the active app when there is none."""
active_app: ContextVar["App"] = ContextVar("active_app")

60
src/textual/_on.py Normal file
View 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

View File

@@ -8,6 +8,8 @@ if TYPE_CHECKING:
class MessageTarget(Protocol):
"""Protocol that must be followed by objects that can receive messages."""
async def _post_message(self, message: "Message") -> bool:
...
@@ -25,6 +27,7 @@ class EventTarget(Protocol):
SegmentLines = List[List["Segment"]]
CallbackType = Union[Callable[[], Awaitable[None]], Callable[[], None]]
"""Type used for arbitrary callables used in callbacks."""
WatchCallbackType = Union[
Callable[[], Awaitable[None]],
Callable[[Any], Awaitable[None]],
@@ -33,3 +36,4 @@ WatchCallbackType = Union[
Callable[[Any], None],
Callable[[Any, Any], None],
]
"""Type used for callbacks passed to the `watch` method of widgets."""

View File

@@ -97,8 +97,11 @@ from .widget import AwaitMount, Widget
if TYPE_CHECKING:
from typing_extensions import Coroutine, TypeAlias
# Unused & ignored imports are needed for the docs to link to these objects:
from .css.query import WrongType # type: ignore # noqa: F401
from .devtools.client import DevtoolsClient
from .pilot import Pilot
from .widget import MountError # type: ignore # noqa: F401
PLATFORM = platform.system()
WINDOWS = PLATFORM == "Windows"
@@ -137,6 +140,7 @@ ComposeResult = Iterable[Widget]
RenderResult = RenderableType
AutopilotCallbackType: TypeAlias = "Callable[[Pilot], Coroutine[Any, Any, None]]"
"""Signature for valid callbacks that can be used to control apps."""
class AppError(Exception):
@@ -167,6 +171,7 @@ CSSPathType = Union[
PurePath,
List[Union[str, PurePath]],
]
"""Valid ways of specifying paths to CSS files."""
CallThreadReturnType = TypeVar("CallThreadReturnType")
@@ -186,17 +191,7 @@ class _NullFile:
@rich.repr.auto
class App(Generic[ReturnType], DOMNode):
"""The base class for Textual Applications.
Args:
driver_class: Driver class or `None` to auto-detect. This will be used by some Textual tools.
css_path: Path to CSS or `None` to use the `CSS_PATH` class variable.
To load multiple CSS files, pass a list of strings or paths which will be loaded in order.
watch_css: Reload CSS if the files changed. This is set automatically if you are using `textual run` with the `dev` switch.
Raises:
CssPathError: When the supplied CSS path(s) are an unexpected type.
"""
"""The base class for Textual Applications."""
CSS: ClassVar[str] = ""
"""Inline CSS, useful for quick scripts. This is loaded after CSS_PATH,
@@ -218,8 +213,10 @@ class App(Generic[ReturnType], DOMNode):
"""
SCREENS: ClassVar[dict[str, Screen | Callable[[], Screen]]] = {}
"""Screens associated with the app for the lifetime of the app."""
_BASE_PATH: str | None = None
CSS_PATH: ClassVar[CSSPathType | None] = None
"""File paths to load CSS from."""
TITLE: str | None = None
"""A class variable to set the *default* title for the application.
@@ -255,6 +252,20 @@ class App(Generic[ReturnType], DOMNode):
css_path: CSSPathType | None = None,
watch_css: bool = False,
):
"""Create an instance of an app.
Args:
driver_class: Driver class or `None` to auto-detect.
This will be used by some Textual tools.
css_path: Path to CSS or `None` to use the `CSS_PATH` class variable.
To load multiple CSS files, pass a list of strings or paths which
will be loaded in order.
watch_css: Reload CSS if the files changed. This is set automatically if
you are using `textual run` with the `dev` switch.
Raises:
CssPathError: When the supplied CSS path(s) are an unexpected type.
"""
super().__init__()
self.features: frozenset[FeatureFlag] = parse_features(os.getenv("TEXTUAL", ""))
@@ -406,7 +417,7 @@ class App(Generic[ReturnType], DOMNode):
@property
def return_value(self) -> ReturnType | None:
"""The return value of the app, or `None` if it as not yet been set.
"""The return value of the app, or `None` if it has not yet been set.
The return value is set when calling [exit][textual.app.App.exit].
"""
@@ -414,10 +425,11 @@ class App(Generic[ReturnType], DOMNode):
@property
def children(self) -> Sequence["Widget"]:
"""A view on to the App's children.
"""A view onto the app's immediate children.
This attribute exists on all widgets.
In the case of the App, it will only every contain a single child, which will be the currently active screen.
In the case of the App, it will only ever contain a single child, which will
be the currently active screen.
Returns:
A sequence of widgets.
@@ -499,7 +511,7 @@ class App(Generic[ReturnType], DOMNode):
@property
def screen_stack(self) -> Sequence[Screen]:
"""The current screen stack.
"""A snapshot of the current screen stack.
Returns:
A snapshot of the current state of the screen stack.
@@ -523,7 +535,7 @@ class App(Generic[ReturnType], DOMNode):
@property
def focused(self) -> Widget | None:
"""The widget that is focused on the currently active screen.
"""The widget that is focused on the currently active screen, or `None`.
Focused widgets receive keyboard input.
@@ -534,7 +546,7 @@ class App(Generic[ReturnType], DOMNode):
@property
def namespace_bindings(self) -> dict[str, tuple[DOMNode, Binding]]:
"""Get current bindings.
"""Get currently active bindings.
If no widget is focused, then app-level bindings are returned.
If a widget is focused, then any bindings present in the active screen and app are merged and returned.
@@ -542,8 +554,7 @@ class App(Generic[ReturnType], DOMNode):
This property may be used to inspect current bindings.
Returns:
A mapping of keys on to node + binding.
A mapping of keys onto pairs of nodes and bindings.
"""
namespace_binding_map: dict[str, tuple[DOMNode, Binding]] = {}
@@ -650,7 +661,7 @@ class App(Generic[ReturnType], DOMNode):
@property
def screen(self) -> Screen:
"""Screen: The current screen.
"""The current active screen.
Returns:
The currently active (visible) screen.
@@ -689,7 +700,7 @@ class App(Generic[ReturnType], DOMNode):
@property
def log(self) -> Logger:
"""Textual log interface.
"""The textual logger.
Example:
```python
@@ -763,13 +774,17 @@ class App(Generic[ReturnType], DOMNode):
*args,
**kwargs,
) -> CallThreadReturnType:
"""Run a callback from another thread.
"""Run a callable from another thread, and return the result.
Like asyncio apps in general, Textual apps are not thread-safe. If you call methods
or set attributes on Textual objects from a thread, you may get unpredictable results.
This method will ensure that your code runs within the correct context.
!!! tip
Consider using [post_message][textual.message_pump.MessagePump.post_message] which is also thread-safe.
Args:
callback: A callable to run.
*args: Arguments to the callback.
@@ -778,6 +793,9 @@ class App(Generic[ReturnType], DOMNode):
Raises:
RuntimeError: If the app isn't running or if this method is called from the same
thread where the app is running.
Returns:
The result of the callback.
"""
if self._loop is None:
@@ -1162,14 +1180,15 @@ class App(Generic[ReturnType], DOMNode):
Args:
id: The ID of the node to search for.
expect_type: Require the object be of the supplied type, or None for any type.
expect_type: Require the object be of the supplied type,
or use `None` to apply no type restriction.
Returns:
The first child of this node with the specified ID.
Raises:
NoMatches: if no children could be found for this ID
WrongType: if the wrong type was found.
NoMatches: If no children could be found for this ID.
WrongType: If the wrong type was found.
"""
return (
self.screen.get_child_by_id(id)
@@ -1463,7 +1482,6 @@ class App(Generic[ReturnType], DOMNode):
with [install_screen][textual.app.App.install_screen].
Textual will also uninstall screens automatically on exit.
Args:
screen: The screen to uninstall or the name of a installed screen.
@@ -1836,6 +1854,7 @@ class App(Generic[ReturnType], DOMNode):
*widgets: The widget(s) to register.
before: A location to mount before.
after: A location to mount after.
Returns:
List of modified widgets.
"""
@@ -2140,7 +2159,7 @@ class App(Generic[ReturnType], DOMNode):
or None to use app.
Returns:
True if the event has handled.
True if the event has been handled.
"""
if isinstance(action, str):
target, params = actions.parse(action)

View File

@@ -536,7 +536,7 @@ class Color(NamedTuple):
return self.darken(-amount, alpha)
@lru_cache(maxsize=1024)
def get_contrast_text(self, alpha=0.95) -> Color:
def get_contrast_text(self, alpha: float = 0.95) -> Color:
"""Get a light or dark color that best contrasts this color, for use with text.
Args:
@@ -576,9 +576,8 @@ class Gradient:
Positions that are between stops will return a blended color.
Args:
factor: A number between 0 and 1, where 0 is the first stop, and 1 is the last.
position: A number between 0 and 1, where 0 is the first stop, and 1 is the last.
Returns:
A color.

View File

@@ -12,6 +12,7 @@ from functools import lru_cache
from inspect import getfile
from typing import (
TYPE_CHECKING,
Callable,
ClassVar,
Iterable,
Sequence,
@@ -49,17 +50,22 @@ 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
from typing_extensions import Self, TypeAlias
# Unused & ignored imports are needed for the docs to link to these objects:
from .css.query import NoMatches, TooManyMatches, WrongType # type: ignore # noqa: F401
from typing_extensions import Literal
_re_identifier = re.compile(IDENTIFIER)
WalkMethod: TypeAlias = Literal["depth", "breadth"]
"""Valid walking methods for the [`DOMNode.walk_children` method][textual.dom.DOMNode.walk_children]."""
class BadIdentifier(Exception):
@@ -143,6 +149,8 @@ class DOMNode(MessagePump):
_reactives: ClassVar[dict[str, Reactive]]
_decorated_handlers: dict[type[Message], list[tuple[Callable, str | None]]]
def __init__(
self,
*,

View File

@@ -14,4 +14,8 @@ class RenderError(TextualError):
class DuplicateKeyHandlers(TextualError):
"""More than one handler for a single key press. E.g. key_ctrl_i and key_tab handlers both found on one object."""
"""More than one handler for a single key press.
For example, if the handlers `key_ctrl_i` and `key_tab` were defined on the same
widget, then this error would be raised.
"""

View File

@@ -27,6 +27,7 @@ if TYPE_CHECKING:
SpacingDimensions: TypeAlias = Union[
int, Tuple[int], Tuple[int, int], Tuple[int, int, int, int]
]
"""The valid ways in which you can specify spacing."""
T = TypeVar("T", int, float)
@@ -1043,4 +1044,4 @@ class Spacing(NamedTuple):
NULL_OFFSET: Final = Offset(0, 0)
"""An Offset constant for (0, 0)."""
"""An [offset][textual.geometry.Offset] constant for (0, 0)."""

View File

@@ -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
@@ -337,7 +348,7 @@ class MessagePump(metaclass=_MessagePumpMeta):
self._timers.add(timer)
return timer
def call_after_refresh(self, callback: Callable, *args, **kwargs) -> None:
def call_after_refresh(self, callback: Callable, *args: Any, **kwargs: Any) -> None:
"""Schedule a callback to run after all messages are processed and the screen
has been refreshed. Positional and keyword arguments are passed to the callable.
@@ -350,7 +361,7 @@ class MessagePump(metaclass=_MessagePumpMeta):
message = messages.InvokeLater(partial(callback, *args, **kwargs))
self.post_message(message)
def call_later(self, callback: Callable, *args, **kwargs) -> None:
def call_later(self, callback: Callable, *args: Any, **kwargs: Any) -> None:
"""Schedule a callback to run after all messages are processed in this object.
Positional and keywords arguments are passed to the callable.
@@ -362,7 +373,7 @@ class MessagePump(metaclass=_MessagePumpMeta):
message = events.Callback(callback=partial(callback, *args, **kwargs))
self.post_message(message)
def call_next(self, callback: Callable, *args, **kwargs) -> None:
def call_next(self, callback: Callable, *args: Any, **kwargs: Any) -> None:
"""Schedule a callback to run immediately after processing the current message.
Args:
@@ -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:

View File

@@ -41,6 +41,9 @@ from .widget import Widget
if TYPE_CHECKING:
from typing_extensions import Final
# Unused & ignored imports are needed for the docs to link to these objects:
from .errors import NoWidget # type: ignore # noqa: F401
# Screen updates will be batched so that they don't happen more often than 60 times per second:
UPDATE_PERIOD: Final[float] = 1 / 60

View File

@@ -1,3 +1,8 @@
"""
Implements the scrollbar-related widgets for internal use.
You will not need to use the widgets defined in this module.
"""
from __future__ import annotations
from math import ceil
@@ -18,7 +23,7 @@ from .widget import Widget
class ScrollMessage(Message, bubble=False):
pass
"""Base class for all scrollbar messages."""
@rich.repr.auto

View File

@@ -20,6 +20,7 @@ from ._time import sleep
from ._types import MessageTarget
TimerCallback = Union[Callable[[], Awaitable[None]], Callable[[], None]]
"""Type of valid callbacks to be used with timers."""
class EventTargetGone(Exception):

20
src/textual/types.py Normal file
View File

@@ -0,0 +1,20 @@
"""
Export some objects that are used by Textual and that help document other features.
"""
from ._animator import Animatable, EasingFunction
from ._context import NoActiveAppError
from ._types import CallbackType, MessageTarget, WatchCallbackType
from .actions import ActionParseResult
from .css.styles import RenderStyles
__all__ = [
"ActionParseResult",
"Animatable",
"CallbackType",
"EasingFunction",
"MessageTarget",
"NoActiveAppError",
"RenderStyles",
"WatchCallbackType",
]

View File

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

View File

@@ -99,6 +99,7 @@ WorkType: TypeAlias = Union[
Callable[[], ResultType],
Awaitable[ResultType],
]
"""Type used for [workers](/guide/workers/)."""
class _ReprText:

View File

@@ -1,6 +1,5 @@
from __future__ import annotations
import difflib
import os
from dataclasses import dataclass
from datetime import datetime
@@ -107,7 +106,6 @@ class SvgSnapshotDiff:
snapshot: Optional[str]
actual: Optional[str]
test_name: str
file_similarity: float
path: PathLike
line_number: int
app: App
@@ -132,17 +130,10 @@ def pytest_sessionfinish(
if app:
path, line_index, name = item.reportinfo()
similarity = (
100
* difflib.SequenceMatcher(
a=str(snapshot_svg), b=str(actual_svg)
).ratio()
)
diffs.append(
SvgSnapshotDiff(
snapshot=str(snapshot_svg),
actual=str(actual_svg),
file_similarity=similarity,
test_name=name,
path=path,
line_number=line_index + 1,
@@ -152,7 +143,7 @@ def pytest_sessionfinish(
)
if diffs:
diff_sort_key = attrgetter("file_similarity")
diff_sort_key = attrgetter("test_name")
diffs = sorted(diffs, key=diff_sort_key)
conftest_path = Path(__file__)

104
tests/test_on.py Normal file
View 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