diff --git a/CHANGELOG.md b/CHANGELOG.md
index c467e55012..0676b51aa4 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -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
diff --git a/docs/examples/widgets/selection_list.css b/docs/examples/widgets/selection_list.css
new file mode 100644
index 0000000000..a05e16281f
--- /dev/null
+++ b/docs/examples/widgets/selection_list.css
@@ -0,0 +1,10 @@
+Screen {
+ align: center middle;
+}
+
+SelectionList {
+ padding: 1;
+ border: solid $accent;
+ width: 80%;
+ height: 80%;
+}
diff --git a/docs/examples/widgets/selection_list_selected.css b/docs/examples/widgets/selection_list_selected.css
new file mode 100644
index 0000000000..92db41c14c
--- /dev/null
+++ b/docs/examples/widgets/selection_list_selected.css
@@ -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;
+}
diff --git a/docs/examples/widgets/selection_list_selected.py b/docs/examples/widgets/selection_list_selected.py
new file mode 100644
index 0000000000..954fb36b11
--- /dev/null
+++ b/docs/examples/widgets/selection_list_selected.py
@@ -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()
diff --git a/docs/examples/widgets/selection_list_selections.py b/docs/examples/widgets/selection_list_selections.py
new file mode 100644
index 0000000000..4a5e582a07
--- /dev/null
+++ b/docs/examples/widgets/selection_list_selections.py
@@ -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()
diff --git a/docs/examples/widgets/selection_list_tuples.py b/docs/examples/widgets/selection_list_tuples.py
new file mode 100644
index 0000000000..bff54e69cc
--- /dev/null
+++ b/docs/examples/widgets/selection_list_tuples.py
@@ -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()
diff --git a/docs/widget_gallery.md b/docs/widget_gallery.md
index 04e6fada27..15610ad964 100644
--- a/docs/widget_gallery.md
+++ b/docs/widget_gallery.md
@@ -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
diff --git a/docs/widgets/selection_list.md b/docs/widgets/selection_list.md
new file mode 100644
index 0000000000..d4b98132f4
--- /dev/null
+++ b/docs/widgets/selection_list.md
@@ -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
diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml
index f5a9ab4f18..3be2151805 100644
--- a/mkdocs-nav.yml
+++ b/mkdocs-nav.yml
@@ -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"
diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py
index 04e00e53ba..3ed23b4c2c 100644
--- a/src/textual/widgets/__init__.py
+++ b/src/textual/widgets/__init__.py
@@ -30,6 +30,7 @@
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 @@
"RadioButton",
"RadioSet",
"Select",
+ "SelectionList",
"Static",
"Switch",
"Tab",
diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi
index 27e7f4ed16..86a3985a59 100644
--- a/src/textual/widgets/__init__.pyi
+++ b/src/textual/widgets/__init__.pyi
@@ -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
diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py
new file mode 100644
index 0000000000..5a6023ad1c
--- /dev/null
+++ b/src/textual/widgets/_selection_list.py
@@ -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])
diff --git a/src/textual/widgets/selection_list.py b/src/textual/widgets/selection_list.py
new file mode 100644
index 0000000000..06209dbabf
--- /dev/null
+++ b/src/textual/widgets/selection_list.py
@@ -0,0 +1,8 @@
+from ._selection_list import (
+ MessageSelectionType,
+ Selection,
+ SelectionError,
+ SelectionType,
+)
+
+__all__ = ["MessageSelectionType", "Selection", "SelectionError", "SelectionType"]
diff --git a/tests/selection_list/test_selection_list_create.py b/tests/selection_list/test_selection_list_create.py
new file mode 100644
index 0000000000..7df871bf60
--- /dev/null
+++ b/tests/selection_list/test_selection_list_create.py
@@ -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))
diff --git a/tests/selection_list/test_selection_messages.py b/tests/selection_list/test_selection_messages.py
new file mode 100644
index 0000000000..d90f04c6d3
--- /dev/null
+++ b/tests/selection_list/test_selection_messages.py
@@ -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),
+ ]
diff --git a/tests/selection_list/test_selection_values.py b/tests/selection_list/test_selection_values.py
new file mode 100644
index 0000000000..0e2779b0ca
--- /dev/null
+++ b/tests/selection_list/test_selection_values.py
@@ -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 == []
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index 2d796a7cad..47b0e0c611 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -23685,6 +23685,497 @@
'''
# ---
+# name: test_selection_list_selected
+ '''
+
+
+ '''
+# ---
+# name: test_selection_list_selections
+ '''
+
+
+ '''
+# ---
+# name: test_selection_list_tuples
+ '''
+
+
+ '''
+# ---
# name: test_switches
'''