mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into M-x
This commit is contained in:
3
.github/workflows/pythonpackage.yml
vendored
3
.github/workflows/pythonpackage.yml
vendored
@@ -17,7 +17,7 @@ jobs:
|
||||
strategy:
|
||||
matrix:
|
||||
os: [ubuntu-latest, macos-latest, windows-latest]
|
||||
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11"]
|
||||
python-version: ["3.7", "3.8", "3.9", "3.10", "3.11", "3.12"]
|
||||
defaults:
|
||||
run:
|
||||
shell: bash
|
||||
@@ -33,6 +33,7 @@ jobs:
|
||||
with:
|
||||
python-version: ${{ matrix.python-version }}
|
||||
architecture: x64
|
||||
allow-prereleases: true
|
||||
- name: Load cached venv
|
||||
id: cached-poetry-dependencies
|
||||
uses: actions/cache@v3
|
||||
|
||||
19
CHANGELOG.md
19
CHANGELOG.md
@@ -10,6 +10,25 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
### Added
|
||||
|
||||
- Added the command palette https://github.com/Textualize/textual/pull/3058
|
||||
- `Input` is now validated when focus moves out of it https://github.com/Textualize/textual/pull/3193
|
||||
- Attribute `Input.validate_on` (and `__init__` parameter of the same name) to customise when validation occurs https://github.com/Textualize/textual/pull/3193
|
||||
- Screen-specific (sub-)title attributes https://github.com/Textualize/textual/pull/3199:
|
||||
- `Screen.TITLE`
|
||||
- `Screen.SUB_TITLE`
|
||||
- `Screen.title`
|
||||
- `Screen.sub_title`
|
||||
- Properties `Header.screen_title` and `Header.screen_sub_title` https://github.com/Textualize/textual/pull/3199
|
||||
|
||||
### Fixed
|
||||
|
||||
- Fixed a crash when removing an option from an `OptionList` while the mouse is hovering over the last option https://github.com/Textualize/textual/issues/3270
|
||||
|
||||
### Changed
|
||||
|
||||
- Widget.notify and App.notify are now thread-safe https://github.com/Textualize/textual/pull/3275
|
||||
- Breaking change: Widget.notify and App.notify now return None https://github.com/Textualize/textual/pull/3275
|
||||
- App.unnotify is now private (renamed to App._unnotify) https://github.com/Textualize/textual/pull/3275
|
||||
|
||||
|
||||
## [0.36.0] - 2023-09-05
|
||||
|
||||
|
||||
45
docs/blog/posts/textual-web.md
Normal file
45
docs/blog/posts/textual-web.md
Normal file
@@ -0,0 +1,45 @@
|
||||
---
|
||||
draft: false
|
||||
date: 2023-09-06
|
||||
categories:
|
||||
- News
|
||||
title: "What is Textual Web?"
|
||||
authors:
|
||||
- willmcgugan
|
||||
---
|
||||
|
||||
# What is Textual Web?
|
||||
|
||||
If you know us, you will know that we are the team behind [Rich](https://github.com/Textualize/rich) and [Textual](https://github.com/Textualize/textual) — two popular Python libraries that work magic in the terminal.
|
||||
|
||||
!!! note
|
||||
|
||||
Not to mention [Rich-CLI](https://github.com/Textualize/rich-cli), [Trogon](https://github.com/Textualize/trogon), and [Frogmouth](https://github.com/Textualize/frogmouth)
|
||||
|
||||
Today we are adding one project more to that lineup: [textual-web](https://github.com/Textualize/textual-web).
|
||||
|
||||
|
||||
<!-- more -->
|
||||
|
||||
Textual Web takes a Textual-powered TUI and turns it in to a web application.
|
||||
Here's a video of that in action:
|
||||
|
||||
<div class="video-wrapper">
|
||||
<iframe width="auto" src="https://www.youtube.com/embed/A8k8TD7_wg0" title="Textual Web in action" frameborder="0" allow="accelerometer; autoplay; clipboard-write; encrypted-media; gyroscope; picture-in-picture; web-share" allowfullscreen></iframe>
|
||||
</div>
|
||||
|
||||
With the `textual-web` command you can publish any Textual app on the web, making it available to anyone you send the URL to.
|
||||
This works without creating a socket server on your machine, so you won't have to configure firewalls and ports to share your applications.
|
||||
|
||||
We're excited about the possibilities here.
|
||||
Textual web apps are fast to spin up and tear down, and they can run just about anywhere that has an outgoing internet connection.
|
||||
They can be built by a single developer without any experience with a traditional web stack.
|
||||
All you need is proficiency in Python and a little time to read our [lovely docs](https://textual.textualize.io/).
|
||||
|
||||
Future releases will expose more of the Web platform APIs to Textual apps, such as notifications and file system access.
|
||||
We plan to do this in a way that allows the same (Python) code to drive those features.
|
||||
For instance, a Textual app might save a file to disk in a terminal, but offer to download it in the browser.
|
||||
|
||||
Also in the pipeline is [PWA](https://en.wikipedia.org/wiki/Progressive_web_app) support, so you can build terminal apps, web apps, and desktop apps with a single codebase.
|
||||
|
||||
Textual Web is currently in a public beta. Join our [Discord server](https://discord.gg/Enf6Z3qhVr) if you would like to help us test, or if you have any questions.
|
||||
@@ -26,7 +26,14 @@ The example below shows how you might create a simple form using two `Input` wid
|
||||
|
||||
You can supply one or more *[validators][textual.validation.Validator]* to the `Input` widget to validate the value.
|
||||
|
||||
When the value changes or the `Input` is submitted, all the supplied validators will run.
|
||||
All the supplied validators will run when the value changes, the `Input` is submitted, or focus moves _out_ of the `Input`.
|
||||
The values `"changed"`, `"submitted"`, and `"blur"`, can be passed as an iterable to the `Input` parameter `validate_on` to request that validation occur only on the respective mesages.
|
||||
(See [`InputValidationOn`][textual.widgets._input.InputValidationOn] and [`Input.validate_on`][textual.widgets.Input.validate_on].)
|
||||
For example, the code below creates an `Input` widget that only gets validated when the value is submitted explicitly:
|
||||
|
||||
```python
|
||||
input = Input(validate_on=["submitted"])
|
||||
```
|
||||
|
||||
Validation is considered to have failed if *any* of the validators fail.
|
||||
|
||||
|
||||
@@ -92,7 +92,7 @@ from .keys import (
|
||||
_get_unicode_name_from_key,
|
||||
)
|
||||
from .messages import CallbackType
|
||||
from .notifications import Notification, Notifications, SeverityLevel
|
||||
from .notifications import Notification, Notifications, Notify, SeverityLevel
|
||||
from .reactive import Reactive
|
||||
from .renderables.blank import Blank
|
||||
from .screen import Screen, ScreenResultCallbackType, ScreenResultType
|
||||
@@ -240,6 +240,10 @@ class _PrintCapture:
|
||||
# TODO: should this be configurable?
|
||||
return True
|
||||
|
||||
def fileno(self) -> int:
|
||||
"""Return invalid fileno."""
|
||||
return -1
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class App(Generic[ReturnType], DOMNode):
|
||||
@@ -310,13 +314,15 @@ class App(Generic[ReturnType], DOMNode):
|
||||
TITLE: str | None = None
|
||||
"""A class variable to set the *default* title for the application.
|
||||
|
||||
To update the title while the app is running, you can set the [title][textual.app.App.title] attribute
|
||||
To update the title while the app is running, you can set the [title][textual.app.App.title] attribute.
|
||||
See also [the `Screen.TITLE` attribute][textual.screen.Screen.TITLE].
|
||||
"""
|
||||
|
||||
SUB_TITLE: str | None = None
|
||||
"""A class variable to set the default sub-title for the application.
|
||||
|
||||
To update the sub-title while the app is running, you can set the [sub_title][textual.app.App.sub_title] attribute.
|
||||
See also [the `Screen.SUB_TITLE` attribute][textual.screen.Screen.SUB_TITLE].
|
||||
"""
|
||||
|
||||
ENABLE_COMMAND_PALETTE: ClassVar[bool] = True
|
||||
@@ -443,7 +449,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
an empty string if it doesn't.
|
||||
|
||||
Sub-titles are typically used to show the high-level state of the app, such as the current mode, or path to
|
||||
the file being worker on.
|
||||
the file being worked on.
|
||||
|
||||
Assign a new value to this attribute to change the sub-title.
|
||||
The new value is always converted to string.
|
||||
@@ -2952,18 +2958,20 @@ class App(Generic[ReturnType], DOMNode):
|
||||
title: str = "",
|
||||
severity: SeverityLevel = "information",
|
||||
timeout: float = Notification.timeout,
|
||||
) -> Notification:
|
||||
) -> None:
|
||||
"""Create a notification.
|
||||
|
||||
!!! tip
|
||||
|
||||
This method is thread-safe.
|
||||
|
||||
|
||||
Args:
|
||||
message: The message for the notification.
|
||||
title: The title for the notification.
|
||||
severity: The severity of the notification.
|
||||
timeout: The timeout for the notification.
|
||||
|
||||
Returns:
|
||||
The new notification.
|
||||
|
||||
The `notify` method is used to create an application-wide
|
||||
notification, shown in a [`Toast`][textual.widgets._toast.Toast],
|
||||
normally originating in the bottom right corner of the display.
|
||||
@@ -2998,11 +3006,14 @@ class App(Generic[ReturnType], DOMNode):
|
||||
```
|
||||
"""
|
||||
notification = Notification(message, title, severity, timeout)
|
||||
self._notifications.add(notification)
|
||||
self._refresh_notifications()
|
||||
return notification
|
||||
self.post_message(Notify(notification))
|
||||
|
||||
def unnotify(self, notification: Notification, refresh: bool = True) -> None:
|
||||
def _on_notify(self, event: Notify) -> None:
|
||||
"""Handle notification message."""
|
||||
self._notifications.add(event.notification)
|
||||
self._refresh_notifications()
|
||||
|
||||
def _unnotify(self, notification: Notification, refresh: bool = True) -> None:
|
||||
"""Remove a notification from the notification collection.
|
||||
|
||||
Args:
|
||||
|
||||
@@ -10,10 +10,19 @@ from uuid import uuid4
|
||||
from rich.repr import Result
|
||||
from typing_extensions import Literal, Self, TypeAlias
|
||||
|
||||
from .message import Message
|
||||
|
||||
SeverityLevel: TypeAlias = Literal["information", "warning", "error"]
|
||||
"""The severity level for a notification."""
|
||||
|
||||
|
||||
@dataclass
|
||||
class Notify(Message, bubble=False):
|
||||
"""Message to show a notification."""
|
||||
|
||||
notification: Notification
|
||||
|
||||
|
||||
@dataclass
|
||||
class Notification:
|
||||
"""Holds the details of a notification."""
|
||||
|
||||
@@ -9,6 +9,7 @@ from functools import partial
|
||||
from operator import attrgetter
|
||||
from typing import (
|
||||
TYPE_CHECKING,
|
||||
Any,
|
||||
Awaitable,
|
||||
Callable,
|
||||
ClassVar,
|
||||
@@ -130,10 +131,31 @@ class Screen(Generic[ScreenResultType], Widget):
|
||||
background: $surface;
|
||||
}
|
||||
"""
|
||||
|
||||
TITLE: ClassVar[str | None] = None
|
||||
"""A class variable to set the *default* title for the screen.
|
||||
|
||||
This overrides the app title.
|
||||
To update the title while the screen is running,
|
||||
you can set the [title][textual.screen.Screen.title] attribute.
|
||||
"""
|
||||
|
||||
SUB_TITLE: ClassVar[str | None] = None
|
||||
"""A class variable to set the *default* sub-title for the screen.
|
||||
|
||||
This overrides the app sub-title.
|
||||
To update the sub-title while the screen is running,
|
||||
you can set the [sub_title][textual.screen.Screen.sub_title] attribute.
|
||||
"""
|
||||
|
||||
focused: Reactive[Widget | None] = Reactive(None)
|
||||
"""The focused [widget][textual.widget.Widget] or `None` for no focus."""
|
||||
stack_updates: Reactive[int] = Reactive(0, repaint=False)
|
||||
"""An integer that updates when the screen is resumed."""
|
||||
sub_title: Reactive[str | None] = Reactive(None, compute=False)
|
||||
"""Screen sub-title to override [the app sub-title][textual.app.App.sub_title]."""
|
||||
title: Reactive[str | None] = Reactive(None, compute=False)
|
||||
"""Screen title to override [the app title][textual.app.App.title]."""
|
||||
|
||||
COMMAND_SOURCES: ClassVar[set[type[CommandSource]]] = set()
|
||||
"""The [command sources](/api/command_palette/) for the screen."""
|
||||
@@ -178,6 +200,9 @@ class Screen(Generic[ScreenResultType], Widget):
|
||||
]
|
||||
self.css_path = css_paths
|
||||
|
||||
self.title = self.TITLE
|
||||
self.sub_title = self.SUB_TITLE
|
||||
|
||||
@property
|
||||
def is_modal(self) -> bool:
|
||||
"""Is the screen modal?"""
|
||||
@@ -1007,6 +1032,14 @@ class Screen(Generic[ScreenResultType], Widget):
|
||||
# Failing that fall back to normal checking.
|
||||
return super().can_view(widget)
|
||||
|
||||
def validate_title(self, title: Any) -> str | None:
|
||||
"""Ensure the title is a string or `None`."""
|
||||
return None if title is None else str(title)
|
||||
|
||||
def validate_sub_title(self, sub_title: Any) -> str | None:
|
||||
"""Ensure the sub-title is a string or `None`."""
|
||||
return None if sub_title is None else str(sub_title)
|
||||
|
||||
|
||||
@rich.repr.auto
|
||||
class ModalScreen(Screen[ScreenResultType]):
|
||||
|
||||
@@ -9,6 +9,7 @@ from ._types import CallbackType, MessageTarget, WatchCallbackType
|
||||
from .actions import ActionParseResult
|
||||
from .css.styles import RenderStyles
|
||||
from .widgets._data_table import CursorType
|
||||
from .widgets._input import InputValidationOn
|
||||
|
||||
__all__ = [
|
||||
"ActionParseResult",
|
||||
@@ -18,6 +19,7 @@ __all__ = [
|
||||
"CSSPathType",
|
||||
"CursorType",
|
||||
"EasingFunction",
|
||||
"InputValidationOn",
|
||||
"MessageTarget",
|
||||
"NoActiveAppError",
|
||||
"RenderStyles",
|
||||
|
||||
@@ -3391,18 +3391,19 @@ class Widget(DOMNode):
|
||||
title: str = "",
|
||||
severity: SeverityLevel = "information",
|
||||
timeout: float = Notification.timeout,
|
||||
) -> Notification:
|
||||
) -> None:
|
||||
"""Create a notification.
|
||||
|
||||
!!! tip
|
||||
|
||||
This method is thread-safe.
|
||||
|
||||
Args:
|
||||
message: The message for the notification.
|
||||
title: The title for the notification.
|
||||
severity: The severity of the notification.
|
||||
timeout: The timeout for the notification.
|
||||
|
||||
Returns:
|
||||
The new notification.
|
||||
|
||||
See [`App.notify`][textual.app.App.notify] for the full
|
||||
documentation for this method.
|
||||
"""
|
||||
|
||||
@@ -160,12 +160,36 @@ class Header(Widget):
|
||||
def _on_click(self):
|
||||
self.toggle_class("-tall")
|
||||
|
||||
@property
|
||||
def screen_title(self) -> str:
|
||||
"""The title that this header will display.
|
||||
|
||||
This depends on [`Screen.title`][textual.screen.Screen.title] and [`App.title`][textual.app.App.title].
|
||||
"""
|
||||
screen_title = self.screen.title
|
||||
title = screen_title if screen_title is not None else self.app.title
|
||||
return title
|
||||
|
||||
@property
|
||||
def screen_sub_title(self) -> str:
|
||||
"""The sub-title that this header will display.
|
||||
|
||||
This depends on [`Screen.sub_title`][textual.screen.Screen.sub_title] and [`App.sub_title`][textual.app.App.sub_title].
|
||||
"""
|
||||
screen_sub_title = self.screen.sub_title
|
||||
sub_title = (
|
||||
screen_sub_title if screen_sub_title is not None else self.app.sub_title
|
||||
)
|
||||
return sub_title
|
||||
|
||||
def _on_mount(self, _: Mount) -> None:
|
||||
def set_title(title: str) -> None:
|
||||
self.query_one(HeaderTitle).text = title
|
||||
def set_title() -> None:
|
||||
self.query_one(HeaderTitle).text = self.screen_title
|
||||
|
||||
def set_sub_title(sub_title: str) -> None:
|
||||
self.query_one(HeaderTitle).sub_text = sub_title
|
||||
self.query_one(HeaderTitle).sub_text = self.screen_sub_title
|
||||
|
||||
self.watch(self.app, "title", set_title)
|
||||
self.watch(self.app, "sub_title", set_sub_title)
|
||||
self.watch(self.screen, "title", set_title)
|
||||
self.watch(self.screen, "sub_title", set_sub_title)
|
||||
|
||||
@@ -9,6 +9,7 @@ from rich.console import Console, ConsoleOptions, RenderableType, RenderResult
|
||||
from rich.highlighter import Highlighter
|
||||
from rich.segment import Segment
|
||||
from rich.text import Text
|
||||
from typing_extensions import Literal
|
||||
|
||||
from .. import events
|
||||
from .._segment_tools import line_crop
|
||||
@@ -21,6 +22,11 @@ from ..suggester import Suggester, SuggestionReady
|
||||
from ..validation import ValidationResult, Validator
|
||||
from ..widget import Widget
|
||||
|
||||
InputValidationOn = Literal["blur", "changed", "submitted"]
|
||||
"""Possible messages that trigger input validation."""
|
||||
_POSSIBLE_VALIDATE_ON_VALUES = {"blur", "changed", "submitted"}
|
||||
"""Set literal with the legal values for the type `InputValidationOn`."""
|
||||
|
||||
|
||||
class _InputRenderable:
|
||||
"""Render the input content."""
|
||||
@@ -221,6 +227,7 @@ class Input(Widget, can_focus=True):
|
||||
*,
|
||||
suggester: Suggester | None = None,
|
||||
validators: Validator | Iterable[Validator] | None = None,
|
||||
validate_on: Iterable[InputValidationOn] | None = None,
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
@@ -236,6 +243,9 @@ class Input(Widget, can_focus=True):
|
||||
suggester: [`Suggester`][textual.suggester.Suggester] associated with this
|
||||
input instance.
|
||||
validators: An iterable of validators that the Input value will be checked against.
|
||||
validate_on: Zero or more of the values "blur", "changed", and "submitted",
|
||||
which determine when to do input validation. The default is to do
|
||||
validation for all messages.
|
||||
name: Optional name for the input widget.
|
||||
id: Optional ID for the widget.
|
||||
classes: Optional initial classes for the widget.
|
||||
@@ -254,7 +264,25 @@ class Input(Widget, can_focus=True):
|
||||
elif validators is None:
|
||||
self.validators = []
|
||||
else:
|
||||
self.validators = list(validators) or []
|
||||
self.validators = list(validators)
|
||||
|
||||
self.validate_on = (
|
||||
set(validate_on) & _POSSIBLE_VALIDATE_ON_VALUES
|
||||
if validate_on is not None
|
||||
else _POSSIBLE_VALIDATE_ON_VALUES
|
||||
)
|
||||
"""Set with event names to do input validation on.
|
||||
|
||||
Validation can only be performed on blur, on input changes and on input submission.
|
||||
|
||||
Example:
|
||||
This creates an `Input` widget that only gets validated when the value
|
||||
is submitted explicitly:
|
||||
|
||||
```py
|
||||
input = Input(validate_on=["submitted"])
|
||||
```
|
||||
"""
|
||||
|
||||
def _position_to_cell(self, position: int) -> int:
|
||||
"""Convert an index within the value to cell position."""
|
||||
@@ -306,8 +334,9 @@ class Input(Widget, can_focus=True):
|
||||
if self.styles.auto_dimensions:
|
||||
self.refresh(layout=True)
|
||||
|
||||
validation_result = self.validate(value)
|
||||
|
||||
validation_result = (
|
||||
self.validate(value) if "changed" in self.validate_on else None
|
||||
)
|
||||
self.post_message(self.Changed(self, value, validation_result))
|
||||
|
||||
def validate(self, value: str) -> ValidationResult | None:
|
||||
@@ -389,6 +418,8 @@ class Input(Widget, can_focus=True):
|
||||
|
||||
def _on_blur(self, _: Blur) -> None:
|
||||
self.blink_timer.pause()
|
||||
if "blur" in self.validate_on:
|
||||
self.validate(self.value)
|
||||
|
||||
def _on_focus(self, _: Focus) -> None:
|
||||
self.cursor_position = len(self.value)
|
||||
@@ -577,7 +608,9 @@ class Input(Widget, can_focus=True):
|
||||
async def action_submit(self) -> None:
|
||||
"""Handle a submit action.
|
||||
|
||||
Normally triggered by the user pressing Enter. This will also run any validators.
|
||||
Normally triggered by the user pressing Enter. This may also run any validators.
|
||||
"""
|
||||
validation_result = self.validate(self.value)
|
||||
validation_result = (
|
||||
self.validate(self.value) if "submitted" in self.validate_on else None
|
||||
)
|
||||
self.post_message(self.Submitted(self, self.value, validation_result))
|
||||
|
||||
@@ -613,6 +613,7 @@ class OptionList(ScrollView, can_focus=True):
|
||||
self._refresh_content_tracking(force=True)
|
||||
# Force a re-validation of the highlight.
|
||||
self.highlighted = self.highlighted
|
||||
self._mouse_hovering_over = None
|
||||
self.refresh()
|
||||
|
||||
def remove_option(self, option_id: str) -> Self:
|
||||
|
||||
@@ -128,7 +128,7 @@ class Toast(Static, inherit_css=False):
|
||||
# the notification that caused us to exist. Note that we tell the
|
||||
# app to not bother refreshing the display on our account, we're
|
||||
# about to handle that anyway.
|
||||
self.app.unnotify(self._notification, refresh=False)
|
||||
self.app._unnotify(self._notification, refresh=False)
|
||||
# Note that we attempt to remove our parent, because we're wrapped
|
||||
# inside an alignment container. The testing that we are is as much
|
||||
# to keep type checkers happy as anything else.
|
||||
|
||||
@@ -1,18 +1,23 @@
|
||||
import pytest
|
||||
|
||||
from textual import on
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.events import Blur
|
||||
from textual.validation import Number, ValidationResult
|
||||
from textual.widgets import Input
|
||||
|
||||
|
||||
class InputApp(App):
|
||||
def __init__(self):
|
||||
def __init__(self, validate_on=None):
|
||||
super().__init__()
|
||||
self.messages = []
|
||||
self.validator = Number(minimum=1, maximum=5)
|
||||
self.validate_on = validate_on
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
yield Input(
|
||||
validators=self.validator,
|
||||
validate_on=self.validate_on,
|
||||
)
|
||||
|
||||
@on(Input.Changed)
|
||||
@@ -77,3 +82,126 @@ async def test_input_submitted_message_validation_success():
|
||||
await pilot.pause()
|
||||
assert len(app.messages) == 2
|
||||
assert app.messages[1].validation_result == ValidationResult.success()
|
||||
|
||||
|
||||
async def test_on_blur_triggers_validation():
|
||||
app = InputApp()
|
||||
async with app.run_test() as pilot:
|
||||
input = app.query_one(Input)
|
||||
input.focus()
|
||||
input.value = "3"
|
||||
input.remove_class("-valid")
|
||||
app.set_focus(None)
|
||||
await pilot.pause()
|
||||
assert input.has_class("-valid")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"validate_on",
|
||||
[
|
||||
set(),
|
||||
{"blur"},
|
||||
{"submitted"},
|
||||
{"blur", "submitted"},
|
||||
{"fried", "garbage"},
|
||||
],
|
||||
)
|
||||
async def test_validation_on_changed_should_not_happen(validate_on):
|
||||
app = InputApp(validate_on)
|
||||
async with app.run_test() as pilot:
|
||||
# sanity checks
|
||||
assert len(app.messages) == 0
|
||||
input = app.query_one(Input)
|
||||
assert not input.has_class("-valid")
|
||||
assert not input.has_class("-invalid")
|
||||
|
||||
input.value = "3"
|
||||
await pilot.pause()
|
||||
assert len(app.messages) == 1
|
||||
assert app.messages[-1].validation_result is None
|
||||
assert not input.has_class("-valid")
|
||||
assert not input.has_class("-invalid")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"validate_on",
|
||||
[
|
||||
set(),
|
||||
{"blur"},
|
||||
{"changed"},
|
||||
{"blur", "changed"},
|
||||
{"fried", "garbage"},
|
||||
],
|
||||
)
|
||||
async def test_validation_on_submitted_should_not_happen(validate_on):
|
||||
app = InputApp(validate_on)
|
||||
async with app.run_test() as pilot:
|
||||
# sanity checks
|
||||
assert len(app.messages) == 0
|
||||
input = app.query_one(Input)
|
||||
assert not input.has_class("-valid")
|
||||
assert not input.has_class("-invalid")
|
||||
|
||||
await input.action_submit()
|
||||
await pilot.pause()
|
||||
assert len(app.messages) == 1
|
||||
assert app.messages[-1].validation_result is None
|
||||
assert not input.has_class("-valid")
|
||||
assert not input.has_class("-invalid")
|
||||
|
||||
|
||||
@pytest.mark.parametrize(
|
||||
"validate_on",
|
||||
[
|
||||
set(),
|
||||
{"submitted"},
|
||||
{"changed"},
|
||||
{"submitted", "changed"},
|
||||
{"fried", "garbage"},
|
||||
],
|
||||
)
|
||||
async def test_validation_on_blur_should_not_happen_unless_specified(validate_on):
|
||||
app = InputApp(validate_on)
|
||||
async with app.run_test() as pilot:
|
||||
# sanity checks
|
||||
input = app.query_one(Input)
|
||||
assert not input.has_class("-valid")
|
||||
assert not input.has_class("-invalid")
|
||||
|
||||
input.focus()
|
||||
await pilot.pause()
|
||||
app.set_focus(None)
|
||||
await pilot.pause()
|
||||
assert not input.has_class("-valid")
|
||||
assert not input.has_class("-invalid")
|
||||
|
||||
|
||||
async def test_none_validate_on_means_all_validations_happen():
|
||||
app = InputApp(None)
|
||||
async with app.run_test() as pilot:
|
||||
assert len(app.messages) == 0 # sanity checks
|
||||
input = app.query_one(Input)
|
||||
assert not input.has_class("-valid")
|
||||
assert not input.has_class("-invalid")
|
||||
|
||||
input.value = "3"
|
||||
await pilot.pause()
|
||||
assert len(app.messages) == 1
|
||||
assert app.messages[-1].validation_result is not None
|
||||
assert input.has_class("-valid")
|
||||
|
||||
input.remove_class("-valid")
|
||||
|
||||
await input.action_submit()
|
||||
await pilot.pause()
|
||||
assert len(app.messages) == 2
|
||||
assert app.messages[-1].validation_result is not None
|
||||
assert input.has_class("-valid")
|
||||
|
||||
input.remove_class("-valid")
|
||||
|
||||
input.focus()
|
||||
await pilot.pause()
|
||||
app.set_focus(None)
|
||||
await pilot.pause()
|
||||
assert input.has_class("-valid")
|
||||
|
||||
@@ -17,15 +17,17 @@ async def test_app_with_notifications() -> None:
|
||||
"""An app with notifications should have notifications in the list."""
|
||||
async with NotificationApp().run_test() as pilot:
|
||||
pilot.app.notify("test")
|
||||
await pilot.pause()
|
||||
assert len(pilot.app._notifications) == 1
|
||||
|
||||
|
||||
async def test_app_with_removing_notifications() -> None:
|
||||
"""An app with notifications should have notifications in the list, which can be removed."""
|
||||
async with NotificationApp().run_test() as pilot:
|
||||
notification = pilot.app.notify("test")
|
||||
pilot.app.notify("test")
|
||||
await pilot.pause()
|
||||
assert len(pilot.app._notifications) == 1
|
||||
pilot.app.unnotify(notification)
|
||||
pilot.app._unnotify(list(pilot.app._notifications)[0])
|
||||
assert len(pilot.app._notifications) == 0
|
||||
|
||||
|
||||
@@ -34,6 +36,7 @@ async def test_app_with_notifications_that_expire() -> None:
|
||||
async with NotificationApp().run_test() as pilot:
|
||||
for n in range(100):
|
||||
pilot.app.notify("test", timeout=(0.5 if bool(n % 2) else 60))
|
||||
await pilot.pause()
|
||||
assert len(pilot.app._notifications) == 100
|
||||
sleep(0.6)
|
||||
assert len(pilot.app._notifications) == 50
|
||||
@@ -44,6 +47,7 @@ async def test_app_clearing_notifications() -> None:
|
||||
async with NotificationApp().run_test() as pilot:
|
||||
for _ in range(100):
|
||||
pilot.app.notify("test", timeout=120)
|
||||
await pilot.pause()
|
||||
assert len(pilot.app._notifications) == 100
|
||||
pilot.app.clear_notifications()
|
||||
assert len(pilot.app._notifications) == 0
|
||||
|
||||
@@ -5,6 +5,7 @@ from __future__ import annotations
|
||||
import pytest
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.geometry import Offset
|
||||
from textual.widgets import OptionList
|
||||
from textual.widgets.option_list import Option, OptionDoesNotExist
|
||||
|
||||
@@ -99,3 +100,13 @@ async def test_remove_invalid_index() -> None:
|
||||
async with OptionListApp().run_test() as pilot:
|
||||
with pytest.raises(OptionDoesNotExist):
|
||||
pilot.app.query_one(OptionList).remove_option_at_index(23)
|
||||
|
||||
|
||||
async def test_remove_with_hover_on_last_option():
|
||||
"""https://github.com/Textualize/textual/issues/3270"""
|
||||
async with OptionListApp().run_test() as pilot:
|
||||
await pilot.hover(OptionList, Offset(1, 1) + Offset(2, 1))
|
||||
option_list = pilot.app.query_one(OptionList)
|
||||
assert option_list._mouse_hovering_over == 1
|
||||
option_list.remove_option_at_index(0)
|
||||
assert option_list._mouse_hovering_over == None
|
||||
|
||||
151
tests/test_header.py
Normal file
151
tests/test_header.py
Normal file
@@ -0,0 +1,151 @@
|
||||
from textual.app import App
|
||||
from textual.screen import Screen
|
||||
from textual.widgets import Header
|
||||
|
||||
|
||||
async def test_screen_title_none_is_ignored():
|
||||
class MyScreen(Screen):
|
||||
def compose(self):
|
||||
yield Header()
|
||||
|
||||
class MyApp(App):
|
||||
TITLE = "app title"
|
||||
|
||||
def on_mount(self):
|
||||
self.push_screen(MyScreen())
|
||||
|
||||
app = MyApp()
|
||||
async with app.run_test():
|
||||
assert app.query_one("HeaderTitle").text == "app title"
|
||||
|
||||
|
||||
async def test_screen_title_overrides_app_title():
|
||||
class MyScreen(Screen):
|
||||
TITLE = "screen title"
|
||||
|
||||
def compose(self):
|
||||
yield Header()
|
||||
|
||||
class MyApp(App):
|
||||
TITLE = "app title"
|
||||
|
||||
def on_mount(self):
|
||||
self.push_screen(MyScreen())
|
||||
|
||||
app = MyApp()
|
||||
async with app.run_test():
|
||||
assert app.query_one("HeaderTitle").text == "screen title"
|
||||
|
||||
|
||||
async def test_screen_title_reactive_updates_title():
|
||||
class MyScreen(Screen):
|
||||
TITLE = "screen title"
|
||||
|
||||
def compose(self):
|
||||
yield Header()
|
||||
|
||||
class MyApp(App):
|
||||
TITLE = "app title"
|
||||
|
||||
def on_mount(self):
|
||||
self.push_screen(MyScreen())
|
||||
|
||||
app = MyApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.screen.title = "new screen title"
|
||||
await pilot.pause()
|
||||
assert app.query_one("HeaderTitle").text == "new screen title"
|
||||
|
||||
|
||||
async def test_app_title_reactive_does_not_update_title_when_screen_title_is_set():
|
||||
class MyScreen(Screen):
|
||||
TITLE = "screen title"
|
||||
|
||||
def compose(self):
|
||||
yield Header()
|
||||
|
||||
class MyApp(App):
|
||||
TITLE = "app title"
|
||||
|
||||
def on_mount(self):
|
||||
self.push_screen(MyScreen())
|
||||
|
||||
app = MyApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.title = "new app title"
|
||||
await pilot.pause()
|
||||
assert app.query_one("HeaderTitle").text == "screen title"
|
||||
|
||||
|
||||
async def test_screen_sub_title_none_is_ignored():
|
||||
class MyScreen(Screen):
|
||||
def compose(self):
|
||||
yield Header()
|
||||
|
||||
class MyApp(App):
|
||||
SUB_TITLE = "app sub-title"
|
||||
|
||||
def on_mount(self):
|
||||
self.push_screen(MyScreen())
|
||||
|
||||
app = MyApp()
|
||||
async with app.run_test():
|
||||
assert app.query_one("HeaderTitle").sub_text == "app sub-title"
|
||||
|
||||
|
||||
async def test_screen_sub_title_overrides_app_sub_title():
|
||||
class MyScreen(Screen):
|
||||
SUB_TITLE = "screen sub-title"
|
||||
|
||||
def compose(self):
|
||||
yield Header()
|
||||
|
||||
class MyApp(App):
|
||||
SUB_TITLE = "app sub-title"
|
||||
|
||||
def on_mount(self):
|
||||
self.push_screen(MyScreen())
|
||||
|
||||
app = MyApp()
|
||||
async with app.run_test():
|
||||
assert app.query_one("HeaderTitle").sub_text == "screen sub-title"
|
||||
|
||||
|
||||
async def test_screen_sub_title_reactive_updates_sub_title():
|
||||
class MyScreen(Screen):
|
||||
SUB_TITLE = "screen sub-title"
|
||||
|
||||
def compose(self):
|
||||
yield Header()
|
||||
|
||||
class MyApp(App):
|
||||
SUB_TITLE = "app sub-title"
|
||||
|
||||
def on_mount(self):
|
||||
self.push_screen(MyScreen())
|
||||
|
||||
app = MyApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.screen.sub_title = "new screen sub-title"
|
||||
await pilot.pause()
|
||||
assert app.query_one("HeaderTitle").sub_text == "new screen sub-title"
|
||||
|
||||
|
||||
async def test_app_sub_title_reactive_does_not_update_sub_title_when_screen_sub_title_is_set():
|
||||
class MyScreen(Screen):
|
||||
SUB_TITLE = "screen sub-title"
|
||||
|
||||
def compose(self):
|
||||
yield Header()
|
||||
|
||||
class MyApp(App):
|
||||
SUB_TITLE = "app sub-title"
|
||||
|
||||
def on_mount(self):
|
||||
self.push_screen(MyScreen())
|
||||
|
||||
app = MyApp()
|
||||
async with app.run_test() as pilot:
|
||||
app.sub_title = "new app sub-title"
|
||||
await pilot.pause()
|
||||
assert app.query_one("HeaderTitle").sub_text == "screen sub-title"
|
||||
Reference in New Issue
Block a user