Skip to content

Commit 795265b

Browse files
authored
Merge pull request #1229 from Textualize/placeholder
Add Placeholder widget
2 parents c68e749 + 08bf1bc commit 795265b

File tree

10 files changed

+500
-44
lines changed

10 files changed

+500
-44
lines changed

CHANGELOG.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -12,6 +12,7 @@ and this project adheres to [Semantic Versioning](http://semver.org/).
1212

1313
- Added "inherited bindings" -- BINDINGS classvar will be merged with base classes, unless inherit_bindings is set to False
1414
- Added `Tree` widget which replaces `TreeControl`.
15+
- Added widget `Placeholder` https://github.com/Textualize/textual/issues/1200.
1516

1617
### Changed
1718

docs/api/placeholder.md

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1 @@
1+
::: textual.widgets.Placeholder
Lines changed: 50 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,50 @@
1+
Placeholder {
2+
height: 100%;
3+
}
4+
5+
#top {
6+
height: 50%;
7+
width: 100%;
8+
layout: grid;
9+
grid-size: 2 2;
10+
}
11+
12+
#left {
13+
row-span: 2;
14+
}
15+
16+
#bot {
17+
height: 50%;
18+
width: 100%;
19+
layout: grid;
20+
grid-size: 8 8;
21+
}
22+
23+
#c1 {
24+
row-span: 4;
25+
column-span: 8;
26+
}
27+
28+
#col1, #col2, #col3 {
29+
width: 1fr;
30+
}
31+
32+
#p1 {
33+
row-span: 4;
34+
column-span: 4;
35+
}
36+
37+
#p2 {
38+
row-span: 2;
39+
column-span: 4;
40+
}
41+
42+
#p3 {
43+
row-span: 2;
44+
column-span: 2;
45+
}
46+
47+
#p4 {
48+
row-span: 1;
49+
column-span: 2;
50+
}
Lines changed: 39 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,39 @@
1+
from textual.app import App, ComposeResult
2+
from textual.containers import Container, Horizontal, Vertical
3+
from textual.widgets import Placeholder
4+
5+
6+
class PlaceholderApp(App):
7+
8+
CSS_PATH = "placeholder.css"
9+
10+
def compose(self) -> ComposeResult:
11+
yield Vertical(
12+
Container(
13+
Placeholder("This is a custom label for p1.", id="p1"),
14+
Placeholder("Placeholder p2 here!", id="p2"),
15+
Placeholder(id="p3"),
16+
Placeholder(id="p4"),
17+
Placeholder(id="p5"),
18+
Placeholder(),
19+
Horizontal(
20+
Placeholder(variant="size", id="col1"),
21+
Placeholder(variant="text", id="col2"),
22+
Placeholder(variant="size", id="col3"),
23+
id="c1",
24+
),
25+
id="bot"
26+
),
27+
Container(
28+
Placeholder(variant="text", id="left"),
29+
Placeholder(variant="size", id="topright"),
30+
Placeholder(variant="text", id="botright"),
31+
id="top",
32+
),
33+
id="content",
34+
)
35+
36+
37+
if __name__ == "__main__":
38+
app = PlaceholderApp()
39+
app.run()

docs/widgets/placeholder.md

Lines changed: 47 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,47 @@
1+
# Placeholder
2+
3+
4+
A widget that is meant to have no complex functionality.
5+
Use the placeholder widget when studying the layout of your app before having to develop your custom widgets.
6+
7+
The placeholder widget has variants that display different bits of useful information.
8+
Clicking a placeholder will cycle through its variants.
9+
10+
- [ ] Focusable
11+
- [ ] Container
12+
13+
## Example
14+
15+
The example below shows each placeholder variant.
16+
17+
=== "Output"
18+
19+
```{.textual path="docs/examples/widgets/placeholder.py"}
20+
```
21+
22+
=== "placeholder.py"
23+
24+
```python
25+
--8<-- "docs/examples/widgets/placeholder.py"
26+
```
27+
28+
=== "placeholder.css"
29+
30+
```css
31+
--8<-- "docs/examples/widgets/placeholder.css"
32+
```
33+
34+
## Reactive Attributes
35+
36+
| Name | Type | Default | Description |
37+
| ---------- | ------ | ----------- | -------------------------------------------------- |
38+
| `variant` | `str` | `"default"` | Styling variant. One of `default`, `size`, `text`. |
39+
40+
41+
## Messages
42+
43+
This widget sends no messages.
44+
45+
## See Also
46+
47+
* [Placeholder](../api/placeholder.md) code reference

mkdocs.yml

