diff --git a/docs/examples/widgets/select_widget_no_blank.py b/docs/examples/widgets/select_widget_no_blank.py new file mode 100644 index 000000000..8fab93667 --- /dev/null +++ b/docs/examples/widgets/select_widget_no_blank.py @@ -0,0 +1,38 @@ +from textual import on +from textual.app import App, ComposeResult +from textual.widgets import Header, Select + +LINES = """I must not fear. +Fear is the mind-killer. +Fear is the little-death that brings total obliteration. +I will face my fear. +I will permit it to pass over me and through me.""".splitlines() + +ALTERNATE_LINES = """Twinkle, twinkle, little star, +How I wonder what you are! +Up above the world so high, +Like a diamond in the sky. +Twinkle, twinkle, little star, +How I wonder what you are!""".splitlines() + + +class SelectApp(App): + CSS_PATH = "select.tcss" + + BINDINGS = [("s", "swap", "Swap Select options")] + + def compose(self) -> ComposeResult: + yield Header() + yield Select(zip(LINES, LINES), allow_blank=False) + + @on(Select.Changed) + def select_changed(self, event: Select.Changed) -> None: + self.title = str(event.value) + + def action_swap(self) -> None: + self.query_one(Select).set_options(zip(ALTERNATE_LINES, ALTERNATE_LINES)) + + +if __name__ == "__main__": + app = SelectApp() + app.run() diff --git a/src/textual/widgets/_select.py b/src/textual/widgets/_select.py index 94fcff6dd..dccd35764 100644 --- a/src/textual/widgets/_select.py +++ b/src/textual/widgets/_select.py @@ -1,7 +1,7 @@ from __future__ import annotations from dataclasses import dataclass -from typing import TYPE_CHECKING, Generic, Iterable, Optional, TypeVar +from typing import TYPE_CHECKING, Generic, Iterable, TypeVar, Union from rich.console import RenderableType from rich.text import Text @@ -19,6 +19,24 @@ if TYPE_CHECKING: from typing_extensions import TypeAlias +class _NoSelection: + """Used by the `Select` widget to flag the unselected state.""" + + def __repr__(self) -> str: + return "Select.BLANK" + + +BLANK = _NoSelection() + + +class InvalidSelectValueError(Exception): + """Raised when setting a [`Select`][textual.widgets.Select] to an unknown option.""" + + +class EmptySelectError(Exception): + """Raised when a [`Select`][textual.widgets.Select] has no options and `allow_blank=False`.""" + + class SelectOverlay(OptionList): """The 'pop-up' overlay for the Select control.""" @@ -106,14 +124,6 @@ class SelectCurrent(Horizontal): color: $text-muted; background: transparent; } - SelectCurrent .arrow { - box-sizing: content-box; - width: 1; - height: 1; - padding: 0 0 0 1; - color: $text-muted; - background: transparent; - } """ has_value: var[bool] = var(False) @@ -130,18 +140,18 @@ class SelectCurrent(Horizontal): """ super().__init__() self.placeholder = placeholder - self.label: RenderableType | None = None + self.label: RenderableType | _NoSelection = Select.BLANK - def update(self, label: RenderableType | None) -> None: + def update(self, label: RenderableType | _NoSelection) -> None: """Update the content in the widget. Args: label: A renderable to display, or `None` for the placeholder. """ self.label = label - self.has_value = label is not None + self.has_value = label is not Select.BLANK self.query_one("#label", Static).update( - self.placeholder if label is None else label + self.placeholder if isinstance(label, _NoSelection) else label ) def compose(self) -> ComposeResult: @@ -170,9 +180,11 @@ class Select(Generic[SelectType], Vertical, can_focus=True): A Select displays the current selection. When activated with ++enter++ the widget displays an overlay with a list of all possible options. - """ + BLANK = BLANK + """Constant to flag that the widget has no selection.""" + BINDINGS = [("enter,down,space,up", "show_overlay")] """ | Key(s) | Description | @@ -223,8 +235,13 @@ class Select(Generic[SelectType], Vertical, can_focus=True): """True to show the overlay, otherwise False.""" prompt: var[str] = var[str]("Select") """The prompt to show when no value is selected.""" - value: var[SelectType | None] = var[Optional[SelectType]](None, init=False) - """The value of the select.""" + value: var[SelectType | _NoSelection] = var[Union[SelectType, _NoSelection]](BLANK) + """The value of the selection. + + If the widget has no selection, its value will be [`Select.BLANK`][textual.widgets.Select.BLANK]. + Setting this to an illegal value will raise a [`UnknownSelectValueError`][textual.widgets.select.UnknownSelectValueError] + exception. + """ class Changed(Message): """Posted when the select value was changed. @@ -233,7 +250,7 @@ class Select(Generic[SelectType], Vertical, can_focus=True): """ def __init__( - self, select: Select[SelectType], value: SelectType | None + self, select: Select[SelectType], value: SelectType | _NoSelection ) -> None: """ Initialize the Changed message. @@ -251,52 +268,70 @@ class Select(Generic[SelectType], Vertical, can_focus=True): def __init__( self, - options: Iterable[tuple[str, SelectType]], + options: Iterable[tuple[RenderableType, SelectType]], *, prompt: str = "Select", allow_blank: bool = True, - value: SelectType | None = None, + value: SelectType | _NoSelection = BLANK, name: str | None = None, id: str | None = None, classes: str | None = None, disabled: bool = False, ): - """Initialize the Select control + """Initialize the Select control. Args: - options: Options to select from. + options: Options to select from. If no options are provided then + `allow_blank` must be set to `True`. prompt: Text to show in the control when no option is select. - allow_blank: Allow the selection of a blank option. - value: Initial value (should be one of the values in `options`). + allow_blank: Enables or disables the ability to have the widget in a state + with no selection made, in which case its value is set to the constant + [`Select.BLANK`][textual.widgets.Select.BLANK]. + value: Initial value selected. Should be one of the values in `options`. + If no initial value is set and `allow_blank` is `False`, the widget + will auto-select the first available option. name: The name of the select control. id: The ID of the control the DOM. classes: The CSS classes of the control. disabled: Whether the control is disabled or not. + + Raises: + EmptySelectError: If no options are provided and `allow_blank` is `False`. """ super().__init__(name=name, id=id, classes=classes, disabled=disabled) self._allow_blank = allow_blank self.prompt = prompt - self._initial_options = list(options) - self._legal_values = {value for _, value in self._initial_options} - self._value: SelectType | None = value - self._options = options + self._value = value + self._setup_variables_for_options(options) - def set_options(self, options: Iterable[tuple[RenderableType, SelectType]]) -> None: - """Set the options for the Select. + def _setup_variables_for_options( + self, + options: Iterable[tuple[RenderableType, SelectType]], + ) -> None: + """Setup function for the auxiliary variables related to options. - Args: - options: An iterable of tuples containing (STRING, VALUE). + This method sets up `self._options` and `self._legal_values`. """ - self._options: list[tuple[RenderableType, SelectType | None]] = list(options) - self._legal_values = {value for _, value in self._options} - + self._options: list[tuple[RenderableType, SelectType | _NoSelection]] = [] if self._allow_blank: - self._options.insert(0, ("", None)) + self._options.append(("", self.BLANK)) + self._options.extend(options) + if not self._options: + raise EmptySelectError( + "Select options cannot be empty if selection can't be blank." + ) + + self._legal_values: set[SelectType | _NoSelection] = { + value for _, value in self._options + } + + def _setup_options_renderables(self) -> None: + """Sets up the `Option` renderables associated with the `Select` options.""" self._select_options: list[Option] = [ ( Option(Text(self.prompt, style="dim")) - if value is None + if value == self.BLANK else Option(prompt) ) for prompt, value in self._options @@ -307,13 +342,54 @@ class Select(Generic[SelectType], Vertical, can_focus=True): for option in self._select_options: option_list.add_option(option) - def _validate_value(self, value: SelectType | None) -> SelectType | None: - """Ensure the new value is a valid option or None.""" - if value is not None and value in self._legal_values: - return value - return None + def _init_selected_option(self, hint: SelectType | _NoSelection = BLANK) -> None: + """Initialises the selected option for the `Select`.""" + if hint == self.BLANK and not self._allow_blank: + hint = self._options[0][1] + self.value = hint - def _watch_value(self, value: SelectType | None) -> None: + def set_options(self, options: Iterable[tuple[RenderableType, SelectType]]) -> None: + """Set the options for the Select. + + This will reset the selection. The selection will be empty, if allowed, otherwise + the first valid option is picked. + + Args: + options: An iterable of tuples containing the renderable to display for each + option and the corresponding internal value. + + Raises: + EmptySelectError: If the options iterable is empty and `allow_blank` is + `False`. + """ + self._setup_variables_for_options(options) + self._setup_options_renderables() + self._init_selected_option() + + def _validate_value( + self, value: SelectType | _NoSelection + ) -> SelectType | _NoSelection: + """Ensure the new value is a valid option. + + If `allow_blank` is `True`, `None` is also a valid value and corresponds to no + selection. + + Raises: + UnknownSelectValueError: If the new value does not correspond to any known + value. + """ + if value not in self._legal_values: + # It would make sense to use `None` to flag that the Select has no selection, + # so we provide a helpful message to catch this mistake in case people didn't + # realise we use a special value to flag "no selection". + help_text = " Did you mean to use Select.clear()?" if value is None else "" + raise InvalidSelectValueError( + f"Illegal select value {value!r}." + help_text + ) + + return value + + def _watch_value(self, value: SelectType | _NoSelection) -> None: """Update the current value when it changes.""" self._value = value try: @@ -321,8 +397,8 @@ class Select(Generic[SelectType], Vertical, can_focus=True): except NoMatches: pass else: - if value is None: - select_current.update(None) + if value == self.BLANK: + select_current.update(self.BLANK) else: for index, (prompt, _value) in enumerate(self._options): if _value == value: @@ -338,8 +414,8 @@ class Select(Generic[SelectType], Vertical, can_focus=True): def _on_mount(self, _event: events.Mount) -> None: """Set initial values.""" - self.set_options(self._initial_options) - self.value = self._value + self._setup_options_renderables() + self._init_selected_option(self._value) def _watch_expanded(self, expanded: bool) -> None: """Display or hide overlay.""" @@ -347,7 +423,7 @@ class Select(Generic[SelectType], Vertical, can_focus=True): self.set_class(expanded, "-expanded") if expanded: overlay.focus() - if self.value is None: + if self.value is self.BLANK: overlay.select(None) self.query_one(SelectCurrent).has_value = False else: @@ -394,3 +470,24 @@ class Select(Generic[SelectType], Vertical, can_focus=True): select_current = self.query_one(SelectCurrent) select_current.has_value = True self.expanded = True + + def is_blank(self) -> bool: + """Indicates whether this `Select` is blank or not. + + Returns: + True if the selection is blank, False otherwise. + """ + return self.value == self.BLANK + + def clear(self) -> None: + """Clear the selection if `allow_blank` is `True`. + + Raises: + InvalidSelectValueError: If `allow_blank` is set to `False`. + """ + try: + self.value = self.BLANK + except InvalidSelectValueError: + raise InvalidSelectValueError( + "Can't clear selection if allow_blank is set to False." + ) from None diff --git a/src/textual/widgets/select.py b/src/textual/widgets/select.py new file mode 100644 index 000000000..e26c4f721 --- /dev/null +++ b/src/textual/widgets/select.py @@ -0,0 +1,3 @@ +from ._select import EmptySelectError, InvalidSelectValueError + +__all__ = ["EmptySelectError", "InvalidSelectValueError"] diff --git a/tests/select/test_blank_and_clear.py b/tests/select/test_blank_and_clear.py new file mode 100644 index 000000000..5fa24cd2a --- /dev/null +++ b/tests/select/test_blank_and_clear.py @@ -0,0 +1,65 @@ +import pytest + +from textual.app import App +from textual.widgets import Select +from textual.widgets.select import InvalidSelectValueError + +SELECT_OPTIONS = [(str(n), n) for n in range(3)] + + +async def test_value_is_blank_by_default(): + class SelectApp(App[None]): + def compose(self): + yield Select(SELECT_OPTIONS) + + app = SelectApp() + async with app.run_test(): + select = app.query_one(Select) + assert select.value == Select.BLANK + assert select.is_blank() + + +async def test_setting_and_checking_blank(): + class SelectApp(App[None]): + def compose(self): + yield Select(SELECT_OPTIONS) + + app = SelectApp() + async with app.run_test(): + select = app.query_one(Select) + assert select.value == Select.BLANK + assert select.is_blank() + + select.value = 0 + assert select.value != Select.BLANK + assert not select.is_blank() + + select.value = Select.BLANK + assert select.value == Select.BLANK + assert select.is_blank() + + +async def test_clear_with_allow_blanks(): + class SelectApp(App[None]): + def compose(self): + yield Select(SELECT_OPTIONS, value=1) + + app = SelectApp() + async with app.run_test(): + select = app.query_one(Select) + assert select.value == 1 # Sanity check. + select.clear() + assert select.is_blank() + + +async def test_clear_fails_if_allow_blank_is_false(): + class SelectApp(App[None]): + def compose(self): + yield Select(SELECT_OPTIONS, allow_blank=False) + + app = SelectApp() + async with app.run_test(): + select = app.query_one(Select) + assert not select.is_blank() + with pytest.raises(InvalidSelectValueError): + select.clear() diff --git a/tests/select/test_changed_message.py b/tests/select/test_changed_message.py index d0519b894..7f876ac1f 100644 --- a/tests/select/test_changed_message.py +++ b/tests/select/test_changed_message.py @@ -17,6 +17,16 @@ class SelectApp(App[None]): self.changed_messages.append(event) +async def test_message_control(): + app = SelectApp() + async with app.run_test() as pilot: + await pilot.click(Select) + await pilot.click(SelectOverlay, offset=(2, 3)) + await pilot.pause() + message = app.changed_messages[0] + assert message.control is app.query_one(Select) + + async def test_selecting_posts_message(): app = SelectApp() async with app.run_test() as pilot: diff --git a/tests/select/test_empty_select.py b/tests/select/test_empty_select.py new file mode 100644 index 000000000..75bf722eb --- /dev/null +++ b/tests/select/test_empty_select.py @@ -0,0 +1,53 @@ +import pytest + +from textual.app import App +from textual.widgets import Select +from textual.widgets.select import EmptySelectError + + +async def test_empty_select_is_ok_with_blanks(): + class SelectApp(App[None]): + def compose(self): + yield Select([]) + + app = SelectApp() + async with app.run_test(): + # Sanity check: + assert app.query_one(Select).is_blank() + + +async def test_empty_set_options_is_ok_with_blanks(): + class SelectApp(App[None]): + def compose(self): + yield Select([(str(n), n) for n in range(3)], value=0) + + app = SelectApp() + async with app.run_test(): + select = app.query_one(Select) + assert not select.is_blank() # Sanity check. + select.set_options([]) + assert select.is_blank() # Sanity check. + + +async def test_empty_select_raises_exception_if_allow_blank_is_false(): + class SelectApp(App[None]): + def compose(self): + yield Select([], allow_blank=False) + + app = SelectApp() + with pytest.raises(EmptySelectError): + async with app.run_test(): + pass + + +async def test_empty_set_options_raises_exception_if_allow_blank_is_false(): + class SelectApp(App[None]): + def compose(self): + yield Select([(str(n), n) for n in range(3)], allow_blank=False) + + app = SelectApp() + async with app.run_test(): + select = app.query_one(Select) + assert not select.is_blank() # Sanity check. + with pytest.raises(EmptySelectError): + select.set_options([]) diff --git a/tests/select/test_initial_value.py b/tests/select/test_initial_value.py deleted file mode 100644 index 49aaceb18..000000000 --- a/tests/select/test_initial_value.py +++ /dev/null @@ -1,16 +0,0 @@ -"""Initially https://github.com/Textualize/textual/discussions/3037""" - -from textual.app import App, ComposeResult -from textual.widgets import Select - - -class SelectApp(App[None]): - INITIAL_VALUE = 3 - - def compose(self) -> ComposeResult: - yield Select[int]([(str(n), n) for n in range(10)], value=self.INITIAL_VALUE) - - -async def test_select_initial_value(): - async with SelectApp().run_test() as pilot: - assert pilot.app.query_one(Select).value == SelectApp.INITIAL_VALUE diff --git a/tests/select/test_value.py b/tests/select/test_value.py new file mode 100644 index 000000000..c47e28500 --- /dev/null +++ b/tests/select/test_value.py @@ -0,0 +1,127 @@ +import pytest + +from textual.app import App +from textual.widgets import Select +from textual.widgets.select import InvalidSelectValueError + +SELECT_OPTIONS = [(str(n), n) for n in range(3)] +MORE_OPTIONS = [(str(n), n) for n in range(5, 8)] + + +class SelectApp(App[None]): + def __init__(self, initial_value=Select.BLANK): + self.initial_value = initial_value + super().__init__() + + def compose(self): + yield Select[int](SELECT_OPTIONS, value=self.initial_value) + + +async def test_initial_value_is_validated(): + """The initial value should be respected if it is a legal value. + + Regression test for https://github.com/Textualize/textual/discussions/3037. + """ + app = SelectApp(1) + async with app.run_test(): + assert app.query_one(Select).value == 1 + + +async def test_value_unknown_option_raises_error(): + """Setting the value to an unknown value raises an error.""" + app = SelectApp() + async with app.run_test(): + with pytest.raises(InvalidSelectValueError): + app.query_one(Select).value = "french fries" + + +async def test_initial_value_inside_compose_is_validated(): + """Setting the value to an unknown value inside compose should raise an error.""" + + class SelectApp(App[None]): + def compose(self): + s = Select[int](SELECT_OPTIONS) + s.value = 73 + yield s + + app = SelectApp() + with pytest.raises(InvalidSelectValueError): + async with app.run_test(): + pass + + +async def test_value_assign_to_blank(): + """Setting the value to BLANK should work with default `allow_blank` value.""" + app = SelectApp(1) + async with app.run_test(): + select = app.query_one(Select) + assert select.value == 1 + select.value = Select.BLANK + assert select.is_blank() + + +async def test_initial_value_is_picked_if_allow_blank_is_false(): + """The initial value should be picked by default if allow_blank=False.""" + + class SelectApp(App[None]): + def compose(self): + yield Select[int](SELECT_OPTIONS, allow_blank=False) + + app = SelectApp() + async with app.run_test(): + assert app.query_one(Select).value == 0 + + +async def test_initial_value_is_picked_if_allow_blank_is_false(): + """The initial value should be respected even if allow_blank=False.""" + + class SelectApp(App[None]): + def compose(self): + yield Select[int](SELECT_OPTIONS, value=2, allow_blank=False) + + app = SelectApp() + async with app.run_test(): + assert app.query_one(Select).value == 2 + + +async def test_set_value_to_blank_with_allow_blank_false(): + """Setting the value to BLANK with allow_blank=False should raise an error.""" + + class SelectApp(App[None]): + def compose(self): + yield Select[int](SELECT_OPTIONS, allow_blank=False) + + app = SelectApp() + async with app.run_test(): + with pytest.raises(InvalidSelectValueError): + app.query_one(Select).value = Select.BLANK + + +async def test_set_options_resets_value_to_blank(): + """Resetting the options should reset the value to BLANK.""" + + class SelectApp(App[None]): + def compose(self): + yield Select[int](SELECT_OPTIONS, value=2) + + app = SelectApp() + async with app.run_test(): + select = app.query_one(Select) + assert select.value == 2 + select.set_options(MORE_OPTIONS) + assert select.is_blank() + + +async def test_set_options_resets_value_if_allow_blank_is_false(): + """Resetting the options should reset the value if allow_blank=False.""" + + class SelectApp(App[None]): + def compose(self): + yield Select[int](SELECT_OPTIONS, allow_blank=False) + + app = SelectApp() + async with app.run_test(): + select = app.query_one(Select) + assert select.value == 0 + select.set_options(MORE_OPTIONS) + assert select.value > 2 diff --git a/tests/select/test_value_validation.py b/tests/select/test_value_validation.py deleted file mode 100644 index c01c7aacb..000000000 --- a/tests/select/test_value_validation.py +++ /dev/null @@ -1,37 +0,0 @@ -from textual.app import App -from textual.widgets import Select - - -class SelectApp(App[None]): - def __init__(self, initial_value=None): - self.initial_value = initial_value - super().__init__() - - def compose(self): - yield Select[int]([(str(n), n) for n in range(3)], value=self.initial_value) - - -async def test_value_assignment_is_validated(): - app = SelectApp() - async with app.run_test() as pilot: - app.query_one(Select).value = "french fries" - await pilot.pause() # Let watchers/validators do their thing. - assert app.query_one(Select).value is None - - -async def test_initial_value_is_validated(): - app = SelectApp(1) - async with app.run_test(): - assert app.query_one(Select).value == 1 - - -async def test_initial_value_inside_compose_is_validated(): - class SelectApp(App[None]): - def compose(self): - s = Select[int]([(str(n), n) for n in range(3)]) - s.value = 73 - yield s - - app = SelectApp() - async with app.run_test(): - assert app.query_one(Select).value is None diff --git a/tests/snapshot_tests/test_snapshots.py b/tests/snapshot_tests/test_snapshots.py index 6856c89d7..ee0590625 100644 --- a/tests/snapshot_tests/test_snapshots.py +++ b/tests/snapshot_tests/test_snapshots.py @@ -105,7 +105,9 @@ def test_input_suggestions(snap_compare): async def run_before(pilot): pilot.app.query_one(Input).cursor_blink = False - assert snap_compare(SNAPSHOT_APPS_DIR / "input_suggestions.py", press=[], run_before=run_before) + assert snap_compare( + SNAPSHOT_APPS_DIR / "input_suggestions.py", press=[], run_before=run_before + ) def test_buttons_render(snap_compare): @@ -356,6 +358,18 @@ def test_select_expanded_changed(snap_compare): ) +def test_select_no_blank_has_default_value(snap_compare): + """Make sure that the first value is selected by default if allow_blank=False.""" + assert snap_compare(WIDGET_EXAMPLES_DIR / "select_widget_no_blank.py") + + +def test_select_set_options(snap_compare): + assert snap_compare( + WIDGET_EXAMPLES_DIR / "select_widget_no_blank.py", + press=["s"], + ) + + def test_sparkline_render(snap_compare): assert snap_compare(WIDGET_EXAMPLES_DIR / "sparkline.py")