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 + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SelectionListApp + + + + + + + + + + SelectionListApp + + +  Shall we play some games? ── Selected games ───────────── + [ + XFalken's Maze'secret_back_door', + XBlack Jack'a_nice_game_of_chess', + XGin Rummy'fighter_combat' + XHearts] + XBridge────────────────────────────── + XCheckers + XChess + XPoker + XFighter Combat + + + + + + ────────────────────────────── + + + + + + + + + ''' +# --- +# name: test_selection_list_selections + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SelectionListApp + + + + + + + + + + SelectionListApp + + +  Shall we play some games? ────────────────────────────────── + + XFalken's Maze + XBlack Jack + XGin Rummy + XHearts + XBridge + XCheckers + XChess + XPoker + XFighter Combat + + + + + + + ────────────────────────────────────────────────────────────── + + + + + + + + ''' +# --- +# name: test_selection_list_tuples + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + SelectionListApp + + + + + + + + + + SelectionListApp + + +  Shall we play some games? ────────────────────────────────── + + XFalken's Maze + XBlack Jack + XGin Rummy + XHearts + XBridge + XCheckers + XChess + XPoker + XFighter Combat + + + + + + + ────────────────────────────────────────────────────────────── + + + + + + + + ''' +# --- # name: test_switches ''' diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 93b3ef37cd..f960e18558 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -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(