Lines changed: 1 addition & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -98,6 +98,7 @@ nav:
9898
- "widgets/index.md"
9999
- "widgets/input.md"
100100
- "widgets/label.md"
101+
- "widgets/placeholder.md"
101102
- "widgets/static.md"
102103
- "widgets/tree.md"
103104
- API:
Lines changed: 168 additions & 44 deletions
Original file line numberDiff line numberDiff line change
@@ -1,63 +1,187 @@
11
from __future__ import annotations
22

3-
from rich import box
4-
from rich.align import Align
5-
from rich.console import RenderableType
6-
from rich.panel import Panel
7-
from rich.pretty import Pretty
8-
import rich.repr
9-
from rich.style import Style
3+
from itertools import cycle
104

115
from .. import events
12-
from ..reactive import Reactive
13-
from ..widget import Widget
6+
from ..containers import Container
7+
from ..css._error_tools import friendly_list
8+
from ..reactive import Reactive, reactive
9+
from ..widget import Widget, RenderResult
10+
from ..widgets import Label
11+
from .._typing import Literal
1412

13+
PlaceholderVariant = Literal["default", "size", "text"]
14+
_VALID_PLACEHOLDER_VARIANTS_ORDERED: list[PlaceholderVariant] = [
15+
"default",
16+
"size",
17+
"text",
18+
]
19+
_VALID_PLACEHOLDER_VARIANTS: set[PlaceholderVariant] = set(
20+
_VALID_PLACEHOLDER_VARIANTS_ORDERED
21+
)
22+
_PLACEHOLDER_BACKGROUND_COLORS = [
23+
"#881177",
24+
"#aa3355",
25+
"#cc6666",
26+
"#ee9944",
27+
"#eedd00",
28+
"#99dd55",
29+
"#44dd88",
30+
"#22ccbb",
31+
"#00bbcc",
32+
"#0099cc",
33+
"#3366bb",
34+
"#663399",
35+
]
36+
_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."
1537

16-
@rich.repr.auto(angular=False)
17-
class Placeholder(Widget, can_focus=True):
1838

19-
has_focus: Reactive[bool] = Reactive(False)
20-
mouse_over: Reactive[bool] = Reactive(False)
39+
class InvalidPlaceholderVariant(Exception):
40+
pass
41+
42+
43+
class _PlaceholderLabel(Widget):
44+
def __init__(self, content, classes) -> None:
45+
super().__init__(classes=classes)
46+
self._content = content
47+
48+
def render(self) -> RenderResult:
49+
return self._content
50+
51+
52+
class Placeholder(Container):
53+
"""A simple placeholder widget to use before you build your custom widgets.
54+
55+
This placeholder has a couple of variants that show different data.
56+
Clicking the placeholder cycles through the available variants, but a placeholder
57+
can also be initialised in a specific variant.
58+
59+
The variants available are:
60+
default: shows an identifier label or the ID of the placeholder.
61+
size: shows the size of the placeholder.
62+
text: shows some Lorem Ipsum text on the placeholder.
63+
"""
64+
65+
DEFAULT_CSS = """
66+
Placeholder {
67+
align: center middle;
68+
}
69+
70+
Placeholder.-text {
71+
padding: 1;
72+
}
73+
74+
_PlaceholderLabel {
75+
height: auto;
76+
}
77+
78+
Placeholder > _PlaceholderLabel {
79+
content-align: center middle;
80+
}
81+
82+
Placeholder.-default > _PlaceholderLabel.-size,
83+
Placeholder.-default > _PlaceholderLabel.-text,
84+
Placeholder.-size > _PlaceholderLabel.-default,
85+
Placeholder.-size > _PlaceholderLabel.-text,
86+
Placeholder.-text > _PlaceholderLabel.-default,
87+
Placeholder.-text > _PlaceholderLabel.-size {
88+
display: none;
89+
}
90+
91+
Placeholder.-default > _PlaceholderLabel.-default,
92+
Placeholder.-size > _PlaceholderLabel.-size,
93+
Placeholder.-text > _PlaceholderLabel.-text {
94+
display: block;
95+
}
96+
"""
97+
# Consecutive placeholders get assigned consecutive colors.
98+
_COLORS = cycle(_PLACEHOLDER_BACKGROUND_COLORS)
99+
_SIZE_RENDER_TEMPLATE = "[b]{} x {}[/b]"
100+
101+
variant: Reactive[PlaceholderVariant] = reactive("default")
102+
103+
@classmethod
104+
def reset_color_cycle(cls) -> None:
105+
"""Reset the placeholder background color cycle."""
106+
cls._COLORS = cycle(_PLACEHOLDER_BACKGROUND_COLORS)
21107

