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
|
||||
|
||||
- `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
|
||||
|
||||
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"}
|
||||
```
|
||||
|
||||
## 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
|
||||
|
||||
|
||||
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"
|
||||
|
||||
@@ -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
|
||||
|
||||
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):
|
||||
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(
|
||||
|
||||
Reference in New Issue
Block a user