Improve Select.

Use a special value to flag a blank selection, add methods is_blank and clear, slightly refactor the way the widget is setup to avoid having attributes _initial_options and _options.
Implement exceptions to be raised when the widget is entering bad state.
This commit is contained in:
Rodrigo Girão Serrão
2023-11-01 12:27:22 +00:00
parent 8bb247ea15
commit 8997615430
10 changed files with 455 additions and 101 deletions

View File

@@ -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()

View File

@@ -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

View File

@@ -0,0 +1,3 @@
from ._select import EmptySelectError, InvalidSelectValueError
__all__ = ["EmptySelectError", "InvalidSelectValueError"]

View File

@@ -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()

View File

@@ -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:

View File

@@ -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([])

View File

@@ -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

127
tests/select/test_value.py Normal file
View File

@@ -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

View File

@@ -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

View File

@@ -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")