22108
def __init__(
23-
# parent class constructor signature:
24109
self,
25-
*children: Widget,
110+
label: str | None = None,
111+
variant: PlaceholderVariant = "default",
112+
*,
26113
name: str | None = None,
27114
id: str | None = None,
28115
classes: str | None = None,
29-
# ...and now for our own class specific params:
30-
title: str | None = None,
31116
) -> None:
32-
super().__init__(*children, name=name, id=id, classes=classes)
33-
self.title = title
34-
35-
def __rich_repr__(self) -> rich.repr.Result:
36-
yield from super().__rich_repr__()
37-
yield "has_focus", self.has_focus, False
38-
yield "mouse_over", self.mouse_over, False
39-
40-
def render(self) -> RenderableType:
41-
# Apply colours only inside render_styled
42-
# Pass the full RICH style object into `render` - not the `Styles`
43-
return Panel(
44-
Align.center(
45-
Pretty(self, no_wrap=True, overflow="ellipsis"),
46-
vertical="middle",
47-
),
48-
title=self.title or self.__class__.__name__,
49-
border_style="green" if self.mouse_over else "blue",
50-
box=box.HEAVY if self.has_focus else box.ROUNDED,
117+
"""Create a Placeholder widget.
118+
119+
Args:
120+
label (str | None, optional): The label to identify the placeholder.
121+
If no label is present, uses the placeholder ID instead. Defaults to None.
122+
variant (PlaceholderVariant, optional): The variant of the placeholder.
123+
Defaults to "default".
124+
name (str | None, optional): The name of the placeholder. Defaults to None.
125+
id (str | None, optional): The ID of the placeholder in the DOM.
126+
Defaults to None.
127+
classes (str | None, optional): A space separated string with the CSS classes
128+
of the placeholder, if any. Defaults to None.
129+
"""
130+
# Create and cache labels for all the variants.
131+
self._default_label = _PlaceholderLabel(
132+
label if label else f"#{id}" if id else "Placeholder",
133+
"-default",
134+
)
135+
self._size_label = _PlaceholderLabel(
136+
"",
137+
"-size",
138+
)
139+
self._text_label = _PlaceholderLabel(
140+
_LOREM_IPSUM_PLACEHOLDER_TEXT,
141+
"-text",
51142
)
143+
super().__init__(
144+
self._default_label,
145+
self._size_label,
146+
self._text_label,
147+
name=name,
148+
id=id,
149+
classes=classes,
150+
)
151+
152+
self.styles.background = f"{next(Placeholder._COLORS)} 70%"
153+
154+
self.variant = self.validate_variant(variant)
155+
# Set a cycle through the variants with the correct starting point.
156+
self._variants_cycle = cycle(_VALID_PLACEHOLDER_VARIANTS_ORDERED)
157+
while next(self._variants_cycle) != self.variant:
158+
pass
52159

53-
async def on_focus(self, event: events.Focus) -> None:
54-
self.has_focus = True
160+
def cycle_variant(self) -> None:
161+
"""Get the next variant in the cycle."""
162+
self.variant = next(self._variants_cycle)
163+
164+
def watch_variant(
165+
self, old_variant: PlaceholderVariant, variant: PlaceholderVariant
166+
) -> None:
167+
self.remove_class(f"-{old_variant}")
168+
self.add_class(f"-{variant}")
55169

56-
async def on_blur(self, event: events.Blur) -> None:
57-
self.has_focus = False
170+
def validate_variant(self, variant: PlaceholderVariant) -> PlaceholderVariant:
171+
"""Validate the variant to which the placeholder was set."""
172+
if variant not in _VALID_PLACEHOLDER_VARIANTS:
173+
raise InvalidPlaceholderVariant(
174+
"Valid placeholder variants are "
175+
+ f"{friendly_list(_VALID_PLACEHOLDER_VARIANTS)}"
176+
)
177+
return variant
58178

59-
async def on_enter(self, event: events.Enter) -> None:
60-
self.mouse_over = True
179+
def on_click(self) -> None:
180+
"""Click handler to cycle through the placeholder variants."""
181+
self.cycle_variant()
61182

62-
async def on_leave(self, event: events.Leave) -> None:
63-
self.mouse_over = False
183+
def on_resize(self, event: events.Resize) -> None:
184+
"""Update the placeholder "size" variant with the new placeholder size."""
185+
self._size_label._content = self._SIZE_RENDER_TEMPLATE.format(*self.size)
186+
if self.variant == "size":
187+
self._size_label.refresh(layout=True)

0 commit comments

Comments
 (0)