Merge branch 'main' into input-blur-validation

This commit is contained in:
Rodrigo Girão Serrão
2023-09-11 14:27:31 +01:00
committed by GitHub
9 changed files with 267 additions and 21 deletions

View File

@@ -11,10 +11,23 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- `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
### Added

View File

@@ -90,7 +90,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
@@ -238,6 +238,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):
@@ -308,13 +312,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].
"""
BINDINGS: ClassVar[list[BindingType]] = [
@@ -426,7 +432,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.
@@ -2927,18 +2933,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.
@@ -2973,11 +2981,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,
@@ -128,10 +129,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]."""
BINDINGS = [
Binding("tab", "focus_next", "Focus Next", show=False),
@@ -173,6 +195,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?"""
@@ -1002,6 +1027,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

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

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

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

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"