Select widget (#2501)

* overlay rule

* select WIP

* select control, made binding description optional

* changelog

* style tweak

* Added constrain

* changelog

* test fix

* drop markup, tidy

* tidy

* select namespace

* tests

* docs

* Added changed event

* changelog

* expanded

* tests and snapshits

* examples and docs

* simplify

* update reactive attributes

* type fix

* docstrings

* allow renderables

* superfluous init

* typing fix

* optimization

* revert optimizations

* fixed words

* changelog

* docstrings

* don't need this

* changelog

* comment

* Update docs/widgets/select.md

Co-authored-by: Dave Pearson <davep@davep.org>

* review changes

* review updates

---------

Co-authored-by: Dave Pearson <davep@davep.org>
This commit is contained in:
Will McGugan
2023-05-08 10:55:39 +01:00
committed by GitHub
parent c2a19bd632
commit 7db7139bb8
32 changed files with 3731 additions and 2564 deletions

View File

@@ -18,23 +18,24 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- The DataTable cursor is now scrolled into view when the cursor coordinate is changed programmatically https://github.com/Textualize/textual/issues/2459
- run_worker exclusive parameter is now `False` by default https://github.com/Textualize/textual/pull/2470
- Added `always_update` as an optional argument for `reactive.var`
- Made Binding description default to empty string, which is equivalent to show=False https://github.com/Textualize/textual/pull/2501
- Modified Message to allow it to be used as a dataclass https://github.com/Textualize/textual/pull/2501
### Added
- Experimental: Added "overlay" rule https://github.com/Textualize/textual/pull/2501
- Experimental: Added "constrain" rule https://github.com/Textualize/textual/pull/2501
- Added textual.widgets.Select https://github.com/Textualize/textual/pull/2501
- Added Region.translate_inside https://github.com/Textualize/textual/pull/2501
- `TabbedContent` now takes kwargs `id`, `name`, `classes`, and `disabled`, upon initialization, like other widgets https://github.com/Textualize/textual/pull/2497
### Added
- Method `DataTable.move_cursor` https://github.com/Textualize/textual/issues/2472
### Added
- Added `OptionList.add_options` https://github.com/Textualize/textual/pull/2508
### Added
- Added `TreeNode.is_root` https://github.com/Textualize/textual/pull/2510
- Added `TreeNode.remove_children` https://github.com/Textualize/textual/pull/2510
- Added `TreeNode.remove` https://github.com/Textualize/textual/pull/2510
## [0.23.0] - 2023-05-03
### Fixed

View File

@@ -0,0 +1,11 @@
Screen {
align: center top;
}
Select {
width: 60;
margin: 2;
}
Input {
width: 60;
}

View File

@@ -0,0 +1,26 @@
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()
class SelectApp(App):
CSS_PATH = "select.css"
def compose(self) -> ComposeResult:
yield Header()
yield Select((line, line) for line in LINES)
@on(Select.Changed)
def select_changed(self, event: Select.Changed) -> None:
self.title = str(event.value)
if __name__ == "__main__":
app = SelectApp()
app.run()

View File

@@ -188,6 +188,16 @@ A collection of radio buttons, that enforces uniqueness.
```{.textual path="docs/examples/widgets/radio_set.py"}
```
## Select
Select from a number of possible options.
[Select reference](./widgets/select.md){ .md-button .md-button--primary }
```{.textual path="docs/examples/widgets/select_widget.py" press="tab,enter,down,down"}
```
## Static
Displays simple static content. Typically used as a base class.

90
docs/widgets/select.md Normal file
View File

@@ -0,0 +1,90 @@
# Select
!!! tip "Added in version 0.24.0"
A Select widget is a compact control to allow the user to select between a number of possible options.
- [X] Focusable
- [ ] Container
The options in a select control may be passed in to the constructor or set later with [set_options][textual.widgets.Select.set_options].
Options should be given as a sequence of tuples consisting of two values: the first is the string (or [Rich Renderable](https://rich.readthedocs.io/en/latest/protocol.html)) to display in the control and list of options, the second is the value of option.
The value of the currently selected option is stored in the `value` attribute of the widget, and the `value` attribute of the [Changed][textual.widgets.Select.Changed] message.
## Typing
The `Select` control is a typing Generic which allows you to set the type of the option values.
For instance, if the data type for your values is an integer, you would type the widget as follows:
```python
options = [("First", 1), ("Second", 2)]
my_select: Select[int] = Select(options)
```
!!! note
Typing is entirely optional.
If you aren't familiar with typing or don't want to worry about it right now, feel free to ignore it.
## Example
The following example presents a `Select` with a number of options.
=== "Output"
```{.textual path="docs/examples/widgets/select_widget.py"}
```
=== "Output (expanded)"
```{.textual path="docs/examples/widgets/select_widget.py" press="tab,enter,down,down"}
```
=== "select_widget.py"
```python
--8<-- "docs/examples/widgets/select_widget.py"
```
=== "select.css"
```sass
--8<-- "docs/examples/widgets/select.css"
```
## Messages
- [Select.Changed][textual.widgets.Select.Changed]
## Reactive attributes
| Name | Type | Default | Description |
| ---------- | -------------------- | ------- | ----------------------------------- |
| `expanded` | `bool` | `False` | True to expand the options overlay. |
| `value` | `SelectType \| None` | `None` | Current value of the Select. |
## Bindings
The Select widget defines the following bindings:
::: textual.widgets.Select.BINDINGS
options:
show_root_heading: false
show_root_toc_entry: false
---
::: textual.widgets.Select
options:
heading_level: 2

View File

@@ -149,6 +149,7 @@ nav:
- "widgets/progress_bar.md"
- "widgets/radiobutton.md"
- "widgets/radioset.md"
- "widgets/select.md"
- "widgets/static.md"
- "widgets/switch.md"
- "widgets/tabbed_content.md"

View File

@@ -35,7 +35,7 @@ def arrange(
dock_layers: defaultdict[str, list[Widget]] = defaultdict(list)
for child in children:
if child.display:
dock_layers[child.styles.layer or "default"].append(child)
dock_layers[child.layer].append(child)
width, height = size
@@ -121,9 +121,14 @@ def arrange(
if placement_offset:
layout_placements = [
_WidgetPlacement(
_region + placement_offset, margin, layout_widget, order, fixed
_region + placement_offset,
margin,
layout_widget,
order,
fixed,
overlay,
)
for _region, margin, layout_widget, order, fixed in layout_placements
for _region, margin, layout_widget, order, fixed, overlay in layout_placements
]
placements.extend(layout_placements)

View File

@@ -512,6 +512,8 @@ class Compositor:
add_new_widget = widgets.add
layer_order: int = 0
no_clip = size.region
def add_widget(
widget: Widget,
virtual_region: Region,
@@ -586,12 +588,13 @@ class Compositor:
layers_to_index = {
layer_name: index for index, layer_name in enumerate(_layers)
}
get_layer_index = layers_to_index.get
scroll_spacing = arrange_result.scroll_spacing
# Add all the widgets
for sub_region, margin, sub_widget, z, fixed in reversed(
for sub_region, margin, sub_widget, z, fixed, overlay in reversed(
placements
):
layer_index = get_layer_index(sub_widget.layer, 0)
@@ -608,13 +611,23 @@ class Compositor:
widget_order = order + ((layer_index, z, layer_order),)
if overlay:
constrain = sub_widget.styles.constrain
if constrain != "none":
# Constrain to avoid clipping
widget_region = widget_region.translate_inside(
no_clip,
constrain in ("x", "both"),
constrain in ("y", "both"),
)
add_widget(
sub_widget,
sub_region,
widget_region,
widget_order,
((1, 0, 0),) if overlay else widget_order,
layer_order,
sub_clip,
no_clip if overlay else sub_clip,
visible,
)
@@ -991,13 +1004,9 @@ class Compositor:
first_cut, last_cut = render_region.column_span
final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)]
if len(final_cuts) <= 2:
# Two cuts, which means the entire line
cut_strips = [strip]
else:
render_x = render_region.x
relative_cuts = [cut - render_x for cut in final_cuts[1:]]
cut_strips = strip.divide(relative_cuts)
render_x = render_region.x
relative_cuts = [cut - render_x for cut in final_cuts[1:]]
cut_strips = strip.divide(relative_cuts)
# Since we are painting front to back, the first segments for a cut "wins"
get_chops_line = chops_line.get

View File

@@ -36,6 +36,7 @@ class DockArrangeResult:
(
placement.region.grow(placement.margin),
placement.fixed,
placement.overlay,
placement,
)
for placement in self.placements
@@ -73,6 +74,7 @@ class WidgetPlacement(NamedTuple):
widget: Widget
order: int = 0
fixed: bool = False
overlay: bool = False
class Layout(ABC):

View File

@@ -57,7 +57,7 @@ class SpatialMap(Generic[ValueType]):
)
def insert(
self, regions_and_values: Iterable[tuple[Region, bool, ValueType]]
self, regions_and_values: Iterable[tuple[Region, bool, bool, ValueType]]
) -> None:
"""Insert values into the Spatial map.
@@ -71,8 +71,9 @@ class SpatialMap(Generic[ValueType]):
get_grid_list = self._map.__getitem__
_region_to_grid = self._region_to_grid_coordinates
total_region = self.total_region
for region, fixed, value in regions_and_values:
total_region = total_region.union(region)
for region, fixed, overlay, value in regions_and_values:
if not overlay:
total_region = total_region.union(region)
if fixed:
append_fixed(value)
else:

View File

@@ -18,7 +18,7 @@ from .keys import _character_to_key
if TYPE_CHECKING:
from typing_extensions import TypeAlias
BindingType: TypeAlias = "Binding | tuple[str, str, str]"
BindingType: TypeAlias = "Binding | tuple[str, str] | tuple[str, str, str]"
class BindingError(Exception):
@@ -41,7 +41,7 @@ class Binding:
"""Key to bind. This can also be a comma-separated list of keys to map multiple keys to a single action."""
action: str
"""Action to bind to."""
description: str
description: str = ""
"""Description of action."""
show: bool = True
"""Show the action in Footer, or False to hide."""
@@ -74,9 +74,9 @@ class _Bindings:
for binding in bindings:
# If it's a tuple of length 3, convert into a Binding first
if isinstance(binding, tuple):
if len(binding) != 3:
if len(binding) not in (2, 3):
raise BindingError(
f"BINDINGS must contain a tuple of three strings, not {binding!r}"
f"BINDINGS must contain a tuple of two or three strings, not {binding!r}"
)
binding = Binding(*binding)
@@ -95,7 +95,7 @@ class _Bindings:
key=key,
action=binding.action,
description=binding.description,
show=binding.show,
show=bool(binding.description and binding.show),
key_display=binding.key_display,
priority=binding.priority,
)
@@ -165,7 +165,7 @@ class _Bindings:
key,
action,
description,
show=show,
show=bool(description and show),
key_display=key_display,
priority=priority,
)

View File

@@ -39,9 +39,11 @@ from .constants import (
VALID_ALIGN_VERTICAL,
VALID_BORDER,
VALID_BOX_SIZING,
VALID_CONSTRAIN,
VALID_DISPLAY,
VALID_EDGE,
VALID_OVERFLOW,
VALID_OVERLAY,
VALID_SCROLLBAR_GUTTER,
VALID_STYLE_FLAGS,
VALID_TEXT_ALIGN,
@@ -1003,6 +1005,30 @@ class StylesBuilder:
else:
self.error(name, tokens[0], "expected two integers here")
def process_overlay(self, name: str, tokens: list[Token]) -> None:
try:
value = self._process_enum(name, tokens, VALID_OVERLAY)
except StyleValueError:
self.error(
name,
tokens[0],
string_enum_help_text(name, VALID_OVERLAY, context="css"),
)
else:
self.styles._rules[name] = value # type: ignore
def process_constrain(self, name: str, tokens: list[Token]) -> None:
try:
value = self._process_enum(name, tokens, VALID_CONSTRAIN)
except StyleValueError:
self.error(
name,
tokens[0],
string_enum_help_text(name, VALID_CONSTRAIN, context="css"),
)
else:
self.styles._rules[name] = value # type: ignore
def _get_suggested_property_name_for_rule(self, rule_name: str) -> str | None:
"""
Returns a valid CSS property "Python" name, or None if no close matches could be found.

View File

@@ -69,6 +69,7 @@ VALID_PSEUDO_CLASSES: Final = {
"focus",
"hover",
}
VALID_OVERLAY: Final = {"none", "screen"}
VALID_CONSTRAIN: Final = {"x", "y", "both", "none"}
NULL_SPACING: Final = Spacing.all(0)

View File

@@ -153,9 +153,8 @@ class DOMQuery(Generic[QueryType]):
return self.nodes[index]
def __rich_repr__(self) -> rich.repr.Result:
yield self.node
if self._filters:
yield "filter", " AND ".join(
yield "query", " AND ".join(
",".join(selector.css for selector in selectors)
for selectors in self._filters
)
@@ -214,6 +213,7 @@ class DOMQuery(Generic[QueryType]):
Returns:
The matching Widget.
"""
_rich_traceback_omit = True
if self.nodes:
first = self.nodes[0]
if expect_type is not None:
@@ -250,6 +250,7 @@ class DOMQuery(Generic[QueryType]):
Returns:
The matching Widget.
"""
_rich_traceback_omit = True
# Call on first to get the first item. Here we'll use all of the
# testing and checking it provides.
the_one = self.first(expect_type) if expect_type is not None else self.first()

View File

@@ -39,8 +39,10 @@ from .constants import (
VALID_ALIGN_HORIZONTAL,
VALID_ALIGN_VERTICAL,
VALID_BOX_SIZING,
VALID_CONSTRAIN,
VALID_DISPLAY,
VALID_OVERFLOW,
VALID_OVERLAY,
VALID_SCROLLBAR_GUTTER,
VALID_TEXT_ALIGN,
VALID_VISIBILITY,
@@ -52,9 +54,11 @@ from .types import (
AlignHorizontal,
AlignVertical,
BoxSizing,
Constrain,
Display,
Edge,
Overflow,
Overlay,
ScrollbarGutter,
Specificity3,
Specificity6,
@@ -177,6 +181,9 @@ class RulesMap(TypedDict, total=False):
border_subtitle_background: Color
border_subtitle_style: Style
overlay: Overlay
constrain: Constrain
RULE_NAMES = list(RulesMap.__annotations__.keys())
RULE_NAMES_SET = frozenset(RULE_NAMES)
@@ -223,7 +230,7 @@ class StylesBase(ABC):
node: DOMNode | None = None
display = StringEnumProperty(
VALID_DISPLAY, "block", layout=True, refresh_parent=True
VALID_DISPLAY, "block", layout=True, refresh_parent=True, refresh_children=True
)
visibility = StringEnumProperty(
VALID_VISIBILITY, "visible", layout=True, refresh_parent=True
@@ -341,6 +348,11 @@ class StylesBase(ABC):
border_subtitle_background = ColorProperty(Color(0, 0, 0, 0))
border_subtitle_style = StyleFlagsProperty()
overlay = StringEnumProperty(
VALID_OVERLAY, "none", layout=True, refresh_parent=True
)
constrain = StringEnumProperty(VALID_CONSTRAIN, "none")
def __textual_animation__(
self,
attribute: str,
@@ -1024,7 +1036,10 @@ class Styles(StylesBase):
)
if "border_subtitle_text_style" in rules:
append_declaration("subtitle-text-style", str(self.border_subtitle_style))
if "overlay" in rules:
append_declaration("overlay", str(self.overlay))
if "constrain" in rules:
append_declaration("constrain", str(self.constrain))
lines.sort()
return lines

View File

@@ -37,6 +37,8 @@ BoxSizing = Literal["border-box", "content-box"]
Overflow = Literal["scroll", "hidden", "auto"]
EdgeStyle = Tuple[EdgeType, Color]
TextAlign = Literal["left", "start", "center", "right", "end", "justify"]
Constrain = Literal["none", "x", "y", "both"]
Overlay = Literal["none", "screen"]
Specificity3 = Tuple[int, int, int]
Specificity6 = Tuple[int, int, int, int, int, int]

View File

@@ -1058,6 +1058,7 @@ class DOMNode(MessagePump):
Returns:
A widget matching the selector.
"""
_rich_traceback_omit = True
from .css.query import DOMQuery
if isinstance(selector, str):

View File

@@ -867,6 +867,44 @@ class Region(NamedTuple):
Region(x, y + cut, width, height - cut),
)
def translate_inside(
self, container: Region, x_axis: bool = True, y_axis: bool = True
) -> Region:
"""Translate this region, so it fits within a container.
This will ensure that there is as little overlap as possible.
The top left of the returned region is guaranteed to be within the container.
```
┌──────────────────┐ ┌──────────────────┐
│ container │ │ container │
│ │ │ ┌─────────────┤
│ │ ──▶ │ │ return │
│ ┌──────────┴──┐ │ │ │
│ │ self │ │ │ │
└───────┤ │ └────┴─────────────┘
│ │
└─────────────┘
```
Args:
container: A container region.
x_axis: Allow translation of X axis.
y_axis: Allow translation of Y axis.
Returns:
A new region with same dimensions that fits with inside container.
"""
x1, y1, width1, height1 = container
x2, y2, width2, height2 = self
return Region(
max(min(x2, x1 + width1 - width2), x1) if x_axis else x2,
max(min(y2, y1 + height1 - height2), y1) if y_axis else y2,
width2,
height2,
)
class Spacing(NamedTuple):
"""The spacing around a renderable, such as padding and border

View File

@@ -70,6 +70,7 @@ class HorizontalLayout(Layout):
_Region = Region
_WidgetPlacement = WidgetPlacement
for widget, box_model, margin in zip(children, box_models, margins):
overlay = widget.styles.overlay == "screen"
content_width, content_height, box_margin = box_model
offset_y = box_margin.top
next_x = x + content_width
@@ -79,7 +80,10 @@ class HorizontalLayout(Layout):
max_height = max(
max_height, content_height + offset_y + box_model.margin.bottom
)
add_placement(_WidgetPlacement(region, box_model.margin, widget, 0))
x = next_x + margin
add_placement(
_WidgetPlacement(region, box_model.margin, widget, 0, False, overlay)
)
if not overlay:
x = next_x + margin
return placements, set(displayed_children)

View File

@@ -64,13 +64,26 @@ class VerticalLayout(Layout):
_Region = Region
_WidgetPlacement = WidgetPlacement
for widget, box_model, margin in zip(children, box_models, margins):
overlay = widget.styles.overlay == "screen"
content_width, content_height, box_margin = box_model
next_y = y + content_height
region = _Region(
box_margin.left, int(y), int(content_width), int(next_y) - int(y)
)
add_placement(_WidgetPlacement(region, box_model.margin, widget, 0))
y = next_y + margin
add_placement(
_WidgetPlacement(
region,
box_model.margin,
widget,
0,
False,
overlay,
)
)
if not overlay:
y = next_y + margin
return placements, set(children)

View File

@@ -38,6 +38,10 @@ class Message:
namespace: ClassVar[str] = "" # Namespace to disambiguate messages
def __init__(self) -> None:
self.__post_init__()
def __post_init__(self) -> None:
"""Allow dataclasses to initialize the object."""
self._sender: MessageTarget | None = active_message_pump.get(None)
self.time: float = _time.get_time()
self._forwarded = False
@@ -48,7 +52,6 @@ class Message:
f"on_{self.namespace}_{name}" if self.namespace else f"on_{name}"
)
self._prevent: set[type[Message]] = set()
super().__init__()
def __rich_repr__(self) -> rich.repr.Result:
yield from ()
@@ -71,6 +74,7 @@ class Message:
@property
def is_forwarded(self) -> bool:
"""Has the message been forwarded?"""
return self._forwarded
@property

View File

@@ -648,6 +648,7 @@ class MessagePump(metaclass=_MessagePumpMeta):
Returns:
`True` if the messages was processed, `False` if it wasn't.
"""
_rich_traceback_omit = True
if self._closing or self._closed:
return False
if not self.check_message_enabled(message):

View File

@@ -1569,12 +1569,13 @@ class Widget(DOMNode):
Returns:
Tuple of layer names.
"""
layers: tuple[str, ...] = ("default",)
for node in self.ancestors_with_self:
if not isinstance(node, Widget):
break
if node.styles.has_rule("layers"):
return node.styles.layers
return ("default",)
layers = node.styles.layers
return layers
@property
def link_style(self) -> Style:
@@ -1637,8 +1638,11 @@ class Widget(DOMNode):
self._dirty_regions.clear()
self._repaint_regions.clear()
self._styles_cache.clear()
self._dirty_regions.add(self.outer_size.region)
self._repaint_regions.add(self.outer_size.region)
outer_size = self.outer_size
self._dirty_regions.add(outer_size.region)
if outer_size:
self._repaint_regions.add(outer_size.region)
def _exchange_repaint_regions(self) -> Collection[Region]:
"""Get a copy of the regions which need a repaint, and clear internal cache.
@@ -2921,6 +2925,13 @@ class Widget(DOMNode):
Returns:
True if the message was posted, False if this widget was closed / closing.
"""
_rich_traceback_omit = True
# Catch a common error.
# This will error anyway, but at least we can offer a helpful message here.
if not hasattr(message, "_prevent"):
raise RuntimeError(
f"{type(message)!r} is missing expected attributes; did you forget to call super().__init__() in the constructor?"
)
if constants.DEBUG and not self.is_running and not message.no_dispatch:
try:

View File

@@ -29,6 +29,7 @@ if typing.TYPE_CHECKING:
from ._progress_bar import ProgressBar
from ._radio_button import RadioButton
from ._radio_set import RadioSet
from ._select import Select
from ._static import Static
from ._switch import Switch
from ._tabbed_content import TabbedContent, TabPane
@@ -59,6 +60,7 @@ __all__ = [
"ProgressBar",
"RadioButton",
"RadioSet",
"Select",
"Static",
"Switch",
"Tab",

View File

@@ -19,6 +19,7 @@ from ._pretty import Pretty as Pretty
from ._progress_bar import ProgressBar as ProgressBar
from ._radio_button import RadioButton as RadioButton
from ._radio_set import RadioSet as RadioSet
from ._select import Select as Select
from ._static import Static as Static
from ._switch import Switch as Switch
from ._tabbed_content import TabbedContent as TabbedContent

View File

@@ -10,6 +10,7 @@ from __future__ import annotations
from typing import ClassVar, Iterable, NamedTuple
from rich.console import RenderableType
from rich.padding import Padding
from rich.repr import Result
from rich.rule import Rule
from rich.style import Style
@@ -146,6 +147,7 @@ class OptionList(ScrollView, can_focus=True):
"""
COMPONENT_CLASSES: ClassVar[set[str]] = {
"option-list--option",
"option-list--option-disabled",
"option-list--option-highlighted",
"option-list--option-highlighted-disabled",
@@ -474,6 +476,7 @@ class OptionList(ScrollView, can_focus=True):
# also set up the tracking of the actual options.
line = 0
option = 0
padding = self.get_component_styles("option-list--option").padding
for content in self._contents:
if isinstance(content, Option):
# The content is an option, so render out the prompt and
@@ -483,7 +486,10 @@ class OptionList(ScrollView, can_focus=True):
Strip(prompt_line).apply_style(Style(meta={"option": option})),
option,
)
for prompt_line in lines_from(content.prompt, options)
for prompt_line in lines_from(
Padding(content.prompt, padding) if padding else content.prompt,
options,
)
]
# Record the span information for the option.
add_span(OptionLineSpan(line, len(new_lines)))
@@ -838,8 +844,13 @@ class OptionList(ScrollView, can_focus=True):
# It's a normal option line.
return strip.apply_style(self.rich_style)
def scroll_to_highlight(self) -> None:
"""Ensure that the highlighted option is in view."""
def scroll_to_highlight(self, top: bool = False) -> None:
"""Ensure that the highlighted option is in view.
Args:
top: Scroll highlight to top of the list.
"""
highlighted = self.highlighted
if highlighted is None:
return
@@ -856,6 +867,7 @@ class OptionList(ScrollView, can_focus=True):
),
force=True,
animate=False,
top=top,
)
def validate_highlighted(self, highlighted: int | None) -> int | None:

View File

@@ -0,0 +1,378 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Generic, Iterable, Optional, TypeVar
from rich.console import RenderableType
from rich.text import Text
from .. import events, on
from ..app import ComposeResult
from ..containers import Horizontal, Vertical
from ..message import Message
from ..reactive import var
from ..widgets import Static
from ._option_list import Option, OptionList
if TYPE_CHECKING:
from typing_extensions import TypeAlias
class SelectOverlay(OptionList):
"""The 'pop-up' overlay for the Select control."""
BINDINGS = [("escape", "dismiss")]
DEFAULT_CSS = """
SelectOverlay {
border: tall $background;
background: $panel;
color: $text;
width: 100%;
padding: 0 1;
}
SelectOverlay > .option-list--option {
padding: 0 1;
}
"""
@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 select(self, index: int | None) -> None:
"""Move selection.
Args:
index: Index of new selection.
"""
self.highlighted = index
self.scroll_to_highlight(top=True)
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))
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))
class SelectCurrent(Horizontal):
"""Displays the currently selected option."""
DEFAULT_CSS = """
SelectCurrent {
border: tall $background;
background: $boost;
color: $text;
width: 100%;
height: auto;
padding: 0 2;
}
SelectCurrent Static#label {
width: 1fr;
height: auto;
color: $text-disabled;
background: transparent;
}
SelectCurrent.-has-value Static#label {
color: $text;
}
SelectCurrent .arrow {
box-sizing: content-box;
width: 1;
height: 1;
padding: 0 0 0 1;
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)
"""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 | None = None
def update(self, label: RenderableType | None) -> 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.query_one("#label", Static).update(
self.placeholder if label is None 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")
async def _on_click(self, event: events.Click) -> None:
"""Inform ancestor we want to toggle."""
self.post_message(self.Toggle())
SelectType = TypeVar("SelectType")
"""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.
"""
BINDINGS = [("enter,down,space,up", "show_overlay")]
"""
| Key(s) | Description |
| :- | :- |
| enter,down,space,up | Activate the overlay |
"""
DEFAULT_CSS = """
Select {
height: auto;
}
Select:focus > SelectCurrent {
border: tall $accent;
}
Select {
height: auto;
}
Select > SelectOverlay {
width: 1fr;
display: none;
height: auto;
max-height: 10;
overlay: screen;
constrain: y;
}
Select .up-arrow {
display:none;
}
Select.-expanded .down-arrow {
display:none;
}
Select.-expanded .up-arrow {
display: block;
}
Select.-expanded > SelectOverlay {
display: block;
}
Select.-expanded > SelectCurrent {
border: tall $accent;
}
"""
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 | None] = var[Optional[SelectType]](None)
"""The value of the select."""
class Changed(Message, bubble=True):
"""Posted when the select value was changed.
This message can be handled using a `on_select_changed` method.
"""
def __init__(self, control: Select, value: SelectType | None) -> None:
"""
Initialize the Changed message.
"""
super().__init__()
self.control = control
"""The select control."""
self.value = value
"""The value of the Select when it changed."""
def __init__(
self,
options: Iterable[tuple[str, SelectType]],
*,
prompt: str = "Select",
allow_blank: bool = True,
value: SelectType | None = None,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
):
"""Initialize the Select control
Args:
options: Options to select from.
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`).
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.
"""
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self._allow_blank = allow_blank
self.prompt = prompt
self._initial_options = list(options)
self._value: SelectType | None = value
def set_options(self, options: Iterable[tuple[RenderableType, SelectType]]) -> None:
"""Set the options for the Select.
Args:
options: An iterable of tuples containing (STRING, VALUE).
"""
self._options: list[tuple[RenderableType, SelectType | None]] = list(options)
if self._allow_blank:
self._options.insert(0, ("", None))
self._select_options: list[Option] = [
(
Option(Text(self.prompt, style="dim"))
if value is None
else Option(prompt)
)
for prompt, value in self._options
]
option_list = self.query_one(SelectOverlay)
option_list.clear_options()
for option in self._select_options:
option_list.add_option(option)
def _watch_value(self, value: SelectType | None) -> None:
"""Update the current value when it changes."""
self._value = value
if value is None:
self.query_one(SelectCurrent).update(None)
else:
for index, (prompt, _value) in enumerate(self._options):
if _value == value:
select_overlay = self.query_one(SelectOverlay)
select_overlay.highlighted = index
self.query_one(SelectCurrent).update(prompt)
break
else:
self.query_one(SelectCurrent).update(None)
def compose(self) -> ComposeResult:
"""Compose Select with overlay and current value."""
yield SelectCurrent(self.prompt)
yield SelectOverlay()
def _on_mount(self, _event: events.Mount) -> None:
"""Set initial values."""
self.set_options(self._initial_options)
self.value = self._value
def _watch_expanded(self, expanded: bool) -> None:
"""Display or hide overlay."""
overlay = self.query_one(SelectOverlay)
self.set_class(expanded, "-expanded")
if expanded:
overlay.focus()
if self.value is None:
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]
self.value = value
async def update_focus() -> None:
"""Update focus and reset overlay."""
self.focus()
self.expanded = False
self.call_after_refresh(update_focus) # Prevents a little flicker
self.post_message(self.Changed(self, value))
def action_show_overlay(self) -> None:
"""Show the overlay."""
select_current = self.query_one(SelectCurrent)
select_current.has_value = True
self.expanded = True

File diff suppressed because one or more lines are too long

View File

@@ -230,6 +230,23 @@ def test_progress_bar_completed_styled(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "progress_bar_styled_.py", press=["u"])
def test_select(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "select_widget.py")
def test_select_expanded(snap_compare):
assert snap_compare(
WIDGET_EXAMPLES_DIR / "select_widget.py", press=["tab", "enter"]
)
def test_select_expanded_changed(snap_compare):
assert snap_compare(
WIDGET_EXAMPLES_DIR / "select_widget.py",
press=["tab", "enter", "down", "enter"],
)
# --- CSS properties ---
# We have a canonical example for each CSS property that is shown in their docs.
# If any of these change, something has likely broken, so snapshot each of them.

View File

@@ -52,7 +52,7 @@ def test_bindings_merge_overlap():
def test_bad_binding_tuple():
with pytest.raises(BindingError):
_ = _Bindings((("a", "action"),))
_ = _Bindings((("a",),))
with pytest.raises(BindingError):
_ = _Bindings((("a", "action", "description", "too much"),))

View File

@@ -447,3 +447,15 @@ def test_split_horizontal_negative():
Region(10, 5, 22, 14),
Region(10, 19, 22, 1),
)
def test_translate_inside():
# Needs to be moved up
assert Region(10, 20, 10, 20).translate_inside(Region(0, 0, 30, 25)) == Region(
10, 5, 10, 20
)
# Already inside
assert Region(10, 10, 20, 5).translate_inside(Region(0, 0, 100, 100)) == Region(
10, 10, 20, 5
)

View File

@@ -44,9 +44,9 @@ def test_get_values_in_region() -> None:
spatial_map.insert(
[
(Region(10, 5, 5, 5), False, "foo"),
(Region(5, 20, 5, 5), False, "bar"),
(Region(0, 0, 40, 1), True, "title"),
(Region(10, 5, 5, 5), False, False, "foo"),
(Region(5, 20, 5, 5), False, False, "bar"),
(Region(0, 0, 40, 1), True, False, "title"),
]
)