Merge branch 'main' into messages-control

This commit is contained in:
Rodrigo Girão Serrão
2023-05-08 11:18:43 +01:00
committed by GitHub
46 changed files with 4221 additions and 2601 deletions

View File

@@ -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

View File

@@ -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"),

View File

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

View File

@@ -0,0 +1,26 @@
from textual import on
from textual.app import App, ComposeResult
from textual.widgets import Header, Select
LINES = """I must not fear.
Fear is the mind-killer.
Fear is the little-death that brings total obliteration.
I will face my fear.
I will permit it to pass over me and through me.""".splitlines()
class SelectApp(App):
CSS_PATH = "select.css"
def compose(self) -> ComposeResult:
yield Header()
yield Select((line, line) for line in LINES)
@on(Select.Changed)
def select_changed(self, event: Select.Changed) -> None:
self.title = str(event.value)
if __name__ == "__main__":
app = SelectApp()
app.run()

View File

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

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

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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):

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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,
)

View File

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

View File

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

View File

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

View File

@@ -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()

View File

@@ -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:

View File

@@ -0,0 +1,378 @@
from __future__ import annotations
from dataclasses import dataclass
from typing import TYPE_CHECKING, Generic, Iterable, Optional, TypeVar
from rich.console import RenderableType
from rich.text import Text
from .. import events, on
from ..app import ComposeResult
from ..containers import Horizontal, Vertical
from ..message import Message
from ..reactive import var
from ..widgets import Static
from ._option_list import Option, OptionList
if TYPE_CHECKING:
from typing_extensions import TypeAlias
class SelectOverlay(OptionList):
"""The 'pop-up' overlay for the Select control."""
BINDINGS = [("escape", "dismiss")]
DEFAULT_CSS = """
SelectOverlay {
border: tall $background;
background: $panel;
color: $text;
width: 100%;
padding: 0 1;
}
SelectOverlay > .option-list--option {
padding: 0 1;
}
"""
@dataclass
class Dismiss(Message):
"""Inform ancestor the overlay should be dismissed."""
lost_focus: bool = False
"""True if the overlay lost focus."""
@dataclass
class UpdateSelection(Message):
"""Inform ancestor the selection was changed."""
option_index: int
"""The index of the new selection."""
def select(self, index: int | None) -> None:
"""Move selection.
Args:
index: Index of new selection.
"""
self.highlighted = index
self.scroll_to_highlight(top=True)
def action_dismiss(self) -> None:
"""Dismiss the overlay."""
self.post_message(self.Dismiss())
def _on_blur(self, _event: events.Blur) -> None:
"""On blur we want to dismiss the overlay."""
self.post_message(self.Dismiss(lost_focus=True))
def on_option_list_option_selected(self, event: OptionList.OptionSelected) -> None:
"""Inform parent when an option is selected."""
event.stop()
self.post_message(self.UpdateSelection(event.option_index))
class SelectCurrent(Horizontal):
"""Displays the currently selected option."""
DEFAULT_CSS = """
SelectCurrent {
border: tall $background;
background: $boost;
color: $text;
width: 100%;
height: auto;
padding: 0 2;
}
SelectCurrent Static#label {
width: 1fr;
height: auto;
color: $text-disabled;
background: transparent;
}
SelectCurrent.-has-value Static#label {
color: $text;
}
SelectCurrent .arrow {
box-sizing: content-box;
width: 1;
height: 1;
padding: 0 0 0 1;
color: $text-muted;
background: transparent;
}
SelectCurrent .arrow {
box-sizing: content-box;
width: 1;
height: 1;
padding: 0 0 0 1;
color: $text-muted;
background: transparent;
}
"""
has_value: var[bool] = var(False)
"""True if there is a current value, or False if it is None."""
class Toggle(Message):
"""Request toggle overlay."""
def __init__(self, placeholder: str) -> None:
"""Initialize the SelectCurrent.
Args:
placeholder: A string to display when there is nothing selected.
"""
super().__init__()
self.placeholder = placeholder
self.label: RenderableType | None = None
def update(self, label: RenderableType | None) -> None:
"""Update the content in the widget.
Args:
label: A renderable to display, or `None` for the placeholder.
"""
self.label = label
self.has_value = label is not None
self.query_one("#label", Static).update(
self.placeholder if label is None else label
)
def compose(self) -> ComposeResult:
"""Compose label and down arrow."""
yield Static(self.placeholder, id="label")
yield Static("", classes="arrow down-arrow")
yield Static("", classes="arrow up-arrow")
def _watch_has_value(self, has_value: bool) -> None:
"""Toggle the class."""
self.set_class(has_value, "-has-value")
async def _on_click(self, event: events.Click) -> None:
"""Inform ancestor we want to toggle."""
self.post_message(self.Toggle())
SelectType = TypeVar("SelectType")
"""The type used for data in the Select."""
SelectOption: TypeAlias = "tuple[str, SelectType]"
"""The type used for options in the Select."""
class Select(Generic[SelectType], Vertical, can_focus=True):
"""Widget to select from a list of possible options.
A Select displays the current selection.
When activated with ++enter++ the widget displays an overlay with a list of all possible options.
"""
BINDINGS = [("enter,down,space,up", "show_overlay")]
"""
| Key(s) | Description |
| :- | :- |
| enter,down,space,up | Activate the overlay |
"""
DEFAULT_CSS = """
Select {
height: auto;
}
Select:focus > SelectCurrent {
border: tall $accent;
}
Select {
height: auto;
}
Select > SelectOverlay {
width: 1fr;
display: none;
height: auto;
max-height: 10;
overlay: screen;
constrain: y;
}
Select .up-arrow {
display:none;
}
Select.-expanded .down-arrow {
display:none;
}
Select.-expanded .up-arrow {
display: block;
}
Select.-expanded > SelectOverlay {
display: block;
}
Select.-expanded > SelectCurrent {
border: tall $accent;
}
"""
expanded: var[bool] = var(False, init=False)
"""True to show the overlay, otherwise False."""
prompt: var[str] = var[str]("Select")
"""The prompt to show when no value is selected."""
value: var[SelectType | None] = var[Optional[SelectType]](None)
"""The value of the select."""
class Changed(Message, bubble=True):
"""Posted when the select value was changed.
This message can be handled using a `on_select_changed` method.
"""
def __init__(self, control: Select, value: SelectType | None) -> None:
"""
Initialize the Changed message.
"""
super().__init__()
self.control = control
"""The select control."""
self.value = value
"""The value of the Select when it changed."""
def __init__(
self,
options: Iterable[tuple[str, SelectType]],
*,
prompt: str = "Select",
allow_blank: bool = True,
value: SelectType | None = None,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
):
"""Initialize the Select control
Args:
options: Options to select from.
prompt: Text to show in the control when no option is select.
allow_blank: Allow the selection of a blank option.
value: Initial value (should be one of the values in `options`).
name: The name of the select control.
id: The ID of the control the DOM.
classes: The CSS classes of the control.
disabled: Whether the control is disabled or not.
"""
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self._allow_blank = allow_blank
self.prompt = prompt
self._initial_options = list(options)
self._value: SelectType | None = value
def set_options(self, options: Iterable[tuple[RenderableType, SelectType]]) -> None:
"""Set the options for the Select.
Args:
options: An iterable of tuples containing (STRING, VALUE).
"""
self._options: list[tuple[RenderableType, SelectType | None]] = list(options)
if self._allow_blank:
self._options.insert(0, ("", None))
self._select_options: list[Option] = [
(
Option(Text(self.prompt, style="dim"))
if value is None
else Option(prompt)
)
for prompt, value in self._options
]
option_list = self.query_one(SelectOverlay)
option_list.clear_options()
for option in self._select_options:
option_list.add_option(option)
def _watch_value(self, value: SelectType | None) -> None:
"""Update the current value when it changes."""
self._value = value
if value is None:
self.query_one(SelectCurrent).update(None)
else:
for index, (prompt, _value) in enumerate(self._options):
if _value == value:
select_overlay = self.query_one(SelectOverlay)
select_overlay.highlighted = index
self.query_one(SelectCurrent).update(prompt)
break
else:
self.query_one(SelectCurrent).update(None)
def compose(self) -> ComposeResult:
"""Compose Select with overlay and current value."""
yield SelectCurrent(self.prompt)
yield SelectOverlay()
def _on_mount(self, _event: events.Mount) -> None:
"""Set initial values."""
self.set_options(self._initial_options)
self.value = self._value
def _watch_expanded(self, expanded: bool) -> None:
"""Display or hide overlay."""
overlay = self.query_one(SelectOverlay)
self.set_class(expanded, "-expanded")
if expanded:
overlay.focus()
if self.value is None:
overlay.select(None)
self.query_one(SelectCurrent).has_value = False
else:
value = self.value
for index, (_prompt, prompt_value) in enumerate(self._options):
if value == prompt_value:
overlay.select(index)
break
self.query_one(SelectCurrent).has_value = True
@on(SelectCurrent.Toggle)
def _select_current_toggle(self, event: SelectCurrent.Toggle) -> None:
"""Show the overlay when toggled."""
event.stop()
self.expanded = not self.expanded
@on(SelectOverlay.Dismiss)
def _select_overlay_dismiss(self, event: SelectOverlay.Dismiss) -> None:
"""Dismiss the overlay."""
event.stop()
self.expanded = False
if not event.lost_focus:
# If the overlay didn't lose focus, we want to re-focus the select.
self.focus()
@on(SelectOverlay.UpdateSelection)
def _update_selection(self, event: SelectOverlay.UpdateSelection) -> None:
"""Update the current selection."""
event.stop()
value = self._options[event.option_index][1]
self.value = value
async def update_focus() -> None:
"""Update focus and reset overlay."""
self.focus()
self.expanded = False
self.call_after_refresh(update_focus) # Prevents a little flicker
self.post_message(self.Changed(self, value))
def action_show_overlay(self) -> None:
"""Show the overlay."""
select_current = self.query_one(SelectCurrent)
select_current.has_value = True
self.expanded = True

View File

@@ -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.

View File

@@ -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

View File

@@ -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.

View File

@@ -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

View 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()

View File

@@ -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.

View File

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

View File

@@ -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)

View File

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

View File

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

View File

@@ -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),
]

View File

@@ -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),
]

View File

@@ -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():

View File

@@ -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()