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

Add Placeholder widget #1229

Merged
merged 19 commits into from
Dec 7, 2022
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
19 commits
Select commit Hold shift + click to select a range
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 @@ -12,6 +12,7 @@ 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

Expand Down
1 change: 1 addition & 0 deletions docs/api/placeholder.md
Original file line number Diff line number Diff line change
@@ -0,0 +1 @@
::: textual.widgets.Placeholder
50 changes: 50 additions & 0 deletions docs/examples/widgets/placeholder.css
Original file line number Diff line number Diff line change
@@ -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;
}
39 changes: 39 additions & 0 deletions docs/examples/widgets/placeholder.py
Original file line number Diff line number Diff line change
@@ -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()
47 changes: 47 additions & 0 deletions docs/widgets/placeholder.md
Original file line number Diff line number Diff line change
@@ -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
1 change: 1 addition & 0 deletions mkdocs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -98,6 +98,7 @@ nav:
- "widgets/index.md"
- "widgets/input.md"
- "widgets/label.md"
- "widgets/placeholder.md"
- "widgets/static.md"
- "widgets/tree.md"
- API:
Expand Down
212 changes: 168 additions & 44 deletions src/textual/widgets/_placeholder.py
Original file line number Diff line number Diff line change
@@ -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):

has_focus: Reactive[bool] = Reactive(False)
mouse_over: Reactive[bool] = Reactive(False)
class InvalidPlaceholderVariant(Exception):
pass


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

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,
"""Create a Placeholder widget.

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

self.styles.background = f"{next(Placeholder._COLORS)} 70%"

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_focus(self, event: events.Focus) -> None:
self.has_focus = True
def cycle_variant(self) -> None:
"""Get the next variant in the cycle."""
self.variant = next(self._variants_cycle)

def watch_variant(
self, old_variant: PlaceholderVariant, variant: PlaceholderVariant
) -> None:
self.remove_class(f"-{old_variant}")
self.add_class(f"-{variant}")

async def on_blur(self, event: events.Blur) -> None:
self.has_focus = False
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

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

async def on_leave(self, event: events.Leave) -> None:
self.mouse_over = False
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)
Loading