diff --git a/CHANGELOG.md b/CHANGELOG.md
index 50c6979b5..0801fb9b7 100644
--- a/CHANGELOG.md
+++ b/CHANGELOG.md
@@ -12,15 +12,19 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
- Added "inherited bindings" -- BINDINGS classvar will be merged with base classes, unless inherit_bindings is set to False
- Added `Tree` widget which replaces `TreeControl`.
+- Added widget `Placeholder` https://github.com/Textualize/textual/issues/1200.
### Changed
- Rebuilt `DirectoryTree` with new `Tree` control.
- Empty containers with a dimension set to `"auto"` will now collapse instead of filling up the available space.
+- Container widgets now have default height of `1fr`.
+- The default `width` of a `Label` is now `auto`.
### Fixed
- Type selectors can now contain numbers https://github.com/Textualize/textual/issues/1253
+- Fixed visibility not affecting children https://github.com/Textualize/textual/issues/1313
## [0.5.0] - 2022-11-20
diff --git a/docs/api/placeholder.md b/docs/api/placeholder.md
new file mode 100644
index 000000000..4ec987d9b
--- /dev/null
+++ b/docs/api/placeholder.md
@@ -0,0 +1 @@
+::: textual.widgets.Placeholder
diff --git a/docs/examples/guide/dom4.css b/docs/examples/guide/dom4.css
index d9169ee17..8ac843abb 100644
--- a/docs/examples/guide/dom4.css
+++ b/docs/examples/guide/dom4.css
@@ -1,6 +1,7 @@
/* The top level dialog (a Container) */
#dialog {
+ height: 100%;
margin: 4 8;
background: $panel;
color: $text;
diff --git a/docs/examples/widgets/placeholder.css b/docs/examples/widgets/placeholder.css
new file mode 100644
index 000000000..0fe651cd5
--- /dev/null
+++ b/docs/examples/widgets/placeholder.css
@@ -0,0 +1,50 @@
+Placeholder {
+ height: 100%;
+}
+
+#top {
+ height: 50%;
+ width: 100%;
+ layout: grid;
+ grid-size: 2 2;
+}
+
+#left {
+ row-span: 2;
+}
+
+#bot {
+ height: 50%;
+ width: 100%;
+ layout: grid;
+ grid-size: 8 8;
+}
+
+#c1 {
+ row-span: 4;
+ column-span: 8;
+}
+
+#col1, #col2, #col3 {
+ width: 1fr;
+}
+
+#p1 {
+ row-span: 4;
+ column-span: 4;
+}
+
+#p2 {
+ row-span: 2;
+ column-span: 4;
+}
+
+#p3 {
+ row-span: 2;
+ column-span: 2;
+}
+
+#p4 {
+ row-span: 1;
+ column-span: 2;
+}
diff --git a/docs/examples/widgets/placeholder.py b/docs/examples/widgets/placeholder.py
new file mode 100644
index 000000000..0c6499842
--- /dev/null
+++ b/docs/examples/widgets/placeholder.py
@@ -0,0 +1,39 @@
+from textual.app import App, ComposeResult
+from textual.containers import Container, Horizontal, Vertical
+from textual.widgets import Placeholder
+
+
+class PlaceholderApp(App):
+
+ CSS_PATH = "placeholder.css"
+
+ def compose(self) -> ComposeResult:
+ yield Vertical(
+ Container(
+ Placeholder("This is a custom label for p1.", id="p1"),
+ Placeholder("Placeholder p2 here!", id="p2"),
+ Placeholder(id="p3"),
+ Placeholder(id="p4"),
+ Placeholder(id="p5"),
+ Placeholder(),
+ Horizontal(
+ Placeholder(variant="size", id="col1"),
+ Placeholder(variant="text", id="col2"),
+ Placeholder(variant="size", id="col3"),
+ id="c1",
+ ),
+ id="bot"
+ ),
+ Container(
+ Placeholder(variant="text", id="left"),
+ Placeholder(variant="size", id="topright"),
+ Placeholder(variant="text", id="botright"),
+ id="top",
+ ),
+ id="content",
+ )
+
+
+if __name__ == "__main__":
+ app = PlaceholderApp()
+ app.run()
diff --git a/docs/widgets/placeholder.md b/docs/widgets/placeholder.md
new file mode 100644
index 000000000..be935d4a3
--- /dev/null
+++ b/docs/widgets/placeholder.md
@@ -0,0 +1,47 @@
+# Placeholder
+
+
+A widget that is meant to have no complex functionality.
+Use the placeholder widget when studying the layout of your app before having to develop your custom widgets.
+
+The placeholder widget has variants that display different bits of useful information.
+Clicking a placeholder will cycle through its variants.
+
+- [ ] Focusable
+- [ ] Container
+
+## Example
+
+The example below shows each placeholder variant.
+
+=== "Output"
+
+ ```{.textual path="docs/examples/widgets/placeholder.py"}
+ ```
+
+=== "placeholder.py"
+
+ ```python
+ --8<-- "docs/examples/widgets/placeholder.py"
+ ```
+
+=== "placeholder.css"
+
+ ```css
+ --8<-- "docs/examples/widgets/placeholder.css"
+ ```
+
+## Reactive Attributes
+
+| Name | Type | Default | Description |
+| ---------- | ------ | ----------- | -------------------------------------------------- |
+| `variant` | `str` | `"default"` | Styling variant. One of `default`, `size`, `text`. |
+
+
+## Messages
+
+This widget sends no messages.
+
+## See Also
+
+* [Placeholder](../api/placeholder.md) code reference
diff --git a/mkdocs.yml b/mkdocs.yml
index 1712e6741..b8a1df2fd 100644
--- a/mkdocs.yml
+++ b/mkdocs.yml
@@ -98,6 +98,7 @@ nav:
- "widgets/index.md"
- "widgets/input.md"
- "widgets/label.md"
+ - "widgets/placeholder.md"
- "widgets/static.md"
- "widgets/tree.md"
- API:
diff --git a/src/textual/_arrange.py b/src/textual/_arrange.py
index 2bf706e0f..eb9818ee7 100644
--- a/src/textual/_arrange.py
+++ b/src/textual/_arrange.py
@@ -41,7 +41,6 @@ def arrange(
placements: list[WidgetPlacement] = []
add_placement = placements.append
- region = size.region
_WidgetPlacement = WidgetPlacement
top_z = TOP_Z
@@ -50,7 +49,9 @@ def arrange(
get_dock = attrgetter("styles.dock")
styles = widget.styles
+ layer_region = size.region
for widgets in dock_layers.values():
+ region = layer_region
layout_widgets, dock_widgets = partition(get_dock, widgets)
diff --git a/src/textual/_compositor.py b/src/textual/_compositor.py
index ab9d69eca..b59710218 100644
--- a/src/textual/_compositor.py
+++ b/src/textual/_compositor.py
@@ -347,6 +347,7 @@ class Compositor:
order: tuple[tuple[int, ...], ...],
layer_order: int,
clip: Region,
+ visible: bool,
) -> None:
"""Called recursively to place a widget and its children in the map.
@@ -356,7 +357,12 @@ class Compositor:
order (tuple[int, ...]): A tuple of ints to define the order.
clip (Region): The clipping region (i.e. the viewport which contains it).
"""
- widgets.add(widget)
+ visibility = widget.styles.get_rule("visibility")
+ if visibility is not None:
+ visible = visibility == "visible"
+
+ if visible:
+ widgets.add(widget)
styles_offset = widget.styles.offset
layout_offset = (
styles_offset.resolve(region.size, clip.size)
@@ -420,32 +426,34 @@ class Compositor:
widget_order,
layer_order,
sub_clip,
+ visible,
)
layer_order -= 1
- # Add any scrollbars
- for chrome_widget, chrome_region in widget._arrange_scrollbars(
- container_region
- ):
- map[chrome_widget] = MapGeometry(
- chrome_region + layout_offset,
+ if visible:
+ # Add any scrollbars
+ for chrome_widget, chrome_region in widget._arrange_scrollbars(
+ container_region
+ ):
+ map[chrome_widget] = MapGeometry(
+ chrome_region + layout_offset,
+ order,
+ clip,
+ container_size,
+ container_size,
+ chrome_region,
+ )
+
+ map[widget] = MapGeometry(
+ region + layout_offset,
order,
clip,
+ total_region.size,
container_size,
- container_size,
- chrome_region,
+ virtual_region,
)
- map[widget] = MapGeometry(
- region + layout_offset,
- order,
- clip,
- total_region.size,
- container_size,
- virtual_region,
- )
-
- else:
+ elif visible:
# Add the widget to the map
map[widget] = MapGeometry(
region + layout_offset,
@@ -457,7 +465,15 @@ class Compositor:
)
# Add top level (root) widget
- add_widget(root, size.region, size.region, ((0,),), layer_order, size.region)
+ add_widget(
+ root,
+ size.region,
+ size.region,
+ ((0,),),
+ layer_order,
+ size.region,
+ True,
+ )
return map, widgets
@property
@@ -630,11 +646,6 @@ class Compositor:
if not self.map:
return
- def is_visible(widget: Widget) -> bool:
- """Return True if the widget is (literally) visible by examining various
- properties which affect whether it can be seen or not."""
- return widget.visible and widget.styles.opacity > 0
-
_Region = Region
visible_widgets = self.visible_widgets
@@ -644,13 +655,13 @@ class Compositor:
widget_regions = [
(widget, region, clip)
for widget, (region, clip) in visible_widgets.items()
- if crop_overlaps(clip) and is_visible(widget)
+ if crop_overlaps(clip) and widget.styles.opacity > 0
]
else:
widget_regions = [
(widget, region, clip)
for widget, (region, clip) in visible_widgets.items()
- if is_visible(widget)
+ if widget.styles.opacity > 0
]
intersection = _Region.intersection
diff --git a/src/textual/containers.py b/src/textual/containers.py
index 231e220b0..bbd4c13d7 100644
--- a/src/textual/containers.py
+++ b/src/textual/containers.py
@@ -6,6 +6,7 @@ class Container(Widget):
DEFAULT_CSS = """
Container {
+ height: 1fr;
layout: vertical;
overflow: auto;
}
@@ -17,6 +18,7 @@ class Vertical(Widget):
DEFAULT_CSS = """
Vertical {
+ height: 1fr;
layout: vertical;
overflow-y: auto;
}
@@ -28,6 +30,7 @@ class Horizontal(Widget):
DEFAULT_CSS = """
Horizontal {
+ height: 1fr;
layout: horizontal;
overflow-x: hidden;
}
@@ -39,6 +42,7 @@ class Grid(Widget):
DEFAULT_CSS = """
Grid {
+ height: 1fr;
layout: grid;
}
"""
@@ -49,6 +53,7 @@ class Content(Widget, can_focus=True, can_focus_children=False):
DEFAULT_CSS = """
Vertical {
+ height: 1fr;
layout: vertical;
overflow-y: auto;
}
diff --git a/src/textual/widgets/_label.py b/src/textual/widgets/_label.py
index df519ae4f..344c37013 100644
--- a/src/textual/widgets/_label.py
+++ b/src/textual/widgets/_label.py
@@ -5,3 +5,11 @@ from ._static import Static
class Label(Static):
"""A simple label widget for displaying text-oriented renderables."""
+
+ DEFAULT_CSS = """
+ Label {
+ width: auto;
+ height: auto;
+ }
+ """
+ """str: The default styling of a `Label`."""
diff --git a/src/textual/widgets/_placeholder.py b/src/textual/widgets/_placeholder.py
index 7ce714a24..33ad25e0c 100644
--- a/src/textual/widgets/_placeholder.py
+++ b/src/textual/widgets/_placeholder.py
@@ -1,63 +1,187 @@
from __future__ import annotations
-from rich import box
-from rich.align import Align
-from rich.console import RenderableType
-from rich.panel import Panel
-from rich.pretty import Pretty
-import rich.repr
-from rich.style import Style
+from itertools import cycle
from .. import events
-from ..reactive import Reactive
-from ..widget import Widget
+from ..containers import Container
+from ..css._error_tools import friendly_list
+from ..reactive import Reactive, reactive
+from ..widget import Widget, RenderResult
+from ..widgets import Label
+from .._typing import Literal
+
+PlaceholderVariant = Literal["default", "size", "text"]
+_VALID_PLACEHOLDER_VARIANTS_ORDERED: list[PlaceholderVariant] = [
+ "default",
+ "size",
+ "text",
+]
+_VALID_PLACEHOLDER_VARIANTS: set[PlaceholderVariant] = set(
+ _VALID_PLACEHOLDER_VARIANTS_ORDERED
+)
+_PLACEHOLDER_BACKGROUND_COLORS = [
+ "#881177",
+ "#aa3355",
+ "#cc6666",
+ "#ee9944",
+ "#eedd00",
+ "#99dd55",
+ "#44dd88",
+ "#22ccbb",
+ "#00bbcc",
+ "#0099cc",
+ "#3366bb",
+ "#663399",
+]
+_LOREM_IPSUM_PLACEHOLDER_TEXT = "Lorem ipsum dolor sit amet, consectetur adipiscing elit. Etiam feugiat ac elit sit amet accumsan. Suspendisse bibendum nec libero quis gravida. Phasellus id eleifend ligula. Nullam imperdiet sem tellus, sed vehicula nisl faucibus sit amet. Praesent iaculis tempor ultricies. Sed lacinia, tellus id rutrum lacinia, sapien sapien congue mauris, sit amet pellentesque quam quam vel nisl. Curabitur vulputate erat pellentesque mauris posuere, non dictum risus mattis."
-@rich.repr.auto(angular=False)
-class Placeholder(Widget, can_focus=True):
+class InvalidPlaceholderVariant(Exception):
+ pass
- has_focus: Reactive[bool] = Reactive(False)
- mouse_over: Reactive[bool] = Reactive(False)
+
+class _PlaceholderLabel(Widget):
+ def __init__(self, content, classes) -> None:
+ super().__init__(classes=classes)
+ self._content = content
+
+ def render(self) -> RenderResult:
+ return self._content
+
+
+class Placeholder(Container):
+ """A simple placeholder widget to use before you build your custom widgets.
+
+ This placeholder has a couple of variants that show different data.
+ Clicking the placeholder cycles through the available variants, but a placeholder
+ can also be initialised in a specific variant.
+
+ The variants available are:
+ default: shows an identifier label or the ID of the placeholder.
+ size: shows the size of the placeholder.
+ text: shows some Lorem Ipsum text on the placeholder.
+ """
+
+ DEFAULT_CSS = """
+ Placeholder {
+ align: center middle;
+ }
+
+ Placeholder.-text {
+ padding: 1;
+ }
+
+ _PlaceholderLabel {
+ height: auto;
+ }
+
+ Placeholder > _PlaceholderLabel {
+ content-align: center middle;
+ }
+
+ Placeholder.-default > _PlaceholderLabel.-size,
+ Placeholder.-default > _PlaceholderLabel.-text,
+ Placeholder.-size > _PlaceholderLabel.-default,
+ Placeholder.-size > _PlaceholderLabel.-text,
+ Placeholder.-text > _PlaceholderLabel.-default,
+ Placeholder.-text > _PlaceholderLabel.-size {
+ display: none;
+ }
+
+ Placeholder.-default > _PlaceholderLabel.-default,
+ Placeholder.-size > _PlaceholderLabel.-size,
+ Placeholder.-text > _PlaceholderLabel.-text {
+ display: block;
+ }
+ """
+ # Consecutive placeholders get assigned consecutive colors.
+ _COLORS = cycle(_PLACEHOLDER_BACKGROUND_COLORS)
+ _SIZE_RENDER_TEMPLATE = "[b]{} x {}[/b]"
+
+ variant: Reactive[PlaceholderVariant] = reactive("default")
+
+ @classmethod
+ def reset_color_cycle(cls) -> None:
+ """Reset the placeholder background color cycle."""
+ cls._COLORS = cycle(_PLACEHOLDER_BACKGROUND_COLORS)
def __init__(
- # parent class constructor signature:
self,
- *children: Widget,
+ label: str | None = None,
+ variant: PlaceholderVariant = "default",
+ *,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
- # ...and now for our own class specific params:
- title: str | None = None,
) -> None:
- super().__init__(*children, name=name, id=id, classes=classes)
- self.title = title
+ """Create a Placeholder widget.
- def __rich_repr__(self) -> rich.repr.Result:
- yield from super().__rich_repr__()
- yield "has_focus", self.has_focus, False
- yield "mouse_over", self.mouse_over, False
-
- def render(self) -> RenderableType:
- # Apply colours only inside render_styled
- # Pass the full RICH style object into `render` - not the `Styles`
- return Panel(
- Align.center(
- Pretty(self, no_wrap=True, overflow="ellipsis"),
- vertical="middle",
- ),
- title=self.title or self.__class__.__name__,
- border_style="green" if self.mouse_over else "blue",
- box=box.HEAVY if self.has_focus else box.ROUNDED,
+ Args:
+ label (str | None, optional): The label to identify the placeholder.
+ If no label is present, uses the placeholder ID instead. Defaults to None.
+ variant (PlaceholderVariant, optional): The variant of the placeholder.
+ Defaults to "default".
+ name (str | None, optional): The name of the placeholder. Defaults to None.
+ id (str | None, optional): The ID of the placeholder in the DOM.
+ Defaults to None.
+ classes (str | None, optional): A space separated string with the CSS classes
+ of the placeholder, if any. Defaults to None.
+ """
+ # Create and cache labels for all the variants.
+ self._default_label = _PlaceholderLabel(
+ label if label else f"#{id}" if id else "Placeholder",
+ "-default",
+ )
+ self._size_label = _PlaceholderLabel(
+ "",
+ "-size",
+ )
+ self._text_label = _PlaceholderLabel(
+ _LOREM_IPSUM_PLACEHOLDER_TEXT,
+ "-text",
+ )
+ super().__init__(
+ self._default_label,
+ self._size_label,
+ self._text_label,
+ name=name,
+ id=id,
+ classes=classes,
)
- async def on_focus(self, event: events.Focus) -> None:
- self.has_focus = True
+ self.styles.background = f"{next(Placeholder._COLORS)} 70%"
- async def on_blur(self, event: events.Blur) -> None:
- self.has_focus = False
+ self.variant = self.validate_variant(variant)
+ # Set a cycle through the variants with the correct starting point.
+ self._variants_cycle = cycle(_VALID_PLACEHOLDER_VARIANTS_ORDERED)
+ while next(self._variants_cycle) != self.variant:
+ pass
- async def on_enter(self, event: events.Enter) -> None:
- self.mouse_over = True
+ def cycle_variant(self) -> None:
+ """Get the next variant in the cycle."""
+ self.variant = next(self._variants_cycle)
- async def on_leave(self, event: events.Leave) -> None:
- self.mouse_over = False
+ def watch_variant(
+ self, old_variant: PlaceholderVariant, variant: PlaceholderVariant
+ ) -> None:
+ self.remove_class(f"-{old_variant}")
+ self.add_class(f"-{variant}")
+
+ def validate_variant(self, variant: PlaceholderVariant) -> PlaceholderVariant:
+ """Validate the variant to which the placeholder was set."""
+ if variant not in _VALID_PLACEHOLDER_VARIANTS:
+ raise InvalidPlaceholderVariant(
+ "Valid placeholder variants are "
+ + f"{friendly_list(_VALID_PLACEHOLDER_VARIANTS)}"
+ )
+ return variant
+
+ def on_click(self) -> None:
+ """Click handler to cycle through the placeholder variants."""
+ self.cycle_variant()
+
+ def on_resize(self, event: events.Resize) -> None:
+ """Update the placeholder "size" variant with the new placeholder size."""
+ self._size_label._content = self._SIZE_RENDER_TEMPLATE.format(*self.size)
+ if self.variant == "size":
+ self._size_label.refresh(layout=True)
diff --git a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
index 977ab4286..d950c09a7 100644
--- a/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
+++ b/tests/snapshot_tests/__snapshots__/test_snapshots.ambr
@@ -6637,6 +6637,494 @@
'''
# ---
+# name: test_placeholder_render
+ '''
+
+
+ '''
+# ---
+# name: test_order_independence
+ '''
+
+
+ '''
+# ---
+# name: test_order_independence_toggle
+ '''
+
+
+ '''
+# ---
# name: test_textlog_max_lines
'''