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
|
||||||
|
|
||||||
- Fixed crash when creating a `DirectoryTree` starting anywhere other than `.`
|
- 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
|
### Changed
|
||||||
|
|
||||||
- The DataTable cursor is now scrolled into view when the cursor coordinate is changed programmatically https://github.com/Textualize/textual/issues/2459
|
- 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
|
- 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
|
### 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 `overflow` https://github.com/Textualize/textual/issues/2420
|
||||||
- Fixed `!important` not applying to `scrollbar-size` 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 `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
|
### Changed
|
||||||
|
|
||||||
|
|||||||
@@ -4,7 +4,6 @@ from rich.table import Table
|
|||||||
|
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.widgets import Footer, Header, OptionList
|
from textual.widgets import Footer, Header, OptionList
|
||||||
from textual.widgets.option_list import Option, Separator
|
|
||||||
|
|
||||||
COLONIES: tuple[tuple[str, str, str, str], ...] = (
|
COLONIES: tuple[tuple[str, str, str, str], ...] = (
|
||||||
("Aerilon", "Demeter", "1.2 Billion", "Gaoth"),
|
("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"}
|
```{.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
|
## Static
|
||||||
|
|
||||||
Displays simple static content. Typically used as a base class.
|
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/progress_bar.md"
|
||||||
- "widgets/radiobutton.md"
|
- "widgets/radiobutton.md"
|
||||||
- "widgets/radioset.md"
|
- "widgets/radioset.md"
|
||||||
|
- "widgets/select.md"
|
||||||
- "widgets/static.md"
|
- "widgets/static.md"
|
||||||
- "widgets/switch.md"
|
- "widgets/switch.md"
|
||||||
- "widgets/tabbed_content.md"
|
- "widgets/tabbed_content.md"
|
||||||
|
|||||||
@@ -35,7 +35,7 @@ def arrange(
|
|||||||
dock_layers: defaultdict[str, list[Widget]] = defaultdict(list)
|
dock_layers: defaultdict[str, list[Widget]] = defaultdict(list)
|
||||||
for child in children:
|
for child in children:
|
||||||
if child.display:
|
if child.display:
|
||||||
dock_layers[child.styles.layer or "default"].append(child)
|
dock_layers[child.layer].append(child)
|
||||||
|
|
||||||
width, height = size
|
width, height = size
|
||||||
|
|
||||||
@@ -121,9 +121,14 @@ def arrange(
|
|||||||
if placement_offset:
|
if placement_offset:
|
||||||
layout_placements = [
|
layout_placements = [
|
||||||
_WidgetPlacement(
|
_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)
|
placements.extend(layout_placements)
|
||||||
|
|||||||
@@ -512,6 +512,8 @@ class Compositor:
|
|||||||
add_new_widget = widgets.add
|
add_new_widget = widgets.add
|
||||||
layer_order: int = 0
|
layer_order: int = 0
|
||||||
|
|
||||||
|
no_clip = size.region
|
||||||
|
|
||||||
def add_widget(
|
def add_widget(
|
||||||
widget: Widget,
|
widget: Widget,
|
||||||
virtual_region: Region,
|
virtual_region: Region,
|
||||||
@@ -586,12 +588,13 @@ class Compositor:
|
|||||||
layers_to_index = {
|
layers_to_index = {
|
||||||
layer_name: index for index, layer_name in enumerate(_layers)
|
layer_name: index for index, layer_name in enumerate(_layers)
|
||||||
}
|
}
|
||||||
|
|
||||||
get_layer_index = layers_to_index.get
|
get_layer_index = layers_to_index.get
|
||||||
|
|
||||||
scroll_spacing = arrange_result.scroll_spacing
|
scroll_spacing = arrange_result.scroll_spacing
|
||||||
|
|
||||||
# Add all the widgets
|
# 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
|
placements
|
||||||
):
|
):
|
||||||
layer_index = get_layer_index(sub_widget.layer, 0)
|
layer_index = get_layer_index(sub_widget.layer, 0)
|
||||||
@@ -608,13 +611,23 @@ class Compositor:
|
|||||||
|
|
||||||
widget_order = order + ((layer_index, z, layer_order),)
|
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(
|
add_widget(
|
||||||
sub_widget,
|
sub_widget,
|
||||||
sub_region,
|
sub_region,
|
||||||
widget_region,
|
widget_region,
|
||||||
widget_order,
|
((1, 0, 0),) if overlay else widget_order,
|
||||||
layer_order,
|
layer_order,
|
||||||
sub_clip,
|
no_clip if overlay else sub_clip,
|
||||||
visible,
|
visible,
|
||||||
)
|
)
|
||||||
|
|
||||||
@@ -991,13 +1004,9 @@ class Compositor:
|
|||||||
first_cut, last_cut = render_region.column_span
|
first_cut, last_cut = render_region.column_span
|
||||||
final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)]
|
final_cuts = [cut for cut in cuts[y] if (last_cut >= cut >= first_cut)]
|
||||||
|
|
||||||
if len(final_cuts) <= 2:
|
render_x = render_region.x
|
||||||
# Two cuts, which means the entire line
|
relative_cuts = [cut - render_x for cut in final_cuts[1:]]
|
||||||
cut_strips = [strip]
|
cut_strips = strip.divide(relative_cuts)
|
||||||
else:
|
|
||||||
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"
|
# Since we are painting front to back, the first segments for a cut "wins"
|
||||||
get_chops_line = chops_line.get
|
get_chops_line = chops_line.get
|
||||||
|
|||||||
@@ -36,6 +36,7 @@ class DockArrangeResult:
|
|||||||
(
|
(
|
||||||
placement.region.grow(placement.margin),
|
placement.region.grow(placement.margin),
|
||||||
placement.fixed,
|
placement.fixed,
|
||||||
|
placement.overlay,
|
||||||
placement,
|
placement,
|
||||||
)
|
)
|
||||||
for placement in self.placements
|
for placement in self.placements
|
||||||
@@ -73,6 +74,7 @@ class WidgetPlacement(NamedTuple):
|
|||||||
widget: Widget
|
widget: Widget
|
||||||
order: int = 0
|
order: int = 0
|
||||||
fixed: bool = False
|
fixed: bool = False
|
||||||
|
overlay: bool = False
|
||||||
|
|
||||||
|
|
||||||
class Layout(ABC):
|
class Layout(ABC):
|
||||||
|
|||||||
@@ -57,7 +57,7 @@ class SpatialMap(Generic[ValueType]):
|
|||||||
)
|
)
|
||||||
|
|
||||||
def insert(
|
def insert(
|
||||||
self, regions_and_values: Iterable[tuple[Region, bool, ValueType]]
|
self, regions_and_values: Iterable[tuple[Region, bool, bool, ValueType]]
|
||||||
) -> None:
|
) -> None:
|
||||||
"""Insert values into the Spatial map.
|
"""Insert values into the Spatial map.
|
||||||
|
|
||||||
@@ -71,8 +71,9 @@ class SpatialMap(Generic[ValueType]):
|
|||||||
get_grid_list = self._map.__getitem__
|
get_grid_list = self._map.__getitem__
|
||||||
_region_to_grid = self._region_to_grid_coordinates
|
_region_to_grid = self._region_to_grid_coordinates
|
||||||
total_region = self.total_region
|
total_region = self.total_region
|
||||||
for region, fixed, value in regions_and_values:
|
for region, fixed, overlay, value in regions_and_values:
|
||||||
total_region = total_region.union(region)
|
if not overlay:
|
||||||
|
total_region = total_region.union(region)
|
||||||
if fixed:
|
if fixed:
|
||||||
append_fixed(value)
|
append_fixed(value)
|
||||||
else:
|
else:
|
||||||
|
|||||||
@@ -1660,7 +1660,7 @@ class App(Generic[ReturnType], DOMNode):
|
|||||||
app_css_path = (
|
app_css_path = (
|
||||||
f"{inspect.getfile(self.__class__)}:{self.__class__.__name__}"
|
f"{inspect.getfile(self.__class__)}:{self.__class__.__name__}"
|
||||||
)
|
)
|
||||||
except TypeError:
|
except (TypeError, OSError):
|
||||||
app_css_path = f"{self.__class__.__name__}"
|
app_css_path = f"{self.__class__.__name__}"
|
||||||
self.stylesheet.add_source(
|
self.stylesheet.add_source(
|
||||||
self.CSS, path=app_css_path, is_default_css=False
|
self.CSS, path=app_css_path, is_default_css=False
|
||||||
|
|||||||
@@ -18,7 +18,7 @@ from .keys import _character_to_key
|
|||||||
if TYPE_CHECKING:
|
if TYPE_CHECKING:
|
||||||
from typing_extensions import TypeAlias
|
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):
|
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."""
|
"""Key to bind. This can also be a comma-separated list of keys to map multiple keys to a single action."""
|
||||||
action: str
|
action: str
|
||||||
"""Action to bind to."""
|
"""Action to bind to."""
|
||||||
description: str
|
description: str = ""
|
||||||
"""Description of action."""
|
"""Description of action."""
|
||||||
show: bool = True
|
show: bool = True
|
||||||
"""Show the action in Footer, or False to hide."""
|
"""Show the action in Footer, or False to hide."""
|
||||||
@@ -74,9 +74,9 @@ class _Bindings:
|
|||||||
for binding in bindings:
|
for binding in bindings:
|
||||||
# If it's a tuple of length 3, convert into a Binding first
|
# If it's a tuple of length 3, convert into a Binding first
|
||||||
if isinstance(binding, tuple):
|
if isinstance(binding, tuple):
|
||||||
if len(binding) != 3:
|
if len(binding) not in (2, 3):
|
||||||
raise BindingError(
|
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)
|
binding = Binding(*binding)
|
||||||
|
|
||||||
@@ -95,7 +95,7 @@ class _Bindings:
|
|||||||
key=key,
|
key=key,
|
||||||
action=binding.action,
|
action=binding.action,
|
||||||
description=binding.description,
|
description=binding.description,
|
||||||
show=binding.show,
|
show=bool(binding.description and binding.show),
|
||||||
key_display=binding.key_display,
|
key_display=binding.key_display,
|
||||||
priority=binding.priority,
|
priority=binding.priority,
|
||||||
)
|
)
|
||||||
@@ -165,7 +165,7 @@ class _Bindings:
|
|||||||
key,
|
key,
|
||||||
action,
|
action,
|
||||||
description,
|
description,
|
||||||
show=show,
|
show=bool(description and show),
|
||||||
key_display=key_display,
|
key_display=key_display,
|
||||||
priority=priority,
|
priority=priority,
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -39,9 +39,11 @@ from .constants import (
|
|||||||
VALID_ALIGN_VERTICAL,
|
VALID_ALIGN_VERTICAL,
|
||||||
VALID_BORDER,
|
VALID_BORDER,
|
||||||
VALID_BOX_SIZING,
|
VALID_BOX_SIZING,
|
||||||
|
VALID_CONSTRAIN,
|
||||||
VALID_DISPLAY,
|
VALID_DISPLAY,
|
||||||
VALID_EDGE,
|
VALID_EDGE,
|
||||||
VALID_OVERFLOW,
|
VALID_OVERFLOW,
|
||||||
|
VALID_OVERLAY,
|
||||||
VALID_SCROLLBAR_GUTTER,
|
VALID_SCROLLBAR_GUTTER,
|
||||||
VALID_STYLE_FLAGS,
|
VALID_STYLE_FLAGS,
|
||||||
VALID_TEXT_ALIGN,
|
VALID_TEXT_ALIGN,
|
||||||
@@ -1003,6 +1005,30 @@ class StylesBuilder:
|
|||||||
else:
|
else:
|
||||||
self.error(name, tokens[0], "expected two integers here")
|
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:
|
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.
|
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",
|
"focus",
|
||||||
"hover",
|
"hover",
|
||||||
}
|
}
|
||||||
|
VALID_OVERLAY: Final = {"none", "screen"}
|
||||||
|
VALID_CONSTRAIN: Final = {"x", "y", "both", "none"}
|
||||||
|
|
||||||
NULL_SPACING: Final = Spacing.all(0)
|
NULL_SPACING: Final = Spacing.all(0)
|
||||||
|
|||||||
@@ -153,9 +153,8 @@ class DOMQuery(Generic[QueryType]):
|
|||||||
return self.nodes[index]
|
return self.nodes[index]
|
||||||
|
|
||||||
def __rich_repr__(self) -> rich.repr.Result:
|
def __rich_repr__(self) -> rich.repr.Result:
|
||||||
yield self.node
|
|
||||||
if self._filters:
|
if self._filters:
|
||||||
yield "filter", " AND ".join(
|
yield "query", " AND ".join(
|
||||||
",".join(selector.css for selector in selectors)
|
",".join(selector.css for selector in selectors)
|
||||||
for selectors in self._filters
|
for selectors in self._filters
|
||||||
)
|
)
|
||||||
@@ -214,6 +213,7 @@ class DOMQuery(Generic[QueryType]):
|
|||||||
Returns:
|
Returns:
|
||||||
The matching Widget.
|
The matching Widget.
|
||||||
"""
|
"""
|
||||||
|
_rich_traceback_omit = True
|
||||||
if self.nodes:
|
if self.nodes:
|
||||||
first = self.nodes[0]
|
first = self.nodes[0]
|
||||||
if expect_type is not None:
|
if expect_type is not None:
|
||||||
@@ -250,6 +250,7 @@ class DOMQuery(Generic[QueryType]):
|
|||||||
Returns:
|
Returns:
|
||||||
The matching Widget.
|
The matching Widget.
|
||||||
"""
|
"""
|
||||||
|
_rich_traceback_omit = True
|
||||||
# Call on first to get the first item. Here we'll use all of the
|
# Call on first to get the first item. Here we'll use all of the
|
||||||
# testing and checking it provides.
|
# testing and checking it provides.
|
||||||
the_one = self.first(expect_type) if expect_type is not None else self.first()
|
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_HORIZONTAL,
|
||||||
VALID_ALIGN_VERTICAL,
|
VALID_ALIGN_VERTICAL,
|
||||||
VALID_BOX_SIZING,
|
VALID_BOX_SIZING,
|
||||||
|
VALID_CONSTRAIN,
|
||||||
VALID_DISPLAY,
|
VALID_DISPLAY,
|
||||||
VALID_OVERFLOW,
|
VALID_OVERFLOW,
|
||||||
|
VALID_OVERLAY,
|
||||||
VALID_SCROLLBAR_GUTTER,
|
VALID_SCROLLBAR_GUTTER,
|
||||||
VALID_TEXT_ALIGN,
|
VALID_TEXT_ALIGN,
|
||||||
VALID_VISIBILITY,
|
VALID_VISIBILITY,
|
||||||
@@ -52,9 +54,11 @@ from .types import (
|
|||||||
AlignHorizontal,
|
AlignHorizontal,
|
||||||
AlignVertical,
|
AlignVertical,
|
||||||
BoxSizing,
|
BoxSizing,
|
||||||
|
Constrain,
|
||||||
Display,
|
Display,
|
||||||
Edge,
|
Edge,
|
||||||
Overflow,
|
Overflow,
|
||||||
|
Overlay,
|
||||||
ScrollbarGutter,
|
ScrollbarGutter,
|
||||||
Specificity3,
|
Specificity3,
|
||||||
Specificity6,
|
Specificity6,
|
||||||
@@ -177,6 +181,9 @@ class RulesMap(TypedDict, total=False):
|
|||||||
border_subtitle_background: Color
|
border_subtitle_background: Color
|
||||||
border_subtitle_style: Style
|
border_subtitle_style: Style
|
||||||
|
|
||||||
|
overlay: Overlay
|
||||||
|
constrain: Constrain
|
||||||
|
|
||||||
|
|
||||||
RULE_NAMES = list(RulesMap.__annotations__.keys())
|
RULE_NAMES = list(RulesMap.__annotations__.keys())
|
||||||
RULE_NAMES_SET = frozenset(RULE_NAMES)
|
RULE_NAMES_SET = frozenset(RULE_NAMES)
|
||||||
@@ -223,7 +230,7 @@ class StylesBase(ABC):
|
|||||||
node: DOMNode | None = None
|
node: DOMNode | None = None
|
||||||
|
|
||||||
display = StringEnumProperty(
|
display = StringEnumProperty(
|
||||||
VALID_DISPLAY, "block", layout=True, refresh_parent=True
|
VALID_DISPLAY, "block", layout=True, refresh_parent=True, refresh_children=True
|
||||||
)
|
)
|
||||||
visibility = StringEnumProperty(
|
visibility = StringEnumProperty(
|
||||||
VALID_VISIBILITY, "visible", layout=True, refresh_parent=True
|
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_background = ColorProperty(Color(0, 0, 0, 0))
|
||||||
border_subtitle_style = StyleFlagsProperty()
|
border_subtitle_style = StyleFlagsProperty()
|
||||||
|
|
||||||
|
overlay = StringEnumProperty(
|
||||||
|
VALID_OVERLAY, "none", layout=True, refresh_parent=True
|
||||||
|
)
|
||||||
|
constrain = StringEnumProperty(VALID_CONSTRAIN, "none")
|
||||||
|
|
||||||
def __textual_animation__(
|
def __textual_animation__(
|
||||||
self,
|
self,
|
||||||
attribute: str,
|
attribute: str,
|
||||||
@@ -1024,7 +1036,10 @@ class Styles(StylesBase):
|
|||||||
)
|
)
|
||||||
if "border_subtitle_text_style" in rules:
|
if "border_subtitle_text_style" in rules:
|
||||||
append_declaration("subtitle-text-style", str(self.border_subtitle_style))
|
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()
|
lines.sort()
|
||||||
return lines
|
return lines
|
||||||
|
|
||||||
|
|||||||
@@ -37,6 +37,8 @@ BoxSizing = Literal["border-box", "content-box"]
|
|||||||
Overflow = Literal["scroll", "hidden", "auto"]
|
Overflow = Literal["scroll", "hidden", "auto"]
|
||||||
EdgeStyle = Tuple[EdgeType, Color]
|
EdgeStyle = Tuple[EdgeType, Color]
|
||||||
TextAlign = Literal["left", "start", "center", "right", "end", "justify"]
|
TextAlign = Literal["left", "start", "center", "right", "end", "justify"]
|
||||||
|
Constrain = Literal["none", "x", "y", "both"]
|
||||||
|
Overlay = Literal["none", "screen"]
|
||||||
|
|
||||||
Specificity3 = Tuple[int, int, int]
|
Specificity3 = Tuple[int, int, int]
|
||||||
Specificity6 = Tuple[int, int, int, 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"""
|
"""Get a path to the DOM Node"""
|
||||||
try:
|
try:
|
||||||
return f"{getfile(base)}:{base.__name__}"
|
return f"{getfile(base)}:{base.__name__}"
|
||||||
except TypeError:
|
except (TypeError, OSError):
|
||||||
return f"{base.__name__}"
|
return f"{base.__name__}"
|
||||||
|
|
||||||
for tie_breaker, base in enumerate(self._node_bases):
|
for tie_breaker, base in enumerate(self._node_bases):
|
||||||
@@ -1058,6 +1058,7 @@ class DOMNode(MessagePump):
|
|||||||
Returns:
|
Returns:
|
||||||
A widget matching the selector.
|
A widget matching the selector.
|
||||||
"""
|
"""
|
||||||
|
_rich_traceback_omit = True
|
||||||
from .css.query import DOMQuery
|
from .css.query import DOMQuery
|
||||||
|
|
||||||
if isinstance(selector, str):
|
if isinstance(selector, str):
|
||||||
|
|||||||
@@ -867,6 +867,44 @@ class Region(NamedTuple):
|
|||||||
Region(x, y + cut, width, height - cut),
|
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):
|
class Spacing(NamedTuple):
|
||||||
"""The spacing around a renderable, such as padding and border
|
"""The spacing around a renderable, such as padding and border
|
||||||
|
|||||||
@@ -70,6 +70,7 @@ class HorizontalLayout(Layout):
|
|||||||
_Region = Region
|
_Region = Region
|
||||||
_WidgetPlacement = WidgetPlacement
|
_WidgetPlacement = WidgetPlacement
|
||||||
for widget, box_model, margin in zip(children, box_models, margins):
|
for widget, box_model, margin in zip(children, box_models, margins):
|
||||||
|
overlay = widget.styles.overlay == "screen"
|
||||||
content_width, content_height, box_margin = box_model
|
content_width, content_height, box_margin = box_model
|
||||||
offset_y = box_margin.top
|
offset_y = box_margin.top
|
||||||
next_x = x + content_width
|
next_x = x + content_width
|
||||||
@@ -79,7 +80,10 @@ class HorizontalLayout(Layout):
|
|||||||
max_height = max(
|
max_height = max(
|
||||||
max_height, content_height + offset_y + box_model.margin.bottom
|
max_height, content_height + offset_y + box_model.margin.bottom
|
||||||
)
|
)
|
||||||
add_placement(_WidgetPlacement(region, box_model.margin, widget, 0))
|
add_placement(
|
||||||
x = next_x + margin
|
_WidgetPlacement(region, box_model.margin, widget, 0, False, overlay)
|
||||||
|
)
|
||||||
|
if not overlay:
|
||||||
|
x = next_x + margin
|
||||||
|
|
||||||
return placements, set(displayed_children)
|
return placements, set(displayed_children)
|
||||||
|
|||||||
@@ -64,13 +64,26 @@ class VerticalLayout(Layout):
|
|||||||
|
|
||||||
_Region = Region
|
_Region = Region
|
||||||
_WidgetPlacement = WidgetPlacement
|
_WidgetPlacement = WidgetPlacement
|
||||||
|
|
||||||
for widget, box_model, margin in zip(children, box_models, margins):
|
for widget, box_model, margin in zip(children, box_models, margins):
|
||||||
|
overlay = widget.styles.overlay == "screen"
|
||||||
content_width, content_height, box_margin = box_model
|
content_width, content_height, box_margin = box_model
|
||||||
next_y = y + content_height
|
next_y = y + content_height
|
||||||
|
|
||||||
region = _Region(
|
region = _Region(
|
||||||
box_margin.left, int(y), int(content_width), int(next_y) - int(y)
|
box_margin.left, int(y), int(content_width), int(next_y) - int(y)
|
||||||
)
|
)
|
||||||
add_placement(_WidgetPlacement(region, box_model.margin, widget, 0))
|
add_placement(
|
||||||
y = next_y + margin
|
_WidgetPlacement(
|
||||||
|
region,
|
||||||
|
box_model.margin,
|
||||||
|
widget,
|
||||||
|
0,
|
||||||
|
False,
|
||||||
|
overlay,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
if not overlay:
|
||||||
|
y = next_y + margin
|
||||||
|
|
||||||
return placements, set(children)
|
return placements, set(children)
|
||||||
|
|||||||
@@ -38,6 +38,10 @@ class Message:
|
|||||||
namespace: ClassVar[str] = "" # Namespace to disambiguate messages
|
namespace: ClassVar[str] = "" # Namespace to disambiguate messages
|
||||||
|
|
||||||
def __init__(self) -> None:
|
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._sender: MessageTarget | None = active_message_pump.get(None)
|
||||||
self.time: float = _time.get_time()
|
self.time: float = _time.get_time()
|
||||||
self._forwarded = False
|
self._forwarded = False
|
||||||
@@ -48,7 +52,6 @@ class Message:
|
|||||||
f"on_{self.namespace}_{name}" if self.namespace else f"on_{name}"
|
f"on_{self.namespace}_{name}" if self.namespace else f"on_{name}"
|
||||||
)
|
)
|
||||||
self._prevent: set[type[Message]] = set()
|
self._prevent: set[type[Message]] = set()
|
||||||
super().__init__()
|
|
||||||
|
|
||||||
def __rich_repr__(self) -> rich.repr.Result:
|
def __rich_repr__(self) -> rich.repr.Result:
|
||||||
yield from ()
|
yield from ()
|
||||||
@@ -71,6 +74,7 @@ class Message:
|
|||||||
|
|
||||||
@property
|
@property
|
||||||
def is_forwarded(self) -> bool:
|
def is_forwarded(self) -> bool:
|
||||||
|
"""Has the message been forwarded?"""
|
||||||
return self._forwarded
|
return self._forwarded
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
|||||||
@@ -648,6 +648,7 @@ class MessagePump(metaclass=_MessagePumpMeta):
|
|||||||
Returns:
|
Returns:
|
||||||
`True` if the messages was processed, `False` if it wasn't.
|
`True` if the messages was processed, `False` if it wasn't.
|
||||||
"""
|
"""
|
||||||
|
_rich_traceback_omit = True
|
||||||
if self._closing or self._closed:
|
if self._closing or self._closed:
|
||||||
return False
|
return False
|
||||||
if not self.check_message_enabled(message):
|
if not self.check_message_enabled(message):
|
||||||
|
|||||||
@@ -76,11 +76,13 @@ class Reactive(Generic[ReactiveType]):
|
|||||||
def var(
|
def var(
|
||||||
cls,
|
cls,
|
||||||
default: ReactiveType | Callable[[], ReactiveType],
|
default: ReactiveType | Callable[[], ReactiveType],
|
||||||
|
always_update: bool = False,
|
||||||
) -> Reactive:
|
) -> Reactive:
|
||||||
"""A reactive variable that doesn't update or layout.
|
"""A reactive variable that doesn't update or layout.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
default: A default value or callable that returns a default.
|
default: A default value or callable that returns a default.
|
||||||
|
always_update: Call watchers even when the new value equals the old value.
|
||||||
|
|
||||||
Returns:
|
Returns:
|
||||||
A Reactive descriptor.
|
A Reactive descriptor.
|
||||||
@@ -326,18 +328,21 @@ class var(Reactive[ReactiveType]):
|
|||||||
Args:
|
Args:
|
||||||
default: A default value or callable that returns a default.
|
default: A default value or callable that returns a default.
|
||||||
init: Call watchers on initialize (post mount).
|
init: Call watchers on initialize (post mount).
|
||||||
|
always_update: Call watchers even when the new value equals the old value.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def __init__(
|
def __init__(
|
||||||
self,
|
self,
|
||||||
default: ReactiveType | Callable[[], ReactiveType],
|
default: ReactiveType | Callable[[], ReactiveType],
|
||||||
init: bool = True,
|
init: bool = True,
|
||||||
|
always_update: bool = False,
|
||||||
) -> None:
|
) -> None:
|
||||||
super().__init__(
|
super().__init__(
|
||||||
default,
|
default,
|
||||||
layout=False,
|
layout=False,
|
||||||
repaint=False,
|
repaint=False,
|
||||||
init=init,
|
init=init,
|
||||||
|
always_update=always_update,
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|
||||||
|
|||||||
@@ -1569,12 +1569,13 @@ class Widget(DOMNode):
|
|||||||
Returns:
|
Returns:
|
||||||
Tuple of layer names.
|
Tuple of layer names.
|
||||||
"""
|
"""
|
||||||
|
layers: tuple[str, ...] = ("default",)
|
||||||
for node in self.ancestors_with_self:
|
for node in self.ancestors_with_self:
|
||||||
if not isinstance(node, Widget):
|
if not isinstance(node, Widget):
|
||||||
break
|
break
|
||||||
if node.styles.has_rule("layers"):
|
if node.styles.has_rule("layers"):
|
||||||
return node.styles.layers
|
layers = node.styles.layers
|
||||||
return ("default",)
|
return layers
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def link_style(self) -> Style:
|
def link_style(self) -> Style:
|
||||||
@@ -1637,8 +1638,11 @@ class Widget(DOMNode):
|
|||||||
self._dirty_regions.clear()
|
self._dirty_regions.clear()
|
||||||
self._repaint_regions.clear()
|
self._repaint_regions.clear()
|
||||||
self._styles_cache.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]:
|
def _exchange_repaint_regions(self) -> Collection[Region]:
|
||||||
"""Get a copy of the regions which need a repaint, and clear internal cache.
|
"""Get a copy of the regions which need a repaint, and clear internal cache.
|
||||||
@@ -2921,6 +2925,13 @@ class Widget(DOMNode):
|
|||||||
Returns:
|
Returns:
|
||||||
True if the message was posted, False if this widget was closed / closing.
|
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:
|
if constants.DEBUG and not self.is_running and not message.no_dispatch:
|
||||||
try:
|
try:
|
||||||
|
|||||||
@@ -29,6 +29,7 @@ if typing.TYPE_CHECKING:
|
|||||||
from ._progress_bar import ProgressBar
|
from ._progress_bar import ProgressBar
|
||||||
from ._radio_button import RadioButton
|
from ._radio_button import RadioButton
|
||||||
from ._radio_set import RadioSet
|
from ._radio_set import RadioSet
|
||||||
|
from ._select import Select
|
||||||
from ._static import Static
|
from ._static import Static
|
||||||
from ._switch import Switch
|
from ._switch import Switch
|
||||||
from ._tabbed_content import TabbedContent, TabPane
|
from ._tabbed_content import TabbedContent, TabPane
|
||||||
@@ -59,6 +60,7 @@ __all__ = [
|
|||||||
"ProgressBar",
|
"ProgressBar",
|
||||||
"RadioButton",
|
"RadioButton",
|
||||||
"RadioSet",
|
"RadioSet",
|
||||||
|
"Select",
|
||||||
"Static",
|
"Static",
|
||||||
"Switch",
|
"Switch",
|
||||||
"Tab",
|
"Tab",
|
||||||
|
|||||||
@@ -19,6 +19,7 @@ from ._pretty import Pretty as Pretty
|
|||||||
from ._progress_bar import ProgressBar as ProgressBar
|
from ._progress_bar import ProgressBar as ProgressBar
|
||||||
from ._radio_button import RadioButton as RadioButton
|
from ._radio_button import RadioButton as RadioButton
|
||||||
from ._radio_set import RadioSet as RadioSet
|
from ._radio_set import RadioSet as RadioSet
|
||||||
|
from ._select import Select as Select
|
||||||
from ._static import Static as Static
|
from ._static import Static as Static
|
||||||
from ._switch import Switch as Switch
|
from ._switch import Switch as Switch
|
||||||
from ._tabbed_content import TabbedContent as TabbedContent
|
from ._tabbed_content import TabbedContent as TabbedContent
|
||||||
|
|||||||
@@ -311,6 +311,11 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
|||||||
cursor_coordinate: Reactive[Coordinate] = Reactive(
|
cursor_coordinate: Reactive[Coordinate] = Reactive(
|
||||||
Coordinate(0, 0), repaint=False, always_update=True
|
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(
|
hover_coordinate: Reactive[Coordinate] = Reactive(
|
||||||
Coordinate(0, 0), repaint=False, always_update=True
|
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":
|
elif self.cursor_type == "column":
|
||||||
self.refresh_column(old_coordinate.column)
|
self.refresh_column(old_coordinate.column)
|
||||||
self._highlight_column(new_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:
|
def _highlight_coordinate(self, coordinate: Coordinate) -> None:
|
||||||
"""Apply highlighting to the cell at the coordinate, and post event."""
|
"""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(
|
self.cursor_coordinate = Coordinate(
|
||||||
row_index + rows_to_scroll - 1, column_index
|
row_index + rows_to_scroll - 1, column_index
|
||||||
)
|
)
|
||||||
self._scroll_cursor_into_view()
|
|
||||||
else:
|
else:
|
||||||
super().action_page_down()
|
super().action_page_down()
|
||||||
|
|
||||||
@@ -2079,7 +2117,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
|||||||
self.cursor_coordinate = Coordinate(
|
self.cursor_coordinate = Coordinate(
|
||||||
row_index - rows_to_scroll + 1, column_index
|
row_index - rows_to_scroll + 1, column_index
|
||||||
)
|
)
|
||||||
self._scroll_cursor_into_view()
|
|
||||||
else:
|
else:
|
||||||
super().action_page_up()
|
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"):
|
if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"):
|
||||||
row_index, column_index = self.cursor_coordinate
|
row_index, column_index = self.cursor_coordinate
|
||||||
self.cursor_coordinate = Coordinate(0, column_index)
|
self.cursor_coordinate = Coordinate(0, column_index)
|
||||||
self._scroll_cursor_into_view()
|
|
||||||
else:
|
else:
|
||||||
super().action_scroll_home()
|
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"):
|
if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"):
|
||||||
row_index, column_index = self.cursor_coordinate
|
row_index, column_index = self.cursor_coordinate
|
||||||
self.cursor_coordinate = Coordinate(self.row_count - 1, column_index)
|
self.cursor_coordinate = Coordinate(self.row_count - 1, column_index)
|
||||||
self._scroll_cursor_into_view()
|
|
||||||
else:
|
else:
|
||||||
super().action_scroll_end()
|
super().action_scroll_end()
|
||||||
|
|
||||||
@@ -2110,7 +2145,6 @@ class DataTable(ScrollView, Generic[CellType], can_focus=True):
|
|||||||
cursor_type = self.cursor_type
|
cursor_type = self.cursor_type
|
||||||
if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"):
|
if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"):
|
||||||
self.cursor_coordinate = self.cursor_coordinate.up()
|
self.cursor_coordinate = self.cursor_coordinate.up()
|
||||||
self._scroll_cursor_into_view()
|
|
||||||
else:
|
else:
|
||||||
# If the cursor doesn't move up (e.g. column cursor can't go up),
|
# If the cursor doesn't move up (e.g. column cursor can't go up),
|
||||||
# then ensure that we instead scroll the DataTable.
|
# 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
|
cursor_type = self.cursor_type
|
||||||
if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"):
|
if self.show_cursor and (cursor_type == "cell" or cursor_type == "row"):
|
||||||
self.cursor_coordinate = self.cursor_coordinate.down()
|
self.cursor_coordinate = self.cursor_coordinate.down()
|
||||||
self._scroll_cursor_into_view()
|
|
||||||
else:
|
else:
|
||||||
super().action_scroll_down()
|
super().action_scroll_down()
|
||||||
|
|
||||||
|
|||||||
@@ -7,9 +7,10 @@ forms of bounce-bar menu.
|
|||||||
|
|
||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
from typing import ClassVar, NamedTuple
|
from typing import ClassVar, Iterable, NamedTuple
|
||||||
|
|
||||||
from rich.console import RenderableType
|
from rich.console import RenderableType
|
||||||
|
from rich.padding import Padding
|
||||||
from rich.repr import Result
|
from rich.repr import Result
|
||||||
from rich.rule import Rule
|
from rich.rule import Rule
|
||||||
from rich.style import Style
|
from rich.style import Style
|
||||||
@@ -146,6 +147,7 @@ class OptionList(ScrollView, can_focus=True):
|
|||||||
"""
|
"""
|
||||||
|
|
||||||
COMPONENT_CLASSES: ClassVar[set[str]] = {
|
COMPONENT_CLASSES: ClassVar[set[str]] = {
|
||||||
|
"option-list--option",
|
||||||
"option-list--option-disabled",
|
"option-list--option-disabled",
|
||||||
"option-list--option-highlighted",
|
"option-list--option-highlighted",
|
||||||
"option-list--option-highlighted-disabled",
|
"option-list--option-highlighted-disabled",
|
||||||
@@ -483,6 +485,7 @@ class OptionList(ScrollView, can_focus=True):
|
|||||||
# also set up the tracking of the actual options.
|
# also set up the tracking of the actual options.
|
||||||
line = 0
|
line = 0
|
||||||
option = 0
|
option = 0
|
||||||
|
padding = self.get_component_styles("option-list--option").padding
|
||||||
for content in self._contents:
|
for content in self._contents:
|
||||||
if isinstance(content, Option):
|
if isinstance(content, Option):
|
||||||
# The content is an option, so render out the prompt and
|
# 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})),
|
Strip(prompt_line).apply_style(Style(meta={"option": 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.
|
# Record the span information for the option.
|
||||||
add_span(OptionLineSpan(line, len(new_lines)))
|
add_span(OptionLineSpan(line, len(new_lines)))
|
||||||
@@ -517,6 +523,31 @@ class OptionList(ScrollView, can_focus=True):
|
|||||||
# list, set the virtual size.
|
# list, set the virtual size.
|
||||||
self.virtual_size = Size(self.scrollable_content_region.width, len(self._lines))
|
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:
|
def add_option(self, item: NewOptionListContent = None) -> Self:
|
||||||
"""Add a new option to the end of the option list.
|
"""Add a new option to the end of the option list.
|
||||||
|
|
||||||
@@ -529,15 +560,7 @@ class OptionList(ScrollView, can_focus=True):
|
|||||||
Raises:
|
Raises:
|
||||||
DuplicateID: If there is an attempt to use a duplicate ID.
|
DuplicateID: If there is an attempt to use a duplicate ID.
|
||||||
"""
|
"""
|
||||||
# Turn any incoming value into valid content for the list.
|
return self.add_options([item])
|
||||||
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
|
|
||||||
|
|
||||||
def _remove_option(self, index: int) -> None:
|
def _remove_option(self, index: int) -> None:
|
||||||
"""Remove an option from the option list.
|
"""Remove an option from the option list.
|
||||||
@@ -830,8 +853,13 @@ class OptionList(ScrollView, can_focus=True):
|
|||||||
# It's a normal option line.
|
# It's a normal option line.
|
||||||
return strip.apply_style(self.rich_style)
|
return strip.apply_style(self.rich_style)
|
||||||
|
|
||||||
def scroll_to_highlight(self) -> None:
|
def scroll_to_highlight(self, top: bool = False) -> None:
|
||||||
"""Ensure that the highlighted option is in view."""
|
"""Ensure that the highlighted option is in view.
|
||||||
|
|
||||||
|
Args:
|
||||||
|
top: Scroll highlight to top of the list.
|
||||||
|
|
||||||
|
"""
|
||||||
highlighted = self.highlighted
|
highlighted = self.highlighted
|
||||||
if highlighted is None:
|
if highlighted is None:
|
||||||
return
|
return
|
||||||
@@ -848,6 +876,7 @@ class OptionList(ScrollView, can_focus=True):
|
|||||||
),
|
),
|
||||||
force=True,
|
force=True,
|
||||||
animate=False,
|
animate=False,
|
||||||
|
top=top,
|
||||||
)
|
)
|
||||||
|
|
||||||
def validate_highlighted(self, highlighted: int | None) -> int | None:
|
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.tabbed_content
|
||||||
yield self.tab
|
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.
|
"""Initialize a TabbedContent widgets.
|
||||||
|
|
||||||
Args:
|
Args:
|
||||||
*titles: Positional argument will be used as title.
|
*titles: Positional argument will be used as title.
|
||||||
initial: The id of the initial tab, or empty string to select the first tab.
|
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.titles = [self.render_str(title) for title in titles]
|
||||||
self._tab_content: list[Widget] = []
|
self._tab_content: list[Widget] = []
|
||||||
self._initial = initial
|
self._initial = initial
|
||||||
super().__init__()
|
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
|
||||||
|
|
||||||
def validate_active(self, active: str) -> str:
|
def validate_active(self, active: str) -> str:
|
||||||
"""It doesn't make sense for `active` to be an empty string.
|
"""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
|
self.tab = tab
|
||||||
super().__init__()
|
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:
|
def __rich_repr__(self) -> rich.repr.Result:
|
||||||
yield self.tabs
|
yield self.tabs
|
||||||
yield self.tab
|
yield self.tab
|
||||||
@@ -213,6 +222,15 @@ class Tabs(Widget, can_focus=True):
|
|||||||
self.tabs = tabs
|
self.tabs = tabs
|
||||||
super().__init__()
|
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:
|
def __rich_repr__(self) -> rich.repr.Result:
|
||||||
yield self.tabs
|
yield self.tabs
|
||||||
|
|
||||||
|
|||||||
@@ -184,6 +184,11 @@ class TreeNode(Generic[TreeDataType]):
|
|||||||
self._parent._children and self._parent._children[-1] == self,
|
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
|
@property
|
||||||
def allow_expand(self) -> bool:
|
def allow_expand(self) -> bool:
|
||||||
"""Is this node allowed to expand?"""
|
"""Is this node allowed to expand?"""
|
||||||
@@ -344,6 +349,47 @@ class TreeNode(Generic[TreeDataType]):
|
|||||||
node = self.add(label, data, expand=False, allow_expand=False)
|
node = self.add(label, data, expand=False, allow_expand=False)
|
||||||
return node
|
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):
|
class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
||||||
"""A widget for displaying and navigating data in a tree."""
|
"""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."""
|
"""Show the root of the tree."""
|
||||||
hover_line = var(-1)
|
hover_line = var(-1)
|
||||||
"""The line number under the mouse pointer, or -1 if not under the mouse pointer."""
|
"""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."""
|
"""The line with the cursor, or -1 if no cursor."""
|
||||||
show_guides = reactive(True)
|
show_guides = reactive(True)
|
||||||
"""Enable display of tree guide lines."""
|
"""Enable display of tree guide lines."""
|
||||||
@@ -858,6 +904,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
|||||||
self._cursor_node = node
|
self._cursor_node = node
|
||||||
if previous_node != node:
|
if previous_node != node:
|
||||||
self.post_message(self.NodeHighlighted(self, node))
|
self.post_message(self.NodeHighlighted(self, node))
|
||||||
|
else:
|
||||||
|
self._cursor_node = None
|
||||||
|
|
||||||
def watch_guide_depth(self, guide_depth: int) -> None:
|
def watch_guide_depth(self, guide_depth: int) -> None:
|
||||||
self._invalidate()
|
self._invalidate()
|
||||||
@@ -1020,8 +1068,8 @@ class Tree(Generic[TreeDataType], ScrollView, can_focus=True):
|
|||||||
"tree--guides-selected", partial=True
|
"tree--guides-selected", partial=True
|
||||||
)
|
)
|
||||||
|
|
||||||
hover = self.root._hover
|
hover = line.path[0]._hover
|
||||||
selected = self.root._selected and self.has_focus
|
selected = line.path[0]._selected and self.has_focus
|
||||||
|
|
||||||
def get_guides(style: Style) -> tuple[str, str, str, str]:
|
def get_guides(style: Style) -> tuple[str, str, str, str]:
|
||||||
"""Get the guide strings for a given style.
|
"""Get the guide strings for a given style.
|
||||||
|
|||||||
@@ -106,6 +106,14 @@ async def test_add_later() -> None:
|
|||||||
assert option_list.option_count == 6
|
assert option_list.option_count == 6
|
||||||
option_list.add_option(Option("even more"))
|
option_list.add_option(Option("even more"))
|
||||||
assert option_list.option_count == 7
|
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:
|
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_options.py")
|
||||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "option_list_tables.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):
|
def test_progress_bar_indeterminate(snap_compare):
|
||||||
assert snap_compare(WIDGET_EXAMPLES_DIR / "progress_bar_isolated_.py", press=["f"])
|
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"])
|
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 ---
|
# --- CSS properties ---
|
||||||
# We have a canonical example for each CSS property that is shown in their docs.
|
# 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.
|
# 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():
|
def test_bad_binding_tuple():
|
||||||
with pytest.raises(BindingError):
|
with pytest.raises(BindingError):
|
||||||
_ = _Bindings((("a", "action"),))
|
_ = _Bindings((("a",),))
|
||||||
with pytest.raises(BindingError):
|
with pytest.raises(BindingError):
|
||||||
_ = _Bindings((("a", "action", "description", "too much"),))
|
_ = _Bindings((("a", "action", "description", "too much"),))
|
||||||
|
|
||||||
|
|||||||
@@ -996,23 +996,34 @@ def test_key_string_lookup():
|
|||||||
async def test_scrolling_cursor_into_view():
|
async def test_scrolling_cursor_into_view():
|
||||||
"""Regression test for https://github.com/Textualize/textual/issues/2459"""
|
"""Regression test for https://github.com/Textualize/textual/issues/2459"""
|
||||||
|
|
||||||
class TableApp(App):
|
class ScrollingApp(DataTableApp):
|
||||||
CSS = "DataTable { height: 100%; }"
|
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):
|
def key_c(self):
|
||||||
self.query_one(DataTable).cursor_coordinate = Coordinate(200, 0)
|
self.query_one(DataTable).cursor_coordinate = Coordinate(200, 0)
|
||||||
|
|
||||||
app = TableApp()
|
app = ScrollingApp()
|
||||||
|
|
||||||
async with app.run_test() as pilot:
|
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.press("c")
|
||||||
await pilot.pause()
|
assert table.scroll_y > 100
|
||||||
assert app.query_one(DataTable).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, 5, 22, 14),
|
||||||
Region(10, 19, 22, 1),
|
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(
|
spatial_map.insert(
|
||||||
[
|
[
|
||||||
(Region(10, 5, 5, 5), False, "foo"),
|
(Region(10, 5, 5, 5), False, False, "foo"),
|
||||||
(Region(5, 20, 5, 5), False, "bar"),
|
(Region(5, 20, 5, 5), False, False, "bar"),
|
||||||
(Region(0, 0, 40, 1), True, "title"),
|
(Region(0, 0, 40, 1), True, False, "title"),
|
||||||
]
|
]
|
||||||
)
|
)
|
||||||
|
|
||||||
|
|||||||
@@ -15,7 +15,9 @@ class CheckboxApp(App[None]):
|
|||||||
yield Checkbox(value=True, id="cb3")
|
yield Checkbox(value=True, id="cb3")
|
||||||
|
|
||||||
def on_checkbox_changed(self, event: Checkbox.Changed) -> None:
|
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:
|
async def test_checkbox_initial_state() -> None:
|
||||||
@@ -43,7 +45,7 @@ async def test_checkbox_toggle() -> None:
|
|||||||
]
|
]
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
assert pilot.app.events_received == [
|
assert pilot.app.events_received == [
|
||||||
("cb1", True),
|
("cb1", True, True),
|
||||||
("cb2", True),
|
("cb2", True, True),
|
||||||
("cb3", False),
|
("cb3", False, True),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -15,7 +15,13 @@ class RadioButtonApp(App[None]):
|
|||||||
yield RadioButton(value=True, id="rb3")
|
yield RadioButton(value=True, id="rb3")
|
||||||
|
|
||||||
def on_radio_button_changed(self, event: RadioButton.Changed) -> None:
|
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:
|
async def test_radio_button_initial_state() -> None:
|
||||||
@@ -51,7 +57,7 @@ async def test_radio_button_toggle() -> None:
|
|||||||
]
|
]
|
||||||
await pilot.pause()
|
await pilot.pause()
|
||||||
assert pilot.app.events_received == [
|
assert pilot.app.events_received == [
|
||||||
("rb1", True),
|
("rb1", True, True),
|
||||||
("rb2", True),
|
("rb2", True, True),
|
||||||
("rb3", False),
|
("rb3", False, True),
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -11,7 +11,7 @@ class RadioSetApp(App[None]):
|
|||||||
|
|
||||||
def compose(self) -> ComposeResult:
|
def compose(self) -> ComposeResult:
|
||||||
with RadioSet(id="from_buttons"):
|
with RadioSet(id="from_buttons"):
|
||||||
yield RadioButton()
|
yield RadioButton(id="clickme")
|
||||||
yield RadioButton()
|
yield RadioButton()
|
||||||
yield RadioButton(value=True)
|
yield RadioButton(value=True)
|
||||||
yield RadioSet("One", "True", "Three", id="from_strings")
|
yield RadioSet("One", "True", "Three", id="from_strings")
|
||||||
@@ -36,6 +36,14 @@ async def test_radio_sets_initial_state():
|
|||||||
assert pilot.app.events_received == []
|
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():
|
async def test_radio_sets_toggle():
|
||||||
"""Test the status of the radio sets after they've been toggled."""
|
"""Test the status of the radio sets after they've been toggled."""
|
||||||
async with RadioSetApp().run_test() as pilot:
|
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():
|
async def test_radioset_inner_navigation():
|
||||||
"""Using the cursor keys should navigate between buttons in a set."""
|
"""Using the cursor keys should navigate between buttons in a set."""
|
||||||
async with RadioSetApp().run_test() as pilot:
|
async with RadioSetApp().run_test() as pilot:
|
||||||
assert pilot.app.screen.focused is None
|
assert pilot.app.screen.focused is None
|
||||||
await pilot.press("tab")
|
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")
|
await pilot.press(key, "enter")
|
||||||
assert (
|
assert (
|
||||||
pilot.app.query_one("#from_buttons", RadioSet).pressed_button
|
pilot.app.query_one("#from_buttons", RadioSet).pressed_button
|
||||||
== pilot.app.query_one("#from_buttons").children[landing]
|
== 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():
|
async def test_radioset_breakout_navigation():
|
||||||
|
|||||||
@@ -1,7 +1,10 @@
|
|||||||
from __future__ import annotations
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
|
||||||
from textual.app import App, ComposeResult
|
from textual.app import App, ComposeResult
|
||||||
from textual.widgets import Tree
|
from textual.widgets import Tree
|
||||||
|
from textual.widgets.tree import TreeNode
|
||||||
|
|
||||||
|
|
||||||
class VerseBody:
|
class VerseBody:
|
||||||
@@ -71,3 +74,37 @@ async def test_tree_reset_with_label_and_data() -> None:
|
|||||||
assert len(tree.root.children) == 0
|
assert len(tree.root.children) == 0
|
||||||
assert str(tree.root.label) == "Jiangyin"
|
assert str(tree.root.label) == "Jiangyin"
|
||||||
assert isinstance(tree.root.data, VersePlanet)
|
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