708 lines
23 KiB
Python
708 lines
23 KiB
Python
from __future__ import annotations
|
|
|
|
from dataclasses import dataclass
|
|
from typing import TYPE_CHECKING, Generic, Hashable, Iterable, TypeVar, Union
|
|
|
|
import rich.repr
|
|
from rich.console import RenderableType
|
|
from rich.text import Text
|
|
|
|
from textual import events, on
|
|
from textual.binding import Binding
|
|
from textual.containers import Horizontal, Vertical
|
|
from textual.css.query import NoMatches
|
|
from textual.message import Message
|
|
from textual.reactive import reactive, var
|
|
from textual.timer import Timer
|
|
from textual.widgets import Static
|
|
from textual.widgets._option_list import Option, OptionList
|
|
|
|
if TYPE_CHECKING:
|
|
from typing_extensions import TypeAlias
|
|
|
|
from textual.app import ComposeResult
|
|
|
|
|
|
class NoSelection:
|
|
"""Used by the `Select` widget to flag the unselected state. See [`Select.BLANK`][textual.widgets.Select.BLANK]."""
|
|
|
|
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."""
|
|
|
|
BINDINGS = [("escape", "dismiss", "Dismiss menu")]
|
|
|
|
@dataclass
|
|
class Dismiss(Message):
|
|
"""Inform ancestor the overlay should be dismissed."""
|
|
|
|
lost_focus: bool = False
|
|
"""True if the overlay lost focus."""
|
|
|
|
@dataclass
|
|
class UpdateSelection(Message):
|
|
"""Inform ancestor the selection was changed."""
|
|
|
|
option_index: int
|
|
"""The index of the new selection."""
|
|
|
|
def __init__(self, type_to_search: bool = True) -> None:
|
|
super().__init__()
|
|
self._type_to_search = type_to_search
|
|
"""If True (default), the user can type to search for a matching option and the cursor will jump to it."""
|
|
|
|
self._search_query: str = ""
|
|
"""The current search query used to find a matching option and jump to it."""
|
|
|
|
self._search_reset_delay: float = 0.7
|
|
"""The number of seconds to wait after the most recent key press before resetting the search query."""
|
|
|
|
def on_mount(self) -> None:
|
|
def reset_query() -> None:
|
|
self._search_query = ""
|
|
|
|
self._search_reset_timer = Timer(
|
|
self, self._search_reset_delay, callback=reset_query
|
|
)
|
|
|
|
def watch_has_focus(self, value: bool) -> None:
|
|
self._search_query = ""
|
|
if value:
|
|
self._search_reset_timer._start()
|
|
else:
|
|
self._search_reset_timer.reset()
|
|
self._search_reset_timer.stop()
|
|
super().watch_has_focus(value)
|
|
|
|
async def _on_key(self, event: events.Key) -> None:
|
|
if not self._type_to_search:
|
|
return
|
|
|
|
self._search_reset_timer.reset()
|
|
|
|
if event.character is not None and event.is_printable:
|
|
event.time = 0
|
|
event.stop()
|
|
event.prevent_default()
|
|
|
|
# Update the search query and jump to the next option that matches.
|
|
self._search_query += event.character
|
|
index = self._find_search_match(self._search_query)
|
|
if index is not None:
|
|
self.select(index)
|
|
|
|
def check_consume_key(self, key: str, character: str | None = None) -> bool:
|
|
"""Check if the widget may consume the given key."""
|
|
return (
|
|
self._type_to_search and character is not None and character.isprintable()
|
|
)
|
|
|
|
def select(self, index: int | None) -> None:
|
|
"""Move selection.
|
|
|
|
Args:
|
|
index: Index of new selection.
|
|
"""
|
|
self.highlighted = index
|
|
self.scroll_to_highlight()
|
|
|
|
def _find_search_match(self, query: str) -> int | None:
|
|
"""A simple substring search which favors options containing the substring
|
|
earlier in the prompt.
|
|
|
|
Args:
|
|
query: The substring to search for.
|
|
|
|
Returns:
|
|
The index of the option that matches the query, or `None` if no match is found.
|
|
"""
|
|
best_match: int | None = None
|
|
minimum_index: int | None = None
|
|
|
|
query = query.lower()
|
|
for index, option in enumerate(self._options):
|
|
prompt = option.prompt
|
|
if isinstance(prompt, Text):
|
|
lower_prompt = prompt.plain.lower()
|
|
elif isinstance(prompt, str):
|
|
lower_prompt = prompt.lower()
|
|
else:
|
|
continue
|
|
|
|
match_index = lower_prompt.find(query)
|
|
if match_index != -1 and (
|
|
minimum_index is None or match_index < minimum_index
|
|
):
|
|
best_match = index
|
|
minimum_index = match_index
|
|
|
|
return best_match
|
|
|
|
def action_dismiss(self) -> None:
|
|
"""Dismiss the overlay."""
|
|
self.post_message(self.Dismiss())
|
|
|
|
def _on_blur(self, _event: events.Blur) -> None:
|
|
"""On blur we want to dismiss the overlay."""
|
|
self.post_message(self.Dismiss(lost_focus=True))
|
|
self.suppress_click()
|
|
|
|
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
|
|
"""Inform parent when an option is selected."""
|
|
event.stop()
|
|
self.post_message(self.UpdateSelection(event.option_index))
|
|
|
|
def on_option_list_option_highlighted(
|
|
self, event: OptionList.OptionHighlighted
|
|
) -> None:
|
|
"""Stop option list highlighted messages leaking."""
|
|
event.stop()
|
|
|
|
|
|
class SelectCurrent(Horizontal):
|
|
"""Displays the currently selected option."""
|
|
|
|
DEFAULT_CSS = """
|
|
SelectCurrent {
|
|
border: tall $border-blurred;
|
|
color: $foreground;
|
|
background: $surface;
|
|
width: 1fr;
|
|
height: auto;
|
|
padding: 0 2;
|
|
|
|
&.-textual-compact {
|
|
border: none !important;
|
|
}
|
|
|
|
&:ansi {
|
|
border: tall ansi_blue;
|
|
color: ansi_default;
|
|
background: ansi_default;
|
|
}
|
|
|
|
Static#label {
|
|
width: 1fr;
|
|
height: auto;
|
|
color: $foreground 50%;
|
|
background: transparent;
|
|
}
|
|
|
|
&.-has-value Static#label {
|
|
color: $foreground;
|
|
}
|
|
|
|
.arrow {
|
|
box-sizing: content-box;
|
|
width: 1;
|
|
height: 1;
|
|
padding: 0 0 0 1;
|
|
color: $foreground 50%;
|
|
background: transparent;
|
|
}
|
|
}
|
|
"""
|
|
|
|
has_value: var[bool] = var(False)
|
|
"""True if there is a current value, or False if it is None."""
|
|
|
|
class Toggle(Message):
|
|
"""Request toggle overlay."""
|
|
|
|
def __init__(self, placeholder: str) -> None:
|
|
"""Initialize the SelectCurrent.
|
|
|
|
Args:
|
|
placeholder: A string to display when there is nothing selected.
|
|
"""
|
|
super().__init__()
|
|
self.placeholder = placeholder
|
|
self.label: RenderableType | NoSelection = Select.BLANK
|
|
|
|
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 Select.BLANK
|
|
self.query_one("#label", Static).update(
|
|
self.placeholder if isinstance(label, NoSelection) else label
|
|
)
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Compose label and down arrow."""
|
|
yield Static(self.placeholder, id="label")
|
|
yield Static("▼", classes="arrow down-arrow")
|
|
yield Static("▲", classes="arrow up-arrow")
|
|
|
|
def _watch_has_value(self, has_value: bool) -> None:
|
|
"""Toggle the class."""
|
|
self.set_class(has_value, "-has-value")
|
|
|
|
def _on_click(self, event: events.Click) -> None:
|
|
"""Inform ancestor we want to toggle."""
|
|
event.stop()
|
|
self.post_message(self.Toggle())
|
|
|
|
|
|
SelectType = TypeVar("SelectType", bound=Hashable)
|
|
"""The type used for data in the Select."""
|
|
SelectOption: TypeAlias = "tuple[str, SelectType]"
|
|
"""The type used for options in the Select."""
|
|
|
|
|
|
class Select(Generic[SelectType], Vertical, can_focus=True):
|
|
"""Widget to select from a list of possible options.
|
|
|
|
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 = [
|
|
Binding("enter,down,space,up", "show_overlay", "Show menu", show=False),
|
|
]
|
|
"""
|
|
| Key(s) | Description |
|
|
| :- | :- |
|
|
| enter,down,space,up | Activate the overlay |
|
|
"""
|
|
|
|
DEFAULT_CSS = """
|
|
Select {
|
|
height: auto;
|
|
color: $foreground;
|
|
|
|
&.-textual-compact {
|
|
& > SelectCurrent {
|
|
padding: 0 1 0 0;
|
|
border: none !important;
|
|
}
|
|
}
|
|
|
|
.up-arrow {
|
|
display: none;
|
|
}
|
|
|
|
&:focus > SelectCurrent {
|
|
border: tall $border;
|
|
background-tint: $foreground 5%;
|
|
}
|
|
|
|
& > SelectOverlay {
|
|
width: 1fr;
|
|
display: none;
|
|
height: auto;
|
|
max-height: 12;
|
|
overlay: screen;
|
|
constrain: none inside;
|
|
color: $foreground;
|
|
border: tall $border-blurred;
|
|
background: $surface;
|
|
&:focus {
|
|
background-tint: $foreground 5%;
|
|
}
|
|
& > .option-list--option {
|
|
padding: 0 1;
|
|
}
|
|
}
|
|
|
|
&.-expanded {
|
|
.down-arrow {
|
|
display: none;
|
|
}
|
|
.up-arrow {
|
|
display: block;
|
|
}
|
|
& > SelectOverlay {
|
|
display: block;
|
|
}
|
|
}
|
|
|
|
}
|
|
|
|
"""
|
|
|
|
expanded: var[bool] = var(False, init=False)
|
|
"""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 | NoSelection] = var[Union[SelectType, NoSelection]](
|
|
BLANK, init=False
|
|
)
|
|
"""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 [`InvalidSelectValueError`][textual.widgets.select.InvalidSelectValueError]
|
|
exception.
|
|
"""
|
|
|
|
compact = reactive(False, toggle_class="-textual-compact")
|
|
"""Make the select compact (without borders)."""
|
|
|
|
@rich.repr.auto
|
|
class Changed(Message):
|
|
"""Posted when the select value was changed.
|
|
|
|
This message can be handled using a `on_select_changed` method.
|
|
"""
|
|
|
|
def __init__(
|
|
self, select: Select[SelectType], value: SelectType | NoSelection
|
|
) -> None:
|
|
"""
|
|
Initialize the Changed message.
|
|
"""
|
|
super().__init__()
|
|
self.select = select
|
|
"""The select widget."""
|
|
self.value = value
|
|
"""The value of the Select when it changed."""
|
|
|
|
def __rich_repr__(self) -> rich.repr.Result:
|
|
yield self.select
|
|
yield self.value
|
|
|
|
@property
|
|
def control(self) -> Select[SelectType]:
|
|
"""The Select that sent the message."""
|
|
return self.select
|
|
|
|
def __init__(
|
|
self,
|
|
options: Iterable[tuple[RenderableType, SelectType]],
|
|
*,
|
|
prompt: str = "Select",
|
|
allow_blank: bool = True,
|
|
value: SelectType | NoSelection = BLANK,
|
|
type_to_search: bool = True,
|
|
name: str | None = None,
|
|
id: str | None = None,
|
|
classes: str | None = None,
|
|
disabled: bool = False,
|
|
tooltip: RenderableType | None = None,
|
|
compact: bool = False,
|
|
):
|
|
"""Initialize the Select control.
|
|
|
|
Args:
|
|
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 selected.
|
|
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.
|
|
type_to_search: If `True`, typing will search for options.
|
|
name: The name of the select control.
|
|
id: The ID of the control in the DOM.
|
|
classes: The CSS classes of the control.
|
|
disabled: Whether the control is disabled or not.
|
|
tooltip: Optional tooltip.
|
|
compact: Enable compact select (without borders).
|
|
|
|
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._value = value
|
|
self._setup_variables_for_options(options)
|
|
self._type_to_search = type_to_search
|
|
if tooltip is not None:
|
|
self.tooltip = tooltip
|
|
self.compact = compact
|
|
|
|
@classmethod
|
|
def from_values(
|
|
cls,
|
|
values: Iterable[SelectType],
|
|
*,
|
|
prompt: str = "Select",
|
|
allow_blank: bool = True,
|
|
value: SelectType | NoSelection = BLANK,
|
|
type_to_search: bool = True,
|
|
name: str | None = None,
|
|
id: str | None = None,
|
|
classes: str | None = None,
|
|
disabled: bool = False,
|
|
compact: bool = False,
|
|
) -> Select[SelectType]:
|
|
"""Initialize the Select control with values specified by an arbitrary iterable
|
|
|
|
The options shown in the control are computed by calling the built-in `str`
|
|
on each value.
|
|
|
|
Args:
|
|
values: Values used to generate options to select from.
|
|
prompt: Text to show in the control when no option is selected.
|
|
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 `values`.
|
|
If no initial value is set and `allow_blank` is `False`, the widget
|
|
will auto-select the first available value.
|
|
type_to_search: If `True`, typing will search for options.
|
|
name: The name of the select control.
|
|
id: The ID of the control in the DOM.
|
|
classes: The CSS classes of the control.
|
|
disabled: Whether the control is disabled or not.
|
|
compact: Enable compact style?
|
|
|
|
Returns:
|
|
A new Select widget with the provided values as options.
|
|
"""
|
|
options_iterator = [(str(value), value) for value in values]
|
|
|
|
return cls(
|
|
options_iterator,
|
|
prompt=prompt,
|
|
allow_blank=allow_blank,
|
|
value=value,
|
|
type_to_search=type_to_search,
|
|
name=name,
|
|
id=id,
|
|
classes=classes,
|
|
disabled=disabled,
|
|
compact=compact,
|
|
)
|
|
|
|
@property
|
|
def selection(self) -> SelectType | None:
|
|
"""The currently selected item.
|
|
|
|
Unlike [value][textual.widgets.Select.value], this will not return Blanks.
|
|
If nothing is selected, this will return `None`.
|
|
|
|
"""
|
|
value = self.value
|
|
if isinstance(value, NoSelection):
|
|
return None
|
|
return value
|
|
|
|
def _setup_variables_for_options(
|
|
self,
|
|
options: Iterable[tuple[RenderableType, SelectType]],
|
|
) -> None:
|
|
"""Setup function for the auxiliary variables related to options.
|
|
|
|
This method sets up `self._options` and `self._legal_values`.
|
|
"""
|
|
self._options: list[tuple[RenderableType, SelectType | NoSelection]] = []
|
|
if self._allow_blank:
|
|
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."""
|
|
options: list[Option] = [
|
|
(
|
|
Option(Text(self.prompt, style="dim"))
|
|
if value == self.BLANK
|
|
else Option(prompt)
|
|
)
|
|
for prompt, value in self._options
|
|
]
|
|
|
|
option_list = self.query_one(SelectOverlay)
|
|
option_list.clear_options()
|
|
option_list.add_options(options)
|
|
|
|
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 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:
|
|
InvalidSelectValueError: 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:
|
|
select_current = self.query_one(SelectCurrent)
|
|
except NoMatches:
|
|
pass
|
|
else:
|
|
if value == self.BLANK:
|
|
select_current.update(self.BLANK)
|
|
else:
|
|
for index, (prompt, _value) in enumerate(self._options):
|
|
if _value == value:
|
|
select_overlay = self.query_one(SelectOverlay)
|
|
select_overlay.highlighted = index
|
|
select_current.update(prompt)
|
|
break
|
|
self.post_message(self.Changed(self, value))
|
|
|
|
def compose(self) -> ComposeResult:
|
|
"""Compose Select with overlay and current value."""
|
|
yield SelectCurrent(self.prompt)
|
|
yield SelectOverlay(type_to_search=self._type_to_search).data_bind(
|
|
compact=Select.compact
|
|
)
|
|
|
|
def _on_mount(self, _event: events.Mount) -> None:
|
|
"""Set initial values."""
|
|
self._setup_options_renderables()
|
|
self._init_selected_option(self._value)
|
|
|
|
def _watch_expanded(self, expanded: bool) -> None:
|
|
"""Display or hide overlay."""
|
|
try:
|
|
overlay = self.query_one(SelectOverlay)
|
|
except NoMatches:
|
|
# The widget has likely been removed
|
|
return
|
|
self.set_class(expanded, "-expanded")
|
|
if expanded:
|
|
overlay.focus(scroll_visible=False)
|
|
if self.value is self.BLANK:
|
|
overlay.select(None)
|
|
self.query_one(SelectCurrent).has_value = False
|
|
else:
|
|
value = self.value
|
|
for index, (_prompt, prompt_value) in enumerate(self._options):
|
|
if value == prompt_value:
|
|
overlay.select(index)
|
|
break
|
|
self.query_one(SelectCurrent).has_value = True
|
|
|
|
@on(SelectCurrent.Toggle)
|
|
def _select_current_toggle(self, event: SelectCurrent.Toggle) -> None:
|
|
"""Show the overlay when toggled."""
|
|
event.stop()
|
|
self.expanded = not self.expanded
|
|
|
|
@on(SelectOverlay.Dismiss)
|
|
def _select_overlay_dismiss(self, event: SelectOverlay.Dismiss) -> None:
|
|
"""Dismiss the overlay."""
|
|
event.stop()
|
|
self.expanded = False
|
|
if not event.lost_focus:
|
|
# If the overlay didn't lose focus, we want to re-focus the select.
|
|
self.focus()
|
|
|
|
@on(SelectOverlay.UpdateSelection)
|
|
def _update_selection(self, event: SelectOverlay.UpdateSelection) -> None:
|
|
"""Update the current selection."""
|
|
event.stop()
|
|
value = self._options[event.option_index][1]
|
|
if value != self.value:
|
|
self.value = value
|
|
|
|
self.focus()
|
|
self.expanded = False
|
|
|
|
def action_show_overlay(self) -> None:
|
|
"""Show the overlay."""
|
|
select_current = self.query_one(SelectCurrent)
|
|
select_current.has_value = True
|
|
self.expanded = True
|
|
# If we haven't opened the overlay yet, highlight the first option.
|
|
select_overlay = self.query_one(SelectOverlay)
|
|
if select_overlay.highlighted is None:
|
|
select_overlay.action_first()
|
|
|
|
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
|
|
|
|
def _watch_prompt(self, prompt: str) -> None:
|
|
if not self.is_mounted:
|
|
return
|
|
select_current = self.query_one(SelectCurrent)
|
|
select_current.placeholder = prompt
|
|
if not self._allow_blank:
|
|
return
|
|
if self.value == self.BLANK:
|
|
select_current.update(self.BLANK)
|
|
option_list = self.query_one(SelectOverlay)
|
|
option_list.replace_option_prompt_at_index(0, Text(prompt, style="dim"))
|