mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into directory-tree-redux
This commit is contained in:
10
.github/workflows/pythonpackage.yml
vendored
10
.github/workflows/pythonpackage.yml
vendored
@@ -1,6 +1,14 @@
|
||||
name: Test Textual module
|
||||
|
||||
on: [pull_request]
|
||||
on:
|
||||
pull_request:
|
||||
paths:
|
||||
- '**.py'
|
||||
- '**.pyi'
|
||||
- '**.css'
|
||||
- '**.ambr'
|
||||
- '**.lock'
|
||||
- 'Makefile'
|
||||
|
||||
jobs:
|
||||
build:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
67
docs/_templates/python/material/attribute.html
vendored
67
docs/_templates/python/material/attribute.html
vendored
@@ -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
1
docs/api/errors.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.errors
|
||||
1
docs/api/filter.md
Normal file
1
docs/api/filter.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.filter
|
||||
3
docs/api/on.md
Normal file
3
docs/api/on.md
Normal file
@@ -0,0 +1,3 @@
|
||||
# On
|
||||
|
||||
::: textual.on
|
||||
1
docs/api/scrollbar.md
Normal file
1
docs/api/scrollbar.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.scrollbar
|
||||
1
docs/api/types.md
Normal file
1
docs/api/types.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.types
|
||||
@@ -1,6 +1,6 @@
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.color import Color
|
||||
from textual.message import Message, MessageTarget
|
||||
from textual.message import Message
|
||||
from textual.widgets import Static
|
||||
|
||||
|
||||
|
||||
8
docs/examples/events/on_decorator.css
Normal file
8
docs/examples/events/on_decorator.css
Normal file
@@ -0,0 +1,8 @@
|
||||
Screen {
|
||||
align: center middle;
|
||||
layout: horizontal;
|
||||
}
|
||||
|
||||
Button {
|
||||
margin: 2 4;
|
||||
}
|
||||
27
docs/examples/events/on_decorator01.py
Normal file
27
docs/examples/events/on_decorator01.py
Normal file
@@ -0,0 +1,27 @@
|
||||
from textual import on
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Button
|
||||
|
||||
|
||||
class OnDecoratorApp(App):
|
||||
CSS_PATH = "on_decorator.css"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Three buttons."""
|
||||
yield Button("Bell", id="bell")
|
||||
yield Button("Toggle dark", classes="toggle dark")
|
||||
yield Button("Quit", id="quit")
|
||||
|
||||
def on_button_pressed(self, event: Button.Pressed) -> None:
|
||||
"""Handle all button pressed events."""
|
||||
if event.button.id == "bell":
|
||||
self.bell()
|
||||
elif event.button.has_class("toggle", "dark"):
|
||||
self.dark = not self.dark
|
||||
elif event.button.id == "quit":
|
||||
self.exit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = OnDecoratorApp()
|
||||
app.run()
|
||||
33
docs/examples/events/on_decorator02.py
Normal file
33
docs/examples/events/on_decorator02.py
Normal file
@@ -0,0 +1,33 @@
|
||||
from textual import on
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Button
|
||||
|
||||
|
||||
class OnDecoratorApp(App):
|
||||
CSS_PATH = "on_decorator.css"
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
"""Three buttons."""
|
||||
yield Button("Bell", id="bell")
|
||||
yield Button("Toggle dark", classes="toggle dark")
|
||||
yield Button("Quit", id="quit")
|
||||
|
||||
@on(Button.Pressed, "#bell") # (1)!
|
||||
def play_bell(self):
|
||||
"""Called when the bell button is pressed."""
|
||||
self.bell()
|
||||
|
||||
@on(Button.Pressed, ".toggle.dark") # (2)!
|
||||
def toggle_dark(self):
|
||||
"""Called when the 'toggle dark' button is pressed."""
|
||||
self.dark = not self.dark
|
||||
|
||||
@on(Button.Pressed, "#quit") # (3)!
|
||||
def quit(self):
|
||||
"""Called when the quit button is pressed."""
|
||||
self.exit()
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
app = OnDecoratorApp()
|
||||
app.run()
|
||||
@@ -155,9 +155,74 @@ Textual uses the following scheme to map messages classes on to a Python method.
|
||||
--8<-- "docs/images/events/naming.excalidraw.svg"
|
||||
</div>
|
||||
|
||||
### On decorator
|
||||
|
||||
In addition to the naming convention, message handlers may be created with the [`on`][textual.on] decorator, which turns a method into a handler for the given message or event.
|
||||
|
||||
For instance, the two methods declared below are equivalent:
|
||||
|
||||
```python
|
||||
@on(Button.Pressed)
|
||||
def handle_button_pressed(self):
|
||||
...
|
||||
|
||||
def on_button_pressed(self):
|
||||
...
|
||||
```
|
||||
|
||||
While this allows you to name your method handlers anything you want, the main advantage of the decorator approach over the naming convention is that you can specify *which* widget(s) you want to handle messages for.
|
||||
|
||||
Let's first explore where this can be useful.
|
||||
In the following example we have three buttons, each of which does something different; one plays the bell, one toggles dark mode, and the other quits the app.
|
||||
|
||||
=== "on_decorator01.py"
|
||||
|
||||
```python title="on_decorator01.py"
|
||||
--8<-- "docs/examples/events/on_decorator01.py"
|
||||
```
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/events/on_decorator01.py"}
|
||||
```
|
||||
|
||||
Note how the message handler has a chained `if` statement to match the action to the button.
|
||||
While this works just fine, it can be a little hard to follow when the number of buttons grows.
|
||||
|
||||
The `on` decorator takes a [CSS selector](./CSS.md#selectors) in addition to the event type which will be used to select which controls the handler should work with.
|
||||
We can use this to write a handler per control rather than manage them all in a single handler.
|
||||
|
||||
The following example uses the decorator approach to write individual message handlers for each of the three buttons:
|
||||
|
||||
=== "on_decorator02.py"
|
||||
|
||||
```python title="on_decorator02.py"
|
||||
--8<-- "docs/examples/events/on_decorator02.py"
|
||||
```
|
||||
|
||||
1. Matches the button with an id of "bell" (note the `#` to match the id)
|
||||
2. Matches the button with class names "toggle" *and* "dark"
|
||||
3. Matches the button with an id of "quit"
|
||||
|
||||
=== "Output"
|
||||
|
||||
```{.textual path="docs/examples/events/on_decorator02.py"}
|
||||
```
|
||||
|
||||
While there are a few more lines of code, it is clearer what will happen when you click any given button.
|
||||
|
||||
Note that the decorator requires that the message class has a `control` attribute which should be the widget associated with the message.
|
||||
Messages from builtin controls will have this attribute, but you may need to add `control` to any [custom messages](#custom-messages) you write.
|
||||
|
||||
!!! note
|
||||
|
||||
If multiple decorated handlers match the `control`, then they will *all* be called in the order they are defined.
|
||||
|
||||
The naming convention handler will be called *after* any decorated handlers.
|
||||
|
||||
### Handler arguments
|
||||
|
||||
Message handler methods can be written with or without a positional argument. If you add a positional argument, Textual will call the handler with the event object. The following handler (taken from custom01.py above) contains a `message` parameter. The body of the code makes use of the message to set a preset color.
|
||||
Message handler methods can be written with or without a positional argument. If you add a positional argument, Textual will call the handler with the event object. The following handler (taken from `custom01.py` above) contains a `message` parameter. The body of the code makes use of the message to set a preset color.
|
||||
|
||||
```python
|
||||
def on_color_button_selected(self, message: ColorButton.Selected) -> None:
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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__":
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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"
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
60
src/textual/_on.py
Normal file
@@ -0,0 +1,60 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import Callable, TypeVar
|
||||
|
||||
from .css.parse import parse_selectors
|
||||
from .css.tokenizer import TokenError
|
||||
from .message import Message
|
||||
|
||||
DecoratedType = TypeVar("DecoratedType")
|
||||
|
||||
|
||||
class OnDecoratorError(Exception):
|
||||
"""Errors related to the `on` decorator.
|
||||
|
||||
Typically raised at import time as an early warning system.
|
||||
|
||||
"""
|
||||
|
||||
|
||||
def on(
|
||||
message_type: type[Message], selector: str | None = None
|
||||
) -> Callable[[DecoratedType], DecoratedType]:
|
||||
"""Decorator to declare method is a message handler.
|
||||
|
||||
Example:
|
||||
```python
|
||||
@on(Button.Pressed, "#quit")
|
||||
def quit_button(self) -> None:
|
||||
self.app.quit()
|
||||
```
|
||||
|
||||
Args:
|
||||
message_type: The message type (i.e. the class).
|
||||
selector: An optional [selector](/guide/CSS#selectors). If supplied, the handler will only be called if `selector`
|
||||
matches the widget from the `control` attribute of the message.
|
||||
"""
|
||||
|
||||
if selector is not None and not hasattr(message_type, "control"):
|
||||
raise OnDecoratorError(
|
||||
"The 'selector' argument requires a message class with a 'control' attribute (such as events from controls)."
|
||||
)
|
||||
|
||||
if selector is not None:
|
||||
try:
|
||||
parse_selectors(selector)
|
||||
except TokenError as error:
|
||||
raise OnDecoratorError(
|
||||
f"Unable to parse selector {selector!r}; check for syntax errors"
|
||||
) from None
|
||||
|
||||
def decorator(method: DecoratedType) -> DecoratedType:
|
||||
"""Store message and selector in function attribute, return callable unaltered."""
|
||||
|
||||
if not hasattr(method, "_textual_on"):
|
||||
setattr(method, "_textual_on", [])
|
||||
getattr(method, "_textual_on").append((message_type, selector))
|
||||
|
||||
return method
|
||||
|
||||
return decorator
|
||||
@@ -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."""
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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,
|
||||
*,
|
||||
|
||||
@@ -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.
|
||||
"""
|
||||
|
||||
@@ -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)."""
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
20
src/textual/types.py
Normal 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",
|
||||
]
|
||||
@@ -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:
|
||||
|
||||
@@ -99,6 +99,7 @@ WorkType: TypeAlias = Union[
|
||||
Callable[[], ResultType],
|
||||
Awaitable[ResultType],
|
||||
]
|
||||
"""Type used for [workers](/guide/workers/)."""
|
||||
|
||||
|
||||
class _ReprText:
|
||||
|
||||
@@ -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
104
tests/test_on.py
Normal file
@@ -0,0 +1,104 @@
|
||||
import pytest
|
||||
|
||||
from textual import on
|
||||
from textual._on import OnDecoratorError
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.message import Message
|
||||
from textual.widget import Widget
|
||||
from textual.widgets import Button
|
||||
|
||||
|
||||
async def test_on_button_pressed() -> None:
|
||||
"""Test handlers with @on decorator."""
|
||||
|
||||
pressed: list[str] = []
|
||||
|
||||
class ButtonApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Button("OK", id="ok")
|
||||
yield Button("Cancel", classes="exit cancel")
|
||||
yield Button("Quit", classes="exit quit")
|
||||
|
||||
@on(Button.Pressed, "#ok")
|
||||
def ok(self):
|
||||
pressed.append("ok")
|
||||
|
||||
@on(Button.Pressed, ".exit")
|
||||
def exit(self):
|
||||
pressed.append("exit")
|
||||
|
||||
@on(Button.Pressed, ".exit.quit")
|
||||
def _(self):
|
||||
pressed.append("quit")
|
||||
|
||||
def on_button_pressed(self):
|
||||
pressed.append("default")
|
||||
|
||||
app = ButtonApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("tab", "enter", "tab", "enter", "tab", "enter")
|
||||
await pilot.pause()
|
||||
|
||||
assert pressed == [
|
||||
"ok", # Matched ok first
|
||||
"default", # on_button_pressed matched everything
|
||||
"exit", # Cancel button, matches exit
|
||||
"default", # on_button_pressed matched everything
|
||||
"exit", # Quit button pressed, matched exit and _
|
||||
"quit", # Matched previous button
|
||||
"default", # on_button_pressed matched everything
|
||||
]
|
||||
|
||||
|
||||
async def test_on_inheritance() -> None:
|
||||
"""Test on decorator and inheritance."""
|
||||
pressed: list[str] = []
|
||||
|
||||
class MyWidget(Widget):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Button("OK", id="ok")
|
||||
|
||||
# Also called
|
||||
@on(Button.Pressed, "#ok")
|
||||
def ok(self):
|
||||
pressed.append("MyWidget.ok base")
|
||||
|
||||
class DerivedWidget(MyWidget):
|
||||
# Should be called first
|
||||
@on(Button.Pressed, "#ok")
|
||||
def ok(self):
|
||||
pressed.append("MyWidget.ok derived")
|
||||
|
||||
class ButtonApp(App):
|
||||
def compose(self) -> ComposeResult:
|
||||
yield DerivedWidget()
|
||||
|
||||
app = ButtonApp()
|
||||
async with app.run_test() as pilot:
|
||||
await pilot.press("tab", "enter")
|
||||
|
||||
expected = ["MyWidget.ok derived", "MyWidget.ok base"]
|
||||
assert pressed == expected
|
||||
|
||||
|
||||
def test_on_bad_selector() -> None:
|
||||
"""Check bad selectors raise an error."""
|
||||
|
||||
with pytest.raises(OnDecoratorError):
|
||||
|
||||
@on(Button.Pressed, "@")
|
||||
def foo():
|
||||
pass
|
||||
|
||||
|
||||
def test_on_no_control() -> None:
|
||||
"""Check messages with no 'control' attribute raise an error."""
|
||||
|
||||
class CustomMessage(Message):
|
||||
pass
|
||||
|
||||
with pytest.raises(OnDecoratorError):
|
||||
|
||||
@on(CustomMessage, "#foo")
|
||||
def foo():
|
||||
pass
|
||||
Reference in New Issue
Block a user