mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into input-auto-completion
This commit is contained in:
39
CHANGELOG.md
39
CHANGELOG.md
@@ -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
1
docs/api/validation.md
Normal file
@@ -0,0 +1 @@
|
||||
::: textual.validation
|
||||
@@ -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()
|
||||
|
||||
72
docs/examples/widgets/input_validation.py
Normal file
72
docs/examples/widgets/input_validation.py
Normal 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()
|
||||
10
docs/examples/widgets/selection_list.css
Normal file
10
docs/examples/widgets/selection_list.css
Normal file
@@ -0,0 +1,10 @@
|
||||
Screen {
|
||||
align: center middle;
|
||||
}
|
||||
|
||||
SelectionList {
|
||||
padding: 1;
|
||||
border: solid $accent;
|
||||
width: 80%;
|
||||
height: 80%;
|
||||
}
|
||||
19
docs/examples/widgets/selection_list_selected.css
Normal file
19
docs/examples/widgets/selection_list_selected.css
Normal 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;
|
||||
}
|
||||
40
docs/examples/widgets/selection_list_selected.py
Normal file
40
docs/examples/widgets/selection_list_selected.py
Normal 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()
|
||||
29
docs/examples/widgets/selection_list_selections.py
Normal file
29
docs/examples/widgets/selection_list_selections.py
Normal 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()
|
||||
28
docs/examples/widgets/selection_list_tuples.py
Normal file
28
docs/examples/widgets/selection_list_tuples.py
Normal 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()
|
||||
@@ -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`.
|
||||
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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. |
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
171
docs/widgets/selection_list.md
Normal file
171
docs/widgets/selection_list.md
Normal 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
|
||||
@@ -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
2930
poetry.lock
generated
File diff suppressed because it is too large
Load Diff
@@ -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>"]
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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"
|
||||
)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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,
|
||||
),
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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?"""
|
||||
|
||||
@@ -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
511
src/textual/validation.py
Normal 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."
|
||||
@@ -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)
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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__()
|
||||
|
||||
@@ -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))
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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.
|
||||
|
||||
|
||||
@@ -16,7 +16,7 @@ class Pretty(Widget):
|
||||
"""
|
||||
|
||||
DEFAULT_CSS = """
|
||||
Static {
|
||||
Pretty {
|
||||
height: auto;
|
||||
}
|
||||
"""
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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."""
|
||||
|
||||
660
src/textual/widgets/_selection_list.py
Normal file
660
src/textual/widgets/_selection_list.py
Normal 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])
|
||||
@@ -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
|
||||
|
||||
@@ -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))
|
||||
|
||||
8
src/textual/widgets/selection_list.py
Normal file
8
src/textual/widgets/selection_list.py
Normal file
@@ -0,0 +1,8 @@
|
||||
from ._selection_list import (
|
||||
MessageSelectionType,
|
||||
Selection,
|
||||
SelectionError,
|
||||
SelectionType,
|
||||
)
|
||||
|
||||
__all__ = ["MessageSelectionType", "Selection", "SelectionError", "SelectionType"]
|
||||
79
tests/input/test_input_validation.py
Normal file
79
tests/input/test_input_validation.py
Normal 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()
|
||||
100
tests/selection_list/test_selection_list_create.py
Normal file
100
tests/selection_list/test_selection_list_create.py
Normal 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))
|
||||
210
tests/selection_list/test_selection_messages.py
Normal file
210
tests/selection_list/test_selection_messages.py
Normal 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),
|
||||
]
|
||||
82
tests/selection_list/test_selection_values.py
Normal file
82
tests/selection_list/test_selection_values.py
Normal 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
20
tests/snapshot_tests/snapshot_apps/blur_on_disabled.py
Normal file
20
tests/snapshot_tests/snapshot_apps/blur_on_disabled.py
Normal 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()
|
||||
45
tests/snapshot_tests/snapshot_apps/input_validation.py
Normal file
45
tests/snapshot_tests/snapshot_apps/input_validation.py
Normal 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()
|
||||
@@ -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"],
|
||||
)
|
||||
|
||||
@@ -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
28
tests/test_footer.py
Normal 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
|
||||
@@ -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
277
tests/test_screen_modes.py
Normal 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"]
|
||||
@@ -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
216
tests/test_validation.py
Normal 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
|
||||
Reference in New Issue
Block a user