Merge branch 'main' into input-auto-completion

This commit is contained in:
Rodrigo Girão Serrão
2023-05-25 17:42:37 +01:00
58 changed files with 5718 additions and 1801 deletions

View File

@@ -5,6 +5,44 @@ All notable changes to this project will be documented in this file.
The format is based on [Keep a Changelog](http://keepachangelog.com/)
and this project adheres to [Semantic Versioning](http://semver.org/).
## Unreleased
### Added
- `work` decorator accepts `description` parameter to add debug string https://github.com/Textualize/textual/issues/2597
- Added `SelectionList` widget https://github.com/Textualize/textual/pull/2652
- `App.AUTO_FOCUS` to set auto focus on all screens https://github.com/Textualize/textual/issues/2594
### Changed
- `Placeholder` now sets its color cycle per app https://github.com/Textualize/textual/issues/2590
- Footer now clears key highlight regardless of whether it's in the active screen or not https://github.com/Textualize/textual/issues/2606
- The default Widget repr no longer displays classes and pseudo-classes (to reduce noise in logs). Add them to your `__rich_repr__` method if needed. https://github.com/Textualize/textual/pull/2623
- Setting `Screen.AUTO_FOCUS` to `None` will inherit `AUTO_FOCUS` from the app instead of disabling it https://github.com/Textualize/textual/issues/2594
- Setting `Screen.AUTO_FOCUS` to `""` will disable it on the screen https://github.com/Textualize/textual/issues/2594
### Removed
- `Placeholder.reset_color_cycle`
- Removed `Widget.reset_focus` (now called `Widget.blur`) https://github.com/Textualize/textual/issues/2642
## [0.26.0] - 2023-05-20
### Added
- Added Widget.can_view
### Changed
- Textual will now scroll focused widgets to center if not in view
## Unreleased
### Changed
- `Message.control` is now a property instead of a class variable. https://github.com/Textualize/textual/issues/2528
- `Tree` and `DirectoryTree` Messages no longer accept a `tree` parameter, using `self.node.tree` instead. https://github.com/Textualize/textual/issues/2529
## Unreleased
@@ -979,6 +1017,7 @@ https://textual.textualize.io/blog/2022/11/08/version-040/#version-040
- New handler system for messages that doesn't require inheritance
- Improved traceback handling
[0.26.0]: https://github.com/Textualize/textual/compare/v0.25.0...v0.26.0
[0.25.0]: https://github.com/Textualize/textual/compare/v0.24.1...v0.25.0
[0.24.1]: https://github.com/Textualize/textual/compare/v0.24.0...v0.24.1
[0.24.0]: https://github.com/Textualize/textual/compare/v0.23.0...v0.24.0

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

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

View File

@@ -1,6 +1,6 @@
from textual.app import App
from textual.containers import Horizontal
from textual.widgets import Placeholder, Label, Static
from textual.widgets import Label, Placeholder, Static
class Ruler(Static):
@@ -9,7 +9,7 @@ class Ruler(Static):
yield Label(ruler_text)
class HeightComparisonApp(App):
class WidthComparisonApp(App):
def compose(self):
yield Horizontal(
Placeholder(id="cells"), # (1)!
@@ -25,4 +25,6 @@ class HeightComparisonApp(App):
yield Ruler()
app = HeightComparisonApp(css_path="width_comparison.css")
app = WidthComparisonApp(css_path="width_comparison.css")
if __name__ == "__main__":
app.run()

View File

@@ -0,0 +1,72 @@
from textual import on
from textual.app import App, ComposeResult
from textual.validation import Function, Number, ValidationResult, Validator
from textual.widgets import Input, Label, Pretty
class InputApp(App):
# (6)!
CSS = """
Input.-valid {
border: tall $success 60%;
}
Input.-valid:focus {
border: tall $success;
}
Input {
margin: 1 1;
}
Label {
margin: 1 2;
}
Pretty {
margin: 1 2;
}
"""
def compose(self) -> ComposeResult:
yield Label("Enter an even number between 1 and 100 that is also a palindrome.")
yield Input(
placeholder="Enter a number...",
validators=[
Number(minimum=1, maximum=100), # (1)!
Function(is_even, "Value is not even."), # (2)!
Palindrome(), # (3)!
],
)
yield Pretty([])
@on(Input.Changed)
def show_invalid_reasons(self, event: Input.Changed) -> None:
# Updating the UI to show the reasons why validation failed
if not event.validation_result.is_valid: # (4)!
self.query_one(Pretty).update(event.validation_result.failure_descriptions)
else:
self.query_one(Pretty).update([])
def is_even(value: str) -> bool:
try:
return int(value) % 2 == 0
except ValueError:
return False
# A custom validator
class Palindrome(Validator): # (5)!
def validate(self, value: str) -> ValidationResult:
"""Check a string is equal to its reverse."""
if self.is_palindrome(value):
return self.success()
else:
return self.failure("That's not a palindrome :/")
@staticmethod
def is_palindrome(value: str) -> bool:
return value == value[::-1]
app = InputApp()
if __name__ == "__main__":
app.run()

View File

@@ -0,0 +1,10 @@
Screen {
align: center middle;
}
SelectionList {
padding: 1;
border: solid $accent;
width: 80%;
height: 80%;
}

View File

@@ -0,0 +1,19 @@
Screen {
align: center middle;
}
Horizontal {
width: 80%;
height: 80%;
}
SelectionList {
padding: 1;
border: solid $accent;
width: 1fr;
}
Pretty {
width: 1fr;
border: solid $accent;
}

View File

@@ -0,0 +1,40 @@
from textual import on
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.events import Mount
from textual.widgets import Footer, Header, Pretty, SelectionList
from textual.widgets.selection_list import Selection
class SelectionListApp(App[None]):
CSS_PATH = "selection_list_selected.css"
def compose(self) -> ComposeResult:
yield Header()
with Horizontal():
yield SelectionList[str]( # (1)!
Selection("Falken's Maze", "secret_back_door", True),
Selection("Black Jack", "black_jack"),
Selection("Gin Rummy", "gin_rummy"),
Selection("Hearts", "hearts"),
Selection("Bridge", "bridge"),
Selection("Checkers", "checkers"),
Selection("Chess", "a_nice_game_of_chess", True),
Selection("Poker", "poker"),
Selection("Fighter Combat", "fighter_combat", True),
)
yield Pretty([])
yield Footer()
def on_mount(self) -> None:
self.query_one(SelectionList).border_title = "Shall we play some games?"
self.query_one(Pretty).border_title = "Selected games"
@on(Mount)
@on(SelectionList.SelectedChanged)
def update_selected_view(self) -> None:
self.query_one(Pretty).update(self.query_one(SelectionList).selected)
if __name__ == "__main__":
SelectionListApp().run()

View File

@@ -0,0 +1,29 @@
from textual.app import App, ComposeResult
from textual.widgets import Footer, Header, SelectionList
from textual.widgets.selection_list import Selection
class SelectionListApp(App[None]):
CSS_PATH = "selection_list.css"
def compose(self) -> ComposeResult:
yield Header()
yield SelectionList[int]( # (1)!
Selection("Falken's Maze", 0, True),
Selection("Black Jack", 1),
Selection("Gin Rummy", 2),
Selection("Hearts", 3),
Selection("Bridge", 4),
Selection("Checkers", 5),
Selection("Chess", 6, True),
Selection("Poker", 7),
Selection("Fighter Combat", 8, True),
)
yield Footer()
def on_mount(self) -> None:
self.query_one(SelectionList).border_title = "Shall we play some games?"
if __name__ == "__main__":
SelectionListApp().run()

View File

@@ -0,0 +1,28 @@
from textual.app import App, ComposeResult
from textual.widgets import Footer, Header, SelectionList
class SelectionListApp(App[None]):
CSS_PATH = "selection_list.css"
def compose(self) -> ComposeResult:
yield Header()
yield SelectionList[int]( # (1)!
("Falken's Maze", 0, True),
("Black Jack", 1),
("Gin Rummy", 2),
("Hearts", 3),
("Bridge", 4),
("Checkers", 5),
("Chess", 6, True),
("Poker", 7),
("Fighter Combat", 8, True),
)
yield Footer()
def on_mount(self) -> None:
self.query_one(SelectionList).border_title = "Shall we play some games?"
if __name__ == "__main__":
SelectionListApp().run()

View File

@@ -225,8 +225,8 @@ The main screen is darkened to indicate to the user that it is not active, and o
It is a common requirement for screens to be able to return data.
For instance, you may want a screen to show a dialog and have the result of that dialog processed *after* the screen has been popped.
To return data from a screen, call [`dismiss()`][textual.screen.dismiss] on the screen with the data you wish to return.
This will pop the screen and invoke a callback set when the screen was pushed (with [`push_screen`][textual.app.push_screen]).
To return data from a screen, call [`dismiss()`][textual.screen.Screen.dismiss] on the screen with the data you wish to return.
This will pop the screen and invoke a callback set when the screen was pushed (with [`push_screen`][textual.app.App.push_screen]).
Let's modify the previous example to use `dismiss` rather than an explicit `pop_screen`.

View File

@@ -197,6 +197,14 @@ Select from a number of possible options.
```{.textual path="docs/examples/widgets/select_widget.py" press="tab,enter,down,down"}
```
## SelectionList
Select multiple values from a list of options.
[SelectionList reference](./widgets/selection_list.md){ .md-button .md-button--primary }
```{.textual path="docs/examples/widgets/selection_list_selections.py" press="down,down,down"}
```
## Static

View File

@@ -5,7 +5,9 @@ A single-line text input widget.
- [x] Focusable
- [ ] Container
## Example
## Examples
### A Simple Example
The example below shows how you might create a simple form using two `Input` widgets.
@@ -20,10 +22,52 @@ The example below shows how you might create a simple form using two `Input` wid
--8<-- "docs/examples/widgets/input.py"
```
### Validating Input
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.
Validation is considered to have failed if *any* of the validators fail.
You can check whether the validation succeeded or failed inside an [Input.Changed][textual.widgets.Input.Changed] or
[Input.Submitted][textual.widgets.Input.Submitted] handler by looking at the `validation_result` attribute on these events.
In the example below, we show how to combine multiple validators and update the UI to tell the user
why validation failed.
Click the tabs to see the output for validation failures and successes.
=== "input_validation.py"
```python hl_lines="8-15 31-35 42-45 56-62"
--8<-- "docs/examples/widgets/input_validation.py"
```
1. `Number` is a built-in `Validator`. It checks that the value in the `Input` is a valid number, and optionally can check that it falls within a range.
2. `Function` lets you quickly define custom validation constraints. In this case, we check the value in the `Input` is even.
3. `Palindrome` is a custom `Validator` defined below.
4. The `Input.Changed` event has a `validation_result` attribute which contains information about the validation that occurred when the value changed.
5. Here's how we can implement a custom validator which checks if a string is a palindrome. Note how the description passed into `self.failure` corresponds to the message seen on UI.
6. Textual offers default styling for the `-invalid` CSS class (a red border), which is automatically applied to `Input` when validation fails. We can also provide custom styling for the `-valid` class, as seen here. In this case, we add a green border around the `Input` to indicate successful validation.
=== "Validation Failure"
```{.textual path="docs/examples/widgets/input_validation.py" press="-,2,3"}
```
=== "Validation Success"
```{.textual path="docs/examples/widgets/input_validation.py" press="4,4"}
```
Textual offers several [built-in validators][textual.validation] for common requirements,
but you can easily roll your own by extending [Validator][textual.validation.Validator],
as seen for `Palindrome` in the example above.
## Reactive Attributes
| Name | Type | Default | Description |
| ----------------- | ------ | ------- | --------------------------------------------------------------- |
|-------------------|--------|---------|-----------------------------------------------------------------|
| `cursor_blink` | `bool` | `True` | True if cursor blinking is enabled. |
| `value` | `str` | `""` | The value currently in the text input. |
| `cursor_position` | `int` | `0` | The index of the cursor in the value string. |

View File

@@ -87,7 +87,7 @@ tables](https://rich.readthedocs.io/en/latest/tables.html):
## Messages
- [OptionList.OptionHighlight][textual.widgets.OptionList.OptionHighlighted]
- [OptionList.OptionHighlighted][textual.widgets.OptionList.OptionHighlighted]
- [OptionList.OptionSelected][textual.widgets.OptionList.OptionSelected]
Both of the messages above inherit from the common base [`OptionList`][textual.widgets.OptionList.OptionMessage], so refer to its documentation to see what attributes are available.
@@ -115,3 +115,8 @@ The option list provides the following component classes:
::: textual.widgets.OptionList
options:
heading_level: 2
::: textual.widgets.option_list
options:
heading_level: 2

View File

@@ -66,10 +66,10 @@ The following example presents a `Select` with a number of options.
## Reactive attributes
| Name | Type | Default | Description |
| ---------- | -------------------- | ------- | ----------------------------------- |
| `expanded` | `bool` | `False` | True to expand the options overlay. |
| `value` | `SelectType \| None` | `None` | Current value of the Select. |
| Name | Type | Default | Description |
|------------|------------------------|---------|-------------------------------------|
| `expanded` | `bool` | `False` | True to expand the options overlay. |
| `value` | `SelectType` \| `None` | `None` | Current value of the Select. |
## Bindings

View File

@@ -0,0 +1,171 @@
# SelectionList
!!! tip "Added in version 0.27.0"
A widget for showing a vertical list of selectable options.
- [x] Focusable
- [ ] Container
## Typing
The `SelectionList` control is a
[`Generic`](https://docs.python.org/3/library/typing.html#typing.Generic),
which allows you to set the type of the
[selection values][textual.widgets.selection_list.Selection.value]. For instance, if
the data type for your values is an integer, you would type the widget as
follows:
```python
selections = [("First", 1), ("Second", 2)]
my_selection_list: SelectionList[int] = SelectionList(selections)
```
!!! note
Typing is entirely optional.
If you aren't familiar with typing or don't want to worry about it right now, feel free to ignore it.
## Examples
A selection list is designed to be built up of single-line prompts (which
can be [Rich renderables](/guide/widgets/#rich-renderables)) and an
associated unique value.
### Selections as tuples
A selection list can be built with tuples, either of two or three values in
length. Each tuple must contain a prompt and a value, and it can also
optionally contain a flag for the initial selected state of the option.
=== "Output"
```{.textual path="docs/examples/widgets/selection_list_tuples.py"}
```
=== "selection_list_tuples.py"
~~~python
--8<-- "docs/examples/widgets/selection_list_tuples.py"
~~~
1. Note that the `SelectionList` is typed as `int`, for the type of the values.
=== "selection_list.css"
~~~python
--8<-- "docs/examples/widgets/selection_list.css"
~~~
### Selections as Selection objects
Alternatively, selections can be passed in as
[`Selection`][textual.widgets.selection_list.Selection]s:
=== "Output"
```{.textual path="docs/examples/widgets/selection_list_selections.py"}
```
=== "selection_list_selections.py"
~~~python
--8<-- "docs/examples/widgets/selection_list_selections.py"
~~~
1. Note that the `SelectionList` is typed as `int`, for the type of the values.
=== "selection_list.css"
~~~python
--8<-- "docs/examples/widgets/selection_list.css"
~~~
### Handling changes to the selections
Most of the time, when using the `SelectionList`, you will want to know when
the collection of selected items has changed; this is ideally done using the
[`SelectedChanged`][textual.widgets.SelectionList.SelectedChanged] message.
Here is an example of using that message to update a `Pretty` with the
collection of selected values:
=== "Output"
```{.textual path="docs/examples/widgets/selection_list_selected.py"}
```
=== "selection_list_selections.py"
~~~python
--8<-- "docs/examples/widgets/selection_list_selected.py"
~~~
1. Note that the `SelectionList` is typed as `str`, for the type of the values.
=== "selection_list.css"
~~~python
--8<-- "docs/examples/widgets/selection_list_selected.css"
~~~
## Reactive Attributes
| Name | Type | Default | Description |
|---------------|-----------------|---------|------------------------------------------------------------------------------|
| `highlighted` | `int` \| `None` | `None` | The index of the highlighted selection. `None` means nothing is highlighted. |
## Messages
The following messages will be posted as the user interacts with the list:
- [SelectionList.SelectionHighlighted][textual.widgets.SelectionList.SelectionHighlighted]
- [SelectionList.SelectionToggled][textual.widgets.SelectionList.SelectionToggled]
The following message will be posted if the content of
[`selected`][textual.widgets.SelectionList.selected] changes, either by user
interaction or by API calls:
- [SelectionList.SelectedChanged][textual.widgets.SelectionList.SelectedChanged]
## Bindings
The selection list widget defines the following bindings:
::: textual.widgets.SelectionList.BINDINGS
options:
show_root_heading: false
show_root_toc_entry: false
It inherits from [`OptionList`][textual.widgets.OptionList]
and so also inherits the following bindings:
::: textual.widgets.OptionList.BINDINGS
options:
show_root_heading: false
show_root_toc_entry: false
## Component Classes
The selection list provides the following component classes:
::: textual.widgets.SelectionList.COMPONENT_CLASSES
options:
show_root_heading: false
show_root_toc_entry: false
It inherits from [`OptionList`][textual.widgets.OptionList] and so also
makes use of the following component classes:
::: textual.widgets.OptionList.COMPONENT_CLASSES
options:
show_root_heading: false
show_root_toc_entry: false
::: textual.widgets.SelectionList
options:
heading_level: 2
::: textual.widgets.selection_list
options:
heading_level: 2

View File

@@ -150,6 +150,7 @@ nav:
- "widgets/radiobutton.md"
- "widgets/radioset.md"
- "widgets/select.md"
- "widgets/selection_list.md"
- "widgets/static.md"
- "widgets/switch.md"
- "widgets/tabbed_content.md"

2930
poetry.lock generated

File diff suppressed because it is too large Load Diff

View File

@@ -1,6 +1,6 @@
[tool.poetry]
name = "textual"
version = "0.25.0"
version = "0.26.0"
homepage = "https://github.com/Textualize/textual"
description = "Modern Text User Interface framework"
authors = ["Will McGugan <will@textualize.io>"]

View File

@@ -806,7 +806,7 @@ class Compositor:
if self.root is None:
raise errors.NoWidget("Widget is not in layout")
try:
if self._full_map is not None:
if not self._full_map_invalidated:
try:
return self._full_map[widget]
except KeyError:

View File

@@ -34,7 +34,12 @@ def format_svg(source, language, css_class, options, md, attrs, **kwargs) -> str
rows = int(attrs.get("lines", 24))
columns = int(attrs.get("columns", 80))
svg = take_svg_screenshot(
None, path, press, title, terminal_size=(columns, rows)
None,
path,
press,
title,
terminal_size=(columns, rows),
wait_for_animation=False,
)
finally:
os.chdir(cwd)
@@ -56,6 +61,7 @@ def take_svg_screenshot(
title: str | None = None,
terminal_size: tuple[int, int] = (80, 24),
run_before: Callable[[Pilot], Awaitable[None] | None] | None = None,
wait_for_animation: bool = True,
) -> str:
"""
@@ -68,6 +74,7 @@ def take_svg_screenshot(
run_before: An arbitrary callable that runs arbitrary code before taking the
screenshot. Use this to simulate complex user interactions with the app
that cannot be simulated by key presses.
wait_for_animation: Wait for animation to complete before taking screenshot.
Returns:
An SVG string, showing the content of the terminal window at the time
@@ -109,8 +116,9 @@ def take_svg_screenshot(
if inspect.isawaitable(result):
await result
await pilot.press(*press)
await pilot.wait_for_scheduled_animations()
await pilot.pause()
if wait_for_animation:
await pilot.wait_for_scheduled_animations()
await pilot.pause()
svg = app.export_screenshot(title=title)
app.exit(svg)

View File

@@ -65,7 +65,7 @@ def on(
parsed_selectors: dict[str, tuple[SelectorSet, ...]] = {}
for attribute, css_selector in selectors.items():
if attribute == "control":
if message_type.control is None:
if message_type.control == Message.control:
raise OnDecoratorError(
"The message class must have a 'control' to match with the on decorator"
)

View File

@@ -1,7 +1,6 @@
from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Union
from typing import TYPE_CHECKING, Any, Awaitable, Callable, List, Pattern, Union
from rich.segment import Segment
from typing_extensions import Protocol
from typing_extensions import Protocol, runtime_checkable
if TYPE_CHECKING:
from .message import Message

View File

@@ -58,6 +58,7 @@ def work(
group: str = "default",
exit_on_error: bool = True,
exclusive: bool = False,
description: str | None = None,
) -> Callable[FactoryParamSpec, Worker[ReturnType]] | Decorator:
"""A decorator used to create [workers](/guide/workers).
@@ -67,6 +68,9 @@ def work(
group: A short string to identify a group of workers.
exit_on_error: Exit the app if the worker raises an error. Set to `False` to suppress exceptions.
exclusive: Cancel all workers in the same group.
description: Readable description of the worker for debugging purposes.
By default, it uses a string representation of the decorated method
and its arguments.
"""
def decorator(
@@ -87,22 +91,25 @@ def work(
self = args[0]
assert isinstance(self, DOMNode)
try:
positional_arguments = ", ".join(repr(arg) for arg in args[1:])
keyword_arguments = ", ".join(
f"{name}={value!r}" for name, value in kwargs.items()
)
tokens = [positional_arguments, keyword_arguments]
worker_description = f"{method.__name__}({', '.join(token for token in tokens if token)})"
except Exception:
worker_description = "<worker>"
if description is not None:
debug_description = description
else:
try:
positional_arguments = ", ".join(repr(arg) for arg in args[1:])
keyword_arguments = ", ".join(
f"{name}={value!r}" for name, value in kwargs.items()
)
tokens = [positional_arguments, keyword_arguments]
debug_description = f"{method.__name__}({', '.join(token for token in tokens if token)})"
except Exception:
debug_description = "<worker>"
worker = cast(
"Worker[ReturnType]",
self.run_worker(
partial(method, *args, **kwargs),
name=name or method.__name__,
group=group,
description=worker_description,
description=debug_description,
exclusive=exclusive,
exit_on_error=exit_on_error,
),

View File

@@ -71,7 +71,7 @@ from ._wait import wait_for_idle
from ._worker_manager import WorkerManager
from .actions import ActionParseResult, SkipAction
from .await_remove import AwaitRemove
from .binding import Binding, _Bindings
from .binding import Binding, BindingType, _Bindings
from .css.query import NoMatches
from .css.stylesheet import Stylesheet
from .design import ColorSystem
@@ -159,6 +159,38 @@ class ScreenStackError(ScreenError):
"""Raised when trying to manipulate the screen stack incorrectly."""
class ModeError(Exception):
"""Base class for exceptions related to modes."""
class InvalidModeError(ModeError):
"""Raised if there is an issue with a mode name."""
class UnknownModeError(ModeError):
"""Raised when attempting to use a mode that is not known."""
class ActiveModeError(ModeError):
"""Raised when attempting to remove the currently active mode."""
class ModeError(Exception):
"""Base class for exceptions related to modes."""
class InvalidModeError(ModeError):
"""Raised if there is an issue with a mode name."""
class UnknownModeError(ModeError):
"""Raised when attempting to use a mode that is not known."""
class ActiveModeError(ModeError):
"""Raised when attempting to remove the currently active mode."""
class CssPathError(Exception):
"""Raised when supplied CSS path(s) are invalid."""
@@ -212,8 +244,45 @@ class App(Generic[ReturnType], DOMNode):
}
"""
MODES: ClassVar[dict[str, str | Screen | Callable[[], Screen]]] = {}
"""Modes associated with the app and their base screens.
The base screen is the screen at the bottom of the mode stack. You can think of
it as the default screen for that stack.
The base screens can be names of screens listed in [SCREENS][textual.app.App.SCREENS],
[`Screen`][textual.screen.Screen] instances, or callables that return screens.
Example:
```py
class HelpScreen(Screen[None]):
...
class MainAppScreen(Screen[None]):
...
class MyApp(App[None]):
MODES = {
"default": "main",
"help": HelpScreen,
}
SCREENS = {
"main": MainAppScreen,
}
...
```
"""
SCREENS: ClassVar[dict[str, Screen | Callable[[], Screen]]] = {}
"""Screens associated with the app for the lifetime of the app."""
AUTO_FOCUS: ClassVar[str | None] = "*"
"""A selector to determine what to focus automatically when a screen is activated.
The widget focused is the first that matches the given [CSS selector](/guide/queries/#query-selectors).
Setting to `None` or `""` disables auto focus.
"""
_BASE_PATH: str | None = None
CSS_PATH: ClassVar[CSSPathType | None] = None
"""File paths to load CSS from."""
@@ -230,7 +299,9 @@ class App(Generic[ReturnType], DOMNode):
To update the sub-title while the app is running, you can set the [sub_title][textual.app.App.sub_title] attribute.
"""
BINDINGS = [Binding("ctrl+c", "quit", "Quit", show=False, priority=True)]
BINDINGS: ClassVar[list[BindingType]] = [
Binding("ctrl+c", "quit", "Quit", show=False, priority=True)
]
title: Reactive[str] = Reactive("", compute=False)
sub_title: Reactive[str] = Reactive("", compute=False)
@@ -294,7 +365,10 @@ class App(Generic[ReturnType], DOMNode):
self._workers = WorkerManager(self)
self.error_console = Console(markup=False, stderr=True)
self.driver_class = driver_class or self.get_driver_class()
self._screen_stack: list[Screen] = []
self._screen_stacks: dict[str, list[Screen]] = {"_default": []}
"""A stack of screens per mode."""
self._current_mode: str = "_default"
"""The current mode the app is in."""
self._sync_available = False
self.mouse_over: Widget | None = None
@@ -526,7 +600,19 @@ class App(Generic[ReturnType], DOMNode):
Returns:
A snapshot of the current state of the screen stack.
"""
return self._screen_stack.copy()
return self._screen_stacks[self._current_mode].copy()
@property
def _screen_stack(self) -> list[Screen]:
"""A reference to the current screen stack.
Note:
Consider using [`screen_stack`][textual.app.App.screen_stack] instead.
Returns:
A reference to the current screen stack.
"""
return self._screen_stacks[self._current_mode]
def exit(
self, result: ReturnType | None = None, message: RenderableType | None = None
@@ -674,6 +760,8 @@ class App(Generic[ReturnType], DOMNode):
"""
try:
return self._screen_stack[-1]
except KeyError:
raise UnknownModeError(f"No known mode {self._current_mode!r}") from None
except IndexError:
raise ScreenStackError("No screens on stack") from None
@@ -942,14 +1030,11 @@ class App(Generic[ReturnType], DOMNode):
app = self
driver = app._driver
assert driver is not None
await wait_for_idle(0)
for key in keys:
if key.startswith("wait:"):
_, wait_ms = key.split(":")
print(f"(pause {wait_ms}ms)")
await asyncio.sleep(float(wait_ms) / 1000)
await app._animator.wait_until_complete()
await wait_for_idle(0)
else:
if len(key) == 1 and not key.isalnum():
key = _character_to_key(key)
@@ -964,9 +1049,8 @@ class App(Generic[ReturnType], DOMNode):
key_event._set_sender(app)
driver.send_event(key_event)
await wait_for_idle(0)
await app._animator.wait_until_complete()
await wait_for_idle(0)
await app._animator.wait_until_complete()
await wait_for_idle(0)
@asynccontextmanager
async def run_test(
@@ -1022,7 +1106,9 @@ class App(Generic[ReturnType], DOMNode):
# Context manager returns pilot object to manipulate the app
try:
yield Pilot(app)
pilot = Pilot(app)
await pilot._wait_for_screen()
yield pilot
finally:
# Shutdown the app cleanly
await app._shutdown()
@@ -1319,6 +1405,88 @@ class App(Generic[ReturnType], DOMNode):
"""
return self.mount(*widgets, before=before, after=after)
def _init_mode(self, mode: str) -> None:
"""Do internal initialisation of a new screen stack mode."""
stack = self._screen_stacks.get(mode, [])
if not stack:
_screen = self.MODES[mode]
if callable(_screen):
screen, _ = self._get_screen(_screen())
else:
screen, _ = self._get_screen(self.MODES[mode])
stack.append(screen)
self._screen_stacks[mode] = [screen]
def switch_mode(self, mode: str) -> None:
"""Switch to a given mode.
Args:
mode: The mode to switch to.
Raises:
UnknownModeError: If trying to switch to an unknown mode.
"""
if mode not in self.MODES:
raise UnknownModeError(f"No known mode {mode!r}")
self.screen.post_message(events.ScreenSuspend())
self.screen.refresh()
if mode not in self._screen_stacks:
self._init_mode(mode)
self._current_mode = mode
self.screen._screen_resized(self.size)
self.screen.post_message(events.ScreenResume())
self.log.system(f"{self._current_mode!r} is the current mode")
self.log.system(f"{self.screen} is active")
def add_mode(
self, mode: str, base_screen: str | Screen | Callable[[], Screen]
) -> None:
"""Adds a mode and its corresponding base screen to the app.
Args:
mode: The new mode.
base_screen: The base screen associated with the given mode.
Raises:
InvalidModeError: If the name of the mode is not valid/duplicated.
"""
if mode == "_default":
raise InvalidModeError("Cannot use '_default' as a custom mode.")
elif mode in self.MODES:
raise InvalidModeError(f"Duplicated mode name {mode!r}.")
self.MODES[mode] = base_screen
def remove_mode(self, mode: str) -> None:
"""Removes a mode from the app.
Screens that are running in the stack of that mode are scheduled for pruning.
Args:
mode: The mode to remove. It can't be the active mode.
Raises:
ActiveModeError: If trying to remove the active mode.
UnknownModeError: If trying to remove an unknown mode.
"""
if mode == self._current_mode:
raise ActiveModeError(f"Can't remove active mode {mode!r}")
elif mode not in self.MODES:
raise UnknownModeError(f"Unknown mode {mode!r}")
else:
del self.MODES[mode]
if mode not in self._screen_stacks:
return
stack = self._screen_stacks[mode]
del self._screen_stacks[mode]
for screen in reversed(stack):
self._replace_screen(screen)
def is_screen_installed(self, screen: Screen | str) -> bool:
"""Check if a given screen has been installed.
@@ -1395,7 +1563,9 @@ class App(Generic[ReturnType], DOMNode):
self.screen.refresh()
screen.post_message(events.ScreenSuspend())
self.log.system(f"{screen} SUSPENDED")
if not self.is_screen_installed(screen) and screen not in self._screen_stack:
if not self.is_screen_installed(screen) and all(
screen not in stack for stack in self._screen_stacks.values()
):
screen.remove()
self.log.system(f"{screen} REMOVED")
return screen
@@ -1496,13 +1666,13 @@ class App(Generic[ReturnType], DOMNode):
if screen not in self._installed_screens:
return None
uninstall_screen = self._installed_screens[screen]
if uninstall_screen in self._screen_stack:
if any(uninstall_screen in stack for stack in self._screen_stacks.values()):
raise ScreenStackError("Can't uninstall screen in screen stack")
del self._installed_screens[screen]
self.log.system(f"{uninstall_screen} UNINSTALLED name={screen!r}")
return screen
else:
if screen in self._screen_stack:
if any(screen in stack for stack in self._screen_stacks.values()):
raise ScreenStackError("Can't uninstall screen in screen stack")
for name, installed_screen in self._installed_screens.items():
if installed_screen is screen:
@@ -1689,7 +1859,7 @@ class App(Generic[ReturnType], DOMNode):
if self.css_monitor:
self.set_interval(0.25, self.css_monitor, name="css monitor")
self.log.system("[b green]STARTED[/]", self.css_monitor)
self.log.system("STARTED", self.css_monitor)
async def run_process_messages():
"""The main message loop, invoke below."""
@@ -1911,7 +2081,7 @@ class App(Generic[ReturnType], DOMNode):
Args:
widget: A Widget to unregister
"""
widget.reset_focus()
widget.blur()
if isinstance(widget._parent, Widget):
widget._parent._nodes._remove(widget)
widget._detach()
@@ -1947,12 +2117,12 @@ class App(Generic[ReturnType], DOMNode):
async def _close_all(self) -> None:
"""Close all message pumps."""
# Close all screens on the stack.
for stack_screen in reversed(self._screen_stack):
if stack_screen._running:
await self._prune_node(stack_screen)
self._screen_stack.clear()
# Close all screens on all stacks:
for stack in self._screen_stacks.values():
for stack_screen in reversed(stack):
if stack_screen._running:
await self._prune_node(stack_screen)
stack.clear()
# Close pre-defined screens.
for screen in self.SCREENS.values():
@@ -2137,7 +2307,7 @@ class App(Generic[ReturnType], DOMNode):
# Handle input events that haven't been forwarded
# If the event has been forwarded it may have bubbled up back to the App
if isinstance(event, events.Compose):
screen = Screen(id="_default")
screen = Screen(id=f"_default")
self._register(self, screen)
self._screen_stack.append(screen)
screen.post_message(events.ScreenResume())
@@ -2549,7 +2719,7 @@ class App(Generic[ReturnType], DOMNode):
def _on_terminal_supports_synchronized_output(
self, message: messages.TerminalSupportsSynchronizedOutput
) -> None:
log.system("[b green]SynchronizedOutput mode is supported")
log.system("SynchronizedOutput mode is supported")
self._sync_available = True
def _begin_update(self) -> None:

View File

@@ -1123,7 +1123,8 @@ class DOMNode(MessagePump):
"""Replace all classes.
Args:
A string contain space separated classes, or an iterable of class names.
classes: A string containing space separated classes, or an
iterable of class names.
Returns:
Self.

View File

@@ -35,9 +35,6 @@ if TYPE_CHECKING:
class Event(Message):
"""The base class for all events."""
def __rich_repr__(self) -> rich.repr.Result:
yield from ()
@rich.repr.auto
class Callback(Event, bubble=False, verbose=True):

View File

@@ -42,7 +42,6 @@ class Message:
verbose: ClassVar[bool] = False # Message is verbose
no_dispatch: ClassVar[bool] = False # Message may not be handled by client code
namespace: ClassVar[str] = "" # Namespace to disambiguate messages
control: Widget | None = None
def __init__(self) -> None:
self.__post_init__()
@@ -79,6 +78,11 @@ class Message:
if namespace is not None:
cls.namespace = namespace
@property
def control(self) -> Widget | None:
"""The widget associated with this message, or None by default."""
return None
@property
def is_forwarded(self) -> bool:
"""Has the message been forwarded?"""

View File

@@ -94,11 +94,12 @@ class ResultCallback(Generic[ScreenResultType]):
class Screen(Generic[ScreenResultType], Widget):
"""The base class for screens."""
AUTO_FOCUS: ClassVar[str | None] = "*"
AUTO_FOCUS: ClassVar[str | None] = None
"""A selector to determine what to focus automatically when the screen is activated.
The widget focused is the first that matches the given [CSS selector](/guide/queries/#query-selectors).
Set to `None` to disable auto focus.
Set to `None` to inherit the value from the screen's app.
Set to `""` to disable auto focus.
"""
DEFAULT_CSS = """
@@ -246,6 +247,9 @@ class Screen(Generic[ScreenResultType], Widget):
@property
def focus_chain(self) -> list[Widget]:
"""A list of widgets that may receive focus, in focus order."""
# TODO: Calculating a focus chain is moderately expensive.
# Suspect we can move focus without calculating the entire thing again.
widgets: list[Widget] = []
add_widget = widgets.append
stack: list[Iterator[Widget]] = [iter(self.focusable_children)]
@@ -283,6 +287,8 @@ class Screen(Generic[ScreenResultType], Widget):
is not `None`, then it is guaranteed that the widget returned matches
the CSS selectors given in the argument.
"""
# TODO: This shouldn't be required
self._compositor._full_map_invalidated = True
if not isinstance(selector, str):
selector = selector.__name__
selector_set = parse_selectors(selector)
@@ -381,6 +387,7 @@ class Screen(Generic[ScreenResultType], Widget):
focusable_widgets = self.focus_chain
if not focusable_widgets:
# If there's nothing to focus... give up now.
self.set_focus(None)
return
try:
@@ -469,11 +476,16 @@ class Screen(Generic[ScreenResultType], Widget):
self.focused = widget
# Send focus event
if scroll_visible:
self.screen.scroll_to_widget(widget)
def scroll_to_center(widget: Widget) -> None:
"""Scroll to center (after a refresh)."""
if widget.has_focus and not self.screen.can_view(widget):
self.screen.scroll_to_center(widget)
self.call_after_refresh(scroll_to_center, widget)
widget.post_message(events.Focus())
focused = widget
self._update_focus_styles(self.focused, widget)
self.log.debug(widget, "was focused")
self._update_focus_styles(focused, blurred)
@@ -670,8 +682,9 @@ class Screen(Generic[ScreenResultType], Widget):
size = self.app.size
self._refresh_layout(size, full=True)
self.refresh()
if self.AUTO_FOCUS is not None and self.focused is None:
for widget in self.query(self.AUTO_FOCUS):
auto_focus = self.app.AUTO_FOCUS if self.AUTO_FOCUS is None else self.AUTO_FOCUS
if auto_focus and self.focused is None:
for widget in self.query(auto_focus):
if widget.focusable:
self.set_focus(widget)
break
@@ -768,7 +781,7 @@ class Screen(Generic[ScreenResultType], Widget):
def dismiss(self, result: ScreenResultType | Type[_NoResult] = _NoResult) -> None:
"""Dismiss the screen, optionally with a result.
If `result` is provided and a callback was set when the screen was [pushed][textual.app.push_screen], then
If `result` is provided and a callback was set when the screen was [pushed][textual.app.App.push_screen], then
the callback will be invoked with `result`.
Args:

511
src/textual/validation.py Normal file
View File

@@ -0,0 +1,511 @@
"""Framework for validating string values"""
from __future__ import annotations
import math
import re
from abc import ABC, abstractmethod
from dataclasses import dataclass, field
from typing import Callable, Sequence
from urllib.parse import urlparse
import rich.repr
from textual._types import Pattern
@dataclass
class ValidationResult:
"""The result of calling a `Validator.validate` method."""
failures: Sequence[Failure] = field(default_factory=list)
"""A list of reasons why the value was invalid. Empty if valid=True"""
@staticmethod
def merge(results: Sequence["ValidationResult"]) -> "ValidationResult":
"""Merge multiple ValidationResult objects into one.
Args:
results: List of ValidationResult objects to merge.
Returns:
Merged ValidationResult object.
"""
is_valid = all(result.is_valid for result in results)
failures = [failure for result in results for failure in result.failures]
if is_valid:
return ValidationResult.success()
else:
return ValidationResult.failure(failures)
@staticmethod
def success() -> ValidationResult:
"""Construct a successful ValidationResult.
Returns:
A successful ValidationResult.
"""
return ValidationResult()
@staticmethod
def failure(failures: Sequence[Failure]) -> ValidationResult:
"""Construct a failure ValidationResult.
Args:
failures: The failures.
Returns:
A failure ValidationResult.
"""
return ValidationResult(failures)
@property
def failure_descriptions(self) -> list[str]:
"""Utility for extracting failure descriptions as strings.
Useful if you don't care about the additional metadata included in the `Failure` objects.
Returns:
A list of the string descriptions explaining the failing validations.
"""
return [
failure.description
for failure in self.failures
if failure.description is not None
]
@property
def is_valid(self) -> bool:
"""True if the validation was successful."""
return len(self.failures) == 0
@dataclass
class Failure:
"""Information about a validation failure."""
validator: Validator
"""The Validator which produced the failure."""
value: str | None = None
"""The value which resulted in validation failing."""
description: str | None = None
"""An optional override for describing this failure. Takes precedence over any messages set in the Validator."""
def __post_init__(self) -> None:
# If a failure message isn't supplied, try to get it from the Validator.
if self.description is None:
if self.validator.failure_description is not None:
self.description = self.validator.failure_description
else:
self.description = self.validator.describe_failure(self)
def __rich_repr__(self) -> rich.repr.Result: # pragma: no cover
yield self.value
yield self.validator
yield self.description
class Validator(ABC):
"""Base class for the validation of string values.
Commonly used in conjunction with the `Input` widget, which accepts a
list of validators via its constructor. This validation framework can also be used to validate any 'stringly-typed'
values (for example raw command line input from `sys.args`).
To implement your own `Validator`, subclass this class.
Example:
```python
class Palindrome(Validator):
def validate(self, value: str) -> ValidationResult:
def is_palindrome(value: str) -> bool:
return value == value[::-1]
return self.success() if is_palindrome(value) else self.failure("Not palindrome!")
```
"""
def __init__(self, failure_description: str | None = None) -> None:
self.failure_description = failure_description
"""A description of why the validation failed.
The description (intended to be user-facing) to attached to the Failure if the validation fails.
This failure description is ultimately accessible at the time of validation failure via the `Input.Changed`
or `Input.Submitted` event, and you can access it on your message handler (a method called, for example,
`on_input_changed` or a method decorated with `@on(Input.Changed)`.
"""
@abstractmethod
def validate(self, value: str) -> ValidationResult:
"""Validate the value and return a ValidationResult describing the outcome of the validation.
Args:
value: The value to validate.
Returns:
The result of the validation.
"""
def describe_failure(self, failure: Failure) -> str | None:
"""Return a string description of the Failure.
Used to provide a more fine-grained description of the failure. A Validator could fail for multiple
reasons, so this method could be used to provide a different reason for different types of failure.
!!! warning
This method is only called if no other description has been supplied. If you supply a description
inside a call to `self.failure(description="...")`, or pass a description into the constructor of
the validator, those will take priority, and this method won't be called.
Args:
failure: Information about why the validation failed.
Returns:
A string description of the failure.
"""
return self.failure_description
def success(self) -> ValidationResult:
"""Shorthand for `ValidationResult(True)`.
You can return success() from a `Validator.validate` method implementation to signal
that validation has succeeded.
Returns:
A ValidationResult indicating validation succeeded.
"""
return ValidationResult()
def failure(
self,
description: str | None = None,
value: str | None = None,
failures: Failure | Sequence[Failure] | None = None,
) -> ValidationResult:
"""Shorthand for signaling validation failure.
You can return failure(...) from a `Validator.validate` implementation to signal validation succeeded.
Args:
description: The failure description that will be used. When used in conjunction with the Input widget,
this is the description that will ultimately be available inside the handler for `Input.Changed`. If not
supplied, the `failure_description` from the `Validator` will be used. If that is not supplied either,
then the `describe_failure` method on `Validator` will be called.
value: The value that was considered invalid. This is optional, and only needs to be supplied if required
in your `Input.Changed` handler.
validator: The validator that performed the validation. This is optional, and only needs to be supplied if
required in your `Input.Changed` handler.
failures: The reasons the validator failed. If not supplied, a generic `Failure` will be included in the
ValidationResult returned from this function.
Returns:
A ValidationResult representing failed validation, and containing the metadata supplied
to this function.
"""
if isinstance(failures, Failure):
failures = [failures]
result = ValidationResult(
failures or [Failure(validator=self, value=value, description=description)],
)
return result
class Regex(Validator):
"""A validator that checks the value matches a regex (via `re.fullmatch`)."""
def __init__(
self,
regex: str | Pattern[str],
flags: int | re.RegexFlag = 0,
failure_description: str | None = None,
) -> None:
super().__init__(failure_description=failure_description)
self.regex = regex
"""The regex which we'll validate is matched by the value."""
self.flags = flags
"""The flags to pass to `re.fullmatch`."""
class NoResults(Failure):
"""Indicates validation failed because the regex could not be found within the value string."""
def validate(self, value: str) -> ValidationResult:
"""Ensure that the value matches the regex.
Args:
value: The value that should match the regex.
Returns:
The result of the validation.
"""
regex = self.regex
has_match = re.fullmatch(regex, value, flags=self.flags) is not None
if not has_match:
failures = [Regex.NoResults(self, value)]
return self.failure(failures=failures)
return self.success()
def describe_failure(self, failure: Failure) -> str | None:
"""Describes why the validator failed.
Args:
failure: Information about why the validation failed.
Returns:
A string description of the failure.
"""
return f"Must match regular expression {self.regex!r} (flags={self.flags})."
class Number(Validator):
"""Validator that ensures the value is a number, with an optional range check."""
def __init__(
self,
minimum: float | None = None,
maximum: float | None = None,
failure_description: str | None = None,
) -> None:
super().__init__(failure_description=failure_description)
self.minimum = minimum
"""The minimum value of the number, inclusive. If `None`, the minimum is unbounded."""
self.maximum = maximum
"""The maximum value of the number, inclusive. If `None`, the maximum is unbounded."""
class NotANumber(Failure):
"""Indicates a failure due to the value not being a valid number (decimal/integer, inc. scientific notation)"""
class NotInRange(Failure):
"""Indicates a failure due to the number not being within the range [minimum, maximum]."""
def validate(self, value: str) -> ValidationResult:
"""Ensure that `value` is a valid number, optionally within a range.
Args:
value: The value to validate.
Returns:
The result of the validation.
"""
try:
float_value = float(value)
except ValueError:
return ValidationResult.failure([Number.NotANumber(self, value)])
if float_value in {math.nan, math.inf, -math.inf}:
return ValidationResult.failure([Number.NotANumber(self, value)])
if not self._validate_range(float_value):
return ValidationResult.failure(
[Number.NotInRange(self, value)],
)
return self.success()
def _validate_range(self, value: float) -> bool:
"""Return a boolean indicating whether the number is within the range specified in the attributes."""
if self.minimum is not None and value < self.minimum:
return False
if self.maximum is not None and value > self.maximum:
return False
return True
def describe_failure(self, failure: Failure) -> str | None:
"""Describes why the validator failed.
Args:
failure: Information about why the validation failed.
Returns:
A string description of the failure.
"""
if isinstance(failure, Number.NotANumber):
return f"Must be a valid number."
elif isinstance(failure, Number.NotInRange):
if self.minimum is None and self.maximum is not None:
return f"Must be less than or equal to {self.maximum}."
elif self.minimum is not None and self.maximum is None:
return f"Must be greater than or equal to {self.minimum}."
else:
return f"Must be between {self.minimum} and {self.maximum}."
else:
return None
class Integer(Number):
"""Validator which ensures the value is an integer which falls within a range."""
class NotAnInteger(Failure):
"""Indicates a failure due to the value not being a valid integer."""
def validate(self, value: str) -> ValidationResult:
"""Ensure that `value` is an integer, optionally within a range.
Args:
value: The value to validate.
Returns:
The result of the validation.
"""
# First, check that we're dealing with a number in the range.
number_validation_result = super().validate(value)
if not number_validation_result.is_valid:
return number_validation_result
# We know it's a number, but is that number an integer?
is_integer = float(value).is_integer()
if not is_integer:
return ValidationResult.failure([Integer.NotAnInteger(self, value)])
return self.success()
def describe_failure(self, failure: Failure) -> str | None:
"""Describes why the validator failed.
Args:
failure: Information about why the validation failed.
Returns:
A string description of the failure.
"""
if isinstance(failure, Integer.NotAnInteger):
return f"Must be a valid integer."
elif isinstance(failure, Integer.NotInRange):
if self.minimum is None and self.maximum is not None:
return f"Must be less than or equal to {self.maximum}."
elif self.minimum is not None and self.maximum is None:
return f"Must be greater than or equal to {self.minimum}."
else:
return f"Must be between {self.minimum} and {self.maximum}."
else:
return None
class Length(Validator):
"""Validate that a string is within a range (inclusive)."""
def __init__(
self,
minimum: int | None = None,
maximum: int | None = None,
failure_description: str | None = None,
) -> None:
super().__init__(failure_description=failure_description)
self.minimum = minimum
"""The inclusive minimum length of the value, or None if unbounded."""
self.maximum = maximum
"""The inclusive maximum length of the value, or None if unbounded."""
class Incorrect(Failure):
"""Indicates a failure due to the length of the value being outside the range."""
def validate(self, value: str) -> ValidationResult:
"""Ensure that value falls within the maximum and minimum length constraints.
Args:
value: The value to validate.
Returns:
The result of the validation.
"""
too_short = self.minimum is not None and len(value) < self.minimum
too_long = self.maximum is not None and len(value) > self.maximum
if too_short or too_long:
return ValidationResult.failure([Length.Incorrect(self, value)])
return self.success()
def describe_failure(self, failure: Failure) -> str | None:
"""Describes why the validator failed.
Args:
failure: Information about why the validation failed.
Returns:
A string description of the failure.
"""
if isinstance(failure, Length.Incorrect):
if self.minimum is None and self.maximum is not None:
return f"Must be shorter than {self.maximum} characters."
elif self.minimum is not None and self.maximum is None:
return f"Must be longer than {self.minimum} characters."
else:
return f"Must be between {self.minimum} and {self.maximum} characters."
return None
class Function(Validator):
"""A flexible validator which allows you to provide custom validation logic."""
def __init__(
self,
function: Callable[[str], bool],
failure_description: str | None = None,
) -> None:
super().__init__(failure_description=failure_description)
self.function = function
"""Function which takes the value to validate and returns True if valid, and False otherwise."""
class ReturnedFalse(Failure):
"""Indicates validation failed because the supplied function returned False."""
def validate(self, value: str) -> ValidationResult:
"""Validate that the supplied function returns True.
Args:
value: The value to pass into the supplied function.
Returns:
A ValidationResult indicating success if the function returned True,
and failure if the function return False.
"""
is_valid = self.function(value)
if is_valid:
return self.success()
return self.failure(failures=Function.ReturnedFalse(self, value))
def describe_failure(self, failure: Failure) -> str | None:
"""Describes why the validator failed.
Args:
failure: Information about why the validation failed.
Returns:
A string description of the failure.
"""
return self.failure_description
class URL(Validator):
"""Validator that checks if a URL is valid (ensuring a scheme is present)."""
class InvalidURL(Failure):
"""Indicates that the URL is not valid."""
def validate(self, value: str) -> ValidationResult:
"""Validates that `value` is a valid URL (contains a scheme).
Args:
value: The value to validate.
Returns:
The result of the validation.
"""
invalid_url = ValidationResult.failure([URL.InvalidURL(self, value)])
try:
parsed_url = urlparse(value)
if not all([parsed_url.scheme, parsed_url.netloc]):
return invalid_url
except ValueError:
return invalid_url
return self.success()
def describe_failure(self, failure: Failure) -> str | None:
"""Describes why the validator failed.
Args:
failure: Information about why the validation failed.
Returns:
A string description of the failure.
"""
return "Must be a valid URL."

View File

@@ -2267,13 +2267,12 @@ class Widget(DOMNode):
while isinstance(widget.parent, Widget) and widget is not self:
container = widget.parent
if widget.styles.dock:
scroll_offset = Offset(0, 0)
else:
scroll_offset = container.scroll_to_region(
region,
spacing=widget.parent.gutter + widget.dock_gutter,
spacing=widget.gutter + widget.dock_gutter,
animate=animate,
speed=speed,
duration=duration,
@@ -2286,15 +2285,17 @@ class Widget(DOMNode):
# Adjust the region by the amount we just scrolled it, and convert to
# it's parent's virtual coordinate system.
region = (
(
region.translate(-scroll_offset)
.translate(-widget.scroll_offset)
.translate(container.virtual_region.offset)
.translate(container.virtual_region_with_margin.offset)
)
.grow(container.styles.margin)
.intersection(container.virtual_region)
.intersection(container.virtual_region_with_margin)
)
widget = container
return scrolled
@@ -2483,6 +2484,30 @@ class Widget(DOMNode):
force=force,
)
def can_view(self, widget: Widget) -> bool:
"""Check if a given widget is in the current view (scrollable area).
Note: This doesn't necessarily equate to a widget being visible.
There are other reasons why a widget may not be visible.
Args:
widget: A widget that is a descendant of self.
Returns:
True if the entire widget is in view, False if it is partially visible or not in view.
"""
if widget is self:
return True
region = widget.region
node: Widget = widget
while isinstance(node.parent, Widget) and node is not self:
if region not in node.parent.scrollable_content_region:
return False
node = node.parent
return True
def __init_subclass__(
cls,
can_focus: bool | None = None,
@@ -2507,11 +2532,6 @@ class Widget(DOMNode):
yield "id", self.id, None
if self.name:
yield "name", self.name
if self.classes:
yield "classes", set(self.classes)
pseudo_classes = self.pseudo_classes
if pseudo_classes:
yield "pseudo_classes", set(pseudo_classes)
def _get_scrollable_region(self, region: Region) -> Region:
"""Adjusts the Widget region to accommodate scrollbars.
@@ -2692,6 +2712,7 @@ class Widget(DOMNode):
def watch_disabled(self) -> None:
"""Update the styles of the widget and its children when disabled is toggled."""
self.blur()
self._update_styles()
def _size_updated(
@@ -2997,8 +3018,10 @@ class Widget(DOMNode):
self.app.call_later(set_focus, self)
return self
def reset_focus(self) -> Self:
"""Reset the focus (move it to the next available widget).
def blur(self) -> Self:
"""Blur (un-focus) the widget.
Focus will be moved to the next available widget in the focus chain..
Returns:
The `Widget` instance.
@@ -3152,7 +3175,7 @@ class Widget(DOMNode):
def _on_hide(self, event: events.Hide) -> None:
if self.has_focus:
self.reset_focus()
self.blur()
def _on_scroll_to_region(self, message: messages.ScrollToRegion) -> None:
self.scroll_to_region(message.region, animate=True)

View File

@@ -30,6 +30,7 @@ if typing.TYPE_CHECKING:
from ._radio_button import RadioButton
from ._radio_set import RadioSet
from ._select import Select
from ._selection_list import SelectionList
from ._static import Static
from ._switch import Switch
from ._tabbed_content import TabbedContent, TabPane
@@ -61,6 +62,7 @@ __all__ = [
"RadioButton",
"RadioSet",
"Select",
"SelectionList",
"Static",
"Switch",
"Tab",

View File

@@ -20,6 +20,7 @@ from ._progress_bar import ProgressBar as ProgressBar
from ._radio_button import RadioButton as RadioButton
from ._radio_set import RadioSet as RadioSet
from ._select import Select as Select
from ._selection_list import SelectionList as SelectionList
from ._static import Static as Static
from ._switch import Switch as Switch
from ._tabbed_content import TabbedContent as TabbedContent

View File

@@ -3,7 +3,7 @@ from __future__ import annotations
from asyncio import Queue
from dataclasses import dataclass
from pathlib import Path
from typing import ClassVar, Iterable, Iterator
from typing import Callable, ClassVar, Iterable, Iterator
from rich.style import Style
from rich.text import Text, TextType
@@ -59,6 +59,9 @@ class DirectoryTree(Tree[DirEntry]):
}
"""
PATH: Callable[[str | Path], Path] = Path
"""Callable that returns a fresh path object."""
class FileSelected(Message, bubble=True):
"""Posted when a file is selected.
@@ -66,9 +69,7 @@ class DirectoryTree(Tree[DirEntry]):
`DirectoryTree` or in a parent widget in the DOM.
"""
def __init__(
self, tree: DirectoryTree, node: TreeNode[DirEntry], path: Path
) -> None:
def __init__(self, node: TreeNode[DirEntry], path: Path) -> None:
"""Initialise the FileSelected object.
Args:
@@ -76,23 +77,17 @@ class DirectoryTree(Tree[DirEntry]):
path: The path of the file that was selected.
"""
super().__init__()
self.tree: DirectoryTree = tree
"""The `DirectoryTree` that had a file selected."""
self.node: TreeNode[DirEntry] = node
"""The tree node of the file that was selected."""
self.path: Path = path
"""The path of the file that was selected."""
@property
def control(self) -> DirectoryTree:
"""The `DirectoryTree` that had a file selected.
def control(self) -> Tree[DirEntry]:
"""The `Tree` that had a file selected."""
return self.node.tree
This is an alias for [`FileSelected.tree`][textual.widgets.DirectoryTree.FileSelected.tree]
which is used by the [`on`][textual.on] decorator.
"""
return self.tree
path: var[str | Path] = var["str | Path"](Path("."), init=False, always_update=True)
path: var[str | Path] = var["str | Path"](PATH("."), init=False, always_update=True)
"""The path that is the root of the directory tree.
Note:
@@ -121,7 +116,7 @@ class DirectoryTree(Tree[DirEntry]):
self._load_queue: Queue[TreeNode[DirEntry]] = Queue()
super().__init__(
str(path),
data=DirEntry(Path(path)),
data=DirEntry(self.PATH(path)),
name=name,
id=id,
classes=classes,
@@ -141,7 +136,7 @@ class DirectoryTree(Tree[DirEntry]):
def reload(self) -> None:
"""Reload the `DirectoryTree` contents."""
self.reset(str(self.path), DirEntry(Path(self.path)))
self.reset(str(self.path), DirEntry(self.PATH(self.path)))
# Orphan the old queue...
self._load_queue = Queue()
# ...and replace the old load with a new one.
@@ -163,7 +158,7 @@ class DirectoryTree(Tree[DirEntry]):
The result will always be a Python `Path` object, regardless of
the value given.
"""
return Path(path)
return self.PATH(path)
def watch_path(self) -> None:
"""Watch for changes to the `path` of the directory tree.
@@ -358,7 +353,7 @@ class DirectoryTree(Tree[DirEntry]):
if not dir_entry.loaded:
self._add_to_load_queue(event.node)
else:
self.post_message(self.FileSelected(self, event.node, dir_entry.path))
self.post_message(self.FileSelected(event.node, dir_entry.path))
def _on_tree_node_selected(self, event: Tree.NodeSelected) -> None:
event.stop()
@@ -366,4 +361,4 @@ class DirectoryTree(Tree[DirEntry]):
if dir_entry is None:
return
if not self._safe_is_dir(dir_entry.path):
self.post_message(self.FileSelected(self, event.node, dir_entry.path))
self.post_message(self.FileSelected(event.node, dir_entry.path))

View File

@@ -79,8 +79,7 @@ class Footer(Widget):
def _on_leave(self, _: events.Leave) -> None:
"""Clear any highlight when the mouse leaves the widget"""
if self.screen.is_current:
self.highlight_key = None
self.highlight_key = None
def __rich_repr__(self) -> rich.repr.Result:
yield from super().__rich_repr__()

View File

@@ -1,6 +1,7 @@
from __future__ import annotations
import re
from dataclasses import dataclass
from typing import ClassVar, Iterable, List, Optional
from rich.cells import cell_len, get_character_cell_size
@@ -17,6 +18,7 @@ from ..geometry import Size
from ..message import Message
from ..reactive import reactive
from ..suggester import Suggester, SuggestionReady
from ..validation import Failure, ValidationResult, Validator
from ..widget import Widget
@@ -141,6 +143,12 @@ class Input(Widget, can_focus=True):
Input>.input--placeholder, Input>.input--suggestion {
color: $text-disabled;
}
Input.-invalid {
border: tall $error 60%;
}
Input.-invalid:focus {
border: tall $error;
}
"""
cursor_blink = reactive(True)
@@ -159,42 +167,45 @@ class Input(Widget, can_focus=True):
_suggestion = reactive("")
"""A completion suggestion for the current value in the input."""
class Changed(Message, bubble=True):
@dataclass
class Changed(Message):
"""Posted when the value changes.
Can be handled using `on_input_changed` in a subclass of `Input` or in a parent
widget in the DOM.
Attributes:
value: The value that the input was changed to.
input: The `Input` widget that was changed.
"""
def __init__(self, input: Input, value: str) -> None:
super().__init__()
self.input: Input = input
self.value: str = value
input: Input
"""The `Input` widget that was changed."""
value: str
"""The value that the input was changed to."""
validation_result: ValidationResult | None = None
"""The result of validating the value (formed by combining the results from each validator), or None
if validation was not performed (for example when no validators are specified in the `Input`s init)"""
@property
def control(self) -> Input:
"""Alias for self.input."""
return self.input
class Submitted(Message, bubble=True):
@dataclass
class Submitted(Message):
"""Posted when the enter key is pressed within an `Input`.
Can be handled using `on_input_submitted` in a subclass of `Input` or in a
parent widget in the DOM.
Attributes:
value: The value of the `Input` being submitted.
input: The `Input` widget that is being submitted.
"""
def __init__(self, input: Input, value: str) -> None:
super().__init__()
self.input: Input = input
self.value: str = value
input: Input
"""The `Input` widget that is being submitted."""
value: str
"""The value of the `Input` being submitted."""
validation_result: ValidationResult | None = None
"""The result of validating the value on submission, formed by combining the results for each validator.
This value will be None if no validation was performed, which will be the case if no validators are supplied
to the corresponding `Input` widget."""
@property
def control(self) -> Input:
@@ -207,6 +218,7 @@ class Input(Widget, can_focus=True):
placeholder: str = "",
highlighter: Highlighter | None = None,
password: bool = False,
validators: Validator | Iterable[Validator] | None = None,
*,
suggester: Suggester | None = None,
name: str | None = None,
@@ -223,6 +235,7 @@ class Input(Widget, can_focus=True):
password: Flag to say if the field should obfuscate its content.
suggester: [`Suggester`][textual.suggester.Suggester] associated with this
input instance.
validators: An iterable of validators that the Input value will be checked against.
name: Optional name for the input widget.
id: Optional ID for the widget.
classes: Optional initial classes for the widget.
@@ -235,6 +248,13 @@ class Input(Widget, can_focus=True):
self.highlighter = highlighter
self.password = password
self.suggester = suggester
# Ensure we always end up with an Iterable of validators
if isinstance(validators, Validator):
self.validators: list[Validator] = [validators]
elif validators is None:
self.validators = []
else:
self.validators = list(validators) or []
def _position_to_cell(self, position: int) -> int:
"""Convert an index within the value to cell position."""
@@ -285,7 +305,36 @@ class Input(Widget, can_focus=True):
self.run_worker(self.suggester._get_suggestion(self, value))
if self.styles.auto_dimensions:
self.refresh(layout=True)
self.post_message(self.Changed(self, value))
validation_result = self.validate(value)
self.post_message(self.Changed(self, value, validation_result))
def validate(self, value: str) -> ValidationResult | None:
"""Run all the validators associated with this Input on the supplied value.
Runs all validators, combines the result into one. If any of the validators
failed, the combined result will be a failure. If no validators are present,
None will be returned. This also sets the `-invalid` CSS class on the Input
if the validation fails, and sets the `-valid` CSS class on the Input if
the validation succeeds.
Returns:
A ValidationResult indicating whether *all* validators succeeded or not.
That is, if *any* validator fails, the result will be an unsuccessful
validation.
"""
# If no validators are supplied, and therefore no validation occurs, we return None.
if not self.validators:
return None
validation_results: list[ValidationResult] = [
validator.validate(value) for validator in self.validators
]
combined_result = ValidationResult.merge(validation_results)
self.set_class(not combined_result.is_valid, "-invalid")
self.set_class(combined_result.is_valid, "-valid")
return combined_result
@property
def cursor_width(self) -> int:
@@ -478,7 +527,7 @@ class Input(Widget, can_focus=True):
self.value = self.value[: self.cursor_position]
else:
self.value = (
f"{self.value[: self.cursor_position]}{after[hit.end()-1 :]}"
f"{self.value[: self.cursor_position]}{after[hit.end() - 1:]}"
)
def action_delete_right_all(self) -> None:
@@ -530,5 +579,9 @@ class Input(Widget, can_focus=True):
self.cursor_position = 0
async def action_submit(self) -> None:
"""Handle a submit action (normally the user hitting Enter in the input)."""
self.post_message(self.Submitted(self, self.value))
"""Handle a submit action.
Normally triggered by the user pressing Enter. This will also run any validators.
"""
validation_result = self.validate(self.value)
self.post_message(self.Submitted(self, self.value, validation_result))

View File

@@ -753,7 +753,7 @@ class OptionList(ScrollView, can_focus=True):
"""Get the option with the given ID.
Args:
index: The ID of the option to get.
option_id: The ID of the option to get.
Returns:
The option at with the ID.

View File

@@ -3,10 +3,14 @@
from __future__ import annotations
from itertools import cycle
from typing import Iterator
from weakref import WeakKeyDictionary
from rich.console import RenderableType
from typing_extensions import Literal, Self
from textual.app import App
from .. import events
from ..css._error_tools import friendly_list
from ..reactive import Reactive, reactive
@@ -72,18 +76,13 @@ class Placeholder(Widget):
"""
# Consecutive placeholders get assigned consecutive colors.
_COLORS = cycle(_PLACEHOLDER_BACKGROUND_COLORS)
_COLORS: WeakKeyDictionary[App, Iterator[str]] = WeakKeyDictionary()
_SIZE_RENDER_TEMPLATE = "[b]{} x {}[/b]"
variant: Reactive[PlaceholderVariant] = reactive[PlaceholderVariant]("default")
_renderables: dict[PlaceholderVariant, str]
@classmethod
def reset_color_cycle(cls) -> None:
"""Reset the placeholder background color cycle."""
cls._COLORS = cycle(_PLACEHOLDER_BACKGROUND_COLORS)
def __init__(
self,
label: str | None = None,
@@ -113,8 +112,6 @@ class Placeholder(Widget):
super().__init__(name=name, id=id, classes=classes)
self.styles.background = f"{next(Placeholder._COLORS)} 50%"
self.variant = self.validate_variant(variant)
"""The current variant of the placeholder."""
@@ -123,6 +120,13 @@ class Placeholder(Widget):
while next(self._variants_cycle) != self.variant:
pass
def on_mount(self) -> None:
"""Set the color for this placeholder."""
colors = Placeholder._COLORS.setdefault(
self.app, cycle(_PLACEHOLDER_BACKGROUND_COLORS)
)
self.styles.background = f"{next(colors)} 50%"
def render(self) -> RenderableType:
"""Render the placeholder.

View File

@@ -16,7 +16,7 @@ class Pretty(Widget):
"""
DEFAULT_CSS = """
Static {
Pretty {
height: auto;
}
"""

View File

@@ -281,9 +281,6 @@ class ProgressBar(Widget, can_focus=False):
The percentage is a value between 0 and 1 and the returned value is only
`None` if the total progress of the bar hasn't been set yet.
In other words, after the progress bar emits the message
[`ProgressBar.Started`][textual.widgets.ProgressBar.Started],
the value of `percentage` is always not `None`.
Example:
```py

View File

@@ -9,6 +9,7 @@ from rich.text import Text
from .. import events, on
from ..app import ComposeResult
from ..containers import Horizontal, Vertical
from ..css.query import NoMatches
from ..message import Message
from ..reactive import var
from ..widgets import Static
@@ -226,20 +227,23 @@ class Select(Generic[SelectType], Vertical, can_focus=True):
"""Posted when the select value was changed.
This message can be handled using a `on_select_changed` method.
"""
def __init__(self, control: Select, value: SelectType | None) -> None:
def __init__(self, select: Select, value: SelectType | None) -> None:
"""
Initialize the Changed message.
"""
super().__init__()
self.control = control
"""The select control."""
self.select = select
"""The select widget."""
self.value = value
"""The value of the Select when it changed."""
@property
def control(self) -> Select:
"""The Select that sent the message."""
return self.select
def __init__(
self,
options: Iterable[tuple[str, SelectType]],
@@ -298,17 +302,22 @@ class Select(Generic[SelectType], Vertical, can_focus=True):
def _watch_value(self, value: SelectType | None) -> None:
"""Update the current value when it changes."""
self._value = value
if value is None:
self.query_one(SelectCurrent).update(None)
try:
select_current = self.query_one(SelectCurrent)
except NoMatches:
pass
else:
for index, (prompt, _value) in enumerate(self._options):
if _value == value:
select_overlay = self.query_one(SelectOverlay)
select_overlay.highlighted = index
self.query_one(SelectCurrent).update(prompt)
break
else:
if value is None:
self.query_one(SelectCurrent).update(None)
else:
for index, (prompt, _value) in enumerate(self._options):
if _value == value:
select_overlay = self.query_one(SelectOverlay)
select_overlay.highlighted = index
self.query_one(SelectCurrent).update(prompt)
break
else:
self.query_one(SelectCurrent).update(None)
def compose(self) -> ComposeResult:
"""Compose Select with overlay and current value."""

View File

@@ -0,0 +1,660 @@
"""Provides a selection list widget, allowing one or more items to be selected."""
from __future__ import annotations
from dataclasses import dataclass
from typing import Callable, ClassVar, Generic, Iterable, TypeVar, cast
from rich.repr import Result
from rich.segment import Segment
from rich.style import Style
from rich.text import Text, TextType
from typing_extensions import Self
from ..binding import Binding
from ..messages import Message
from ..strip import Strip
from ._option_list import NewOptionListContent, Option, OptionList
from ._toggle_button import ToggleButton
SelectionType = TypeVar("SelectionType")
"""The type for the value of a [`Selection`][textual.widgets.selection_list.Selection] in a [`SelectionList`][textual.widgets.SelectionList]"""
MessageSelectionType = TypeVar("MessageSelectionType")
"""The type for the value of a [`Selection`][textual.widgets.selection_list.Selection] in a [`SelectionList`][textual.widgets.SelectionList] message."""
class SelectionError(TypeError):
"""Type of an error raised if a selection is badly-formed."""
class Selection(Generic[SelectionType], Option):
"""A selection for a [`SelectionList`][textual.widgets.SelectionList]."""
def __init__(
self,
prompt: TextType,
value: SelectionType,
initial_state: bool = False,
id: str | None = None,
disabled: bool = False,
):
"""Initialise the selection.
Args:
prompt: The prompt for the selection.
value: The value for the selection.
initial_state: The initial selected state of the selection.
id: The optional ID for the selection.
disabled: The initial enabled/disabled state. Enabled by default.
"""
if isinstance(prompt, str):
prompt = Text.from_markup(prompt)
super().__init__(prompt.split()[0], id, disabled)
self._value: SelectionType = value
"""The value associated with the selection."""
self._initial_state: bool = initial_state
"""The initial selected state for the selection."""
@property
def value(self) -> SelectionType:
"""The value for this selection."""
return self._value
@property
def initial_state(self) -> bool:
"""The initial selected state for the selection."""
return self._initial_state
class SelectionList(Generic[SelectionType], OptionList):
"""A vertical selection list that allows making multiple selections."""
BINDINGS = [Binding("space", "select")]
"""
| Key(s) | Description |
| :- | :- |
| space | Toggle the state of the highlighted selection. |
"""
COMPONENT_CLASSES: ClassVar[set[str]] = {
"selection-list--button",
"selection-list--button-selected",
"selection-list--button-highlighted",
"selection-list--button-selected-highlighted",
}
"""
| Class | Description |
| :- | :- |
| `selection-list--button` | Target the default button style. |
| `selection-list--button-selected` | Target a selected button style. |
| `selection-list--button-highlighted` | Target a highlighted button style. |
| `selection-list--button-selected-highlighted` | Target a highlighted selected button style. |
"""
DEFAULT_CSS = """
SelectionList > .selection-list--button {
text-style: bold;
background: $foreground 15%;
}
SelectionList:focus > .selection-list--button {
text-style: bold;
background: $foreground 25%;
}
SelectionList > .selection-list--button-highlighted {
text-style: bold;
background: $foreground 15%;
}
SelectionList:focus > .selection-list--button-highlighted {
text-style: bold;
background: $foreground 25%;
}
SelectionList > .selection-list--button-selected {
text-style: bold;
background: $foreground 15%;
}
SelectionList:focus > .selection-list--button-selected {
text-style: bold;
color: $success;
background: $foreground 25%;
}
SelectionList > .selection-list--button-selected-highlighted {
text-style: bold;
color: $success;
background: $foreground 15%;
}
SelectionList:focus > .selection-list--button-selected-highlighted {
text-style: bold;
color: $success;
background: $foreground 25%;
}
"""
class SelectionMessage(Generic[MessageSelectionType], Message):
"""Base class for all selection messages."""
def __init__(self, selection_list: SelectionList, index: int) -> None:
"""Initialise the selection message.
Args:
selection_list: The selection list that owns the selection.
index: The index of the selection that the message relates to.
"""
super().__init__()
self.selection_list: SelectionList[MessageSelectionType] = selection_list
"""The selection list that sent the message."""
self.selection: Selection[
MessageSelectionType
] = selection_list.get_option_at_index(index)
"""The highlighted selection."""
self.selection_index: int = index
"""The index of the selection that the message relates to."""
@property
def control(self) -> OptionList:
"""The selection list that sent the message.
This is an alias for
[`SelectionMessage.selection_list`][textual.widgets.SelectionList.SelectionMessage.selection_list]
and is used by the [`on`][textual.on] decorator.
"""
return self.selection_list
def __rich_repr__(self) -> Result:
yield "selection_list", self.selection_list
yield "selection", self.selection
yield "selection_index", self.selection_index
class SelectionHighlighted(SelectionMessage):
"""Message sent when a selection is highlighted.
Can be handled using `on_selection_list_selection_highlighted` in a subclass of
[`SelectionList`][textual.widgets.SelectionList] or in a parent node in the DOM.
"""
class SelectionToggled(SelectionMessage):
"""Message sent when a selection is toggled.
Can be handled using `on_selection_list_selection_toggled` in a subclass of
[`SelectionList`][textual.widgets.SelectionList] or in a parent node in the DOM.
Note:
This message is only sent if the selection is toggled by user
interaction. See
[`SelectedChanged`][textual.widgets.SelectionList.SelectedChanged]
for a message sent when any change (selected or deselected,
either by user interaction or by API calls) is made to the
selected values.
"""
@dataclass
class SelectedChanged(Generic[MessageSelectionType], Message):
"""Message sent when the collection of selected values changes.
This message is sent when any change to the collection of selected
values takes place; either by user interaction or by API calls.
"""
selection_list: SelectionList[MessageSelectionType]
"""The `SelectionList` that sent the message."""
@property
def control(self) -> SelectionList[MessageSelectionType]:
"""An alias for `selection_list`."""
return self.selection_list
def __init__(
self,
*selections: Selection
| tuple[TextType, SelectionType]
| tuple[TextType, SelectionType, bool],
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
):
"""Initialise the selection list.
Args:
*selections: The content for the selection list.
name: The name of the selection list.
id: The ID of the selection list in the DOM.
classes: The CSS classes of the selection list.
disabled: Whether the selection list is disabled or not.
"""
self._selected: dict[SelectionType, None] = {}
"""Tracking of which values are selected."""
self._send_messages = False
"""Keep track of when we're ready to start sending messages."""
super().__init__(
*[self._make_selection(selection) for selection in selections],
name=name,
id=id,
classes=classes,
disabled=disabled,
)
@property
def selected(self) -> list[SelectionType]:
"""The selected values.
This is a list of all of the
[values][textual.widgets.selection_list.Selection.value] associated
with selections in the list that are currently in the selected
state.
"""
return list(self._selected.keys())
def _on_mount(self) -> None:
"""Configure the list once the DOM is ready."""
self._send_messages = True
def _message_changed(self) -> None:
"""Post a message that the selected collection has changed, where appropriate.
Note:
A message will only be sent if `_send_messages` is `True`. This
makes this safe to call before the widget is ready for posting
messages.
"""
if self._send_messages:
self.post_message(self.SelectedChanged(self))
def _apply_to_all(self, state_change: Callable[[SelectionType], bool]) -> Self:
"""Apply a selection state change to all selection options in the list.
Args:
state_change: The state change function to apply.
Returns:
The [`SelectionList`][textual.widgets.SelectionList] instance.
Note:
This method will post a single
[`SelectedChanged`][textual.widgets.OptionList.SelectedChanged]
message if a change is made in a call to this method.
"""
# Keep track of if anything changed.
changed = False
# Next we run through everything and apply the change, preventing
# the changed message because the caller really isn't going to be
# expecting a message storm from this.
with self.prevent(self.SelectedChanged):
for selection in self._options:
changed = state_change(cast(Selection, selection).value) or changed
# If the above did make a change, *then* send a message.
if changed:
self._message_changed()
self.refresh()
return self
def _select(self, value: SelectionType) -> bool:
"""Mark the given value as selected.
Args:
value: The value to mark as selected.
Returns:
`True` if the value was selected, `False` if not.
"""
if value not in self._selected:
self._selected[value] = None
self._message_changed()
return True
return False
def select(self, selection: Selection[SelectionType] | SelectionType) -> Self:
"""Mark the given selection as selected.
Args:
selection: The selection to mark as selected.
Returns:
The [`SelectionList`][textual.widgets.SelectionList] instance.
"""
if self._select(
selection.value
if isinstance(selection, Selection)
else cast(SelectionType, selection)
):
self.refresh()
return self
def select_all(self) -> Self:
"""Select all items.
Returns:
The [`SelectionList`][textual.widgets.SelectionList] instance.
"""
return self._apply_to_all(self._select)
def _deselect(self, value: SelectionType) -> bool:
"""Mark the given selection as not selected.
Args:
value: The value to mark as not selected.
Returns:
`True` if the value was deselected, `False` if not.
"""
try:
del self._selected[value]
except KeyError:
return False
self._message_changed()
return True
def deselect(self, selection: Selection[SelectionType] | SelectionType) -> Self:
"""Mark the given selection as not selected.
Args:
selection: The selection to mark as not selected.
Returns:
The [`SelectionList`][textual.widgets.SelectionList] instance.
"""
if self._deselect(
selection.value
if isinstance(selection, Selection)
else cast(SelectionType, selection)
):
self.refresh()
return self
def deselect_all(self) -> Self:
"""Deselect all items.
Returns:
The [`SelectionList`][textual.widgets.SelectionList] instance.
"""
return self._apply_to_all(self._deselect)
def _toggle(self, value: SelectionType) -> bool:
"""Toggle the selection state of the given value.
Args:
value: The value to toggle.
Returns:
`True`.
"""
if value in self._selected:
self._deselect(value)
else:
self._select(value)
return True
def toggle(self, selection: Selection[SelectionType] | SelectionType) -> Self:
"""Toggle the selected state of the given selection.
Args:
selection: The selection to toggle.
Returns:
The [`SelectionList`][textual.widgets.SelectionList] instance.
"""
self._toggle(
selection.value
if isinstance(selection, Selection)
else cast(SelectionType, selection)
)
self.refresh()
return self
def toggle_all(self) -> Self:
"""Toggle all items.
Returns:
The [`SelectionList`][textual.widgets.SelectionList] instance.
"""
return self._apply_to_all(self._toggle)
def _make_selection(
self,
selection: Selection
| tuple[TextType, SelectionType]
| tuple[TextType, SelectionType, bool],
) -> Selection[SelectionType]:
"""Turn incoming selection data into a `Selection` instance.
Args:
selection: The selection data.
Returns:
An instance of a `Selection`.
Raises:
SelectionError: If the selection was badly-formed.
"""
# If we've been given a tuple of some sort, turn that into a proper
# Selection.
if isinstance(selection, tuple):
if len(selection) == 2:
selection = cast(
"tuple[TextType, SelectionType, bool]", (*selection, False)
)
elif len(selection) != 3:
raise SelectionError(f"Expected 2 or 3 values, got {len(selection)}")
selection = Selection[SelectionType](*selection)
# At this point we should have a proper selection.
assert isinstance(selection, Selection)
# If the initial state for this is that it's selected, add it to the
# selected collection.
if selection.initial_state:
self._select(selection.value)
return selection
def _toggle_highlighted_selection(self) -> None:
"""Toggle the state of the highlighted selection.
If nothing is selected in the list this is a non-operation.
"""
if self.highlighted is not None:
self.toggle(self.get_option_at_index(self.highlighted))
def render_line(self, y: int) -> Strip:
"""Render a line in the display.
Args:
y: The line to render.
Returns:
A [`Strip`][textual.strip.Strip] that is the line to render.
"""
# First off, get the underlying prompt from OptionList.
prompt = super().render_line(y)
# If it looks like the prompt itself is actually an empty line...
if not prompt:
# ...get out with that. We don't need to do any more here.
return prompt
# We know the prompt we're going to display, what we're going to do
# is place a CheckBox-a-like button next to it. So to start with
# let's pull out the actual Selection we're looking at right now.
_, scroll_y = self.scroll_offset
selection_index = scroll_y + y
selection = self.get_option_at_index(selection_index)
# Figure out which component style is relevant for a checkbox on
# this particular line.
component_style = "selection-list--button"
if selection.value in self._selected:
component_style += "-selected"
if self.highlighted == selection_index:
component_style += "-highlighted"
# Get the underlying style used for the prompt.
underlying_style = next(iter(prompt)).style
assert underlying_style is not None
# Get the style for the button.
button_style = self.get_component_rich_style(component_style)
# If the button is in the unselected state, we're going to do a bit
# of a switcharound to make it look like it's a "cutout".
if not selection.value in self._selected:
button_style += Style.from_color(
self.background_colors[1].rich_color, button_style.bgcolor
)
# Build the style for the side characters. Note that this is
# sensitive to the type of character used, so pay attention to
# BUTTON_LEFT and BUTTON_RIGHT.
side_style = Style.from_color(button_style.bgcolor, underlying_style.bgcolor)
# At this point we should have everything we need to place a
# "button" before the option.
return Strip(
[
Segment(ToggleButton.BUTTON_LEFT, style=side_style),
Segment(ToggleButton.BUTTON_INNER, style=button_style),
Segment(ToggleButton.BUTTON_RIGHT, style=side_style),
Segment(" ", style=underlying_style),
*prompt,
]
)
def _on_option_list_option_highlighted(
self, event: OptionList.OptionHighlighted
) -> None:
"""Capture the `OptionList` highlight event and turn it into a [`SelectionList`][textual.widgets.SelectionList] event.
Args:
event: The event to capture and recreate.
"""
event.stop()
self.post_message(self.SelectionHighlighted(self, event.option_index))
def _on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
"""Capture the `OptionList` selected event and turn it into a [`SelectionList`][textual.widgets.SelectionList] event.
Args:
event: The event to capture and recreate.
"""
event.stop()
self._toggle_highlighted_selection()
self.post_message(self.SelectionToggled(self, event.option_index))
def get_option_at_index(self, index: int) -> Selection[SelectionType]:
"""Get the selection option at the given index.
Args:
index: The index of the selection option to get.
Returns:
The selection option at that index.
Raises:
OptionDoesNotExist: If there is no selection option with the index.
"""
return cast("Selection[SelectionType]", super().get_option_at_index(index))
def get_option(self, option_id: str) -> Selection[SelectionType]:
"""Get the selection option with the given ID.
Args:
index: The ID of the selection option to get.
Returns:
The selection option with the ID.
Raises:
OptionDoesNotExist: If no selection option has the given ID.
"""
return cast("Selection[SelectionType]", super().get_option(option_id))
def _remove_option(self, index: int) -> None:
"""Remove a selection option from the selection option list.
Args:
index: The index of the selection option to remove.
Raises:
IndexError: If there is no selection option of the given index.
"""
self._deselect(self.get_option_at_index(index).value)
return super()._remove_option(index)
def add_options(
self,
items: Iterable[
NewOptionListContent
| Selection
| tuple[TextType, SelectionType]
| tuple[TextType, SelectionType, bool]
],
) -> Self:
"""Add new selection options to the end of the list.
Args:
items: The new items to add.
Returns:
The [`SelectionList`][textual.widgets.SelectionList] instance.
Raises:
DuplicateID: If there is an attempt to use a duplicate ID.
SelectionError: If one of the selection options is of the wrong form.
"""
# This... is sort of sub-optimal, but a natural consequence of
# inheriting from and narrowing down OptionList. Here we don't want
# things like a separator, or a base Option, being passed in. So we
# extend the types of accepted items to keep mypy and friends happy,
# but then we runtime check that we've been given sensible types (in
# this case the supported tuple values).
cleaned_options: list[Selection] = []
for item in items:
if isinstance(item, tuple):
cleaned_options.append(
self._make_selection(
cast(
"tuple[TextType, SelectionType] | tuple[TextType, SelectionType, bool]",
item,
)
)
)
elif isinstance(item, Selection):
cleaned_options.append(self._make_selection(item))
else:
raise SelectionError(
"Only Selection or a prompt/value tuple is supported in SelectionList"
)
return super().add_options(cleaned_options)
def add_option(
self,
item: NewOptionListContent
| Selection
| tuple[TextType, SelectionType]
| tuple[TextType, SelectionType, bool] = None,
) -> Self:
"""Add a new selection option to the end of the list.
Args:
item: The new item to add.
Returns:
The [`SelectionList`][textual.widgets.SelectionList] instance.
Raises:
DuplicateID: If there is an attempt to use a duplicate ID.
SelectionError: If the selection option is of the wrong form.
"""
return self.add_options([item])

View File

@@ -183,11 +183,6 @@ class Tabs(Widget, can_focus=True):
ALLOW_SELECTOR_MATCH = {"tab"}
"""Additional message attributes that can be used with the [`on` decorator][textual.on]."""
tabs: Tabs
"""The tabs widget containing the tab."""
tab: Tab
"""The tab that was activated."""
def __init__(self, tabs: Tabs, tab: Tab) -> None:
"""Initialize event.
@@ -195,8 +190,10 @@ class Tabs(Widget, can_focus=True):
tabs: The Tabs widget.
tab: The tab that was activated.
"""
self.tabs = tabs
self.tab = tab
self.tabs: Tabs = tabs
"""The tabs widget containing the tab."""
self.tab: Tab = tab
"""The tab that was activated."""
super().__init__()
@property
@@ -215,16 +212,14 @@ class Tabs(Widget, can_focus=True):
class Cleared(Message):
"""Sent when there are no active tabs."""
tabs: Tabs
"""The tabs widget which was cleared."""
def __init__(self, tabs: Tabs) -> None:
"""Initialize the event.
Args:
tabs: The tabs widget.
"""
self.tabs = tabs
self.tabs: Tabs = tabs
"""The tabs widget which was cleared."""
super().__init__()
@property

View File

@@ -207,7 +207,7 @@ class TreeNode(Generic[TreeDataType]):
"""
self._expanded = True
self._updates += 1
self._tree.post_message(Tree.NodeExpanded(self._tree, self))
self._tree.post_message(Tree.NodeExpanded(self))
if expand_all:
for child in self.children:
child._expand(expand_all)
@@ -240,7 +240,7 @@ class TreeNode(Generic[TreeDataType]):
"""
self._expanded = False
self._updates += 1
self._tree.post_message(Tree.NodeCollapsed(self._tree, self))
self._tree.post_message(Tree.NodeCollapsed(self))
if collapse_all:
for child in self.children:
child._collapse(collapse_all)
@@ -514,23 +514,15 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
parent node in the DOM.
"""
def __init__(
self, tree: Tree[EventTreeDataType], node: TreeNode[EventTreeDataType]
) -> None:
self.tree = tree
"""The tree that sent the message."""
def __init__(self, node: TreeNode[EventTreeDataType]) -> None:
self.node: TreeNode[EventTreeDataType] = node
"""The node that was collapsed."""
super().__init__()
@property
def control(self) -> Tree[EventTreeDataType]:
"""The tree that sent the message.
This is an alias for [`NodeCollapsed.tree`][textual.widgets.Tree.NodeCollapsed.tree]
and is used by the [`on`][textual.on] decorator.
"""
return self.tree
"""The tree that sent the message."""
return self.node.tree
class NodeExpanded(Generic[EventTreeDataType], Message, bubble=True):
"""Event sent when a node is expanded.
@@ -539,23 +531,15 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
parent node in the DOM.
"""
def __init__(
self, tree: Tree[EventTreeDataType], node: TreeNode[EventTreeDataType]
) -> None:
self.tree = tree
"""The tree that sent the message."""
def __init__(self, node: TreeNode[EventTreeDataType]) -> None:
self.node: TreeNode[EventTreeDataType] = node
"""The node that was expanded."""
super().__init__()
@property
def control(self) -> Tree[EventTreeDataType]:
"""The tree that sent the message.
This is an alias for [`NodeExpanded.tree`][textual.widgets.Tree.NodeExpanded.tree]
and is used by the [`on`][textual.on] decorator.
"""
return self.tree
"""The tree that sent the message."""
return self.node.tree
class NodeHighlighted(Generic[EventTreeDataType], Message, bubble=True):
"""Event sent when a node is highlighted.
@@ -564,23 +548,15 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
parent node in the DOM.
"""
def __init__(
self, tree: Tree[EventTreeDataType], node: TreeNode[EventTreeDataType]
) -> None:
self.tree = tree
"""The tree that sent the message."""
def __init__(self, node: TreeNode[EventTreeDataType]) -> None:
self.node: TreeNode[EventTreeDataType] = node
"""The node that was highlighted."""
super().__init__()
@property
def control(self) -> Tree[EventTreeDataType]:
"""The tree that sent the message.
This is an alias for [`NodeHighlighted.tree`][textual.widgets.Tree.NodeHighlighted.tree]
and is used by the [`on`][textual.on] decorator.
"""
return self.tree
"""The tree that sent the message."""
return self.node.tree
class NodeSelected(Generic[EventTreeDataType], Message, bubble=True):
"""Event sent when a node is selected.
@@ -589,23 +565,15 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
parent node in the DOM.
"""
def __init__(
self, tree: Tree[EventTreeDataType], node: TreeNode[EventTreeDataType]
) -> None:
self.tree = tree
"""The tree that sent the message."""
def __init__(self, node: TreeNode[EventTreeDataType]) -> None:
self.node: TreeNode[EventTreeDataType] = node
"""The node that was selected."""
super().__init__()
@property
def control(self) -> Tree[EventTreeDataType]:
"""The tree that sent the message.
This is an alias for [`NodeSelected.tree`][textual.widgets.Tree.NodeSelected.tree]
and is used by the [`on`][textual.on] decorator.
"""
return self.tree
"""The tree that sent the message."""
return self.node.tree
def __init__(
self,
@@ -905,7 +873,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
node._selected = True
self._cursor_node = node
if previous_node != node:
self.post_message(self.NodeHighlighted(self, node))
self.post_message(self.NodeHighlighted(node))
else:
self._cursor_node = None
@@ -1236,7 +1204,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
Note:
If `auto_expand` is `True` use of this action on a non-leaf node
will cause both an expand/collapse event to occour, as well as a
will cause both an expand/collapse event to occur, as well as a
selected event.
"""
try:
@@ -1247,4 +1215,4 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
node = line.path[-1]
if self.auto_expand:
self._toggle_node(node)
self.post_message(self.NodeSelected(self, node))
self.post_message(self.NodeSelected(node))

View File

@@ -0,0 +1,8 @@
from ._selection_list import (
MessageSelectionType,
Selection,
SelectionError,
SelectionType,
)
__all__ = ["MessageSelectionType", "Selection", "SelectionError", "SelectionType"]

View File

@@ -0,0 +1,79 @@
from textual import on
from textual.app import App, ComposeResult
from textual.validation import Number, ValidationResult
from textual.widgets import Input
class InputApp(App):
def __init__(self):
super().__init__()
self.messages = []
self.validator = Number(minimum=1, maximum=5)
def compose(self) -> ComposeResult:
yield Input(
validators=self.validator,
)
@on(Input.Changed)
@on(Input.Submitted)
def on_changed_or_submitted(self, event):
self.messages.append(event)
async def test_input_changed_message_validation_failure():
app = InputApp()
async with app.run_test() as pilot:
input = app.query_one(Input)
input.value = "8"
await pilot.pause()
assert len(app.messages) == 1
assert app.messages[0].validation_result == ValidationResult.failure(
failures=[
Number.NotInRange(
value="8",
validator=app.validator,
description="Must be between 1 and 5.",
)
],
)
async def test_input_changed_message_validation_success():
app = InputApp()
async with app.run_test() as pilot:
input = app.query_one(Input)
input.value = "3"
await pilot.pause()
assert len(app.messages) == 1
assert app.messages[0].validation_result == ValidationResult.success()
async def test_input_submitted_message_validation_failure():
app = InputApp()
async with app.run_test() as pilot:
input = app.query_one(Input)
input.value = "8"
await input.action_submit()
await pilot.pause()
assert len(app.messages) == 2
assert app.messages[1].validation_result == ValidationResult.failure(
failures=[
Number.NotInRange(
value="8",
validator=app.validator,
description="Must be between 1 and 5.",
)
],
)
async def test_input_submitted_message_validation_success():
app = InputApp()
async with app.run_test() as pilot:
input = app.query_one(Input)
input.value = "3"
await input.action_submit()
await pilot.pause()
assert len(app.messages) == 2
assert app.messages[1].validation_result == ValidationResult.success()

View File

@@ -0,0 +1,100 @@
"""Core selection list unit tests, aimed at testing basic list creation.
Note that the vast majority of the API *isn't* tested in here as
`SelectionList` inherits from `OptionList` and so that would be duplicated
effort. Instead these tests aim to just test the things that have been
changed or wrapped in some way.
"""
from __future__ import annotations
import pytest
from rich.text import Text
from textual.app import App, ComposeResult
from textual.widgets import SelectionList
from textual.widgets.option_list import Option
from textual.widgets.selection_list import Selection, SelectionError
class SelectionListApp(App[None]):
"""Test selection list application."""
def compose(self) -> ComposeResult:
yield SelectionList[int](
("0", 0),
("1", 1, False),
("2", 2, True),
Selection("3", 3, id="3"),
Selection("4", 4, True, id="4"),
)
async def test_all_parameters_become_selctions() -> None:
"""All input parameters to a list should become selections."""
async with SelectionListApp().run_test() as pilot:
selections = pilot.app.query_one(SelectionList)
assert selections.option_count == 5
for n in range(5):
assert isinstance(selections.get_option_at_index(n), Selection)
async def test_get_selection_by_index() -> None:
"""It should be possible to get a selection by index."""
async with SelectionListApp().run_test() as pilot:
option_list = pilot.app.query_one(SelectionList)
for n in range(5):
assert option_list.get_option_at_index(n).prompt == Text(str(n))
assert option_list.get_option_at_index(-1).prompt == Text("4")
async def test_get_selection_by_id() -> None:
"""It should be possible to get a selection by ID."""
async with SelectionListApp().run_test() as pilot:
option_list = pilot.app.query_one(SelectionList)
assert option_list.get_option("3").prompt == Text("3")
assert option_list.get_option("4").prompt == Text("4")
async def test_add_later() -> None:
"""It should be possible to add more items to a selection list."""
async with SelectionListApp().run_test() as pilot:
selections = pilot.app.query_one(SelectionList)
assert selections.option_count == 5
selections.add_option(("5", 5))
assert selections.option_count == 6
selections.add_option(Selection("6", 6))
assert selections.option_count == 7
selections.add_options(
[Selection("7", 7), Selection("8", 8, True), ("9", 9), ("10", 10, True)]
)
assert selections.option_count == 11
selections.add_options([])
assert selections.option_count == 11
async def test_add_later_selcted_state() -> None:
"""When adding selections later the selected collection should get updated."""
async with SelectionListApp().run_test() as pilot:
selections = pilot.app.query_one(SelectionList)
assert selections.selected == [2, 4]
selections.add_option(("5", 5, True))
assert selections.selected == [2, 4, 5]
selections.add_option(Selection("6", 6, True))
assert selections.selected == [2, 4, 5, 6]
async def test_add_non_selections() -> None:
"""Adding options that aren't selections should result in errors."""
async with SelectionListApp().run_test() as pilot:
selections = pilot.app.query_one(SelectionList)
with pytest.raises(SelectionError):
selections.add_option(None)
with pytest.raises(SelectionError):
selections.add_option(Option("Nope"))
with pytest.raises(SelectionError):
selections.add_option("Nope")
with pytest.raises(SelectionError):
selections.add_option(("Nope",))
with pytest.raises(SelectionError):
selections.add_option(("Nope", 0, False, 23))

View File

@@ -0,0 +1,210 @@
"""Unit tests aimed at testing the selection list messages.
Note that these tests only cover a subset of the public API of this widget.
The bulk of the API is inherited from OptionList, and as such there are
comprehensive tests for that. These tests simply cover the parts of the API
that have been modified by the child class.
"""
from __future__ import annotations
from textual import on
from textual.app import App, ComposeResult
from textual.messages import Message
from textual.widgets import OptionList, SelectionList
class SelectionListApp(App[None]):
"""Test selection list application."""
def __init__(self) -> None:
super().__init__()
self.messages: list[tuple[str, int | None]] = []
def compose(self) -> ComposeResult:
yield SelectionList[int](*[(str(n), n) for n in range(10)])
@on(OptionList.OptionHighlighted)
@on(OptionList.OptionSelected)
@on(SelectionList.SelectionHighlighted)
@on(SelectionList.SelectionToggled)
@on(SelectionList.SelectedChanged)
def _record(self, event: Message) -> None:
assert event.control == self.query_one(SelectionList)
self.messages.append(
(
event.__class__.__name__,
event.selection_index
if isinstance(event, SelectionList.SelectionMessage)
else None,
)
)
async def test_messages_on_startup() -> None:
"""There should be a highlighted message when a non-empty selection list first starts up."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
assert pilot.app.messages == [("SelectionHighlighted", 0)]
async def test_new_highlight() -> None:
"""Setting the highlight to a new option should result in a message."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
pilot.app.query_one(SelectionList).highlighted = 2
await pilot.pause()
assert pilot.app.messages[1:] == [("SelectionHighlighted", 2)]
async def test_toggle() -> None:
"""Toggling an option should result in messages."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
pilot.app.query_one(SelectionList).toggle(0)
await pilot.pause()
assert pilot.app.messages == [
("SelectionHighlighted", 0),
("SelectedChanged", None),
]
async def test_toggle_via_user() -> None:
"""Toggling via the user should result in the correct messages."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.press("space")
await pilot.pause()
assert pilot.app.messages == [
("SelectionHighlighted", 0),
("SelectedChanged", None),
("SelectionToggled", 0),
]
async def test_toggle_all() -> None:
"""Toggling all options should result in messages."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
pilot.app.query_one(SelectionList).toggle_all()
await pilot.pause()
assert pilot.app.messages == [
("SelectionHighlighted", 0),
("SelectedChanged", None),
]
async def test_select() -> None:
"""Selecting all an option should result in a message."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
pilot.app.query_one(SelectionList).select(1)
await pilot.pause()
assert pilot.app.messages == [
("SelectionHighlighted", 0),
("SelectedChanged", None),
]
async def test_select_selected() -> None:
"""Selecting an option that is already selected should emit no extra message.."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
pilot.app.query_one(SelectionList).select(0)
await pilot.pause()
pilot.app.query_one(SelectionList).select(0)
await pilot.pause()
assert pilot.app.messages == [
("SelectionHighlighted", 0),
("SelectedChanged", None),
]
async def test_select_all() -> None:
"""Selecting all options should result in messages."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
pilot.app.query_one(SelectionList).select_all()
await pilot.pause()
assert pilot.app.messages == [
("SelectionHighlighted", 0),
("SelectedChanged", None),
]
async def test_select_all_selected() -> None:
"""Selecting all when all are selected should result in no extra messages."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
pilot.app.query_one(SelectionList).select_all()
await pilot.pause()
pilot.app.query_one(SelectionList).select_all()
await pilot.pause()
assert pilot.app.messages == [
("SelectionHighlighted", 0),
("SelectedChanged", None),
]
async def test_deselect() -> None:
"""Deselecting an option should result in a message."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
pilot.app.query_one(SelectionList).select(1)
await pilot.pause()
pilot.app.query_one(SelectionList).deselect(1)
await pilot.pause()
assert pilot.app.messages == [
("SelectionHighlighted", 0),
("SelectedChanged", None),
("SelectedChanged", None),
]
async def test_deselect_deselected() -> None:
"""Deselecting a deselected option should result in no extra messages."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
pilot.app.query_one(SelectionList).deselect(0)
await pilot.pause()
assert pilot.app.messages == [("SelectionHighlighted", 0)]
async def test_deselect_all() -> None:
"""Deselecting all deselected options should result in no additional messages."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
pilot.app.query_one(SelectionList).deselect_all()
await pilot.pause()
assert pilot.app.messages == [("SelectionHighlighted", 0)]
async def test_select_then_deselect_all() -> None:
"""Selecting and then deselecting all options should result in messages."""
async with SelectionListApp().run_test() as pilot:
assert isinstance(pilot.app, SelectionListApp)
await pilot.pause()
pilot.app.query_one(SelectionList).select_all()
await pilot.pause()
assert pilot.app.messages == [
("SelectionHighlighted", 0),
("SelectedChanged", None),
]
pilot.app.query_one(SelectionList).deselect_all()
await pilot.pause()
assert pilot.app.messages == [
("SelectionHighlighted", 0),
("SelectedChanged", None),
("SelectedChanged", None),
]

View File

@@ -0,0 +1,82 @@
"""Unit tests dealing with the tracking of selection list values."""
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.widgets import SelectionList
class SelectionListApp(App[None]):
def __init__(self, default_state: bool = False) -> None:
super().__init__()
self._default_state = default_state
def compose(self) -> ComposeResult:
yield SelectionList[int](*[(str(n), n, self._default_state) for n in range(50)])
async def test_empty_selected() -> None:
"""Selected should be empty when nothing is selected."""
async with SelectionListApp().run_test() as pilot:
assert pilot.app.query_one(SelectionList).selected == []
async def test_programatic_select() -> None:
"""Selected should contain a selected value."""
async with SelectionListApp().run_test() as pilot:
selection = pilot.app.query_one(SelectionList)
selection.select(0)
assert pilot.app.query_one(SelectionList).selected == [0]
async def test_programatic_select_all() -> None:
"""Selected should contain all selected values."""
async with SelectionListApp().run_test() as pilot:
selection = pilot.app.query_one(SelectionList)
selection.select_all()
assert pilot.app.query_one(SelectionList).selected == list(range(50))
async def test_programatic_deselect() -> None:
"""Selected should not contain a deselected value."""
async with SelectionListApp(True).run_test() as pilot:
selection = pilot.app.query_one(SelectionList)
selection.deselect(0)
assert pilot.app.query_one(SelectionList).selected == list(range(50)[1:])
async def test_programatic_deselect_all() -> None:
"""Selected should not contain anything after deselecting all values."""
async with SelectionListApp(True).run_test() as pilot:
selection = pilot.app.query_one(SelectionList)
selection.deselect_all()
assert pilot.app.query_one(SelectionList).selected == []
async def test_programatic_toggle() -> None:
"""Selected should reflect a toggle."""
async with SelectionListApp().run_test() as pilot:
selection = pilot.app.query_one(SelectionList)
for n in range(25, 50):
selection.select(n)
for n in range(50):
selection.toggle(n)
assert pilot.app.query_one(SelectionList).selected == list(range(50)[:25])
async def test_programatic_toggle_all() -> None:
"""Selected should contain all values after toggling all on."""
async with SelectionListApp().run_test() as pilot:
selection = pilot.app.query_one(SelectionList)
selection.toggle_all()
assert pilot.app.query_one(SelectionList).selected == list(range(50))
async def test_removal_of_selected_item() -> None:
"""Removing a selected selection should remove its value from the selected set."""
async with SelectionListApp().run_test() as pilot:
selection = pilot.app.query_one(SelectionList)
selection.toggle(0)
assert pilot.app.query_one(SelectionList).selected == [0]
selection.remove_option_at_index(0)
assert pilot.app.query_one(SelectionList).selected == []

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,20 @@
from textual.app import App, ComposeResult
from textual.widgets import Input
class BlurApp(App):
BINDINGS = [("f3", "disable")]
def compose(self) -> ComposeResult:
yield Input()
def on_ready(self) -> None:
self.query_one(Input).focus()
def action_disable(self) -> None:
self.query_one(Input).disabled = True
if __name__ == "__main__":
app = BlurApp()
app.run()

View File

@@ -0,0 +1,45 @@
from textual.app import App, ComposeResult
from textual.validation import Number
from textual.widgets import Input
VALIDATORS = [
Number(minimum=1, maximum=5),
]
class InputApp(App):
CSS = """
Input.-valid {
border: tall $success 60%;
}
Input.-valid:focus {
border: tall $success;
}
Input {
margin: 1 2;
}
"""
def compose(self) -> ComposeResult:
yield Input(
placeholder="Enter a number between 1 and 5",
validators=VALIDATORS,
)
yield Input(
placeholder="Enter a number between 1 and 5",
validators=VALIDATORS,
)
yield Input(
placeholder="Enter a number between 1 and 5",
validators=VALIDATORS,
)
yield Input(
placeholder="Enter a number between 1 and 5",
validators=VALIDATORS,
)
app = InputApp()
if __name__ == '__main__':
app.run()

View File

@@ -84,6 +84,19 @@ def test_input_and_focus(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "input.py", press=press)
def test_input_validation(snap_compare):
"""Checking that invalid styling is applied. The snapshot app itself
also adds styling for -valid which gives a green border."""
press = [
*"-2", # -2 is invalid, so -invalid should be applied
"tab",
"3", # This is valid, so -valid should be applied
"tab",
*"-2", # -2 is invalid, so -invalid should be applied (and :focus, since we stop here)
]
assert snap_compare(SNAPSHOT_APPS_DIR / "input_validation.py", press=press)
def test_input_suggestions(snap_compare):
assert snap_compare(SNAPSHOT_APPS_DIR / "input_suggestions.py", press=[])
@@ -95,7 +108,6 @@ def test_buttons_render(snap_compare):
def test_placeholder_render(snap_compare):
# Testing the rendering of the multiple placeholder variants and labels.
Placeholder.reset_color_cycle()
assert snap_compare(WIDGET_EXAMPLES_DIR / "placeholder.py")
@@ -238,6 +250,18 @@ def test_select(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "select_widget.py")
def test_selection_list_selected(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_selected.py")
def test_selection_list_selections(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_selections.py")
def test_selection_list_tuples(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_tuples.py")
def test_select_expanded(snap_compare):
assert snap_compare(
WIDGET_EXAMPLES_DIR / "select_widget.py", press=["tab", "enter"]
@@ -265,7 +289,6 @@ PATHS = [
@pytest.mark.parametrize("file_name", PATHS)
def test_css_property(file_name, snap_compare):
path_to_app = STYLES_EXAMPLES_DIR / file_name
Placeholder.reset_color_cycle()
assert snap_compare(path_to_app)
@@ -524,3 +547,11 @@ def test_select_rebuild(snap_compare):
SNAPSHOT_APPS_DIR / "select_rebuild.py",
press=["space", "escape", "tab", "enter", "tab", "space"],
)
def test_blur_on_disabled(snap_compare):
# https://github.com/Textualize/textual/issues/2641
assert snap_compare(
SNAPSHOT_APPS_DIR / "blur_on_disabled.py",
press=[*"foo", "f3", *"this should not appear"],
)

View File

@@ -1009,7 +1009,9 @@ async def test_scrolling_cursor_into_view():
table.add_column("n")
table.add_rows([(n,) for n in range(300)])
await pilot.pause()
await pilot.press("c")
await pilot.pause()
assert table.scroll_y > 100

28
tests/test_footer.py Normal file
View File

@@ -0,0 +1,28 @@
from textual.app import App, ComposeResult
from textual.geometry import Offset
from textual.screen import ModalScreen
from textual.widgets import Footer, Label
async def test_footer_highlight_when_pushing_modal():
"""Regression test for https://github.com/Textualize/textual/issues/2606"""
class MyModalScreen(ModalScreen):
def compose(self) -> ComposeResult:
yield Label("apple")
class MyApp(App[None]):
BINDINGS = [("a", "p", "push")]
def compose(self) -> ComposeResult:
yield Footer()
def action_p(self):
self.push_screen(MyModalScreen())
app = MyApp()
async with app.run_test(size=(80, 2)) as pilot:
await pilot.hover(None, Offset(0, 1))
await pilot.click(None, Offset(0, 1))
assert isinstance(app.screen, MyModalScreen)
assert app.screen_stack[0].query_one(Footer).highlight_key is None

View File

@@ -413,3 +413,51 @@ async def test_public_and_private_watch() -> None:
pilot.app.counter += 1
assert calls["private"] is True
assert calls["public"] is True
@pytest.mark.xfail(reason="https://github.com/Textualize/textual/issues/2539")
async def test_public_and_private_validate() -> None:
"""If a reactive/var has public and private validate both should get called."""
calls: dict[str, bool] = {"private": False, "public": False}
class PrivateValidateTest(App):
counter = var(0, init=False)
def validate_counter(self, _: int) -> None:
calls["public"] = True
def _validate_counter(self, _: int) -> None:
calls["private"] = True
async with PrivateValidateTest().run_test() as pilot:
assert calls["private"] is False
assert calls["public"] is False
pilot.app.counter += 1
assert calls["private"] is True
assert calls["public"] is True
@pytest.mark.xfail(reason="https://github.com/Textualize/textual/issues/2539")
async def test_public_and_private_compute() -> None:
"""If a reactive/var has public and private compute both should get called."""
calls: dict[str, bool] = {"private": False, "public": False}
class PrivateComputeTest(App):
counter = var(0, init=False)
def compute_counter(self) -> int:
calls["public"] = True
return 23
def _compute_counter(self) -> int:
calls["private"] = True
return 42
async with PrivateComputeTest().run_test() as pilot:
assert calls["private"] is False
assert calls["public"] is False
_ = pilot.app.counter
assert calls["private"] is True
assert calls["public"] is True

277
tests/test_screen_modes.py Normal file
View File

@@ -0,0 +1,277 @@
from functools import partial
from itertools import cycle
from typing import Type
import pytest
from textual.app import (
ActiveModeError,
App,
ComposeResult,
InvalidModeError,
UnknownModeError,
)
from textual.screen import ModalScreen, Screen
from textual.widgets import Footer, Header, Label, TextLog
FRUITS = cycle("apple mango strawberry banana peach pear melon watermelon".split())
class ScreenBindingsMixin(Screen[None]):
BINDINGS = [
("1", "one", "Mode 1"),
("2", "two", "Mode 2"),
("p", "push", "Push rnd scrn"),
("o", "pop_screen", "Pop"),
("r", "remove", "Remove mode 1"),
]
def action_one(self) -> None:
self.app.switch_mode("one")
def action_two(self) -> None:
self.app.switch_mode("two")
def action_fruits(self) -> None:
self.app.switch_mode("fruits")
def action_push(self) -> None:
self.app.push_screen(FruitModal())
class BaseScreen(ScreenBindingsMixin):
def __init__(self, label):
super().__init__()
self.label = label
def compose(self) -> ComposeResult:
yield Header()
yield Label(self.label)
yield Footer()
def action_remove(self) -> None:
self.app.remove_mode("one")
class FruitModal(ModalScreen[str], ScreenBindingsMixin):
BINDINGS = [("d", "dismiss_fruit", "Dismiss")]
def compose(self) -> ComposeResult:
yield Label(next(FRUITS))
class FruitsScreen(ScreenBindingsMixin):
def compose(self) -> ComposeResult:
yield TextLog()
@pytest.fixture
def ModesApp():
class ModesApp(App[None]):
MODES = {
"one": lambda: BaseScreen("one"),
"two": "screen_two",
}
SCREENS = {
"screen_two": lambda: BaseScreen("two"),
}
def on_mount(self):
self.switch_mode("one")
return ModesApp
async def test_mode_setup(ModesApp: Type[App]):
app = ModesApp()
async with app.run_test():
assert isinstance(app.screen, BaseScreen)
assert str(app.screen.query_one(Label).renderable) == "one"
async def test_switch_mode(ModesApp: Type[App]):
app = ModesApp()
async with app.run_test() as pilot:
await pilot.press("2")
assert str(app.screen.query_one(Label).renderable) == "two"
await pilot.press("1")
assert str(app.screen.query_one(Label).renderable) == "one"
async def test_switch_same_mode(ModesApp: Type[App]):
app = ModesApp()
async with app.run_test() as pilot:
await pilot.press("1")
assert str(app.screen.query_one(Label).renderable) == "one"
await pilot.press("1")
assert str(app.screen.query_one(Label).renderable) == "one"
async def test_switch_unknown_mode(ModesApp: Type[App]):
app = ModesApp()
async with app.run_test():
with pytest.raises(UnknownModeError):
app.switch_mode("unknown mode here")
async def test_remove_mode(ModesApp: Type[App]):
app = ModesApp()
async with app.run_test() as pilot:
app.switch_mode("two")
await pilot.pause()
assert str(app.screen.query_one(Label).renderable) == "two"
app.remove_mode("one")
assert "one" not in app.MODES
async def test_remove_active_mode(ModesApp: Type[App]):
app = ModesApp()
async with app.run_test():
with pytest.raises(ActiveModeError):
app.remove_mode("one")
async def test_add_mode(ModesApp: Type[App]):
app = ModesApp()
async with app.run_test() as pilot:
app.add_mode("three", BaseScreen("three"))
app.switch_mode("three")
await pilot.pause()
assert str(app.screen.query_one(Label).renderable) == "three"
async def test_add_mode_duplicated(ModesApp: Type[App]):
app = ModesApp()
async with app.run_test():
with pytest.raises(InvalidModeError):
app.add_mode("one", BaseScreen("one"))
async def test_screen_stack_preserved(ModesApp: Type[App]):
fruits = []
N = 5
app = ModesApp()
async with app.run_test() as pilot:
# Build the stack up.
for _ in range(N):
await pilot.press("p")
fruits.append(str(app.query_one(Label).renderable))
assert len(app.screen_stack) == N + 1
# Switch out and back.
await pilot.press("2")
assert len(app.screen_stack) == 1
await pilot.press("1")
# Check the stack.
assert len(app.screen_stack) == N + 1
for _ in range(N):
assert str(app.query_one(Label).renderable) == fruits.pop()
await pilot.press("o")
async def test_inactive_stack_is_alive():
"""This tests that timers in screens outside the active stack keep going."""
pings = []
class FastCounter(Screen[None]):
def compose(self) -> ComposeResult:
yield Label("fast")
def on_mount(self) -> None:
self.set_interval(0.01, self.ping)
def ping(self) -> None:
pings.append(str(self.app.query_one(Label).renderable))
def key_s(self):
self.app.switch_mode("smile")
class SmileScreen(Screen[None]):
def compose(self) -> ComposeResult:
yield Label(":)")
def key_s(self):
self.app.switch_mode("fast")
class ModesApp(App[None]):
MODES = {
"fast": FastCounter,
"smile": SmileScreen,
}
def on_mount(self) -> None:
self.switch_mode("fast")
app = ModesApp()
async with app.run_test() as pilot:
await pilot.press("s")
assert str(app.query_one(Label).renderable) == ":)"
await pilot.press("s")
assert ":)" in pings
async def test_multiple_mode_callbacks():
written = []
class LogScreen(Screen[None]):
def __init__(self, value):
super().__init__()
self.value = value
def key_p(self) -> None:
self.app.push_screen(ResultScreen(self.value), written.append)
class ResultScreen(Screen[str]):
def __init__(self, value):
super().__init__()
self.value = value
def key_p(self) -> None:
self.dismiss(self.value)
def key_f(self) -> None:
self.app.switch_mode("first")
def key_o(self) -> None:
self.app.switch_mode("other")
class ModesApp(App[None]):
MODES = {
"first": lambda: LogScreen("first"),
"other": lambda: LogScreen("other"),
}
def on_mount(self) -> None:
self.switch_mode("first")
def key_f(self) -> None:
self.switch_mode("first")
def key_o(self) -> None:
self.switch_mode("other")
app = ModesApp()
async with app.run_test() as pilot:
# Push and dismiss ResultScreen("first")
await pilot.press("p")
await pilot.press("p")
assert written == ["first"]
# Push ResultScreen("first")
await pilot.press("p")
# Switch to LogScreen("other")
await pilot.press("o")
# Push and dismiss ResultScreen("other")
await pilot.press("p")
await pilot.press("p")
assert written == ["first", "other"]
# Go back to ResultScreen("first")
await pilot.press("f")
# Dismiss ResultScreen("first")
await pilot.press("p")
assert written == ["first", "other", "first"]

View File

@@ -153,7 +153,9 @@ async def test_screens():
await app._shutdown()
async def test_auto_focus():
async def test_auto_focus_on_screen_if_app_auto_focus_is_none():
"""Setting app.AUTO_FOCUS = `None` means it is not taken into consideration."""
class MyScreen(Screen[None]):
def compose(self):
yield Button()
@@ -161,10 +163,11 @@ async def test_auto_focus():
yield Input(id="two")
class MyApp(App[None]):
pass
AUTO_FOCUS = None
app = MyApp()
async with app.run_test():
MyScreen.AUTO_FOCUS = "*"
await app.push_screen(MyScreen())
assert isinstance(app.focused, Button)
app.pop_screen()
@@ -193,6 +196,80 @@ async def test_auto_focus():
assert app.focused.id == "two"
async def test_auto_focus_on_screen_if_app_auto_focus_is_disabled():
"""Setting app.AUTO_FOCUS = `None` means it is not taken into consideration."""
class MyScreen(Screen[None]):
def compose(self):
yield Button()
yield Input(id="one")
yield Input(id="two")
class MyApp(App[None]):
AUTO_FOCUS = ""
app = MyApp()
async with app.run_test():
MyScreen.AUTO_FOCUS = "*"
await app.push_screen(MyScreen())
assert isinstance(app.focused, Button)
app.pop_screen()
MyScreen.AUTO_FOCUS = None
await app.push_screen(MyScreen())
assert app.focused is None
app.pop_screen()
MyScreen.AUTO_FOCUS = "Input"
await app.push_screen(MyScreen())
assert isinstance(app.focused, Input)
assert app.focused.id == "one"
app.pop_screen()
MyScreen.AUTO_FOCUS = "#two"
await app.push_screen(MyScreen())
assert isinstance(app.focused, Input)
assert app.focused.id == "two"
# If we push and pop another screen, focus should be preserved for #two.
MyScreen.AUTO_FOCUS = None
await app.push_screen(MyScreen())
assert app.focused is None
app.pop_screen()
assert app.focused.id == "two"
async def test_auto_focus_inheritance():
"""Setting app.AUTO_FOCUS = `None` means it is not taken into consideration."""
class MyScreen(Screen[None]):
def compose(self):
yield Button()
yield Input(id="one")
yield Input(id="two")
class MyApp(App[None]):
pass
app = MyApp()
async with app.run_test():
MyApp.AUTO_FOCUS = "Input"
MyScreen.AUTO_FOCUS = "*"
await app.push_screen(MyScreen())
assert isinstance(app.focused, Button)
app.pop_screen()
MyScreen.AUTO_FOCUS = None
await app.push_screen(MyScreen())
assert isinstance(app.focused, Input)
app.pop_screen()
MyScreen.AUTO_FOCUS = ""
await app.push_screen(MyScreen())
assert app.focused is None
app.pop_screen()
async def test_auto_focus_skips_non_focusable_widgets():
class MyScreen(Screen[None]):
def compose(self):

216
tests/test_validation.py Normal file
View File

@@ -0,0 +1,216 @@
from __future__ import annotations
import pytest
from textual.validation import (
URL,
Failure,
Function,
Integer,
Length,
Number,
Regex,
ValidationResult,
Validator,
)
VALIDATOR = Function(lambda value: True)
def test_ValidationResult_merge_successes():
results = [ValidationResult.success(), ValidationResult.success()]
assert ValidationResult.merge(results) == ValidationResult.success()
def test_ValidationResult_merge_failures():
failure_one = Failure(VALIDATOR, "1")
failure_two = Failure(VALIDATOR, "2")
results = [
ValidationResult.failure([failure_one]),
ValidationResult.failure([failure_two]),
ValidationResult.success(),
]
expected_result = ValidationResult.failure([failure_one, failure_two])
assert ValidationResult.merge(results) == expected_result
def test_ValidationResult_failure_descriptions():
result = ValidationResult.failure(
[
Failure(VALIDATOR, description="One"),
Failure(VALIDATOR, description="Two"),
Failure(VALIDATOR, description="Three"),
],
)
assert result.failure_descriptions == ["One", "Two", "Three"]
class ValidatorWithDescribeFailure(Validator):
def validate(self, value: str) -> ValidationResult:
return self.failure()
def describe_failure(self, failure: Failure) -> str | None:
return "describe_failure"
def test_Failure_description_priorities_parameter_only():
number_validator = Number(failure_description="ABC")
non_number_value = "x"
result = number_validator.validate(non_number_value)
# The inline value takes priority over the describe_failure.
assert result.failures[0].description == "ABC"
def test_Failure_description_priorities_parameter_and_describe_failure():
validator = ValidatorWithDescribeFailure(failure_description="ABC")
result = validator.validate("x")
# Even though the validator has a `describe_failure`, we've provided it
# inline and the inline value should take priority.
assert result.failures[0].description == "ABC"
def test_Failure_description_priorities_describe_failure_only():
validator = ValidatorWithDescribeFailure()
result = validator.validate("x")
assert result.failures[0].description == "describe_failure"
class ValidatorWithFailureMessageAndNoDescribe(Validator):
def validate(self, value: str) -> ValidationResult:
return self.failure(description="ABC")
def test_Failure_description_parameter_and_description_inside_validate():
validator = ValidatorWithFailureMessageAndNoDescribe()
result = validator.validate("x")
assert result.failures[0].description == "ABC"
class ValidatorWithFailureMessageAndDescribe(Validator):
def validate(self, value: str) -> ValidationResult:
return self.failure(value=value, description="ABC")
def describe_failure(self, failure: Failure) -> str | None:
return "describe_failure"
def test_Failure_description_describe_and_description_inside_validate():
# This is kind of a weird case - there's no reason to supply both of
# these but lets still make sure we're sensible about how we handle it.
validator = ValidatorWithFailureMessageAndDescribe()
result = validator.validate("x")
assert result.failures == [Failure(validator, "x", "ABC")]
@pytest.mark.parametrize(
"value, minimum, maximum, expected_result",
[
("123", None, None, True), # valid number, no range
("-123", None, None, True), # valid negative number, no range
("123.45", None, None, True), # valid float, no range
("1.23e-4", None, None, True), # valid scientific notation, no range
("abc", None, None, False), # non-numeric string, no range
("123", 100, 200, True), # valid number within range
("99", 100, 200, False), # valid number but not in range
("201", 100, 200, False), # valid number but not in range
("1.23e4", 0, 50000, True), # valid scientific notation within range
],
)
def test_Number_validate(value, minimum, maximum, expected_result):
validator = Number(minimum=minimum, maximum=maximum)
result = validator.validate(value)
assert result.is_valid == expected_result
@pytest.mark.parametrize(
"regex, value, expected_result",
[
(r"\d+", "123", True), # matches regex for one or more digits
(r"\d+", "abc", False), # does not match regex for one or more digits
(r"[a-z]+", "abc", True), # matches regex for one or more lowercase letters
(
r"[a-z]+",
"ABC",
False,
), # does not match regex for one or more lowercase letters
(r"\w+", "abc123", True), # matches regex for one or more word characters
(r"\w+", "!@#", False), # does not match regex for one or more word characters
],
)
def test_Regex_validate(regex, value, expected_result):
validator = Regex(regex)
result = validator.validate(value)
assert result.is_valid == expected_result
@pytest.mark.parametrize(
"value, minimum, maximum, expected_result",
[
("123", None, None, True), # valid integer, no range
("-123", None, None, True), # valid negative integer, no range
("123.45", None, None, False), # float, not a valid integer
("1.23e-4", None, None, False), # scientific notation, not a valid integer
("abc", None, None, False), # non-numeric string, not a valid integer
("123", 100, 200, True), # valid integer within range
("99", 100, 200, False), # valid integer but not in range
("201", 100, 200, False), # valid integer but not in range
("1.23e4", None, None, True), # valid integer in scientific notation
],
)
def test_Integer_validate(value, minimum, maximum, expected_result):
validator = Integer(minimum=minimum, maximum=maximum)
result = validator.validate(value)
assert result.is_valid == expected_result
@pytest.mark.parametrize(
"value, min_length, max_length, expected_result",
[
("", None, None, True), # empty string
("test", None, None, True), # any string with no restrictions
("test", 5, None, False), # shorter than minimum length
("test", None, 3, False), # longer than maximum length
("test", 4, 4, True), # exactly matches minimum and maximum length
("test", 2, 6, True), # within length range
],
)
def test_Length_validate(value, min_length, max_length, expected_result):
validator = Length(minimum=min_length, maximum=max_length)
result = validator.validate(value)
assert result.is_valid == expected_result
@pytest.mark.parametrize(
"value, expected_result",
[
("http://example.com", True), # valid URL
("https://example.com", True), # valid URL with https
("www.example.com", False), # missing scheme
("://example.com", False), # invalid URL (no scheme)
("https:///path", False), # missing netloc
(
"redis://username:pass[word@localhost:6379/0",
False,
), # invalid URL characters
("", False), # empty string
],
)
def test_URL_validate(value, expected_result):
validator = URL()
result = validator.validate(value)
assert result.is_valid == expected_result
@pytest.mark.parametrize(
"function, failure_description, is_valid",
[
((lambda value: True), None, True),
((lambda value: False), "failure!", False),
],
)
def test_Function_validate(function, failure_description, is_valid):
validator = Function(function, failure_description)
result = validator.validate("x")
assert result.is_valid is is_valid
if result.failure_descriptions:
assert result.failure_descriptions[0] == failure_description