Merge branch 'main' into M-x

This commit is contained in:
Will McGugan
2023-09-11 14:49:28 +01:00
committed by GitHub
17 changed files with 509 additions and 29 deletions

View File

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

View File

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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