From 0c18839c8a020956341cc91ad22dd54940be374c Mon Sep 17 00:00:00 2001 From: Dave Pearson Date: Thu, 18 May 2023 13:00:23 +0100 Subject: [PATCH] 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()