mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
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:
38
docs/examples/widgets/select_widget_no_blank.py
Normal file
38
docs/examples/widgets/select_widget_no_blank.py
Normal 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()
|
||||
@@ -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
|
||||
|
||||
3
src/textual/widgets/select.py
Normal file
3
src/textual/widgets/select.py
Normal file
@@ -0,0 +1,3 @@
|
||||
from ._select import EmptySelectError, InvalidSelectValueError
|
||||
|
||||
__all__ = ["EmptySelectError", "InvalidSelectValueError"]
|
||||
65
tests/select/test_blank_and_clear.py
Normal file
65
tests/select/test_blank_and_clear.py
Normal 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()
|
||||
@@ -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:
|
||||
|
||||
53
tests/select/test_empty_select.py
Normal file
53
tests/select/test_empty_select.py
Normal 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([])
|
||||
@@ -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
127
tests/select/test_value.py
Normal 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
|
||||
@@ -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
|
||||
@@ -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")
|
||||
|
||||
|
||||
Reference in New Issue
Block a user