mirror of
https://github.com/Textualize/textual.git
synced 2025-10-17 02:38:12 +03:00
Merge branch 'main' into messages-control
This commit is contained in:
20
CHANGELOG.md
20
CHANGELOG.md
@@ -10,11 +10,30 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
### Fixed
|
||||
|
||||
- Fixed crash when creating a `DirectoryTree` starting anywhere other than `.`
|
||||
- Fixed line drawing in `Tree` when `Tree.show_root` is `True` https://github.com/Textualize/textual/issues/2397
|
||||
- Fixed line drawing in `Tree` not marking branches as selected when first getting focus https://github.com/Textualize/textual/issues/2397
|
||||
|
||||
### Changed
|
||||
|
||||
- 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
|
||||
|
||||
- Property `control` as alias for attribute `tabs` in `Tabs` messages https://github.com/Textualize/textual/pull/2483
|
||||
- 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
|
||||
- Method `DataTable.move_cursor` https://github.com/Textualize/textual/issues/2472
|
||||
- Added `OptionList.add_options` https://github.com/Textualize/textual/pull/2508
|
||||
- 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
|
||||
|
||||
### Added
|
||||
|
||||
@@ -52,6 +71,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
|
||||
- Fixed `!important` not applying to `overflow` https://github.com/Textualize/textual/issues/2420
|
||||
- Fixed `!important` not applying to `scrollbar-size` https://github.com/Textualize/textual/issues/2420
|
||||
- Fixed `outline-right` not being recognised https://github.com/Textualize/textual/issues/2446
|
||||
- Fixed OSError when a file system is not available https://github.com/Textualize/textual/issues/2468
|
||||
|
||||
### Changed
|
||||
|
||||
|
||||
@@ -4,7 +4,6 @@ from rich.table import Table
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Footer, Header, OptionList
|
||||
from textual.widgets.option_list import Option, Separator
|
||||
|
||||
COLONIES: tuple[tuple[str, str, str, str], ...] = (
|
||||
("Aerilon", "Demeter", "1.2 Billion", "Gaoth"),
|
||||
|
||||
11
docs/examples/widgets/select.css
Normal file
11
docs/examples/widgets/select.css
Normal file
@@ -0,0 +1,11 @@
|
||||
Screen {
|
||||
align: center top;
|
||||
}
|
||||
|
||||
Select {
|
||||
width: 60;
|
||||
margin: 2;
|
||||
}
|
||||
Input {
|
||||
width: 60;
|
||||
}
|
||||
26
docs/examples/widgets/select_widget.py
Normal file
26
docs/examples/widgets/select_widget.py
Normal 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()
|
||||
@@ -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
90
docs/widgets/select.md
Normal 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
|
||||
@@ -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"
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -1660,7 +1660,7 @@ class App(Generic[ReturnType], DOMNode):
|
||||
app_css_path = (
|
||||
f"{inspect.getfile(self.__class__)}:{self.__class__.__name__}"
|
||||
)
|
||||
except TypeError:
|
||||
except (TypeError, OSError):
|
||||
app_css_path = f"{self.__class__.__name__}"
|
||||
self.stylesheet.add_source(
|
||||
self.CSS, path=app_css_path, is_default_css=False
|
||||
|
||||
@@ -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,
|
||||
)
|
||||
|
||||
@@ -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.
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -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
|
||||
|
||||
|
||||
@@ -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]
|
||||
|
||||
@@ -418,7 +418,7 @@ class DOMNode(MessagePump):
|
||||
"""Get a path to the DOM Node"""
|
||||
try:
|
||||
return f"{getfile(base)}:{base.__name__}"
|
||||
except TypeError:
|
||||
except (TypeError, OSError):
|
||||
return f"{base.__name__}"
|
||||
|
||||
for tie_breaker, base in enumerate(self._node_bases):
|
||||
@@ -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):
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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)
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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):
|
||||
|
||||
@@ -76,11 +76,13 @@ class Reactive(Generic[ReactiveType]):
|
||||
def var(
|
||||
cls,
|
||||
default: ReactiveType | Callable[[], ReactiveType],
|
||||
always_update: bool = False,
|
||||
) -> Reactive:
|
||||
"""A reactive variable that doesn't update or layout.
|
||||
|
||||
Args:
|
||||
default: A default value or callable that returns a default.
|
||||
always_update: Call watchers even when the new value equals the old value.
|
||||
|
||||
Returns:
|
||||
A Reactive descriptor.
|
||||
@@ -326,18 +328,21 @@ class var(Reactive[ReactiveType]):
|
||||
Args:
|
||||
default: A default value or callable that returns a default.
|
||||
init: Call watchers on initialize (post mount).
|
||||
always_update: Call watchers even when the new value equals the old value.
|
||||
"""
|
||||
|
||||
def __init__(
|
||||
self,
|
||||
default: ReactiveType | Callable[[], ReactiveType],
|
||||
init: bool = True,
|
||||
always_update: bool = False,
|
||||
) -> None:
|
||||
super().__init__(
|
||||
default,
|
||||
layout=False,
|
||||
repaint=False,
|
||||
init=init,
|
||||
always_update=always_update,
|
||||
)
|
||||
|
||||
|
||||
|
||||
@@ -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:
|
||||
|
||||
@@ -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",
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -311,6 +311,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
cursor_coordinate: Reactive[Coordinate] = Reactive(
|
||||
Coordinate(0, 0), repaint=False, always_update=True
|
||||
)
|
||||
"""Current cursor [`Coordinate`][textual.coordinate.Coordinate].
|
||||
|
||||
This can be set programmatically or changed via the method
|
||||
[`move_cursor`][textual.widgets.DataTable.move_cursor].
|
||||
"""
|
||||
hover_coordinate: Reactive[Coordinate] = Reactive(
|
||||
Coordinate(0, 0), repaint=False, always_update=True
|
||||
)
|
||||
@@ -953,7 +958,41 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
elif self.cursor_type == "column":
|
||||
self.refresh_column(old_coordinate.column)
|
||||
self._highlight_column(new_coordinate.column)
|
||||
self._scroll_cursor_into_view()
|
||||
# If the coordinate was changed via `move_cursor`, give priority to its
|
||||
# scrolling because it may be animated.
|
||||
self.call_next(self._scroll_cursor_into_view)
|
||||
|
||||
def move_cursor(
|
||||
self,
|
||||
*,
|
||||
row: int | None = None,
|
||||
column: int | None = None,
|
||||
animate: bool = False,
|
||||
) -> None:
|
||||
"""Move the cursor to the given position.
|
||||
|
||||
Example:
|
||||
```py
|
||||
datatable = app.query_one(DataTable)
|
||||
datatable.move_cursor(row=4, column=6)
|
||||
# datatable.cursor_coordinate == Coordinate(4, 6)
|
||||
datatable.move_cursor(row=3)
|
||||
# datatable.cursor_coordinate == Coordinate(3, 6)
|
||||
```
|
||||
|
||||
Args:
|
||||
row: The new row to move the cursor to.
|
||||
column: The new column to move the cursor to.
|
||||
animate: Whether to animate the change of coordinates.
|
||||
"""
|
||||
cursor_row, cursor_column = self.cursor_coordinate
|
||||
if row is not None:
|
||||
cursor_row = row
|
||||
if column is not None:
|
||||
cursor_column = column
|
||||
destination = Coordinate(cursor_row, cursor_column)
|
||||
self.cursor_coordinate = destination
|
||||
self._scroll_cursor_into_view(animate=animate)
|
||||
|
||||
def _highlight_coordinate(self, coordinate: Coordinate) -> None:
|
||||
"""Apply highlighting to the cell at the coordinate, and post event."""
|
||||
@@ -2055,7 +2094,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
self.cursor_coordinate = Coordinate(
|
||||
row_index + rows_to_scroll - 1, column_index
|
||||
)
|
||||
self._scroll_cursor_into_view()
|
||||
else:
|
||||
super().action_page_down()
|
||||
|
||||
@@ -2079,7 +2117,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
self.cursor_coordinate = Coordinate(
|
||||
row_index - rows_to_scroll + 1, column_index
|
||||
)
|
||||
self._scroll_cursor_into_view()
|
||||
else:
|
||||
super().action_page_up()
|
||||
|
||||
@@ -2090,7 +2127,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"):
|
||||
row_index, column_index = self.cursor_coordinate
|
||||
self.cursor_coordinate = Coordinate(0, column_index)
|
||||
self._scroll_cursor_into_view()
|
||||
else:
|
||||
super().action_scroll_home()
|
||||
|
||||
@@ -2101,7 +2137,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"):
|
||||
row_index, column_index = self.cursor_coordinate
|
||||
self.cursor_coordinate = Coordinate(self.row_count - 1, column_index)
|
||||
self._scroll_cursor_into_view()
|
||||
else:
|
||||
super().action_scroll_end()
|
||||
|
||||
@@ -2110,7 +2145,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
cursor_type = self.cursor_type
|
||||
if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"):
|
||||
self.cursor_coordinate = self.cursor_coordinate.up()
|
||||
self._scroll_cursor_into_view()
|
||||
else:
|
||||
# If the cursor doesn't move up (e.g. column cursor can't go up),
|
||||
# then ensure that we instead scroll the DataTable.
|
||||
@@ -2121,7 +2155,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
||||
cursor_type = self.cursor_type
|
||||
if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"):
|
||||
self.cursor_coordinate = self.cursor_coordinate.down()
|
||||
self._scroll_cursor_into_view()
|
||||
else:
|
||||
super().action_scroll_down()
|
||||
|
||||
|
||||
@@ -7,9 +7,10 @@ forms of bounce-bar menu.
|
||||
|
||||
from __future__ import annotations
|
||||
|
||||
from typing import ClassVar, NamedTuple
|
||||
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",
|
||||
@@ -483,6 +485,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
|
||||
@@ -492,7 +495,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)))
|
||||
@@ -517,6 +523,31 @@ class OptionList(ScrollView, can_focus=True):
|
||||
# list, set the virtual size.
|
||||
self.virtual_size = Size(self.scrollable_content_region.width, len(self._lines))
|
||||
|
||||
def add_options(self, items: Iterable[NewOptionListContent]) -> Self:
|
||||
"""Add new options to the end of the option list.
|
||||
|
||||
Args:
|
||||
items: The new items to add.
|
||||
|
||||
Returns:
|
||||
The `OptionList` instance.
|
||||
|
||||
Raises:
|
||||
DuplicateID: If there is an attempt to use a duplicate ID.
|
||||
"""
|
||||
# Only work if we have items to add; but don't make a fuss out of
|
||||
# zero items to add, just carry on like nothing happened.
|
||||
if items:
|
||||
# Turn any incoming values into valid content for the list.
|
||||
content = [self._make_content(item) for item in items]
|
||||
self._contents.extend(content)
|
||||
# Pull out the content that is genuine options and add them to the
|
||||
# list of options.
|
||||
self._options.extend([item for item in content if isinstance(item, Option)])
|
||||
self._refresh_content_tracking(force=True)
|
||||
self.refresh()
|
||||
return self
|
||||
|
||||
def add_option(self, item: NewOptionListContent = None) -> Self:
|
||||
"""Add a new option to the end of the option list.
|
||||
|
||||
@@ -529,15 +560,7 @@ class OptionList(ScrollView, can_focus=True):
|
||||
Raises:
|
||||
DuplicateID: If there is an attempt to use a duplicate ID.
|
||||
"""
|
||||
# Turn any incoming value into valid content for the list.
|
||||
content = self._make_content(item)
|
||||
self._contents.append(content)
|
||||
# If the content is a genuine option, add it to the list of options.
|
||||
if isinstance(content, Option):
|
||||
self._options.append(content)
|
||||
self._refresh_content_tracking(force=True)
|
||||
self.refresh()
|
||||
return self
|
||||
return self.add_options([item])
|
||||
|
||||
def _remove_option(self, index: int) -> None:
|
||||
"""Remove an option from the option list.
|
||||
@@ -830,8 +853,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
|
||||
@@ -848,6 +876,7 @@ class OptionList(ScrollView, can_focus=True):
|
||||
),
|
||||
force=True,
|
||||
animate=False,
|
||||
top=top,
|
||||
)
|
||||
|
||||
def validate_highlighted(self, highlighted: int | None) -> int | None:
|
||||
|
||||
378
src/textual/widgets/_select.py
Normal file
378
src/textual/widgets/_select.py
Normal 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
|
||||
@@ -114,17 +114,29 @@ class TabbedContent(Widget):
|
||||
yield self.tabbed_content
|
||||
yield self.tab
|
||||
|
||||
def __init__(self, *titles: TextType, initial: str = "") -> None:
|
||||
def __init__(
|
||||
self,
|
||||
*titles: TextType,
|
||||
initial: str = "",
|
||||
name: str | None = None,
|
||||
id: str | None = None,
|
||||
classes: str | None = None,
|
||||
disabled: bool = False,
|
||||
):
|
||||
"""Initialize a TabbedContent widgets.
|
||||
|
||||
Args:
|
||||
*titles: Positional argument will be used as title.
|
||||
initial: The id of the initial tab, or empty string to select the first tab.
|
||||
name: The name of the button.
|
||||
id: The ID of the button in the DOM.
|
||||
classes: The CSS classes of the button.
|
||||
disabled: Whether the button is disabled or not.
|
||||
"""
|
||||
self.titles = [self.render_str(title) for title in titles]
|
||||
self._tab_content: list[Widget] = []
|
||||
self._initial = initial
|
||||
super().__init__()
|
||||
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
||||
|
||||
def validate_active(self, active: str) -> str:
|
||||
"""It doesn't make sense for `active` to be an empty string.
|
||||
|
||||
@@ -194,6 +194,15 @@ class Tabs(Widget, can_focus=True):
|
||||
self.tab = tab
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def control(self) -> Tabs:
|
||||
"""The tabs widget containing the tab that was activated.
|
||||
|
||||
This is an alias for [`TabActivated.tabs`][textual.widgets.Tabs.TabActivated.tabs]
|
||||
which is used by the [`on`][textual.on] decorator.
|
||||
"""
|
||||
return self.tabs
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield self.tabs
|
||||
yield self.tab
|
||||
@@ -213,6 +222,15 @@ class Tabs(Widget, can_focus=True):
|
||||
self.tabs = tabs
|
||||
super().__init__()
|
||||
|
||||
@property
|
||||
def control(self) -> Tabs:
|
||||
"""The tabs widget which was cleared.
|
||||
|
||||
This is an alias for [`Cleared.tabs`][textual.widgets.Tabs.Cleared] which
|
||||
is used by the [`on`][textual.on] decorator.
|
||||
"""
|
||||
return self.tabs
|
||||
|
||||
def __rich_repr__(self) -> rich.repr.Result:
|
||||
yield self.tabs
|
||||
|
||||
|
||||
@@ -184,6 +184,11 @@ class TreeNode(Generic[TreeDataType]):
|
||||
self._parent._children and self._parent._children[-1] == self,
|
||||
)
|
||||
|
||||
@property
|
||||
def is_root(self) -> bool:
|
||||
"""Is this node the root of the tree?"""
|
||||
return self == self._tree.root
|
||||
|
||||
@property
|
||||
def allow_expand(self) -> bool:
|
||||
"""Is this node allowed to expand?"""
|
||||
@@ -344,6 +349,47 @@ class TreeNode(Generic[TreeDataType]):
|
||||
node = self.add(label, data, expand=False, allow_expand=False)
|
||||
return node
|
||||
|
||||
class RemoveRootError(Exception):
|
||||
"""Exception raised when trying to remove a tree's root node."""
|
||||
|
||||
def _remove_children(self) -> None:
|
||||
"""Remove child nodes of this node.
|
||||
|
||||
Note:
|
||||
This is the internal support method for `remove_children`. Call
|
||||
`remove_children` to ensure the tree gets refreshed.
|
||||
"""
|
||||
for child in reversed(self._children):
|
||||
child._remove()
|
||||
|
||||
def _remove(self) -> None:
|
||||
"""Remove the current node and all its children.
|
||||
|
||||
Note:
|
||||
This is the internal support method for `remove`. Call `remove`
|
||||
to ensure the tree gets refreshed.
|
||||
"""
|
||||
self._remove_children()
|
||||
assert self._parent is not None
|
||||
del self._parent._children[self._parent._children.index(self)]
|
||||
del self._tree._tree_nodes[self.id]
|
||||
|
||||
def remove(self) -> None:
|
||||
"""Remove this node from the tree.
|
||||
|
||||
Raises:
|
||||
TreeNode.RemoveRootError: If there is an attempt to remove the root.
|
||||
"""
|
||||
if self.is_root:
|
||||
raise self.RemoveRootError("Attempt to remove the root node of a Tree.")
|
||||
self._remove()
|
||||
self._tree._invalidate()
|
||||
|
||||
def remove_children(self) -> None:
|
||||
"""Remove any child nodes of this node."""
|
||||
self._remove_children()
|
||||
self._tree._invalidate()
|
||||
|
||||
|
||||
class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
"""A widget for displaying and navigating data in a tree."""
|
||||
@@ -429,7 +475,7 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
"""Show the root of the tree."""
|
||||
hover_line = var(-1)
|
||||
"""The line number under the mouse pointer, or -1 if not under the mouse pointer."""
|
||||
cursor_line = var(-1)
|
||||
cursor_line = var(-1, always_update=True)
|
||||
"""The line with the cursor, or -1 if no cursor."""
|
||||
show_guides = reactive(True)
|
||||
"""Enable display of tree guide lines."""
|
||||
@@ -858,6 +904,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
self._cursor_node = node
|
||||
if previous_node != node:
|
||||
self.post_message(self.NodeHighlighted(self, node))
|
||||
else:
|
||||
self._cursor_node = None
|
||||
|
||||
def watch_guide_depth(self, guide_depth: int) -> None:
|
||||
self._invalidate()
|
||||
@@ -1020,8 +1068,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||
"tree--guides-selected", partial=True
|
||||
)
|
||||
|
||||
hover = self.root._hover
|
||||
selected = self.root._selected and self.has_focus
|
||||
hover = line.path[0]._hover
|
||||
selected = line.path[0]._selected and self.has_focus
|
||||
|
||||
def get_guides(style: Style) -> tuple[str, str, str, str]:
|
||||
"""Get the guide strings for a given style.
|
||||
|
||||
@@ -106,6 +106,14 @@ async def test_add_later() -> None:
|
||||
assert option_list.option_count == 6
|
||||
option_list.add_option(Option("even more"))
|
||||
assert option_list.option_count == 7
|
||||
option_list.add_options(
|
||||
[Option("more still"), "Yet more options", "so many options!"]
|
||||
)
|
||||
assert option_list.option_count == 10
|
||||
option_list.add_option(None)
|
||||
assert option_list.option_count == 10
|
||||
option_list.add_options([])
|
||||
assert option_list.option_count == 10
|
||||
|
||||
|
||||
async def test_create_with_duplicate_id() -> None:
|
||||
|
||||
File diff suppressed because one or more lines are too long
45
tests/snapshot_tests/snapshot_apps/option_list.py
Normal file
45
tests/snapshot_tests/snapshot_apps/option_list.py
Normal file
@@ -0,0 +1,45 @@
|
||||
from __future__ import annotations
|
||||
|
||||
from rich.text import Text
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.containers import Horizontal
|
||||
from textual.widgets import OptionList
|
||||
from textual.widgets.option_list import Option
|
||||
|
||||
|
||||
class OptionListApp(App[None]):
|
||||
|
||||
def compose( self ) -> ComposeResult:
|
||||
with Horizontal():
|
||||
yield OptionList(
|
||||
"One",
|
||||
Option("Two"),
|
||||
None,
|
||||
Text.from_markup("[red]Three[/]")
|
||||
)
|
||||
yield OptionList(id="later-individual")
|
||||
yield OptionList(id="later-at-once")
|
||||
|
||||
def on_mount(self) -> None:
|
||||
options: list[None | str | Text | Option] = [
|
||||
"One",
|
||||
Option("Two"),
|
||||
None,
|
||||
Text.from_markup("[red]Three[/]"),
|
||||
]
|
||||
option_list = self.query_one("#later-individual", OptionList)
|
||||
for option in options:
|
||||
option_list.add_option(option)
|
||||
option_list.highlighted = 0
|
||||
option_list = self.query_one("#later-at-once", OptionList)
|
||||
option_list.add_options([
|
||||
"One",
|
||||
Option("Two"),
|
||||
None,
|
||||
Text.from_markup("[red]Three[/]"),
|
||||
])
|
||||
option_list.highlighted = 0
|
||||
|
||||
if __name__ == "__main__":
|
||||
OptionListApp().run()
|
||||
@@ -203,6 +203,8 @@ def test_option_list(snap_compare):
|
||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_options.py")
|
||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_tables.py")
|
||||
|
||||
def test_option_list_build(snap_compare):
|
||||
assert snap_compare(SNAPSHOT_APPS_DIR / "option_list.py")
|
||||
|
||||
def test_progress_bar_indeterminate(snap_compare):
|
||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "progress_bar_isolated_.py", press=["f"])
|
||||
@@ -228,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.
|
||||
|
||||
@@ -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"),))
|
||||
|
||||
|
||||
@@ -996,23 +996,34 @@ def test_key_string_lookup():
|
||||
async def test_scrolling_cursor_into_view():
|
||||
"""Regression test for https://github.com/Textualize/textual/issues/2459"""
|
||||
|
||||
class TableApp(App):
|
||||
class ScrollingApp(DataTableApp):
|
||||
CSS = "DataTable { height: 100%; }"
|
||||
|
||||
def compose(self):
|
||||
yield DataTable()
|
||||
|
||||
def on_mount(self) -> None:
|
||||
table = self.query_one(DataTable)
|
||||
table.add_column("n")
|
||||
table.add_rows([(n,) for n in range(300)])
|
||||
|
||||
def key_c(self):
|
||||
self.query_one(DataTable).cursor_coordinate = Coordinate(200, 0)
|
||||
|
||||
app = TableApp()
|
||||
app = ScrollingApp()
|
||||
|
||||
async with app.run_test() as pilot:
|
||||
table = app.query_one(DataTable)
|
||||
table.add_column("n")
|
||||
table.add_rows([(n,) for n in range(300)])
|
||||
|
||||
await pilot.press("c")
|
||||
await pilot.pause()
|
||||
assert app.query_one(DataTable).scroll_y > 100
|
||||
assert table.scroll_y > 100
|
||||
|
||||
|
||||
async def test_move_cursor():
|
||||
app = DataTableApp()
|
||||
|
||||
async with app.run_test():
|
||||
table = app.query_one(DataTable)
|
||||
table.add_columns(*"These are some columns in your nice table".split())
|
||||
table.add_rows(["These are some columns in your nice table".split()] * 10)
|
||||
|
||||
table.move_cursor(row=4, column=6)
|
||||
assert table.cursor_coordinate == Coordinate(4, 6)
|
||||
table.move_cursor(row=3)
|
||||
assert table.cursor_coordinate == Coordinate(3, 6)
|
||||
table.move_cursor(column=3)
|
||||
assert table.cursor_coordinate == Coordinate(3, 3)
|
||||
|
||||
@@ -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
|
||||
)
|
||||
|
||||
@@ -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"),
|
||||
]
|
||||
)
|
||||
|
||||
|
||||
@@ -15,7 +15,9 @@ class CheckboxApp(App[None]):
|
||||
yield Checkbox(value=True, id="cb3")
|
||||
|
||||
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
|
||||
self.events_received.append((event.checkbox.id, event.checkbox.value))
|
||||
self.events_received.append(
|
||||
(event.checkbox.id, event.checkbox.value, event.checkbox == event.control)
|
||||
)
|
||||
|
||||
|
||||
async def test_checkbox_initial_state() -> None:
|
||||
@@ -43,7 +45,7 @@ async def test_checkbox_toggle() -> None:
|
||||
]
|
||||
await pilot.pause()
|
||||
assert pilot.app.events_received == [
|
||||
("cb1", True),
|
||||
("cb2", True),
|
||||
("cb3", False),
|
||||
("cb1", True, True),
|
||||
("cb2", True, True),
|
||||
("cb3", False, True),
|
||||
]
|
||||
|
||||
@@ -15,7 +15,13 @@ class RadioButtonApp(App[None]):
|
||||
yield RadioButton(value=True, id="rb3")
|
||||
|
||||
def on_radio_button_changed(self, event: RadioButton.Changed) -> None:
|
||||
self.events_received.append((event.radio_button.id, event.radio_button.value))
|
||||
self.events_received.append(
|
||||
(
|
||||
event.radio_button.id,
|
||||
event.radio_button.value,
|
||||
event.radio_button == event.control,
|
||||
)
|
||||
)
|
||||
|
||||
|
||||
async def test_radio_button_initial_state() -> None:
|
||||
@@ -51,7 +57,7 @@ async def test_radio_button_toggle() -> None:
|
||||
]
|
||||
await pilot.pause()
|
||||
assert pilot.app.events_received == [
|
||||
("rb1", True),
|
||||
("rb2", True),
|
||||
("rb3", False),
|
||||
("rb1", True, True),
|
||||
("rb2", True, True),
|
||||
("rb3", False, True),
|
||||
]
|
||||
|
||||
@@ -11,7 +11,7 @@ class RadioSetApp(App[None]):
|
||||
|
||||
def compose(self) -> ComposeResult:
|
||||
with RadioSet(id="from_buttons"):
|
||||
yield RadioButton()
|
||||
yield RadioButton(id="clickme")
|
||||
yield RadioButton()
|
||||
yield RadioButton(value=True)
|
||||
yield RadioSet("One", "True", "Three", id="from_strings")
|
||||
@@ -36,6 +36,14 @@ async def test_radio_sets_initial_state():
|
||||
assert pilot.app.events_received == []
|
||||
|
||||
|
||||
async def test_click_sets_focus():
|
||||
"""Clicking within a radio set should set focus."""
|
||||
async with RadioSetApp().run_test() as pilot:
|
||||
assert pilot.app.screen.focused is None
|
||||
await pilot.click("#clickme")
|
||||
assert pilot.app.screen.focused == pilot.app.query_one("#from_buttons")
|
||||
|
||||
|
||||
async def test_radio_sets_toggle():
|
||||
"""Test the status of the radio sets after they've been toggled."""
|
||||
async with RadioSetApp().run_test() as pilot:
|
||||
@@ -52,17 +60,42 @@ async def test_radio_sets_toggle():
|
||||
]
|
||||
|
||||
|
||||
async def test_radioset_same_button_mash():
|
||||
"""Mashing the same button should have no effect."""
|
||||
async with RadioSetApp().run_test() as pilot:
|
||||
assert pilot.app.query_one("#from_buttons", RadioSet).pressed_index == 2
|
||||
pilot.app.query_one("#from_buttons", RadioSet)._nodes[2].toggle()
|
||||
assert pilot.app.query_one("#from_buttons", RadioSet).pressed_index == 2
|
||||
assert pilot.app.events_received == []
|
||||
|
||||
|
||||
async def test_radioset_inner_navigation():
|
||||
"""Using the cursor keys should navigate between buttons in a set."""
|
||||
async with RadioSetApp().run_test() as pilot:
|
||||
assert pilot.app.screen.focused is None
|
||||
await pilot.press("tab")
|
||||
for key, landing in (("down", 1), ("up", 0), ("right", 1), ("left", 0)):
|
||||
for key, landing in (
|
||||
("down", 1),
|
||||
("up", 0),
|
||||
("right", 1),
|
||||
("left", 0),
|
||||
("up", 2),
|
||||
("down", 0),
|
||||
):
|
||||
await pilot.press(key, "enter")
|
||||
assert (
|
||||
pilot.app.query_one("#from_buttons", RadioSet).pressed_button
|
||||
== pilot.app.query_one("#from_buttons").children[landing]
|
||||
)
|
||||
async with RadioSetApp().run_test() as pilot:
|
||||
assert pilot.app.screen.focused is None
|
||||
await pilot.press("tab")
|
||||
assert pilot.app.screen.focused is pilot.app.screen.query_one("#from_buttons")
|
||||
await pilot.press("tab")
|
||||
assert pilot.app.screen.focused is pilot.app.screen.query_one("#from_strings")
|
||||
assert pilot.app.query_one("#from_strings", RadioSet)._selected == 0
|
||||
await pilot.press("down")
|
||||
assert pilot.app.query_one("#from_strings", RadioSet)._selected == 1
|
||||
|
||||
|
||||
async def test_radioset_breakout_navigation():
|
||||
|
||||
@@ -1,7 +1,10 @@
|
||||
from __future__ import annotations
|
||||
|
||||
import pytest
|
||||
|
||||
from textual.app import App, ComposeResult
|
||||
from textual.widgets import Tree
|
||||
from textual.widgets.tree import TreeNode
|
||||
|
||||
|
||||
class VerseBody:
|
||||
@@ -71,3 +74,37 @@ async def test_tree_reset_with_label_and_data() -> None:
|
||||
assert len(tree.root.children) == 0
|
||||
assert str(tree.root.label) == "Jiangyin"
|
||||
assert isinstance(tree.root.data, VersePlanet)
|
||||
|
||||
|
||||
async def test_remove_node():
|
||||
async with TreeClearApp().run_test() as pilot:
|
||||
tree = pilot.app.query_one(VerseTree)
|
||||
assert len(tree.root.children) == 2
|
||||
tree.root.children[0].remove()
|
||||
assert len(tree.root.children) == 1
|
||||
|
||||
|
||||
async def test_remove_node_children():
|
||||
async with TreeClearApp().run_test() as pilot:
|
||||
tree = pilot.app.query_one(VerseTree)
|
||||
assert len(tree.root.children) == 2
|
||||
assert len(tree.root.children[0].children) == 2
|
||||
tree.root.children[0].remove_children()
|
||||
assert len(tree.root.children) == 2
|
||||
assert len(tree.root.children[0].children) == 0
|
||||
|
||||
|
||||
async def test_tree_remove_children_of_root():
|
||||
"""Test removing the children of the root."""
|
||||
async with TreeClearApp().run_test() as pilot:
|
||||
tree = pilot.app.query_one(VerseTree)
|
||||
assert len(tree.root.children) > 1
|
||||
tree.root.remove_children()
|
||||
assert len(tree.root.children) == 0
|
||||
|
||||
|
||||
async def test_attempt_to_remove_root():
|
||||
"""Attempting to remove the root should be an error."""
|
||||
async with TreeClearApp().run_test() as pilot:
|
||||
with pytest.raises(TreeNode.RemoveRootError):
|
||||
pilot.app.query_one(VerseTree).root.remove()
|
||||
|
||||
Reference in New Issue
Block a user