From e0ac60ce03d7c54855abf850f2d477b971a55ca6 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 11 May 2023 16:19:13 +0100 Subject: [PATCH 01/96] Initial framework for the SelectionList Nothing here actually implements a selection list yet; this just sets out the very basic framework of the widget, as it inherits form OptionList. The key things here are: 1. It introduces a Selection class, which is an Option with a typed value. 2. The SelectionList is also typed and expects Selections of that type. --- src/textual/widgets/__init__.py | 2 + src/textual/widgets/__init__.pyi | 1 + src/textual/widgets/_selection_list.py | 57 ++++++++++++++++++++++++++ src/textual/widgets/selection_list.py | 3 ++ 4 files changed, 63 insertions(+) create mode 100644 src/textual/widgets/_selection_list.py create mode 100644 src/textual/widgets/selection_list.py diff --git a/src/textual/widgets/__init__.py b/src/textual/widgets/__init__.py index 04e00e53b..3ed23b4c2 100644 --- a/src/textual/widgets/__init__.py +++ b/src/textual/widgets/__init__.py @@ -30,6 +30,7 @@ if typing.TYPE_CHECKING: from ._radio_button import RadioButton from ._radio_set import RadioSet from ._select import Select + from ._selection_list import SelectionList from ._static import Static from ._switch import Switch from ._tabbed_content import TabbedContent, TabPane @@ -61,6 +62,7 @@ __all__ = [ "RadioButton", "RadioSet", "Select", + "SelectionList", "Static", "Switch", "Tab", diff --git a/src/textual/widgets/__init__.pyi b/src/textual/widgets/__init__.pyi index 27e7f4ed1..86a3985a5 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 000000000..4f1259b73 --- /dev/null +++ b/src/textual/widgets/_selection_list.py @@ -0,0 +1,57 @@ +"""Provides a selection list widget, allowing one or more items to be selected.""" + +from typing import Generic, TypeVar + +from rich.console import RenderableType + +from ._option_list import Option, OptionList + +SelectionType = TypeVar("SelectionType") +"""The type for the value of a `Selection`""" + + +class Selection(Generic[SelectionType], Option): + """A selection for the `SelectionList`.""" + + def __init__( + self, + value: SelectionType, + prompt: RenderableType, + id: str | None = None, + disabled: bool = False, + ): + """Initialise the selection. + + Args: + value: The value for the selection. + prompt: The prompt for the selection. + id: The optional ID for the selection. + disabled: The initial enabled/disabled state. Enabled by default. + """ + super().__init__(prompt, id, disabled) + self._value: SelectionType = value + + +class SelectionList(Generic[SelectionType], OptionList): + """A vertical option list that allows making multiple selections.""" + + def __init__( + self, + *selections: Selection[SelectionType], + name: str | None = None, + id: str | None = None, + classes: str | None = None, + disabled: bool = False, + ): + """Initialise the selection list. + + Args: + *content: 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. + """ + super().__init__( + *selections, name=name, id=id, classes=classes, disabled=disabled + ) diff --git a/src/textual/widgets/selection_list.py b/src/textual/widgets/selection_list.py new file mode 100644 index 000000000..3f87209cb --- /dev/null +++ b/src/textual/widgets/selection_list.py @@ -0,0 +1,3 @@ +from ._selection_list import Selection + +__all__ = ["Selection"] From 8208388cf9fa78659bd6b58cb09659873a3ed2cc Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 15 May 2023 10:13:28 +0100 Subject: [PATCH 02/96] Allow for type unions under Python 3.7 --- src/textual/widgets/_selection_list.py | 2 ++ 1 file changed, 2 insertions(+) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 4f1259b73..0b620fb67 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -1,5 +1,7 @@ """Provides a selection list widget, allowing one or more items to be selected.""" +from __future__ import annotations + from typing import Generic, TypeVar from rich.console import RenderableType From 258180c996ad2a98eab1883a492bcb1a994656c2 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 15 May 2023 10:25:30 +0100 Subject: [PATCH 03/96] Add a selected flag to the Selection --- src/textual/widgets/_selection_list.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 0b620fb67..4a3b05fc7 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -19,6 +19,7 @@ class Selection(Generic[SelectionType], Option): self, value: SelectionType, prompt: RenderableType, + selected: bool = False, id: str | None = None, disabled: bool = False, ): @@ -27,11 +28,18 @@ class Selection(Generic[SelectionType], Option): Args: value: The value for the selection. prompt: The prompt for the selection. + selected: The initial selected state for the selection. id: The optional ID for the selection. disabled: The initial enabled/disabled state. Enabled by default. """ super().__init__(prompt, id, disabled) self._value: SelectionType = value + self._selected: bool = selected + + @property + def selected(self) -> bool: + """The selected state of this selection.""" + return self._selected class SelectionList(Generic[SelectionType], OptionList): From d296fc5f044d2f5add3539be86b47489d28a9597 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 15 May 2023 11:11:04 +0100 Subject: [PATCH 04/96] Allow for passing in a selection as a tuple --- src/textual/widgets/_selection_list.py | 21 +++++++++++++++++++-- 1 file changed, 19 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 4a3b05fc7..3543a0de2 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -47,7 +47,7 @@ class SelectionList(Generic[SelectionType], OptionList): def __init__( self, - *selections: Selection[SelectionType], + *selections: Selection[SelectionType] | tuple[SelectionType, str], name: str | None = None, id: str | None = None, classes: str | None = None, @@ -63,5 +63,22 @@ class SelectionList(Generic[SelectionType], OptionList): disabled: Whether the selection list is disabled or not. """ super().__init__( - *selections, name=name, id=id, classes=classes, disabled=disabled + *[self._make_selection(selection) for selection in selections], + name=name, + id=id, + classes=classes, + disabled=disabled, ) + + def _make_selection( + self, selection: Selection[SelectionType] | tuple[SelectionType, str] + ) -> Selection[SelectionType]: + """Turn incoming selection data into a `Selection` instance. + + Args: + selection: The selection data. + + Returns: + An instance of a `Selection`. + """ + return selection if isinstance(selection, Selection) else Selection(*selection) From 0c18839c8a020956341cc91ad22dd54940be374c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 18 May 2023 13:00:23 +0100 Subject: [PATCH 05/96] WiP selection list I think I'm going to give up on basing this off OptionList. It's close enough that inheriting from it and doing more makes some sense, but it's also just far enough away that it's starting to feel like it's more work that is worthwhile and it'll be easier to hand-roll something fresh. --- src/textual/widgets/_selection_list.py | 126 ++++++++++++++++++++++--- 1 file changed, 115 insertions(+), 11 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 3543a0de2..08fb8fa57 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -2,11 +2,15 @@ from __future__ import annotations -from typing import Generic, TypeVar +from typing import ClassVar, Generic, TypeVar from rich.console import RenderableType +from rich.style import Style +from rich.text import Text, TextType +from ..binding import Binding from ._option_list import Option, OptionList +from ._toggle_button import ToggleButton SelectionType = TypeVar("SelectionType") """The type for the value of a `Selection`""" @@ -17,9 +21,9 @@ class Selection(Generic[SelectionType], Option): def __init__( self, + parent: SelectionList, value: SelectionType, - prompt: RenderableType, - selected: bool = False, + prompt: TextType, id: str | None = None, disabled: bool = False, ): @@ -28,26 +32,68 @@ class Selection(Generic[SelectionType], Option): Args: value: The value for the selection. prompt: The prompt for the selection. - selected: The initial selected state for the selection. + selected: Is this particular selection selected? id: The optional ID for the selection. disabled: The initial enabled/disabled state. Enabled by default. """ + self._prompt = prompt + self._parent = parent super().__init__(prompt, id, disabled) self._value: SelectionType = value - self._selected: bool = selected + + @property + def value(self) -> SelectionType: + """The value for this selection.""" + return self._value + + @property + def prompt(self) -> RenderableType: + return self._parent._make_label(self) @property def selected(self) -> bool: - """The selected state of this selection.""" - return self._selected + return self._value in self._parent._selected class SelectionList(Generic[SelectionType], OptionList): """A vertical option list that allows making multiple selections.""" + BINDINGS = [Binding("space, enter", "toggle"), Binding("x", "redraw")] + + COMPONENT_CLASSES: ClassVar[set[str]] = { + "selection-list--button", + "selection-list--button-selected", + } + + DEFAULT_CSS = """ + /* Base button colours (including in dark mode). */ + + SelectionList > .selection-list--button { + color: $background; + text-style: bold; + background: $foreground 15%; + } + + SelectionList:focus > .selection-list--button { + background: $foreground 25%; + background: red; + color: red; + } + + SelectionList > .selection-list--button-selected { + color: $success; + text-style: bold; + } + + SelectionList:focus > .selection-list--button-selected { + background: $foreground 25%; + } + """ + def __init__( self, - *selections: Selection[SelectionType] | tuple[SelectionType, str], + *selections: tuple[SelectionType, TextType] + | tuple[SelectionType, TextType, bool], name: str | None = None, id: str | None = None, classes: str | None = None, @@ -63,15 +109,53 @@ class SelectionList(Generic[SelectionType], OptionList): disabled: Whether the selection list is disabled or not. """ super().__init__( - *[self._make_selection(selection) for selection in selections], name=name, id=id, classes=classes, disabled=disabled, ) + self._selected: dict[SelectionType, None] = {} + self._selections = selections + + def _on_mount(self): + self.add_options( + [self._make_selection(selection) for selection in self._selections] + ) + if self.option_count: + self.highlighted = 0 + + def _make_label(self, selection: Selection) -> Text: + # Grab the button style. + button_style = self.get_component_rich_style( + f"selection-list--button{'-selected' if selection.selected else ''}" + ) + + # If the button is off, we're going to do a bit of a switcharound to + # make it look like it's a "cutout". + if not selection.selected: + button_style += Style.from_color( + self.background_colors[1].rich_color, button_style.bgcolor + ) + + # Building 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, self.background_colors[1].rich_color + ) + + return Text.assemble( + (ToggleButton.BUTTON_LEFT, side_style), + (ToggleButton.BUTTON_INNER, button_style), + (ToggleButton.BUTTON_RIGHT, side_style), + " ", + selection._prompt, + ) def _make_selection( - self, selection: Selection[SelectionType] | tuple[SelectionType, str] + self, + selection: tuple[SelectionType, TextType] + | tuple[SelectionType, TextType, bool], ) -> Selection[SelectionType]: """Turn incoming selection data into a `Selection` instance. @@ -81,4 +165,24 @@ class SelectionList(Generic[SelectionType], OptionList): Returns: An instance of a `Selection`. """ - return selection if isinstance(selection, Selection) else Selection(*selection) + if len(selection) == 3: + value, label, selected = selection + elif len(selection) == 2: + value, label, selected = (*selection, False) + else: + # TODO: Proper error. + raise TypeError("Wrong number of values for a selection.") + if selected: + self._selected[value] = None + return Selection(self, value, label) + + def action_toggle(self) -> None: + if self.highlighted is not None: + option = self.get_option_at_index(self.highlighted) + assert isinstance(option, Selection) + if option.selected: + del self._selected[option._value] + else: + self._selected[option._value] = None + self._refresh_content_tracking(force=True) + self.refresh() From 8459a8c4f9c84c9f8ada98d4837cf42eb1c22c2d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 18 May 2023 15:20:01 +0100 Subject: [PATCH 06/96] Swap to overriding render_line More experimenting with overriding OptionList, and rather than trying to swap out and around the prompt under the hood, I got to thinking that it made more sense to perhaps override render_line. So far so good... --- src/textual/widgets/_selection_list.py | 106 +++++++++++++++++++++---- 1 file changed, 90 insertions(+), 16 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 08fb8fa57..dc1bab3fc 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -4,11 +4,12 @@ from __future__ import annotations from typing import ClassVar, Generic, TypeVar -from rich.console import RenderableType +from rich.segment import Segment from rich.style import Style from rich.text import Text, TextType from ..binding import Binding +from ..strip import Strip from ._option_list import Option, OptionList from ._toggle_button import ToggleButton @@ -36,7 +37,6 @@ class Selection(Generic[SelectionType], Option): id: The optional ID for the selection. disabled: The initial enabled/disabled state. Enabled by default. """ - self._prompt = prompt self._parent = parent super().__init__(prompt, id, disabled) self._value: SelectionType = value @@ -46,10 +46,6 @@ class Selection(Generic[SelectionType], Option): """The value for this selection.""" return self._value - @property - def prompt(self) -> RenderableType: - return self._parent._make_label(self) - @property def selected(self) -> bool: return self._value in self._parent._selected @@ -58,34 +54,51 @@ class Selection(Generic[SelectionType], Option): class SelectionList(Generic[SelectionType], OptionList): """A vertical option list that allows making multiple selections.""" - BINDINGS = [Binding("space, enter", "toggle"), Binding("x", "redraw")] + BINDINGS = [Binding("space, enter", "toggle")] COMPONENT_CLASSES: ClassVar[set[str]] = { "selection-list--button", "selection-list--button-selected", + "selection-list--button-highlighted", + "selection-list--button-selected-highlighted", } DEFAULT_CSS = """ - /* Base button colours (including in dark mode). */ - SelectionList > .selection-list--button { - color: $background; text-style: bold; background: $foreground 15%; } SelectionList:focus > .selection-list--button { background: $foreground 25%; - background: red; - color: red; + } + + 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 { - color: $success; - text-style: bold; + background: $foreground 15%; } SelectionList:focus > .selection-list--button-selected { + color: $success; + background: $foreground 25%; + } + + SelectionList > .selection-list--button-selected-highlighted { + color: $success; + background: $foreground 15%; + } + + SelectionList:focus > .selection-list--button-selected-highlighted { + color: $success; background: $foreground 25%; } """ @@ -148,8 +161,6 @@ class SelectionList(Generic[SelectionType], OptionList): (ToggleButton.BUTTON_LEFT, side_style), (ToggleButton.BUTTON_INNER, button_style), (ToggleButton.BUTTON_RIGHT, side_style), - " ", - selection._prompt, ) def _make_selection( @@ -186,3 +197,66 @@ class SelectionList(Generic[SelectionType], OptionList): self._selected[option._value] = None self._refresh_content_tracking(force=True) self.refresh() + + def render_line(self, y: int) -> Strip: + """Render a line in the display. + + Args: + y: The line to render. + + Returns: + A `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) + assert isinstance(selection, Selection) + + component_style = "selection-list--button" + if selection.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 off, we're going to do a bit of a switcharound to + # make it look like it's a "cutout". + if not selection.selected: + button_style += Style.from_color( + self.background_colors[1].rich_color, button_style.bgcolor + ) + + # Building 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, + ] + ) From b63e85f81c72da8cec18251782e4a61ed62ce89e Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 18 May 2023 15:39:59 +0100 Subject: [PATCH 07/96] Remove _make_label I don't need this any more. --- src/textual/widgets/_selection_list.py | 26 -------------------------- 1 file changed, 26 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index dc1bab3fc..367d0f2ac 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -137,32 +137,6 @@ class SelectionList(Generic[SelectionType], OptionList): if self.option_count: self.highlighted = 0 - def _make_label(self, selection: Selection) -> Text: - # Grab the button style. - button_style = self.get_component_rich_style( - f"selection-list--button{'-selected' if selection.selected else ''}" - ) - - # If the button is off, we're going to do a bit of a switcharound to - # make it look like it's a "cutout". - if not selection.selected: - button_style += Style.from_color( - self.background_colors[1].rich_color, button_style.bgcolor - ) - - # Building 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, self.background_colors[1].rich_color - ) - - return Text.assemble( - (ToggleButton.BUTTON_LEFT, side_style), - (ToggleButton.BUTTON_INNER, button_style), - (ToggleButton.BUTTON_RIGHT, side_style), - ) - def _make_selection( self, selection: tuple[SelectionType, TextType] From beb3645aa1797b0335013ae4082b44e621a704a0 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 18 May 2023 15:41:14 +0100 Subject: [PATCH 08/96] Remove Selection's knowledge of its parent This isn't needed any more now that I'm doing everything in render_line. --- src/textual/widgets/_selection_list.py | 14 ++++---------- 1 file changed, 4 insertions(+), 10 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 367d0f2ac..e5a4d9005 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -22,7 +22,6 @@ class Selection(Generic[SelectionType], Option): def __init__( self, - parent: SelectionList, value: SelectionType, prompt: TextType, id: str | None = None, @@ -37,7 +36,6 @@ class Selection(Generic[SelectionType], Option): id: The optional ID for the selection. disabled: The initial enabled/disabled state. Enabled by default. """ - self._parent = parent super().__init__(prompt, id, disabled) self._value: SelectionType = value @@ -46,10 +44,6 @@ class Selection(Generic[SelectionType], Option): """The value for this selection.""" return self._value - @property - def selected(self) -> bool: - return self._value in self._parent._selected - class SelectionList(Generic[SelectionType], OptionList): """A vertical option list that allows making multiple selections.""" @@ -159,13 +153,13 @@ class SelectionList(Generic[SelectionType], OptionList): raise TypeError("Wrong number of values for a selection.") if selected: self._selected[value] = None - return Selection(self, value, label) + return Selection(value, label) def action_toggle(self) -> None: if self.highlighted is not None: option = self.get_option_at_index(self.highlighted) assert isinstance(option, Selection) - if option.selected: + if option.value in self._selected: del self._selected[option._value] else: self._selected[option._value] = None @@ -199,7 +193,7 @@ class SelectionList(Generic[SelectionType], OptionList): assert isinstance(selection, Selection) component_style = "selection-list--button" - if selection.selected: + if selection.value in self._selected: component_style += "-selected" if self.highlighted == selection_index: component_style += "-highlighted" @@ -213,7 +207,7 @@ class SelectionList(Generic[SelectionType], OptionList): # If the button is off, we're going to do a bit of a switcharound to # make it look like it's a "cutout". - if not selection.selected: + if not selection.value in self._selected: button_style += Style.from_color( self.background_colors[1].rich_color, button_style.bgcolor ) From 12416d81d187cc69c31ae14d0f7764706b2bd588 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 18 May 2023 15:42:45 +0100 Subject: [PATCH 09/96] Remove unused import of Text --- src/textual/widgets/_selection_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index e5a4d9005..fc59b3c4a 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -6,7 +6,7 @@ from typing import ClassVar, Generic, TypeVar from rich.segment import Segment from rich.style import Style -from rich.text import Text, TextType +from rich.text import TextType from ..binding import Binding from ..strip import Strip From bc126ce03677f2b80d367a5907aa71111d0f2895 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 18 May 2023 16:20:32 +0100 Subject: [PATCH 10/96] Build the selection list back in __init__ again Now that I'm no longer having to dodge issues with getting component classes before the DOM has spun up, I can go back to the simpler method of setting up the selections. This also means I can drop Mount handling. --- src/textual/widgets/_selection_list.py | 9 +-------- 1 file changed, 1 insertion(+), 8 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index fc59b3c4a..c6a79611b 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -116,20 +116,13 @@ class SelectionList(Generic[SelectionType], OptionList): disabled: Whether the selection list is disabled or not. """ super().__init__( + *[self._make_selection(selection) for selection in selections], name=name, id=id, classes=classes, disabled=disabled, ) self._selected: dict[SelectionType, None] = {} - self._selections = selections - - def _on_mount(self): - self.add_options( - [self._make_selection(selection) for selection in self._selections] - ) - if self.option_count: - self.highlighted = 0 def _make_selection( self, From 6bea9f86d3132bffa3ea0342088923568155d479 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 18 May 2023 16:22:47 +0100 Subject: [PATCH 11/96] Sprinkle bold over all the buttons At some point I should go through these styles and start to collapse/dedupe them. --- src/textual/widgets/_selection_list.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index c6a79611b..d774429df 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -64,6 +64,7 @@ class SelectionList(Generic[SelectionType], OptionList): } SelectionList:focus > .selection-list--button { + text-style: bold; background: $foreground 25%; } @@ -78,20 +79,24 @@ class SelectionList(Generic[SelectionType], OptionList): } 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%; } From c0b58321834d89badb187b7269b1bc5c2ef31f4f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 18 May 2023 16:34:30 +0100 Subject: [PATCH 12/96] Explain things a wee bit better for the future reader --- src/textual/widgets/_selection_list.py | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index d774429df..e954ea21c 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -190,6 +190,8 @@ class SelectionList(Generic[SelectionType], OptionList): selection = self.get_option_at_index(selection_index) assert isinstance(selection, Selection) + # 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" @@ -203,14 +205,14 @@ class SelectionList(Generic[SelectionType], OptionList): # Get the style for the button. button_style = self.get_component_rich_style(component_style) - # If the button is off, we're going to do a bit of a switcharound to - # make it look like it's a "cutout". + # 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 ) - # Building the style for the side characters. Note that this is + # 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) From a570b4403ed87e51ce4cf8081598045a8c09346e Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 22 May 2023 10:30:22 +0100 Subject: [PATCH 13/96] Swap the order of the prompt and value for selection items Mostly I feel it makes sense to have the value first, and the actual prompt second (based on no reason at all); but given that Select does it prompt then value, this should conform to the same approach. --- src/textual/widgets/_selection_list.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index e954ea21c..c17cf546d 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -22,16 +22,16 @@ class Selection(Generic[SelectionType], Option): def __init__( self, - value: SelectionType, prompt: TextType, + value: SelectionType, id: str | None = None, disabled: bool = False, ): """Initialise the selection. Args: - value: The value for the selection. prompt: The prompt for the selection. + value: The value for the selection. selected: Is this particular selection selected? id: The optional ID for the selection. disabled: The initial enabled/disabled state. Enabled by default. @@ -104,8 +104,8 @@ class SelectionList(Generic[SelectionType], OptionList): def __init__( self, - *selections: tuple[SelectionType, TextType] - | tuple[SelectionType, TextType, bool], + *selections: tuple[TextType, SelectionType] + | tuple[TextType, SelectionType, bool], name: str | None = None, id: str | None = None, classes: str | None = None, @@ -131,8 +131,8 @@ class SelectionList(Generic[SelectionType], OptionList): def _make_selection( self, - selection: tuple[SelectionType, TextType] - | tuple[SelectionType, TextType, bool], + selection: tuple[TextType, SelectionType] + | tuple[TextType, SelectionType, bool], ) -> Selection[SelectionType]: """Turn incoming selection data into a `Selection` instance. @@ -143,15 +143,15 @@ class SelectionList(Generic[SelectionType], OptionList): An instance of a `Selection`. """ if len(selection) == 3: - value, label, selected = selection + label, value, selected = selection elif len(selection) == 2: - value, label, selected = (*selection, False) + label, value, selected = (*selection, False) else: # TODO: Proper error. raise TypeError("Wrong number of values for a selection.") if selected: self._selected[value] = None - return Selection(value, label) + return Selection(label, value) def action_toggle(self) -> None: if self.highlighted is not None: From 4dab6d3b575c65cd1da675667bf0790af0d6671b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 22 May 2023 11:09:18 +0100 Subject: [PATCH 14/96] Start the `SelectionList` messages It would be nice to just inherit form the OptionList messages, but the naming of the properties wouldn't quite make sense, and there's also the generic typing issue too. So here I start to spin up my own messages down here. Also, as an initial use of this, grab the OptionList highlight message and turn it onto one of out own. --- src/textual/widgets/_selection_list.py | 59 +++++++++++++++++++++++++- 1 file changed, 58 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index c17cf546d..f33ceb944 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -2,19 +2,23 @@ from __future__ import annotations -from typing import ClassVar, Generic, TypeVar +from typing import ClassVar, Generic, TypeVar, cast +from rich.repr import Result from rich.segment import Segment from rich.style import Style from rich.text import TextType from ..binding import Binding +from ..messages import Message from ..strip import Strip from ._option_list import Option, OptionList from ._toggle_button import ToggleButton SelectionType = TypeVar("SelectionType") """The type for the value of a `Selection`""" +MessageSelectionType = TypeVar("MessageSelectionType") +"""The type for the value of a `SelectionList` message""" class Selection(Generic[SelectionType], Option): @@ -102,6 +106,48 @@ class SelectionList(Generic[SelectionType], OptionList): } """ + 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 = selection_list + """The option list that sent the message.""" + self.selection: Selection[MessageSelectionType] = cast( + 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 option list that sent the message. + + This is an alias for [`OptionMessage.option_list`][textual.widgets.OptionList.OptionMessage.option_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` or in a parent node in the DOM. + """ + def __init__( self, *selections: tuple[TextType, SelectionType] @@ -228,3 +274,14 @@ class SelectionList(Generic[SelectionType], OptionList): *prompt, ] ) + + def _on_option_list_option_highlighted( + self, event: OptionList.OptionHighlighted + ) -> None: + """Capture the `OptionList` highlight event and turn it into a `SelectionList` event. + + Args: + event: The event to capture and recreate. + """ + event.stop() + self.post_message(self.SelectionHighlighted(self, event.option_index)) From 41b1c08db5ed5fbf40b96d086a4434ceabd7205d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 22 May 2023 11:16:17 +0100 Subject: [PATCH 15/96] Docstring tweak --- src/textual/widgets/_selection_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index f33ceb944..00443dc27 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -50,7 +50,7 @@ class Selection(Generic[SelectionType], Option): class SelectionList(Generic[SelectionType], OptionList): - """A vertical option list that allows making multiple selections.""" + """A vertical selection list that allows making multiple selections.""" BINDINGS = [Binding("space, enter", "toggle")] From 6bc2a6ebd2fdc862c71f3c174befffe024650b4d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 22 May 2023 11:25:56 +0100 Subject: [PATCH 16/96] Add support for a selection message In doing so, change up how the toggling happens. --- src/textual/widgets/_selection_list.py | 25 +++++++++++++++++++++++-- 1 file changed, 23 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 00443dc27..855daa47f 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -52,7 +52,7 @@ class Selection(Generic[SelectionType], Option): class SelectionList(Generic[SelectionType], OptionList): """A vertical selection list that allows making multiple selections.""" - BINDINGS = [Binding("space, enter", "toggle")] + BINDINGS = [Binding("space", "select")] COMPONENT_CLASSES: ClassVar[set[str]] = { "selection-list--button", @@ -148,6 +148,13 @@ class SelectionList(Generic[SelectionType], OptionList): `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` or in a parent node in the DOM. + """ + def __init__( self, *selections: tuple[TextType, SelectionType] @@ -199,7 +206,11 @@ class SelectionList(Generic[SelectionType], OptionList): self._selected[value] = None return Selection(label, value) - def action_toggle(self) -> None: + 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: option = self.get_option_at_index(self.highlighted) assert isinstance(option, Selection) @@ -285,3 +296,13 @@ class SelectionList(Generic[SelectionType], OptionList): """ 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` event. + + Args: + event: The event to capture and recreate. + """ + event.stop() + self._toggle_highlighted_selection() + self.post_message(self.SelectionToggled(self, event.option_index)) From 424c30fcf19dfdf6b92adf3dcaac1014489e6683 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 22 May 2023 11:40:03 +0100 Subject: [PATCH 17/96] Add a method of getting at the selected values --- src/textual/widgets/_selection_list.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 855daa47f..02ec4839f 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -182,6 +182,11 @@ class SelectionList(Generic[SelectionType], OptionList): ) self._selected: dict[SelectionType, None] = {} + @property + def selected(self) -> list[SelectionType]: + """The selected values.""" + return list(self._selected.keys()) + def _make_selection( self, selection: tuple[TextType, SelectionType] From 127d93a2605e8d697b87732ed4a23b300c1a0825 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 22 May 2023 13:13:37 +0100 Subject: [PATCH 18/96] Remove a couple of annoying type errors The code worked and was fine, but pyright was getting upset at the typing. This clears that up. --- src/textual/widgets/_selection_list.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 02ec4839f..29ede5dbe 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -17,6 +17,7 @@ from ._toggle_button import ToggleButton SelectionType = TypeVar("SelectionType") """The type for the value of a `Selection`""" + MessageSelectionType = TypeVar("MessageSelectionType") """The type for the value of a `SelectionList` message""" @@ -201,9 +202,13 @@ class SelectionList(Generic[SelectionType], OptionList): An instance of a `Selection`. """ if len(selection) == 3: - label, value, selected = selection + label, value, selected = cast( + tuple[TextType, SelectionType, bool], selection + ) elif len(selection) == 2: - label, value, selected = (*selection, False) + label, value, selected = cast( + tuple[TextType, SelectionType, bool], (*selection, False) + ) else: # TODO: Proper error. raise TypeError("Wrong number of values for a selection.") From 1d925da551c144408747fdb78fa5de49b66b08f5 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 22 May 2023 13:20:49 +0100 Subject: [PATCH 19/96] Ensure selection casting works in earlier Pythons --- src/textual/widgets/_selection_list.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 29ede5dbe..928dcd6ca 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -203,11 +203,11 @@ class SelectionList(Generic[SelectionType], OptionList): """ if len(selection) == 3: label, value, selected = cast( - tuple[TextType, SelectionType, bool], selection + "tuple[TextType, SelectionType, bool]", selection ) elif len(selection) == 2: label, value, selected = cast( - tuple[TextType, SelectionType, bool], (*selection, False) + "tuple[TextType, SelectionType, bool]", (*selection, False) ) else: # TODO: Proper error. From dae0cd7c02516de15420b91a3b939b77355596cf Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 22 May 2023 13:21:25 +0100 Subject: [PATCH 20/96] Raise a widget-specific exception when given a bad option --- src/textual/widgets/_selection_list.py | 10 ++++++++-- src/textual/widgets/selection_list.py | 4 ++-- 2 files changed, 10 insertions(+), 4 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 928dcd6ca..75ddb0634 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -22,6 +22,10 @@ MessageSelectionType = TypeVar("MessageSelectionType") """The type for the value of a `SelectionList` message""" +class SelectionError(TypeError): + """Type of an error raised if a selection is badly-formed.""" + + class Selection(Generic[SelectionType], Option): """A selection for the `SelectionList`.""" @@ -200,6 +204,9 @@ class SelectionList(Generic[SelectionType], OptionList): Returns: An instance of a `Selection`. + + Raises: + SelectionError: If the selection was badly-formed. """ if len(selection) == 3: label, value, selected = cast( @@ -210,8 +217,7 @@ class SelectionList(Generic[SelectionType], OptionList): "tuple[TextType, SelectionType, bool]", (*selection, False) ) else: - # TODO: Proper error. - raise TypeError("Wrong number of values for a selection.") + raise SelectionError(f"Expected 2 or 3 values, got {len(selection)}") if selected: self._selected[value] = None return Selection(label, value) diff --git a/src/textual/widgets/selection_list.py b/src/textual/widgets/selection_list.py index 3f87209cb..56a8caa18 100644 --- a/src/textual/widgets/selection_list.py +++ b/src/textual/widgets/selection_list.py @@ -1,3 +1,3 @@ -from ._selection_list import Selection +from ._selection_list import Selection, SelectionError -__all__ = ["Selection"] +__all__ = ["Selection", "SelectionError"] From 07515e22c8872557136cc1e8965eb3533c704736 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 22 May 2023 14:01:09 +0100 Subject: [PATCH 21/96] Add an interface for changing selections from code --- src/textual/widgets/_selection_list.py | 82 +++++++++++++++++++++++--- 1 file changed, 75 insertions(+), 7 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 75ddb0634..ac8b3beee 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -8,6 +8,7 @@ from rich.repr import Result from rich.segment import Segment from rich.style import Style from rich.text import TextType +from typing_extensions import Self from ..binding import Binding from ..messages import Message @@ -192,6 +193,78 @@ class SelectionList(Generic[SelectionType], OptionList): """The selected values.""" return list(self._selected.keys()) + def _select(self, value: SelectionType) -> None: + """Mark the given value as selected. + + Args: + value: The value to mark as selected. + """ + self._selected[value] = None + + def select(self, selection: Selection[SelectionType] | SelectionType) -> Self: + """Mark the given selection as selected. + + Args: + selection: The selection to mark as selected. + """ + self._select( + selection.value + if isinstance(selection, Selection) + else cast(SelectionType, selection) + ) + self.refresh() + return self + + def _deselect(self, value: SelectionType) -> None: + """Mark the given selection as not selected. + + Args: + value: The value to mark as not selected. + """ + try: + del self._selected[value] + except KeyError: + pass + + def deselect(self, selection: Selection[SelectionType] | SelectionType) -> Self: + """Mark the given selection as not selected. + + Args: + selection: The selection to mark as selected. + """ + self._deselect( + selection.value + if isinstance(selection, Selection) + else cast(SelectionType, selection) + ) + self.refresh() + return self + + def _toggle(self, value: SelectionType) -> None: + """Toggle the selection state of the given value. + + Args: + value: The value to toggle. + """ + if value in self._selected: + self._deselect(value) + else: + self._select(value) + + def toggle(self, selection: Selection[SelectionType] | SelectionType) -> Self: + """Toggle the selected state of the given selection. + + Args: + selection: The selection to toggle. + """ + self._toggle( + selection.value + if isinstance(selection, Selection) + else cast(SelectionType, selection) + ) + self.refresh() + return self + def _make_selection( self, selection: tuple[TextType, SelectionType] @@ -219,7 +292,7 @@ class SelectionList(Generic[SelectionType], OptionList): else: raise SelectionError(f"Expected 2 or 3 values, got {len(selection)}") if selected: - self._selected[value] = None + self._select(value) return Selection(label, value) def _toggle_highlighted_selection(self) -> None: @@ -230,12 +303,7 @@ class SelectionList(Generic[SelectionType], OptionList): if self.highlighted is not None: option = self.get_option_at_index(self.highlighted) assert isinstance(option, Selection) - if option.value in self._selected: - del self._selected[option._value] - else: - self._selected[option._value] = None - self._refresh_content_tracking(force=True) - self.refresh() + self.toggle(option) def render_line(self, y: int) -> Strip: """Render a line in the display. From 51d1dade5c32a614e0b7e50a289ad6e0af5c2d36 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 22 May 2023 14:18:20 +0100 Subject: [PATCH 22/96] Ensure access to options is actually access to selections --- src/textual/widgets/_selection_list.py | 18 ++++++++++-------- 1 file changed, 10 insertions(+), 8 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index ac8b3beee..98c95fe4e 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -125,10 +125,9 @@ class SelectionList(Generic[SelectionType], OptionList): super().__init__() self.selection_list: SelectionList = selection_list """The option list that sent the message.""" - self.selection: Selection[MessageSelectionType] = cast( - Selection[MessageSelectionType], - selection_list.get_option_at_index(index), - ) + 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.""" @@ -301,9 +300,7 @@ class SelectionList(Generic[SelectionType], OptionList): If nothing is selected in the list this is a non-operation. """ if self.highlighted is not None: - option = self.get_option_at_index(self.highlighted) - assert isinstance(option, Selection) - self.toggle(option) + self.toggle(self.get_option_at_index(self.highlighted)) def render_line(self, y: int) -> Strip: """Render a line in the display. @@ -329,7 +326,6 @@ class SelectionList(Generic[SelectionType], OptionList): _, scroll_y = self.scroll_offset selection_index = scroll_y + y selection = self.get_option_at_index(selection_index) - assert isinstance(selection, Selection) # Figure out which component style is relevant for a checkbox on # this particular line. @@ -390,3 +386,9 @@ class SelectionList(Generic[SelectionType], OptionList): 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]: + return cast("Selection[SelectionType]", super().get_option_at_index(index)) + + def get_option(self, option_id: str) -> Selection[SelectionType]: + return cast("Selection[SelectionType]", super().get_option(option_id)) From 13e796bfea55c557669ca8eb12d2a365dbf7ea78 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 22 May 2023 14:56:21 +0100 Subject: [PATCH 23/96] Ensure selections are only one line in length --- src/textual/widgets/_selection_list.py | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 98c95fe4e..0bfba439d 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -7,7 +7,7 @@ from typing import ClassVar, Generic, TypeVar, cast from rich.repr import Result from rich.segment import Segment from rich.style import Style -from rich.text import TextType +from rich.text import Text, TextType from typing_extensions import Self from ..binding import Binding @@ -46,7 +46,9 @@ class Selection(Generic[SelectionType], Option): id: The optional ID for the selection. disabled: The initial enabled/disabled state. Enabled by default. """ - super().__init__(prompt, id, disabled) + if isinstance(prompt, str): + prompt = Text.from_markup(prompt) + super().__init__(prompt.split()[0], id, disabled) self._value: SelectionType = value @property From a25ef78a7f3c81e3c5d4fbb4b847d41765509067 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 22 May 2023 15:02:32 +0100 Subject: [PATCH 24/96] Fully hint the type of the selection list in mesages --- src/textual/widgets/_selection_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 0bfba439d..449feeab5 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -125,7 +125,7 @@ class SelectionList(Generic[SelectionType], OptionList): index: The index of the selection that the message relates to. """ super().__init__() - self.selection_list: SelectionList = selection_list + self.selection_list: SelectionList[MessageSelectionType] = selection_list """The option list that sent the message.""" self.selection: Selection[ MessageSelectionType From 189181ba33179fcf1edaf5df3d67b9af9db54b60 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 22 May 2023 15:23:11 +0100 Subject: [PATCH 25/96] Add support for sending a message when the selection list changes The developer using this may wish to react to UI changes being made, but they may also want to just know when the collection of selected values has changed -- this could happen via code so won't get any UI/IO messages. So this adds a message that is always sent when a change to the collection of selected values happens. --- src/textual/widgets/_selection_list.py | 18 +++++++++++++++++- 1 file changed, 17 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 449feeab5..427d0eda6 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -2,6 +2,7 @@ from __future__ import annotations +from dataclasses import dataclass from typing import ClassVar, Generic, TypeVar, cast from rich.repr import Result @@ -162,6 +163,18 @@ class SelectionList(Generic[SelectionType], OptionList): `SelectionList` or in a parent node in the DOM. """ + @dataclass + class SelectedChanged(Generic[MessageSelectionType], Message): + """Message sent when the collection of selected values changes.""" + + 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: tuple[TextType, SelectionType] @@ -200,7 +213,9 @@ class SelectionList(Generic[SelectionType], OptionList): Args: value: The value to mark as selected. """ - self._selected[value] = None + if value not in self._selected: + self._selected[value] = None + self.post_message(self.SelectedChanged(self)) def select(self, selection: Selection[SelectionType] | SelectionType) -> Self: """Mark the given selection as selected. @@ -224,6 +239,7 @@ class SelectionList(Generic[SelectionType], OptionList): """ try: del self._selected[value] + self.post_message(self.SelectedChanged(self)) except KeyError: pass From 68250e6ce3dbe73e0b4b6b4392638df416a60c70 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 22 May 2023 15:25:11 +0100 Subject: [PATCH 26/96] Override _remove_option to update the selected values The developer could remove an option that is selected, so we need to catch that this has happened and update the collection of selected values. --- src/textual/widgets/_selection_list.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 427d0eda6..65032a012 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -410,3 +410,7 @@ class SelectionList(Generic[SelectionType], OptionList): def get_option(self, option_id: str) -> Selection[SelectionType]: return cast("Selection[SelectionType]", super().get_option(option_id)) + + def _remove_option(self, index: int) -> None: + self._deselect(self.get_option_at_index(index).value) + return super()._remove_option(index) From f530efda2a8fe07518c396b401c32d72fda9cc76 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Mon, 22 May 2023 15:58:08 +0100 Subject: [PATCH 27/96] Extend SelectionList.add_options to better support the selection list --- src/textual/widgets/_selection_list.py | 38 ++++++++++++++++++++++++-- 1 file changed, 36 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 65032a012..c266bfb0b 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import ClassVar, Generic, TypeVar, cast +from typing import ClassVar, Generic, Iterable, TypeVar, cast from rich.repr import Result from rich.segment import Segment @@ -14,7 +14,7 @@ from typing_extensions import Self from ..binding import Binding from ..messages import Message from ..strip import Strip -from ._option_list import Option, OptionList +from ._option_list import NewOptionListContent, Option, OptionList from ._toggle_button import ToggleButton SelectionType = TypeVar("SelectionType") @@ -414,3 +414,37 @@ class SelectionList(Generic[SelectionType], OptionList): def _remove_option(self, index: int) -> None: 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: + # 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(item) + else: + raise SelectionError( + "Only Selection or a prompt/value tuple is supported in SelectionList" + ) + return super().add_options(cleaned_options) From 9e6bf085b88940f8695c6873a31a323ccb30ea2c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 May 2023 09:24:29 +0100 Subject: [PATCH 28/96] Extend add_option so that it accepts selections and selection tuples --- src/textual/widgets/_selection_list.py | 9 +++++++++ 1 file changed, 9 insertions(+) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index c266bfb0b..be8d80261 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -448,3 +448,12 @@ class SelectionList(Generic[SelectionType], OptionList): "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: + return self.add_options([item]) From ca07d7a58d459fea6d0e701aa35a6d9c8481669d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 May 2023 09:47:45 +0100 Subject: [PATCH 29/96] Fill in the blanks with docstrings Adding back docstrings from overriding methods. Initially I was thinking it made sense to keep them empty, allowing for any inheriting of the docs (if/when our documentation generation system does that); but in most cases there's a subtle difference in what's supported in terms of parameters or return values so it makes sense to tweak the docs a wee bit. --- src/textual/widgets/_selection_list.py | 64 ++++++++++++++++++++++++++ 1 file changed, 64 insertions(+) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index be8d80261..71acc414e 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -406,12 +406,42 @@ class SelectionList(Generic[SelectionType], OptionList): 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 at 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) @@ -424,6 +454,23 @@ class SelectionList(Generic[SelectionType], OptionList): | 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` 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. + + Note: + Any new selection option added should either be an instance of + `Option`, or should be a `tuple` of prompt and value, or prompt, + value and selected state. + """ # 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 @@ -456,4 +503,21 @@ class SelectionList(Generic[SelectionType], OptionList): | 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` instance. + + Raises: + DuplicateID: If there is an attempt to use a duplicate ID. + SelectionError: If the selection option is of the wrong form. + + Note: + Any new selection option added should either be an instance of + `Option`, or should be a `tuple` of prompt and value, or prompt, + value and selected state. + """ return self.add_options([item]) From 195e9b4021948f3d12350f8ca1f3527ebe3e9ade Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 May 2023 09:50:44 +0100 Subject: [PATCH 30/96] Add the docstring for the bindings --- src/textual/widgets/_selection_list.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 71acc414e..99e86d136 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -62,6 +62,11 @@ 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", From 4c9afca8fdc2d16f04825407304e0b8d8da82d8d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 May 2023 10:02:07 +0100 Subject: [PATCH 31/96] Add a docstring for the component classes. --- src/textual/widgets/_selection_list.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 99e86d136..5f42d4e3b 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -74,6 +74,14 @@ class SelectionList(Generic[SelectionType], OptionList): "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 { From a2fc3fad432aaf4416fd89ffe0ea2934d6b09358 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 May 2023 10:19:20 +0100 Subject: [PATCH 32/96] Add a method to apply a state change to all selection options --- src/textual/widgets/_selection_list.py | 30 +++++++++++++++++++++++++- 1 file changed, 29 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 5f42d4e3b..018a8fdfd 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -3,7 +3,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import ClassVar, Generic, Iterable, TypeVar, cast +from typing import Callable, ClassVar, Generic, Iterable, TypeVar, cast from rich.repr import Result from rich.segment import Segment @@ -244,6 +244,34 @@ class SelectionList(Generic[SelectionType], OptionList): self.refresh() return self + def _apply_to_all(self, state_change: Callable[[SelectionType], None]) -> 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` instance. + """ + + # We're only going to signal a change in the selections if there is + # any actual change; so let's count how many are selected now. + selected_count = len(self.selected) + + # 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: + state_change(cast(Selection, selection).value) + + # If the above did make a change, *then* send a message. + if len(self.selected) != selected_count: + self.post_message(self.SelectedChanged(self)) + + self.refresh() + return self + def _deselect(self, value: SelectionType) -> None: """Mark the given selection as not selected. From 3ce04c814a900fa7ee2d2835836832f80778b61c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 May 2023 10:20:00 +0100 Subject: [PATCH 33/96] Add a method of selecting all selection options --- src/textual/widgets/_selection_list.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 018a8fdfd..846d0efe0 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -272,6 +272,10 @@ class SelectionList(Generic[SelectionType], OptionList): self.refresh() return self + def select_all(self) -> Self: + """Select all items.""" + return self._apply_to_all(self._select) + def _deselect(self, value: SelectionType) -> None: """Mark the given selection as not selected. From a4148d028b8e2f9ae15cf31a6e2731dde8ea917c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 May 2023 10:22:01 +0100 Subject: [PATCH 34/96] Add a method for deselecting all options --- src/textual/widgets/_selection_list.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 846d0efe0..f0e4e37b0 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -302,6 +302,10 @@ class SelectionList(Generic[SelectionType], OptionList): self.refresh() return self + def deselect_all(self) -> Self: + """Deselect all items.""" + return self._apply_to_all(self._deselect) + def _toggle(self, value: SelectionType) -> None: """Toggle the selection state of the given value. From db273ea9ffa58afa60a648ec568933cda8cf6c9f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 May 2023 10:26:16 +0100 Subject: [PATCH 35/96] Add a method for toggling all options --- src/textual/widgets/_selection_list.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index f0e4e37b0..ea1e8e927 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -331,6 +331,10 @@ class SelectionList(Generic[SelectionType], OptionList): self.refresh() return self + def toggle_all(self) -> Self: + """Toggle all items.""" + return self._apply_to_all(self._toggle) + def _make_selection( self, selection: tuple[TextType, SelectionType] From d861cced97d46b609c59167609c93e77e6630f63 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 May 2023 10:33:39 +0100 Subject: [PATCH 36/96] Improve how the _all methods work Deciding if there was a change when turning all on or off by looking at the before an after counts was fine, but it's not a sensible way of seeing if there was a change during a toggle. So this swaps things up a bit and has the core selection changing methods return a flag to say if they actually made a change or not. --- src/textual/widgets/_selection_list.py | 80 +++++++++++++++----------- 1 file changed, 46 insertions(+), 34 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index ea1e8e927..8e3ba4688 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -220,31 +220,7 @@ class SelectionList(Generic[SelectionType], OptionList): """The selected values.""" return list(self._selected.keys()) - def _select(self, value: SelectionType) -> None: - """Mark the given value as selected. - - Args: - value: The value to mark as selected. - """ - if value not in self._selected: - self._selected[value] = None - self.post_message(self.SelectedChanged(self)) - - def select(self, selection: Selection[SelectionType] | SelectionType) -> Self: - """Mark the given selection as selected. - - Args: - selection: The selection to mark as selected. - """ - self._select( - selection.value - if isinstance(selection, Selection) - else cast(SelectionType, selection) - ) - self.refresh() - return self - - def _apply_to_all(self, state_change: Callable[[SelectionType], None]) -> 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: @@ -254,39 +230,71 @@ class SelectionList(Generic[SelectionType], OptionList): The `SelectionList` instance. """ - # We're only going to signal a change in the selections if there is - # any actual change; so let's count how many are selected now. - selected_count = len(self.selected) + # 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: - state_change(cast(Selection, selection).value) + changed = state_change(cast(Selection, selection).value) or changed # If the above did make a change, *then* send a message. - if len(self.selected) != selected_count: + if changed: self.post_message(self.SelectedChanged(self)) 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.post_message(self.SelectedChanged(self)) + 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. + """ + 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.""" return self._apply_to_all(self._select) - def _deselect(self, value: SelectionType) -> None: + 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] - self.post_message(self.SelectedChanged(self)) except KeyError: - pass + return False + self.post_message(self.SelectedChanged(self)) + return True def deselect(self, selection: Selection[SelectionType] | SelectionType) -> Self: """Mark the given selection as not selected. @@ -306,16 +314,20 @@ class SelectionList(Generic[SelectionType], OptionList): """Deselect all items.""" return self._apply_to_all(self._deselect) - def _toggle(self, value: SelectionType) -> None: + def _toggle(self, value: SelectionType) -> bool: """Toggle the selection state of the given value. Args: value: The value to toggle. + + Returns: + Always `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. From ff404e2bbfcabc7f15fe1459417730bcd215a53d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 May 2023 10:35:48 +0100 Subject: [PATCH 37/96] Only refresh on deselect if something was deselected --- src/textual/widgets/_selection_list.py | 6 +++--- 1 file changed, 3 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 8e3ba4688..17852b6dc 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -302,12 +302,12 @@ class SelectionList(Generic[SelectionType], OptionList): Args: selection: The selection to mark as selected. """ - self._deselect( + if self._deselect( selection.value if isinstance(selection, Selection) else cast(SelectionType, selection) - ) - self.refresh() + ): + self.refresh() return self def deselect_all(self) -> Self: From 23d899935f653bbf429ccbbc7714e228ab10331a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 May 2023 10:36:59 +0100 Subject: [PATCH 38/96] Correct a docstring --- src/textual/widgets/_selection_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 17852b6dc..96c792d9d 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -300,7 +300,7 @@ class SelectionList(Generic[SelectionType], OptionList): """Mark the given selection as not selected. Args: - selection: The selection to mark as selected. + selection: The selection to mark as not selected. """ if self._deselect( selection.value From 81abac1c687f5f3538f3c537e9a7d36eea8f93ae Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 May 2023 10:46:00 +0100 Subject: [PATCH 39/96] Tidy up some docstrings Mainly adding missing return values, that sort of thing. --- src/textual/widgets/_selection_list.py | 27 +++++++++++++++++++++++--- 1 file changed, 24 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 96c792d9d..986a59f4a 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -267,6 +267,9 @@ class SelectionList(Generic[SelectionType], OptionList): Args: selection: The selection to mark as selected. + + Returns: + The `SelectionList` instance. """ if self._select( selection.value @@ -277,7 +280,11 @@ class SelectionList(Generic[SelectionType], OptionList): return self def select_all(self) -> Self: - """Select all items.""" + """Select all items. + + Returns: + The `SelectionList` instance. + """ return self._apply_to_all(self._select) def _deselect(self, value: SelectionType) -> bool: @@ -301,6 +308,9 @@ class SelectionList(Generic[SelectionType], OptionList): Args: selection: The selection to mark as not selected. + + Returns: + The `SelectionList` instance. """ if self._deselect( selection.value @@ -311,7 +321,11 @@ class SelectionList(Generic[SelectionType], OptionList): return self def deselect_all(self) -> Self: - """Deselect all items.""" + """Deselect all items. + + Returns: + The `SelectionList` instance. + """ return self._apply_to_all(self._deselect) def _toggle(self, value: SelectionType) -> bool: @@ -334,6 +348,9 @@ class SelectionList(Generic[SelectionType], OptionList): Args: selection: The selection to toggle. + + Returns: + The `SelectionList` instance. """ self._toggle( selection.value @@ -344,7 +361,11 @@ class SelectionList(Generic[SelectionType], OptionList): return self def toggle_all(self) -> Self: - """Toggle all items.""" + """Toggle all items. + + Returns: + The `SelectionList` instance. + """ return self._apply_to_all(self._toggle) def _make_selection( From fefb33a23b76ff818ae54e6f04ce6ef4336ff2c6 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 May 2023 11:07:45 +0100 Subject: [PATCH 40/96] Add a docstring to the internal copy of the selection value --- src/textual/widgets/_selection_list.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 986a59f4a..6442d2d13 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -51,6 +51,7 @@ class Selection(Generic[SelectionType], Option): prompt = Text.from_markup(prompt) super().__init__(prompt.split()[0], id, disabled) self._value: SelectionType = value + """The value associated with the selection.""" @property def value(self) -> SelectionType: From bee438bc53c1a67ef3f07d41c64d66a665df3ae6 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 May 2023 11:30:04 +0100 Subject: [PATCH 41/96] Get the selection value tracker in place before calling the superclass --- src/textual/widgets/_selection_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 6442d2d13..732e97409 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -207,6 +207,7 @@ class SelectionList(Generic[SelectionType], OptionList): classes: The CSS classes of the selection list. disabled: Whether the selection list is disabled or not. """ + self._selected: dict[SelectionType, None] = {} super().__init__( *[self._make_selection(selection) for selection in selections], name=name, @@ -214,7 +215,6 @@ class SelectionList(Generic[SelectionType], OptionList): classes=classes, disabled=disabled, ) - self._selected: dict[SelectionType, None] = {} @property def selected(self) -> list[SelectionType]: From d5799377a2988796dcd8c47ff89819df5d23797e Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 May 2023 11:31:07 +0100 Subject: [PATCH 42/96] Document _selected It's not for public consumption, but it's useful for anyone reading the code. --- src/textual/widgets/_selection_list.py | 1 + 1 file changed, 1 insertion(+) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 732e97409..1075f4918 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -208,6 +208,7 @@ class SelectionList(Generic[SelectionType], OptionList): disabled: Whether the selection list is disabled or not. """ self._selected: dict[SelectionType, None] = {} + """Tracking of which values are selected.""" super().__init__( *[self._make_selection(selection) for selection in selections], name=name, From d38780ba971e6c6c1fd9cccac3b72298e513618d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 May 2023 11:45:31 +0100 Subject: [PATCH 43/96] Ensure we don't try and post messages before the widget is ready --- src/textual/widgets/_selection_list.py | 23 ++++++++++++++++++++--- 1 file changed, 20 insertions(+), 3 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 1075f4918..9774d19ed 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -209,6 +209,8 @@ class SelectionList(Generic[SelectionType], OptionList): """ 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, @@ -222,6 +224,21 @@ class SelectionList(Generic[SelectionType], OptionList): """The selected values.""" 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 send 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. @@ -244,7 +261,7 @@ class SelectionList(Generic[SelectionType], OptionList): # If the above did make a change, *then* send a message. if changed: - self.post_message(self.SelectedChanged(self)) + self._message_changed() self.refresh() return self @@ -260,7 +277,7 @@ class SelectionList(Generic[SelectionType], OptionList): """ if value not in self._selected: self._selected[value] = None - self.post_message(self.SelectedChanged(self)) + self._message_changed() return True return False @@ -302,7 +319,7 @@ class SelectionList(Generic[SelectionType], OptionList): del self._selected[value] except KeyError: return False - self.post_message(self.SelectedChanged(self)) + self._message_changed() return True def deselect(self, selection: Selection[SelectionType] | SelectionType) -> Self: From f9780d034656370c5ab4b5865c8727ddbba21916 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 May 2023 15:13:46 +0100 Subject: [PATCH 44/96] Add basic selection list creation unit tests --- .../test_selection_list_create.py | 73 +++++++++++++++++++ 1 file changed, 73 insertions(+) create mode 100644 tests/selection_list/test_selection_list_create.py 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 000000000..492b4756b --- /dev/null +++ b/tests/selection_list/test_selection_list_create.py @@ -0,0 +1,73 @@ +"""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), + ) + + +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 == 3 + for n in range(3): + 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(3): + assert option_list.get_option_at_index(n).prompt == Text(str(n)) + assert option_list.get_option_at_index(-1).prompt == Text("2") + + +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 == 3 + selections.add_option(("3", 3)) + assert selections.option_count == 4 + selections.add_option(Selection("4", 4)) + assert selections.option_count == 5 + selections.add_options([Selection("5", 5), ("6", 6), ("7", 7, True)]) + assert selections.option_count == 8 + selections.add_options([]) + assert selections.option_count == 8 + + +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") From c448fa1ea089e405b30702d8d761bfacc64988e8 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 May 2023 15:48:34 +0100 Subject: [PATCH 45/96] Add unit tests for selection list messages --- .../selection_list/test_selection_messages.py | 194 ++++++++++++++++++ 1 file changed, 194 insertions(+) create mode 100644 tests/selection_list/test_selection_messages.py diff --git a/tests/selection_list/test_selection_messages.py b/tests/selection_list/test_selection_messages.py new file mode 100644 index 000000000..3e6041200 --- /dev/null +++ b/tests/selection_list/test_selection_messages.py @@ -0,0 +1,194 @@ +"""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 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(SelectionList.SelectionHighlighted) + @on(SelectionList.SelectionToggled) + @on(SelectionList.SelectedChanged) + def _record(self, event: Message) -> None: + 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_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 deslected 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 options should result in no additional.""" + 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), + ] From 9f6d35b871c7f7fe558ad597f777a2586a746d79 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 May 2023 16:26:28 +0100 Subject: [PATCH 46/96] Start unit tests for the actual selected property --- tests/selection_list/test_selection_values.py | 72 +++++++++++++++++++ 1 file changed, 72 insertions(+) create mode 100644 tests/selection_list/test_selection_values.py diff --git a/tests/selection_list/test_selection_values.py b/tests/selection_list/test_selection_values.py new file mode 100644 index 000000000..4a2aa4f5c --- /dev/null +++ b/tests/selection_list/test_selection_values.py @@ -0,0 +1,72 @@ +"""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)) From 50d77b231fb9769cb209fcfcd0b1519cac5d49b2 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Tue, 23 May 2023 16:45:53 +0100 Subject: [PATCH 47/96] Add tests for the wrong sized tuple While type checking picks this up, not everyone uses type checking. --- tests/selection_list/test_selection_list_create.py | 4 ++++ 1 file changed, 4 insertions(+) diff --git a/tests/selection_list/test_selection_list_create.py b/tests/selection_list/test_selection_list_create.py index 492b4756b..1a4982c29 100644 --- a/tests/selection_list/test_selection_list_create.py +++ b/tests/selection_list/test_selection_list_create.py @@ -71,3 +71,7 @@ async def test_add_non_selections() -> None: 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)) From 56103c52e7142d7f4d57d3b9690caaf8db15d48a Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 09:09:40 +0100 Subject: [PATCH 48/96] Ensure we log any OptionList messages in the messages test We don't actually want to see them, we don't expect to see them, so here I make a point of logging them so we can be sure we *don't* see them. --- tests/selection_list/test_selection_messages.py | 4 +++- 1 file changed, 3 insertions(+), 1 deletion(-) diff --git a/tests/selection_list/test_selection_messages.py b/tests/selection_list/test_selection_messages.py index 3e6041200..9736cb151 100644 --- a/tests/selection_list/test_selection_messages.py +++ b/tests/selection_list/test_selection_messages.py @@ -11,7 +11,7 @@ from __future__ import annotations from textual import on from textual.app import App, ComposeResult from textual.messages import Message -from textual.widgets import SelectionList +from textual.widgets import OptionList, SelectionList class SelectionListApp(App[None]): @@ -24,6 +24,8 @@ class SelectionListApp(App[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) From 9d6e977e0e81345c3b1747b9385aaa5ace7df070 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 09:15:43 +0100 Subject: [PATCH 49/96] Test messages when toggling a selection via user input --- tests/selection_list/test_selection_messages.py | 13 +++++++++++++ 1 file changed, 13 insertions(+) diff --git a/tests/selection_list/test_selection_messages.py b/tests/selection_list/test_selection_messages.py index 9736cb151..ea31c6ead 100644 --- a/tests/selection_list/test_selection_messages.py +++ b/tests/selection_list/test_selection_messages.py @@ -71,6 +71,19 @@ async def test_toggle() -> 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: From 2e540548f813b15185327b52bd2e19b2dd51035f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 09:19:56 +0100 Subject: [PATCH 50/96] Add a test that removed selected selections are removed from selected --- tests/selection_list/test_selection_values.py | 10 ++++++++++ 1 file changed, 10 insertions(+) diff --git a/tests/selection_list/test_selection_values.py b/tests/selection_list/test_selection_values.py index 4a2aa4f5c..0e2779b0c 100644 --- a/tests/selection_list/test_selection_values.py +++ b/tests/selection_list/test_selection_values.py @@ -70,3 +70,13 @@ async def test_programatic_toggle_all() -> None: 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 == [] From da1faf8fb91adf69f0ae9dd3f4f33f19aaa0d64b Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 10:09:21 +0100 Subject: [PATCH 51/96] Allow for storing the initial state of a selection --- src/textual/widgets/_selection_list.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 9774d19ed..c5f1a22d9 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -35,6 +35,7 @@ class Selection(Generic[SelectionType], Option): self, prompt: TextType, value: SelectionType, + initial_state: bool = False, id: str | None = None, disabled: bool = False, ): @@ -52,12 +53,19 @@ class Selection(Generic[SelectionType], Option): 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.""" From d3fe23f0bc23d2a05dc9a6c3e493607b7a161805 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 10:09:53 +0100 Subject: [PATCH 52/96] Allow passing a Selection into a SelctionList Up until now I've only been allowing tuples; mostly a hangover from the initial take on this. Things have drifted a bit now and I feel it makes sense to allow Selection instances in too. --- src/textual/widgets/_selection_list.py | 44 ++++++++++++------- .../test_selection_list_create.py | 34 +++++++++----- 2 files changed, 52 insertions(+), 26 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index c5f1a22d9..ca8fd0cac 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -199,7 +199,8 @@ class SelectionList(Generic[SelectionType], OptionList): def __init__( self, - *selections: tuple[TextType, SelectionType] + *selections: Selection + | tuple[TextType, SelectionType] | tuple[TextType, SelectionType, bool], name: str | None = None, id: str | None = None, @@ -397,7 +398,8 @@ class SelectionList(Generic[SelectionType], OptionList): def _make_selection( self, - selection: tuple[TextType, SelectionType] + selection: Selection + | tuple[TextType, SelectionType] | tuple[TextType, SelectionType, bool], ) -> Selection[SelectionType]: """Turn incoming selection data into a `Selection` instance. @@ -411,19 +413,31 @@ class SelectionList(Generic[SelectionType], OptionList): Raises: SelectionError: If the selection was badly-formed. """ - if len(selection) == 3: - label, value, selected = cast( - "tuple[TextType, SelectionType, bool]", selection - ) - elif len(selection) == 2: - label, value, selected = cast( - "tuple[TextType, SelectionType, bool]", (*selection, False) - ) - else: - raise SelectionError(f"Expected 2 or 3 values, got {len(selection)}") - if selected: - self._select(value) - return Selection(label, value) + + # If we've been given a tuple of some sort, turn that into a proper + # Selection. + if isinstance(selection, tuple): + if len(selection) == 3: + label, value, selected = cast( + "tuple[TextType, SelectionType, bool]", selection + ) + elif len(selection) == 2: + label, value, selected = cast( + "tuple[TextType, SelectionType, bool]", (*selection, False) + ) + else: + raise SelectionError(f"Expected 2 or 3 values, got {len(selection)}") + selection = Selection(label, value, selected) + + # 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. diff --git a/tests/selection_list/test_selection_list_create.py b/tests/selection_list/test_selection_list_create.py index 1a4982c29..f756ff068 100644 --- a/tests/selection_list/test_selection_list_create.py +++ b/tests/selection_list/test_selection_list_create.py @@ -25,6 +25,8 @@ class SelectionListApp(App[None]): ("0", 0), ("1", 1, False), ("2", 2, True), + Selection("3", 3, id="3"), + Selection("4", 4, True, id="4"), ) @@ -32,8 +34,8 @@ 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 == 3 - for n in range(3): + assert selections.option_count == 5 + for n in range(5): assert isinstance(selections.get_option_at_index(n), Selection) @@ -41,24 +43,34 @@ 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(3): + 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("2") + 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 == 3 - selections.add_option(("3", 3)) - assert selections.option_count == 4 - selections.add_option(Selection("4", 4)) assert selections.option_count == 5 - selections.add_options([Selection("5", 5), ("6", 6), ("7", 7, True)]) - assert selections.option_count == 8 + 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 == 8 + assert selections.option_count == 11 async def test_add_non_selections() -> None: From 7110b30b449926d044a88f68d3bace45c11639bc Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 10:16:06 +0100 Subject: [PATCH 53/96] Make sure adding a selection later updates selected --- src/textual/widgets/_selection_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index ca8fd0cac..f429a247f 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -616,7 +616,7 @@ class SelectionList(Generic[SelectionType], OptionList): ) ) elif isinstance(item, Selection): - cleaned_options.append(item) + cleaned_options.append(self._make_selection(item)) else: raise SelectionError( "Only Selection or a prompt/value tuple is supported in SelectionList" From 0a63748573fee0122a28eb7126706e929caedd8d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 10:16:25 +0100 Subject: [PATCH 54/96] Add a test for later addition of selected selections --- tests/selection_list/test_selection_list_create.py | 11 +++++++++++ 1 file changed, 11 insertions(+) diff --git a/tests/selection_list/test_selection_list_create.py b/tests/selection_list/test_selection_list_create.py index f756ff068..7df871bf6 100644 --- a/tests/selection_list/test_selection_list_create.py +++ b/tests/selection_list/test_selection_list_create.py @@ -73,6 +73,17 @@ async def test_add_later() -> None: 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: From cb05cfff53bbf7e255ab979ca1e628f0cbfe3f47 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 10:21:40 +0100 Subject: [PATCH 55/96] Test that the control of selection list events is always correct --- tests/selection_list/test_selection_messages.py | 1 + 1 file changed, 1 insertion(+) diff --git a/tests/selection_list/test_selection_messages.py b/tests/selection_list/test_selection_messages.py index ea31c6ead..2659f70ae 100644 --- a/tests/selection_list/test_selection_messages.py +++ b/tests/selection_list/test_selection_messages.py @@ -30,6 +30,7 @@ class SelectionListApp(App[None]): @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__, From 65375e88b683399865a87fd19b2e287ea95ec4f5 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 12:39:33 +0100 Subject: [PATCH 56/96] Remove an outdated note --- src/textual/widgets/_selection_list.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index f429a247f..0596fce22 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -592,11 +592,6 @@ class SelectionList(Generic[SelectionType], OptionList): Raises: DuplicateID: If there is an attempt to use a duplicate ID. SelectionError: If one of the selection options is of the wrong form. - - Note: - Any new selection option added should either be an instance of - `Option`, or should be a `tuple` of prompt and value, or prompt, - value and selected state. """ # This... is sort of sub-optimal, but a natural consequence of # inheriting from and narrowing down OptionList. Here we don't want From b1136632216e724e3e046a87f9b522a1f4bc901f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 12:55:31 +0100 Subject: [PATCH 57/96] Add a note about SelctionToggled vs SelectedChanged --- src/textual/widgets/_selection_list.py | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 0596fce22..c5b74748d 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -183,6 +183,14 @@ class SelectionList(Generic[SelectionType], OptionList): Can be handled using `on_selection_list_selection_toggled` in a subclass of `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 From 64ed982fd427ac7c9a471ca16a97f95fa4c0f2d8 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 12:57:13 +0100 Subject: [PATCH 58/96] Make it very clear when SelectedChanged is posted --- src/textual/widgets/_selection_list.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index c5b74748d..719aa590a 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -195,7 +195,11 @@ class SelectionList(Generic[SelectionType], OptionList): @dataclass class SelectedChanged(Generic[MessageSelectionType], Message): - """Message sent when the collection of selected values changes.""" + """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.""" From 2e37541d709ca203b25c35ccd072cbce69aa0cf9 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 12:59:20 +0100 Subject: [PATCH 59/96] Correct the types in a copied docstring --- src/textual/widgets/_selection_list.py | 5 +++-- 1 file changed, 3 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 719aa590a..231d2de1d 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -159,9 +159,10 @@ class SelectionList(Generic[SelectionType], OptionList): @property def control(self) -> OptionList: - """The option list that sent the message. + """The selection list that sent the message. - This is an alias for [`OptionMessage.option_list`][textual.widgets.OptionList.OptionMessage.option_list] + 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 From 9742144e8c5d33c24d8fdc54eab5f9923f9c83b7 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 13:00:20 +0100 Subject: [PATCH 60/96] Remove a note that isn't relevant any more --- src/textual/widgets/_selection_list.py | 5 ----- 1 file changed, 5 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 231d2de1d..183fc3069 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -649,10 +649,5 @@ class SelectionList(Generic[SelectionType], OptionList): Raises: DuplicateID: If there is an attempt to use a duplicate ID. SelectionError: If the selection option is of the wrong form. - - Note: - Any new selection option added should either be an instance of - `Option`, or should be a `tuple` of prompt and value, or prompt, - value and selected state. """ return self.add_options([item]) From a31c3f07745c608859f543bec2e7f8eb367ccb98 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 14:03:09 +0100 Subject: [PATCH 61/96] Correct the Selection.__init__ docstring --- src/textual/widgets/_selection_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 183fc3069..2184a875d 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -44,7 +44,7 @@ class Selection(Generic[SelectionType], Option): Args: prompt: The prompt for the selection. value: The value for the selection. - selected: Is this particular selection selected? + 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. """ From 258181d1de1c607ca34acfe44590d0c166d2a5f2 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 14:04:53 +0100 Subject: [PATCH 62/96] Flesh out the docstring for the selected property --- src/textual/widgets/_selection_list.py | 8 +++++++- 1 file changed, 7 insertions(+), 1 deletion(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 2184a875d..9f6e39832 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -243,7 +243,13 @@ class SelectionList(Generic[SelectionType], OptionList): @property def selected(self) -> list[SelectionType]: - """The selected values.""" + """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: From 2874b24a879716f07779c3b187dae52002c25013 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 14:30:15 +0100 Subject: [PATCH 63/96] Export genetic types for SelectionList So these can end up in the docs. --- src/textual/widgets/selection_list.py | 9 +++++++-- 1 file changed, 7 insertions(+), 2 deletions(-) diff --git a/src/textual/widgets/selection_list.py b/src/textual/widgets/selection_list.py index 56a8caa18..06209dbab 100644 --- a/src/textual/widgets/selection_list.py +++ b/src/textual/widgets/selection_list.py @@ -1,3 +1,8 @@ -from ._selection_list import Selection, SelectionError +from ._selection_list import ( + MessageSelectionType, + Selection, + SelectionError, + SelectionType, +) -__all__ = ["Selection", "SelectionError"] +__all__ = ["MessageSelectionType", "Selection", "SelectionError", "SelectionType"] From 64dd7d0f178e1dc5eebbad6f1dbe6c2643a0cf8f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 14:31:50 +0100 Subject: [PATCH 64/96] Better linking for the docstring for SelectionType --- src/textual/widgets/_selection_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 9f6e39832..f713363de 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -18,7 +18,7 @@ from ._option_list import NewOptionListContent, Option, OptionList from ._toggle_button import ToggleButton SelectionType = TypeVar("SelectionType") -"""The type for the value of a `Selection`""" +"""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 `SelectionList` message""" From a32cfdbe404cd15e6f3e4fbacf3c35820632897d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 14:32:32 +0100 Subject: [PATCH 65/96] Better linking for the docstring for MessageSelectionType --- src/textual/widgets/_selection_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index f713363de..d37654303 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -21,7 +21,7 @@ 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 `SelectionList` message""" +"""The type for the value of a [`Selection`][textual.widgets.selection_list.Selection] in a [`SelectionList`][textual.widgets.SelectionList] message.""" class SelectionError(TypeError): From 9c0df44b592e32669581ccf6ec4b2f847faf3457 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 14:35:13 +0100 Subject: [PATCH 66/96] Supply the generic type when creating a Selection I don't think this is *needed* as such, but it seems like the sensible thing to do. --- src/textual/widgets/_selection_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index d37654303..6af898043 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -446,7 +446,7 @@ class SelectionList(Generic[SelectionType], OptionList): ) else: raise SelectionError(f"Expected 2 or 3 values, got {len(selection)}") - selection = Selection(label, value, selected) + selection = Selection[SelectionType](label, value, selected) # At this point we should have a proper selection. assert isinstance(selection, Selection) From ac7a892965212607d2d5467b789e2bf3ba56546d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 14:41:11 +0100 Subject: [PATCH 67/96] Link most(all?) docstring mentions of SelectionList Putting the hype in hypertext. --- src/textual/widgets/_selection_list.py | 18 +++++++++--------- 1 file changed, 9 insertions(+), 9 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 6af898043..cc877b1bd 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -274,7 +274,7 @@ class SelectionList(Generic[SelectionType], OptionList): state_change: The state change function to apply. Returns: - The `SelectionList` instance. + The [`SelectionList`][textual.widgets.SelectionList] instance. """ # Keep track of if anything changed. @@ -316,7 +316,7 @@ class SelectionList(Generic[SelectionType], OptionList): selection: The selection to mark as selected. Returns: - The `SelectionList` instance. + The [`SelectionList`][textual.widgets.SelectionList] instance. """ if self._select( selection.value @@ -357,7 +357,7 @@ class SelectionList(Generic[SelectionType], OptionList): selection: The selection to mark as not selected. Returns: - The `SelectionList` instance. + The [`SelectionList`][textual.widgets.SelectionList] instance. """ if self._deselect( selection.value @@ -371,7 +371,7 @@ class SelectionList(Generic[SelectionType], OptionList): """Deselect all items. Returns: - The `SelectionList` instance. + The [`SelectionList`][textual.widgets.SelectionList] instance. """ return self._apply_to_all(self._deselect) @@ -397,7 +397,7 @@ class SelectionList(Generic[SelectionType], OptionList): selection: The selection to toggle. Returns: - The `SelectionList` instance. + The [`SelectionList`][textual.widgets.SelectionList] instance. """ self._toggle( selection.value @@ -411,7 +411,7 @@ class SelectionList(Generic[SelectionType], OptionList): """Toggle all items. Returns: - The `SelectionList` instance. + The [`SelectionList`][textual.widgets.SelectionList] instance. """ return self._apply_to_all(self._toggle) @@ -533,7 +533,7 @@ class SelectionList(Generic[SelectionType], OptionList): def _on_option_list_option_highlighted( self, event: OptionList.OptionHighlighted ) -> None: - """Capture the `OptionList` highlight event and turn it into a `SelectionList` event. + """Capture the `OptionList` highlight event and turn it into a [`SelectionList`][textual.widgets.SelectionList] event. Args: event: The event to capture and recreate. @@ -606,7 +606,7 @@ class SelectionList(Generic[SelectionType], OptionList): items: The new items to add. Returns: - The `SelectionList` instance. + The [`SelectionList`][textual.widgets.SelectionList] instance. Raises: DuplicateID: If there is an attempt to use a duplicate ID. @@ -650,7 +650,7 @@ class SelectionList(Generic[SelectionType], OptionList): item: The new item to add. Returns: - The `SelectionList` instance. + The [`SelectionList`][textual.widgets.SelectionList] instance. Raises: DuplicateID: If there is an attempt to use a duplicate ID. From 113ab41c3b22578cdd38dbb3878de5c847fea868 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 14:58:27 +0100 Subject: [PATCH 68/96] Some more linking to types within the SelectionList docstrings --- src/textual/widgets/_selection_list.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index cc877b1bd..168e0c7a1 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -29,7 +29,7 @@ class SelectionError(TypeError): class Selection(Generic[SelectionType], Option): - """A selection for the `SelectionList`.""" + """A selection for a [`SelectionList`][textual.widgets.SelectionList].""" def __init__( self, @@ -176,14 +176,14 @@ class SelectionList(Generic[SelectionType], OptionList): """Message sent when a selection is highlighted. Can be handled using `on_selection_list_selection_highlighted` in a subclass of - `SelectionList` or in a parent node in the DOM. + [`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` or in a parent node in the DOM. + [`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 @@ -330,7 +330,7 @@ class SelectionList(Generic[SelectionType], OptionList): """Select all items. Returns: - The `SelectionList` instance. + The [`SelectionList`][textual.widgets.SelectionList] instance. """ return self._apply_to_all(self._select) @@ -542,7 +542,7 @@ class SelectionList(Generic[SelectionType], OptionList): 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` event. + """Capture the `OptionList` selected event and turn it into a [`SelectionList`][textual.widgets.SelectionList] event. Args: event: The event to capture and recreate. From 910c4782f1847684bc22e7e007f50ad0a45844f3 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 15:05:10 +0100 Subject: [PATCH 69/96] Add the main framework for the OptionList documentation Lots to flesh out here still, but this gets the core framework in place. --- docs/widgets/selection_list.md | 77 ++++++++++++++++++++++++++++++++++ mkdocs-nav.yml | 1 + 2 files changed, 78 insertions(+) create mode 100644 docs/widgets/selection_list.md diff --git a/docs/widgets/selection_list.md b/docs/widgets/selection_list.md new file mode 100644 index 000000000..017fffc54 --- /dev/null +++ b/docs/widgets/selection_list.md @@ -0,0 +1,77 @@ +# SelectionList + +!!! tip "Added in version 0.??.0" + +A widget for showing a vertical list check boxes. + +- [x] Focusable +- [ ] Container + +## Examples + +Some super-cool examples will appear here! + +## 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.Selection + options: + heading_level: 2 + +::: textual.widgets.selection_list + options: + heading_level: 2 diff --git a/mkdocs-nav.yml b/mkdocs-nav.yml index f5a9ab4f1..3be215180 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" From 49c7b20bc165d1122e7f234f6e228625c22494ae Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 15:19:02 +0100 Subject: [PATCH 70/96] Link mention of Strip in a docstring --- src/textual/widgets/_selection_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 168e0c7a1..1886ce8a7 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -473,7 +473,7 @@ class SelectionList(Generic[SelectionType], OptionList): y: The line to render. Returns: - A `Strip` that is the line to render. + A [`Strip`][textual.strip.Strip] that is the line to render. """ # First off, get the underlying prompt from OptionList. From 3e4291cf947eac5519fe8dc136218ae6441138e5 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 15:31:27 +0100 Subject: [PATCH 71/96] Remove unnecessary inclusion of Selection It will be included by the nature of selection_list being included. --- docs/widgets/selection_list.md | 4 ---- 1 file changed, 4 deletions(-) diff --git a/docs/widgets/selection_list.md b/docs/widgets/selection_list.md index 017fffc54..6ef43a8a1 100644 --- a/docs/widgets/selection_list.md +++ b/docs/widgets/selection_list.md @@ -68,10 +68,6 @@ makes use of the following component classes: options: heading_level: 2 -::: textual.widgets.selection_list.Selection - options: - heading_level: 2 - ::: textual.widgets.selection_list options: heading_level: 2 From a9100988b466833a9d4d7905ec6e714f78b25a17 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Wed, 24 May 2023 21:36:14 +0100 Subject: [PATCH 72/96] Make a start on the SelectionList example apps --- docs/examples/widgets/selection_list.css | 10 ++++ docs/examples/widgets/selection_list.py | 28 +++++++++++ .../widgets/selection_list_selections.py | 29 +++++++++++ docs/widgets/selection_list.md | 48 ++++++++++++++++++- 4 files changed, 114 insertions(+), 1 deletion(-) create mode 100644 docs/examples/widgets/selection_list.css create mode 100644 docs/examples/widgets/selection_list.py create mode 100644 docs/examples/widgets/selection_list_selections.py diff --git a/docs/examples/widgets/selection_list.css b/docs/examples/widgets/selection_list.css new file mode 100644 index 000000000..a05e16281 --- /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.py b/docs/examples/widgets/selection_list.py new file mode 100644 index 000000000..07b7d85bc --- /dev/null +++ b/docs/examples/widgets/selection_list.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]( + ("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/examples/widgets/selection_list_selections.py b/docs/examples/widgets/selection_list_selections.py new file mode 100644 index 000000000..d959a689f --- /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]( + 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/widgets/selection_list.md b/docs/widgets/selection_list.md index 6ef43a8a1..c7ac8df7a 100644 --- a/docs/widgets/selection_list.md +++ b/docs/widgets/selection_list.md @@ -9,7 +9,53 @@ A widget for showing a vertical list check boxes. ## Examples -Some super-cool examples will appear here! +A selection list is designed to be built up of single-line prompts (which +can be 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.py"} + ``` + +=== "selection_list.py" + + ~~~python + --8<-- "docs/examples/widgets/selection_list.py" + ~~~ + +=== "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" + ~~~ + +=== "selection_list.css" + + ~~~python + --8<-- "docs/examples/widgets/selection_list.css" + ~~~ ## Reactive Attributes From 2d544ca697f6669d65c9ad6099bf9d02d3d0dfee Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 08:58:45 +0100 Subject: [PATCH 73/96] Rename the tuples selection list example to mention tuples Making this one look like it was *the* canonical example wasn't a good idea. --- .../widgets/{selection_list.py => selection_list_tuples.py} | 0 docs/widgets/selection_list.md | 6 +++--- 2 files changed, 3 insertions(+), 3 deletions(-) rename docs/examples/widgets/{selection_list.py => selection_list_tuples.py} (100%) diff --git a/docs/examples/widgets/selection_list.py b/docs/examples/widgets/selection_list_tuples.py similarity index 100% rename from docs/examples/widgets/selection_list.py rename to docs/examples/widgets/selection_list_tuples.py diff --git a/docs/widgets/selection_list.md b/docs/widgets/selection_list.md index c7ac8df7a..232fceaa7 100644 --- a/docs/widgets/selection_list.md +++ b/docs/widgets/selection_list.md @@ -20,13 +20,13 @@ optionally contain a flag for the initial selected state of the option. === "Output" - ```{.textual path="docs/examples/widgets/selection_list.py"} + ```{.textual path="docs/examples/widgets/selection_list_tuples.py"} ``` -=== "selection_list.py" +=== "selection_list_tuples.py" ~~~python - --8<-- "docs/examples/widgets/selection_list.py" + --8<-- "docs/examples/widgets/selection_list_tuples.py" ~~~ === "selection_list.css" From fe26b89803d1f998e686272e113af729850b8515 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 09:12:57 +0100 Subject: [PATCH 74/96] Add some more hints about type hinting Also add a couple more useful links in the area I'm editing. --- .../widgets/selection_list_selections.py | 2 +- .../examples/widgets/selection_list_tuples.py | 2 +- docs/widgets/selection_list.md | 27 ++++++++++++++++++- 3 files changed, 28 insertions(+), 3 deletions(-) diff --git a/docs/examples/widgets/selection_list_selections.py b/docs/examples/widgets/selection_list_selections.py index d959a689f..4a5e582a0 100644 --- a/docs/examples/widgets/selection_list_selections.py +++ b/docs/examples/widgets/selection_list_selections.py @@ -8,7 +8,7 @@ class SelectionListApp(App[None]): def compose(self) -> ComposeResult: yield Header() - yield SelectionList[int]( + yield SelectionList[int]( # (1)! Selection("Falken's Maze", 0, True), Selection("Black Jack", 1), Selection("Gin Rummy", 2), diff --git a/docs/examples/widgets/selection_list_tuples.py b/docs/examples/widgets/selection_list_tuples.py index 07b7d85bc..bff54e69c 100644 --- a/docs/examples/widgets/selection_list_tuples.py +++ b/docs/examples/widgets/selection_list_tuples.py @@ -7,7 +7,7 @@ class SelectionListApp(App[None]): def compose(self) -> ComposeResult: yield Header() - yield SelectionList[int]( + yield SelectionList[int]( # (1)! ("Falken's Maze", 0, True), ("Black Jack", 1), ("Gin Rummy", 2), diff --git a/docs/widgets/selection_list.md b/docs/widgets/selection_list.md index 232fceaa7..498d886fb 100644 --- a/docs/widgets/selection_list.md +++ b/docs/widgets/selection_list.md @@ -7,10 +7,31 @@ A widget for showing a vertical list check boxes. - [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[int](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) and an associated unique value. +can be [Rich renderables](/guide/widgets/#rich-renderables)) and an +associated unique value. ### Selections as tuples @@ -29,6 +50,8 @@ optionally contain a flag for the initial selected state of the option. --8<-- "docs/examples/widgets/selection_list_tuples.py" ~~~ + 1. Note that the `SelectionList` is typed as `int`, for the type of the vlaues. + === "selection_list.css" ~~~python @@ -51,6 +74,8 @@ Alternatively, selections can be passed in as --8<-- "docs/examples/widgets/selection_list_selections.py" ~~~ + 1. Note that the `SelectionList` is typed as `int`, for the type of the vlaues. + === "selection_list.css" ~~~python From 02c4f4d69bbff863cdeaa0880617b5e72a2e209f Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 09:35:37 +0100 Subject: [PATCH 75/96] Add an example of using SelectionList.SelectedChanged --- .../widgets/selection_list_selected.css | 19 +++++++++ .../widgets/selection_list_selected.py | 40 +++++++++++++++++++ docs/widgets/selection_list.md | 27 +++++++++++++ 3 files changed, 86 insertions(+) create mode 100644 docs/examples/widgets/selection_list_selected.css create mode 100644 docs/examples/widgets/selection_list_selected.py diff --git a/docs/examples/widgets/selection_list_selected.css b/docs/examples/widgets/selection_list_selected.css new file mode 100644 index 000000000..92db41c14 --- /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 000000000..954fb36b1 --- /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/widgets/selection_list.md b/docs/widgets/selection_list.md index 498d886fb..9febaf88b 100644 --- a/docs/widgets/selection_list.md +++ b/docs/widgets/selection_list.md @@ -82,6 +82,33 @@ Alternatively, selections can be passed in as --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`][textual.widgets.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 vlaues. + +=== "selection_list.css" + + ~~~python + --8<-- "docs/examples/widgets/selection_list_selected.css" + ~~~ + ## Reactive Attributes | Name | Type | Default | Description | From 4ceeefba233b84b70841de4662d02e6b35ee123e Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 09:42:12 +0100 Subject: [PATCH 76/96] Remove the attempt to link to Pretty Weirdly Pretty isn't in the docs. Yet. --- docs/widgets/selection_list.md | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/docs/widgets/selection_list.md b/docs/widgets/selection_list.md index 9febaf88b..636232ccc 100644 --- a/docs/widgets/selection_list.md +++ b/docs/widgets/selection_list.md @@ -87,8 +87,8 @@ Alternatively, selections can be passed in as 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`][textual.widgets.Pretty] with the collection of selected values: +Here is an example of using that message to update a `Pretty` with the +collection of selected values: === "Output" From 112f18b94dcdb724829e11d2b472c120d6d0d667 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 09:58:43 +0100 Subject: [PATCH 77/96] Add SelectionList to the widget gallery --- docs/widget_gallery.md | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/docs/widget_gallery.md b/docs/widget_gallery.md index 04e6fada2..15610ad96 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 From 939586f5f812e83164c6c4fc16aacaf992a0dc55 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 11:06:21 +0100 Subject: [PATCH 78/96] Add snapshot tests for the SelectionList examples --- .../__snapshots__/test_snapshots.ambr | 491 ++++++++++++++++++ tests/snapshot_tests/test_snapshots.py | 4 + 2 files changed, 495 insertions(+) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 7013a574a..ea041f772 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -23523,6 +23523,497 @@ ''' # --- +# name: test_selection_list + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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.1 + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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.2 + ''' + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + 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 fd22c0ca3..6f9aabbcc 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -232,6 +232,10 @@ 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(snap_compare): + assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_selected.py") + assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_selections.py") + assert snap_compare(WIDGET_EXAMPLES_DIR / "selection_list_tuples.py") def test_select_expanded(snap_compare): assert snap_compare( From 3796a849c4c776f20dde11237654b21b08634c23 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 11:21:50 +0100 Subject: [PATCH 79/96] Simplify _make_selection a wee bit --- src/textual/widgets/_selection_list.py | 12 ++++-------- 1 file changed, 4 insertions(+), 8 deletions(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 1886ce8a7..9579b7431 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -436,17 +436,13 @@ class SelectionList(Generic[SelectionType], OptionList): # If we've been given a tuple of some sort, turn that into a proper # Selection. if isinstance(selection, tuple): - if len(selection) == 3: - label, value, selected = cast( - "tuple[TextType, SelectionType, bool]", selection - ) - elif len(selection) == 2: - label, value, selected = cast( + if len(selection) == 2: + selection = cast( "tuple[TextType, SelectionType, bool]", (*selection, False) ) - else: + elif len(selection) != 3: raise SelectionError(f"Expected 2 or 3 values, got {len(selection)}") - selection = Selection[SelectionType](label, value, selected) + selection = Selection[SelectionType](*selection) # At this point we should have a proper selection. assert isinstance(selection, Selection) From 4472c862bede7609d33f6b982b9e37c62826ae09 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 11:28:52 +0100 Subject: [PATCH 80/96] Anticipate SelectionList making it into 0.27.0 --- CHANGELOG.md | 1 + docs/widgets/selection_list.md | 2 +- 2 files changed, 2 insertions(+), 1 deletion(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index f43453c25..99a46540b 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 ### Changed diff --git a/docs/widgets/selection_list.md b/docs/widgets/selection_list.md index 636232ccc..7dbbb334d 100644 --- a/docs/widgets/selection_list.md +++ b/docs/widgets/selection_list.md @@ -1,6 +1,6 @@ # SelectionList -!!! tip "Added in version 0.??.0" +!!! tip "Added in version 0.27.0" A widget for showing a vertical list check boxes. From bec362e52793a5f8956ec2b8e81d6000ee74db83 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 13:04:12 +0100 Subject: [PATCH 81/96] Improve the title for the widget MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit It's sort moved on from been about check boxen. Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- docs/widgets/selection_list.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/widgets/selection_list.md b/docs/widgets/selection_list.md index 7dbbb334d..28410d39c 100644 --- a/docs/widgets/selection_list.md +++ b/docs/widgets/selection_list.md @@ -2,7 +2,7 @@ !!! tip "Added in version 0.27.0" -A widget for showing a vertical list check boxes. +A widget for showing a vertical list of selectable options. - [x] Focusable - [ ] Container From 34f7136f21a18fa5b51cdc3c6a35cc319b198f43 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 13:14:27 +0100 Subject: [PATCH 82/96] Fix a typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- docs/widgets/selection_list.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/widgets/selection_list.md b/docs/widgets/selection_list.md index 28410d39c..71cdcd520 100644 --- a/docs/widgets/selection_list.md +++ b/docs/widgets/selection_list.md @@ -50,7 +50,7 @@ optionally contain a flag for the initial selected state of the option. --8<-- "docs/examples/widgets/selection_list_tuples.py" ~~~ - 1. Note that the `SelectionList` is typed as `int`, for the type of the vlaues. + 1. Note that the `SelectionList` is typed as `int`, for the type of the values. === "selection_list.css" From 51133b3a6233dd5e692eb1d978b747bbe6cc5a94 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 13:19:18 +0100 Subject: [PATCH 83/96] Typo fix MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- tests/selection_list/test_selection_messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/selection_list/test_selection_messages.py b/tests/selection_list/test_selection_messages.py index 2659f70ae..5dd68a353 100644 --- a/tests/selection_list/test_selection_messages.py +++ b/tests/selection_list/test_selection_messages.py @@ -171,7 +171,7 @@ async def test_deselect() -> None: async def test_deselect_deselected() -> None: - """Deselecting a deslected option should result in no extra messages.""" + """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() From 51f8d0dc9adc4fc421abd183a4a4d41753846b91 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 13:20:22 +0100 Subject: [PATCH 84/96] Break up the `SelectionList` snapshit tests MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- tests/snapshot_tests/test_snapshots.py | 6 +++++- 1 file changed, 5 insertions(+), 1 deletion(-) diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 6f9aabbcc..130ff19f3 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -232,9 +232,13 @@ 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(snap_compare): +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): From d656fa6a79778bcccae6131fff528ab21d4f6bd0 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 13:24:01 +0100 Subject: [PATCH 85/96] Fix a typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/widgets/_selection_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 9579b7431..c0208dfca 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -568,7 +568,7 @@ class SelectionList(Generic[SelectionType], OptionList): index: The ID of the selection option to get. Returns: - The selection option at with the ID. + The selection option with the ID. Raises: OptionDoesNotExist: If no selection option has the given ID. From ad4c68ba0d0e11adde345bc2f3361049716f6da2 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 13:26:57 +0100 Subject: [PATCH 86/96] Fix a typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- docs/widgets/selection_list.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/widgets/selection_list.md b/docs/widgets/selection_list.md index 71cdcd520..4662d9c77 100644 --- a/docs/widgets/selection_list.md +++ b/docs/widgets/selection_list.md @@ -74,7 +74,7 @@ Alternatively, selections can be passed in as --8<-- "docs/examples/widgets/selection_list_selections.py" ~~~ - 1. Note that the `SelectionList` is typed as `int`, for the type of the vlaues. + 1. Note that the `SelectionList` is typed as `int`, for the type of the values. === "selection_list.css" From 6d82d7a1db52652ebea25b05ef27dfcd0506157c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 13:27:31 +0100 Subject: [PATCH 87/96] Fix a typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- docs/widgets/selection_list.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/widgets/selection_list.md b/docs/widgets/selection_list.md index 4662d9c77..cb22e048f 100644 --- a/docs/widgets/selection_list.md +++ b/docs/widgets/selection_list.md @@ -101,7 +101,7 @@ collection of selected values: --8<-- "docs/examples/widgets/selection_list_selected.py" ~~~ - 1. Note that the `SelectionList` is typed as `str`, for the type of the vlaues. + 1. Note that the `SelectionList` is typed as `str`, for the type of the values. === "selection_list.css" From 4c93e63ed653aa344a355f3200ce8ad4f4a88354 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 13:35:27 +0100 Subject: [PATCH 88/96] Fix a copy/pasteo --- src/textual/widgets/_selection_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index c0208dfca..40c9f3257 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -149,7 +149,7 @@ class SelectionList(Generic[SelectionType], OptionList): """ super().__init__() self.selection_list: SelectionList[MessageSelectionType] = selection_list - """The option list that sent the message.""" + """The selection list that sent the message.""" self.selection: Selection[ MessageSelectionType ] = selection_list.get_option_at_index(index) From baa060f9fab7729913e632926053447284bc9b0d Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 13:57:08 +0100 Subject: [PATCH 89/96] Remove annotation from RHS of the typing example --- docs/widgets/selection_list.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/widgets/selection_list.md b/docs/widgets/selection_list.md index cb22e048f..8dbf7a2de 100644 --- a/docs/widgets/selection_list.md +++ b/docs/widgets/selection_list.md @@ -18,7 +18,7 @@ follows: ```python selections = [("First", 1), ("Second", 2)] -my_selection_list: SelectionList[int] = SelectionList[int](selections) +my_selection_list: SelectionList[int] = SelectionList(selections) ``` !!! note From a944554d0d28e2aa66849bae2e378f69b107bd34 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 13:58:31 +0100 Subject: [PATCH 90/96] Finish a half-finished docstring --- tests/selection_list/test_selection_messages.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/tests/selection_list/test_selection_messages.py b/tests/selection_list/test_selection_messages.py index 5dd68a353..d90f04c6d 100644 --- a/tests/selection_list/test_selection_messages.py +++ b/tests/selection_list/test_selection_messages.py @@ -181,7 +181,7 @@ async def test_deselect_deselected() -> None: async def test_deselect_all() -> None: - """Deselecting all options should result in no additional.""" + """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() From c69e53f77eba26e6aa57ac162cd3416fa34241a9 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 14:04:32 +0100 Subject: [PATCH 91/96] Save a word! MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/widgets/_selection_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 40c9f3257..084c48274 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -382,7 +382,7 @@ class SelectionList(Generic[SelectionType], OptionList): value: The value to toggle. Returns: - Always `True`. + `True`. """ if value in self._selected: self._deselect(value) From 95389ebe764613b8f7b7e92eefc63df8a24d1d53 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 14:05:05 +0100 Subject: [PATCH 92/96] Fix a typo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/widgets/_selection_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 084c48274..146e25f18 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -260,7 +260,7 @@ class SelectionList(Generic[SelectionType], OptionList): """Post a message that the selected collection has changed, where appropriate. Note: - A message will only be send if `_send_messages` is `True`. This + 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. """ From 45e64254e6328f348ba3ed3997ddce70557f4a4e Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 14:11:35 +0100 Subject: [PATCH 93/96] Be clear that _apply_to_all sends a SelectedChange message --- src/textual/widgets/_selection_list.py | 5 +++++ 1 file changed, 5 insertions(+) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index 40c9f3257..fa126b785 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -275,6 +275,11 @@ class SelectionList(Generic[SelectionType], OptionList): 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. From 4764c100e30cf9e5a336738706a3a61f56edc5e3 Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 14:16:15 +0100 Subject: [PATCH 94/96] Fix a copy/pasteo MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com> --- src/textual/widgets/_selection_list.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/src/textual/widgets/_selection_list.py b/src/textual/widgets/_selection_list.py index dbe6a656b..5a6023ad1 100644 --- a/src/textual/widgets/_selection_list.py +++ b/src/textual/widgets/_selection_list.py @@ -223,7 +223,7 @@ class SelectionList(Generic[SelectionType], OptionList): """Initialise the selection list. Args: - *content: The content for the selection list. + *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. From 658c1cdf73c4033a3630b9edcf07d00b22cb762e Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 14:19:47 +0100 Subject: [PATCH 95/96] Documentation punctuation change --- docs/widgets/selection_list.md | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/docs/widgets/selection_list.md b/docs/widgets/selection_list.md index 8dbf7a2de..d4b98132f 100644 --- a/docs/widgets/selection_list.md +++ b/docs/widgets/selection_list.md @@ -85,7 +85,7 @@ Alternatively, selections can be passed in as ### 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 +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: From 400043dda19bb9962f3c870c94e573d9cb10a9da Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 25 May 2023 14:47:11 +0100 Subject: [PATCH 96/96] Update snapshit tests --- .../__snapshots__/test_snapshots.ambr | 136 +++++++++--------- 1 file changed, 68 insertions(+), 68 deletions(-) diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr index 6aac75d65..47b0e0c61 100644 --- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr +++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr @@ -23685,7 +23685,7 @@ ''' # --- -# name: test_selection_list +# name: test_selection_list_selected ''' @@ -23708,141 +23708,141 @@ font-weight: 700; } - .terminal-11693157-matrix { + .terminal-3781955182-matrix { font-family: Fira Code, monospace; font-size: 20px; line-height: 24.4px; font-variant-east-asian: full-width; } - .terminal-11693157-title { + .terminal-3781955182-title { font-size: 18px; font-weight: bold; font-family: arial; } - .terminal-11693157-r1 { fill: #c5c8c6 } - .terminal-11693157-r2 { fill: #e3e3e3 } - .terminal-11693157-r3 { fill: #e1e1e1 } - .terminal-11693157-r4 { fill: #0178d4 } - .terminal-11693157-r5 { fill: #e1e1e1;font-weight: bold } - .terminal-11693157-r6 { fill: #62666a } - .terminal-11693157-r7 { fill: #4ebf71;font-weight: bold } - .terminal-11693157-r8 { fill: #ddedf9;font-weight: bold } - .terminal-11693157-r9 { fill: #98a84b } - .terminal-11693157-r10 { fill: #34393f;font-weight: bold } - .terminal-11693157-r11 { fill: #e4e5e6 } - .terminal-11693157-r12 { fill: #ddedf9 } + .terminal-3781955182-r1 { fill: #c5c8c6 } + .terminal-3781955182-r2 { fill: #e3e3e3 } + .terminal-3781955182-r3 { fill: #e1e1e1 } + .terminal-3781955182-r4 { fill: #0178d4 } + .terminal-3781955182-r5 { fill: #e1e1e1;font-weight: bold } + .terminal-3781955182-r6 { fill: #62666a } + .terminal-3781955182-r7 { fill: #4ebf71;font-weight: bold } + .terminal-3781955182-r8 { fill: #ddedf9;font-weight: bold } + .terminal-3781955182-r9 { fill: #98a84b } + .terminal-3781955182-r10 { fill: #34393f;font-weight: bold } + .terminal-3781955182-r11 { fill: #e4e5e6 } + .terminal-3781955182-r12 { fill: #ddedf9 } - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - + - SelectionListApp + 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 - - - - - - ──────────────────────────────────────────────────────────── - - - + + + + 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 + + + + + + ────────────────────────────── + + + @@ -23850,7 +23850,7 @@ ''' # --- -# name: test_selection_list.1 +# name: test_selection_list_selections ''' @@ -24013,7 +24013,7 @@ ''' # --- -# name: test_selection_list.2 +# name: test_selection_list_tuples '''