Widget collapsible (#2989)

* Collapsible container widget.

* Expose collapsible widget.

* Add collapsible container example

* Rename member variables as label and apply formatting

* Apply hover effect

* Apply formatting

* Add collapsible construction example with children.

* Wrap contents within Container and move _collapsed flag to Collapsible class from  Summary for easier access.

* Add collapsible example that is expanded by default.

* Update collapsed property to be reactive

* Add footer to collapse and expand all with bound keys.

* Expose summary property of Collapsible

* Assign ids of ollapsed, expanded label instead of classes

* Add unit tests of Collapsible

* Rename class Summary to Title

* Rename variables of expanded/collapsed symbols and add it to arguments..

* Add documentation for Collapsible

* Update symbol ids of Collapsible title

* Update src/textual/widgets/_collapsible.py

Correct import path

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* Sort module names in alphabetical order

* Clarify that collapsible is non-focusable in documentation.

* Add version hint

* Fix documentation of Collapsible.

* Add snapshot test for collapsible widget

* Stop on click event from Collapsible.

* Handle Title.Toggle event to prevent event in Contents from propagating to the children or parents Collapsible widgets.

* Update Collapsible default css to have 1 fraction of width instead of 100%

* Update Collapsible custom symbol snapshot

* Add Collapsible custom symbol snapshot as an example

* Update docs/widgets/collapsible.md

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* Update src/textual/widgets/_collapsible.py

Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>

* Fix typo in Collapsible docs

* Rework collapsible documentation.

---------

Co-authored-by: Sunyoung Yoo <luysunyoung@aifactory.page>
Co-authored-by: Rodrigo Girão Serrão <5621605+rodrigogiraoserrao@users.noreply.github.com>
This commit is contained in:
Sunyoung Yoo
2023-09-14 14:10:21 +02:00
committed by GitHub
parent ccc3e7a791
commit 3b2b9aaaf5
10 changed files with 1367 additions and 1 deletions

View File

@@ -0,0 +1,47 @@
from textual.app import App, ComposeResult
from textual.widgets import Collapsible, Footer, Label, Markdown
LETO = """
# Duke Leto I Atreides
Head of House Atreides.
"""
JESSICA = """
# Lady Jessica
Bene Gesserit and concubine of Leto, and mother of Paul and Alia.
"""
PAUL = """
# Paul Atreides
Son of Leto and Jessica.
"""
class CollapsibleApp(App[None]):
"""An example of colllapsible container."""
BINDINGS = [
("c", "collapse_or_expand(True)", "Collapse All"),
("e", "collapse_or_expand(False)", "Expand All"),
]
def compose(self) -> ComposeResult:
"""Compose app with collapsible containers."""
yield Footer()
with Collapsible(collapsed=False, title="Leto"):
yield Label(LETO)
yield Collapsible(Markdown(JESSICA), collapsed=False, title="Jessica")
with Collapsible(collapsed=True, title="Paul"):
yield Markdown(PAUL)
def action_collapse_or_expand(self, collapse: bool) -> None:
for child in self.walk_children(Collapsible):
child.collapsed = collapse
if __name__ == "__main__":
app = CollapsibleApp()
app.run()

View File

@@ -0,0 +1,25 @@
from textual.app import App, ComposeResult
from textual.containers import Horizontal
from textual.widgets import Collapsible, Label
class CollapsibleApp(App[None]):
def compose(self) -> ComposeResult:
with Horizontal():
with Collapsible(
collapsed_symbol=">>>",
expanded_symbol="v",
):
yield Label("Hello, world.")
with Collapsible(
collapsed_symbol=">>>",
expanded_symbol="v",
collapsed=False,
):
yield Label("Hello, world.")
if __name__ == "__main__":
app = CollapsibleApp()
app.run()

View File

@@ -0,0 +1,14 @@
from textual.app import App, ComposeResult
from textual.widgets import Collapsible, Label
class CollapsibleApp(App[None]):
def compose(self) -> ComposeResult:
with Collapsible(collapsed=False):
with Collapsible():
yield Label("Hello, world.")
if __name__ == "__main__":
app = CollapsibleApp()
app.run()

153
docs/widgets/collapsible.md Normal file
View File

@@ -0,0 +1,153 @@
# Collapsible
!!! tip "Added in version 0.36"
Widget that wraps its contents in a collapsible container.
- [ ] Focusable
- [x] Container
## Composing
There are two ways to wrap other widgets.
You can pass them as positional arguments to the [Collapsible][textual.widgets.Collapsible] constructor:
```python
def compose(self) -> ComposeResult:
yield Collapsible(Label("Hello, world."))
```
Alternatively, you can compose other widgets under the context manager:
```python
def compose(self) -> ComposeResult:
with Collapsible():
yield Label("Hello, world.")
```
## Title
The default title "Toggle" of the `Collapsible` widget can be customized by specifying the parameter `title` of the constructor:
```python
def compose(self) -> ComposeResult:
with Collapsible(title="An interesting story."):
yield Label("Interesting but verbose story.")
```
## Initial State
The initial state of the `Collapsible` widget can be customized via the parameter `collapsed` of the constructor:
```python
def compose(self) -> ComposeResult:
with Collapsible(title="Contents 1", collapsed=False):
yield Label("Hello, world.")
with Collapsible(title="Contents 2", collapsed=True): # Default.
yield Label("Hello, world.")
```
## Collapse/Expand Symbols
The symbols `►` and `▼` of the `Collapsible` widget can be customized by specifying the parameters `collapsed_symbol` and `expanded_symbol`, respectively, of the `Collapsible` constructor:
```python
def compose(self) -> ComposeResult:
with Collapsible(collapsed_symbol=">>>", expanded_symbol="v"):
yield Label("Hello, world.")
```
=== "Output"
```{.textual path="tests/snapshot_tests/snapshot_apps/collapsible_custom_symbol.py"}
```
=== "collapsible_custom_symbol.py"
```python
--8<-- "tests/snapshot_tests/snapshot_apps/collapsible_custom_symbol.py"
```
## Examples
### Basic example
The following example contains three `Collapsible`s in different states.
=== "All expanded"
```{.textual path="docs/examples/widgets/collapsible.py press="e"}
```
=== "All collapsed"
```{.textual path="docs/examples/widgets/collapsible.py press="c"}
```
=== "Mixed"
```{.textual path="docs/examples/widgets/collapsible.py"}
```
=== "collapsible.py"
```python
--8<-- "docs/examples/widgets/collapsible.py"
```
### Setting Initial State
The example below shows nested `Collapsible` widgets and how to set their initial state.
=== "Output"
```{.textual path="tests/snapshot_tests/snapshot_apps/collapsible_nested.py"}
```
=== "collapsible_nested.py"
```python hl_lines="7"
--8<-- "tests/snapshot_tests/snapshot_apps/collapsible_nested.py"
```
### Custom Symbols
The app below shows `Collapsible` widgets with custom expand/collapse symbols.
=== "Output"
```{.textual path="tests/snapshot_tests/snapshot_apps/collapsible_custom_symbol.py"}
```
=== "collapsible_custom_symbol.py"
```python
--8<-- "tests/snapshot_tests/snapshot_apps/collapsible_custom_symbol.py"
```
## Reactive attributes
| Name | Type | Default | Description |
| ----------- | ------ | ------- | -------------------------------------------------------------- |
| `collapsed` | `bool` | `True` | Controls the collapsed/expanded state of the widget. |
## Messages
- [Collapsible.Title.Toggle][textual.widgets.Collapsible.Title.Toggle]
<!--
## See also
TODO: Add Accordion widgets later
-->
---
::: textual.widgets.Collapsible
options:
heading_level: 2

View File

@@ -12,6 +12,7 @@ if typing.TYPE_CHECKING:
from ..widget import Widget
from ._button import Button
from ._checkbox import Checkbox
from ._collapsible import Collapsible
from ._content_switcher import ContentSwitcher
from ._data_table import DataTable
from ._digits import Digits
@@ -44,10 +45,10 @@ if typing.TYPE_CHECKING:
from ._tree import Tree
from ._welcome import Welcome
__all__ = [
"Button",
"Checkbox",
"Collapsible",
"ContentSwitcher",
"DataTable",
"Digits",

View File

@@ -1,6 +1,7 @@
# This stub file must re-export every classes exposed in the __init__.py's `__all__` list:
from ._button import Button as Button
from ._checkbox import Checkbox as Checkbox
from ._collapsible import Collapsible as Collapsible
from ._content_switcher import ContentSwitcher as ContentSwitcher
from ._data_table import DataTable as DataTable
from ._digits import Digits as Digits

View File

@@ -0,0 +1,156 @@
from __future__ import annotations
from textual.widget import Widget
from .. import events
from ..app import ComposeResult
from ..containers import Container, Horizontal
from ..message import Message
from ..reactive import reactive
from ..widget import Widget
from ..widgets import Label
__all__ = ["Collapsible"]
class Collapsible(Widget):
"""A collapsible container."""
collapsed = reactive(True)
DEFAULT_CSS = """
Collapsible {
width: 1fr;
height: auto;
}
"""
class Title(Horizontal):
DEFAULT_CSS = """
Title {
width: 100%;
height: auto;
}
Title:hover {
background: grey;
}
Title .label {
padding: 0 0 0 1;
}
Title #collapsed-symbol {
display:none;
}
Title.-collapsed #expanded-symbol {
display:none;
}
Title.-collapsed #collapsed-symbol {
display:block;
}
"""
def __init__(
self,
*,
label: str,
collapsed_symbol: str,
expanded_symbol: str,
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
) -> None:
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self.collapsed_symbol = collapsed_symbol
self.expanded_symbol = expanded_symbol
self.label = label
class Toggle(Message):
"""Request toggle."""
async def _on_click(self, event: events.Click) -> None:
"""Inform ancestor we want to toggle."""
event.stop()
self.post_message(self.Toggle())
def compose(self) -> ComposeResult:
"""Compose right/down arrow and label."""
yield Label(self.expanded_symbol, classes="label", id="expanded-symbol")
yield Label(self.collapsed_symbol, classes="label", id="collapsed-symbol")
yield Label(self.label, classes="label")
class Contents(Container):
DEFAULT_CSS = """
Contents {
width: 100%;
height: auto;
padding: 0 0 0 3;
}
Contents.-collapsed {
display: none;
}
"""
def __init__(
self,
*children: Widget,
title: str = "Toggle",
collapsed: bool = True,
collapsed_symbol: str = "",
expanded_symbol: str = "",
name: str | None = None,
id: str | None = None,
classes: str | None = None,
disabled: bool = False,
) -> None:
"""Initialize a Collapsible widget.
Args:
*children: Contents that will be collapsed/expanded.
title: Title of the collapsed/expanded contents.
collapsed: Default status of the contents.
collapsed_symbol: Collapsed symbol before the title.
expanded_symbol: Expanded symbol before the title.
name: The name of the collapsible.
id: The ID of the collapsible in the DOM.
classes: The CSS classes of the collapsible.
disabled: Whether the collapsible is disabled or not.
"""
self._title = self.Title(
label=title,
collapsed_symbol=collapsed_symbol,
expanded_symbol=expanded_symbol,
)
self._contents_list: list[Widget] = list(children)
super().__init__(name=name, id=id, classes=classes, disabled=disabled)
self.collapsed = collapsed
def _on_title_toggle(self, event: Title.Toggle) -> None:
event.stop()
self.collapsed = not self.collapsed
def watch_collapsed(self) -> None:
for child in self._nodes:
child.set_class(self.collapsed, "-collapsed")
def compose(self) -> ComposeResult:
yield from (
child.set_class(self.collapsed, "-collapsed")
for child in (
self._title,
self.Contents(*self._contents_list),
)
)
def compose_add_child(self, widget: Widget) -> None:
"""When using the context manager compose syntax, we want to attach nodes to the contents.
Args:
widget: A Widget to add.
"""
self._contents_list.append(widget)

File diff suppressed because one or more lines are too long

View File

@@ -330,6 +330,26 @@ def test_sparkline_component_classes_colors(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "sparkline_colors.py")
def test_collapsible_render(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "collapsible.py")
def test_collapsible_collapsed(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "collapsible.py", press=["c"])
def test_collapsible_expanded(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "collapsible.py", press=["e"])
def test_collapsible_nested(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "collapsible_nested.py")
def test_collapsible_custom_symbol(snap_compare):
assert snap_compare(WIDGET_EXAMPLES_DIR / "collapsible_custom_symbol.py")
# --- CSS properties ---
# We have a canonical example for each CSS property that is shown in their docs.
# If any of these change, something has likely broken, so snapshot each of them.

163
tests/test_collapsible.py Normal file
View File

@@ -0,0 +1,163 @@
from __future__ import annotations
from textual.app import App, ComposeResult
from textual.widgets import Collapsible, Label
COLLAPSED_CLASS = "-collapsed"
def get_title(collapsible: Collapsible) -> Collapsible.Title:
return collapsible.get_child_by_type(Collapsible.Title)
def get_contents(collapsible: Collapsible) -> Collapsible.Contents:
return collapsible.get_child_by_type(Collapsible.Contents)
async def test_collapsible():
"""It should be possible to access title and collapsed."""
collapsible = Collapsible(title="Pilot", collapsed=True)
assert collapsible._title.label == "Pilot"
assert collapsible.collapsed
async def test_compose_default_collapsible():
"""Test default settings of Collapsible with 1 widget in contents."""
class CollapsibleApp(App[None]):
def compose(self) -> ComposeResult:
yield Collapsible(Label("Some Contents"))
async with CollapsibleApp().run_test() as pilot:
collapsible = pilot.app.query_one(Collapsible)
assert get_title(collapsible).label == "Toggle"
assert get_title(collapsible).has_class(COLLAPSED_CLASS)
assert len(get_contents(collapsible).children) == 1
assert get_contents(collapsible).has_class(COLLAPSED_CLASS)
async def test_compose_empty_collapsible():
"""It should be possible to create an empty Collapsible."""
class CollapsibleApp(App[None]):
def compose(self) -> ComposeResult:
yield Collapsible()
async with CollapsibleApp().run_test() as pilot:
collapsible = pilot.app.query_one(Collapsible)
assert len(get_contents(collapsible).children) == 0
async def test_compose_nested_collapsible():
"""Children Collapsibles are independent from parents Collapsibles."""
class CollapsibleApp(App[None]):
def compose(self) -> ComposeResult:
with Collapsible(Label("Outer"), id="outer", collapsed=False):
yield Collapsible(Label("Inner"), id="inner", collapsed=False)
async with CollapsibleApp().run_test() as pilot:
outer: Collapsible = pilot.app.get_child_by_id("outer")
inner: Collapsible = get_contents(outer).get_child_by_id("inner")
outer.collapsed = True
assert not inner.collapsed
async def test_compose_expanded_collapsible():
"""It should be possible to create a Collapsible with expanded contents."""
class CollapsibleApp(App[None]):
def compose(self) -> ComposeResult:
yield Collapsible(collapsed=False)
async with CollapsibleApp().run_test() as pilot:
collapsible = pilot.app.query_one(Collapsible)
assert not get_title(collapsible).has_class(COLLAPSED_CLASS)
assert not get_contents(collapsible).has_class(COLLAPSED_CLASS)
async def test_collapsible_collapsed_title_label():
"""Collapsed title label should be displayed."""
class CollapsibleApp(App[None]):
def compose(self) -> ComposeResult:
yield Collapsible(Label("Some Contents"), collapsed=True)
async with CollapsibleApp().run_test() as pilot:
title = get_title(pilot.app.query_one(Collapsible))
assert not title.get_child_by_id("expanded-symbol").display
assert title.get_child_by_id("collapsed-symbol").display
async def test_collapsible_expanded_title_label():
"""Expanded title label should be displayed."""
class CollapsibleApp(App[None]):
def compose(self) -> ComposeResult:
yield Collapsible(Label("Some Contents"), collapsed=False)
async with CollapsibleApp().run_test() as pilot:
title = get_title(pilot.app.query_one(Collapsible))
assert title.get_child_by_id("expanded-symbol").display
assert not title.get_child_by_id("collapsed-symbol").display
async def test_collapsible_collapsed_contents_display_false():
"""Test default settings of Collapsible with 1 widget in contents."""
class CollapsibleApp(App[None]):
def compose(self) -> ComposeResult:
yield Collapsible(Label("Some Contents"), collapsed=True)
async with CollapsibleApp().run_test() as pilot:
collapsible = pilot.app.query_one(Collapsible)
assert not get_contents(collapsible).display
async def test_collapsible_expanded_contents_display_true():
"""Test default settings of Collapsible with 1 widget in contents."""
class CollapsibleApp(App[None]):
def compose(self) -> ComposeResult:
yield Collapsible(Label("Some Contents"), collapsed=False)
async with CollapsibleApp().run_test() as pilot:
collapsible = pilot.app.query_one(Collapsible)
assert get_contents(collapsible).display
async def test_reactive_collapsed():
"""Updating ``collapsed`` should change classes of children."""
class CollapsibleApp(App[None]):
def compose(self) -> ComposeResult:
yield Collapsible(collapsed=False)
async with CollapsibleApp().run_test() as pilot:
collapsible = pilot.app.query_one(Collapsible)
assert not get_title(collapsible).has_class(COLLAPSED_CLASS)
collapsible.collapsed = True
assert get_contents(collapsible).has_class(COLLAPSED_CLASS)
collapsible.collapsed = False
assert not get_title(collapsible).has_class(COLLAPSED_CLASS)
async def test_toggle_title():
"""Clicking title should update ``collapsed``."""
class CollapsibleApp(App[None]):
def compose(self) -> ComposeResult:
yield Collapsible(collapsed=False)
async with CollapsibleApp().run_test() as pilot:
collapsible = pilot.app.query_one(Collapsible)
assert not collapsible.collapsed
assert not get_title(collapsible).has_class(COLLAPSED_CLASS)
await pilot.click(Collapsible.Title)
assert collapsible.collapsed
assert get_contents(collapsible).has_class(COLLAPSED_CLASS)
await pilot.click(Collapsible.Title)
assert not collapsible.collapsed
assert not get_title(collapsible).has_class(COLLAPSED_CLASS)