Merge branch 'main' into fix-1309

This commit is contained in:
Will McGugan
2022-12-07 17:33:03 +01:00
committed by GitHub
17 changed files with 1164 additions and 71 deletions

View File

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

1
docs/api/placeholder.md Normal file
View File

@@ -0,0 +1 @@
::: textual.widgets.Placeholder

View File

@@ -1,6 +1,7 @@
/* The top level dialog (a Container) */
#dialog {
height: 100%;
margin: 4 8;
background: $panel;
color: $text;

View File

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

View File

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

View File

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

View File

@@ -98,6 +98,7 @@ nav:
- "widgets/index.md"
- "widgets/input.md"
- "widgets/label.md"
- "widgets/placeholder.md"
- "widgets/static.md"
- "widgets/tree.md"
- API:

View File

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

View File

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

View File

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

View File

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

View File

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

File diff suppressed because one or more lines are too long

View File

@@ -0,0 +1,71 @@
from textual.app import App, ComposeResult
from textual.screen import Screen
from textual.widgets import Header, Footer, Label
from textual.containers import Vertical, Container
class Overlay(Container):
def compose(self) -> ComposeResult:
yield Label("This should float over the top")
class Body1(Vertical):
def compose(self) -> ComposeResult:
yield Label("My God! It's full of stars! " * 300)
class Body2(Vertical):
def compose(self) -> ComposeResult:
yield Label("I'm sorry, Dave. I'm afraid I can't do that. " * 300)
class Good(Screen):
def compose(self) -> ComposeResult:
yield Header()
yield Overlay()
yield Body1()
yield Footer()
class Bad(Screen):
def compose(self) -> ComposeResult:
yield Overlay()
yield Header()
yield Body2()
yield Footer()
class Layers(App[None]):
CSS = """
Screen {
layers: base higher;
}
Overlay {
layer: higher;
dock: top;
width: auto;
height: auto;
padding: 2;
border: solid yellow;
background: red;
color: yellow;
}
"""
SCREENS = {"good": Good, "bad": Bad}
BINDINGS = [("t", "toggle", "Toggle Screen")]
def on_mount(self):
self.push_screen("good")
def action_toggle(self):
self.switch_screen(
"bad" if self.screen.__class__.__name__ == "Good" else "good"
)
if __name__ == "__main__":
Layers().run()

View File

@@ -0,0 +1,48 @@
from textual.app import App
from textual.containers import Vertical
from textual.widgets import Static
class Visibility(App):
"""Check that visibility: hidden also makes children invisible;"""
CSS = """
Screen {
layout: horizontal;
}
Vertical {
width: 1fr;
border: solid red;
}
#container1 {
visibility: hidden;
}
.float {
border: solid blue;
}
/* Make a child of a hidden widget visible again */
#container1 .float {
visibility: visible;
}
"""
def compose(self):
yield Vertical(
Static("foo"),
Static("float", classes="float"),
id="container1",
)
yield Vertical(
Static("bar"),
Static("float", classes="float"),
id="container2",
)
if __name__ == "__main__":
app = Visibility()
app.run()

View File

@@ -2,6 +2,8 @@ from pathlib import Path
import pytest
from textual.widgets import Placeholder
# These paths should be relative to THIS directory.
WIDGET_EXAMPLES_DIR = Path("../../docs/examples/widgets")
LAYOUT_EXAMPLES_DIR = Path("../../docs/examples/guide/layout")
@@ -79,6 +81,12 @@ def test_buttons_render(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "button.py", press=["tab"])
def test_placeholder_render(snap_compare):
# Testing the rendering of the multiple placeholder variants and labels.
Placeholder.reset_color_cycle()
assert snap_compare(WIDGET_EXAMPLES_DIR / "placeholder.py")
def test_datatable_render(snap_compare):
press = ["tab", "down", "down", "right", "up", "left"]
assert snap_compare(WIDGET_EXAMPLES_DIR / "data_table.py", press=press)
@@ -100,6 +108,10 @@ def test_fr_units(snap_compare):
assert snap_compare("snapshot_apps/fr_units.py")
def test_visibility(snap_compare):
assert snap_compare("snapshot_apps/visibility.py")
def test_tree_example(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "tree.py")
@@ -126,6 +138,16 @@ def test_multiple_css(snap_compare):
assert snap_compare("snapshot_apps/multiple_css/multiple_css.py")
def test_order_independence(snap_compare):
# Interaction between multiple CSS files and app-level/classvar CSS
assert snap_compare("snapshot_apps/order_independence.py")
def test_order_independence_toggle(snap_compare):
# Interaction between multiple CSS files and app-level/classvar CSS
assert snap_compare("snapshot_apps/order_independence.py", press="t,_")
# --- Other ---

15
tests/test_placeholder.py Normal file
View File

@@ -0,0 +1,15 @@
import pytest
from textual.widgets import Placeholder
from textual.widgets._placeholder import InvalidPlaceholderVariant
def test_invalid_placeholder_variant():
with pytest.raises(InvalidPlaceholderVariant):
Placeholder(variant="this is clearly not a valid variant!")
def test_invalid_reactive_variant_change():
p = Placeholder()
with pytest.raises(InvalidPlaceholderVariant):
p.variant = "this is clearly not a valid variant!"