mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge pull request #2652 from davep/multiselect
This commit is contained in:
@@ -10,6 +10,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
|||||||
### Added
|
### Added
|
||||||
|
|
||||||
- `work` decorator accepts `description` parameter to add debug string https://github.com/Textualize/textual/issues/2597
|
- `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
|
- `App.AUTO_FOCUS` to set auto focus on all screens https://github.com/Textualize/textual/issues/2594
|
||||||
|
|
||||||
### Changed
|
### Changed
|
||||||
|
|||||||
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()
|
||||||
@@ -197,6 +197,14 @@ Select from a number of possible options.
|
|||||||
```{.textual path="docs/examples/widgets/select_widget.py" press="tab,enter,down,down"}
|
```{.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
|
## Static
|
||||||
|
|
||||||
|
|||||||
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/radiobutton.md"
|
||||||
- "widgets/radioset.md"
|
- "widgets/radioset.md"
|
||||||
- "widgets/select.md"
|
- "widgets/select.md"
|
||||||
|
- "widgets/selection_list.md"
|
||||||
- "widgets/static.md"
|
- "widgets/static.md"
|
||||||
- "widgets/switch.md"
|
- "widgets/switch.md"
|
||||||
- "widgets/tabbed_content.md"
|
- "widgets/tabbed_content.md"
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ if typing.TYPE_CHECKING:
|
|||||||
from ._radio_button import RadioButton
|
from ._radio_button import RadioButton
|
||||||
from ._radio_set import RadioSet
|
from ._radio_set import RadioSet
|
||||||
from ._select import Select
|
from ._select import Select
|
||||||
|
from ._selection_list import SelectionList
|
||||||
from ._static import Static
|
from ._static import Static
|
||||||
from ._switch import Switch
|
from ._switch import Switch
|
||||||
from ._tabbed_content import TabbedContent, TabPane
|
from ._tabbed_content import TabbedContent, TabPane
|
||||||
@@ -61,6 +62,7 @@ __all__ = [
|
|||||||
"RadioButton",
|
"RadioButton",
|
||||||
"RadioSet",
|
"RadioSet",
|
||||||
"Select",
|
"Select",
|
||||||
|
"SelectionList",
|
||||||
"Static",
|
"Static",
|
||||||
"Switch",
|
"Switch",
|
||||||
"Tab",
|
"Tab",
|
||||||
|
|||||||
@@ -20,6 +20,7 @@ from ._progress_bar import ProgressBar as ProgressBar
|
|||||||
from ._radio_button import RadioButton as RadioButton
|
from ._radio_button import RadioButton as RadioButton
|
||||||
from ._radio_set import RadioSet as RadioSet
|
from ._radio_set import RadioSet as RadioSet
|
||||||
from ._select import Select as Select
|
from ._select import Select as Select
|
||||||
|
from ._selection_list import SelectionList as SelectionList
|
||||||
from ._static import Static as Static
|
from ._static import Static as Static
|
||||||
from ._switch import Switch as Switch
|
from ._switch import Switch as Switch
|
||||||
from ._tabbed_content import TabbedContent as TabbedContent
|
from ._tabbed_content import TabbedContent as TabbedContent
|
||||||
|
|||||||
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])
|
||||||
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"]
|
||||||
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
@@ -245,6 +245,14 @@ def test_progress_bar_completed_styled(snap_compare):
|
|||||||
def test_select(snap_compare):
|
def test_select(snap_compare):
|
||||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "select_widget.py")
|
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):
|
def test_select_expanded(snap_compare):
|
||||||
assert snap_compare(
|
assert snap_compare(
|
||||||
|
|||||||
Reference in New Issue
Block a user