Merge pull request #2652 from davep/multiselect

This commit is contained in:
Dave Pearson
2023-05-25 17:10:20 +01:00
committed by GitHub
18 changed files with 1869 additions and 0 deletions

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -150,6 +150,7 @@ nav:
- "widgets/radiobutton.md" - "widgets/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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

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