Skip to content
New issue

Have a question about this project? Sign up for a free GitHub account to open an issue and contact its maintainers and the community.

By clicking “Sign up for GitHub”, you agree to our terms of service and privacy statement. We’ll occasionally send you account related emails.

Already on GitHub? Sign in to your account

error if no children allowed #3758

Merged
merged 8 commits into from
Nov 27, 2023
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
1 change: 1 addition & 0 deletions CHANGELOG.md
Original file line number Diff line number Diff line change
Expand Up @@ -17,6 +17,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).

- Added experimental Canvas class https://github.com/Textualize/textual/pull/3669/
- Added `keyline` rule https://github.com/Textualize/textual/pull/3669/
- Widgets can now have an ALLOW_CHILDREN (bool) classvar to disallow adding children to a widget https://github.com/Textualize/textual/pull/3758

### Changed

Expand Down
4 changes: 3 additions & 1 deletion src/textual/_box_drawing.py
Original file line number Diff line number Diff line change
Expand Up @@ -12,11 +12,13 @@

"""

from __future__ import annotations

from functools import lru_cache

from typing_extensions import TypeAlias

Quad: TypeAlias = tuple[int, int, int, int]
Quad: TypeAlias = "tuple[int, int, int, int]"
"""Four values indicating the composition of the box character."""

# Yes, I typed this out by hand. - WM
Expand Down
18 changes: 16 additions & 2 deletions src/textual/_compose.py
Original file line number Diff line number Diff line change
Expand Up @@ -16,19 +16,33 @@ def compose(node: App | Widget) -> list[Widget]:
Returns:
A list of widgets.
"""
_rich_traceback_omit = True
app = node.app
nodes: list[Widget] = []
compose_stack: list[Widget] = []
composed: list[Widget] = []
app._compose_stacks.append(compose_stack)
app._composed.append(composed)
iter_compose = iter(node.compose())
try:
for child in node.compose():
while True:
try:
child = next(iter_compose)
except StopIteration:
break
if composed:
nodes.extend(composed)
composed.clear()
if compose_stack:
compose_stack[-1].compose_add_child(child)
try:
compose_stack[-1].compose_add_child(child)
except Exception as error:
if hasattr(iter_compose, "throw"):
# So the error is raised inside the generator
# This will generate a more sensible traceback for the dev
iter_compose.throw(error) # type: ignore
else:
raise
else:
nodes.append(child)
if composed:
Expand Down
1 change: 1 addition & 0 deletions src/textual/app.py
Original file line number Diff line number Diff line change
Expand Up @@ -2305,6 +2305,7 @@ async def take_screenshot() -> None:
)

async def _on_compose(self) -> None:
_rich_traceback_omit = True
try:
widgets = [*self.screen._nodes, *compose(self)]
except TypeError as error:
Expand Down
2 changes: 2 additions & 0 deletions src/textual/scroll_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -18,6 +18,8 @@ class ScrollView(ScrollableContainer):
on the compositor to render children).
"""

ALLOW_CHILDREN = False

DEFAULT_CSS = """
ScrollView {
overflow-y: auto;
Expand Down
22 changes: 22 additions & 0 deletions src/textual/widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -87,6 +87,10 @@
}


class NotAContainer(Exception):
"""Exception raised if you attempt to add a child to a widget which doesn't permit child nodes."""


_NULL_STYLE = Style()


Expand Down Expand Up @@ -264,6 +268,9 @@ class Widget(DOMNode):
BORDER_SUBTITLE: ClassVar[str] = ""
"""Initial value for border_subtitle attribute."""

ALLOW_CHILDREN: ClassVar[bool] = True
"""Set to `False` to prevent adding children to this widget."""

can_focus: bool = False
"""Widget may receive focus."""
can_focus_children: bool = True
Expand Down Expand Up @@ -488,6 +495,21 @@ def tooltip(self, tooltip: RenderableType | None):
except NoScreen:
pass

def compose_add_child(self, widget: Widget) -> None:
"""Add a node to children.

This is used by the compose process when it adds children.
There is no need to use it directly, but you may want to override it in a subclass
if you want children to be attached to a different node.

Args:
widget: A Widget to add.
"""
_rich_traceback_omit = True
if not self.ALLOW_CHILDREN:
raise NotAContainer(f"Can't add children to {type(widget)} widgets")
self._nodes._append(widget)

def __enter__(self) -> Self:
"""Use as context manager when composing."""
self.app._compose_stacks[-1].append(self)
Expand Down
2 changes: 2 additions & 0 deletions src/textual/widgets/_button.py
Original file line number Diff line number Diff line change
Expand Up @@ -153,6 +153,8 @@ class Button(Widget, can_focus=True):

BINDINGS = [Binding("enter", "press", "Press Button", show=False)]

ALLOW_CHILDREN = False

label: reactive[TextType] = reactive[TextType]("")
"""The text label that appears within the button."""

Expand Down
2 changes: 2 additions & 0 deletions src/textual/widgets/_static.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,6 +44,8 @@ class Static(Widget, inherit_bindings=False):
}
"""

ALLOW_CHILDREN = False

_renderable: RenderableType

def __init__(
Expand Down
2 changes: 2 additions & 0 deletions src/textual/widgets/_toggle_button.py
Original file line number Diff line number Diff line change
Expand Up @@ -51,6 +51,8 @@ class ToggleButton(Static, can_focus=True):
| `toggle--label` | Targets the text label of the toggle button. |
"""

ALLOW_CHILDREN = False

DEFAULT_CSS = """
ToggleButton {
width: auto;
Expand Down
20 changes: 18 additions & 2 deletions tests/test_widget.py
Original file line number Diff line number Diff line change
Expand Up @@ -9,8 +9,8 @@
from textual.css.query import NoMatches
from textual.geometry import Offset, Size
from textual.message import Message
from textual.widget import MountError, PseudoClasses, Widget
from textual.widgets import Label, LoadingIndicator
from textual.widget import MountError, NotAContainer, PseudoClasses, Widget
from textual.widgets import Label, LoadingIndicator, Static


@pytest.mark.parametrize(
Expand Down Expand Up @@ -394,3 +394,19 @@ class TestWidgetIsMountedApp(App):
assert widget.is_mounted is False
await pilot.app.mount(widget)
assert widget.is_mounted is True


async def test_not_allow_children():
"""Regression test for https://github.com/Textualize/textual/pull/3758"""

class TestAppExpectFail(App):
def compose(self) -> ComposeResult:
# Statics don't have children, so this should error
with Static():
yield Label("foo")

app = TestAppExpectFail()

with pytest.raises(NotAContainer):
async with app.run_test():
pass
Loading